GUI + Tisk faktur

master
Kowalski 2026-10-31 09:41:08 +01:00
parent 497708b5bb
commit 6a39a66e73
7 changed files with 335 additions and 111 deletions

View File

@ -4,13 +4,6 @@ declare(strict_types=1);
namespace App\Model\Faktura;
use Mpdf\Config\ConfigVariables;
use Mpdf\Config\FontVariables;
use Mpdf\Mpdf;
use Mpdf\Output\Destination;
use Nette\Bridges\ApplicationLatte\LatteFactory;
use Nette\Utils\FileSystem;
/**
* Sestava faktury podle rptFaktura3 (PDF, tisk).
*/
@ -22,8 +15,7 @@ final class FakturaPdf
];
public function __construct(
private LatteFactory $latteFactory,
private string $tempDir,
private PdfSestava $pdfSestava,
) {
}
@ -33,18 +25,13 @@ final class FakturaPdf
public function render(object $faktura, array $polozky, bool $autoPrint = false): string
{
$view = $this->view($faktura, $polozky);
$html = $this->latteFactory->create()->renderToString($this->templateFile(), $view);
$mpdf = $this->createMpdf();
$mpdf->SetHTMLFooter($this->footer((string) $view['vytisteno']));
$mpdf->SetTitle('Faktura ' . (string) ($faktura->CISLO ?? ''));
$mpdf->SetAuthor('Kufřík');
if ($autoPrint) {
$mpdf->SetJS('this.print();');
}
$mpdf->WriteHTML($html);
return $mpdf->Output('', Destination::STRING_RETURN);
return $this->pdfSestava->render(
$this->templateFile(),
$view,
'Faktura ' . (string) ($faktura->CISLO ?? ''),
$autoPrint,
);
}
public static function fileName(string $cislo): string
@ -105,56 +92,9 @@ final class FakturaPdf
'polozky' => $radky,
'celkem' => 'Celkem: ' . self::formatMoney($celkem),
'cinnosti' => $cinnosti === '' ? [] : (preg_split("/\R/u", $cinnosti) ?: []),
'vytisteno' => (new \DateTimeImmutable('today'))->format('d.m.Y'),
];
}
private function createMpdf(): Mpdf
{
$tempDir = $this->tempDir . DIRECTORY_SEPARATOR . 'mpdf';
FileSystem::createDir($tempDir);
$fontDir = dirname(__DIR__, 3) . DIRECTORY_SEPARATOR . 'resources' . DIRECTORY_SEPARATOR . 'fonts';
$config = (new ConfigVariables())->getDefaults();
$fontData = (new FontVariables())->getDefaults();
return new Mpdf([
'mode' => 'utf-8',
'format' => 'A4',
'margin_left' => 18,
'margin_right' => 16,
'margin_top' => 16,
'margin_bottom' => 18,
'margin_footer' => 8,
'default_font' => 'sourcesans3',
'default_font_size' => 9,
'tempDir' => $tempDir,
'shrink_tables_to_fit' => 0,
'fontDir' => array_merge($config['fontDir'], [$fontDir]),
'fontdata' => $fontData['fontdata'] + [
'sourcesans3' => [
'R' => 'SourceSans3-Regular.ttf',
'B' => 'SourceSans3-Bold.ttf',
'I' => 'SourceSans3-It.ttf',
'BI' => 'SourceSans3-BoldIt.ttf',
],
],
]);
}
private function footer(string $vytisteno): string
{
$datum = htmlspecialchars($vytisteno, ENT_QUOTES, 'UTF-8');
return '<table width="100%" style="font-size: 8pt; color: #444; border-top: 0.2mm solid #ccc;">
<tr>
<td width="33%">Vytvořeno aplikací Kufřík</td>
<td width="34%" align="center">Vytištěno dne: ' . $datum . '.</td>
<td width="33%" align="right">Strana {PAGENO}/{nbpg}</td>
</tr>
</table>';
}
private function templateFile(): string
{
return __DIR__ . DIRECTORY_SEPARATOR . 'faktura.latte';

View File

@ -0,0 +1,95 @@
<?php
declare(strict_types=1);
namespace App\Model\Faktura;
use Mpdf\Config\ConfigVariables;
use Mpdf\Config\FontVariables;
use Mpdf\Mpdf;
use Mpdf\Output\Destination;
use Nette\Bridges\ApplicationLatte\LatteFactory;
use Nette\Utils\FileSystem;
/**
* Společné PDF pro sestavy: písmo, stránka a zápatí na každé straně.
*/
final class PdfSestava
{
public function __construct(
private LatteFactory $latteFactory,
private string $tempDir,
) {
}
/**
* @param array<string, mixed> $params
*/
public function render(
string $templateFile,
array $params,
string $title,
bool $autoPrint = false,
string $orientation = 'P',
): string {
$html = $this->latteFactory->create()->renderToString($templateFile, $params);
$mpdf = $this->create($orientation === 'L');
$mpdf->SetHTMLFooter($this->footer());
$mpdf->SetTitle($title);
$mpdf->SetAuthor('Kufřík');
if ($autoPrint) {
$mpdf->SetJS('this.print();');
}
$mpdf->WriteHTML($html);
return $mpdf->Output('', Destination::STRING_RETURN);
}
private function create(bool $landscape): Mpdf
{
$tempDir = $this->tempDir . DIRECTORY_SEPARATOR . 'mpdf';
FileSystem::createDir($tempDir);
$fontDir = dirname(__DIR__, 3) . DIRECTORY_SEPARATOR . 'resources' . DIRECTORY_SEPARATOR . 'fonts';
$config = (new ConfigVariables())->getDefaults();
$fontData = (new FontVariables())->getDefaults();
return new Mpdf([
'mode' => 'utf-8',
'format' => 'A4',
'orientation' => $landscape ? 'L' : 'P',
'margin_left' => $landscape ? 14 : 18,
'margin_right' => $landscape ? 14 : 16,
'margin_top' => $landscape ? 14 : 16,
'margin_bottom' => $landscape ? 16 : 18,
'margin_footer' => 8,
'default_font' => 'sourcesans3',
'default_font_size' => 9,
'tempDir' => $tempDir,
'shrink_tables_to_fit' => 0,
'fontDir' => array_merge($config['fontDir'], [$fontDir]),
'fontdata' => $fontData['fontdata'] + [
'sourcesans3' => [
'R' => 'SourceSans3-Regular.ttf',
'B' => 'SourceSans3-Bold.ttf',
'I' => 'SourceSans3-It.ttf',
'BI' => 'SourceSans3-BoldIt.ttf',
],
],
]);
}
private function footer(): string
{
$datum = htmlspecialchars((new \DateTimeImmutable('today'))->format('d.m.Y'), ENT_QUOTES, 'UTF-8');
return '<table width="100%" style="font-size: 8pt; color: #444; border-top: 0.2mm solid #ccc;">
<tr>
<td width="33%">Vytvořeno aplikací Kufřík</td>
<td width="34%" align="center">Vytištěno dne: ' . $datum . '.</td>
<td width="33%" align="right">Strana {PAGENO}/{nbpg}</td>
</tr>
</table>';
}
}

View File

@ -0,0 +1,129 @@
<?php
declare(strict_types=1);
namespace App\Model\Faktura;
use App\Model\Facade\FakturaFacade;
/**
* Tisková sestava vyfiltrovaného seznamu faktur.
*/
final class SeznamFakturPdf
{
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',
];
public function __construct(
private PdfSestava $pdfSestava,
) {
}
/**
* @param array<int, object> $faktury
*/
public function render(array $faktury, int $rok, string $mesic, bool $autoPrint = false): string
{
$mesicCislo = (int) $mesic;
$obdobi = ($mesicCislo >= 1 && $mesicCislo <= 12)
? self::MESICE[$mesicCislo] . ' ' . $rok
: 'rok ' . $rok;
$radky = [];
$celkem = 0.0;
$zaplaceno = 0.0;
$dnes = new \DateTimeImmutable('today');
foreach ($faktury as $faktura) {
$castka = (float) ($faktura->CASTKA_CELKEM ?? 0);
$uhrazeno = (float) ($faktura->ZAPLACENO ?? 0);
$celkem += $castka;
$zaplaceno += $uhrazeno;
$stav = (string) ($faktura->STAV_UHRADY ?? FakturaFacade::stavZCastek($castka, $uhrazeno));
$splatnost = self::asDate($faktura->DATUM_SPLATNOSTI ?? null);
$poSplatnosti = $stav !== FakturaFacade::STAV_ZAPLACENO
&& $splatnost !== null
&& $splatnost->format('Y-m-d') < $dnes->format('Y-m-d');
$radky[] = [
'cislo' => (string) ($faktura->CISLO ?? ''),
'odberatel' => trim((string) ($faktura->O_PRAJM ?? '')) !== '' ? (string) $faktura->O_PRAJM : '—',
'vystaveno' => self::formatDate(self::asDate($faktura->DATUM_VYSTAVENI ?? null)),
'splatnost' => self::formatDate($splatnost),
'poSplatnosti' => $poSplatnosti,
'castka' => self::formatMoney($castka),
'zaplaceno' => self::formatMoney($uhrazeno),
'stav' => match ($stav) {
FakturaFacade::STAV_ZAPLACENO => 'Zaplaceno',
FakturaFacade::STAV_CASTECNE => 'Částečně',
default => 'Neuhrazeno',
},
'sudy' => count($radky) % 2 === 1,
];
}
return $this->pdfSestava->render(
__DIR__ . DIRECTORY_SEPARATOR . 'seznam.latte',
[
'obdobi' => $obdobi,
'radky' => $radky,
'pocet' => count($radky),
'celkem' => self::formatMoney($celkem),
'zaplaceno' => self::formatMoney($zaplaceno),
'zbyva' => self::formatMoney(max(0, round($celkem - $zaplaceno, 2))),
],
'Seznam faktur — ' . $obdobi,
$autoPrint,
'L',
);
}
public static function fileName(int $rok, string $mesic): string
{
$mesicCislo = (int) $mesic;
$suffix = ($mesicCislo >= 1 && $mesicCislo <= 12)
? $rok . '_' . sprintf('%02d', $mesicCislo)
: (string) $rok;
return 'Seznam_faktur_' . $suffix . '.pdf';
}
private static function asDate(mixed $value): ?\DateTimeImmutable
{
if ($value instanceof \DateTimeImmutable) {
return $value;
}
if ($value instanceof \DateTimeInterface) {
return \DateTimeImmutable::createFromInterface($value);
}
if (!is_string($value) || trim($value) === '') {
return null;
}
$timestamp = strtotime($value);
return $timestamp ? (new \DateTimeImmutable())->setTimestamp($timestamp) : null;
}
private static function formatDate(?\DateTimeInterface $value): string
{
return $value ? $value->format('d.m.Y') : '';
}
private static function formatMoney(float $value): string
{
return number_format($value, 2, ',', ' ') . ' Kč';
}
}

