import React, { useState } from "react"; import { generateId, formatDate, formatIban, formatCHF, getFeiertageForYear, getAbsenzTypes, getWorkdaysInMonth, calcLohn, hashPassword } from "../utils.js"; import { Header, Modal, FormField, useConfirm , DateInput, DatePicker } from "../components/UI.jsx"; export default function Employees({ data, update, saveAll, setPrintContent }) { const employees = data.employees || []; const feiertage = data.feiertage || []; const absenzTypes = getAbsenzTypes(data); const absences = data.absences || []; const ferienEntries = data.ferienEntries || []; const [tab, setTab] = useState(employees.length === 0 ? "mitarbeiter" : "übersicht"); // "übersicht" | "mitarbeiter" | "feiertage" | "absenztypen" const [selectedEmpId, setSelectedEmpId] = useState(null); const [modal, setModal] = useState(null); const [empForm, setEmpForm] = useState({}); const [absForm, setAbsForm] = useState({ employeeId: "", type: "", dateFrom: "", dateTo: "", note: "" }); const { askConfirm, ConfirmModalEl } = useConfirm(); const [ferienForm, setFerienForm] = useState({ employeeId: "", dateFrom: "", dateTo: "", note: "" }); const [viewYear, setViewYear] = useState(new Date().getFullYear()); const [viewMonth, setViewMonth] = useState(new Date().getMonth()); const [abschlussYear, setAbschlussYear] = useState(new Date().getFullYear() - 1); const [abschlussForm, setAbschlussForm] = useState({}); const selectedEmp = employees.find(e => e.id === selectedEmpId) ?? null; // ── Kalkulationen ── const calcEmpMonth = (emp, year, month) => { const today = new Date().toISOString().slice(0, 10); const monthStr = `${year}-${String(month + 1).padStart(2, "0")}`; const entryDate = emp.eintrittsdatum || null; const monthLastDay = `${monthStr}-${new Date(year, month + 1, 0).getDate().toString().padStart(2, "0")}`; // Monat liegt vollständig vor Anstellung → leer if (entryDate && entryDate > monthLastDay) return { sollTotal: 0, istH: 0, absenzH: 0, ferienH: 0, saldo: 0, notStarted: true }; const pensum = (emp.pensum || 100) / 100; const tagessoll = ((emp.wochenstunden || 35) * pensum) / 5; const workdays = getWorkdaysInMonth(year, month, getFTYear(year)); // Soll: nur Arbeitstage ab Eintrittsdatum und bis heute const sollTotal = workdays.reduce((s, d) => { if (entryDate && d.date < entryDate) return s; if (d.date > today) return s; const ft = getFTYear(year).find(f => f.date === d.date); if (ft && (ft.stundenDelta === 0 || ft.stundenDelta === undefined || ft.stundenDelta === null)) return s; return s + tagessoll + (ft?.stundenDelta || 0); }, 0); const istMins = (data.timeEntries || []).filter(e => e.employeeId === emp.id && (e.date || "").startsWith(monthStr)).reduce((s, e) => s + (e.minutes || 0), 0); // Absenzen: unterstützt sowohl date als auch dateFrom/dateTo const empAbsences = absences.filter(a => a.employeeId === emp.id); const absenzH = empAbsences.reduce((s, a) => { const from = a.dateFrom || a.date; const to = a.dateTo || a.date; if (!from) return s; if (from.slice(0, 7) > monthStr || to.slice(0, 7) < monthStr) return s; const days = workdays.filter(d => d.date >= from && d.date <= to && (!entryDate || d.date >= entryDate) && d.date <= today && !(d.feiertag && (d.feiertag.stundenDelta === 0 || d.feiertag.stundenDelta === null || d.feiertag.stundenDelta === undefined))); return s + days.length * tagessoll; }, 0); // Ferien const empFerienRaw = ferienEntries.filter(f => f.employeeId === emp.id && (!f.status || f.status === "approved")); const ferienH = empFerienRaw.reduce((s, f) => { if (!f.dateFrom) return s; if (f.dateFrom.slice(0, 7) > monthStr || f.dateTo.slice(0, 7) < monthStr) return s; const days = workdays.filter(d => d.date >= f.dateFrom && d.date <= f.dateTo && (!entryDate || d.date >= entryDate) && d.date <= today && !(d.feiertag && (d.feiertag.stundenDelta === 0 || d.feiertag.stundenDelta === null || d.feiertag.stundenDelta === undefined))); return s + days.length * tagessoll; }, 0); const istH = istMins / 60; const saldo = istH + absenzH + ferienH - sollTotal; return { sollTotal: Math.round(sollTotal * 10) / 10, istH: Math.round(istH * 10) / 10, absenzH: Math.round(absenzH * 10) / 10, ferienH: Math.round(ferienH * 10) / 10, saldo: Math.round(saldo * 10) / 10 }; }; const getFTYear = (year) => getFeiertageForYear(feiertage, year); // Ferien-Kalkulation für ein Jahr // Ferien-Kalkulation für ein Jahr const calcFerienYear = (emp, year) => { const anspruch = emp.ferienWochen || 4; const pensum = (emp.pensum || 100) / 100; const wochenstunden = emp.wochenstunden || 35; const yearStr = String(year); const yearStart = `${year}-01-01`; const yearEnd = `${year}-12-31`; const eintrittsdatum = emp.eintrittsdatum || null; if (eintrittsdatum && eintrittsdatum > yearEnd) { return { anspruchH: 0, ubertragH: 0, bezogenH: 0, zukunftH: 0, restH: 0 }; } let proRata = 1; if (eintrittsdatum && eintrittsdatum > yearStart) { const entryMonth = parseInt(eintrittsdatum.slice(5, 7)); proRata = (13 - entryMonth) / 12; } const anspruchH = Math.round(anspruch * wochenstunden * pensum * proRata * 10) / 10; const empFerien = ferienEntries.filter(f => f.employeeId === emp.id && (!f.status || f.status === "approved")); const vorjahr = emp.ferienUebertragVorjahr || {}; const ubertragH = vorjahr[year] || 0; const bezogenH = empFerien.filter(f => f.dateFrom.startsWith(yearStr) || f.dateTo.startsWith(yearStr)).reduce((s, f) => { const fyear = f.dateFrom.startsWith(yearStr) ? year : year; // count workdays in this entry within the year let count = 0; const d = new Date(f.dateFrom); const end = new Date(f.dateTo); const fts = getFTYear(year); while (d <= end) { const ds = d.toISOString().slice(0, 10); if (ds.startsWith(yearStr)) { const dow = d.getDay(); if (dow !== 0 && dow !== 6 && !fts.some(ft => ft.date === ds && !ft.stundenDelta)) count++; } d.setDate(d.getDate() + 1); } const tagessoll = (wochenstunden * pensum) / 5; return s + count * tagessoll; }, 0); const zukunftH = empFerien.filter(f => f.dateFrom > new Date().toISOString().slice(0, 10) && f.dateFrom.startsWith(yearStr)).reduce((s, f) => { let count = 0; const d = new Date(f.dateFrom); const end = new Date(f.dateTo); const fts = getFTYear(year); while (d <= end) { const ds = d.toISOString().slice(0, 10); const dow = d.getDay(); if (dow !== 0 && dow !== 6 && !fts.some(ft => ft.date === ds && !ft.stundenDelta)) count++; d.setDate(d.getDate() + 1); } const tagessoll = (wochenstunden * pensum) / 5; return s + count * tagessoll; }, 0); return { anspruchH: Math.round(anspruchH * 10) / 10, ubertragH: Math.round(ubertragH * 10) / 10, bezogenH: Math.round(bezogenH * 10) / 10, zukunftH: Math.round(zukunftH * 10) / 10, restH: Math.round((anspruchH + ubertragH - bezogenH) * 10) / 10, }; }; // Überstundensaldo (total von Eintritt bis heute, minus bereits ausbezahlte) const calcEffectiveUbSaldo = (emp) => { if (!emp?.eintrittsdatum) return 0; const today = new Date().toISOString().slice(0, 10); const start = emp.eintrittsdatum; if (start > today) return 0; const startY = parseInt(start.slice(0, 4)); const startM = parseInt(start.slice(5, 7)) - 1; const endY = new Date().getFullYear(); const endM = new Date().getMonth(); let total = 0; let y = startY, m = startM; while (y < endY || (y === endY && m <= endM)) { total += calcEmpMonth(emp, y, m).saldo; m++; if (m > 11) { m = 0; y++; } } const settled = (data.uberstundenAbschluss || []) .filter(a => a.empId === emp.id && a.type === "payout") .reduce((s, a) => s + (a.hoursSettled || 0), 0); return Math.round((total - settled) * 10) / 10; }; const saveFerienUebertrag = (emp, hours) => { const updated = employees.map(e => e.id === emp.id ? { ...e, ferienUebertragVorjahr: { ...(e.ferienUebertragVorjahr || {}), [abschlussYear + 1]: +hours } } : e); update("employees", updated); }; const saveUbAbschluss = (emp, form) => { const stundenlohn = emp.monatslohn ? Math.round((emp.monatslohn * 12 / ((emp.wochenstunden || 35) * 52)) * 100) / 100 : 0; if (!stundenlohn || !(form.ubHours > 0)) return; const auszahlungBetrag = Math.round(form.ubHours * stundenlohn * (1 + form.ubZuschlag / 100) * 100) / 100; const entry = { id: generateId(), empId: emp.id, year: abschlussYear, date: new Date().toISOString().slice(0, 10), type: "payout", hoursSettled: form.ubHours, zuschlagPercent: form.ubZuschlag, stundenlohn, auszahlungBetrag, }; update("uberstundenAbschluss", [...(data.uberstundenAbschluss || []), entry]); setAbschlussForm(prev => ({ ...prev, [emp.id]: { ...form, ubHours: 0 } })); }; // ── Handlers ── const nextPersonalNr = () => { const nums = employees .map(e => e.personalNr) .filter(n => n && /^\d+$/.test(String(n))) .map(n => parseInt(n)); const next = nums.length ? Math.max(...nums) + 1 : 1; return String(next).padStart(3, "0"); }; const saveEmp = async () => { if (!empForm.name?.trim()) { alert("Bitte einen Namen eingeben."); return; } if (!empForm.eintrittsdatum) { alert("Bitte ein Eintrittsdatum angeben."); return; } if (empForm._appAccess && !empForm._appUsername?.trim()) { alert("Bitte einen Benutzernamen eingeben."); return; } const isNew = !empForm.id; const { _appAccess, _appUsername, _appPassword, _appRoleId, _appUserId, ...empData } = empForm; const empId = empData.id || generateId(); const emp = { ...empData, id: empId }; const existingUserId = _appUserId; let updatedUsers = data.users || []; if (_appAccess) { const userId = existingUserId || generateId(); const existing = updatedUsers.find(u => u.id === userId); const newPwTrimmed = _appPassword?.trim(); const hasExistingCreds = !!(existing?.passwordHash || existing?.password); if (!newPwTrimmed && !hasExistingCreds) { alert("Bitte ein Passwort vergeben."); return; } let credFields; if (newPwTrimmed) { const { hash, salt } = await hashPassword(newPwTrimmed); credFields = { passwordHash: hash, passwordSalt: salt }; } else if (existing?.passwordHash) { credFields = { passwordHash: existing.passwordHash, passwordSalt: existing.passwordSalt }; } else { // Existing legacy plaintext — keep until first login transparently upgrades it. credFields = { password: existing.password }; } const userEntry = { id: userId, username: _appUsername.trim(), ...credFields, role: existing?.role || "mitarbeiter", displayName: emp.name, employeeId: empId, appRoleId: _appRoleId || "", }; updatedUsers = existing ? updatedUsers.map(u => u.id === userId ? userEntry : u) : [...updatedUsers, userEntry]; emp.appUserId = userId; } else if (existingUserId) { const targetUser = (data.users || []).find(u => u.id === existingUserId); const isLastAdmin = (data.users || []).filter(u => u.role === "admin").length <= 1 && targetUser?.role === "admin"; if (isLastAdmin) { alert("Mindestens ein Administrator muss bestehen bleiben."); return; } updatedUsers = updatedUsers.filter(u => u.id !== existingUserId); delete emp.appUserId; } const updatedEmployees = isNew ? [...employees, emp] : employees.map(e => e.id === emp.id ? emp : e); saveAll({ ...data, employees: updatedEmployees, users: updatedUsers }); if (isNew) setSelectedEmpId(empId); setModal(null); }; const delEmp = async (id) => { if (await askConfirm("Mitarbeiter löschen?")) { update("employees", employees.filter(e => e.id !== id)); if (selectedEmpId === id) setSelectedEmpId(null); } }; const saveAbs = () => { const isNew = !absForm.id; const a = { ...absForm, id: absForm.id || generateId() }; update("absences", isNew ? [...absences, a] : absences.map(x => x.id === a.id ? a : x)); setModal(null); }; const delAbs = (id) => update("absences", absences.filter(a => a.id !== id)); const saveFerien = () => { const isNew = !ferienForm.id; const f = { ...ferienForm, id: ferienForm.id || generateId() }; update("ferienEntries", isNew ? [...ferienEntries, f] : ferienEntries.map(x => x.id === f.id ? f : x)); setModal(null); }; const delFerien = (id) => update("ferienEntries", ferienEntries.filter(f => f.id !== id)); const months = ["Januar","Februar","März","April","Mai","Juni","Juli","August","September","Oktober","November","Dezember"]; const empId = selectedEmp?.id; const monthStats = empId ? calcEmpMonth(selectedEmp, viewYear, viewMonth) : null; const ferienStats = empId ? calcFerienYear(selectedEmp, viewYear) : null; const empAbsences = empId ? absences.filter(a => a.employeeId === empId) : []; const empFerien = empId ? ferienEntries.filter(f => f.employeeId === empId) : []; const closedMonths = data.settings.closedMonths || []; const toggleMonth = (monthStr) => { const updated = closedMonths.includes(monthStr) ? closedMonths.filter(m => m !== monthStr) : [...closedMonths, monthStr]; update("settings", { ...data.settings, closedMonths: updated }); }; const TABS = [ { id: "übersicht", label: "Übersicht" }, { id: "ferien", label: "Ferien & Intern / Absenzen" }, { id: "mitarbeiter", label: "Mitarbeiter" }, { id: "monatsabschluss", label: "Monatsabschluss" }, { id: "jahresabschluss", label: "Jahresabschluss" }, ]; return (
{ConfirmModalEl}
} />
{TABS.map(t => ( ))}
{/* ── ÜBERSICHT ── */} {tab === "übersicht" && (
{employees.length === 0 ? (
Noch keine Mitarbeiter erfasst — Tab «Mitarbeiter» verwenden.
) : ( <> {/* Monats-Navigator */}
{months[viewMonth]} {viewYear} {closedMonths.includes(`${viewYear}-${String(viewMonth + 1).padStart(2, "0")}`) && ( geschlossen )}
{/* Alle Mitarbeiter — Monatstabelle */}
MONATSÜBERSICHT — {months[viewMonth].toUpperCase()} {viewYear}
{employees.map(emp => { const s = calcEmpMonth(emp, viewYear, viewMonth); const fs = calcFerienYear(emp, viewYear); const saldoColor = s.saldo > 0.5 ? "#2d6a4f" : s.saldo < -0.5 ? "#8a1a1a" : "#888"; return ( ); })}
Mitarbeiter Soll IST Ferien Intern / Abs. Saldo Monat Ferien-Rest
{emp.name}
{emp.pensum || 100}% · {emp.role || ""}
{s.sollTotal}h {s.istH}h {s.ferienH > 0 ? `${s.ferienH}h` : "—"} {s.absenzH > 0 ? `${s.absenzH}h` : "—"} {s.saldo > 0 ? "+" : ""}{s.saldo}h {fs.restH}h
{/* Jahres-Saldo-Matrix */} {(() => { const today = new Date(); const curYear = today.getFullYear(); const curMonth = today.getMonth(); return (
ÜBERSTUNDEN-KONTO {viewYear} — MONATSSALDO
{months.map((m, i) => { const mStr = `${viewYear}-${String(i + 1).padStart(2, "0")}`; const isClosed = closedMonths.includes(mStr); const isFuture = viewYear > curYear || (viewYear === curYear && i > curMonth); return ( ); })} {employees.map(emp => { let ytd = 0; return ( {months.map((_, i) => { const isFuture = viewYear > curYear || (viewYear === curYear && i > curMonth); if (isFuture) return ; const s = calcEmpMonth(emp, viewYear, i); if (s.notStarted) return ; ytd = Math.round((ytd + s.saldo) * 10) / 10; const color = s.saldo > 0.5 ? "#2d6a4f" : s.saldo < -0.5 ? "#8a1a1a" : "#888"; return ( ); })} ); })}
Mitarbeiter {m.slice(0, 3)} {isClosed && } YTD
{emp.name} {s.saldo > 0 ? "+" : ""}{s.saldo} 0 ? "#2d6a4f" : ytd < 0 ? "#8a1a1a" : "#888" }}> {ytd > 0 ? "+" : ""}{ytd}h
); })()} {/* Absenz-Übersicht mit Vorjahresvergleich */} {employees.length > 0 && (() => { const prevYear = viewYear - 1; const curMonth = new Date().getMonth(); const curYear = new Date().getFullYear(); const calcAbsH = (emp, year, monthIdx) => { const tagessoll = ((emp.wochenstunden || 35) * (emp.pensum || 100) / 100) / 5; const monthStr = `${year}-${String(monthIdx + 1).padStart(2, "0")}`; const workdays = getWorkdaysInMonth(year, monthIdx, getFTYear(year)); return absences.filter(a => a.employeeId === emp.id).reduce((s, a) => { const from = a.dateFrom || a.date; const to = a.dateTo || a.date; if (!from || from.slice(0, 7) > monthStr || to.slice(0, 7) < monthStr) return s; const days = workdays.filter(d => d.date >= from && d.date <= to); return s + days.length * tagessoll; }, 0); }; const calcInternH = (emp, year, monthIdx) => { const monthStr = `${year}-${String(monthIdx + 1).padStart(2, "0")}`; return (data.timeEntries || []).filter(e => e.employeeId === emp.id && !e.projectId && (e.date || "").startsWith(monthStr) ).reduce((s, e) => s + (e.minutes || 0), 0) / 60; }; return ( <> {/* Absenzen-Matrix */}
INTERN / ABSENZEN {viewYear} vs. {prevYear}
{months.map((m, i) => { const isFuture = viewYear > curYear || (viewYear === curYear && i > curMonth); return ; })} {employees.map(emp => { const monthly = months.map((_, i) => Math.round(calcAbsH(emp, viewYear, i) * 10) / 10); const total = Math.round(monthly.reduce((s, h) => s + h, 0) * 10) / 10; const prevTotal = Math.round(months.reduce((s, _, i) => s + calcAbsH(emp, prevYear, i), 0) * 10) / 10; const delta = Math.round((total - prevTotal) * 10) / 10; return ( {monthly.map((h, i) => { const isFuture = viewYear > curYear || (viewYear === curYear && i > curMonth); return ; })} ); })}
Mitarbeiter{m.slice(0, 3)}Total {prevYear} Δ
{emp.name} 0 ? "#8a1a1a" : "#ddd" }}>{h > 0 && !isFuture ? `${h}h` : "—"} 0 ? "#8a1a1a" : "#888" }}>{total > 0 ? `${total}h` : "—"} {prevTotal > 0 ? `${prevTotal}h` : "—"} 0 ? "#8a1a1a" : delta < 0 ? "#2d6a4f" : "#888" }}> {delta !== 0 ? `${delta > 0 ? "+" : ""}${delta}h` : "—"}
{/* Absenzen nach Kategorie */} {(() => { const calcAbsHByType = (typeId, year, monthIdx) => { const monthStr = `${year}-${String(monthIdx + 1).padStart(2, "0")}`; const workdays = getWorkdaysInMonth(year, monthIdx, getFTYear(year)); return absences.filter(a => a.type === typeId).reduce((s, a) => { const emp = employees.find(e => e.id === a.employeeId); if (!emp) return s; const tagessoll = ((emp.wochenstunden || 35) * (emp.pensum || 100) / 100) / 5; const from = a.dateFrom || a.date; const to = a.dateTo || a.date; if (!from || from.slice(0, 7) > monthStr || to.slice(0, 7) < monthStr) return s; const days = workdays.filter(d => d.date >= from && d.date <= to); return s + days.length * tagessoll; }, 0); }; const usedTypes = absenzTypes.filter(t => absences.some(a => a.type === t.id) ); if (usedTypes.length === 0) return null; return (
ABSENZEN NACH KATEGORIE {viewYear} vs. {prevYear}
{months.map((m, i) => { const isFuture = viewYear > curYear || (viewYear === curYear && i > curMonth); return ; })} {usedTypes.map(t => { const monthly = months.map((_, i) => Math.round(calcAbsHByType(t.id, viewYear, i) * 10) / 10); const total = Math.round(monthly.reduce((s, h) => s + h, 0) * 10) / 10; const prevTotal = Math.round(months.reduce((s, _, i) => s + calcAbsHByType(t.id, prevYear, i), 0) * 10) / 10; const delta = Math.round((total - prevTotal) * 10) / 10; return ( {monthly.map((h, i) => { const isFuture = viewYear > curYear || (viewYear === curYear && i > curMonth); return ; })} ); })} {/* Totalzeile */} {(() => { const monthlyTotals = months.map((_, i) => Math.round(usedTypes.reduce((s, t) => s + calcAbsHByType(t.id, viewYear, i), 0) * 10) / 10); const grandTotal = Math.round(monthlyTotals.reduce((s, h) => s + h, 0) * 10) / 10; const prevGrand = Math.round(usedTypes.reduce((s, t) => s + months.reduce((s2, _, i) => s2 + calcAbsHByType(t.id, prevYear, i), 0), 0) * 10) / 10; const delta = Math.round((grandTotal - prevGrand) * 10) / 10; return ( {monthlyTotals.map((h, i) => { const isFuture = viewYear > curYear || (viewYear === curYear && i > curMonth); return ; })} ); })()}
Kategorie{m.slice(0, 3)}Total {prevYear} Δ
{t.label} 0 ? "#555" : "#ddd" }}>{h > 0 && !isFuture ? `${h}h` : "—"}{total > 0 ? `${total}h` : "—"} {prevTotal > 0 ? `${prevTotal}h` : "—"} 0 ? "#8a1a1a" : delta < 0 ? "#2d6a4f" : "#888" }}> {delta !== 0 ? `${delta > 0 ? "+" : ""}${delta}h` : "—"}
Total 0 ? "#1a1a18" : "#ddd" }}>{h > 0 && !isFuture ? `${h}h` : "—"}{grandTotal > 0 ? `${grandTotal}h` : "—"} {prevGrand > 0 ? `${prevGrand}h` : "—"} 0 ? "#8a1a1a" : delta < 0 ? "#2d6a4f" : "#888" }}> {delta !== 0 ? `${delta > 0 ? "+" : ""}${delta}h` : "—"}
); })()} {/* Interne Stunden */} {(() => { const hasIntern = employees.some(emp => months.some((_, i) => calcInternH(emp, viewYear, i) > 0 || calcInternH(emp, prevYear, i) > 0) ); if (!hasIntern) return null; return (
INTERNE STUNDEN {viewYear} — ohne Projektzuweisung vs. {prevYear}
{months.map((m, i) => { const isFuture = viewYear > curYear || (viewYear === curYear && i > curMonth); return ; })} {employees.map(emp => { const monthly = months.map((_, i) => Math.round(calcInternH(emp, viewYear, i) * 10) / 10); const total = Math.round(monthly.reduce((s, h) => s + h, 0) * 10) / 10; const prevTotal = Math.round(months.reduce((s, _, i) => s + calcInternH(emp, prevYear, i), 0) * 10) / 10; const delta = Math.round((total - prevTotal) * 10) / 10; if (total === 0 && prevTotal === 0) return null; return ( {monthly.map((h, i) => { const isFuture = viewYear > curYear || (viewYear === curYear && i > curMonth); return ; })} ); })}
Mitarbeiter{m.slice(0, 3)}Total {prevYear} Δ
{emp.name} 0 ? "#2d5a8e" : "#ddd" }}>{h > 0 && !isFuture ? `${h}h` : "—"} 0 ? "#2d5a8e" : "#888" }}>{total > 0 ? `${total}h` : "—"} {prevTotal > 0 ? `${prevTotal}h` : "—"} 0 ? "#b5621e" : delta < 0 ? "#2d6a4f" : "#888" }}> {delta !== 0 ? `${delta > 0 ? "+" : ""}${delta}h` : "—"}
); })()} ); })()} )}
)} {/* ── FERIEN ── */} {tab === "ferien" && (
{/* Pendente Anträge */} {(() => { const pendingFerien = (data.ferienEntries || []).filter(f => f.status === "pending"); const pendingAbs = (data.absences || []).filter(a => a.status === "pending"); const total = pendingFerien.length + pendingAbs.length; if (total === 0) return null; const approveFerien = (id) => update("ferienEntries", (data.ferienEntries || []).map(f => f.id === id ? { ...f, status: "approved" } : f)); const rejectFerien = async (id) => { if (await askConfirm("Ferienantrag ablehnen?", "Ablehnen")) update("ferienEntries", (data.ferienEntries || []).map(f => f.id === id ? { ...f, status: "rejected" } : f)); }; const approveAbs = (id) => update("absences", (data.absences || []).map(a => a.id === id ? { ...a, status: "approved" } : a)); const rejectAbs = async (id) => { if (await askConfirm("Absenzantrag ablehnen?", "Ablehnen")) update("absences", (data.absences || []).map(a => a.id === id ? { ...a, status: "rejected" } : a)); }; return (
⏳ PENDENTE ANTRÄGE ({total})
{pendingFerien.map(f => { const emp = employees.find(e => e.id === f.employeeId); return ( ); })} {pendingAbs.map(a => { const emp = employees.find(e => e.id === a.employeeId); const t = absenzTypes.find(x => x.id === a.type); return ( ); })}
MITARBEITER TYP VON BIS NOTIZ AKTION
{emp?.name || "—"} 🌴 Ferien {formatDate(f.dateFrom)} {formatDate(f.dateTo)} {f.note || "—"}
{emp?.name || "—"} {t?.label || a.type || "Absenz"} {formatDate(a.dateFrom)} {formatDate(a.dateTo)} {a.note || "—"}
); })()}
FERIENEINTRAGUNGEN
{ferienEntries.filter(f => !selectedEmpId || f.employeeId === selectedEmpId).length === 0 ? : ferienEntries.filter(f => !selectedEmpId || f.employeeId === selectedEmpId) .sort((a, b) => a.dateFrom.localeCompare(b.dateFrom)) .map(f => { const emp = employees.find(e => e.id === f.employeeId); const statusBadge = !f.status || f.status === "approved" ? ✓ Genehmigt : f.status === "pending" ? ⏳ Ausstehend : close Abgelehnt; return ( ); }) }
MitarbeiterVonBisNotizStatus
Noch keine Ferien eingetragen
{emp?.name || "—"} {formatDate(f.dateFrom)} {formatDate(f.dateTo)} {f.note || "—"} {statusBadge} {(!f.status || f.status === "approved") && }
{/* Absenzen-Liste (read-only, Erfassung via MA-Self-Service) */} {(() => { const filteredAbs = absences.filter(a => !selectedEmpId || a.employeeId === selectedEmpId).sort((a, b) => (a.dateFrom || a.date || "").localeCompare(b.dateFrom || b.date || "")); if (filteredAbs.length === 0) return null; return (
INTERN / ABSENZEN
{filteredAbs.map(a => { const emp = employees.find(e => e.id === a.employeeId); const t = absenzTypes.find(x => x.id === a.type); const statusBadge = !a.status || a.status === "approved" ? ✓ Genehmigt : a.status === "pending" ? ⏳ Ausstehend : close Abgelehnt; return ( ); })}
MitarbeiterTypVonBisNotizStatus
{emp?.name || "—"} {t?.label || a.type || "Absenz"} {formatDate(a.dateFrom || a.date)} {formatDate(a.dateTo || a.date)} {a.note || "—"} {statusBadge}
); })()} {/* Ferien-Übersicht pro Mitarbeiter */} {employees.length > 0 && (
FERIENANSPRUCH ÜBERSICHT
{employees.map(emp => { const fs = calcFerienYear(emp, viewYear); return ( ); })}
MitarbeiterPensumAnspruchÜbertragBezogenGeplantRest
{emp.name} {emp.pensum || 100}% {fs.anspruchH}h {fs.ubertragH}h {fs.bezogenH}h {fs.zukunftH}h {fs.restH}h
)}
)} {/* ── MONATSABSCHLUSS ── */} {tab === "monatsabschluss" && (
Geschlossene Monate können in der Zeiterfassung nicht mehr bearbeitet werden. Monate können jederzeit wieder geöffnet werden.
{(() => { const allDates = [ ...(data.timeEntries || []).map(e => e.date), ...(data.projects || []).map(p => p.startDate || p.createdAt), ...(data.invoices || []).map(i => i.date), ...(data.expenses || []).map(e => e.date), ].filter(Boolean).sort(); const today = new Date(); const earliest = allDates[0] ? new Date(allDates[0]) : today; const startYear = earliest.getFullYear(); const startMonth = earliest.getMonth(); // 0-indexed const availableYears = []; for (let y = startYear; y <= today.getFullYear() + 1; y++) availableYears.push(y); return ( <>
{months.map((m, i) => { const monthStr = `${viewYear}-${String(i + 1).padStart(2, "0")}`; const isClosed = closedMonths.includes(monthStr); const isFuture = viewYear > today.getFullYear() || (viewYear === today.getFullYear() && i > today.getMonth()); const isCurrent = viewYear === today.getFullYear() && i === today.getMonth(); const isBeforeStart = viewYear < startYear || (viewYear === startYear && i < startMonth); if (isBeforeStart) return null; return (
{m} {viewYear}
{isCurrent && Aktuell} {isClosed && ● Geschlossen} {!isClosed && !isFuture && ○ Offen}
{!isFuture && ( )}
); })}
); })()}
)} {/* ── MITARBEITER ── */} {tab === "mitarbeiter" && (
{employees.length === 0 && } {employees.map(emp => ( ))}
NamePensumWochenstundenFerien (Wo)Eintritt
Noch keine Mitarbeiter erfasst
{emp.name}{emp.role &&
{emp.role}
}
{emp.pensum || 100}% {((emp.wochenstunden || 35) * (emp.pensum || 100) / 100).toFixed(1)}h ({emp.wochenstunden || 35}h @ 100%) {emp.ferienWochen || 4} Wo {emp.eintrittsdatum ? formatDate(emp.eintrittsdatum) : "—"}
)} {/* ── JAHRESABSCHLUSS ── */} {tab === "jahresabschluss" && (
Ferienüberträge bestätigen und Überstunden abrechnen
{employees.length === 0 &&
Keine Mitarbeiter erfasst
} {employees.filter(emp => emp.eintrittsdatum && emp.eintrittsdatum <= `${abschlussYear}-12-31`).length === 0 && employees.length > 0 && (
Keine Mitarbeiter waren {abschlussYear} angestellt
)} {employees.filter(emp => emp.eintrittsdatum && emp.eintrittsdatum <= `${abschlussYear}-12-31`).map(emp => { const fs = calcFerienYear(emp, abschlussYear); const ubSaldo = calcEffectiveUbSaldo(emp); const currentUebertrag = (emp.ferienUebertragVorjahr || {})[abschlussYear + 1]; const formDef = { ferienUebertrag: fs.restH > 0 ? fs.restH : 0, ubHours: ubSaldo > 0 ? ubSaldo : 0, ubZuschlag: 25 }; const form = { ...formDef, ...(abschlussForm[emp.id] || {}) }; const setForm = (val) => setAbschlussForm(prev => ({ ...prev, [emp.id]: { ...form, ...val } })); const stundenlohn = emp.monatslohn ? Math.round((emp.monatslohn * 12 / ((emp.wochenstunden || 35) * 52)) * 100) / 100 : 0; const auszahlungBetrag = Math.round(form.ubHours * stundenlohn * (1 + form.ubZuschlag / 100) * 100) / 100; const existingPayouts = (data.uberstundenAbschluss || []).filter(a => a.empId === emp.id && a.year === abschlussYear); return (
{emp.name} {emp.role && {emp.role}}
{emp.pensum || 100}% · {((emp.wochenstunden || 35) * (emp.pensum || 100) / 100).toFixed(1)}h/Wo
{/* Ferien */}
Ferien {abschlussYear}
{[ { label: "Jahresanspruch", value: `${fs.anspruchH}h` }, fs.ubertragH > 0 ? { label: "Übertrag Vorjahr", value: `+${fs.ubertragH}h` } : null, { label: "Bezogen", value: `-${fs.bezogenH}h` }, ].filter(Boolean).map(r => (
{r.label}{r.value}
))}
Restanspruch 0 ? "#2d6a4f" : fs.restH < 0 ? "#8a1a1a" : "#888" }}>{fs.restH}h
{fs.restH > 0 ? (
Übertrag ins {abschlussYear + 1}
setForm({ ferienUebertrag: Math.min(+e.target.value, fs.restH) })} style={{ width: 70, fontSize: 12 }} /> h von {fs.restH}h
{currentUebertrag !== undefined && (
✓ {currentUebertrag}h bestätigt für {abschlussYear + 1}
)}
) : fs.restH < 0 ? (
Negativsaldo — Bezug übersteigt Anspruch
) : (
Kein Restanspruch — kein Übertrag nötig
)}
{/* Überstunden */}
Überstunden (Saldo gesamt)
Verbleibend 0 ? "#2d6a4f" : ubSaldo < 0 ? "#8a1a1a" : "#888" }}>{ubSaldo}h
{existingPayouts.length > 0 && (
{existingPayouts.map(p => (
✓ {p.hoursSettled}h ausbezahlt — {formatCHF(p.auszahlungBetrag)} ({p.zuschlagPercent}% Zuschlag)
))}
)} {ubSaldo > 0 ? ( <>
Stunden ausbezahlen
setForm({ ubHours: Math.min(+e.target.value, ubSaldo) })} placeholder="Std." style={{ width: 65, fontSize: 12 }} /> h × setForm({ ubZuschlag: +e.target.value })} style={{ width: 52, fontSize: 12 }} /> % Zuschlag
{form.ubZuschlag < 25 && (
⚠ OR Art. 321c: mind. 25% Zuschlag empfohlen
)} {stundenlohn > 0 ? (
{form.ubHours}h × {formatCHF(stundenlohn)}/h = {formatCHF(auszahlungBetrag)}
) : (
Kein Monatslohn hinterlegt
)}
Restliche Stunden werden übertragen.
) : (
Kein Überstundensaldo vorhanden.
)}
); })}
)} {/* ── MODALS ── */} {modal === "emp" && ( setModal(null)} onSave={saveEmp} wide>
PERSONALIEN
setEmpForm({ ...empForm, name: e.target.value })} autoFocus style={!empForm.name?.trim() ? { borderColor: "#b5621e" } : {}} /> setEmpForm({ ...empForm, role: e.target.value })} placeholder="z.B. Architekt, Praktikant…" />
setEmpForm({ ...empForm, personalNr: e.target.value })} style={{ flex: 1 }} placeholder="001" /> {empForm.id && empForm.personalNr === "" && ( )}
{empForm.personalNr && !/^\d+$/.test(empForm.personalNr) && (
Nicht-numerische Nr. wird nicht für Auto-Zählung verwendet
)}
setEmpForm({ ...empForm, adresse: e.target.value })} placeholder="Musterstrasse 1" /> setEmpForm({ ...empForm, ort: e.target.value })} placeholder="8001 Zürich" />
setEmpForm({ ...empForm, ahvNr: e.target.value })} placeholder="756.1234.5678.90" /> setEmpForm({ ...empForm, geburtsdatum: e.target.value })} /> setEmpForm({ ...empForm, email: e.target.value })} />
setEmpForm({ ...empForm, eintrittsdatum: e.target.value })} style={!empForm.eintrittsdatum ? { borderColor: "#b5621e" } : {}} />
ARBEITSZEIT & FERIEN
setEmpForm({ ...empForm, pensum: +e.target.value })} /> setEmpForm({ ...empForm, wochenstunden: +e.target.value })} />
Effektiv: {(((empForm.wochenstunden || 35) * (empForm.pensum || 100)) / 100).toFixed(1)}h/Wo
setEmpForm({ ...empForm, ferienWochen: +e.target.value })} />
Minimum: 4 Wochen (OR)
LOHN & AUSZAHLUNG
setEmpForm({ ...empForm, monatslohn: +e.target.value })} placeholder="z.B. 7000" /> {(empForm.pensum || 100) < 100 && empForm.monatslohn > 0 && (
Effektiv bei {empForm.pensum}%: {formatCHF(Math.round(empForm.monatslohn * (empForm.pensum / 100) * 100) / 100)}
)}
setEmpForm({ ...empForm, lohnIban: formatIban(e.target.value) })} placeholder="CH00 0000 0000 0000 0000 0" />
Sozialabzüge Arbeitnehmer (anpassbar)
setEmpForm({ ...empForm, ahvSatz: +e.target.value })} />
Standard 5.30%
setEmpForm({ ...empForm, alvSatz: +e.target.value })} />
Standard 1.10%
setEmpForm({ ...empForm, bvgSatz: +e.target.value })} />
Arbeitnehmer-Anteil
Sozialabgaben Arbeitgeber
setEmpForm({ ...empForm, pkAGSatz: +e.target.value })} />
AG muss mind. AN-Anteil zahlen
setEmpForm({ ...empForm, nbuSatz: +e.target.value })} />
trägt AN
setEmpForm({ ...empForm, ktgSatz: +e.target.value })} />
je nach Vertrag
{empForm.quellensteuerPflichtig && ( setEmpForm({ ...empForm, quellensteuerSatz: +e.target.value })} placeholder="Satz %" style={{ marginTop: 4 }} /> )}
{/* APP-ZUGANG */}
APP-ZUGANG
{empForm._appAccess && ( <>
setEmpForm({ ...empForm, _appUsername: e.target.value })} placeholder="vorname.nachname" style={!empForm._appUsername?.trim() ? { borderColor: "#b5621e" } : {}} /> setEmpForm({ ...empForm, _appPassword: e.target.value })} placeholder={empForm._appUserId ? "••••••" : "Passwort vergeben"} />
{!empForm._appRoleId &&
Bitte eine Rolle wählen
} {empForm._appRoleId && (() => { const role = (data.appRoles || []).find(r => r.id === empForm._appRoleId); if (!role) return null; if (role.permissions === null) return
Zugriff auf alle Bereiche
; if (!role.permissions?.length) return
⚠ Rolle hat keine Bereiche — Benutzer sieht nichts
; return
{role.permissions.join(", ")}
; })()}
Rollen und Berechtigungen unter Einstellungen → App-Rollen verwalten.
)}
)} {modal === "abs" && ( setModal(null)} onSave={saveAbs}>
setAbsForm({ ...absForm, dateFrom: e.target.value })} /> setAbsForm({ ...absForm, dateTo: e.target.value })} />
setAbsForm({ ...absForm, note: e.target.value })} />
)} {modal === "ferien" && ( setModal(null)} onSave={saveFerien}>
setFerienForm({ ...ferienForm, dateFrom: e.target.value })} /> setFerienForm({ ...ferienForm, dateTo: e.target.value })} />
setFerienForm({ ...ferienForm, note: e.target.value })} />
)} ); }