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>
This commit is contained in:
2026-05-13 01:16:26 +02:00
commit 00f07d76f6
65 changed files with 28010 additions and 0 deletions
+344
View File
@@ -0,0 +1,344 @@
import React, { useState } from "react";
import { generateId, formatCHF, formatDate, formatIban, calcLohn } from "../utils.js";
import { Header, useConfirm, NavArrows } from "../components/UI.jsx";
export default
function Payroll({ data, update, saveAll, setPrintContent, setView }) {
const now = new Date();
const [selYear, setSelYear] = useState(now.getFullYear());
const [selMonth, setSelMonth] = useState(now.getMonth());
const [selEmpId, setSelEmpId] = useState("");
const [bruttoOverride, setBruttoOverride] = useState({});
const [pensumOverride, setPensumOverride] = useState({});
const [bonusOverride, setBonusOverride] = useState({}); // empId -> bonus CHF
const { askConfirm, ConfirmModalEl } = useConfirm();
const employees = data.employees || [];
const lohnEntries = data.lohnEntries || [];
const months = ["Januar","Februar","März","April","Mai","Juni","Juli","August","September","Oktober","November","Dezember"];
const monatStr = `${selYear}-${String(selMonth + 1).padStart(2, "0")}`;
const todayYear = now.getFullYear();
const todayMonth = now.getMonth();
const isAtFuture = selYear > todayYear || (selYear === todayYear && selMonth >= todayMonth);
const goNext = () => {
if (isAtFuture) return;
if (selMonth === 11) { setSelMonth(0); setSelYear(y => y + 1); } else setSelMonth(m => m + 1);
};
const goPrev = () => {
if (selMonth === 0) { setSelMonth(11); setSelYear(y => y - 1); } else setSelMonth(m => m - 1);
};
const isMonatAvailable = (emp) => {
// Nicht in die Zukunft (aktueller Monat ist max)
if (selYear > todayYear || (selYear === todayYear && selMonth > todayMonth)) return false;
// Nicht vor Eintrittsdatum
if (emp.eintrittsdatum) {
const parts = emp.eintrittsdatum.split("-");
const ey = parseInt(parts[0]);
const em = parseInt(parts[1]) - 1; // 0-basiert
if (selYear < ey || (selYear === ey && selMonth < em)) return false;
}
return true;
};
const getEffBrutto = (emp) => bruttoOverride[emp.id] != null ? bruttoOverride[emp.id] : (emp.monatslohn || 0);
const getEffPensum = (emp) => pensumOverride[emp.id] != null ? pensumOverride[emp.id] : (emp.pensum || 100);
const offeneSpesen = (empId) => (data.expenses || []).filter(e =>
e.employeeId === empId && (e.date || "").startsWith(monatStr) && !e.lohnEntryId
);
const findEntry = (empId) => lohnEntries.find(l => l.monat === monatStr && l.employeeId === empId);
const abschliessen = (emp) => {
if (findEntry(emp.id)) return;
const spesen = offeneSpesen(emp.id);
const effBrutto = getEffBrutto(emp);
const effPensum = getEffPensum(emp);
const bonus = bonusOverride[emp.id] || 0;
const calc = calcLohn({ ...emp, monatslohn: effBrutto, pensum: effPensum }, monatStr, spesen, bonus);
// Alle Sätze statisch festhalten — unabhängig von späteren Änderungen
const saetzeSnapshot = {
ahvSatz: emp.ahvSatz ?? 5.3,
alvSatz: emp.alvSatz ?? 1.1,
bvgSatz: emp.bvgSatz ?? 8.0,
nbuSatz: emp.nbuSatz ?? 1.5,
ktgSatz: emp.ktgSatz ?? 0.5,
quellensteuerPflichtig: emp.quellensteuerPflichtig || false,
quellensteuerSatz: emp.quellensteuerSatz ?? 10,
dreizehnterLohn: emp.dreizehnterLohn || false,
pensum: effPensum,
eintrittsdatum: emp.eintrittsdatum || "",
pkAGSatz: emp.pkAGSatz ?? 8.0,
};
const entry = {
id: generateId(), monat: monatStr, employeeId: emp.id,
empSnapshot: { name: emp.name, role: emp.role, adresse: emp.adresse, ort: emp.ort, ahvNr: emp.ahvNr, personalNr: emp.personalNr, lohnIban: emp.lohnIban },
saetzeSnapshot,
bruttoWasOverridden: bruttoOverride[emp.id] != null,
bonusBeschrieb: bonusOverride[`${emp.id}_beschrieb`] || "",
...calc, spesenIds: spesen.map(s => s.id),
createdAt: new Date().toISOString(),
};
const updatedExp = (data.expenses || []).map(e =>
spesen.some(s => s.id === e.id) ? { ...e, lohnEntryId: entry.id, status: "ausbezahlt" } : e
);
saveAll({ ...data, lohnEntries: [...lohnEntries, entry], expenses: updatedExp });
};
const stornieren = async (id) => {
if (!(await askConfirm("Lohnabrechnung stornieren? Spesen werden wieder freigegeben.", "Stornieren"))) return;
const updatedExp = (data.expenses || []).map(e => e.lohnEntryId === id ? { ...e, lohnEntryId: null, status: "auf nächsten Lohn" } : e);
saveAll({ ...data, lohnEntries: lohnEntries.filter(l => l.id !== id), expenses: updatedExp });
};
const selectedEmp = employees.find(e => e.id === selEmpId);
const previewSpesen = selEmpId ? offeneSpesen(selEmpId) : [];
const previewCalc = selectedEmp ? calcLohn({ ...selectedEmp, monatslohn: getEffBrutto(selectedEmp), pensum: getEffPensum(selectedEmp) }, monatStr, previewSpesen, bonusOverride[selectedEmp?.id] || 0) : null;
const existingEntry = selEmpId ? findEntry(selEmpId) : null;
const monatEntries = lohnEntries.filter(l => l.monat === monatStr);
const cardPensum = existingEntry ? (existingEntry.saetzeSnapshot?.pensum || 100) : (selectedEmp ? getEffPensum(selectedEmp) : 100);
const LohnRow = ({ label, value, bold, color, indent }) => (
<div style={{ display: "flex", justifyContent: "space-between", padding: "3px 0", fontSize: indent ? 11 : 12, color: color || (indent ? "#888" : "#555"), paddingLeft: indent ? 12 : 0 }}>
<span>{label}</span><span style={{ fontWeight: bold ? 700 : 400 }}>{formatCHF(value)}</span>
</div>
);
const renderCalc = (calc, emp, isPreview, displayPensum) => (
<div>
{isPreview && (
<div style={{ marginBottom: 12, padding: "8px 10px", background: "#faf8f5", border: "1px solid #e0dbd4", borderRadius: 4 }}>
<div style={{ fontSize: 10, letterSpacing: "0.08em", color: "#888", marginBottom: 6 }}>MONATSLOHN (100% BASIS) für diesen Lohnlauf anpassbar</div>
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<input type="number" step={100} min={0} value={getEffBrutto(emp)}
onChange={e => setBruttoOverride(o => ({ ...o, [emp.id]: +e.target.value }))}
style={{ flex: 1, height: 32, fontSize: 13, textAlign: "right", border: bruttoOverride[emp.id] != null && bruttoOverride[emp.id] !== emp.monatslohn ? "1.5px solid #b5621e" : "1px solid #c8c0b8" }} />
<span style={{ fontSize: 11, color: "#888" }}>CHF</span>
{bruttoOverride[emp.id] != null && bruttoOverride[emp.id] !== emp.monatslohn && (
<button onClick={() => setBruttoOverride(o => { const n={...o}; delete n[emp.id]; return n; })}
title="Stammdaten-Wert wiederherstellen"
style={{ fontSize: 11, color: "#888", background: "none", border: "1px solid #c8c0b8", borderRadius: 4, cursor: "pointer", padding: "2px 8px" }}> Reset</button>
)}
</div>
{bruttoOverride[emp.id] != null && bruttoOverride[emp.id] !== emp.monatslohn && (
<div style={{ fontSize: 10, color: "#b5621e", marginTop: 4 }}>Abweichend von Stammdaten ({formatCHF(emp.monatslohn || 0)})</div>
)}
<div style={{ fontSize: 10, letterSpacing: "0.08em", color: "#888", margin: "10px 0 6px", paddingTop: 8, borderTop: "1px solid #e0dbd4" }}>PENSUM DIESEN MONAT</div>
<div style={{ display: "flex", alignItems: "center", gap: 8, marginBottom: 4 }}>
<input type="number" min={10} max={100} step={5}
value={getEffPensum(emp)}
onChange={e => setPensumOverride(o => ({ ...o, [emp.id]: +e.target.value }))}
style={{ width: 72, height: 32, fontSize: 13, textAlign: "right", border: pensumOverride[emp.id] != null && pensumOverride[emp.id] !== (emp.pensum || 100) ? "1.5px solid #b5621e" : "1px solid #c8c0b8" }} />
<span style={{ fontSize: 11, color: "#888" }}>%</span>
{pensumOverride[emp.id] != null && pensumOverride[emp.id] !== (emp.pensum || 100) && (
<button onClick={() => setPensumOverride(o => { const n={...o}; delete n[emp.id]; return n; })}
title="Stammdaten-Wert wiederherstellen"
style={{ fontSize: 11, color: "#888", background: "none", border: "1px solid #c8c0b8", borderRadius: 4, cursor: "pointer", padding: "2px 8px" }}> Reset</button>
)}
</div>
{pensumOverride[emp.id] != null && pensumOverride[emp.id] !== (emp.pensum || 100) && (
<div style={{ fontSize: 10, color: "#b5621e", marginBottom: 4 }}>Abweichend von Stammdaten ({emp.pensum || 100}%)</div>
)}
<div style={{ fontSize: 10, letterSpacing: "0.08em", color: "#888", margin: "10px 0 6px", paddingTop: 8, borderTop: "1px solid #e0dbd4" }}>EINMALZAHLUNG / BONUS (AHV-pflichtig)</div>
<div style={{ display: "flex", gap: 8 }}>
<input type="number" step={100} min={0} value={bonusOverride[emp.id] || ""}
onChange={e => setBonusOverride(o => ({ ...o, [emp.id]: +e.target.value || 0 }))}
placeholder="0"
style={{ width: 110, height: 32, fontSize: 13, textAlign: "right", border: bonusOverride[emp.id] ? "1.5px solid #2d6a4f" : "1px solid #c8c0b8" }} />
<span style={{ fontSize: 11, color: "#888", lineHeight: "32px" }}>CHF</span>
<input value={bonusOverride[`${emp.id}_beschrieb`] || ""}
onChange={e => setBonusOverride(o => ({ ...o, [`${emp.id}_beschrieb`]: e.target.value }))}
placeholder="Beschrieb (z.B. Jahresbonus)"
style={{ flex: 1, height: 32, fontSize: 12, border: "1px solid #c8c0b8" }} />
</div>
</div>
)}
<div style={{ display: "flex", justifyContent: "space-between", padding: "3px 0", fontSize: 12, color: "#555" }}>
<span>Monatslohn {months[selMonth]} {selYear}{displayPensum < 100 ? ` (${displayPensum}%)` : ""}</span>
<span>{formatCHF(calc.brutto)}</span>
</div>
{displayPensum < 100 && calc.bruttoBase != null && calc.bruttoBase !== calc.brutto && (
<div style={{ display: "flex", justifyContent: "space-between", padding: "1px 0 3px 12px", fontSize: 10, color: "#888" }}>
<span>Basis 100%: {formatCHF(calc.bruttoBase)} × {displayPensum}%</span>
</div>
)}
{calc.dreizehnter > 0 && <LohnRow label="13. Monatslohn (1/12)" value={calc.dreizehnter} indent />}
{calc.bonusBetrag > 0 && <LohnRow label={emp.saetzeSnapshot ? (emp.bonusBeschrieb || "Einmalzahlung / Bonus") : (bonusOverride[`${emp.id}_beschrieb`] || "Einmalzahlung / Bonus")} value={calc.bonusBetrag} indent />}
<LohnRow label="Bruttolohn Total" value={calc.bruttoTotal} bold />
<div style={{ marginTop: 10, marginBottom: 4, fontSize: 10, letterSpacing: "0.08em", color: "#888" }}>ABZÜGE ARBEITNEHMER</div>
{(() => {
const s = emp.saetzeSnapshot || emp; // abgeschlossen: snapshot, preview: live emp
return <>
<LohnRow label={`AHV/IV/EO (${s.ahvSatz ?? 5.3}%)`} value={-calc.ahv} indent color="#8a1a1a" />
<LohnRow label={`ALV (${s.alvSatz ?? 1.1}%)`} value={-calc.alv} indent color="#8a1a1a" />
<LohnRow label={`BVG/PK (${s.bvgSatz ?? 8.0}%)`} value={-calc.bvg} indent color="#8a1a1a" />
<LohnRow label={`NBU (${s.nbuSatz ?? 1.5}%)`} value={-calc.nbu} indent color="#8a1a1a" />
<LohnRow label={`KTG (${s.ktgSatz ?? 0.5}%)`} value={-calc.ktg} indent color="#8a1a1a" />
{calc.qst > 0 && <LohnRow label={`Quellensteuer (${s.quellensteuerSatz ?? 10}%)`} value={-calc.qst} indent color="#8a1a1a" />}
</>;
})()}
<div style={{ borderTop: "1.5px solid #1a1a18", marginTop: 8, paddingTop: 8 }}>
<LohnRow label="Nettolohn" value={calc.netto} bold />
</div>
{calc.spesenTotal > 0 && <>
<div style={{ marginTop: 10, marginBottom: 4, fontSize: 10, letterSpacing: "0.08em", color: "#888" }}>SPESENERSTATTUNG</div>
<LohnRow label="Spesen" value={calc.spesenTotal} indent />
</>}
<div style={{ borderTop: "1.5px solid #1a1a18", marginTop: 8, paddingTop: 8 }}>
<LohnRow label="Auszahlung Total" value={calc.auszahlung} bold color="#2d6a4f" />
</div>
{calc.bvgAG > 0 && (() => {
const s = emp.saetzeSnapshot || emp;
return (
<div style={{ marginTop: 14, paddingTop: 10, borderTop: "1px dashed #c8c0b8" }}>
<div style={{ fontSize: 10, letterSpacing: "0.08em", color: "#888", marginBottom: 4 }}>LOHNKOSTEN ARBEITGEBER</div>
<LohnRow label={`PK / BVG AG-Anteil (${s.pkAGSatz ?? 8.0}%)`} value={calc.bvgAG} indent color="#b5621e" />
<LohnRow label="Gesamtlohnkosten (inkl. PK AG)" value={calc.auszahlung + calc.bvgAG} bold />
</div>
);
})()}
</div>
);
return (
<div>
{ConfirmModalEl}
<div style={{ display: "flex", alignItems: "center", gap: 10, marginBottom: 4 }}>
</div>
<Header title="Löhne" />
{employees.length === 0 ? (
<div className="card" style={{ padding: 0 }}>
<table>
<thead><tr><th>Name</th><th style={{ textAlign: "right" }}>Brutto</th><th style={{ textAlign: "right" }}>Netto</th><th style={{ textAlign: "right" }}>Spesen</th><th style={{ textAlign: "right" }}>Auszahlung</th><th>Status</th><th></th></tr></thead>
<tbody><tr><td colSpan={7} className="empty-state">Noch keine Mitarbeiter erfasst</td></tr></tbody>
</table>
</div>
) : (
<div className="responsive-grid-2" style={{ display: "grid", gridTemplateColumns: "1fr 340px", gap: 20, alignItems: "start" }}>
<div>
<div className="card" style={{ display: "flex", alignItems: "center", gap: 12, padding: "12px 20px", marginBottom: 16 }}>
<NavArrows onPrev={goPrev} onNext={goNext} disabledNext={isAtFuture} />
<div style={{ flex: 1, textAlign: "center", fontFamily: "'Playfair Display', serif", fontSize: 18 }}>{months[selMonth]} {selYear}</div>
</div>
<div className="card" style={{ padding: 0 }}>
<div className="panel-label">MITARBEITER</div>
<table>
<thead><tr>
<th>Name</th>
<th style={{ textAlign: "right" }}>Brutto</th>
<th style={{ textAlign: "right" }}>Netto</th>
<th style={{ textAlign: "right" }}>Spesen</th>
<th style={{ textAlign: "right" }}>Auszahlung</th>
<th>Status</th>
<th></th>
</tr></thead>
<tbody>
{employees.filter(e => e.monatslohn > 0).length === 0 && (
<tr><td colSpan={7} className="empty-state">Noch kein Monatslohn hinterlegt.</td></tr>
)}
{employees.filter(e => e.monatslohn > 0).map(emp => {
const available = isMonatAvailable(emp);
const entry = findEntry(emp.id);
const spesen = entry ? (data.expenses || []).filter(e => e.lohnEntryId === entry.id) : offeneSpesen(emp.id);
const effBrutto = getEffBrutto(emp);
const calc = entry ? entry : calcLohn({ ...emp, monatslohn: effBrutto, pensum: getEffPensum(emp) }, monatStr, spesen);
return (
<tr key={emp.id} onClick={() => available && setSelEmpId(emp.id)}
style={{ cursor: available ? "pointer" : "default", background: selEmpId === emp.id ? "#faf8f5" : "", opacity: available ? 1 : 0.35 }}>
<td>
<strong>{emp.name}</strong>
{emp.role && <div style={{ fontSize: 11, color: "#888" }}>{emp.role}</div>}
{!available && <div style={{ fontSize: 10, color: "#aaa" }}>vor Eintritt</div>}
</td>
<td style={{ textAlign: "right" }}>{available ? formatCHF(calc.bruttoTotal) : "—"}</td>
<td style={{ textAlign: "right" }}>{available ? formatCHF(calc.netto) : "—"}</td>
<td style={{ textAlign: "right", color: calc.spesenTotal > 0 ? "#b5621e" : "#aaa" }}>{available && calc.spesenTotal > 0 ? formatCHF(calc.spesenTotal) : "—"}</td>
<td style={{ textAlign: "right", fontWeight: 600 }}>{available ? formatCHF(calc.auszahlung) : "—"}</td>
<td>{!available ? null : entry
? <span style={{ fontSize: 10, color: "#2d6a4f", background: "#e8f5ee", border: "1px solid #b0d8c0", borderRadius: 20, padding: "2px 10px", fontWeight: 600 }}>Abgeschlossen</span>
: <span style={{ fontSize: 10, color: "#b5621e", background: "#fff8f0", border: "1px solid #f0d0a0", borderRadius: 20, padding: "2px 10px", fontWeight: 600 }}>Offen</span>}
</td>
<td style={{ textAlign: "right" }}>
{available && (entry
? <button className="btn btn-ghost" style={{ fontSize: 11, padding: "2px 8px" }} onClick={e => { e.stopPropagation(); stornieren(entry.id); }}>Stornieren</button>
: <button className="btn btn-primary" style={{ fontSize: 11, padding: "2px 10px" }} onClick={e => { e.stopPropagation(); abschliessen(emp); }}>Abschliessen</button>
)}
</td>
</tr>
);
})}
</tbody>
{monatEntries.length > 0 && (() => {
const t = monatEntries.reduce((s,l) => ({ b:s.b+l.bruttoTotal, n:s.n+l.netto, sp:s.sp+l.spesenTotal, a:s.a+l.auszahlung }), {b:0,n:0,sp:0,a:0});
return (
<tfoot>
<tr style={{ borderTop: "1.5px solid #1a1a18", fontWeight: 600 }}>
<td>Total</td>
<td style={{ textAlign: "right" }}>{formatCHF(t.b)}</td>
<td style={{ textAlign: "right" }}>{formatCHF(t.n)}</td>
<td style={{ textAlign: "right" }}>{formatCHF(t.sp)}</td>
<td style={{ textAlign: "right" }}>{formatCHF(t.a)}</td>
<td colSpan={2}></td>
</tr>
</tfoot>
);
})()}
</table>
</div>
</div>
<div>
{selectedEmp && isMonatAvailable(selectedEmp) ? (
<div className="card">
<div className="section-label" style={{ marginBottom: 4 }}>LOHNABRECHNUNG</div>
<div style={{ fontFamily: "'Playfair Display', serif", fontSize: 18, marginBottom: 2 }}>{selectedEmp.name}</div>
<div style={{ fontSize: 11, color: "#888", marginBottom: 16 }}>{[selectedEmp.role, `${months[selMonth]} ${selYear}`, cardPensum < 100 ? `${cardPensum}%` : null].filter(Boolean).join(" · ")}</div>
{existingEntry
? renderCalc(existingEntry, { ...selectedEmp, ...existingEntry.empSnapshot, saetzeSnapshot: existingEntry.saetzeSnapshot }, false, existingEntry.saetzeSnapshot?.pensum || 100)
: renderCalc(previewCalc, selectedEmp, true, getEffPensum(selectedEmp))
}
{(() => {
const sp = existingEntry ? (data.expenses||[]).filter(e => e.lohnEntryId === existingEntry.id) : previewSpesen;
return sp.length > 0 ? (
<div style={{ marginTop: 14, paddingTop: 12, borderTop: "1px solid #ece8e2" }}>
<div style={{ fontSize: 10, letterSpacing: "0.08em", color: "#888", marginBottom: 8 }}>SPESEN DETAIL</div>
{sp.map(s => (
<div key={s.id} style={{ display: "flex", justifyContent: "space-between", fontSize: 11, color: "#888", padding: "2px 0" }}>
<span>{s.category}{s.description ? `${s.description}` : ""}</span>
<span>{formatCHF(s.amount)}</span>
</div>
))}
</div>
) : null;
})()}
<div style={{ marginTop: 16, display: "flex", gap: 8 }}>
{existingEntry ? (
<>
<button className="btn btn-ghost" style={{ flex: 1, fontSize: 11 }} onClick={() => setPrintContent({ type: "lohn", entry: existingEntry, emp: selectedEmp, data, monatLabel: `${months[selMonth]} ${selYear}` })}>PDF</button>
<button className="btn btn-ghost" style={{ fontSize: 11, color: "#8a1a1a", borderColor: "#8a1a1a" }} onClick={() => stornieren(existingEntry.id)}>Stornieren</button>
</>
) : (
<button className="btn btn-primary" style={{ flex: 1, fontSize: 11 }}
onClick={() => abschliessen(selectedEmp)}
disabled={!selectedEmp.monatslohn && bruttoOverride[selectedEmp.id] == null}>
Lohnabrechnung abschliessen
</button>
)}
</div>
{!existingEntry && !selectedEmp.monatslohn && bruttoOverride[selectedEmp.id] == null && (
<div style={{ marginTop: 10, fontSize: 11, color: "#b5621e" }}>Kein Monatslohn hinterlegt.</div>
)}
</div>
) : (
<div className="card" style={{ color: "#aaa", textAlign: "center", padding: 40, fontSize: 13 }}>
Mitarbeiter auswählen für Detailansicht
</div>
)}
</div>
</div>
)}
</div>
);
}