master
Kowalski 2026-10-12 18:49:44 +02:00
parent 5185e5efb0
commit 2c0d5039a1
9 changed files with 551 additions and 3 deletions

View File

@ -10,6 +10,6 @@ final class Konstanty
* Verze aplikace. * Verze aplikace.
* @var string * @var string
*/ */
public const verze = '1.8'; public const verze = '1.9';
} }

165
app/Model/LogFacade.php Normal file
View File

@ -0,0 +1,165 @@
<?php
declare(strict_types=1);
namespace App\Model;
use Nette\Database\Explorer;
use Nette\Security\User;
/**
* Textový záznam akcí přihlášeného uživatele.
* Uživatel je jen jméno z LOGIN, bez vazby na účet.
*/
final class LogFacade
{
public function __construct(
private Explorer $database,
private User $user,
) {
}
public function hranice(int $cislo): void
{
$this->zapis('Upraveny hranice obvodu č. ' . $cislo . '.');
}
public function prideleno(int $cislo, string $zvestovatel, \DateTimeInterface $den): void
{
$this->zapis(sprintf(
'Obvod č. %d přidělen zvěstovateli %s dne %s.',
$cislo,
$zvestovatel,
$den->format('j.n.Y'),
));
}
public function vraceno(int $cislo, string $zvestovatel, \DateTimeInterface $den): void
{
$this->zapis(sprintf(
'Obvod č. %d vrácen zvěstovatelem %s dne %s.',
$cislo,
$zvestovatel,
$den->format('j.n.Y'),
));
}
/**
* @param \DateTimeInterface|null $vraceno null, když záznam ještě nebyl vrácen
*/
public function upraven(int $cislo, string $zvestovatel, \DateTimeInterface $pridelen, ?\DateTimeInterface $vraceno): void
{
$this->zapis($this->textZaznamu('upraven', $cislo, $zvestovatel, $pridelen, $vraceno));
}
/**
* @param \DateTimeInterface|null $vraceno null, když záznam ještě nebyl vrácen
*/
public function smazan(int $cislo, string $zvestovatel, \DateTimeInterface $pridelen, ?\DateTimeInterface $vraceno): void
{
$this->zapis($this->textZaznamu('smazán', $cislo, $zvestovatel, $pridelen, $vraceno));
}
private function textZaznamu(
string $akce,
int $cislo,
string $zvestovatel,
\DateTimeInterface $pridelen,
?\DateTimeInterface $vraceno,
): string {
$vraceni = $vraceno === null
? 'nevráceno'
: 'vráceno ' . $vraceno->format('j.n.Y');
return sprintf(
'Záznam u obvodu č. %d %s. Zvěstovatel %s, přiděleno %s, %s.',
$cislo,
$akce,
$zvestovatel,
$pridelen->format('j.n.Y'),
$vraceni,
);
}
public function prejmenovan(string $puvodni, string $nove): void
{
$this->zapis('Zvěstovatel ' . $puvodni . ' přejmenován na ' . $nove . '.');
}
/**
* Záznamy jednoho měsíce, nejnovější první.
* Načte o jeden řádek víc, aby šlo poznat, jestli existuje další dávka.
*
* @return array{radky: list<\Nette\Database\Row>, dalsi: bool}
*/
public function getMesic(int $rok, int $mesic, int $od, int $limit): array
{
$od = max(0, $od);
$limit = max(1, $limit);
$zacatek = new \DateTimeImmutable(sprintf('%04d-%02d-01 00:00:00', $rok, $mesic));
$radky = $this->database->fetchAll(
'SELECT ID, UZIVATEL, DATUM, [TEXT]
FROM [LOG]
WHERE DATUM >= ? AND DATUM < ?
ORDER BY DATUM DESC, ID DESC
OFFSET ' . $od . ' ROWS FETCH NEXT ' . ($limit + 1) . ' ROWS ONLY',
$zacatek,
$zacatek->modify('+1 month'),
);
$dalsi = count($radky) > $limit;
if ($dalsi) {
array_pop($radky);
}
return ['radky' => $radky, 'dalsi' => $dalsi];
}
/**
* Roky, ve kterých je aspoň jeden záznam, plus aktuální a vybraný rok.
*
* @return list<int>
*/
public function getRoky(int $vybrany): array
{
$min = $this->database->fetchField('SELECT MIN(DATUM) FROM [LOG]');
$max = $this->database->fetchField('SELECT MAX(DATUM) FROM [LOG]');
$letos = (int) (new \DateTimeImmutable('today'))->format('Y');
$od = $min instanceof \DateTimeInterface ? (int) $min->format('Y') : $letos;
$do = $max instanceof \DateTimeInterface ? (int) $max->format('Y') : $letos;
$od = min($od, $letos, $vybrany);
$do = max($do, $letos, $vybrany);
$roky = [];
for ($rok = $do; $rok >= $od; $rok--) {
$roky[] = $rok;
}
return $roky;
}
private function zapis(string $text): void
{
$this->database->query(
'INSERT INTO [LOG] (UZIVATEL, DATUM, [TEXT]) VALUES (?, ?, ?)',
$this->jmenoUzivatele(),
new \DateTimeImmutable(),
$text,
);
}
private function jmenoUzivatele(): string
{
$identita = $this->user->getIdentity();
if ($identita instanceof UserIdentity && $identita->username !== '') {
return $identita->username;
}
$id = $this->user->getId();
$jmeno = $id === null
? null
: $this->database->fetchField('SELECT JMENO FROM [LOGIN] WHERE ID = ?', $id);
return is_string($jmeno) && $jmeno !== '' ? $jmeno : 'neznámý';
}
}

