diff --git a/.gitignore b/.gitignore index f97057d..b42c743 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,4 @@ backups/ .env *.log .claude/ +*.csv diff --git a/migrations/037_invoice_lines_type.sql b/migrations/037_invoice_lines_type.sql new file mode 100644 index 0000000..4546ffd --- /dev/null +++ b/migrations/037_invoice_lines_type.sql @@ -0,0 +1,2 @@ +ALTER TABLE invoice_lines + ADD COLUMN IF NOT EXISTS type VARCHAR(20) NOT NULL DEFAULT 'position'; diff --git a/public/static/css/app.css b/public/static/css/app.css index 25893b4..da471b5 100644 --- a/public/static/css/app.css +++ b/public/static/css/app.css @@ -446,9 +446,12 @@ td { padding: .65rem 1rem; vertical-align: middle; } } .invoice-preview { + display: flex; + flex-direction: column; background: #fff; - padding: 2cm 2cm 2.5cm; + padding: 2cm 2cm 20px; max-width: 21cm; + min-height: 29.7cm; margin: 0 auto; box-shadow: var(--shadow); font-size: 11pt; @@ -456,6 +459,47 @@ td { padding: .65rem 1rem; vertical-align: middle; } color: #000; } +.invoice-page-body { + /* natürliche Höhe, kein flex-stretch */ +} + +.invoice-footer-bar { + margin-top: auto; /* immer am unteren Rand der A4-Seite */ +} + +#invoice-pages { + display: flex; + flex-direction: column; + gap: 1cm; +} + +.invoice-form-table th, +.invoice-form-table td { padding-left: 5px; padding-right: 5px; vertical-align: top; } +.invoice-form-table th:first-child, +.invoice-form-table td:first-child { padding-left: 1rem; } +#invoice-pos-table td { vertical-align: top; } + +.line-bez-input { + min-height: unset; + resize: none; + overflow: hidden; + line-height: 1.4; + padding-top: 2px; + padding-bottom: 2px; +} + +.add-line-opt { + display: block; + width: 100%; + padding: .35rem .75rem; + text-align: left; + background: transparent; + border: none; + cursor: pointer; + white-space: nowrap; +} +.add-line-opt:hover { background: #f0f0f0; } + /* * Adressbereich: DIN 5008-Proportionen (50% = 85mm bei vollem A4-Preview). * Breakpoint: nav (220px) + page-body-padding (45px) + A4-max-width (794px) = 1059px. diff --git a/public/static/js/app.js b/public/static/js/app.js index bf5f372..339daaa 100644 --- a/public/static/js/app.js +++ b/public/static/js/app.js @@ -137,23 +137,55 @@ document.addEventListener('input', (e) => { } }); -// ── Rechnungszeile hinzufügen ───────────────────────────────── -let lineCounter = 0; +// ── Rechnungszeile / Überschrift hinzufügen ─────────────────── + +function showAddLineDialog(btn) { + document.getElementById('add-line-popup')?.remove(); + + const popup = document.createElement('div'); + popup.id = 'add-line-popup'; + popup.style.cssText = 'position:absolute;z-index:200;background:#fff;border:1px solid #ccc;border-radius:5px;box-shadow:0 2px 8px rgba(0,0,0,.12);overflow:hidden;font-size:.875rem'; + popup.innerHTML = ` + + `; + document.body.appendChild(popup); + + const r = btn.getBoundingClientRect(); + popup.style.top = (window.scrollY + r.bottom + 3) + 'px'; + popup.style.left = r.left + 'px'; + popup.style.minWidth = r.width + 'px'; + + setTimeout(() => { + document.addEventListener('click', function close(e) { + if (!popup.contains(e.target) && e.target !== btn) { + popup.remove(); + document.removeEventListener('click', close); + } + }); + }, 0); +} + +function _nextLineIdx() { + const tbody = document.getElementById('invoice-lines-body'); + return tbody ? tbody.querySelectorAll('tr').length : 0; +} function addInvoiceLine() { - lineCounter++; const tbody = document.getElementById('invoice-lines-body'); if (!tbody) return; - const idx = tbody.querySelectorAll('.invoice-line-row').length; + const idx = _nextLineIdx(); const row = document.createElement('tr'); row.className = 'invoice-line-row'; row.innerHTML = ` - ${idx + 1} - - + - + + + + + - - + 0,00 € 0,00 € `; tbody.appendChild(row); + renumberLines(); calcInvoiceTotals(); + const ta = row.querySelector('.line-bez-input'); + if (ta) { initBezTextarea(ta); ta.focus(); } +} + +function addInvoiceHeading() { + const tbody = document.getElementById('invoice-lines-body'); + if (!tbody) return; + + const idx = _nextLineIdx(); + const row = document.createElement('tr'); + row.className = 'invoice-heading-row'; + row.innerHTML = ` + + + + + `; + tbody.appendChild(row); + renumberLines(); + const ta = row.querySelector('.line-bez-input'); + if (ta) { initBezTextarea(ta); ta.focus(); } } function removeInvoiceLine(btn) { - btn.closest('.invoice-line-row').remove(); - // Renumber positions - document.querySelectorAll('.invoice-line-row').forEach((row, i) => { - const posEl = row.querySelector('.line-pos'); - if (posEl) posEl.textContent = i + 1; - // Update indices in name attributes + btn.closest('tr').remove(); + renumberLines(); + calcInvoiceTotals(); +} + +function renumberLines() { + const tbody = document.getElementById('invoice-lines-body'); + if (!tbody) return; + let posNum = 0; + tbody.querySelectorAll('tr').forEach((row, i) => { + // Alle name-Attribute auf neuen Array-Index setzen row.querySelectorAll('[name]').forEach(el => { el.name = el.name.replace(/lines\[\d+\]/, `lines[${i}]`); }); + // Positions-Nummer nur bei echten Zeilen hochzählen + const posEl = row.querySelector('.line-pos'); + if (posEl) posEl.textContent = ++posNum; }); - calcInvoiceTotals(); } // Init totals on page load @@ -376,6 +437,27 @@ document.addEventListener('keydown', (e) => { } }); +// ── Rechnungsformular: Enter nicht als Submit, Textarea auto-resize ─────────── +function initBezTextarea(el) { + el.style.height = 'auto'; + el.style.height = el.scrollHeight + 'px'; +} + +document.addEventListener('input', (e) => { + if (e.target.classList.contains('line-bez-input')) initBezTextarea(e.target); +}); + +document.addEventListener('keydown', (e) => { + if (e.key !== 'Enter') return; + if (e.target.tagName === 'TEXTAREA') return; + const form = e.target.closest('form.invoice-form'); + if (form) e.preventDefault(); +}, true); + +document.addEventListener('DOMContentLoaded', () => { + document.querySelectorAll('.line-bez-input').forEach(initBezTextarea); +}); + // ── Flash Message Auto-dismiss ──────────────────────────────── document.addEventListener('DOMContentLoaded', () => { document.querySelectorAll('.flash').forEach(el => { diff --git a/src/Models/Invoice.php b/src/Models/Invoice.php index 0fd90d7..2660e53 100644 --- a/src/Models/Invoice.php +++ b/src/Models/Invoice.php @@ -65,7 +65,7 @@ class Invoice public static function lines(int $invoiceId): array { return Database::fetchAll( - 'SELECT * FROM invoice_lines WHERE invoice_id = ? ORDER BY position', + 'SELECT * FROM invoice_lines WHERE invoice_id = ? ORDER BY id', [$invoiceId] ); } @@ -183,32 +183,46 @@ class Invoice private static function saveLines(int $invoiceId, array $lines): void { - foreach ($lines as $i => $line) { + $posCounter = 0; + foreach ($lines as $line) { if (empty($line['bezeichnung'])) continue; - $menge = round((float)$line['menge'], 3); - $preis = round((float)$line['einzelpreis'], 2); - $ustSatz = round((float)$line['ust_satz'], 2); - $netto = round($menge * $preis, 2); - $ust = round($netto * $ustSatz / 100, 2); - $brutto = round($netto + $ust, 2); + $type = ($line['type'] ?? 'position') === 'heading' ? 'heading' : 'position'; - Database::execute( - 'INSERT INTO invoice_lines (invoice_id, position, bezeichnung, menge, einheit, - einzelpreis, ust_satz, betrag_netto, betrag_ust, betrag_brutto) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)', - [ - $invoiceId, - $i + 1, - $line['bezeichnung'], - $menge, - $line['einheit'] ?? 'Stk.', - $preis, - $ustSatz, - $netto, - $ust, - $brutto, - ] - ); + if ($type === 'heading') { + Database::execute( + 'INSERT INTO invoice_lines (invoice_id, position, bezeichnung, menge, einheit, + einzelpreis, ust_satz, betrag_netto, betrag_ust, betrag_brutto, type) + VALUES (?, ?, ?, 0, \'\', 0, 0, 0, 0, 0, ?)', + [$invoiceId, 0, $line['bezeichnung'], 'heading'] + ); + } else { + $posCounter++; + $menge = round((float)$line['menge'], 3); + $preis = round((float)$line['einzelpreis'], 2); + $ustSatz = round((float)$line['ust_satz'], 2); + $netto = round($menge * $preis, 2); + $ust = round($netto * $ustSatz / 100, 2); + $brutto = round($netto + $ust, 2); + + Database::execute( + 'INSERT INTO invoice_lines (invoice_id, position, bezeichnung, menge, einheit, + einzelpreis, ust_satz, betrag_netto, betrag_ust, betrag_brutto, type) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)', + [ + $invoiceId, + $posCounter, + $line['bezeichnung'], + $menge, + $line['einheit'] ?? 'Stk.', + $preis, + $ustSatz, + $netto, + $ust, + $brutto, + 'position', + ] + ); + } } } diff --git a/src/Services/PdfInvoiceService.php b/src/Services/PdfInvoiceService.php index 1b02913..89615ac 100644 --- a/src/Services/PdfInvoiceService.php +++ b/src/Services/PdfInvoiceService.php @@ -78,15 +78,21 @@ class PdfInvoiceService // 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']) . ' - '; + if (($line['type'] ?? 'position') === 'heading') { + $posRows .= ' + ' . nl2br($h($line['bezeichnung'])) . ' + '; + } else { + $posRows .= ' + ' . $h($line['position']) . ' + ' . nl2br($h($line['bezeichnung'])) . ' + ' . $fmtNum($line['menge']) . ' + ' . $h($line['einheit']) . ' + ' . $h(number_format((float)$line['ust_satz'], 0)) . ' % + ' . $fmt($line['einzelpreis']) . ' + ' . $fmt($line['betrag_netto']) . ' + '; + } } $payDays = $inv['payment_days'] ?? 14; @@ -112,19 +118,8 @@ class PdfInvoiceService {$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'] ?? '')}
- + + RECHNUNG @@ -169,7 +164,7 @@ class PdfInvoiceService - +
@@ -221,7 +216,7 @@ HTML; private function buildCss(): string { return <<
-
+
@@ -60,7 +60,7 @@ $preCustomer = (int)($_GET['customer_id'] ?? 0);
Rechnungspositionen - +
@@ -68,58 +68,86 @@ $preCustomer = (int)($_GET['customer_id'] ?? 0);
-
Nettobetrag: {$fmt($inv['betrag_netto'])}
zzgl. Umsatzsteuer:{$fmt($inv['betrag_ust'])}
+
+ + + + + + + + + + + - + - - - - - - - + + + + + + + - $line): ?> + $line): + $isHeading = ($line['type'] ?? 'position') === 'heading'; + ?> + + + + + + - - - + + + - + +
Pos.Pos. BezeichnungMengeEinheitUSt.EinzelpreisNettoBruttoMengeEinheitUSt.EinzelpreisNettoBrutto
+ + +
- + + + -
-
- - - - - - +
+
Nettobetrag:–
zzgl. Umsatzsteuer:–
Rechnungsbetrag:–
+ + + + +
Nettobetrag:–
zzgl. Umsatzsteuer:–
Rechnungsbetrag:–
diff --git a/src/Views/invoices/show.php b/src/Views/invoices/show.php index eb7a876..09a7610 100644 --- a/src/Views/invoices/show.php +++ b/src/Views/invoices/show.php @@ -20,7 +20,7 @@ $badgeClass = match($invoice['status']) {
- +
@@ -46,7 +46,9 @@ $badgeClass = match($invoice['status']) {
+
+
@@ -73,7 +75,7 @@ $badgeClass = match($invoice['status']) {
-
+
· ·
@@ -83,28 +85,18 @@ $badgeClass = match($invoice['status']) {
-
-
-
-
-
-
-
-
- USt-ID: -
-
+
RECHNUNG
- +
- + @@ -113,13 +105,13 @@ $badgeClass = match($invoice['status']) { - +
Betreff Rechnungsnummer Kundennummer LeistungszeitraumDatumDatum
- +
@@ -133,39 +125,48 @@ $badgeClass = match($invoice['status']) { + + + + + - + - + +
Pos.
%
- -
- - - - - - - -
Nettobetrag:
zzgl. Umsatzsteuer:
Rechnungsbetrag:
+ +
+
+ + + + + + + +
Nettobetrag:
zzgl. Umsatzsteuer:
Rechnungsbetrag:
+
+
+ Bitte überweisen Sie den Rechnungsbetrag innerhalb Tagen auf das unten genannte Konto. + Fällig am: +
- -
- Bitte überweisen Sie den Rechnungsbetrag innerhalb Tagen auf das unten genannte Konto. - Fällig am: -
+
- -
+ + + + diff --git a/src/version.txt b/src/version.txt index 3ada37a..0ebbc35 100644 --- a/src/version.txt +++ b/src/version.txt @@ -1 +1 @@ -1-260426 +1-260427