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
-
+
| Nettobetrag: | {$fmt($inv['betrag_netto'])} |
| zzgl. Umsatzsteuer: | {$fmt($inv['betrag_ust'])} |
@@ -221,7 +216,7 @@ HTML;
private function buildCss(): string
{
return <<