View File

@ -0,0 +1,68 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<style>
body { font-family: sourcesans3, sans-serif; font-size: 9pt; color: #1c1c1c; }
h1 { font-size: 16pt; line-height: 1.15; margin: 0; font-weight: bold; }
.obdobi { margin: 1mm 0 2mm 0; color: #333; font-size: 10pt; }
.rule { border-top: 2.2pt solid #222; margin: 0 0 5mm 0; }
table { width: 100%; border-collapse: collapse; }
.items th, .items td { padding: 1.6mm 1.5mm; border: 0.25mm solid #fff; vertical-align: middle; }
.items th { background: #87cefa; font-weight: bold; text-align: center; color: #10283d; }
.items td.a { background: #ffffff; }
.items td.b { background: #e7f3fb; }
.items td.num { text-align: right; white-space: nowrap; }
.items td.mid { text-align: center; }
.items td.late { color: #b42318; font-weight: bold; }
.items tr.empty td { background: #f4f4f4; text-align: center; padding: 4mm; }
.soucet { margin-top: 3mm; }
.soucet td { background: #d4d4d4; font-weight: bold; padding: 1.8mm 2mm; border: 0.3mm solid #fff; }
.right { text-align: right; }
</style>
</head>
<body>
<h1>Seznam vystavených faktur</h1>
<p class="obdobi">Období: {$obdobi}</p>
<div class="rule"></div>
<table class="items">
<thead>
<tr>
<th style="width: 14%;">Číslo</th>
<th style="width: 26%;">Odběratel</th>
<th style="width: 12%;">Vystaveno</th>
<th style="width: 12%;">Splatnost</th>
<th style="width: 12%;">Částka</th>
<th style="width: 12%;">Zaplaceno</th>
<th style="width: 12%;">Stav</th>
</tr>
</thead>
<tbody>
<tr n:if="!$radky" class="empty">
<td colspan="7">V tomto období nejsou žádné faktury.</td>
</tr>
<tr n:foreach="$radky as $radek">
<td class="{if $radek['sudy']}b{else}a{/if}">{$radek['cislo']}</td>
<td class="{if $radek['sudy']}b{else}a{/if}">{$radek['odberatel']}</td>
<td class="mid {if $radek['sudy']}b{else}a{/if}">{$radek['vystaveno']}</td>
<td class="mid {if $radek['poSplatnosti']}late{/if} {if $radek['sudy']}b{else}a{/if}">{$radek['splatnost']}</td>
<td class="num {if $radek['sudy']}b{else}a{/if}">{$radek['castka']}</td>
<td class="num {if $radek['sudy']}b{else}a{/if}">{$radek['zaplaceno']}</td>
<td class="mid {if $radek['sudy']}b{else}a{/if}">{$radek['stav']}</td>
</tr>
</tbody>
</table>
<table class="soucet">
<tr>
<td>Počet faktur: {$pocet}</td>
<td class="right">Celkem: {$celkem}</td>
<td class="right">Zaplaceno: {$zaplaceno}</td>
<td class="right">Zbývá uhradit: {$zbyva}</td>
</tr>
</table>
</body>
</html>

View File

@ -63,13 +63,6 @@
</a>
</li>
<!-- KUFŘÍK -->
<li class="nav-item">
<a n:href="Uzivatel:edit" n:class="nav-link, $presenter->isLinkCurrent('Uzivatel:*') ? active">
<i class="bi bi-box me-1"></i> Kufřík
</a>
</li>
<!-- ADMIN — pouze systémový administrátor -->
{if $user->isInRole('admin')}
<li class="nav-item">
@ -120,6 +113,10 @@
<!-- FAKTURY -->
<div n:class="tab-pane, fade, $presenter->isLinkCurrent('Faktura:*') || $presenter->isLinkCurrent('FakturaEdit:*') ? 'show active'" id="toolbar-invoices" role="tabpanel">
{var $aktualniRok = (int) date('Y')}
{var $vybranyRok = $selectedRok ?? $aktualniRok}
{var $vybranyMesic = (string) ($selectedMesic ?? '')}
{var $rokyFiltr = $roky ?? range($aktualniRok, $aktualniRok - 3)}
<div class="toolbar-group">
<div class="action-buttons">
<a n:href="Faktura:" type="button" class="btn btn-outline-primary btn-action">
@ -130,31 +127,15 @@
<i class="bi bi-plus-square-fill"></i>
<span>Přidat fakturu</span>
</a>
<button type="button" class="btn btn-outline-secondary btn-action" onclick="window.print()">
<a n:href="Faktura:seznam, rok => $vybranyRok, mesic => $vybranyMesic" target="_blank" class="btn btn-outline-secondary btn-action">
<i class="bi bi-printer-fill"></i>
<span>Tisk</span>
</button>
<div class="v-divider d-none d-sm-block"></div>
<button type="button" class="btn btn-outline-secondary btn-action">
<i class="bi bi-bar-chart-line-fill text-warning"></i>
<span>Grafy</span>
</button>
<button type="button" class="btn btn-outline-secondary btn-action">
<i class="bi bi-file-earmark-pdf-fill text-danger"></i>
<span>Tiskové sestavy</span>
</button>
</a>
</div>
<!-- Filtr pro Faktury v Toolbaru -->
<form n:attr="method => 'get', action => $presenter->link('Faktura:default', ['rok' => null, 'mesic' => null])" class="filter-panel ms-auto ms-lg-0">
<i class="bi bi-funnel-fill text-muted fs-5 ms-1"></i>
{var $aktualniRok = (int) date('Y')}
{var $vybranyRok = $selectedRok ?? $aktualniRok}
{var $vybranyMesic = (string) ($selectedMesic ?? '')}
{var $rokyFiltr = $roky ?? range($aktualniRok, $aktualniRok - 3)}
<!-- Výběr Roku -->
<select name="rok" class="form-select form-select-sm" style="width: 90px;" onchange="this.form.submit()">
@ -209,24 +190,6 @@
</div>
</div>
<!-- KUFŘÍK -->
<div n:class="tab-pane, fade, $presenter->isLinkCurrent('Uzivatel:*') ? 'show active'" id="toolbar-briefcase" role="tabpanel">
<div class="action-buttons">
<a n:href="Uzivatel:edit" n:class="btn, btn-action, $presenter->isLinkCurrent('Uzivatel:edit') ? 'btn-primary active' : 'btn-outline-primary'">
<i class="bi bi-person-gear"></i>
<span>Profil a připojení</span>
</a>
<button type="button" class="btn btn-outline-info btn-action">
<i class="bi bi-question-circle-fill"></i>
<span>Nápověda</span>
</button>
<a href="https://facebook.com" target="_blank" rel="noopener noreferrer" class="btn btn-outline-primary btn-action">
<i class="bi bi-facebook"></i>
<span>Stránky - FB</span>
</a>
</div>
</div>
<!-- ADMIN -->
{if $user->isInRole('admin')}
<div n:class="tab-pane, fade, $presenter->isLinkCurrent('Admin:*') ? 'show active'" id="toolbar-admin" role="tabpanel">

View File

@ -3,6 +3,7 @@
namespace App\UI\Faktura;
use App\Model\Faktura\FakturaPdf;
use App\Model\Faktura\SeznamFakturPdf;
use App\UI\BasePresenter;
use App\Model\Facade\FakturaFacade;
use Nette\Application\Attributes\Persistent;
@ -22,6 +23,7 @@ class FakturaPresenter extends BasePresenter
public function __construct(
private FakturaFacade $fakturaFacade,
private FakturaPdf $fakturaPdf,
private SeznamFakturPdf $seznamFakturPdf,
) {
parent::__construct();
}
@ -37,6 +39,31 @@ class FakturaPresenter extends BasePresenter
$fileName = FakturaPdf::fileName((string) $faktura->CISLO);
$disposition = $tisk ? 'inline' : 'attachment';
$this->sendPdf($pdf, $fileName, $disposition);
}
/**
* Tisk seznamu faktur podle filtru rok / měsíc.
*/
public function actionSeznam(): void
{
$rok = $this->rok ?? (int) date('Y');
$mesic = $this->normalizeMesic($this->mesic);
$this->fakturaFacade->reconcileUhrady();
$faktury = $this->fakturaFacade->getFiltered($rok, $mesic);
foreach ($faktury as $faktura) {
$celkem = (float) ($faktura->CASTKA_CELKEM ?? 0);
$zaplaceno = (float) ($faktura->ZAPLACENO ?? 0);
$faktura->STAV_UHRADY = FakturaFacade::stavZCastek($celkem, $zaplaceno);
}
$pdf = $this->seznamFakturPdf->render($faktury, $rok, $mesic, autoPrint: true);
$this->sendPdf($pdf, SeznamFakturPdf::fileName($rok, $mesic), 'inline');
}
private function sendPdf(string $pdf, string $fileName, string $disposition): void
{
$this->sendResponse(new CallbackResponse(function ($httpRequest, $httpResponse) use ($pdf, $fileName, $disposition): void {
$httpResponse->setContentType('application/pdf');
$httpResponse->setHeader(

View File

@ -18,7 +18,9 @@ services:
- App\Model\Sdf\SdfCompactExporter(%sdfExportScript%, %sdfPassword%)
- App\Model\Sdf\SdfKufrikImporter
- App\Model\Faktura\FakturaPdf(tempDir: %tempDir%)
- App\Model\Faktura\PdfSestava(tempDir: %tempDir%)
- App\Model\Faktura\FakturaPdf
- App\Model\Faktura\SeznamFakturPdf
# 1. Factory pro klientskou databázi
kufrikConnectionFactory: