diff --git a/app/Core/Konstanty.php b/app/Core/Konstanty.php index 1f84d1f..bc647d7 100644 --- a/app/Core/Konstanty.php +++ b/app/Core/Konstanty.php @@ -10,6 +10,6 @@ final class Konstanty * Verze aplikace. * @var string */ - public const verze = '1.8'; + public const verze = '1.9'; } \ No newline at end of file diff --git a/app/Model/LogFacade.php b/app/Model/LogFacade.php new file mode 100644 index 0000000..9c43e6b --- /dev/null +++ b/app/Model/LogFacade.php @@ -0,0 +1,165 @@ +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 + */ + 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ý'; + } +} diff --git a/app/Presentation/@layout.latte b/app/Presentation/@layout.latte index 15feec7..fb18e53 100644 --- a/app/Presentation/@layout.latte +++ b/app/Presentation/@layout.latte @@ -73,6 +73,7 @@ Zvěstovatelé Nastavení Administrace + Logy Odhlásit {else} Home diff --git a/app/Presentation/Logy/LogyPresenter.php b/app/Presentation/Logy/LogyPresenter.php new file mode 100644 index 0000000..255b206 --- /dev/null +++ b/app/Presentation/Logy/LogyPresenter.php @@ -0,0 +1,199 @@ + */ + 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 $radky + * @return list + */ + 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 + */ + 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 + */ + 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 $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(); + } +} diff --git a/app/Presentation/Logy/default.latte b/app/Presentation/Logy/default.latte new file mode 100644 index 0000000..72b5437 --- /dev/null +++ b/app/Presentation/Logy/default.latte @@ -0,0 +1,105 @@ +{block title}Logy{/block} + +{block content} + +
+
+
+

Logy

+

Záznamy za {$nazevMesice} {$rok}.

+
+
+ +
+
+ + +
+ + + + + + + + + + + {include radky.latte, logy: $logy} + +
DatumUživatelText
+

V tomto měsíci není žádný záznam.

+

{$pocetText}

+ +
+ +
+
+
+ + +{/block} diff --git a/app/Presentation/Logy/radky.latte b/app/Presentation/Logy/radky.latte new file mode 100644 index 0000000..02f0d26 --- /dev/null +++ b/app/Presentation/Logy/radky.latte @@ -0,0 +1,5 @@ + + {$log->datum} + {$log->uzivatel} + {$log->text} + diff --git a/app/Presentation/Obvod/ObvodPresenter.php b/app/Presentation/Obvod/ObvodPresenter.php index 8c4dbc0..0e02d4b 100644 --- a/app/Presentation/Obvod/ObvodPresenter.php +++ b/app/Presentation/Obvod/ObvodPresenter.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace App\Presentation\Obvod; +use App\Model\LogFacade; use App\Model\LoginFacade; use App\Model\ObvodFacade; use App\Model\SborFacade; @@ -39,6 +40,7 @@ final class ObvodPresenter extends BasePresenter private ZaznamFacade $zaznam, private LoginFacade $uzivatele, private SborFacade $sbor, + private LogFacade $log, ) { } @@ -144,6 +146,7 @@ final class ObvodPresenter extends BasePresenter return; } + $this->log->hranice($this->cisloObvodu($id)); $this->flashMessage('Hranice obvodu byly uloženy.'); $this->redirect('view', ['id' => $id]); } @@ -186,7 +189,9 @@ final class ObvodPresenter extends BasePresenter 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->redirect('this'); } @@ -225,6 +230,7 @@ final class ObvodPresenter extends BasePresenter } $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->redirect('this'); } @@ -278,6 +284,7 @@ final class ObvodPresenter extends BasePresenter return; } + $this->log->upraven($this->cisloObvodu($obvodId), $jmeno, $vydano, $vraceno); $this->flashMessage('Záznam byl upraven.'); $this->redirect('this'); } @@ -289,7 +296,16 @@ final class ObvodPresenter extends BasePresenter $form->addSubmit('send', 'Smazat'); $form->addProtection('Platnost formuláře vypršela, odešlete ho znovu.'); $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.'); } else { $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 { $id = $this->getParameter('id'); diff --git a/app/Presentation/Zvestovatel/ZvestovatelPresenter.php b/app/Presentation/Zvestovatel/ZvestovatelPresenter.php index 10d43f4..bcec85a 100644 --- a/app/Presentation/Zvestovatel/ZvestovatelPresenter.php +++ b/app/Presentation/Zvestovatel/ZvestovatelPresenter.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace App\Presentation\Zvestovatel; +use App\Model\LogFacade; use App\Model\ZvestovatelFacade; use App\Presentation\BasePresenter; use Nette; @@ -15,6 +16,7 @@ final class ZvestovatelPresenter extends BasePresenter { public function __construct( private ZvestovatelFacade $zvestovatele, + private LogFacade $log, ) { } @@ -75,6 +77,9 @@ final class ZvestovatelPresenter extends BasePresenter } $this->zvestovatele->uprav($puvodni, $jmeno); + if ($jmeno !== $puvodni) { + $this->log->prejmenovan($puvodni, $jmeno); + } $this->flashMessage('Zvěstovatel byl upraven.'); $this->redirect('default'); } diff --git a/www/css/base.css b/www/css/base.css index 403ccf5..629cc74 100644 --- a/www/css/base.css +++ b/www/css/base.css @@ -886,6 +886,38 @@ body:has(#map[data-max]) { 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-telo { max-width: 76rem; @@ -1241,6 +1273,16 @@ body:has(.hranice-lista) #map { 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 form { display: flex;