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>
1660 lines
93 KiB
React
Executable File
1660 lines
93 KiB
React
Executable File
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 & 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>
|
||
)}
|
||
</>
|
||
);
|
||
}
|