View File

@ -73,6 +73,7 @@
<a class='nav-link' n:href=':Zvestovatel:'>Zvěstovatelé</a> <a class='nav-link' n:href=':Zvestovatel:'>Zvěstovatelé</a>
<a class='nav-link' n:href=':Nastaveni:'>Nastavení</a> <a class='nav-link' n:href=':Nastaveni:'>Nastavení</a>
<a n:if="$jeAdmin ?? false" class='nav-link' n:href=':Administrace:'>Administrace</a> <a n:if="$jeAdmin ?? false" class='nav-link' n:href=':Administrace:'>Administrace</a>
<a n:if="$jeAdmin ?? false" class='nav-link' n:href=':Logy:'>Logy</a>
<a class='nav-link' n:href=":Sign:out">Odhlásit</a> <a class='nav-link' n:href=":Sign:out">Odhlásit</a>
{else} {else}
<a class='nav-link' n:href=':Home:'>Home</a> <a class='nav-link' n:href=':Home:'>Home</a>

View File

@ -0,0 +1,199 @@
<?php
declare(strict_types=1);
namespace App\Presentation\Logy;
use App\Model\LogFacade;
use App\Model\LoginFacade;
use App\Presentation\BasePresenter;
use Nette;
use Nette\Database\Row;
final class LogyPresenter extends BasePresenter
{
private const DAVKA = 100;
/** @var array<int, string> */
private const MESICE = [
1 => 'leden',
2 => 'únor',
3 => 'březen',
4 => 'duben',
5 => 'květen',
6 => 'červen',
7 => 'červenec',
8 => 'srpen',
9 => 'září',
10 => 'říjen',
11 => 'listopad',
12 => 'prosinec',
];
private int $rok;
private int $mesic;
public function __construct(
private LoginFacade $uzivatele,
private LogFacade $log,
) {
}
protected function startup(): void
{
parent::startup();
if (!$this->uzivatele->jeAdmin((int) $this->getUser()->getId())) {
$this->error('K logům nemáte přístup.', Nette\Http\IResponse::S403_Forbidden);
}
}
public function actionDefault(?int $rok = null, ?int $mesic = null): void
{
$dnes = new \DateTimeImmutable('today');
$rok = $rok ?? (int) $dnes->format('Y');
$mesic = $mesic ?? (int) $dnes->format('n');
if (!$this->platneObdobi($rok, $mesic)) {
$this->redirect('this', [
'rok' => (int) $dnes->format('Y'),
'mesic' => (int) $dnes->format('n'),
]);
}
$this->rok = $rok;
$this->mesic = $mesic;
}
public function renderDefault(): void
{
$stranka = $this->log->getMesic($this->rok, $this->mesic, 0, self::DAVKA);
$logy = $this->priprav($stranka['radky']);
$this->template->rok = $this->rok;
$this->template->mesic = $this->mesic;
$this->template->nazevMesice = self::MESICE[$this->mesic];
$this->template->mesice = $this->mesiceProSablonu();
$this->template->roky = $this->rokyProSablonu();
$this->template->logy = $logy;
$this->template->pocet = count($logy);
$this->template->pocetText = $this->textPoctu(count($logy));
$this->template->dalsi = $stranka['dalsi'];
$this->template->dalsiUrl = $stranka['dalsi']
? $this->link('dalsi!', ['rok' => $this->rok, 'mesic' => $this->mesic, 'od' => count($logy)])
: null;
}
public function handleDalsi(int $rok, int $mesic, int $od = 0): void
{
if (!$this->platneObdobi($rok, $mesic) || $od < 0) {
$this->error('Neplatné období.', Nette\Http\IResponse::S400_BadRequest);
}
if (!$this->isAjax()) {
$this->redirect('default', ['rok' => $rok, 'mesic' => $mesic]);
}
$stranka = $this->log->getMesic($rok, $mesic, $od, self::DAVKA);
$logy = $this->priprav($stranka['radky']);
$this->sendJson([
'html' => $this->radkyHtml($logy),
'dalsi' => $stranka['dalsi'],
'pocet' => count($logy),
'url' => $stranka['dalsi']
? $this->link('dalsi!', ['rok' => $rok, 'mesic' => $mesic, 'od' => $od + count($logy)])
: null,
]);
}
private function textPoctu(int $pocet): string
{
$jednotky = $pocet % 10;
$desitky = $pocet % 100;
if ($jednotky === 1 && $desitky !== 11) {
return 'Zobrazen ' . $pocet . ' záznam.';
}
if ($jednotky >= 2 && $jednotky <= 4 && ($desitky < 12 || $desitky > 14)) {
return 'Zobrazeny ' . $pocet . ' záznamy.';
}
return 'Zobrazeno ' . $pocet . ' záznamů.';
}
private function platneObdobi(int $rok, int $mesic): bool
{
return $mesic >= 1 && $mesic <= 12 && $rok >= 2000 && $rok <= 2100;
}
/**
* @param list<Row> $radky
* @return list<object{datum: string, uzivatel: string, text: string}>
*/
private function priprav(array $radky): array
{
$logy = [];
foreach ($radky as $radek) {
$datum = $radek->DATUM instanceof \DateTimeInterface
? \DateTimeImmutable::createFromInterface($radek->DATUM)
: new \DateTimeImmutable((string) $radek->DATUM);
$logy[] = (object) [
'datum' => $datum->format('j. n. Y H:i'),
'uzivatel' => (string) $radek->UZIVATEL,
'text' => (string) $radek->TEXT,
];
}
return $logy;
}
/**
* @return list<object{nazev: string, url: string, aktivni: bool}>
*/
private function mesiceProSablonu(): array
{
$mesice = [];
foreach (self::MESICE as $cislo => $nazev) {
$mesice[] = (object) [
'nazev' => mb_convert_case($nazev, MB_CASE_TITLE, 'UTF-8'),
'url' => $this->link('default', ['rok' => $this->rok, 'mesic' => $cislo]),
'aktivni' => $cislo === $this->mesic,
];
}
return $mesice;
}
/**
* @return list<object{rok: int, url: string, aktivni: bool}>
*/
private function rokyProSablonu(): array
{
$roky = [];
foreach ($this->log->getRoky($this->rok) as $rok) {
$roky[] = (object) [
'rok' => $rok,
'url' => $this->link('default', ['rok' => $rok, 'mesic' => $this->mesic]),
'aktivni' => $rok === $this->rok,
];
}
return $roky;
}
/**
* @param list<object{datum: string, uzivatel: string, text: string}> $logy
*/
private function radkyHtml(array $logy): string
{
$sablona = $this->createTemplate();
$sablona->setFile(__DIR__ . '/radky.latte');
$sablona->logy = $logy;
ob_start();
try {
$sablona->render();
} catch (\Throwable $e) {
ob_end_clean();
throw $e;
}
return (string) ob_get_clean();
}
}

