Files
RAPPORT/src/print/PrintComponents.jsx
T
karim 00f07d76f6 Rapport 0.6 — Initial Public Release
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>
2026-05-13 01:16:26 +02:00

1660 lines
93 KiB
React
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { useState, useEffect } from "react";
import { SIA_PHASES, PROJECT_TYPES, STATUS_COLORS } from "../constants.js";
import { calcSIAHours, calcManualHours, formatCHF, formatDate, formatHours, formatSenderAddress, isQRIban, generateQRReference, formatIban, buildPdfName, sanitizeHtml } from "../utils.js";
import { StudioLogo } from "../components/UI.jsx";
export
function PrintView({ content, onClose, settings }) {
const triggerPrint = async () => {
const pdfName = buildPdfName(settings?.pdfNameFormat, content, settings);
const prevTitle = document.title;
document.title = pdfName;
try {
if (window.__TAURI_INTERNALS__) {
const { getCurrentWebviewWindow } = await import("@tauri-apps/api/webviewWindow");
await getCurrentWebviewWindow().print();
} else {
window.print();
}
} catch {
window.print();
} finally {
setTimeout(() => { document.title = prevTitle; }, 2000);
}
};
useEffect(() => {
if (!settings.autoPrint) return;
const timer = setTimeout(() => triggerPrint(), 400);
return () => clearTimeout(timer);
}, [settings.autoPrint]);
const mTop = settings?.pageMarginTop ?? 20;
const mBottom = settings?.pageMarginBottom ?? 20;
const mLeft = settings?.pageMarginLeft ?? 20;
const mRight = settings?.pageMarginRight ?? 20;
const isQrOnly = content.type === "qrbill";
return (
<div className="print-wrapper" style={{ background: "#d6d2cc", minHeight: "100vh", padding: "20px 0" }}>
<style>{`
@import url('https://fonts.googleapis.com/css2?family=Archivo+Black&family=DM+Mono&family=Playfair+Display:wght@400;700&display=swap');
:root { color-scheme: light; }
.print-page { -webkit-print-color-adjust: exact; print-color-adjust: exact; }
@page { size: ${isQrOnly ? "210mm 105mm" : "A4"}; margin: 0; }
.print-page, .print-page * { text-align: left; }
.print-page .align-right, .print-page .align-right * { text-align: right; }
.qr-bill-wrapper {
display: flex;
justify-content: center;
margin: 10mm -${mLeft}mm 0 -${mLeft}mm;
width: 210mm;
}
@media print {
:root { color-scheme: light; }
html, body, #root, #root > div, .print-wrapper { background: white !important; height: auto !important; min-height: 0 !important; margin: 0 !important; padding: 0 !important; }
.no-print { display: none !important; }
.print-page { box-shadow: none !important; margin: 0 !important; padding-top: ${mTop}mm !important; padding-bottom: ${mBottom}mm !important; padding-left: ${mLeft}mm !important; padding-right: ${mRight}mm !important; max-width: 100% !important; width: 100% !important; -webkit-print-color-adjust: exact !important; print-color-adjust: exact !important; }
.print-page, .print-page * { text-align: left !important; }
.print-page .align-right, .print-page .align-right * { text-align: right !important; }
.qr-bill-wrapper { margin: 0 -${mLeft}mm 0 -${mLeft}mm !important; width: 210mm !important; }
.qr-bill-newpage { page-break-before: always !important; break-before: page !important; }
.qr-bill-wrapper > div { width: 210mm !important; }
.qr-bill-wrapper svg { width: 210mm !important; }
}
`}</style>
<div className="no-print" style={{ maxWidth: 800, margin: "0 auto 14px", display: "flex", gap: 10, padding: "0 20px" }}>
<button onClick={onClose} style={{ padding: "9px 18px", background: "#2a2a22", color: "#fff", border: "none", borderRadius: 4, cursor: "pointer", fontFamily: "DM Mono, monospace", fontSize: 13 }}> Zurück</button>
<button onClick={triggerPrint} style={{ padding: "9px 18px", background: "#252520", color: "#f0ede8", border: "none", borderRadius: 4, cursor: "pointer", fontFamily: "DM Mono, monospace", fontSize: 13, fontWeight: 500 }}>Drucken / PDF</button>
</div>
<div className="print-page" style={{
maxWidth: "210mm",
margin: "0 auto",
background: "#fff",
padding: isQrOnly ? 0 : `${mTop}mm ${mRight}mm ${mBottom}mm ${mLeft}mm`,
minHeight: "auto",
boxShadow: "0 4px 20px rgba(0,0,0,0.1)",
fontFamily: "'DM Mono', monospace",
fontSize: 11,
lineHeight: 1.6,
color: "#1a1a18"
}}>
{content.type === "invoice" && <InvoicePrint inv={content.inv} client={content.client} settings={settings} />}
{content.type === "invoice+qr" && (
<>
<InvoicePrint inv={content.inv} client={content.client} settings={settings} />
<div className={`qr-bill-wrapper${settings?.qrNewPage !== false ? " qr-bill-newpage" : ""}`}>
<QRBillPrint inv={content.inv} client={content.client} settings={settings} />
</div>
</>
)}
{content.type === "letter" && <LetterPrint client={content.client} subject={content.subject} body={content.body} isHtml={content.isHtml} settings={settings} />}
{content.type === "projectDetail" && <ProjectDetailPrint content={content} settings={settings} />}
{content.type === "projectsOverview" && <ProjectsOverviewPrint projects={content.projects} data={content.data} settings={settings} />}
{content.type === "qrbill" && <QRBillPrint inv={content.inv} client={content.client} settings={settings} />}
{content.type === "quote" && <QuotePrint quote={content.quote} client={content.client} settings={settings} />}
{content.type === "buchhaltung" && <BuchhaltungPrint data={content.data} filterYear={content.filterYear} settings={settings} />}
{content.type === "lohn" && <LohnPrint entry={content.entry} emp={content.emp} data={content.data} monatLabel={content.monatLabel} settings={settings} />}
{content.type === "lieferschein" && <LieferscheinPrint note={content.note} client={content.client} data={content.data} settings={settings} />}
{content.type === "studioBudget" && <StudioBudgetPrint snapshot={content.snapshot} settings={settings} />}
{content.type === "protokoll" && <ProtokollPrint protokoll={content.protokoll} data={content.data} settings={settings} />}
{content.type === "mitarbeiterOverview" && <MitarbeiterOverviewPrint employees={content.employees} settings={settings} />}
{content.type === "timeReport" && <TimeReportPrint employee={content.employee} entries={content.entries} month={content.month} data={content.data} settings={settings} />}
</div>
</div>
);
}
export
function InvoicePrint({ inv, client, settings }) {
return (
<>
<div style={{ display: "flex", justifyContent: "space-between", marginBottom: 60 }}>
<div>
<StudioLogo settings={settings} size={24} />
<div style={{ whiteSpace: "pre-line", marginTop: 4, fontSize: 10, color: "#666" }}>{formatSenderAddress(settings)}</div>
<div style={{ marginTop: 4, fontSize: 10, color: "#666" }}>{settings.email} · {settings.phone}</div>
</div>
<div className="align-right" style={{ textAlign: "right" }}>
<div style={{ fontSize: 10, letterSpacing: "0.15em", color: "#888" }}>RECHNUNG</div>
<div style={{ fontFamily: "'Playfair Display', serif", fontSize: 22, marginTop: 4 }}>Nr. {inv.number}</div>
</div>
</div>
<div style={{ display: "flex", justifyContent: "space-between", marginBottom: 40 }}>
<div>
<div style={{ fontSize: 9, letterSpacing: "0.12em", color: "#888", marginBottom: 6 }}>RECHNUNG AN</div>
<div style={{ fontSize: 12, fontWeight: 500 }}>{client?.name || "—"}</div>
{(() => {
const contact = inv.contactId ? (client?.contacts || []).find(ct => ct.id === inv.contactId) : null;
if (contact) return <div style={{ fontSize: 11 }}>z.H. {contact.name}{contact.position ? `, ${contact.position}` : ""}</div>;
return null;
})()}
{client?.street && <div style={{ color: "#555" }}>{client.street}</div>}
{(client?.zip || client?.city) && <div style={{ color: "#555" }}>{[client.zip, client.city].filter(Boolean).join(" ")}</div>}
{!client?.street && client?.address && <div style={{ whiteSpace: "pre-line", color: "#555" }}>{client.address}</div>}
</div>
<div className="align-right" style={{ textAlign: "right", fontSize: 10 }}>
<div><span style={{ color: "#888" }}>Datum:</span> <strong>{formatDate(inv.date)}</strong></div>
{inv.dueDate && <div><span style={{ color: "#888" }}>Fällig:</span> <strong>{formatDate(inv.dueDate)}</strong></div>}
</div>
</div>
<table style={{ width: "100%", borderCollapse: "collapse", marginBottom: 24, fontSize: 11 }}>
<thead>
<tr style={{ borderTop: "2px solid #1a1a18", borderBottom: "1px solid #1a1a18" }}>
<th style={{ textAlign: "left", padding: "10px 0", fontSize: 9, letterSpacing: "0.12em", color: "#888", fontWeight: 500 }}>BESCHREIBUNG</th>
<th className="align-right" style={{ textAlign: "right", padding: "10px 8px", fontSize: 9, letterSpacing: "0.12em", color: "#888", fontWeight: 500, width: 70 }}>MENGE</th>
<th className="align-right" style={{ textAlign: "right", padding: "10px 8px", fontSize: 9, letterSpacing: "0.12em", color: "#888", fontWeight: 500, width: 100 }}>PREIS</th>
{(inv.items || []).some(it => (it.discount || 0) > 0) && (
<th className="align-right" style={{ textAlign: "right", padding: "10px 8px", fontSize: 9, letterSpacing: "0.12em", color: "#888", fontWeight: 500, width: 70 }}>RABATT</th>
)}
<th className="align-right" style={{ textAlign: "right", padding: "10px 0", fontSize: 9, letterSpacing: "0.12em", color: "#888", fontWeight: 500, width: 110 }}>TOTAL</th>
</tr>
</thead>
<tbody>
{(inv.items || []).map((item, i) => {
const lineTotal = item.qty * item.price;
const lineDisc = (item.discount || 0) > 0 ? lineTotal * (item.discount / 100) : 0;
const hasAnyDiscount = (inv.items || []).some(it => (it.discount || 0) > 0);
return (
<tr key={i} style={{ borderBottom: "1px solid #eee" }}>
<td style={{ padding: "10px 0" }}>{item.desc}</td>
<td className="align-right" style={{ textAlign: "right", padding: "10px 8px" }}>{item.qty}</td>
<td className="align-right" style={{ textAlign: "right", padding: "10px 8px" }}>{formatCHF(item.price)}</td>
{hasAnyDiscount && (
<td className="align-right" style={{ textAlign: "right", padding: "10px 8px", color: "#b5621e" }}>
{(item.discount || 0) > 0 ? `${item.discount}%` : "—"}
</td>
)}
<td className="align-right" style={{ textAlign: "right", padding: "10px 0" }}>{formatCHF(lineTotal - lineDisc)}</td>
</tr>
);
})}
</tbody>
</table>
<div style={{ display: "flex", justifyContent: "flex-end", marginBottom: 50 }}>
<div style={{ width: 300 }}>
{(inv.globalDisc || 0) > 0 && <>
<div style={{ display: "flex", justifyContent: "space-between", padding: "4px 0" }}><span style={{ color: "#666" }}>Zwischentotal</span><span>{formatCHF((inv.items || []).reduce((s, it) => { const l = it.qty * it.price; return s + l - ((it.discount||0) > 0 ? l*(it.discount/100) : 0); }, 0))}</span></div>
<div style={{ display: "flex", justifyContent: "space-between", padding: "4px 0", color: "#b5621e" }}><span>{inv.discountLabel || "Rabatt"}</span><span>{formatCHF(inv.globalDisc)}</span></div>
</>}
{inv.mwst && <>
<div style={{ display: "flex", justifyContent: "space-between", padding: "4px 0" }}><span style={{ color: "#666" }}>Netto</span><span>{formatCHF(inv.sub)}</span></div>
<div style={{ display: "flex", justifyContent: "space-between", padding: "4px 0" }}><span style={{ color: "#666" }}>MWST {inv.mwstRate || settings.mwstRate}%</span><span>{formatCHF(inv.tax)}</span></div>
</>}
<div style={{ display: "flex", justifyContent: "space-between", padding: "10px 0", borderTop: "2px solid #1a1a18", marginTop: 6, fontFamily: "'Playfair Display', serif", fontSize: 16, fontWeight: 700 }}>
<span>Total</span><span>{formatCHF(inv.total)}</span>
</div>
</div>
</div>
{inv.notes && <div style={{ marginBottom: 40, fontSize: 10, color: "#555", lineHeight: 1.7 }}>{inv.notes}</div>}
<div style={{ marginTop: 60, paddingTop: 20, borderTop: "1px solid #ddd", fontSize: 9, color: "#888", display: "flex", justifyContent: "space-between" }}>
<div>
<div style={{ letterSpacing: "0.1em", marginBottom: 3 }}>ZAHLUNG AUF</div>
<div>{settings.iban}</div>
<div style={{ marginTop: 3 }}>Referenz: {inv.number}</div>
</div>
<div className="align-right" style={{ textAlign: "right" }}>
<div>{settings.mwst}</div>
</div>
</div>
</>
);
}
export
function LieferscheinPrint({ note, client, data, settings }) {
const project = note.projectId ? (data?.projects || []).find(p => p.id === note.projectId) : null;
const projectLabel = project?.name || note.projectManual || null;
const addr = note.deliveryAddress || client?.address || "";
return (
<>
{/* Kopf */}
<div style={{ display: "flex", justifyContent: "space-between", marginBottom: 60 }}>
<div>
<StudioLogo settings={settings} size={24} />
<div style={{ whiteSpace: "pre-line", marginTop: 4, fontSize: 10, color: "#666" }}>{formatSenderAddress(settings)}</div>
<div style={{ marginTop: 4, fontSize: 10, color: "#666" }}>{settings.email} · {settings.phone}</div>
</div>
<div className="align-right" style={{ textAlign: "right" }}>
<div style={{ fontSize: 10, letterSpacing: "0.15em", color: "#888" }}>LIEFERSCHEIN</div>
<div style={{ fontFamily: "'Playfair Display', serif", fontSize: 22, marginTop: 4 }}>{note.number || "—"}</div>
<div style={{ fontSize: 10, color: "#888", marginTop: 6 }}>Datum: <strong style={{ color: "#1a1a18" }}>{formatDate(note.date)}</strong></div>
</div>
</div>
{/* Empfänger & Projektinfo */}
<div style={{ display: "flex", justifyContent: "space-between", marginBottom: 40 }}>
<div>
<div style={{ fontSize: 9, letterSpacing: "0.12em", color: "#888", marginBottom: 6 }}>LIEFERUNG AN</div>
<div style={{ fontSize: 12, fontWeight: 500 }}>{client?.name || note.clientManual || "—"}</div>
{client?.company && <div style={{ fontSize: 11 }}>{client.company}</div>}
{addr && <div style={{ whiteSpace: "pre-line", fontSize: 11, color: "#555", marginTop: 2 }}>{addr}</div>}
</div>
{projectLabel && (
<div className="align-right" style={{ textAlign: "right" }}>
<div style={{ fontSize: 9, letterSpacing: "0.12em", color: "#888", marginBottom: 6 }}>PROJEKT</div>
<div style={{ fontSize: 12, fontWeight: 500 }}>{projectLabel}</div>
{project?.number && <div style={{ fontSize: 10, color: "#888" }}>{project.number}</div>}
</div>
)}
</div>
{/* Positionen */}
<table style={{ width: "100%", borderCollapse: "collapse", marginBottom: 32, fontSize: 11 }}>
<thead>
<tr style={{ borderTop: "2px solid #1a1a18", borderBottom: "1px solid #1a1a18" }}>
<th style={{ textAlign: "left", padding: "10px 0", fontSize: 9, letterSpacing: "0.12em", color: "#888", fontWeight: 500, width: 30 }}>POS.</th>
<th style={{ textAlign: "left", padding: "10px 8px", fontSize: 9, letterSpacing: "0.12em", color: "#888", fontWeight: 500 }}>BESCHREIBUNG</th>
<th className="align-right" style={{ textAlign: "right", padding: "10px 8px", fontSize: 9, letterSpacing: "0.12em", color: "#888", fontWeight: 500, width: 70 }}>MENGE</th>
<th style={{ textAlign: "left", padding: "10px 8px", fontSize: 9, letterSpacing: "0.12em", color: "#888", fontWeight: 500, width: 60 }}>EINHEIT</th>
<th style={{ textAlign: "left", padding: "10px 0", fontSize: 9, letterSpacing: "0.12em", color: "#888", fontWeight: 500, width: "25%" }}>BEMERKUNG</th>
<th className="align-right" style={{ textAlign: "right", padding: "10px 0", fontSize: 9, letterSpacing: "0.12em", color: "#888", fontWeight: 500, width: 80 }}>EMPFANGEN </th>
</tr>
</thead>
<tbody>
{(note.items || []).map((it, i) => (
<tr key={it.id || i} style={{ borderBottom: "1px solid #eee" }}>
<td style={{ padding: "10px 0", color: "#888", fontSize: 10 }}>{i + 1}</td>
<td style={{ padding: "10px 8px", fontWeight: it.desc ? 400 : 300 }}>{it.desc || "—"}</td>
<td className="align-right" style={{ textAlign: "right", padding: "10px 8px", fontWeight: 500 }}>{it.qty}</td>
<td style={{ padding: "10px 8px", color: "#555" }}>{it.unit}</td>
<td style={{ padding: "10px 0", fontSize: 10, color: "#666" }}>{it.note || ""}</td>
<td style={{ padding: "10px 0", textAlign: "right" }}>
<div style={{ display: "inline-block", width: 18, height: 18, border: "1.5px solid #999", borderRadius: 2 }} />
</td>
</tr>
))}
</tbody>
</table>
{/* Notizen */}
{note.notes && (
<div style={{ marginBottom: 40, padding: "12px 16px", background: "#f8f8f6", borderLeft: "3px solid #ddd", fontSize: 10, color: "#555", lineHeight: 1.7 }}>
{note.notes}
</div>
)}
{/* Unterschriften */}
<div style={{ marginTop: 60, display: "grid", gridTemplateColumns: "1fr 1fr", gap: 60 }}>
<div>
<div style={{ borderTop: "1px solid #999", paddingTop: 8, fontSize: 9, color: "#888" }}>
ÜBERGABE {settings.name}
</div>
<div style={{ marginTop: 30, fontSize: 9, color: "#aaa" }}>Datum / Unterschrift</div>
</div>
<div>
<div style={{ borderTop: "1px solid #999", paddingTop: 8, fontSize: 9, color: "#888" }}>
EMPFANG {client?.name || note.clientManual || "Empfänger"}
</div>
<div style={{ marginTop: 30, fontSize: 9, color: "#aaa" }}>Datum / Unterschrift</div>
</div>
</div>
{/* Footer */}
<div style={{ marginTop: 40, paddingTop: 16, borderTop: "1px solid #ddd", fontSize: 9, color: "#aaa", display: "flex", justifyContent: "space-between" }}>
<div>{settings.name} · {formatSenderAddress(settings).split("\n")[0]}</div>
<div>{note.number}</div>
</div>
</>
);
}
export
function StudioBudgetPrint({ snapshot, settings }) {
const r = snapshot.results || {};
const rateOk = (r.currentRate || 0) >= (r.zielHonorar || 0);
const b = snapshot.b || {};
const PRow = ({ label, value, bold, indent, color }) => (
<div style={{ display: "flex", justifyContent: "space-between", padding: indent ? "3px 0 3px 16px" : "5px 0", borderBottom: "1px solid #eee" }}>
<span style={{ fontSize: indent ? 10 : 11, color: indent ? "#888" : "#444" }}>{label}</span>
<span style={{ fontSize: indent ? 10 : 11, fontWeight: bold ? 700 : 400, color: color || (indent ? "#888" : "#1a1a18"), fontFamily: bold ? "'Playfair Display', serif" : "inherit" }}>{value}</span>
</div>
);
return (
<>
{/* Kopf */}
<div style={{ display: "flex", justifyContent: "space-between", marginBottom: 48 }}>
<div>
<StudioLogo settings={settings} size={22} />
<div style={{ whiteSpace: "pre-line", marginTop: 4, fontSize: 10, color: "#666" }}>{formatSenderAddress(settings)}</div>
</div>
<div style={{ textAlign: "right" }}>
<div style={{ fontSize: 10, letterSpacing: "0.15em", color: "#888" }}>BÜROBUDGET</div>
<div style={{ fontFamily: "'Playfair Display', serif", fontSize: 20, marginTop: 4 }}>{snapshot.name}</div>
<div style={{ fontSize: 10, color: "#888", marginTop: 6 }}>
Erstellt: {snapshot.savedAt ? new Date(snapshot.savedAt).toLocaleDateString("de-CH", { day: "numeric", month: "long", year: "numeric" }) : "—"}
</div>
</div>
</div>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 32, marginBottom: 32 }}>
{/* Kostenstruktur */}
<div>
<div style={{ fontSize: 9, letterSpacing: "0.14em", color: "#888", marginBottom: 10, borderBottom: "2px solid #1a1a18", paddingBottom: 6 }}>KOSTENSTRUKTUR / JAHR</div>
<PRow label="Personalkosten" value={formatCHF(r.personalKosten || 0)} />
<PRow label="Fixkosten" value={formatCHF((r.fixKosten || 0) + (r.extraKosten || 0))} />
<PRow label="Basiskosten" value={formatCHF(r.basisKosten || 0)} bold />
<PRow label={`Reserve (${b.reserve || 0}%)`} value={formatCHF(r.reserve || 0)} indent />
<div style={{ display: "flex", justifyContent: "space-between", padding: "8px 0 4px", borderTop: "2px solid #1a1a18", marginTop: 4 }}>
<span style={{ fontSize: 12, fontWeight: 700 }}>Gesamtkosten</span>
<span style={{ fontSize: 15, fontWeight: 700, fontFamily: "'Playfair Display', serif" }}>{formatCHF(r.gesamtKosten || 0)}</span>
</div>
</div>
{/* Jahresstunden & Kernresultat */}
<div>
<div style={{ fontSize: 9, letterSpacing: "0.14em", color: "#888", marginBottom: 10, borderBottom: "2px solid #1a1a18", paddingBottom: 6 }}>STUNDENANALYSE</div>
<PRow label="Jahresstunden (verfügbar)" value={`${r.jahresStundenTotal || 0}h`} />
<PRow label={`Produktiv (${b.produktivQuote || 70}%)`} value={`${r.produktivStunden || 0}h`} />
<div style={{ height: 6, background: "#eee", borderRadius: 3, margin: "8px 0", overflow: "hidden" }}>
<div style={{ width: `${b.produktivQuote || 70}%`, height: "100%", background: "#2d6a4f", borderRadius: 3 }} />
</div>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 10, marginTop: 12 }}>
<div style={{ background: "#f8f8f6", borderRadius: 4, padding: "10px 12px", textAlign: "center" }}>
<div style={{ fontSize: 9, color: "#888", letterSpacing: "0.1em", marginBottom: 4 }}>SELBSTKOSTEN/H</div>
<div style={{ fontSize: 18, fontFamily: "'Playfair Display', serif", fontWeight: 700 }}>{formatCHF(Math.round(r.selbstkosten || 0))}</div>
</div>
<div style={{ background: "#f8f8f6", borderRadius: 4, padding: "10px 12px", textAlign: "center" }}>
<div style={{ fontSize: 9, color: "#888", letterSpacing: "0.1em", marginBottom: 4 }}>ZIEL-HONORAR/H</div>
<div style={{ fontSize: 18, fontFamily: "'Playfair Display', serif", fontWeight: 700, color: "#b5861e" }}>{formatCHF(Math.round(r.zielHonorar || 0))}</div>
<div style={{ fontSize: 9, color: "#aaa", marginTop: 2 }}>inkl. {b.zielMarge || 25}% Marge</div>
</div>
</div>
<div style={{ marginTop: 10, padding: "8px 10px", borderRadius: 4, border: `1.5px solid ${rateOk ? "#b8dcc8" : "#e8b0b0"}`, background: rateOk ? "#f0f8f4" : "#fdf2f2" }}>
<div style={{ fontSize: 11, fontWeight: 600, color: rateOk ? "#2d6a4f" : "#8a1a1a" }}>
{rateOk ? "✓ Aktueller Ansatz ausreichend" : "⚠ Aktueller Ansatz zu tief"}
</div>
<div style={{ fontSize: 10, color: "#666", marginTop: 2 }}>
Aktuell: {formatCHF(r.currentRate || 0)}/h · Ziel: {formatCHF(Math.round(r.zielHonorar || 0))}/h · Differenz: {rateOk ? "+" : ""}{formatCHF(Math.round((r.currentRate || 0) - (r.zielHonorar || 0)))}
</div>
</div>
</div>
</div>
{/* Personal-Detail */}
{(snapshot.empSnapshot || []).length > 0 && (
<div style={{ marginBottom: 28 }}>
<div style={{ fontSize: 9, letterSpacing: "0.14em", color: "#888", marginBottom: 8, borderBottom: "1px solid #ddd", paddingBottom: 5 }}>PERSONAL</div>
<table style={{ width: "100%", borderCollapse: "collapse", fontSize: 10 }}>
<thead>
<tr>
<th style={{ textAlign: "left", padding: "4px 0", color: "#888", fontWeight: 500, fontSize: 9, borderBottom: "1px solid #eee" }}>Mitarbeiter</th>
<th style={{ textAlign: "right", padding: "4px 8px", color: "#888", fontWeight: 500, fontSize: 9, borderBottom: "1px solid #eee" }}>Jahreskosten</th>
<th style={{ textAlign: "right", padding: "4px 0", color: "#888", fontWeight: 500, fontSize: 9, borderBottom: "1px solid #eee" }}>Jahresstunden</th>
</tr>
</thead>
<tbody>
{snapshot.empSnapshot.filter(r => r.aktiv).map((r, i) => (
<tr key={i}>
<td style={{ padding: "4px 0", borderBottom: "1px solid #f5f5f5" }}>{r.name}</td>
<td style={{ textAlign: "right", padding: "4px 8px", borderBottom: "1px solid #f5f5f5" }}>{formatCHF(r.kosten)}</td>
<td style={{ textAlign: "right", padding: "4px 0", borderBottom: "1px solid #f5f5f5" }}>{r.stunden}h</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{/* Fixkosten-Detail */}
{(snapshot.fixSnapshot || []).length > 0 && (
<div style={{ marginBottom: 28 }}>
<div style={{ fontSize: 9, letterSpacing: "0.14em", color: "#888", marginBottom: 8, borderBottom: "1px solid #ddd", paddingBottom: 5 }}>FIXKOSTEN</div>
<table style={{ width: "100%", borderCollapse: "collapse", fontSize: 10 }}>
<thead>
<tr>
<th style={{ textAlign: "left", padding: "4px 0", color: "#888", fontWeight: 500, fontSize: 9, borderBottom: "1px solid #eee" }}>Posten</th>
<th style={{ textAlign: "right", padding: "4px 0", color: "#888", fontWeight: 500, fontSize: 9, borderBottom: "1px solid #eee" }}>CHF/Jahr</th>
</tr>
</thead>
<tbody>
{snapshot.fixSnapshot.map((r, i) => (
<tr key={i}>
<td style={{ padding: "4px 0", borderBottom: "1px solid #f5f5f5" }}>{r.label}</td>
<td style={{ textAlign: "right", padding: "4px 0", borderBottom: "1px solid #f5f5f5" }}>{formatCHF(r.amount)}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{/* Footer */}
<div style={{ marginTop: 40, paddingTop: 14, borderTop: "1px solid #ddd", fontSize: 9, color: "#aaa", display: "flex", justifyContent: "space-between" }}>
<div>{settings.name}</div>
<div>Bürobudget · {snapshot.name}</div>
</div>
</>
);
}
export
function ProtokollPrint({ protokoll, data, settings }) {
const p = protokoll;
const proj = data?.projects?.find(x => x.id === p.projectId);
const projLabel = proj?.name || p.projectManual || null;
const today = new Date().toLocaleDateString("de-CH", { day: "numeric", month: "long", year: "numeric" });
const statusLabels = { anwesend: "A", entschuldigt: "E", abwesend: "Ab", eingeladen: "Eingeladen" };
const statusColors = { anwesend: "#2d6a4f", entschuldigt: "#b5621e", abwesend: "#8a1a1a", eingeladen: "#1a4e8a" };
const allTasks = (p.traktanden || []).flatMap(t =>
(t.items || []).filter(it => it.type === "aufgabe")
.map(it => ({ ...it, tNr: t.nr, tTitle: t.title }))
);
const allBeschluesse = (p.traktanden || []).flatMap(t =>
(t.items || []).filter(it => it.type === "beschluss")
.map(it => ({ ...it, tNr: t.nr, tTitle: t.title }))
);
return (
<>
{/* Kopf */}
<div style={{ display: "flex", justifyContent: "space-between", marginBottom: 36 }}>
<div>
<StudioLogo settings={settings} size={20} />
<div style={{ whiteSpace: "pre-line", marginTop: 4, fontSize: 9, color: "#666" }}>{formatSenderAddress(settings)}</div>
</div>
<div style={{ textAlign: "right" }}>
<div style={{ fontSize: 9, letterSpacing: "0.15em", color: "#888" }}>PROTOKOLL</div>
<div style={{ fontFamily: "'Playfair Display', serif", fontSize: 18, marginTop: 2 }}>{p.nummer}</div>
</div>
</div>
{/* Titel & Meta */}
<div style={{ borderBottom: "2px solid #1a1a18", paddingBottom: 14, marginBottom: 20 }}>
<div style={{ fontFamily: "'Playfair Display', serif", fontSize: 20, fontWeight: 400, marginBottom: 10 }}>{p.title || "Protokoll"}</div>
<div style={{ display: "grid", gridTemplateColumns: "repeat(4, 1fr)", gap: 8, fontSize: 9, color: "#888" }}>
{[
{ label: "TYP", value: p.type },
{ label: "DATUM", value: p.date ? `${formatDate(p.date)}${p.time ? `, ${p.time}${p.endTime ? `${p.endTime}` : ""} Uhr` : ""}` : "—" },
{ label: "ORT", value: p.location || "—" },
{ label: "PROJEKT", value: projLabel || "—" },
].map(m => (
<div key={m.label}>
<div style={{ letterSpacing: "0.1em", marginBottom: 2 }}>{m.label}</div>
<div style={{ fontSize: 10, color: "#1a1a18", fontWeight: 500 }}>{m.value}</div>
</div>
))}
</div>
</div>
{/* Teilnehmer */}
{(p.participants || []).length > 0 && (
<div style={{ marginBottom: 22 }}>
<div style={{ fontSize: 9, letterSpacing: "0.12em", color: "#888", marginBottom: 8, borderBottom: "1px solid #ddd", paddingBottom: 4 }}>TEILNEHMER</div>
<table style={{ width: "100%", borderCollapse: "collapse", fontSize: 9 }}>
<thead>
<tr>
<th style={{ textAlign: "left", padding: "3px 6px 3px 0", fontWeight: 500, color: "#888", borderBottom: "1px solid #eee" }}>Name</th>
<th style={{ textAlign: "left", padding: "3px 6px", fontWeight: 500, color: "#888", borderBottom: "1px solid #eee" }}>Funktion</th>
<th style={{ textAlign: "center", padding: "3px 0", fontWeight: 500, color: "#888", borderBottom: "1px solid #eee", width: 60 }}>Status</th>
</tr>
</thead>
<tbody>
{(p.participants || []).map((tn, i) => (
<tr key={i}>
<td style={{ padding: "4px 6px 4px 0", borderBottom: "1px solid #f5f5f5", fontWeight: 500 }}>{tn.name}</td>
<td style={{ padding: "4px 6px", borderBottom: "1px solid #f5f5f5", color: "#666" }}>{tn.role || "—"}</td>
<td style={{ textAlign: "center", padding: "4px 0", borderBottom: "1px solid #f5f5f5", fontWeight: 700, fontSize: 9, color: statusColors[tn.status] || "#555" }}>
{statusLabels[tn.status] || tn.status}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{/* Traktanden */}
{(p.traktanden || []).map(t => {
const hasContent = t.title || (t.items || []).length > 0;
if (!hasContent) return null;
return (
<div key={t.id} style={{ marginBottom: 18 }}>
<div style={{ display: "flex", gap: 10, alignItems: "baseline", borderBottom: "1.5px solid #1a1a18", paddingBottom: 4, marginBottom: 10 }}>
<span style={{ fontSize: 12, fontWeight: 700, color: "#b5861e", fontFamily: "'Playfair Display', serif" }}>{t.nr}</span>
<span style={{ fontSize: 12, fontWeight: 600 }}>{t.title || "—"}</span>
</div>
{(t.items || []).map((item, ii) => {
const icons = { info: "", beschluss: "✅", aufgabe: "📌" };
const colors = { info: "#1a4e8a", beschluss: "#2d6a4f", aufgabe: "#b5621e" };
return (
<div key={ii} style={{ display: "flex", gap: 10, marginBottom: 8, paddingLeft: 10 }}>
<div style={{ fontSize: 10, width: 16, flexShrink: 0, marginTop: 1 }}>{icons[item.type]}</div>
<div style={{ flex: 1 }}>
<div style={{ fontSize: 10, lineHeight: 1.5 }}>{item.text || "—"}</div>
{item.type === "beschluss" && item.date && (
<div style={{ fontSize: 9, color: "#888", marginTop: 2 }}>Beschluss vom {formatDate(item.date)}</div>
)}
{item.type === "aufgabe" && (
<div style={{ fontSize: 9, color: colors.aufgabe, marginTop: 2, display: "flex", gap: 10 }}>
{item.responsible && <span> {item.responsible}</span>}
<span>{item.dueDateType === "kw" ? (item.dueKW ? `KW ${item.dueKW}/${item.dueYear || ""}` : "—") : item.dueDate ? formatDate(item.dueDate) : "—"}</span>
<span style={{ color: item.status === "erledigt" ? "#2d6a4f" : "#b5621e", fontWeight: 600 }}>{item.status}</span>
</div>
)}
</div>
</div>
);
})}
</div>
);
})}
{/* Aufgabenliste */}
{allTasks.length > 0 && (
<div style={{ marginTop: 24, marginBottom: 18, pageBreakInside: "avoid" }}>
<div style={{ fontSize: 9, letterSpacing: "0.12em", color: "#888", marginBottom: 8, borderBottom: "1px solid #ddd", paddingBottom: 4 }}>AUFGABEN-ÜBERSICHT</div>
<table style={{ width: "100%", borderCollapse: "collapse", fontSize: 9 }}>
<thead>
<tr>
<th style={{ textAlign: "left", padding: "3px 6px 3px 0", fontWeight: 500, color: "#888", borderBottom: "1px solid #eee" }}>Aufgabe</th>
<th style={{ textAlign: "left", padding: "3px 6px", fontWeight: 500, color: "#888", borderBottom: "1px solid #eee", width: 100 }}>Verantwortlich</th>
<th style={{ textAlign: "left", padding: "3px 6px", fontWeight: 500, color: "#888", borderBottom: "1px solid #eee", width: 80 }}>Fälligkeit</th>
<th style={{ textAlign: "left", padding: "3px 0", fontWeight: 500, color: "#888", borderBottom: "1px solid #eee", width: 70 }}>Status</th>
</tr>
</thead>
<tbody>
{allTasks.map((t, i) => (
<tr key={i}>
<td style={{ padding: "4px 6px 4px 0", borderBottom: "1px solid #f5f5f5" }}>
<span style={{ color: "#888", marginRight: 4 }}>{t.tNr}.</span>{t.text || "—"}
</td>
<td style={{ padding: "4px 6px", borderBottom: "1px solid #f5f5f5", color: "#555" }}>{t.responsible || "—"}</td>
<td style={{ padding: "4px 6px", borderBottom: "1px solid #f5f5f5", color: "#555" }}>
{t.dueDateType === "kw" ? (t.dueKW ? `KW ${t.dueKW}/${t.dueYear || ""}` : "—") : t.dueDate ? formatDate(t.dueDate) : "—"}
</td>
<td style={{ padding: "4px 0", borderBottom: "1px solid #f5f5f5", fontWeight: 600, color: t.status === "erledigt" ? "#2d6a4f" : "#b5621e" }}>{t.status}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{/* Nächste Sitzung */}
{(p.nextDate || p.verteiler) && (
<div style={{ marginBottom: 24, padding: "10px 14px", background: "#f8f8f6", borderRadius: 4, fontSize: 9 }}>
{p.nextDate && <div><span style={{ color: "#888", marginRight: 6 }}>Nächste Sitzung:</span><strong>{formatDate(p.nextDate)}</strong></div>}
{p.verteiler && <div style={{ marginTop: 3 }}><span style={{ color: "#888", marginRight: 6 }}>Verteiler:</span>{p.verteiler}</div>}
</div>
)}
{/* Unterschriften */}
<div style={{ marginTop: 40, display: "grid", gridTemplateColumns: "1fr 1fr", gap: 40 }}>
<div>
<div style={{ borderTop: "1px solid #999", paddingTop: 8, fontSize: 9, color: "#888" }}>PROTOKOLLFÜHRUNG</div>
<div style={{ marginTop: 28, fontSize: 9, color: "#aaa" }}>Datum / Unterschrift</div>
</div>
<div>
<div style={{ borderTop: "1px solid #999", paddingTop: 8, fontSize: 9, color: "#888" }}>GENEHMIGUNG</div>
<div style={{ marginTop: 28, fontSize: 9, color: "#aaa" }}>Datum / Unterschrift</div>
</div>
</div>
{/* Footer */}
<div style={{ marginTop: 32, paddingTop: 12, borderTop: "1px solid #ddd", fontSize: 8, color: "#aaa", display: "flex", justifyContent: "space-between" }}>
<div>{settings.name} · {p.nummer}</div>
<div>Erstellt {today}{p.verteiler ? ` · Verteiler: ${p.verteiler}` : ""}</div>
</div>
</>
);
}
export
function LetterPrint({ client, subject, body, isHtml, settings }) {
return (
<>
<div style={{ display: "flex", justifyContent: "space-between", marginBottom: 80 }}>
<div>
<StudioLogo settings={settings} size={22} />
<div style={{ whiteSpace: "pre-line", marginTop: 4, fontSize: 10, color: "#666" }}>{formatSenderAddress(settings)}</div>
</div>
<div className="align-right" style={{ textAlign: "right", fontSize: 10, color: "#666" }}>
<div>{settings.email}</div>
<div>{settings.phone}</div>
</div>
</div>
<div style={{ marginBottom: 50, fontSize: 11 }}>
{client ? (<>
<div style={{ fontWeight: 500 }}>{client.name}</div>
{client.company && <div>{client.company}</div>}
{client.street && <div>{client.street}</div>}
{(client.zip || client.city) && <div>{[client.zip, client.city].filter(Boolean).join(" ")}</div>}
{!client.street && !client.city && client.address && <div style={{ whiteSpace: "pre-line" }}>{client.address}</div>}
</>) : <div style={{ color: "#aaa" }}>[Empfänger]</div>}
</div>
<div style={{ marginBottom: 30, fontSize: 10, color: "#666" }}>
{settings.city || (settings.address || "").split("\n").pop().replace(/^\d{4,5}\s*/, "").trim() || "Zürich"}, {new Date().toLocaleDateString("de-CH", { day: "numeric", month: "long", year: "numeric" })}
</div>
{subject && <div style={{ fontWeight: 500, marginBottom: 30, fontSize: 11 }}>{subject}</div>}
<style>{`
.letter-body p { margin: 0 0 0.7em; }
.letter-body h2 { font-size: 12px; font-weight: 600; margin: 0.8em 0 0.3em; }
.letter-body ul, .letter-body ol { margin: 0.3em 0 0.7em 1.4em; }
.letter-body li { margin-bottom: 0.2em; }
`}</style>
{isHtml
? <div className="letter-body" style={{ lineHeight: 1.8, fontSize: 11 }} dangerouslySetInnerHTML={{ __html: sanitizeHtml(body) }} />
: <div style={{ whiteSpace: "pre-line", lineHeight: 1.8, fontSize: 11 }}>{body}</div>
}
<div style={{ marginTop: 60, fontSize: 11 }}>{settings.name}</div>
</>
);
}
export
function ProjectDetailPrint({ content, settings }) {
const { project, client, entries, phaseStats, unassignedMins, totalMinutes, totalAmount, billingType, invoices, data } = content;
return (
<>
<div style={{ display: "flex", justifyContent: "space-between", marginBottom: 50 }}>
<div>
<StudioLogo settings={settings} size={22} />
<div style={{ whiteSpace: "pre-line", marginTop: 4, fontSize: 10, color: "#666" }}>{formatSenderAddress(settings)}</div>
</div>
<div className="align-right" style={{ textAlign: "right", fontSize: 10, color: "#666" }}>
<div style={{ letterSpacing: "0.15em" }}>PROJEKTREPORT</div>
<div style={{ marginTop: 4 }}>{new Date().toLocaleDateString("de-CH")}</div>
</div>
</div>
<div style={{ marginBottom: 30, paddingBottom: 20, borderBottom: "2px solid #1a1a18" }}>
<div style={{ fontSize: 9, letterSpacing: "0.15em", color: "#888", marginBottom: 6 }}>{(project.category || "PROJEKT").toUpperCase()}</div>
<div style={{ fontFamily: "'Playfair Display', serif", fontSize: 26, fontWeight: 400, letterSpacing: "-0.01em", marginBottom: 6 }}>{project.name}</div>
{client && <div style={{ fontSize: 11, color: "#666" }}>{client.name}{client.company ? ` · ${client.company}` : ""}</div>}
{project.description && <div style={{ fontSize: 10, color: "#555", marginTop: 12, lineHeight: 1.7 }}>{project.description}</div>}
</div>
<div style={{ display: "grid", gridTemplateColumns: "repeat(3, 1fr)", gap: 20, marginBottom: 40 }}>
<div><div style={{ fontSize: 9, letterSpacing: "0.12em", color: "#888", marginBottom: 4 }}>STUNDEN TOTAL</div><div style={{ fontSize: 18, fontFamily: "'Playfair Display', serif", fontWeight: 700 }}>{formatHours(totalMinutes)}</div></div>
<div><div style={{ fontSize: 9, letterSpacing: "0.12em", color: "#888", marginBottom: 4 }}>{billingType === "stundensatz" ? "AUFWAND" : "PAUSCHALE"}</div><div style={{ fontSize: 18, fontFamily: "'Playfair Display', serif", fontWeight: 700 }}>{formatCHF(totalAmount)}</div></div>
<div><div style={{ fontSize: 9, letterSpacing: "0.12em", color: "#888", marginBottom: 4 }}>EINTRÄGE</div><div style={{ fontSize: 18, fontFamily: "'Playfair Display', serif", fontWeight: 700 }}>{entries.length}</div></div>
</div>
{phaseStats.length > 0 && (
<div style={{ marginBottom: 24 }}>
<div style={{ fontSize: 10, letterSpacing: "0.12em", color: "#888", marginBottom: 10, paddingBottom: 6, borderBottom: "1px solid #1a1a18" }}>
AUFWAND PRO SIA-PHASE · HAUPTAUFTRAG
</div>
{phaseStats.map(ps => (
<div key={ps.id} style={{ marginBottom: 8 }}>
<div style={{ display: "flex", justifyContent: "space-between", fontSize: 11, marginBottom: 3 }}>
<span>{ps.label}</span>
<span style={{ color: "#666" }}>{formatHours(ps.minutes)} · {ps.percent.toFixed(1)}%</span>
</div>
<div style={{ height: 4, background: "#ece8e2", borderRadius: 2, overflow: "hidden" }}>
<div style={{ width: `${ps.percent}%`, height: "100%", background: "#2d6a4f" }}></div>
</div>
</div>
))}
{unassignedMins > 0 && <div style={{ fontSize: 10, color: "#888", marginTop: 8 }}>Ohne Phasen-Zuordnung: {formatHours(unassignedMins)}</div>}
</div>
)}
{(project.positions || []).filter(pos => {
const posEntries = entries.filter(e => e.positionId === pos.code);
return posEntries.length > 0 || (pos.enabledPhases || []).length > 0;
}).map(pos => {
const posEntries = entries.filter(e => e.positionId === pos.code);
const posMins = posEntries.reduce((s, e) => s + (e.minutes || 0), 0);
const allPhaseIds = [...new Set([...(pos.enabledPhases || []), ...posEntries.map(e => e.phaseId).filter(Boolean)])];
const linkedQ = pos.quoteId ? (data?.quotes || []).find(q => q.id === pos.quoteId) : null;
return (
<div key={pos.code} style={{ marginBottom: 24 }}>
<div style={{ fontSize: 10, letterSpacing: "0.12em", color: "#1a4e8a", marginBottom: 10, paddingBottom: 6, borderBottom: "1px solid #c8d8ee", display: "flex", justifyContent: "space-between" }}>
<span> {pos.code}{pos.label ? ` · ${pos.label}` : ""}{linkedQ ? ` · ${linkedQ.number}` : ""}</span>
<span style={{ color: "#666" }}>{formatHours(posMins)}</span>
</div>
{allPhaseIds.length > 0 ? allPhaseIds.map(phId => {
const ph = SIA_PHASES.find(p => p.id === phId);
if (!ph) return null;
const phMins = posEntries.filter(e => e.phaseId === phId).reduce((s, e) => s + (e.minutes || 0), 0);
const pct = posMins > 0 ? (phMins / posMins) * 100 : 0;
return (
<div key={phId} style={{ marginBottom: 8 }}>
<div style={{ display: "flex", justifyContent: "space-between", fontSize: 11, marginBottom: 3 }}>
<span style={{ color: "#444" }}>{ph.label}</span>
<span style={{ color: "#666" }}>{formatHours(phMins)} · {pct.toFixed(1)}%</span>
</div>
<div style={{ height: 4, background: "#e8f0fa", borderRadius: 2, overflow: "hidden" }}>
<div style={{ width: `${pct}%`, height: "100%", background: "#1a4e8a" }} />
</div>
</div>
);
}) : (
posMins === 0 ? null : <div style={{ fontSize: 10, color: "#888" }}>Keine Phasen-Unterteilung · {formatHours(posMins)}</div>
)}
</div>
);
})}
{entries.length > 0 && (
<div style={{ marginBottom: 30, pageBreakInside: "avoid" }}>
<div style={{ fontSize: 10, letterSpacing: "0.12em", color: "#888", marginBottom: 10, paddingBottom: 8, borderBottom: "1px solid #1a1a18" }}>ZEITEINTRÄGE</div>
<table style={{ width: "100%", borderCollapse: "collapse", fontSize: 10 }}>
<thead>
<tr style={{ borderBottom: "1px solid #ddd" }}>
<th style={{ textAlign: "left", padding: "6px 0", fontSize: 9, color: "#888", fontWeight: 500, width: 85 }}>DATUM</th>
<th style={{ textAlign: "left", padding: "6px 0", fontSize: 9, color: "#888", fontWeight: 500, width: 50 }}>PHASE</th>
<th style={{ textAlign: "left", padding: "6px 0", fontSize: 9, color: "#888", fontWeight: 500 }}>TÄTIGKEIT</th>
<th className="align-right" style={{ textAlign: "right", padding: "6px 0", fontSize: 9, color: "#888", fontWeight: 500, width: 55 }}>DAUER</th>
</tr>
</thead>
<tbody>
{entries.map(e => {
const phase = SIA_PHASES.find(p => p.id === e.phaseId);
return (
<tr key={e.id} style={{ borderBottom: "1px solid #f0f0f0" }}>
<td style={{ padding: "6px 0" }}>{formatDate(e.date)}</td>
<td style={{ padding: "6px 0", color: "#666" }}>{phase?.id || "—"}</td>
<td style={{ padding: "6px 0", color: "#555" }}>{e.description || "—"}</td>
<td className="align-right" style={{ textAlign: "right", padding: "6px 0" }}>{formatHours(e.minutes)}</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
{invoices && invoices.length > 0 && (
<div>
<div style={{ fontSize: 10, letterSpacing: "0.12em", color: "#888", marginBottom: 10, paddingBottom: 8, borderBottom: "1px solid #1a1a18" }}>RECHNUNGEN</div>
<table style={{ width: "100%", borderCollapse: "collapse", fontSize: 10 }}>
<tbody>
{invoices.map(inv => (
<tr key={inv.id} style={{ borderBottom: "1px solid #f0f0f0" }}>
<td style={{ padding: "6px 0" }}><strong>{inv.number}</strong></td>
<td style={{ padding: "6px 0", color: "#666" }}>{formatDate(inv.date)}</td>
<td style={{ padding: "6px 0", color: "#666" }}>{inv.status}</td>
<td className="align-right" style={{ textAlign: "right", padding: "6px 0" }}><strong>{formatCHF(inv.total)}</strong></td>
</tr>
))}
</tbody>
</table>
</div>
)}
</>
);
}
export
function ProjectsOverviewPrint({ projects, data, settings }) {
const projectMinutes = (id) => data.timeEntries.filter(e => e.projectId === id).reduce((s, e) => s + (e.minutes || 0), 0);
const projectAmount = (p) => {
const bType = p.billingType || p.type;
if (bType === "stundensatz") return (projectMinutes(p.id) / 60) * p.hourlyRate;
return p.budget || 0;
};
const grouped = PROJECT_TYPES.map(cat => ({
category: cat,
projects: projects.filter(p => (p.category || "Sonstiges") === cat),
})).filter(g => g.projects.length > 0);
const uncategorized = projects.filter(p => !p.category);
if (uncategorized.length > 0) grouped.push({ category: "Ohne Kategorie", projects: uncategorized });
const totalMins = projects.reduce((s, p) => s + projectMinutes(p.id), 0);
const totalAmount = projects.reduce((s, p) => s + projectAmount(p), 0);
return (
<>
<div style={{ display: "flex", justifyContent: "space-between", marginBottom: 50 }}>
<div>
<StudioLogo settings={settings} size={22} />
<div style={{ whiteSpace: "pre-line", marginTop: 4, fontSize: 10, color: "#666" }}>{formatSenderAddress(settings)}</div>
</div>
<div className="align-right" style={{ textAlign: "right", fontSize: 10, color: "#666" }}>
<div style={{ letterSpacing: "0.15em" }}>PROJEKTÜBERSICHT</div>
<div style={{ marginTop: 4 }}>{new Date().toLocaleDateString("de-CH")}</div>
</div>
</div>
<div style={{ marginBottom: 30, paddingBottom: 20, borderBottom: "2px solid #1a1a18" }}>
<div style={{ fontFamily: "'Playfair Display', serif", fontSize: 26, fontWeight: 400, letterSpacing: "-0.01em" }}>Alle Projekte</div>
<div style={{ fontSize: 11, color: "#666", marginTop: 4 }}>{projects.length} Projekte · {formatHours(totalMins)} · {formatCHF(totalAmount)}</div>
</div>
{grouped.map(g => (
<div key={g.category} style={{ marginBottom: 28, pageBreakInside: "avoid" }}>
<div style={{ fontSize: 10, letterSpacing: "0.12em", color: "#888", marginBottom: 10, paddingBottom: 6, borderBottom: "1px solid #1a1a18" }}>{g.category.toUpperCase()} {g.projects.length}</div>
<table style={{ width: "100%", borderCollapse: "collapse", fontSize: 10, tableLayout: "fixed" }}>
<colgroup>
<col style={{ width: "40%" }} />
<col style={{ width: "25%" }} />
<col style={{ width: "12%" }} />
<col style={{ width: "11%" }} />
<col style={{ width: "12%" }} />
</colgroup>
<thead>
<tr style={{ borderBottom: "1px solid #ddd" }}>
<th style={{ textAlign: "left", padding: "6px 0", fontSize: 9, color: "#888", fontWeight: 500 }}>PROJEKT</th>
<th style={{ textAlign: "left", padding: "6px 0", fontSize: 9, color: "#888", fontWeight: 500 }}>KUNDE</th>
<th style={{ textAlign: "left", padding: "6px 0", fontSize: 9, color: "#888", fontWeight: 500 }}>STATUS</th>
<th style={{ textAlign: "right", padding: "6px 0", fontSize: 9, color: "#888", fontWeight: 500 }}>STUNDEN</th>
<th style={{ textAlign: "right", padding: "6px 0", fontSize: 9, color: "#888", fontWeight: 500 }}>BETRAG</th>
</tr>
</thead>
<tbody>
{g.projects.map(p => {
const client = ((data.persons||[]).filter(p=>p.isAuftraggeber)).find(c => c.id === p.clientId);
return (
<tr key={p.id} style={{ borderBottom: "1px solid #f0f0f0" }}>
<td style={{ padding: "6px 0", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}><strong>{p.name}</strong></td>
<td style={{ padding: "6px 0", color: "#666", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{client?.name || "—"}</td>
<td style={{ padding: "6px 0", color: "#666" }}>{p.status}</td>
<td style={{ textAlign: "right", padding: "6px 0", whiteSpace: "nowrap" }}>
{formatHours(projectMinutes(p.id))}
{p.budgetHours > 0 && <span style={{ color: "#aaa" }}> / {p.budgetHours}h</span>}
</td>
<td style={{ textAlign: "right", padding: "6px 0" }}>{formatCHF(projectAmount(p))}</td>
</tr>
);
})}
</tbody>
</table>
</div>
))}
<div style={{ marginTop: 30, paddingTop: 16, borderTop: "2px solid #1a1a18", display: "flex", justifyContent: "space-between", fontSize: 12, fontFamily: "'Playfair Display', serif", fontWeight: 700 }}>
<span>Total</span>
<span>{formatHours(totalMins)} · {formatCHF(totalAmount)}</span>
</div>
</>
);
}
export
function QRBillPrint({ inv, client, settings }) {
const [qrSvg, setQrSvg] = useState(null);
const [error, setError] = useState(null);
useEffect(() => {
let cancelled = false;
(async () => {
try {
const { SwissQRBill: SVG } = await import("swissqrbill/svg");
if (cancelled) return;
const ibanClean = (settings.iban || "").replace(/\s/g, "").toUpperCase();
const hasQRIban = isQRIban(ibanClean);
const data = {
currency: "CHF",
amount: inv.total,
creditor: {
name: settings.name || "—",
address: settings.street || "—",
zip: settings.zip || "",
city: settings.city || "",
account: ibanClean,
country: settings.country || "CH",
},
};
if (client) {
data.debtor = {
name: client.company || client.name || "—",
address: client.street || "—",
zip: client.zip || "",
city: client.city || "",
country: client.country || "CH",
};
}
if (hasQRIban) {
data.reference = generateQRReference(inv.number);
}
if (inv.notes || inv.number) {
data.message = `Rechnung ${inv.number}`;
}
const qr = new SVG(data);
const svgString = typeof qr.toString === "function"
? qr.toString()
: (qr.element?.outerHTML || qr.outerHTML || String(qr));
if (!cancelled) setQrSvg(svgString);
} catch (err) {
console.error("QR-Bill Fehler:", err);
if (!cancelled) setError(err.message || "Fehler beim Generieren der QR-Rechnung");
}
})();
return () => { cancelled = true; };
}, [inv, client, settings]);
if (error) {
return (
<div style={{ padding: 40 }}>
<div style={{ fontSize: 14, fontWeight: 500, color: "#8a1a1a", marginBottom: 10 }}>Fehler beim Generieren</div>
<div style={{ fontSize: 12, color: "#666", lineHeight: 1.6 }}>{error}</div>
<div style={{ fontSize: 11, color: "#888", marginTop: 20, lineHeight: 1.7 }}>
Mögliche Ursachen: fehlende Empfänger-Adresse (Strasse, PLZ, Ort) oder ungültige IBAN.
</div>
</div>
);
}
if (!qrSvg) {
return (
<div style={{ padding: 40, fontSize: 12, color: "#888" }}>QR-Rechnung wird generiert</div>
);
}
return (
<div style={{ width: "210mm", height: "105mm" }} dangerouslySetInnerHTML={{ __html: qrSvg }} />
);
}
export
function QuotePrint({ quote, client, settings }) {
const taxRate = settings.mwstRate || 8.1;
const roles = quote.quoteRoles || settings.roles || [];
const siaCalc = quote.mode === "sia" && quote.sia ? calcSIAHours(quote.sia.baukosten, quote.sia.schwierigkeit, quote.sia.phases) : null;
const manCalc = quote.mode === "manual" ? calcManualHours(quote.manualPhases || [], roles) : null;
const stundenansatz = quote.sia?.stundenansatz || 0;
return (
<>
<div style={{ display: "flex", justifyContent: "space-between", marginBottom: 18 }}>
<div>
<StudioLogo settings={settings} size={22} />
<div style={{ whiteSpace: "pre-line", marginTop: 4, fontSize: 10, color: "#666" }}>{formatSenderAddress(settings)}</div>
<div style={{ marginTop: 4, fontSize: 10, color: "#666" }}>{settings.email} · {settings.phone}</div>
</div>
<div className="align-right" style={{ textAlign: "right" }}>
<div style={{ fontSize: 10, letterSpacing: "0.15em", color: "#888" }}>HONORAROFFERTE</div>
<div style={{ fontFamily: "'Playfair Display', serif", fontSize: 22, marginTop: 4 }}>Nr. {quote.number}</div>
</div>
</div>
<div style={{ display: "flex", justifyContent: "space-between", marginBottom: 16 }}>
<div>
<div style={{ fontSize: 9, letterSpacing: "0.12em", color: "#888", marginBottom: 4 }}>OFFERTE AN</div>
{client ? (
<>
{client.company && <div style={{ fontSize: 12, fontWeight: 500 }}>{client.company}</div>}
<div style={{ fontSize: 12, fontWeight: client.company ? 400 : 500 }}>{client.name || "—"}</div>
{client.street && <div style={{ color: "#555" }}>{client.street}</div>}
{(client.zip || client.city) && <div style={{ color: "#555" }}>{[client.zip, client.city].filter(Boolean).join(" ")}</div>}
{!client.street && !client.city && client.address && <div style={{ whiteSpace: "pre-line", color: "#555" }}>{client.address}</div>}
</>
) : <div style={{ color: "#aaa" }}></div>}
</div>
<div className="align-right" style={{ textAlign: "right", fontSize: 10 }}>
<div><span style={{ color: "#888" }}>Datum:</span> <strong>{formatDate(quote.date)}</strong></div>
{quote.validUntil && <div><span style={{ color: "#888" }}>Gültig bis:</span> <strong>{formatDate(quote.validUntil)}</strong></div>}
</div>
</div>
{quote.notes && <div style={{ marginBottom: 14, fontSize: 11, lineHeight: 1.5, whiteSpace: "pre-line" }}>{quote.notes}</div>}
{/* SIA-Modus */}
{quote.mode === "sia" && siaCalc && (
<>
<div style={{ fontSize: 10, letterSpacing: "0.12em", color: "#888", marginBottom: 6, paddingBottom: 5, borderBottom: "1.5px solid #1a1a18" }}>
HONORARBERECHNUNG NACH SIA 102
</div>
<div style={{ display: "grid", gridTemplateColumns: "repeat(3, 1fr)", gap: 12, marginBottom: 14, fontSize: 10 }}>
<div>
<div style={{ color: "#888", marginBottom: 2 }}>Aufwandbestimmende Baukosten</div>
<div style={{ fontWeight: 500 }}>{formatCHF(quote.sia.baukosten)}</div>
</div>
<div>
<div style={{ color: "#888", marginBottom: 2 }}>Schwierigkeitsgrad n</div>
<div style={{ fontWeight: 500 }}>{quote.sia.schwierigkeit}</div>
</div>
<div>
<div style={{ color: "#888", marginBottom: 2 }}>Stundenansatz</div>
<div style={{ fontWeight: 500 }}>CHF {stundenansatz}./h</div>
</div>
</div>
<table style={{ width: "100%", borderCollapse: "collapse", marginBottom: 12, fontSize: 9, lineHeight: 1.3 }}>
<thead>
<tr style={{ borderTop: "1px solid #1a1a18", borderBottom: "1px solid #1a1a18" }}>
<th style={{ textAlign: "left", padding: "4px 0", fontSize: 8, letterSpacing: "0.1em", color: "#888", fontWeight: 500 }}>TEILLEISTUNG</th>
<th className="align-right" style={{ textAlign: "right", padding: "4px 4px", fontSize: 8, letterSpacing: "0.1em", color: "#888", fontWeight: 500, width: 45 }}>%</th>
<th className="align-right" style={{ textAlign: "right", padding: "4px 4px", fontSize: 8, letterSpacing: "0.1em", color: "#888", fontWeight: 500, width: 72 }}>STUNDEN</th>
<th className="align-right" style={{ textAlign: "right", padding: "4px 0", fontSize: 8, letterSpacing: "0.1em", color: "#888", fontWeight: 500, width: 90 }}>HONORAR</th>
</tr>
</thead>
<tbody>
{siaCalc.phases.map(ph => (
<React.Fragment key={ph.id}>
<tr style={{ borderBottom: "1px solid #e8e8e8" }}>
<td colSpan={4} style={{ padding: "4px 0 1px", fontSize: 9, fontWeight: 600, letterSpacing: "0.04em" }}>
Phase {ph.id} · {ph.label}
</td>
</tr>
{ph.items.filter(it => it.enabled !== false && it.hours > 0).map((it, idx) => (
<tr key={idx} style={{ borderBottom: "1px solid #f2f2f2" }}>
<td style={{ padding: "1px 10px", color: "#555" }}>{it.label}</td>
<td className="align-right" style={{ textAlign: "right", padding: "1px 4px", color: "#888" }}>{it.pct}%</td>
<td className="align-right" style={{ textAlign: "right", padding: "1px 4px" }}>{formatHours(Math.round(it.hours * 60))}</td>
<td className="align-right" style={{ textAlign: "right", padding: "1px 0" }}>{formatCHF(it.hours * stundenansatz)}</td>
</tr>
))}
<tr>
<td colSpan={2} style={{ padding: "1px 0 4px", fontSize: 9, color: "#888", fontStyle: "italic" }}>Total Phase {ph.id}</td>
<td className="align-right" style={{ textAlign: "right", padding: "1px 4px 4px", fontWeight: 600 }}>{formatHours(Math.round(ph.hours * 60))}</td>
<td className="align-right" style={{ textAlign: "right", padding: "1px 0 4px", fontWeight: 600 }}>{formatCHF(ph.hours * stundenansatz)}</td>
</tr>
</React.Fragment>
))}
</tbody>
</table>
</>
)}
{/* Manueller Modus */}
{quote.mode === "manual" && manCalc && (
<>
<div style={{ fontSize: 10, letterSpacing: "0.12em", color: "#888", marginBottom: 8, paddingBottom: 6, borderBottom: "1.5px solid #1a1a18" }}>
HONORARSCHÄTZUNG STUNDENAUFWAND
</div>
<div style={{ display: "flex", gap: 14, flexWrap: "wrap", marginBottom: 14, fontSize: 10 }}>
{roles.map(r => (
<div key={r.id}><strong>{r.id}</strong> <span style={{ color: "#888" }}>{r.label} CHF {r.rate}./h</span></div>
))}
</div>
<table style={{ width: "100%", borderCollapse: "collapse", marginBottom: 22, fontSize: 10 }}>
<thead>
<tr style={{ borderTop: "1px solid #1a1a18", borderBottom: "1px solid #1a1a18" }}>
<th style={{ textAlign: "left", padding: "8px 0", fontSize: 9, letterSpacing: "0.1em", color: "#888", fontWeight: 500 }}>PHASE</th>
{roles.map(r => (
<th key={r.id} className="align-right" style={{ textAlign: "right", padding: "8px 4px", fontSize: 9, letterSpacing: "0.1em", color: "#888", fontWeight: 500, width: 40 }}>{r.id}</th>
))}
<th className="align-right" style={{ textAlign: "right", padding: "8px 6px", fontSize: 9, letterSpacing: "0.1em", color: "#888", fontWeight: 500, width: 60 }}>Std</th>
<th className="align-right" style={{ textAlign: "right", padding: "8px 0", fontSize: 9, letterSpacing: "0.1em", color: "#888", fontWeight: 500, width: 100 }}>Honorar</th>
</tr>
</thead>
<tbody>
{manCalc.phases.filter(p => p.totalHours > 0).map(ph => (
<tr key={ph.id} style={{ borderBottom: "1px solid #f0f0f0" }}>
<td style={{ padding: "6px 0" }}>{ph.label}</td>
{roles.map(r => {
const h = ph.roleDetails.find(rd => rd.id === r.id)?.hours || 0;
return <td key={r.id} className="align-right" style={{ textAlign: "right", padding: "6px 4px", color: h ? "#1a1a18" : "#ccc" }}>{h || "—"}</td>;
})}
<td className="align-right" style={{ textAlign: "right", padding: "6px 6px", fontWeight: 500 }}>{ph.totalHours}</td>
<td className="align-right" style={{ textAlign: "right", padding: "6px 0" }}>{formatCHF(ph.totalAmount)}</td>
</tr>
))}
</tbody>
</table>
</>
)}
{/* Freier Modus */}
{quote.mode === "free" && (quote.freeItems || []).length > 0 && (
<>
<div style={{ fontSize: 10, letterSpacing: "0.12em", color: "#888", marginBottom: 8, paddingBottom: 6, borderBottom: "1.5px solid #1a1a18" }}>
LEISTUNGEN / POSITIONEN
</div>
<table style={{ width: "100%", borderCollapse: "collapse", marginBottom: 22, fontSize: 10 }}>
<thead>
<tr style={{ borderTop: "1px solid #1a1a18", borderBottom: "1px solid #1a1a18" }}>
<th style={{ textAlign: "left", padding: "8px 0", fontSize: 9, letterSpacing: "0.1em", color: "#888", fontWeight: 500 }}>BESCHREIBUNG</th>
<th className="align-right" style={{ textAlign: "right", padding: "8px 6px", fontSize: 9, letterSpacing: "0.1em", color: "#888", fontWeight: 500, width: 60 }}>MENGE</th>
<th className="align-right" style={{ textAlign: "right", padding: "8px 6px", fontSize: 9, letterSpacing: "0.1em", color: "#888", fontWeight: 500, width: 90 }}>PREIS</th>
<th className="align-right" style={{ textAlign: "right", padding: "8px 0", fontSize: 9, letterSpacing: "0.1em", color: "#888", fontWeight: 500, width: 100 }}>TOTAL</th>
</tr>
</thead>
<tbody>
{(quote.freeItems || []).map((it, idx) => (
<tr key={idx} style={{ borderBottom: "1px solid #f0f0f0" }}>
<td style={{ padding: "6px 0" }}>{it.desc || "—"}</td>
<td className="align-right" style={{ textAlign: "right", padding: "6px 6px" }}>{it.qty}</td>
<td className="align-right" style={{ textAlign: "right", padding: "6px 6px" }}>{formatCHF(it.price)}</td>
<td className="align-right" style={{ textAlign: "right", padding: "6px 0", fontWeight: 500 }}>{formatCHF(it.qty * it.price)}</td>
</tr>
))}
</tbody>
</table>
</>
)}
{/* Total */}
<div style={{ display: "flex", justifyContent: "flex-end", marginBottom: 16 }}>
<div style={{ width: 320, fontSize: 11 }}>
<div style={{ display: "flex", justifyContent: "space-between", padding: "4px 0" }}>
<span style={{ color: "#666" }}>Netto</span>
<span>{formatCHF(quote.sub)}</span>
</div>
{quote.mwst && (
<div style={{ display: "flex", justifyContent: "space-between", padding: "4px 0" }}>
<span style={{ color: "#666" }}>MWST {taxRate}%</span>
<span>{formatCHF(quote.tax)}</span>
</div>
)}
<div style={{ display: "flex", justifyContent: "space-between", padding: "10px 0", borderTop: "2px solid #1a1a18", marginTop: 6, fontFamily: "'Playfair Display', serif", fontSize: 15, fontWeight: 700 }}>
<span>Offertsumme</span>
<span>{formatCHF(quote.total)}</span>
</div>
</div>
</div>
<div style={{ fontSize: 9, color: "#888", borderTop: "1px solid #ddd", paddingTop: 14, lineHeight: 1.6 }}>
Diese Offerte ist unverbindlich und {quote.validUntil ? `gültig bis ${formatDate(quote.validUntil)}` : "zeitlich unbegrenzt gültig"}.
{quote.mode !== "free" && " Honorar gemäss SIA-Ordnung 102."} Änderungen am Auftragsumfang können zu einer Anpassung führen.
</div>
</>
);
}
export
function LohnPrint({ entry, emp, data, monatLabel, settings }) {
const spesen = (data.expenses || []).filter(e => e.lohnEntryId === entry.id);
// Immer den gespeicherten Snapshot verwenden — nie live emp-Werte
const s = entry.saetzeSnapshot || emp;
const LRow = ({ label, betrag, satz, bold, sub, color, topBorder }) => (
<tr style={{ borderTop: topBorder ? "1.5px solid #1a1a18" : "1px solid #ece8e2", background: bold ? "#faf8f5" : "white" }}>
<td style={{ padding: "5px 10px", fontSize: sub ? 11 : 12, color: sub ? "#666" : "#1a1a18", paddingLeft: sub ? 24 : 10 }}>{label}</td>
<td style={{ padding: "5px 10px", fontSize: 11, color: "#888", textAlign: "right" }}>{satz || ""}</td>
<td style={{ padding: "5px 10px", fontSize: bold ? 13 : 12, fontWeight: bold ? 700 : 400, textAlign: "right", color: color || (bold ? "#1a1a18" : "#333") }}>{formatCHF(betrag)}</td>
</tr>
);
return (
<div style={{ fontFamily: "'DM Mono', monospace", color: "#1a1a18", width: "100%", maxWidth: 760, margin: "0 auto", fontSize: 12 }}>
{/* Header: Arbeitgeber links, Arbeitnehmer rechts */}
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start", marginBottom: 32, paddingBottom: 20, borderBottom: "2px solid #1a1a18" }}>
<div>
<StudioLogo settings={settings} size={22} />
<div style={{ fontSize: 11, color: "#888", marginTop: 8, lineHeight: 1.7 }}>
{formatSenderAddress(settings).split("\n").map((l,i) => <div key={i}>{l}</div>)}
{settings.email && <div>{settings.email}</div>}
</div>
</div>
<div style={{ textAlign: "right" }}>
<div style={{ fontSize: 10, letterSpacing: "0.15em", color: "#888", marginBottom: 6 }}>LOHNABRECHNUNG</div>
<div style={{ fontFamily: "'Playfair Display', serif", fontSize: 22, fontWeight: 400, marginBottom: 2 }}>{entry.empSnapshot?.name || emp.name}</div>
{emp.role && <div style={{ fontSize: 11, color: "#888" }}>{emp.role}</div>}
{emp.personalNr && <div style={{ fontSize: 11, color: "#888" }}>Personal-Nr. {emp.personalNr}</div>}
{emp.ahvNr && <div style={{ fontSize: 11, color: "#888" }}>AHV-Nr. {emp.ahvNr}</div>}
{emp.adresse && <div style={{ fontSize: 11, color: "#888", marginTop: 4 }}>{emp.adresse}</div>}
{emp.ort && <div style={{ fontSize: 11, color: "#888" }}>{emp.ort}</div>}
</div>
</div>
{/* Periode */}
<div style={{ display: "flex", gap: 40, marginBottom: 24 }}>
<div>
<div style={{ fontSize: 10, letterSpacing: "0.1em", color: "#888" }}>PERIODE</div>
<div style={{ fontSize: 14, fontWeight: 500, marginTop: 2 }}>{monatLabel}</div>
</div>
<div>
<div style={{ fontSize: 10, letterSpacing: "0.1em", color: "#888" }}>ABRECHNUNGSDATUM</div>
<div style={{ fontSize: 14, marginTop: 2 }}>{new Date(entry.createdAt).toLocaleDateString("de-CH")}</div>
</div>
{emp.eintrittsdatum && (
<div>
<div style={{ fontSize: 10, letterSpacing: "0.1em", color: "#888" }}>EINTRITTSDATUM</div>
<div style={{ fontSize: 14, marginTop: 2 }}>{formatDate(emp.eintrittsdatum)}</div>
</div>
)}
<div>
<div style={{ fontSize: 10, letterSpacing: "0.1em", color: "#888" }}>PENSUM</div>
<div style={{ fontSize: 14, marginTop: 2 }}>{s.pensum || 100}%</div>
</div>
</div>
{/* Lohn-Tabelle */}
<table style={{ width: "100%", borderCollapse: "collapse", marginBottom: 24 }}>
<thead>
<tr style={{ borderBottom: "1.5px solid #1a1a18" }}>
<th style={{ textAlign: "left", padding: "6px 10px", fontSize: 10, letterSpacing: "0.1em", color: "#888", fontWeight: 500 }}>POSITION</th>
<th style={{ textAlign: "right", padding: "6px 10px", fontSize: 10, letterSpacing: "0.1em", color: "#888", fontWeight: 500 }}>SATZ</th>
<th style={{ textAlign: "right", padding: "6px 10px", fontSize: 10, letterSpacing: "0.1em", color: "#888", fontWeight: 500 }}>BETRAG CHF</th>
</tr>
</thead>
<tbody>
<LRow label={`Monatslohn ${monatLabel}${(s.pensum || 100) < 100 ? ` (${s.pensum}%)` : ""}`} betrag={entry.brutto} />
{(s.pensum || 100) < 100 && entry.bruttoBase != null && entry.bruttoBase !== entry.brutto && (
<LRow label="Basis 100%" betrag={entry.bruttoBase} sub />
)}
{entry.dreizehnter > 0 && <LRow label="13. Monatslohn (1/12)" betrag={entry.dreizehnter} sub />}
{entry.bonusBetrag > 0 && <LRow label={entry.bonusBeschrieb || "Einmalzahlung / Bonus"} betrag={entry.bonusBetrag} sub />}
<LRow label="Bruttolohn" betrag={entry.bruttoTotal} bold topBorder />
{/* Spacer */}
<tr><td colSpan={3} style={{ padding: "4px 0", borderBottom: "none" }}><div style={{ fontSize: 10, letterSpacing: "0.1em", color: "#888", padding: "8px 10px 2px" }}>ABZÜGE ARBEITNEHMER</div></td></tr>
<LRow label="AHV / IV / EO" satz={`${s.ahvSatz ?? 5.3}%`} betrag={-entry.ahv} sub color="#8a1a1a" />
<LRow label="ALV Arbeitslosenversicherung" satz={`${s.alvSatz ?? 1.1}%`} betrag={-entry.alv} sub color="#8a1a1a" />
<LRow label="BVG Berufliche Vorsorge / PK" satz={`${s.bvgSatz ?? 8.0}%`} betrag={-entry.bvg} sub color="#8a1a1a" />
<LRow label="NBU Nichtberufsunfallversicherung" satz={`${s.nbuSatz ?? 1.5}%`} betrag={-entry.nbu} sub color="#8a1a1a" />
<LRow label="KTG Krankentaggeldversicherung" satz={`${s.ktgSatz ?? 0.5}%`} betrag={-entry.ktg} sub color="#8a1a1a" />
{entry.qst > 0 && <LRow label="Quellensteuer" satz={`${s.quellensteuerSatz ?? 10}%`} betrag={-entry.qst} sub color="#8a1a1a" />}
<LRow label="Nettolohn" betrag={entry.netto} bold topBorder />
{spesen.length > 0 && <>
<tr><td colSpan={3} style={{ padding: "4px 0", borderBottom: "none" }}><div style={{ fontSize: 10, letterSpacing: "0.1em", color: "#888", padding: "8px 10px 2px" }}>SPESENERSTATTUNG</div></td></tr>
{spesen.map(s => <LRow key={s.id} label={`${s.category}${s.description ? " — " + s.description : ""}`} betrag={s.amount} sub />)}
</>}
<LRow label="AUSZAHLUNG TOTAL" betrag={entry.auszahlung} bold topBorder color="#2d6a4f" />
</tbody>
</table>
{/* Überweisungsdetails */}
{emp.lohnIban && (
<div style={{ padding: "14px 16px", background: "#faf8f5", border: "1px solid #e0dbd4", borderRadius: 6, marginBottom: 24 }}>
<div style={{ fontSize: 10, letterSpacing: "0.1em", color: "#888", marginBottom: 6 }}>ÜBERWEISUNG AUF</div>
<div style={{ fontSize: 13, fontWeight: 500 }}>{formatIban(emp.lohnIban)}</div>
{emp.name && <div style={{ fontSize: 11, color: "#888", marginTop: 2 }}>{emp.name}{emp.ort ? ` · ${emp.ort}` : ""}</div>}
</div>
)}
{/* Arbeitgeber-Anteile (informativ, klein) */}
<div style={{ padding: "12px 16px", border: "1px solid #e0dbd4", borderRadius: 6, marginBottom: 24 }}>
<div style={{ fontSize: 10, letterSpacing: "0.1em", color: "#888", marginBottom: 8 }}>ARBEITGEBERANTEILE (informativ)</div>
<div style={{ display: "grid", gridTemplateColumns: "repeat(4, 1fr)", gap: 12 }}>
{[
{ label: "AHV/IV/EO", val: Math.round(entry.bruttoTotal * (s.ahvSatz ?? 5.3) / 100 * 100)/100 },
{ label: "ALV", val: Math.round(entry.bruttoTotal * (s.alvSatz ?? 1.1) / 100 * 100)/100 },
{ label: "BVG/PK (AG)", val: Math.round(entry.bruttoTotal * (s.bvgSatz ?? 8.0) / 100 * 100)/100 },
{ label: "UVG BU", val: Math.round(entry.bruttoTotal * 0.5 / 100 * 100)/100 },
].map(r => (
<div key={r.label}>
<div style={{ fontSize: 10, color: "#aaa" }}>{r.label}</div>
<div style={{ fontSize: 12, marginTop: 2 }}>{formatCHF(r.val)}</div>
</div>
))}
</div>
</div>
<div style={{ fontSize: 10, color: "#aaa", borderTop: "1px solid #e0dbd4", paddingTop: 10 }}>
Lohnabrechnung gemäss OR Art. 323b · {settings.name}{settings.mwst ? ` · ${settings.mwst}` : ""}
</div>
</div>
);
}
export
function BuchhaltungPrint({ data, filterYear, settings }) {
const mwstRate = settings.mwstRate || 8.1;
const invoices = [...data.invoices]
.filter(i => !filterYear || (i.date || "").startsWith(filterYear))
.sort((a, b) => (a.date || "").localeCompare(b.date || ""));
const expenses = [...(data.expenses || [])]
.filter(e => !filterYear || (e.date || "").startsWith(filterYear))
.sort((a, b) => (a.date || "").localeCompare(b.date || ""));
const loehne = [...(data.lohnEntries || [])]
.filter(l => !filterYear || l.monat.startsWith(filterYear))
.sort((a, b) => a.monat.localeCompare(b.monat));
const totalInvoicedNet = invoices.reduce((s, i) => s + (i.sub || 0), 0);
const totalInvoicedTax = invoices.reduce((s, i) => s + (i.tax || 0), 0);
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;
const totalLoehne = loehne.reduce((s, l) => s + (l.auszahlung || 0), 0);
const totalAusgaben = totalExpNet + totalLoehne;
const thStyle = { textAlign: "left", padding: "6px 0", fontSize: 8, letterSpacing: "0.1em", color: "#888", fontWeight: 500, borderBottom: "1px solid #1a1a18" };
const thR = { ...thStyle, textAlign: "right" };
const tdStyle = { padding: "5px 0", fontSize: 9, borderBottom: "1px solid #f0f0f0" };
const tdR = { ...tdStyle, textAlign: "right" };
return (
<>
{/* Header */}
<div style={{ display: "flex", justifyContent: "space-between", marginBottom: 30 }}>
<div>
<StudioLogo settings={settings} size={22} />
<div style={{ whiteSpace: "pre-line", marginTop: 4, fontSize: 10, color: "#666" }}>{formatSenderAddress(settings)}</div>
</div>
<div style={{ textAlign: "right" }}>
<div style={{ fontSize: 10, letterSpacing: "0.15em", color: "#888" }}>BUCHHALTUNGSÜBERSICHT</div>
<div style={{ fontFamily: "'Playfair Display', serif", fontSize: 20, marginTop: 4 }}>{filterYear || "Alle Jahre"}</div>
<div style={{ fontSize: 9, color: "#888", marginTop: 4 }}>{new Date().toLocaleDateString("de-CH")}</div>
</div>
</div>
{/* Zusammenfassung */}
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 20, marginBottom: 24, padding: "14px 16px", background: "#faf8f5", borderRadius: 6, border: "1px solid #e0dbd4" }}>
<div>
<div style={{ fontSize: 8, letterSpacing: "0.12em", color: "#888", marginBottom: 8 }}>EINNAHMEN</div>
{[
{ label: "Umsatz (Netto)", value: totalInvoicedNet },
{ label: `MWST ${mwstRate}%`, value: totalInvoicedTax, small: true },
{ label: "Umsatz (Brutto)", value: totalInvoicedNet + totalInvoicedTax, bold: true },
].map((r, i) => (
<div key={i} style={{ display: "flex", justifyContent: "space-between", padding: "2px 0", borderTop: r.bold ? "1px solid #ccc" : "none", marginTop: r.bold ? 4 : 0 }}>
<span style={{ fontSize: r.small ? 8 : 9, color: r.small ? "#888" : "#555" }}>{r.label}</span>
<span style={{ fontSize: r.small ? 8 : 9, fontWeight: r.bold ? 700 : 400 }}>{formatCHF(r.value)}</span>
</div>
))}
</div>
<div>
<div style={{ fontSize: 8, letterSpacing: "0.12em", color: "#888", marginBottom: 8 }}>AUSGABEN &amp; ERGEBNIS</div>
{[
{ label: "Spesen / Ausgaben (Netto)", value: totalExpNet },
{ label: "Vorsteuer", value: totalExpTax, small: true },
{ label: `Personalaufwand / Löhne (${loehne.length})`, value: totalLoehne },
{ label: "Ergebnis (Netto)", value: totalInvoicedNet - totalAusgaben, bold: true },
{ label: "MWST-Schuld", value: totalInvoicedTax - totalExpTax, bold: true, small: true },
].map((r, i) => (
<div key={i} style={{ display: "flex", justifyContent: "space-between", padding: "2px 0", borderTop: r.bold && !r.small ? "1px solid #ccc" : "none", marginTop: r.bold && !r.small ? 4 : 0 }}>
<span style={{ fontSize: r.small ? 8 : 9, color: r.small ? "#888" : "#555" }}>{r.label}</span>
<span style={{ fontSize: r.small ? 8 : 9, fontWeight: r.bold ? 700 : 400 }}>{formatCHF(r.value)}</span>
</div>
))}
</div>
</div>
{/* Rechnungen */}
<div style={{ marginBottom: 24 }}>
<div style={{ fontSize: 10, letterSpacing: "0.12em", color: "#888", marginBottom: 10, paddingBottom: 6, borderBottom: "1.5px solid #1a1a18" }}>
RECHNUNGEN ({invoices.length})
</div>
<table style={{ width: "100%", borderCollapse: "collapse" }}>
<thead>
<tr>
<th style={thStyle}>NR.</th>
<th style={thStyle}>DATUM</th>
<th style={thStyle}>KUNDE</th>
<th style={thStyle}>BESCHREIBUNG</th>
<th style={thR}>NETTO</th>
<th style={thR}>MWST</th>
<th style={thR}>TOTAL</th>
<th style={thR}>STATUS</th>
</tr>
</thead>
<tbody>
{invoices.map(inv => {
const client = ((data.persons||[]).filter(p=>p.isAuftraggeber)).find(c => c.id === inv.clientId);
const desc = (inv.items || []).map(it => it.desc).filter(Boolean).join(", ");
return (
<tr key={inv.id}>
<td style={tdStyle}><strong>{inv.number}</strong></td>
<td style={tdStyle}>{formatDate(inv.date)}</td>
<td style={tdStyle}>{client?.name || "—"}</td>
<td style={{ ...tdStyle, color: "#666", maxWidth: 140, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{desc || "—"}</td>
<td style={tdR}>{formatCHF(inv.sub)}</td>
<td style={{ ...tdR, color: "#888" }}>{inv.mwst ? formatCHF(inv.tax) : "—"}</td>
<td style={{ ...tdR, fontWeight: 600 }}>{formatCHF(inv.total)}</td>
<td style={{ ...tdR, color: STATUS_COLORS[inv.status] || "#888" }}>{inv.status}</td>
</tr>
);
})}
</tbody>
<tfoot>
<tr style={{ borderTop: "1px solid #1a1a18" }}>
<td colSpan={4} style={{ padding: "5px 0", fontSize: 9, color: "#888" }}>Total</td>
<td style={{ ...tdR, fontWeight: 700, borderBottom: "none" }}>{formatCHF(totalInvoicedNet)}</td>
<td style={{ ...tdR, fontWeight: 700, borderBottom: "none", color: "#888" }}>{formatCHF(totalInvoicedTax)}</td>
<td style={{ ...tdR, fontWeight: 700, borderBottom: "none" }}>{formatCHF(totalInvoicedNet + totalInvoicedTax)}</td>
<td style={{ borderBottom: "none" }}></td>
</tr>
</tfoot>
</table>
</div>
{/* Spesen */}
<div style={{ marginBottom: 24 }}>
<div style={{ fontSize: 10, letterSpacing: "0.12em", color: "#888", marginBottom: 10, paddingBottom: 6, borderBottom: "1.5px solid #1a1a18" }}>
SPESEN / AUSGABEN ({expenses.length})
</div>
<table style={{ width: "100%", borderCollapse: "collapse" }}>
<thead>
<tr>
<th style={thStyle}>DATUM</th>
<th style={thStyle}>KATEGORIE</th>
<th style={thStyle}>PROJEKT</th>
<th style={thStyle}>BESCHREIBUNG</th>
<th style={thR}>NETTO</th>
<th style={thR}>VORSTEUER</th>
<th style={thR}>BRUTTO</th>
</tr>
</thead>
<tbody>
{expenses.map(e => {
const proj = data.projects.find(p => p.id === e.projectId);
const net = e.inclMwst ? e.amount / (1 + (e.mwstRate || 0) / 100) : e.amount;
const tax = e.amount - net;
return (
<tr key={e.id}>
<td style={tdStyle}>{formatDate(e.date)}</td>
<td style={tdStyle}>{e.category}</td>
<td style={{ ...tdStyle, color: "#666" }}>{proj?.name || "—"}</td>
<td style={{ ...tdStyle, color: "#666" }}>{e.description || "—"}</td>
<td style={tdR}>{formatCHF(net)}</td>
<td style={{ ...tdR, color: "#888" }}>{e.mwstRate > 0 ? formatCHF(tax) : "—"}</td>
<td style={{ ...tdR, fontWeight: 600 }}>{formatCHF(e.amount)}</td>
</tr>
);
})}
</tbody>
<tfoot>
<tr style={{ borderTop: "1px solid #1a1a18" }}>
<td colSpan={4} style={{ padding: "5px 0", fontSize: 9, color: "#888" }}>Total</td>
<td style={{ ...tdR, fontWeight: 700, borderBottom: "none" }}>{formatCHF(totalExpNet)}</td>
<td style={{ ...tdR, fontWeight: 700, borderBottom: "none", color: "#888" }}>{formatCHF(totalExpTax)}</td>
<td style={{ ...tdR, fontWeight: 700, borderBottom: "none" }}>{formatCHF(totalExpBrutto)}</td>
</tr>
</tfoot>
</table>
</div>
{/* Personalaufwand / Löhne */}
{loehne.length > 0 && (
<div style={{ marginBottom: 24 }}>
<div style={{ fontSize: 10, letterSpacing: "0.12em", color: "#888", marginBottom: 10, paddingBottom: 6, borderBottom: "1.5px solid #1a1a18" }}>
PERSONALAUFWAND / LÖHNE ({loehne.length})
</div>
<table style={{ width: "100%", borderCollapse: "collapse" }}>
<thead>
<tr>
<th style={thStyle}>MONAT</th>
<th style={thStyle}>MITARBEITER</th>
<th style={thR}>BRUTTO</th>
<th style={thR}>ABZÜGE AN</th>
<th style={thR}>NETTO</th>
<th style={thR}>SPESEN</th>
<th style={thR}>AUSZAHLUNG</th>
</tr>
</thead>
<tbody>
{loehne.map(l => (
<tr key={l.id}>
<td style={tdStyle}>{l.monat}</td>
<td style={tdStyle}>{l.empSnapshot?.name || "—"}</td>
<td style={tdR}>{formatCHF(l.bruttoTotal)}</td>
<td style={{ ...tdR, color: "#8a1a1a" }}>{formatCHF(l.totalAbzuege)}</td>
<td style={tdR}>{formatCHF(l.netto)}</td>
<td style={{ ...tdR, color: "#888" }}>{l.spesenTotal > 0 ? formatCHF(l.spesenTotal) : "—"}</td>
<td style={{ ...tdR, fontWeight: 600 }}>{formatCHF(l.auszahlung)}</td>
</tr>
))}
</tbody>
<tfoot>
<tr style={{ borderTop: "1px solid #1a1a18" }}>
<td colSpan={4} style={{ padding: "5px 0", fontSize: 9, color: "#888" }}>Total Auszahlungen</td>
<td style={{ borderBottom: "none" }}></td>
<td style={{ borderBottom: "none" }}></td>
<td style={{ ...tdR, fontWeight: 700, borderBottom: "none" }}>{formatCHF(totalLoehne)}</td>
</tr>
</tfoot>
</table>
</div>
)}
<div style={{ marginTop: 24, paddingTop: 14, borderTop: "1px solid #ddd", fontSize: 8, color: "#aaa" }}>
{settings.name} · {settings.mwst} · Erstellt am {new Date().toLocaleDateString("de-CH")}
</div>
</>
);
}
function MitarbeiterOverviewPrint({ employees, settings }) {
const active = employees.filter(e => e.status !== "inaktiv");
const inactive = employees.filter(e => e.status === "inaktiv");
const Row = ({ label, value }) => value ? (
<div style={{ display: "flex", gap: 8, fontSize: 10, borderBottom: "1px solid #f0ece6", padding: "3px 0" }}>
<span style={{ color: "#888", minWidth: 120 }}>{label}</span>
<span>{value}</span>
</div>
) : null;
const EmpCard = ({ emp }) => (
<div style={{ breakInside: "avoid", marginBottom: 16, padding: "12px 14px", border: "1px solid #e8e4de", borderRadius: 6 }}>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start", marginBottom: 8 }}>
<div>
<div style={{ fontSize: 13, fontWeight: 600 }}>{emp.name}</div>
{emp.role && <div style={{ fontSize: 10, color: "#888", marginTop: 2 }}>{emp.role}</div>}
</div>
{emp.personalNr && <div style={{ fontSize: 10, color: "#aaa", letterSpacing: "0.08em" }}>#{emp.personalNr}</div>}
</div>
<Row label="Pensum" value={emp.pensum != null ? `${emp.pensum}%` : null} />
<Row label="Wochenstunden" value={emp.wochenstunden ? `${emp.wochenstunden} h` : null} />
<Row label="Ferien" value={emp.ferienWochen ? `${emp.ferienWochen} Wochen` : null} />
<Row label="Eintrittsdatum" value={emp.eintrittsdatum ? formatDate(emp.eintrittsdatum) : null} />
<Row label="Monatslohn" value={emp.monatslohn ? formatCHF(emp.monatslohn) : null} />
</div>
);
return (
<>
<div style={{ display: "flex", justifyContent: "space-between", marginBottom: 50 }}>
<div>
<StudioLogo settings={settings} size={22} />
<div style={{ whiteSpace: "pre-line", marginTop: 4, fontSize: 10, color: "#666" }}>{formatSenderAddress(settings)}</div>
</div>
<div style={{ textAlign: "right", fontSize: 10, color: "#666" }}>
<div style={{ letterSpacing: "0.15em" }}>MITARBEITERÜBERSICHT</div>
<div style={{ marginTop: 4 }}>{new Date().toLocaleDateString("de-CH")}</div>
</div>
</div>
<div style={{ marginBottom: 30, paddingBottom: 20, borderBottom: "2px solid #1a1a18" }}>
<div style={{ fontFamily: "'Playfair Display', serif", fontSize: 26, fontWeight: 400 }}>Mitarbeiter</div>
<div style={{ fontSize: 11, color: "#666", marginTop: 4 }}>{employees.length} Mitarbeitende · {active.length} aktiv</div>
</div>
{active.length > 0 && (
<div style={{ marginBottom: 28 }}>
<div style={{ fontSize: 10, letterSpacing: "0.12em", color: "#888", marginBottom: 12, paddingBottom: 6, borderBottom: "1px solid #1a1a18" }}>AKTIV {active.length}</div>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 10 }}>
{active.map(e => <EmpCard key={e.id} emp={e} />)}
</div>
</div>
)}
{inactive.length > 0 && (
<div>
<div style={{ fontSize: 10, letterSpacing: "0.12em", color: "#888", marginBottom: 12, paddingBottom: 6, borderBottom: "1px solid #ccc" }}>INAKTIV {inactive.length}</div>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 10 }}>
{inactive.map(e => <EmpCard key={e.id} emp={e} />)}
</div>
</div>
)}
</>
);
}
function TimeReportPrint({ employee, entries, month, data, settings }) {
const monthEntries = entries.filter(e => e.date.startsWith(month))
.sort((a, b) => a.date.localeCompare(b.date) || (a.startTime || "").localeCompare(b.startTime || ""));
const projects = data.projects || [];
const getProj = (id) => projects.find(p => p.id === id);
const totalMins = monthEntries.reduce((s, e) => s + (e.minutes || 0), 0);
const byProject = {};
monthEntries.forEach(e => {
const k = e.projectId || "__none__";
if (!byProject[k]) byProject[k] = { proj: getProj(e.projectId), mins: 0 };
byProject[k].mins += e.minutes || 0;
});
const monthLabel = new Date(month + "-01").toLocaleDateString("de-CH", { month: "long", year: "numeric" });
return (
<>
<div style={{ display: "flex", justifyContent: "space-between", marginBottom: 50 }}>
<div>
<StudioLogo settings={settings} size={22} />
<div style={{ whiteSpace: "pre-line", marginTop: 4, fontSize: 10, color: "#666" }}>{formatSenderAddress(settings)}</div>
</div>
<div style={{ textAlign: "right", fontSize: 10, color: "#666" }}>
<div style={{ letterSpacing: "0.15em" }}>STUNDENRAPPORT</div>
<div style={{ marginTop: 4 }}>{new Date().toLocaleDateString("de-CH")}</div>
</div>
</div>
<div style={{ marginBottom: 30, paddingBottom: 20, borderBottom: "2px solid #1a1a18" }}>
<div style={{ fontFamily: "'Playfair Display', serif", fontSize: 26, fontWeight: 400 }}>{employee?.name}</div>
<div style={{ fontSize: 11, color: "#666", marginTop: 4 }}>{monthLabel} · {formatHours(totalMins)}</div>
</div>
{/* Zusammenfassung */}
<div style={{ marginBottom: 24 }}>
<div style={{ fontSize: 10, letterSpacing: "0.12em", color: "#888", marginBottom: 8, paddingBottom: 4, borderBottom: "1px solid #e0dbd4" }}>ZUSAMMENFASSUNG PRO PROJEKT</div>
{Object.values(byProject).sort((a, b) => b.mins - a.mins).map(({ proj, mins }) => (
<div key={proj?.id || "none"} style={{ display: "flex", justifyContent: "space-between", fontSize: 11, padding: "3px 0", borderBottom: "1px solid #f4f0ea" }}>
<span>{proj ? `${proj.number ? proj.number + " " : ""}${proj.name}` : "Kein Projekt"}</span>
<strong>{formatHours(mins)}</strong>
</div>
))}
<div style={{ display: "flex", justifyContent: "space-between", fontSize: 12, fontFamily: "'Playfair Display', serif", padding: "8px 0 0", marginTop: 4, borderTop: "1.5px solid #1a1a18" }}>
<span>Total</span><strong>{formatHours(totalMins)}</strong>
</div>
</div>
{/* Detailliste */}
<div style={{ fontSize: 10, letterSpacing: "0.12em", color: "#888", marginBottom: 8, paddingBottom: 4, borderBottom: "1px solid #e0dbd4" }}>EINZELEINTRÄGE</div>
<table style={{ width: "100%", borderCollapse: "collapse", fontSize: 10 }}>
<thead>
<tr style={{ borderBottom: "1px solid #1a1a18" }}>
{["Datum", "Projekt", "Zeit", "Stunden", "Notiz"].map(h => (
<th key={h} style={{ textAlign: "left", padding: "4px 6px", fontWeight: 600, letterSpacing: "0.06em", color: "#555" }}>{h}</th>
))}
</tr>
</thead>
<tbody>
{monthEntries.map(e => {
const proj = getProj(e.projectId);
return (
<tr key={e.id} style={{ borderBottom: "1px solid #f0ece6" }}>
<td style={{ padding: "4px 6px", whiteSpace: "nowrap" }}>{formatDate(e.date)}</td>
<td style={{ padding: "4px 6px" }}>{proj ? `${proj.number ? proj.number + " " : ""}${proj.name}` : "—"}</td>
<td style={{ padding: "4px 6px", whiteSpace: "nowrap", color: "#888" }}>{e.startTime && e.endTime ? `${e.startTime}${e.endTime}` : "—"}</td>
<td style={{ padding: "4px 6px", textAlign: "right", fontWeight: 600 }}>{formatHours(e.minutes || 0)}</td>
<td style={{ padding: "4px 6px", color: "#888" }}>{e.note || ""}</td>
</tr>
);
})}
</tbody>
</table>
{monthEntries.length === 0 && (
<div style={{ textAlign: "center", color: "#aaa", padding: "32px 0", fontSize: 12 }}>Keine Einträge für diesen Monat</div>
)}
</>
);
}