Files
karim 00f07d76f6 Rapport 0.6 — Initial Public Release
Sicherheits-Hardening
- Passwort-Hashing mit PBKDF2 (SHA-256, 100k Iterationen) inkl. transparenter
  Migration bestehender Klartext-Passwörter beim ersten Login
- Login Brute-Force-Schutz (5 Fehlversuche → 60s Lockout), Constant-Time-Compare,
  Mindestpasswortlänge 8 Zeichen
- HTML-Sanitizer für Brieftexte (Allowlist, entfernt javascript:/data:/vbscript:-URLs,
  Event-Handler, Script-Tags; rel=noopener für target=_blank)
- Datenexport entfernt Legacy-Klartextpasswörter (Hashes bleiben)
- Kryptografische IDs via crypto.randomUUID statt Math.random
- sessionStorage speichert keine Credentials mehr

GUI & Performance
- Code-Splitting pro View via React.lazy + Suspense (Initial-Bundle 86 KB gzipped)
- swissqrbill als lokale Dependency — QR-Rechnungen offline-fähig
- Spesenbelege (Bild/PDF) direkt in der Tageserfassung mit Bildkomprimierung
- Avatar-Upload: 256px-Skalierung + JPEG-Kompression, Typprüfung
- Über-Rapport-Modal, einheitliche Bearbeiten-Icons, Pinnwand-Kategorien als Pills

Bug-Fixes
- Auto-überfällig-Routine läuft nur noch einmal pro Tag (verhindert Re-Render-Loop)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-13 01:16:26 +02:00

345 lines
21 KiB
React
Executable File
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { useState } 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>
);
}