View File

@ -0,0 +1,105 @@
{block title}Logy{/block}
{block content}
<div class="platno">
<header class="lista">
<div>
<h1 class="lista-nadpis">Logy</h1>
<p class="lista-popis">Záznamy za {$nazevMesice} {$rok}.</p>
</div>
</header>
<section class="panel">
<div class="filtry logy-filtry">
<label class="razeni">
<span class="razeni-popisek">Měsíc</span>
<select class="razeni-vyber" aria-label="Měsíc" onchange="location.href = this.value">
<option n:foreach="$mesice as $polozka" value="{$polozka->url}" n:attr="selected: $polozka->aktivni">{$polozka->nazev}</option>
</select>
</label>
<label class="razeni">
<span class="razeni-popisek">Rok</span>
<select class="razeni-vyber" aria-label="Rok" onchange="location.href = this.value">
<option n:foreach="$roky as $polozka" value="{$polozka->url}" n:attr="selected: $polozka->aktivni">{$polozka->rok}</option>
</select>
</label>
</div>
<table class="tabulka" n:if="$pocet > 0">
<thead>
<tr>
<th scope="col">Datum</th>
<th scope="col">Uživatel</th>
<th scope="col">Text</th>
</tr>
</thead>
<tbody id="logy-radky">
{include radky.latte, logy: $logy}
</tbody>
</table>
<p class="prazdno" n:if="$pocet === 0">V tomto měsíci není žádný záznam.</p>
<p class="lista-popis logy-pocet" id="logy-pocet" n:if="$pocet > 0" data-pocet="{$pocet}">{$pocetText}</p>
<div class="logy-dalsi" n:if="$dalsi">
<button type="button" class="tlacitko" id="logy-dalsi" data-url="{$dalsiUrl}">Načíst dalších 100 logů</button>
</div>
</section>
</div>
<script n:if="$dalsi">
(function () {
var tlacitko = document.getElementById('logy-dalsi');
var telo = document.getElementById('logy-radky');
var pocet = document.getElementById('logy-pocet');
if (!tlacitko || !telo) {
return;
}
function textPoctu(n) {
var jednotky = n % 10;
var desitky = n % 100;
if (jednotky === 1 && desitky !== 11) {
return 'Zobrazen ' + n + ' záznam.';
}
if (jednotky >= 2 && jednotky <= 4 && (desitky < 12 || desitky > 14)) {
return 'Zobrazeny ' + n + ' záznamy.';
}
return 'Zobrazeno ' + n + ' záznamů.';
}
tlacitko.addEventListener('click', function () {
var url = tlacitko.getAttribute('data-url');
var puvodni = tlacitko.textContent;
tlacitko.disabled = true;
tlacitko.textContent = 'Načítám…';
fetch(url, {
headers: { 'X-Requested-With': 'XMLHttpRequest' },
credentials: 'same-origin'
}).then(function (response) {
if (!response.ok) {
throw new Error();
}
return response.json();
}).then(function (data) {
telo.insertAdjacentHTML('beforeend', data.html || '');
if (pocet) {
var n = parseInt(pocet.getAttribute('data-pocet') || '0', 10) + (data.pocet || 0);
pocet.setAttribute('data-pocet', String(n));
pocet.textContent = textPoctu(n);
}
if (data.dalsi && data.url) {
tlacitko.setAttribute('data-url', data.url);
tlacitko.disabled = false;
tlacitko.textContent = puvodni;
} else {
tlacitko.parentElement.remove();
}
}).catch(function () {
tlacitko.disabled = false;
tlacitko.textContent = puvodni;
});
});
})();
</script>
{/block}

