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:
Executable
+344
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user