Fix: Rechnungsansicht überarbeitet (Layout, Schriftgrößen, Summenblock, Enter-Verhalten)

This commit is contained in:
fds2610-bot 2026-04-27 12:25:43 +02:00
parent 2fe6406f97
commit 84cbbe8401
9 changed files with 442 additions and 154 deletions

1
.gitignore vendored
View file

@ -5,3 +5,4 @@ backups/
.env
*.log
.claude/
*.csv

View file

@ -0,0 +1,2 @@
ALTER TABLE invoice_lines
ADD COLUMN IF NOT EXISTS type VARCHAR(20) NOT NULL DEFAULT 'position';

View file

@ -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.

View file

@ -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 = `
<button class="add-line-opt" onclick="document.getElementById('add-line-popup').remove();addInvoiceHeading()">Überschrift</button>
<button class="add-line-opt" onclick="document.getElementById('add-line-popup').remove();addInvoiceLine()">Neue Position</button>`;
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 = `
<td><span class="line-pos text-muted">${idx + 1}</span></td>
<td><input type="text" name="lines[${idx}][bezeichnung]" required placeholder="Leistungsbeschreibung" style="min-width:200px"></td>
<td><input type="number" name="lines[${idx}][menge]" value="1" min="0.001" step="0.001" style="width:80px" required></td>
<td><span class="line-pos text-muted"></span></td>
<td>
<select name="lines[${idx}][einheit]" style="width:80px">
<input type="hidden" name="lines[${idx}][type]" value="position">
<textarea name="lines[${idx}][bezeichnung]" required placeholder="Leistungsbeschreibung" rows="1" class="line-bez-input" style="width:100%"></textarea>
</td>
<td><input type="number" name="lines[${idx}][menge]" value="1" min="0.001" step="0.001" style="width:100%" required></td>
<td>
<select name="lines[${idx}][einheit]" style="width:100%">
<option value="Stk.">Stk.</option>
<option value="h">h</option>
<option value="Tage">Tage</option>
@ -163,34 +195,63 @@ function addInvoiceLine() {
</select>
</td>
<td>
<select name="lines[${idx}][ust_satz]" style="width:80px">
<select name="lines[${idx}][ust_satz]" style="width:100%">
<option value="19">19 %</option>
<option value="7">7 %</option>
<option value="0">0 %</option>
</select>
</td>
<td><input type="number" name="lines[${idx}][einzelpreis]" value="0.00" min="0" step="0.01" style="width:110px" required></td>
<td><input type="number" name="lines[${idx}][einzelpreis]" value="0.00" min="0" step="0.01" style="width:100%" required></td>
<td class="td-right line-netto text-muted">0,00 €</td>
<td class="td-right line-brutto font-bold">0,00 €</td>
<td>
<button type="button" class="btn btn-danger btn-sm" onclick="removeInvoiceLine(this)">✕</button>
</td>`;
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 = `
<td colspan="8">
<input type="hidden" name="lines[${idx}][type]" value="heading">
<textarea name="lines[${idx}][bezeichnung]" required placeholder="Überschrift" rows="1" class="line-bez-input" style="width:100%;font-weight:bold"></textarea>
</td>
<td><button type="button" class="btn btn-danger btn-sm" onclick="removeInvoiceLine(this)">✕</button></td>`;
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 => {

View file

@ -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,8 +183,20 @@ 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;
$type = ($line['type'] ?? 'position') === 'heading' ? 'heading' : 'position';
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);
@ -194,11 +206,11 @@ class Invoice
Database::execute(
'INSERT INTO invoice_lines (invoice_id, position, bezeichnung, menge, einheit,
einzelpreis, ust_satz, betrag_netto, betrag_ust, betrag_brutto)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
einzelpreis, ust_satz, betrag_netto, betrag_ust, betrag_brutto, type)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
[
$invoiceId,
$i + 1,
$posCounter,
$line['bezeichnung'],
$menge,
$line['einheit'] ?? 'Stk.',
@ -207,10 +219,12 @@ class Invoice
$netto,
$ust,
$brutto,
'position',
]
);
}
}
}
private static function recalcTotals(int $id): void
{

View file

@ -78,16 +78,22 @@ class PdfInvoiceService
// Positionszeilen
$posRows = '';
foreach ($lines as $line) {
if (($line['type'] ?? 'position') === 'heading') {
$posRows .= '<tr>
<td colspan="7" class="pos-heading">' . nl2br($h($line['bezeichnung'])) . '</td>
</tr>';
} else {
$posRows .= '<tr>
<td class="pos-nr">' . $h($line['position']) . '</td>
<td class="pos-bez">' . $h($line['bezeichnung']) . '</td>
<td class="pos-bez">' . nl2br($h($line['bezeichnung'])) . '</td>
<td class="pos-menge">' . $fmtNum($line['menge']) . '</td>
<td class="pos-einheit">' . $h($line['einheit']) . '</td>
<td class="pos-ust">' . $h(number_format((float)$line['ust_satz'], 0)) . ' %</td>
<td class="pos-preis">' . $fmt($line['einzelpreis']) . '</td>
<td class="pos-gesamt">' . $fmt($line['betrag_brutto']) . '</td>
<td class="pos-gesamt">' . $fmt($line['betrag_netto']) . '</td>
</tr>';
}
}
$payDays = $inv['payment_days'] ?? 14;
@ -112,19 +118,8 @@ class PdfInvoiceService
{$h($inv['plz'])} {$h($inv['ort'])}{$land}
</div>
</td>
<!-- Rechte Spalte: Absenderinformationen -->
<td class="address-right">
<table class="sender-table">
<tr><td><strong>{$h($settings['firma'] ?? '')}</strong></td></tr>
<tr><td>{$h($settings['inhaber'] ?? '')}</td></tr>
<tr><td>{$h($settings['strasse'] ?? '')}</td></tr>
<tr><td>{$h($settings['plz'] ?? '')} {$h($settings['ort'] ?? '')}</td></tr>
<tr><td>&nbsp;</td></tr>
<tr><td>{$h($settings['telefon'] ?? '')}</td></tr>
<tr><td>{$h($settings['email'] ?? '')}</td></tr>
<tr><td>USt-ID: {$h($settings['ust_id'] ?? '')}</td></tr>
</table>
</td>
<!-- Rechte Spalte: Dokumententitel -->
<td class="address-right" style="text-align:right;vertical-align:top">RECHNUNG</td>
</tr>
</table>
@ -169,7 +164,7 @@ class PdfInvoiceService
</table>
<!-- Summen (rechtsbündig) -->
<table class="sum-table" align="right">
<table class="sum-table" align="right" style="margin-right:1.4mm">
<tr><td>Nettobetrag:</td> <td class="sum-val">{$fmt($inv['betrag_netto'])}</td></tr>
<tr><td>zzgl. Umsatzsteuer:</td><td class="sum-val">{$fmt($inv['betrag_ust'])}</td></tr>
<tr class="sum-total">
@ -221,7 +216,7 @@ HTML;
private function buildCss(): string
{
return <<<CSS
body { font-family: DejaVu Sans, sans-serif; font-size: 10pt; color: #000; }
body { font-family: DejaVu Sans, sans-serif; font-size: 7.8pt; color: #000; }
.invoice-wrap { width: 100%; }
/* Logo */
@ -233,26 +228,25 @@ body { font-family: DejaVu Sans, sans-serif; font-size: 10pt; color: #000; }
.address-right { vertical-align: top; padding: 0 0 0 5mm; }
.return-address {
font-size: 7pt; color: #555;
font-size: 5.5pt; color: #555;
border-bottom: 0.3pt solid #aaa;
padding-bottom: 1mm; margin-bottom: 2mm;
}
.recipient { font-size: 10pt; line-height: 1.5; min-height: 27.3mm; }
/* Absendertabelle: auf Inhaltsbreite schrumpfen, rechtsbündig */
.sender-table { border-collapse: collapse; font-size: 9pt; margin-left: auto; }
.sender-table td { padding: 0.4mm 0; white-space: nowrap; }
.recipient { font-size: 7.8pt; line-height: 1.5; min-height: 27.3mm; }
/* Meta-Felder */
.meta-table { width: 100%; table-layout: fixed; border-collapse: collapse; margin-bottom: 8mm; font-size: 9pt; }
.meta-table th { background: #f0f0f0; padding: 2mm; text-align: left; font-size: 8pt; color: #555; border: 0.5pt solid #ccc; }
.meta-table { width: 100%; table-layout: fixed; border-collapse: collapse; margin-bottom: 8mm; font-size: 7pt; }
.meta-table th { background: #f0f0f0; padding: 2mm; text-align: left; font-size: 6pt; color: #555; border: 0.5pt solid #ccc; }
.meta-table td { padding: 2mm; border: 0.5pt solid #ccc; }
.meta-table th:last-child, .meta-table td:last-child { text-align: right; }
/* Positionen */
.pos-table { width: 100%; table-layout: fixed; border-collapse: collapse; margin-bottom: 5mm; font-size: 9pt; }
.pos-table thead th { background: #f0f0f0; color: #000; padding: 2mm; }
.pos-table { width: 100%; table-layout: fixed; border-collapse: collapse; margin-bottom: 5mm; font-size: 7pt; }
.pos-table thead th { background: #f0f0f0; color: #000; padding: 2mm; text-align: right; }
.pos-table thead th.pos-nr, .pos-table thead th.pos-bez, .pos-table thead th.pos-einheit { text-align: left; }
.pos-table tbody tr:nth-child(even) { background: #f8f8f8; }
.pos-table tbody td { padding: 1.5mm 2mm; border-bottom: 0.3pt solid #e0e0e0; }
.pos-table tbody td { padding: 1.5mm 2mm; border-bottom: 0.3pt solid #e0e0e0; vertical-align: top; }
.pos-heading { font-weight: bold; }
.pos-nr { width: 8%; }
.pos-bez { /* nimmt den Rest */ }
.pos-menge { width: 11%; text-align: right; }
@ -262,21 +256,20 @@ body { font-family: DejaVu Sans, sans-serif; font-size: 10pt; color: #000; }
.pos-gesamt { width: 16%; text-align: right; }
/* Summen */
.sum-table { border-collapse: collapse; margin-bottom: 8mm; font-size: 9pt; }
.sum-table td { padding: 1.5mm 2mm; }
.sum-table .sum-val { text-align: right; border-top: 0.3pt solid #ccc; }
.sum-table .sum-total td { border-top: 1pt solid #000; }
.sum-table { border-collapse: collapse; margin-bottom: 8mm; font-size: 7pt; }
.sum-table td { padding: 1.5mm 2mm 1.5mm 2mm; }
.sum-table .sum-val { text-align: right; padding-right: 0; width: 16%; }
/* Zahlungshinweis */
.payment-note { margin-top: 5mm; font-size: 9pt; padding: 3mm; background: #f8f8f8; border-left: 2pt solid #2563eb; }
.payment-note { margin-top: 5mm; font-size: 7pt; padding: 3mm; background: #f8f8f8; border-left: 2pt solid #2563eb; }
/* Fußzeile */
.footer-table { width: 100%; border-collapse: collapse; font-size: 7.5pt; border-top: 0.5pt solid #ccc; }
.footer-table { width: 100%; border-collapse: collapse; font-size: 6pt; border-top: 0.5pt solid #ccc; }
.footer-table td { padding: 1mm 2mm; vertical-align: top; }
.footer-table td:nth-child(1) { width: 35%; }
.footer-table td:nth-child(2) { width: 25%; }
.footer-table td:nth-child(3) { width: 28%; }
.page-nr { width: 12%; text-align: right; }
.footer-table td:nth-child(1) { width: 29%; }
.footer-table td:nth-child(2) { width: 29%; }
.footer-table td:nth-child(3) { width: 29%; }
.page-nr { width: 13%; text-align: right; }
CSS;
}
}

View file

@ -8,7 +8,7 @@ $preCustomer = (int)($_GET['customer_id'] ?? 0);
<div class="card">
<div class="card-body">
<form method="post" action="<?= $h($action) ?>">
<form method="post" action="<?= $h($action) ?>" class="invoice-form">
<div class="form-grid form-grid-2 mb-2">
<div class="form-group">
@ -60,7 +60,7 @@ $preCustomer = (int)($_GET['customer_id'] ?? 0);
<div class="mb-2">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:.75rem">
<strong>Rechnungspositionen</strong>
<button type="button" class="btn btn-secondary btn-sm" onclick="addInvoiceLine()">+ Position hinzufügen</button>
<button type="button" class="btn btn-primary btn-sm" onclick="showAddLineDialog(this)">+ Position hinzufügen</button>
</div>
<?php if (isset($errors['lines'])): ?>
@ -68,58 +68,86 @@ $preCustomer = (int)($_GET['customer_id'] ?? 0);
<?php endif; ?>
<div class="table-wrap">
<table>
<table class="invoice-form-table" style="width:calc(100% - 20px);table-layout:fixed">
<colgroup>
<col style="width:3.5em"><!-- Pos. -->
<col><!-- Bezeichnung (flex) -->
<col style="width:5.5em"><!-- Menge -->
<col style="width:6em"><!-- Einheit -->
<col style="width:5em"><!-- USt. -->
<col style="width:8em"><!-- Einzelpreis -->
<col style="width:7em"><!-- Netto -->
<col style="width:7em"><!-- Brutto -->
<col style="width:2.5em"><!-- Löschen -->
</colgroup>
<thead>
<tr>
<th style="width:40px">Pos.</th>
<th>Pos.</th>
<th>Bezeichnung</th>
<th style="width:80px">Menge</th>
<th style="width:80px">Einheit</th>
<th style="width:80px">USt.</th>
<th style="width:110px">Einzelpreis</th>
<th style="width:100px" class="td-right">Netto</th>
<th style="width:110px" class="td-right">Brutto</th>
<th style="width:40px"></th>
<th>Menge</th>
<th>Einheit</th>
<th>USt.</th>
<th>Einzelpreis</th>
<th class="td-right">Netto</th>
<th class="td-right">Brutto</th>
<th></th>
</tr>
</thead>
<tbody id="invoice-lines-body">
<?php foreach ($lines as $i => $line): ?>
<?php
$posIdx = 0;
foreach ($lines as $i => $line):
$isHeading = ($line['type'] ?? 'position') === 'heading';
?>
<?php if ($isHeading): ?>
<tr class="invoice-heading-row">
<td colspan="8">
<input type="hidden" name="lines[<?= $i ?>][type]" value="heading">
<textarea name="lines[<?= $i ?>][bezeichnung]" required placeholder="Überschrift" rows="1" class="line-bez-input" style="width:100%;font-weight:bold"><?= $h($line['bezeichnung']) ?></textarea>
</td>
<td><button type="button" class="btn btn-danger btn-sm" onclick="removeInvoiceLine(this)">✕</button></td>
</tr>
<?php else: ?>
<tr class="invoice-line-row">
<td><span class="line-pos text-muted"><?= $i+1 ?></span></td>
<td><input type="text" name="lines[<?= $i ?>][bezeichnung]" value="<?= $h($line['bezeichnung']) ?>" required style="min-width:200px"></td>
<td><input type="number" name="lines[<?= $i ?>][menge]" value="<?= $h($line['menge']) ?>" min="0.001" step="0.001" style="width:75px" required></td>
<td><span class="line-pos text-muted"><?= ++$posIdx ?></span></td>
<td>
<select name="lines[<?= $i ?>][einheit]" style="width:75px">
<input type="hidden" name="lines[<?= $i ?>][type]" value="position">
<textarea name="lines[<?= $i ?>][bezeichnung]" required rows="1" class="line-bez-input" style="width:100%"><?= $h($line['bezeichnung']) ?></textarea>
</td>
<td><input type="number" name="lines[<?= $i ?>][menge]" value="<?= $h($line['menge']) ?>" min="0.001" step="0.001" style="width:100%" required></td>
<td>
<select name="lines[<?= $i ?>][einheit]" style="width:100%">
<?php foreach (['Stk.','h','Tage','Monat','Pauschale','km'] as $eu): ?>
<option value="<?= $eu ?>"<?= $line['einheit'] === $eu ? ' selected' : '' ?>><?= $eu ?></option>
<?php endforeach; ?>
</select>
</td>
<td>
<select name="lines[<?= $i ?>][ust_satz]" style="width:75px">
<select name="lines[<?= $i ?>][ust_satz]" style="width:100%">
<?php foreach ([19, 7, 0] as $r): ?>
<option value="<?= $r ?>"<?= (float)$line['ust_satz'] === (float)$r ? ' selected' : '' ?>><?= $r ?> %</option>
<?php endforeach; ?>
</select>
</td>
<td><input type="number" name="lines[<?= $i ?>][einzelpreis]" value="<?= $h($line['einzelpreis']) ?>" step="0.01" min="0" style="width:105px" required></td>
<td><input type="number" name="lines[<?= $i ?>][einzelpreis]" value="<?= $h($line['einzelpreis']) ?>" step="0.01" min="0" style="width:100%" required></td>
<td class="td-right line-netto text-muted"><?= View::formatMoney($line['betrag_netto']) ?></td>
<td class="td-right line-brutto font-bold"><?= View::formatMoney($line['betrag_brutto']) ?></td>
<td><button type="button" class="btn btn-danger btn-sm" onclick="removeInvoiceLine(this)">✕</button></td>
</tr>
<?php endif; ?>
<?php endforeach; ?>
</tbody>
</table>
</div>
<!-- Summen -->
<div style="display:flex;justify-content:flex-end;margin-top:.75rem">
<table style="width:280px;font-size:.9rem">
<tr><td>Nettobetrag:</td><td class="td-right" id="total-netto">–</td></tr>
<tr><td>zzgl. Umsatzsteuer:</td><td class="td-right" id="total-ust">–</td></tr>
<tr style="font-size:1rem;font-weight:700">
<td>Rechnungsbetrag:</td>
<td class="td-right" id="total-brutto">–</td>
<div style="display:flex;justify-content:flex-end;margin-top:.75rem;padding-right:.5rem">
<table style="width:auto;font-size:.7rem">
<tr><td style="padding:.3rem 0">Nettobetrag:</td><td class="td-right" style="padding:.3rem 0;width:7.5em" id="total-netto">–</td></tr>
<tr><td style="padding:.3rem 0">zzgl. Umsatzsteuer:</td><td class="td-right" style="padding:.3rem 0" id="total-ust">–</td></tr>
<tr style="font-size:.78rem;font-weight:700">
<td style="padding:.5rem 0">Rechnungsbetrag:</td>
<td class="td-right" style="padding:.5rem 0" id="total-brutto">–</td>
</tr>
</table>
</div>

View file

@ -20,7 +20,7 @@ $badgeClass = match($invoice['status']) {
<!-- Aktionsleiste -->
<div class="toolbar mb-2">
<div class="toolbar-left">
<span class="badge <?= $badgeClass ?>" style="font-size:.9rem;padding:.3rem .8rem"><?= $h($invoice['status']) ?></span>
<span class="badge <?= $badgeClass ?>" style="font-size:.7rem;padding:.3rem .8rem"><?= $h($invoice['status']) ?></span>
</div>
<div class="toolbar-right" style="gap:.5rem">
<?php if ($invoice['status'] === 'Entwurf'): ?>
@ -46,7 +46,9 @@ $badgeClass = match($invoice['status']) {
</div>
<!-- Rechnungsvorschau (DIN 5008 ähnlich) -->
<div id="invoice-pages">
<div class="invoice-preview">
<div class="invoice-page-body">
<!-- Logo -->
<?php if (!empty($settings['logo_path'])): ?>
@ -73,7 +75,7 @@ $badgeClass = match($invoice['status']) {
<!-- Adressbereich (DIN 5008: 50% = 85mm bei vollem A4-Preview) -->
<div class="invoice-address" style="margin-bottom:1.5rem">
<div>
<div style="font-size:8pt;color:#666;border-bottom:1px solid #ccc;padding-bottom:2px;margin-bottom:4px">
<div style="font-size:6pt;color:#666;border-bottom:1px solid #ccc;padding-bottom:2px;margin-bottom:4px">
<?= $h($settings['firma'] ?? '') ?> · <?= $h($settings['strasse'] ?? '') ?> · <?= $h(($settings['plz'] ?? '') . ' ' . ($settings['ort'] ?? '')) ?>
</div>
<div style="line-height:1.5">
@ -83,28 +85,18 @@ $badgeClass = match($invoice['status']) {
<?php if ($invoice['land'] !== 'DE'): ?><br><?= $h($invoice['land']) ?><?php endif; ?>
</div>
</div>
<div style="display:flex;justify-content:flex-end;font-size:.85rem;line-height:1.7">
<div style="text-align:left">
<strong><?= $h($settings['firma'] ?? '') ?></strong><br>
<?= $h($settings['inhaber'] ?? '') ?><br>
<?= $h($settings['strasse'] ?? '') ?><br>
<?= $h(($settings['plz'] ?? '') . ' ' . ($settings['ort'] ?? '')) ?><br>
<?= $h($settings['telefon'] ?? '') ?><br>
<?= $h($settings['email'] ?? '') ?><br>
USt-ID: <?= $h($settings['ust_id'] ?? '') ?>
</div>
</div>
<div style="text-align:right">RECHNUNG</div>
</div>
<!-- Meta-Tabelle -->
<table style="width:100%;border-collapse:collapse;margin-bottom:1.5rem;font-size:.9rem">
<table style="width:100%;border-collapse:collapse;margin-bottom:1.5rem;font-size:.7rem">
<thead>
<tr style="background:#f0f0f0">
<th style="padding:.4rem .5rem;text-align:left;border:1px solid #ddd;font-weight:normal;white-space:nowrap">Betreff</th>
<th style="padding:.4rem .5rem;text-align:left;border:1px solid #ddd;font-weight:normal;white-space:nowrap">Rechnungsnummer</th>
<th style="padding:.4rem .5rem;text-align:left;border:1px solid #ddd;font-weight:normal;white-space:nowrap">Kundennummer</th>
<th style="padding:.4rem .5rem;text-align:left;border:1px solid #ddd;font-weight:normal;white-space:nowrap">Leistungszeitraum</th>
<th style="padding:.4rem .5rem;text-align:left;border:1px solid #ddd;font-weight:normal;white-space:nowrap;width:100%">Datum</th>
<th style="padding:.4rem .5rem;text-align:right;border:1px solid #ddd;font-weight:normal;white-space:nowrap;width:100%">Datum</th>
</tr>
</thead>
<tbody>
@ -113,13 +105,13 @@ $badgeClass = match($invoice['status']) {
<td style="padding:.4rem .5rem;border:1px solid #ddd;white-space:nowrap;font-weight:700"><?= $h($invoice['rechnungs_nr'] ?? 'Entwurf') ?></td>
<td style="padding:.4rem .5rem;border:1px solid #ddd;white-space:nowrap"><?= $h($invoice['kundennummer']) ?></td>
<td style="padding:.4rem .5rem;border:1px solid #ddd"><?= $lzHtml ?: '–' ?></td>
<td style="padding:.4rem .5rem;border:1px solid #ddd;white-space:nowrap"><?= $h(View::formatDate($invoice['rechnungs_datum'])) ?></td>
<td style="padding:.4rem .5rem;border:1px solid #ddd;white-space:nowrap;text-align:right"><?= $h(View::formatDate($invoice['rechnungs_datum'])) ?></td>
</tr>
</tbody>
</table>
<!-- Positionen -->
<table style="width:100%;table-layout:fixed;border-collapse:collapse;margin-bottom:1.5rem;font-size:.9rem">
<table id="invoice-pos-table" style="width:100%;table-layout:fixed;border-collapse:collapse;margin-bottom:1.5rem;font-size:.7rem">
<thead>
<tr style="background:#f0f0f0">
<th style="padding:.5rem;text-align:left;width:6%">Pos.</th>
@ -133,39 +125,48 @@ $badgeClass = match($invoice['status']) {
</thead>
<tbody>
<?php foreach ($lines as $line): ?>
<?php if (($line['type'] ?? 'position') === 'heading'): ?>
<tr style="border-bottom:1px solid #eee">
<td colspan="7" style="padding:.4rem .5rem;font-weight:700"><?= nl2br($h($line['bezeichnung'])) ?></td>
</tr>
<?php else: ?>
<tr style="border-bottom:1px solid #eee">
<td style="padding:.4rem .5rem"><?= $h($line['position']) ?></td>
<td style="padding:.4rem .5rem"><?= $h($line['bezeichnung']) ?></td>
<td style="padding:.4rem .5rem"><?= nl2br($h($line['bezeichnung'])) ?></td>
<td style="padding:.4rem .5rem;text-align:right"><?= number_format((float)$line['menge'], 1, ',', '.') ?></td>
<td style="padding:.4rem .5rem"><?= $h($line['einheit']) ?></td>
<td style="padding:.4rem .5rem;text-align:right"><?= number_format((float)$line['ust_satz'], 0) ?> %</td>
<td style="padding:.4rem .5rem;text-align:right"><?= $fmt($line['einzelpreis']) ?></td>
<td style="padding:.4rem .5rem;text-align:right;font-weight:600"><?= $fmt($line['betrag_brutto']) ?></td>
<td style="padding:.4rem .5rem;text-align:right;font-weight:600"><?= $fmt($line['betrag_netto']) ?></td>
</tr>
<?php endif; ?>
<?php endforeach; ?>
</tbody>
</table>
<!-- Summen -->
<div style="display:flex;justify-content:flex-end;margin-bottom:1.5rem">
<table style="width:260px">
<tr><td style="padding:.3rem 0">Nettobetrag:</td><td style="text-align:right"><?= $fmt($invoice['betrag_netto']) ?></td></tr>
<tr><td style="padding:.3rem 0">zzgl. Umsatzsteuer:</td><td style="text-align:right"><?= $fmt($invoice['betrag_ust']) ?></td></tr>
<tr style="font-weight:700;font-size:1.05rem;border-top:2px solid #000">
<!-- Summen + Zahlungshinweis -->
<div id="invoice-summary">
<div style="display:flex;justify-content:flex-end;margin-bottom:1.5rem;padding-right:.5rem">
<table style="width:auto">
<tr><td style="padding:.3rem 0">Nettobetrag:</td><td style="text-align:right;padding:.3rem 0;width:7.5em"><?= $fmt($invoice['betrag_netto']) ?></td></tr>
<tr><td style="padding:.3rem 0">zzgl. Umsatzsteuer:</td><td style="text-align:right;padding:.3rem 0"><?= $fmt($invoice['betrag_ust']) ?></td></tr>
<tr style="font-weight:700;font-size:.82rem">
<td style="padding:.5rem 0">Rechnungsbetrag:</td>
<td style="text-align:right"><?= $fmt($invoice['betrag_brutto']) ?></td>
<td style="text-align:right;padding:.5rem 0"><?= $fmt($invoice['betrag_brutto']) ?></td>
</tr>
</table>
</div>
<!-- Zahlungshinweis -->
<div style="background:#f0f4ff;border-left:3px solid #2563eb;padding:.75rem 1rem;margin-bottom:2rem;font-size:.9rem">
<div style="background:#f0f4ff;border-left:3px solid #2563eb;padding:.75rem 1rem;margin-bottom:2rem;font-size:.7rem">
Bitte überweisen Sie den Rechnungsbetrag innerhalb <?= $h($invoice['payment_days'] ?? 14) ?> Tagen auf das unten genannte Konto.
<strong>Fällig am: <?= View::formatDate($invoice['faellig_datum']) ?></strong>
</div>
</div>
<!-- Fußzeile -->
<div style="border-top:1px solid #ccc;padding-top:.75rem;display:grid;grid-template-columns:auto auto auto 1fr;gap:1rem;font-size:.75rem;color:#555">
</div><!-- /.invoice-page-body -->
<!-- Fußzeile -->
<div class="invoice-footer-bar">
<div style="border-top:1px solid #ccc;padding-top:.75rem;display:grid;grid-template-columns:1fr 1fr 1fr auto;gap:1rem;font-size:.58rem;color:#555">
<div style="min-width:0">
<strong><?= $h($settings['firma'] ?? '') ?></strong><br>
<?= $h($settings['inhaber'] ?? '') ?><br>
@ -173,17 +174,140 @@ $badgeClass = match($invoice['status']) {
<?= $h(($settings['plz'] ?? '') . ' ' . ($settings['ort'] ?? '')) ?><br>
USt-ID: <?= $h($settings['ust_id'] ?? '') ?>
</div>
<div style="min-width:0">
<div style="white-space:nowrap">
Tel: <?= $h($settings['telefon'] ?? '') ?><br>
E-Mail: <?= $h($settings['email'] ?? '') ?><br>
Web: <?= $h($settings['website'] ?? '') ?>
</div>
<div style="min-width:0">
<div style="white-space:nowrap">
<?= $h($settings['bank_name'] ?? '') ?><br>
IBAN: <?= $h($settings['iban'] ?? '') ?><br>
BIC: <?= $h($settings['bic'] ?? '') ?>
</div>
<div style="text-align:right">Seite 1 von 1</div>
<div style="text-align:right;white-space:nowrap">Seite <span class="page-num">1</span> von <span class="page-total">1</span></div>
</div>
</div>
</div><!-- /.invoice-preview -->
</div><!-- /#invoice-pages -->
<script>
(function () {
'use strict';
// 1cm in px: Referenzelement messen, damit Browserzoom berücksichtigt wird
function getCmPx() {
var d = document.createElement('div');
d.style.cssText = 'position:absolute;visibility:hidden;width:1cm';
document.body.appendChild(d);
var px = d.offsetWidth;
document.body.removeChild(d);
return px;
}
function paginate() {
var wrap = document.getElementById('invoice-pages');
var page1 = wrap.querySelector('.invoice-preview');
var body1 = page1.querySelector('.invoice-page-body');
var footer1 = page1.querySelector('.invoice-footer-bar');
var posTable = document.getElementById('invoice-pos-table');
var summary = document.getElementById('invoice-summary');
if (!posTable || !summary) return;
var cmPx = getCmPx();
var a4H = 29.7 * cmPx;
var padTop = parseFloat(getComputedStyle(page1).paddingTop);
var padBot = parseFloat(getComputedStyle(page1).paddingBottom);
var footerH = footer1.offsetHeight;
var availH = a4H - padTop - padBot - footerH;
// Passt alles auf Seite 1?
if (body1.offsetHeight <= availH) {
return;
}
// Alle Zeilen sichern, tbody leeren
var tbody = posTable.querySelector('tbody');
var rows = Array.from(tbody.querySelectorAll('tr'));
rows.forEach(function (r) { tbody.removeChild(r); });
// summary aus body1 herauslösen
body1.removeChild(summary);
// Hilfsfunktionen
function makeFooter() {
var f = footer1.cloneNode(true);
return f;
}
function makePage() {
var p = document.createElement('div');
p.className = 'invoice-preview';
var b = document.createElement('div');
b.className = 'invoice-page-body';
p.appendChild(b);
p.appendChild(makeFooter());
wrap.appendChild(p);
return b;
}
function makeContTable(targetBody) {
var tbl = posTable.cloneNode(false);
tbl.removeAttribute('id');
var thead = posTable.querySelector('thead').cloneNode(true);
var tb = document.createElement('tbody');
tbl.appendChild(thead);
tbl.appendChild(tb);
targetBody.appendChild(tbl);
return tb;
}
// Zeilen seitenweise verteilen
var pages = [page1];
var currentBody = body1;
var currentTbody = tbody; // zeigt auf tbody in posTable auf Seite 1
for (var i = 0; i < rows.length; i++) {
currentTbody.appendChild(rows[i]);
var currentPage = pages[pages.length - 1];
var usedH = currentBody.offsetHeight;
if (usedH > availH) {
// Zeile wieder entfernen, neue Seite beginnen
currentTbody.removeChild(rows[i]);
var newBody = makePage();
pages.push(newBody.parentNode); // .invoice-preview
currentBody = newBody;
currentTbody = makeContTable(newBody);
currentTbody.appendChild(rows[i]);
}
}
// summary auf letzte Seite; falls dort kein Platz, nochmal neue Seite
currentBody.appendChild(summary);
var lastPage = pages[pages.length - 1];
if (currentBody.offsetHeight > availH) {
var extraBody = makePage();
pages.push(extraBody.parentNode);
extraBody.appendChild(summary);
}
// Seitenzahlen aktualisieren
var total = wrap.querySelectorAll('.invoice-preview').length;
wrap.querySelectorAll('.page-num').forEach(function (el, i) {
el.textContent = String(i + 1);
});
wrap.querySelectorAll('.page-total').forEach(function (el) {
el.textContent = String(total);
});
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', paginate);
} else {
paginate();
}
}());
</script>

View file

@ -1 +1 @@
1-260426
1-260427