View File

@ -0,0 +1,5 @@
<tr n:foreach="$logy as $log">
<td class="log-datum" data-popisek="Datum">{$log->datum}</td>
<td class="log-uzivatel" data-popisek="Uživatel">{$log->uzivatel}</td>
<td class="log-text" data-popisek="Text">{$log->text}</td>
</tr>

View File

@ -4,6 +4,7 @@ declare(strict_types=1);
namespace App\Presentation\Obvod; namespace App\Presentation\Obvod;
use App\Model\LogFacade;
use App\Model\LoginFacade; use App\Model\LoginFacade;
use App\Model\ObvodFacade; use App\Model\ObvodFacade;
use App\Model\SborFacade; use App\Model\SborFacade;
@ -39,6 +40,7 @@ final class ObvodPresenter extends BasePresenter
private ZaznamFacade $zaznam, private ZaznamFacade $zaznam,
private LoginFacade $uzivatele, private LoginFacade $uzivatele,
private SborFacade $sbor, private SborFacade $sbor,
private LogFacade $log,
) { ) {
} }
@ -144,6 +146,7 @@ final class ObvodPresenter extends BasePresenter
return; return;
} }
$this->log->hranice($this->cisloObvodu($id));
$this->flashMessage('Hranice obvodu byly uloženy.'); $this->flashMessage('Hranice obvodu byly uloženy.');
$this->redirect('view', ['id' => $id]); $this->redirect('view', ['id' => $id]);
} }
@ -186,7 +189,9 @@ final class ObvodPresenter extends BasePresenter
return; return;
} }
$this->zaznam->pridej($id, $jmeno, $this->den($values->datum)); $datum = $this->den($values->datum);
$this->zaznam->pridej($id, $jmeno, $datum);
$this->log->prideleno($this->cisloObvodu($id), $jmeno, $datum);
$this->flashMessage('Obvod byl přidělen.'); $this->flashMessage('Obvod byl přidělen.');
$this->redirect('this'); $this->redirect('this');
} }
@ -225,6 +230,7 @@ final class ObvodPresenter extends BasePresenter
} }
$this->zaznam->vrat((int) $zaznam->ID, $obvodId, $vraceno); $this->zaznam->vrat((int) $zaznam->ID, $obvodId, $vraceno);
$this->log->vraceno($this->cisloObvodu($obvodId), (string) $zaznam->JMENO, $vraceno);
$this->flashMessage('Obvod byl vrácen.'); $this->flashMessage('Obvod byl vrácen.');
$this->redirect('this'); $this->redirect('this');
} }
@ -278,6 +284,7 @@ final class ObvodPresenter extends BasePresenter
return; return;
} }
$this->log->upraven($this->cisloObvodu($obvodId), $jmeno, $vydano, $vraceno);
$this->flashMessage('Záznam byl upraven.'); $this->flashMessage('Záznam byl upraven.');
$this->redirect('this'); $this->redirect('this');
} }
@ -289,7 +296,16 @@ final class ObvodPresenter extends BasePresenter
$form->addSubmit('send', 'Smazat'); $form->addSubmit('send', 'Smazat');
$form->addProtection('Platnost formuláře vypršela, odešlete ho znovu.'); $form->addProtection('Platnost formuláře vypršela, odešlete ho znovu.');
$form->onSuccess[] = function () use ($id): void { $form->onSuccess[] = function () use ($id): void {
if ($this->zaznam->smaz((int) $id, $this->obvodId())) { $obvodId = $this->obvodId();
$zaznam = $this->zaznam->getZaznam((int) $id);
if ($zaznam !== null && $this->zaznam->smaz((int) $id, $obvodId)) {
$vraceno = $zaznam->VRACENO === null ? null : $this->den($zaznam->VRACENO);
$this->log->smazan(
$this->cisloObvodu($obvodId),
(string) $zaznam->JMENO,
$this->den($zaznam->VYDANO),
$vraceno,
);
$this->flashMessage('Záznam byl smazán.'); $this->flashMessage('Záznam byl smazán.');
} else { } else {
$this->flashMessage('Tento záznam už nelze smazat.'); $this->flashMessage('Tento záznam už nelze smazat.');
@ -420,6 +436,16 @@ final class ObvodPresenter extends BasePresenter
]; ];
} }
private function cisloObvodu(int $id): int
{
$obvod = $this->obvod->getObvod($id);
if ($obvod === null) {
$this->error('Obvod nenalezen.', Nette\Http\IResponse::S404_NotFound);
}
return (int) $obvod->CISLO;
}
private function obvodId(): int private function obvodId(): int
{ {
$id = $this->getParameter('id'); $id = $this->getParameter('id');

View File

@ -4,6 +4,7 @@ declare(strict_types=1);
namespace App\Presentation\Zvestovatel; namespace App\Presentation\Zvestovatel;
use App\Model\LogFacade;
use App\Model\ZvestovatelFacade; use App\Model\ZvestovatelFacade;
use App\Presentation\BasePresenter; use App\Presentation\BasePresenter;
use Nette; use Nette;
@ -15,6 +16,7 @@ final class ZvestovatelPresenter extends BasePresenter
{ {
public function __construct( public function __construct(
private ZvestovatelFacade $zvestovatele, private ZvestovatelFacade $zvestovatele,
private LogFacade $log,
) { ) {
} }
@ -75,6 +77,9 @@ final class ZvestovatelPresenter extends BasePresenter
} }
$this->zvestovatele->uprav($puvodni, $jmeno); $this->zvestovatele->uprav($puvodni, $jmeno);
if ($jmeno !== $puvodni) {
$this->log->prejmenovan($puvodni, $jmeno);
}
$this->flashMessage('Zvěstovatel byl upraven.'); $this->flashMessage('Zvěstovatel byl upraven.');
$this->redirect('default'); $this->redirect('default');
} }

View File

@ -886,6 +886,38 @@ body:has(#map[data-max]) {
color: #3d5a80; color: #3d5a80;
} }
.logy-filtry {
justify-content: flex-start;
}
.logy-filtry .razeni {
margin-left: 0;
min-width: min(100%, 16rem);
}
.log-datum {
white-space: nowrap;
}
.log-text {
overflow-wrap: anywhere;
}
.logy-pocet {
margin-top: 0.85rem;
}
.logy-dalsi {
display: flex;
justify-content: center;
margin-top: 0.85rem;
}
.logy-dalsi .tlacitko:disabled {
opacity: 0.7;
cursor: wait;
}
.detail .lista, .detail .lista,
.detail-telo { .detail-telo {
max-width: 76rem; max-width: 76rem;
@ -1241,6 +1273,16 @@ body:has(.hranice-lista) #map {
margin-bottom: 0.35rem; margin-bottom: 0.35rem;
} }
.tabulka td.log-text {
display: block;
text-align: left;
}
.tabulka td.log-text::before {
display: block;
margin-bottom: 0.25rem;
}
.bunka-akce .akce-radek, .bunka-akce .akce-radek,
.bunka-akce .akce-radek form { .bunka-akce .akce-radek form {
display: flex; display: flex;