From d8cba3e1f5845caf246ea5c215e77286dc64c41c Mon Sep 17 00:00:00 2001 From: fds2610-bot Date: Mon, 13 Apr 2026 15:32:57 +0200 Subject: [PATCH] =?UTF-8?q?Logo-Upload=20und=20Rechnungserstellung=20?= =?UTF-8?q?=C3=BCberarbeitet?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- migrations/029_logo_position.sql | 6 + public/static/css/app.css | 43 ++++- src/Controllers/SettingsController.php | 5 +- src/Models/Setting.php | 13 +- src/Services/PdfInvoiceService.php | 227 +++++++++++++++---------- src/Views/invoices/show.php | 96 +++++++---- src/Views/settings/form.php | 108 +++++++++--- 7 files changed, 342 insertions(+), 156 deletions(-) create mode 100644 migrations/029_logo_position.sql diff --git a/migrations/029_logo_position.sql b/migrations/029_logo_position.sql new file mode 100644 index 0000000..2960f1f --- /dev/null +++ b/migrations/029_logo_position.sql @@ -0,0 +1,6 @@ +ALTER TABLE company_settings + ADD COLUMN IF NOT EXISTS logo_max_width INT NOT NULL DEFAULT 600, + ADD COLUMN IF NOT EXISTS logo_max_height INT NOT NULL DEFAULT 100, + ADD COLUMN IF NOT EXISTS logo_position VARCHAR(10) NOT NULL DEFAULT 'center', + ADD COLUMN IF NOT EXISTS logo_margin_side INT NOT NULL DEFAULT 75, + ADD COLUMN IF NOT EXISTS logo_margin_top INT NOT NULL DEFAULT 6; diff --git a/public/static/css/app.css b/public/static/css/app.css index a28181f..4e00b26 100644 --- a/public/static/css/app.css +++ b/public/static/css/app.css @@ -109,8 +109,9 @@ img { max-width: 100%; } .main-content { display: flex; flex-direction: column; - min-height: 100vh; - overflow: hidden; + height: 100vh; + overflow-y: auto; + overflow-x: hidden; } .topbar { @@ -121,6 +122,9 @@ img { max-width: 100%; } align-items: center; justify-content: space-between; gap: 1rem; + position: sticky; + top: 0; + z-index: 10; } .topbar-title { @@ -368,6 +372,18 @@ td { padding: .65rem 1rem; vertical-align: middle; } } /* ── Invoice preview (DIN 5008) ──────────────────────────────── */ +.invoice-preview thead th { + text-transform: none; + letter-spacing: normal; + font-size: inherit; + font-weight: normal; + background: revert; + padding: revert; + white-space: revert; + color: inherit; + border-bottom: none; +} + .invoice-preview { background: #fff; padding: 2cm 2cm 2.5cm; @@ -379,6 +395,29 @@ td { padding: .65rem 1rem; vertical-align: middle; } color: #000; } +/* + * Adressbereich: DIN 5008-Proportionen (50% = 85mm bei vollem A4-Preview). + * Breakpoint: nav (220px) + page-body-padding (45px) + A4-max-width (794px) = 1059px. + * Darunter stapeln sich die Spalten vertikal. + */ +.invoice-address { + display: grid; + grid-template-columns: 50% 1fr; + gap: 1rem; +} + +@media (max-width: 1059px) { + .invoice-address { + grid-template-columns: 1fr; + } + .invoice-address > div:last-child { + justify-content: flex-start !important; + border-top: 1px solid #ddd; + padding-top: 0.75rem; + font-size: .85rem; + } +} + /* ── Modals ──────────────────────────────────────────────────── */ .modal-overlay { display: none; diff --git a/src/Controllers/SettingsController.php b/src/Controllers/SettingsController.php index bc18b53..e262a5e 100644 --- a/src/Controllers/SettingsController.php +++ b/src/Controllers/SettingsController.php @@ -15,8 +15,9 @@ class SettingsController $view = new View(); $view->render('layout/base.php', [ - 'pageTitle' => 'Einstellungen', - 'content' => $this->renderView('settings/form', [ + 'pageTitle' => 'Einstellungen', + 'topbarActions' => '', + 'content' => $this->renderView('settings/form', [ 'settings' => $settings, 'errors' => [], ]), diff --git a/src/Models/Setting.php b/src/Models/Setting.php index 903e4c2..c112139 100644 --- a/src/Models/Setting.php +++ b/src/Models/Setting.php @@ -21,6 +21,10 @@ class Setting public static function update(array $data): void { + $logoPosition = in_array($data['logo_position'] ?? '', ['center', 'left', 'right']) + ? $data['logo_position'] + : 'center'; + Database::execute( 'UPDATE company_settings SET firma=?, inhaber=?, strasse=?, plz=?, ort=?, @@ -31,7 +35,9 @@ class Setting invoice_nr_prefix=?, beleg_nr_prefix=?, payment_days_default=?, test_mode=?, ki_vorschlag_aktiv=?, ocr_aktiv=?, nextcloud_url=?, nextcloud_user=?, nextcloud_pass=?, - nextcloud_folder=?, nextcloud_done=? + nextcloud_folder=?, nextcloud_done=?, + logo_max_width=?, logo_max_height=?, + logo_position=?, logo_margin_side=?, logo_margin_top=? WHERE id=1', [ $data['firma'] ?? '', @@ -61,6 +67,11 @@ class Setting $data['nextcloud_pass'] ?: null, $data['nextcloud_folder'] ?: '/Belege/Import', $data['nextcloud_done'] ?: null, + max(50, (int)($data['logo_max_width'] ?? 600)), + max(20, (int)($data['logo_max_height'] ?? 100)), + $logoPosition, + max(0, (int)($data['logo_margin_side'] ?? 75)), + max(0, (int)($data['logo_margin_top'] ?? 6)), ] ); } diff --git a/src/Services/PdfInvoiceService.php b/src/Services/PdfInvoiceService.php index 13d3e60..718c311 100644 --- a/src/Services/PdfInvoiceService.php +++ b/src/Services/PdfInvoiceService.php @@ -5,8 +5,6 @@ declare(strict_types=1); namespace Steuerkram\Services; use Mpdf\Mpdf; -use Mpdf\Config\ConfigVariables; -use Mpdf\Config\FontVariables; class PdfInvoiceService { @@ -15,12 +13,12 @@ class PdfInvoiceService $html = $this->buildHtml($invoice, $lines, $settings); $mpdf = new Mpdf([ - 'mode' => 'utf-8', - 'format' => 'A4', - 'margin_top' => 15, + 'mode' => 'utf-8', + 'format' => 'A4', + 'margin_top' => 15, 'margin_bottom' => 25, - 'margin_left' => 20, - 'margin_right' => 20, + 'margin_left' => 25, // DIN 5008: 25mm links + 'margin_right' => 15, // DIN 5008: 15mm rechts ]); $mpdf->SetTitle('Rechnung ' . ($invoice['rechnungs_nr'] ?? 'Entwurf')); @@ -31,28 +29,45 @@ class PdfInvoiceService $mpdf->WriteHTML($html, \Mpdf\HTMLParserMode::HTML_BODY); $filename = 'Rechnung-' . ($invoice['rechnungs_nr'] ?? 'Entwurf') . '.pdf'; - $mpdf->Output($filename, 'D'); // D = Download + $mpdf->Output($filename, 'D'); } private function buildHtml(array $inv, array $lines, array $settings): string { - $h = fn($v) => htmlspecialchars((string)$v, ENT_QUOTES, 'UTF-8'); - $fmt = fn($v) => number_format((float)$v, 2, ',', '.') . ' €'; - $fmtNum = fn($v) => number_format((float)$v, 3, ',', '.'); + $h = fn($v) => htmlspecialchars((string)$v, ENT_QUOTES, 'UTF-8'); + $fmt = fn($v) => number_format((float)$v, 2, ',', '.') . ' €'; + $fmtNum = fn($v) => number_format((float)$v, 1, ',', '.'); $fmtDate = fn($v) => $v ? date('d.m.Y', strtotime($v)) : '–'; - // Customer address - $customerName = !empty($inv['firma']) ? $inv['firma'] : trim(($inv['anrede'] ?? '') . ' ' . ($inv['vorname'] ?? '') . ' ' . $inv['nachname']); + // Kundenname + $customerName = !empty($inv['firma']) + ? $inv['firma'] + : trim(($inv['anrede'] ?? '') . ' ' . ($inv['vorname'] ?? '') . ' ' . $inv['nachname']); // Logo $logoHtml = ''; if (!empty($settings['logo_path'])) { $logoPath = dirname(__DIR__, 2) . '/public/' . $settings['logo_path']; if (file_exists($logoPath)) { - $logoHtml = ''; + $maxW = max(50, (int)($settings['logo_max_width'] ?? 600)); + $maxH = max(20, (int)($settings['logo_max_height'] ?? 100)); + $logoHtml = 'Logo'; } } + $logoPosition = $settings['logo_position'] ?? 'center'; + $logoMarginSide = max(0, (int)($settings['logo_margin_side'] ?? 75)); + $logoMarginTop = max(0, (int)($settings['logo_margin_top'] ?? 6)); + + if ($logoPosition === 'left') { + $logoWrapStyle = "text-align:left;margin-left:{$logoMarginSide}px;margin-top:{$logoMarginTop}px"; + } elseif ($logoPosition === 'right') { + $logoWrapStyle = "text-align:right;margin-right:{$logoMarginSide}px;margin-top:{$logoMarginTop}px"; + } else { + $logoWrapStyle = 'text-align:center'; + } + // Leistungszeitraum $lz = ''; if (!empty($inv['leistungszeitraum_von'])) { @@ -62,69 +77,84 @@ class PdfInvoiceService } } - // Build positions table + // Fälligkeitsdatum + $fälligText = !empty($inv['faellig_datum']) + ? ' Fällig am: ' . $fmtDate($inv['faellig_datum']) . '' + : ''; + + // Positionszeilen $posRows = ''; foreach ($lines as $line) { $posRows .= ' - ' . $h($line['position']) . ' - ' . $h($line['bezeichnung']) . ' - ' . $fmtNum($line['menge']) . ' - ' . $h($line['einheit']) . ' - ' . $h(number_format((float)$line['ust_satz'], 0)) . ' % - ' . $fmt($line['einzelpreis']) . ' - ' . $fmt($line['betrag_brutto']) . ' + ' . $h($line['position']) . ' + ' . $h($line['bezeichnung']) . ' + ' . $fmtNum($line['menge']) . ' + ' . $h($line['einheit']) . ' + ' . $h(number_format((float)$line['ust_satz'], 0)) . ' % + ' . $fmt($line['einzelpreis']) . ' + ' . $fmt($line['betrag_brutto']) . ' '; } $payDays = $inv['payment_days'] ?? 14; + // Empfänger-Land nur wenn nicht DE + $land = ($inv['land'] ?? 'DE') !== 'DE' ? '
' . $h($inv['land']) : ''; + return << -
{$logoHtml}
+
{$logoHtml}
- -
-
-
{$h($settings['firma'] ?? '')} · {$h($settings['strasse'] ?? '')} · {$h($settings['plz'] ?? '')} {$h($settings['ort'] ?? '')}
-
- {$h($customerName)}
- {$h($inv['strasse'])}
- {$h($inv['plz'])} {$h($inv['ort'])}
- {$h($inv['land'] !== 'DE' ? $inv['land'] : '')} -
-
-
- - - - - - - - - -
{$h($settings['firma'] ?? '')}
{$h($settings['inhaber'] ?? '')}
{$h($settings['strasse'] ?? '')}
{$h($settings['plz'] ?? '')} {$h($settings['ort'] ?? '')}
 
{$h($settings['telefon'] ?? '')}
{$h($settings['email'] ?? '')}
USt-ID: {$h($settings['ust_id'] ?? '')}
-
-
+ + + + + + + + +
+
{$h($settings['firma'] ?? '')} · {$h($settings['strasse'] ?? '')} · {$h($settings['plz'] ?? '')} {$h($settings['ort'] ?? '')}
+
+ {$h($customerName)}
+ {$h($inv['strasse'])}
+ {$h($inv['plz'])} {$h($inv['ort'])}{$land} +
+
+ + + + + + + + + +
{$h($settings['firma'] ?? '')}
{$h($settings['inhaber'] ?? '')}
{$h($settings['strasse'] ?? '')}
{$h($settings['plz'] ?? '')} {$h($settings['ort'] ?? '')}
 
{$h($settings['telefon'] ?? '')}
{$h($settings['email'] ?? '')}
USt-ID: {$h($settings['ust_id'] ?? '')}
+
- - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + +
BetreffRechnungsnummerKundennummerLeistungszeitraumDatum
{$h($inv['betreff'])}{$h($inv['rechnungs_nr'] ?? 'Entwurf')}{$h($inv['kundennummer'])}{$h($lz)}{$fmtDate($inv['rechnungs_datum'])}
BetreffRechnungsnummerKundennummerLeistungszeitraumDatum
{$h($inv['betreff'])}{$h($inv['rechnungs_nr'] ?? 'Entwurf')}{$h($inv['kundennummer'])}{$h($lz ?: '–')}{$fmtDate($inv['rechnungs_datum'])}
@@ -145,16 +175,19 @@ class PdfInvoiceService - - - + +
Nettobetrag:{$fmt($inv['betrag_netto'])}
+ - + + + +
Nettobetrag: {$fmt($inv['betrag_netto'])}
zzgl. Umsatzsteuer:{$fmt($inv['betrag_ust'])}
Rechnungsbetrag:{$fmt($inv['betrag_brutto'])}
Rechnungsbetrag:{$fmt($inv['betrag_brutto'])}

- Bitte überweisen Sie den Rechnungsbetrag innerhalb {$h($payDays)} Tagen auf das unten genannte Konto. + Bitte überweisen Sie den Rechnungsbetrag innerhalb {$h($payDays)} Tagen auf das unten genannte Konto.{$fälligText}

@@ -197,48 +230,60 @@ HTML; return << für die Vorschau-Tabelle (Datumsangaben untereinander) +$lzHtml = ''; +if (!empty($invoice['leistungszeitraum_von'])) { + $lzHtml = $h(View::formatDate($invoice['leistungszeitraum_von'])); + if (!empty($invoice['leistungszeitraum_bis']) && $invoice['leistungszeitraum_bis'] !== $invoice['leistungszeitraum_von']) { + $lzHtml .= '
' . $h(View::formatDate($invoice['leistungszeitraum_bis'])); + } +} + $badgeClass = match($invoice['status']) { 'Bezahlt' => 'badge-green', 'Storniert' => 'badge-red', @@ -56,13 +65,28 @@ $badgeClass = match($invoice['status']) { -
- Logo + +
+ Logo
- -
+ +
· · @@ -74,14 +98,16 @@ $badgeClass = match($invoice['status']) {
-
-
-
-
-
-
-
- USt-ID: +
+
+
+
+
+
+
+
+ USt-ID: +
@@ -89,35 +115,35 @@ $badgeClass = match($invoice['status']) { - - - - - + + + + + - - - - - + + + + +
BetreffRechnungsnummerKundennummerLeistungszeitraumDatumBetreffRechnungsnummerKundennummerLeistungszeitraumDatum
- +
- - + + - - - - - + + + + + @@ -125,7 +151,7 @@ $badgeClass = match($invoice['status']) { - + @@ -154,20 +180,20 @@ $badgeClass = match($invoice['status']) { -
-
+
+




USt-ID:
-
+
Tel:
E-Mail:
Web:
-
+

IBAN:
BIC: diff --git a/src/Views/settings/form.php b/src/Views/settings/form.php index 679f2d4..d61b970 100644 --- a/src/Views/settings/form.php +++ b/src/Views/settings/form.php @@ -1,6 +1,6 @@ View::h($v); ?> -
+
@@ -85,8 +85,6 @@
- -
@@ -239,28 +237,6 @@
-
-
Logo
-
- -
- Logo -
- - - - -
-
- - -
Empfohlen: transparentes PNG, mind. 300px breit
-
- - -
-
-
Nummernkreise
@@ -286,6 +262,82 @@
+
+
+
+
+
Logo
+
+ +
+ Logo +
+ +
+
+ + +
Empfohlen: transparentes PNG, mind. 300px breit
+
+
+ + + + +
+ + +
+ + +
+
Darstellung in der Rechnung
+
+
+ + +
+
+ + +
+
+
+ +
+ 'Zentriert', 'left' => 'Links', 'right' => 'Rechts'] as $val => $label): ?> + + +
+
+ +
+
+
+ + +
+
+ + +
+
+
+
+
+
+
+
+
Pos.
Pos. BezeichnungMengeEinheitUSt.EinzelpreisGesamtMengeEinheitUSt.EinzelpreisGesamt
%