00f07d76f6
Sicherheits-Hardening - Passwort-Hashing mit PBKDF2 (SHA-256, 100k Iterationen) inkl. transparenter Migration bestehender Klartext-Passwörter beim ersten Login - Login Brute-Force-Schutz (5 Fehlversuche → 60s Lockout), Constant-Time-Compare, Mindestpasswortlänge 8 Zeichen - HTML-Sanitizer für Brieftexte (Allowlist, entfernt javascript:/data:/vbscript:-URLs, Event-Handler, Script-Tags; rel=noopener für target=_blank) - Datenexport entfernt Legacy-Klartextpasswörter (Hashes bleiben) - Kryptografische IDs via crypto.randomUUID statt Math.random - sessionStorage speichert keine Credentials mehr GUI & Performance - Code-Splitting pro View via React.lazy + Suspense (Initial-Bundle 86 KB gzipped) - swissqrbill als lokale Dependency — QR-Rechnungen offline-fähig - Spesenbelege (Bild/PDF) direkt in der Tageserfassung mit Bildkomprimierung - Avatar-Upload: 256px-Skalierung + JPEG-Kompression, Typprüfung - Über-Rapport-Modal, einheitliche Bearbeiten-Icons, Pinnwand-Kategorien als Pills Bug-Fixes - Auto-überfällig-Routine läuft nur noch einmal pro Tag (verhindert Re-Render-Loop) Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
375 lines
21 KiB
React
Executable File
375 lines
21 KiB
React
Executable File
import React, { useState } from "react";
|
|
import { formatCHF, formatDate, exportBuchhaltungCSV } from "../utils.js";
|
|
import { Header, StatusBadge } from "../components/UI.jsx";
|
|
import { MahnModal } from "./Protocols.jsx";
|
|
import { ReceiptViewer } from "./Expenses.jsx";
|
|
|
|
export default
|
|
function Accounting({ data, update, setView, setPrintContent }) {
|
|
const mwstRate = data.settings.mwstRate || 8.1;
|
|
const currentYear = new Date().getFullYear().toString();
|
|
const [filterYear, setFilterYear] = useState(currentYear);
|
|
|
|
const availableYears = Array.from(new Set([
|
|
...data.invoices.map(i => (i.date || "").slice(0, 4)),
|
|
...(data.expenses || []).map(e => (e.date || "").slice(0, 4)),
|
|
].filter(Boolean))).sort().reverse();
|
|
if (!availableYears.includes(currentYear)) availableYears.unshift(currentYear);
|
|
|
|
// Gefilterte Daten
|
|
const invoices = data.invoices.filter(i => !filterYear || (i.date || "").startsWith(filterYear));
|
|
const expenses = (data.expenses || []).filter(e => !filterYear || (e.date || "").startsWith(filterYear));
|
|
const lohnEntries = (data.lohnEntries || []).filter(l => !filterYear || l.monat.startsWith(filterYear));
|
|
|
|
// Einnahmen
|
|
const totalInvoiced = invoices.reduce((s, i) => s + (i.sub || 0), 0);
|
|
const totalTax = invoices.reduce((s, i) => s + (i.tax || 0), 0);
|
|
// Akonto-MwSt ist erst bei Schlussrechnung steuerrelevant — separat ausweisen
|
|
const akontoInvoices = invoices.filter(i => i.invoiceKind === "akonto");
|
|
const akontoTax = akontoInvoices.reduce((s, i) => s + (i.tax || 0), 0);
|
|
const akontoSub = akontoInvoices.reduce((s, i) => s + (i.sub || 0), 0);
|
|
const taxWithoutAkonto = totalTax - akontoTax;
|
|
const totalPaid = invoices.filter(i => i.status === "bezahlt").reduce((s, i) => s + (i.total || 0), 0);
|
|
const totalOpen = invoices.filter(i => i.status === "gesendet" || i.status === "entwurf").reduce((s, i) => s + (i.total || 0), 0);
|
|
const totalOverdue = invoices.filter(i => i.status === "überfällig").reduce((s, i) => s + (i.total || 0), 0);
|
|
const totalDraftSub = invoices.filter(i => i.status === "entwurf").reduce((s, i) => s + (i.sub || 0), 0);
|
|
const totalBilledSub = totalInvoiced - totalDraftSub;
|
|
|
|
// Ausgaben Spesen
|
|
const totalExpBrutto = expenses.reduce((s, e) => s + (e.amount || 0), 0);
|
|
const totalExpNet = expenses.reduce((s, e) => { const net = e.inclMwst ? e.amount / (1 + (e.mwstRate || 0) / 100) : e.amount; return s + net; }, 0);
|
|
const totalExpTax = totalExpBrutto - totalExpNet;
|
|
|
|
// Interne Ausgaben
|
|
const internalExpenses = (data.internalExpenses || []).filter(e => !filterYear || (e.date || "").startsWith(filterYear));
|
|
const totalIntExpBrutto = internalExpenses.reduce((s, e) => s + (e.amount || 0), 0);
|
|
const totalIntExpNet = internalExpenses.reduce((s, e) => { const net = e.inclMwst ? e.amount / (1 + (e.mwstRate || 0) / 100) : e.amount; return s + net; }, 0);
|
|
const totalIntExpTax = totalIntExpBrutto - totalIntExpNet;
|
|
|
|
// Personalaufwand (abgeschlossene Lohnabrechnungen — Auszahlung an MA + AG-Abgaben)
|
|
const totalLoehne = lohnEntries.reduce((s, l) => s + (l.auszahlung || 0), 0);
|
|
const totalLoehneAGAbgaben = lohnEntries.reduce((s, l) => s + (l.bvgAG || 0), 0);
|
|
const totalLoehneGesamt = totalLoehne + totalLoehneAGAbgaben;
|
|
const totalLoehneAnzahl = lohnEntries.length;
|
|
|
|
const totalAusgaben = totalExpNet + totalIntExpNet + totalLoehneGesamt;
|
|
const result = totalInvoiced - totalAusgaben;
|
|
|
|
// Überfällige Rechnungen für Mahnwesen
|
|
const today = new Date().toISOString().slice(0, 10);
|
|
const [mahnModal, setMahnModal] = useState(null);
|
|
const [mahnMode, setMahnMode] = useState("new");
|
|
const [mahnSentDate, setMahnSentDate] = useState(new Date().toISOString().slice(0, 10));
|
|
const [receiptView, setReceiptView] = useState(null);
|
|
|
|
const overdueInvoices = data.invoices.filter(i =>
|
|
(i.status === "gesendet" || i.status === "überfällig") && i.dueDate && i.dueDate < today
|
|
).sort((a, b) => (a.dueDate || "").localeCompare(b.dueDate || ""));
|
|
|
|
const sendReminder = (inv) => {
|
|
const reminders = inv.reminders || [];
|
|
setMahnMode(reminders.length === 0 ? "new" : "reprint");
|
|
setMahnSentDate(new Date().toISOString().slice(0, 10));
|
|
setMahnModal({ inv });
|
|
};
|
|
|
|
return (
|
|
<div>
|
|
<Header title="Buchhaltung" action={
|
|
<div style={{ display: "flex", gap: 8, alignItems: "center" }}>
|
|
<select className="pill" value={filterYear} onChange={e => setFilterYear(e.target.value)}>
|
|
<option value="">Alle Jahre</option>
|
|
{availableYears.map(y => <option key={y} value={y}>{y}</option>)}
|
|
</select>
|
|
<button className="btn btn-ghost" onClick={() => exportBuchhaltungCSV(data, filterYear)}>↓ CSV</button>
|
|
<button className="btn btn-ghost" onClick={() => setPrintContent({ type: "buchhaltung", data, filterYear, settings: data.settings })}>↓ PDF</button>
|
|
</div>
|
|
} />
|
|
|
|
{/* KPI-Karten */}
|
|
<div className="responsive-grid-4" style={{ display: "grid", gridTemplateColumns: "repeat(4, 1fr)", gap: 16, marginBottom: 28 }}>
|
|
{[
|
|
{ label: "UMSATZ NETTO", value: formatCHF(totalInvoiced), sub: totalDraftSub > 0 ? `${invoices.length} Rechnungen — davon ${formatCHF(totalDraftSub)} erwartet` : `${invoices.length} Rechnungen`, color: "#2d6a4f" },
|
|
{ label: "DAVON BEZAHLT", value: formatCHF(totalPaid), sub: "eingegangen", color: "#2d6a4f" },
|
|
{ label: "OFFEN / ÜBERFÄLLIG", value: formatCHF(totalOpen + totalOverdue), sub: `${overdueInvoices.length} überfällig`, color: totalOverdue > 0 ? "#8a1a1a" : "#7a6a00" },
|
|
{ label: "AUSGABEN TOTAL", value: formatCHF(totalAusgaben), sub: `Spesen + ${totalLoehneAnzahl} Lohnabrechnungen (inkl. AG-Abgaben)`, color: "#555" },
|
|
].map(c => (
|
|
<div key={c.label} className="card" style={{ borderTop: `3px solid ${c.color}` }}>
|
|
<div style={{ fontSize: 10, color: "#888", letterSpacing: "0.12em", marginBottom: 8 }}>{c.label}</div>
|
|
<div style={{ fontSize: 22, fontFamily: "'Playfair Display', serif", fontWeight: 700, color: c.color }}>{c.value}</div>
|
|
<div style={{ fontSize: 11, color: "#aaa", marginTop: 4 }}>{c.sub}</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 20, marginBottom: 28 }}>
|
|
{/* Ergebnis-Übersicht */}
|
|
<div className="card">
|
|
<div className="section-label" style={{ marginBottom: 16 }}>JAHRESERGEBNIS {filterYear || "GESAMT"}</div>
|
|
{[
|
|
{ label: "Einnahmen (Netto)", value: totalInvoiced, bold: false },
|
|
totalDraftSub > 0 && { label: "→ Davon fakturiert", value: totalBilledSub, note: true },
|
|
totalDraftSub > 0 && { label: "→ Davon erwartet (Entwürfe)", value: totalDraftSub, note: true, expected: true },
|
|
{ label: "Spesen (Netto)", value: -totalExpNet, bold: false },
|
|
{ label: "Interne Ausgaben (Netto)", value: -totalIntExpNet, bold: false },
|
|
{ label: "Personalaufwand (Löhne)", value: -totalLoehne, bold: false },
|
|
totalLoehneAGAbgaben > 0 && { label: "→ AG-Sozialabgaben (PK/BVG)", value: -totalLoehneAGAbgaben, note: true },
|
|
{ label: "Ergebnis vor MWST", value: result, bold: true, sep: true },
|
|
{ label: `MWST auf Einnahmen (${mwstRate}%, excl. Akonto)`, value: taxWithoutAkonto, bold: false, small: true },
|
|
akontoTax > 0 && { label: `↳ Akonto-MWST ausstehend (bei Schlussrechn.)`, value: akontoTax, bold: false, small: true, pending: true },
|
|
{ label: "Vorsteuer (Spesen + Ausgaben)", value: -(totalExpTax + totalIntExpTax), bold: false, small: true },
|
|
{ label: "MWST-Schuld (excl. Akonto)", value: taxWithoutAkonto - totalExpTax - totalIntExpTax, bold: true, small: true },
|
|
].filter(Boolean).map((row, i) => (
|
|
<div key={i} style={{
|
|
display: "flex", justifyContent: "space-between",
|
|
padding: row.sep ? "10px 0 4px" : row.note ? "2px 0 2px 12px" : "4px 0",
|
|
borderTop: row.sep ? "1.5px solid #1a1a18" : "none",
|
|
marginTop: row.sep ? 8 : 0,
|
|
}}>
|
|
<span style={{ fontSize: row.small ? 11 : row.note ? 11 : 13, color: row.expected ? "#b5621e" : row.pending ? "#b07848" : row.small || row.note ? "#888" : "#555", fontStyle: row.note ? "italic" : "normal" }}>
|
|
{row.label}
|
|
{row.expected && <span style={{ fontSize: 10, marginLeft: 4, background: "#fdf0e8", color: "#b5621e", padding: "1px 8px", borderRadius: 20, fontStyle: "normal", fontWeight: 600 }}>noch nicht fakturiert</span>}
|
|
{row.pending && <span style={{ fontSize: 10, marginLeft: 4, background: "#fff8ed", color: "#b07848", padding: "1px 8px", borderRadius: 20, fontStyle: "normal", fontWeight: 600 }}>ausstehend</span>}
|
|
</span>
|
|
<span style={{ fontSize: row.small || row.note ? 11 : 13, fontWeight: row.bold ? 700 : 400, color: row.expected ? "#b5621e" : row.pending ? "#b07848" : row.value < 0 ? "#8a1a1a" : row.bold ? "#1a1a18" : "#888", fontFamily: row.bold ? "'Playfair Display', serif" : "inherit" }}>
|
|
{formatCHF(row.value)}
|
|
</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
{/* Monatsumsatz */}
|
|
<div className="card">
|
|
<div className="section-label" style={{ marginBottom: 4 }}>MONATSUMSATZ {filterYear || new Date().getFullYear()}</div>
|
|
{(() => {
|
|
const year = filterYear || String(new Date().getFullYear());
|
|
const months = ["Jan","Feb","Mär","Apr","Mai","Jun","Jul","Aug","Sep","Okt","Nov","Dez"];
|
|
const monthData = months.map((label, i) => {
|
|
const key = `${year}-${String(i + 1).padStart(2, "0")}`;
|
|
const invs = data.invoices.filter(inv => (inv.date || "").startsWith(key));
|
|
const paid = invs.filter(inv => inv.status === "bezahlt").reduce((s, inv) => s + (inv.sub || 0), 0);
|
|
const open = invs.filter(inv => inv.status === "gesendet" || inv.status === "überfällig").reduce((s, inv) => s + (inv.sub || 0), 0);
|
|
const draft = invs.filter(inv => inv.status === "entwurf").reduce((s, inv) => s + (inv.sub || 0), 0);
|
|
return { label, key, paid, open, draft, total: paid + open + draft };
|
|
});
|
|
const maxVal = Math.max(...monthData.map(m => m.total), 1);
|
|
const yearTotal = monthData.reduce((s, m) => s + m.total, 0);
|
|
const currentMonth = new Date().toISOString().slice(0, 7);
|
|
return (
|
|
<>
|
|
<div style={{ fontSize: 11, color: "#aaa", marginBottom: 16 }}>
|
|
Total {formatCHF(yearTotal)} · {data.invoices.filter(i => (i.date||"").startsWith(year)).length} Rechnungen
|
|
</div>
|
|
<div style={{ display: "flex", alignItems: "flex-end", gap: 4, height: 80, marginBottom: 8 }}>
|
|
{monthData.map(m => (
|
|
<div key={m.key} style={{ flex: 1, display: "flex", flexDirection: "column", alignItems: "center", gap: 0 }}>
|
|
<div style={{ width: "100%", display: "flex", flexDirection: "column", justifyContent: "flex-end", height: 72 }}>
|
|
{m.total === 0 ? (
|
|
<div style={{ height: 2, background: "#ece8e2", borderRadius: 1 }} />
|
|
) : (
|
|
<>
|
|
{m.draft > 0 && <div style={{ height: `${(m.draft / maxVal) * 70}px`, background: "#ccc", borderRadius: "2px 2px 0 0", minHeight: 2 }} title={`Entwurf: ${formatCHF(m.draft)}`} />}
|
|
{m.open > 0 && <div style={{ height: `${(m.open / maxVal) * 70}px`, background: "#b07848", minHeight: 2 }} title={`Ausstehend: ${formatCHF(m.open)}`} />}
|
|
{m.paid > 0 && <div style={{ height: `${(m.paid / maxVal) * 70}px`, background: "#2d6a4f", borderRadius: m.draft === 0 && m.open === 0 ? "2px 2px 0 0" : 0, minHeight: 2 }} title={`Bezahlt: ${formatCHF(m.paid)}`} />}
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
<div style={{ display: "flex", gap: 4 }}>
|
|
{monthData.map(m => (
|
|
<div key={m.key} style={{
|
|
flex: 1, textAlign: "center", fontSize: 9, color: m.key === currentMonth ? "#1a1a18" : "#aaa",
|
|
fontWeight: m.key === currentMonth ? 700 : 400,
|
|
}}>{m.label}</div>
|
|
))}
|
|
</div>
|
|
<div style={{ display: "flex", gap: 14, marginTop: 14, paddingTop: 12, borderTop: "1px solid #ece8e2" }}>
|
|
{[
|
|
{ color: "#2d6a4f", label: "Bezahlt" },
|
|
{ color: "#b07848", label: "Ausstehend" },
|
|
{ color: "#ccc", label: "Entwurf" },
|
|
].map(l => (
|
|
<div key={l.label} style={{ display: "flex", alignItems: "center", gap: 5, fontSize: 10, color: "#888" }}>
|
|
<div style={{ width: 10, height: 10, background: l.color, borderRadius: 2 }} />
|
|
{l.label}
|
|
</div>
|
|
))}
|
|
</div>
|
|
</>
|
|
);
|
|
})()}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Mahnwesen */}
|
|
<div className="card" style={{ marginBottom: 28 }}>
|
|
<div className="section-label" style={{ marginBottom: overdueInvoices.length ? 16 : 0 }}>
|
|
MAHNWESEN — ÜBERFÄLLIGE RECHNUNGEN
|
|
{overdueInvoices.length === 0 && <span style={{ marginLeft: 12, color: "#2d6a4f", fontWeight: 400 }}>✓ Keine überfälligen Rechnungen</span>}
|
|
</div>
|
|
{overdueInvoices.length > 0 && (
|
|
<table>
|
|
<thead><tr>
|
|
<th>Nr.</th><th>Kunde</th><th>Fällig seit</th><th>Mahnungen</th><th style={{ textAlign: "right" }}>Betrag</th><th></th>
|
|
</tr></thead>
|
|
<tbody>
|
|
{overdueInvoices.map(inv => {
|
|
const client = ((data.persons||[]).filter(p=>p.isAuftraggeber)).find(c => c.id === inv.clientId);
|
|
const daysPast = Math.floor((new Date() - new Date(inv.dueDate)) / 86400000);
|
|
const reminders = inv.reminders || [];
|
|
const nextNr = reminders.length + 1;
|
|
const mahnLabel = nextNr === 1 ? "Zahlungserinnerung" : `${nextNr}. Mahnung`;
|
|
const mahnColor = nextNr >= 3 ? "#8a1a1a" : nextNr === 2 ? "#b5621e" : "#7a6a00";
|
|
return (
|
|
<tr key={inv.id}>
|
|
<td><strong>{inv.number}</strong></td>
|
|
<td>{client?.name || "—"}</td>
|
|
<td>
|
|
<span style={{ color: daysPast > 30 ? "#8a1a1a" : "#b5621e", fontWeight: 500 }}>
|
|
{formatDate(inv.dueDate)} ({daysPast} Tage)
|
|
</span>
|
|
</td>
|
|
<td>
|
|
{reminders.length === 0 ? (
|
|
<span style={{ fontSize: 11, color: "#aaa" }}>Keine</span>
|
|
) : (
|
|
<div style={{ display: "flex", flexDirection: "column", gap: 2 }}>
|
|
{reminders.map((r, i) => (
|
|
<span key={i} style={{ fontSize: 11, color: i === reminders.length - 1 ? "#b5621e" : "#aaa" }}>
|
|
{i === 0 ? "Erinnerung" : `${i + 1}. Mahnung`} · {formatDate(r.date)}
|
|
</span>
|
|
))}
|
|
</div>
|
|
)}
|
|
</td>
|
|
<td style={{ textAlign: "right" }}><strong>{formatCHF(inv.total)}</strong></td>
|
|
<td style={{ textAlign: "right", whiteSpace: "nowrap" }}>
|
|
<button className="btn btn-ghost" style={{ fontSize: 12, padding: "5px 12px", borderColor: mahnColor, color: mahnColor }}
|
|
onClick={() => sendReminder(inv)}>
|
|
✉ {mahnLabel}
|
|
</button>
|
|
</td>
|
|
</tr>
|
|
);
|
|
})}
|
|
</tbody>
|
|
</table>
|
|
)}
|
|
</div>
|
|
|
|
{/* Letzte Rechnungen */}
|
|
<div className="card">
|
|
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 16 }}>
|
|
<div className="section-label" style={{ marginBottom: 0 }}>LETZTE RECHNUNGEN</div>
|
|
<button className="btn btn-ghost" style={{ fontSize: 12, padding: "4px 12px" }} onClick={() => setView("invoices")}>Alle anzeigen →</button>
|
|
</div>
|
|
<table>
|
|
<thead><tr><th>Nr.</th><th>Kunde</th><th>Datum</th><th style={{ textAlign: "right" }}>Betrag</th><th>Status</th></tr></thead>
|
|
<tbody>
|
|
{[...data.invoices].sort((a, b) => (b.date || "").localeCompare(a.date || "")).slice(0, 6).map(inv => {
|
|
const client = ((data.persons||[]).filter(p=>p.isAuftraggeber)).find(c => c.id === inv.clientId);
|
|
return (
|
|
<tr key={inv.id}>
|
|
<td><strong>{inv.number}</strong></td>
|
|
<td>{client?.name || "—"}</td>
|
|
<td>{formatDate(inv.date)}</td>
|
|
<td style={{ textAlign: "right" }}><strong>{formatCHF(inv.total)}</strong></td>
|
|
<td><StatusBadge status={inv.status} /></td>
|
|
</tr>
|
|
);
|
|
})}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
{/* Spesen & Belege */}
|
|
{expenses.length > 0 && (
|
|
<div className="card" style={{ marginTop: 28 }}>
|
|
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 16 }}>
|
|
<div className="section-label" style={{ marginBottom: 0 }}>
|
|
SPESEN {filterYear || ""}
|
|
<span style={{ marginLeft: 10, fontWeight: 400, color: "#888", fontSize: 11 }}>
|
|
{expenses.filter(e => e.receiptData).length} von {expenses.length} mit Beleg
|
|
</span>
|
|
</div>
|
|
<button className="btn btn-ghost" style={{ fontSize: 12, padding: "4px 12px" }} onClick={() => setView && setView("expenses")}>
|
|
Alle anzeigen →
|
|
</button>
|
|
</div>
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
<th style={{ width: 100 }}>Datum</th>
|
|
<th style={{ width: 160 }}>Kategorie</th>
|
|
<th>Beschreibung</th>
|
|
<th style={{ width: 120 }}>Mitarbeiter</th>
|
|
<th style={{ textAlign: "right", width: 120 }}>Brutto</th>
|
|
<th style={{ width: 80, textAlign: "center" }}>Beleg</th>
|
|
<th style={{ width: 110 }}>Status</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{[...expenses].sort((a, b) => (b.date || "").localeCompare(a.date || "")).map(e => {
|
|
const emp = (data.employees || []).find(em => em.id === e.employeeId);
|
|
const expStatus = e.status || "offen";
|
|
const statusColors = { offen: "#b07848", genehmigt: "#2d6a4f", "auf nächsten Lohn": "#1a4e8a", ausbezahlt: "#2d6a4f" };
|
|
return (
|
|
<tr key={e.id}>
|
|
<td>{formatDate(e.date)}</td>
|
|
<td style={{ fontSize: 12 }}>{e.category}</td>
|
|
<td style={{ color: "#555" }}>{e.description || "—"}</td>
|
|
<td style={{ color: "#888", fontSize: 12 }}>{emp?.name || "—"}</td>
|
|
<td style={{ textAlign: "right", fontWeight: 600 }}>{formatCHF(e.amount)}</td>
|
|
<td style={{ textAlign: "center" }}>
|
|
{e.receiptData ? (
|
|
<button
|
|
className="btn btn-ghost btn-sm"
|
|
title={e.receiptName || "Beleg anzeigen"}
|
|
onClick={() => setReceiptView(e)}
|
|
style={{ lineHeight: 1, padding: "3px 6px" }}
|
|
><span className="material-icons" style={{ fontSize: 16, verticalAlign: "middle" }}>receipt_long</span></button>
|
|
) : (
|
|
<span style={{ color: "#ddd", fontSize: 12 }}>—</span>
|
|
)}
|
|
</td>
|
|
<td>
|
|
<span style={{ fontSize: 11, fontWeight: 600, color: statusColors[expStatus] || "#888" }}>
|
|
{expStatus.charAt(0).toUpperCase() + expStatus.slice(1)}
|
|
</span>
|
|
</td>
|
|
</tr>
|
|
);
|
|
})}
|
|
</tbody>
|
|
<tfoot>
|
|
<tr style={{ borderTop: "2px solid #1a1a18" }}>
|
|
<td colSpan={4} style={{ color: "#888", fontSize: 12 }}>{expenses.length} Einträge</td>
|
|
<td style={{ textAlign: "right", fontWeight: 700 }}>{formatCHF(totalExpBrutto)}</td>
|
|
<td colSpan={2}></td>
|
|
</tr>
|
|
</tfoot>
|
|
</table>
|
|
</div>
|
|
)}
|
|
|
|
{/* Mahnung-Dialog */}
|
|
{mahnModal && (
|
|
<MahnModal
|
|
inv={mahnModal.inv}
|
|
data={data}
|
|
update={update}
|
|
setPrintContent={setPrintContent}
|
|
onClose={() => setMahnModal(null)}
|
|
mahnMode={mahnMode}
|
|
setMahnMode={setMahnMode}
|
|
mahnSentDate={mahnSentDate}
|
|
setMahnSentDate={setMahnSentDate}
|
|
/>
|
|
)}
|
|
<ReceiptViewer expense={receiptView} onClose={() => setReceiptView(null)} />
|
|
</div>
|
|
);
|
|
}
|