00f07d76f6
Sicherheits-Hardening - Passwort-Hashing mit PBKDF2 (SHA-256, 100k Iterationen) inkl. transparenter Migration bestehender Klartext-Passwörter beim ersten Login - Login Brute-Force-Schutz (5 Fehlversuche → 60s Lockout), Constant-Time-Compare, Mindestpasswortlänge 8 Zeichen - HTML-Sanitizer für Brieftexte (Allowlist, entfernt javascript:/data:/vbscript:-URLs, Event-Handler, Script-Tags; rel=noopener für target=_blank) - Datenexport entfernt Legacy-Klartextpasswörter (Hashes bleiben) - Kryptografische IDs via crypto.randomUUID statt Math.random - sessionStorage speichert keine Credentials mehr GUI & Performance - Code-Splitting pro View via React.lazy + Suspense (Initial-Bundle 86 KB gzipped) - swissqrbill als lokale Dependency — QR-Rechnungen offline-fähig - Spesenbelege (Bild/PDF) direkt in der Tageserfassung mit Bildkomprimierung - Avatar-Upload: 256px-Skalierung + JPEG-Kompression, Typprüfung - Über-Rapport-Modal, einheitliche Bearbeiten-Icons, Pinnwand-Kategorien als Pills Bug-Fixes - Auto-überfällig-Routine läuft nur noch einmal pro Tag (verhindert Re-Render-Loop) Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1299 lines
82 KiB
React
Executable File
1299 lines
82 KiB
React
Executable File
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 (
|
||
<div>
|
||
{ConfirmModalEl}
|
||
<Header title="Mitarbeiter" action={
|
||
<div style={{ display: "flex", gap: 8 }}>
|
||
<button className="btn btn-ghost" onClick={() => setPrintContent({ type: "mitarbeiterOverview", employees, settings: data.settings })}>PDF Übersicht</button>
|
||
<button className="btn btn-primary" onClick={() => { setEmpForm({ pensum: 100, wochenstunden: data.settings.defaultWochenstunden || 35, ferienWochen: data.settings.defaultFerienWochen || 5, pkAGSatz: data.settings.defaultPkAGSatz || 8.0, ferienUebertragVorjahr: {}, personalNr: nextPersonalNr(), _appAccess: false, _appUsername: "", _appPassword: "", _appRoleId: (data.appRoles || []).find(r => r.id !== "r-admin")?.id || "", _appUserId: undefined }); setModal("emp"); }}>+ Mitarbeiter</button>
|
||
</div>
|
||
} />
|
||
|
||
|
||
<div className="filter-bar">
|
||
{TABS.map(t => (
|
||
<button key={t.id} onClick={() => setTab(t.id)} className={`pill${tab === t.id ? " active" : ""}`}>{t.label}</button>
|
||
))}
|
||
</div>
|
||
|
||
|
||
{/* ── ÜBERSICHT ── */}
|
||
{tab === "übersicht" && (
|
||
<div>
|
||
{employees.length === 0 ? (
|
||
<div className="card" style={{ textAlign: "center", padding: 40, color: "#aaa", fontSize: 13 }}>
|
||
Noch keine Mitarbeiter erfasst — Tab «Mitarbeiter» verwenden.
|
||
</div>
|
||
) : (
|
||
<>
|
||
{/* Monats-Navigator */}
|
||
<div className="card" style={{ display: "flex", alignItems: "center", gap: 12, padding: "12px 20px", marginBottom: 20 }}>
|
||
<button className="btn btn-ghost" style={{ padding: "0 12px" }} onClick={() => { if (viewMonth === 0) { setViewMonth(11); setViewYear(y => y - 1); } else setViewMonth(m => m - 1); }}>←</button>
|
||
<div style={{ flex: 1, textAlign: "center", fontFamily: "'Playfair Display', serif", fontSize: 18 }}>
|
||
{months[viewMonth]} {viewYear}
|
||
{closedMonths.includes(`${viewYear}-${String(viewMonth + 1).padStart(2, "0")}`) && (
|
||
<span style={{ marginLeft: 10, fontSize: 11, background: "#1a1a18", color: "#f0ede8", padding: "2px 8px", borderRadius: 3, fontFamily: "inherit", fontWeight: 400 }}>geschlossen</span>
|
||
)}
|
||
</div>
|
||
<button className="btn btn-ghost" style={{ padding: "0 12px" }} onClick={() => { if (viewMonth === 11) { setViewMonth(0); setViewYear(y => y + 1); } else setViewMonth(m => m + 1); }}>→</button>
|
||
<select value={viewYear} onChange={e => setViewYear(+e.target.value)} style={{ width: 90, fontSize: 12 }}>
|
||
{[viewYear - 2, viewYear - 1, viewYear, viewYear + 1].map(y => <option key={y} value={y}>{y}</option>)}
|
||
</select>
|
||
</div>
|
||
|
||
{/* Alle Mitarbeiter — Monatstabelle */}
|
||
<div className="card" style={{ padding: 0, marginBottom: 20 }}>
|
||
<div style={{ padding: "12px 20px", fontSize: 11, letterSpacing: "0.1em", color: "#888", borderBottom: "1px solid #ece8e2" }}>
|
||
MONATSÜBERSICHT — {months[viewMonth].toUpperCase()} {viewYear}
|
||
</div>
|
||
<table style={{ width: "100%" }}>
|
||
<thead>
|
||
<tr>
|
||
<th>Mitarbeiter</th>
|
||
<th style={{ textAlign: "right" }}>Soll</th>
|
||
<th style={{ textAlign: "right" }}>IST</th>
|
||
<th style={{ textAlign: "right" }}>Ferien</th>
|
||
<th style={{ textAlign: "right" }}>Intern / Abs.</th>
|
||
<th style={{ textAlign: "right" }}>Saldo Monat</th>
|
||
<th style={{ textAlign: "right" }}>Ferien-Rest</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{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 (
|
||
<tr key={emp.id}>
|
||
<td>
|
||
<div style={{ fontWeight: 500 }}>{emp.name}</div>
|
||
<div style={{ fontSize: 10, color: "#aaa" }}>{emp.pensum || 100}% · {emp.role || ""}</div>
|
||
</td>
|
||
<td style={{ textAlign: "right", color: "#888" }}>{s.sollTotal}h</td>
|
||
<td style={{ textAlign: "right", fontWeight: 500 }}>{s.istH}h</td>
|
||
<td style={{ textAlign: "right", color: "#7a6a00" }}>{s.ferienH > 0 ? `${s.ferienH}h` : "—"}</td>
|
||
<td style={{ textAlign: "right", color: "#888" }}>{s.absenzH > 0 ? `${s.absenzH}h` : "—"}</td>
|
||
<td style={{ textAlign: "right", fontWeight: 700, fontFamily: "'Playfair Display', serif", color: saldoColor }}>
|
||
{s.saldo > 0 ? "+" : ""}{s.saldo}h
|
||
</td>
|
||
<td style={{ textAlign: "right", color: fs.restH < 0 ? "#8a1a1a" : "#2d6a4f", fontWeight: 500 }}>
|
||
{fs.restH}h
|
||
</td>
|
||
</tr>
|
||
);
|
||
})}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
|
||
{/* Jahres-Saldo-Matrix */}
|
||
{(() => {
|
||
const today = new Date();
|
||
const curYear = today.getFullYear();
|
||
const curMonth = today.getMonth();
|
||
return (
|
||
<div className="card" style={{ padding: 0 }}>
|
||
<div style={{ padding: "12px 20px", fontSize: 11, letterSpacing: "0.1em", color: "#888", borderBottom: "1px solid #ece8e2" }}>
|
||
ÜBERSTUNDEN-KONTO {viewYear} — MONATSSALDO
|
||
</div>
|
||
<table style={{ width: "100%", fontSize: 11 }}>
|
||
<thead>
|
||
<tr>
|
||
<th style={{ minWidth: 140 }}>Mitarbeiter</th>
|
||
{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 (
|
||
<th key={i} style={{ textAlign: "right", minWidth: 52, color: isFuture ? "#ccc" : "#555", fontWeight: 500 }}>
|
||
{m.slice(0, 3)}
|
||
{isClosed && <span style={{ display: "block", fontSize: 8, color: "#888" }}>●</span>}
|
||
</th>
|
||
);
|
||
})}
|
||
<th style={{ textAlign: "right", minWidth: 60 }}>YTD</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{employees.map(emp => {
|
||
let ytd = 0;
|
||
return (
|
||
<tr key={emp.id}>
|
||
<td style={{ fontWeight: 500, fontSize: 12 }}>{emp.name}</td>
|
||
{months.map((_, i) => {
|
||
const isFuture = viewYear > curYear || (viewYear === curYear && i > curMonth);
|
||
if (isFuture) return <td key={i} style={{ textAlign: "right", color: "#ddd" }}>—</td>;
|
||
const s = calcEmpMonth(emp, viewYear, i);
|
||
if (s.notStarted) return <td key={i} style={{ textAlign: "right", color: "#ddd" }}>—</td>;
|
||
ytd = Math.round((ytd + s.saldo) * 10) / 10;
|
||
const color = s.saldo > 0.5 ? "#2d6a4f" : s.saldo < -0.5 ? "#8a1a1a" : "#888";
|
||
return (
|
||
<td key={i} style={{ textAlign: "right", fontWeight: 500, color }}>
|
||
{s.saldo > 0 ? "+" : ""}{s.saldo}
|
||
</td>
|
||
);
|
||
})}
|
||
<td style={{ textAlign: "right", fontWeight: 700, fontFamily: "'Playfair Display', serif", color: ytd > 0 ? "#2d6a4f" : ytd < 0 ? "#8a1a1a" : "#888" }}>
|
||
{ytd > 0 ? "+" : ""}{ytd}h
|
||
</td>
|
||
</tr>
|
||
);
|
||
})}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
);
|
||
})()}
|
||
|
||
{/* 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 */}
|
||
<div className="card" style={{ padding: 0, marginTop: 20 }}>
|
||
<div style={{ padding: "12px 20px", fontSize: 11, letterSpacing: "0.1em", color: "#888", borderBottom: "1px solid #ece8e2", display: "flex", justifyContent: "space-between", alignItems: "center" }}>
|
||
<span>INTERN / ABSENZEN {viewYear}</span>
|
||
<span style={{ fontWeight: 400, letterSpacing: 0, color: "#aaa" }}>vs. {prevYear}</span>
|
||
</div>
|
||
<table style={{ width: "100%", fontSize: 11 }}>
|
||
<thead>
|
||
<tr>
|
||
<th style={{ minWidth: 140 }}>Mitarbeiter</th>
|
||
{months.map((m, i) => {
|
||
const isFuture = viewYear > curYear || (viewYear === curYear && i > curMonth);
|
||
return <th key={i} style={{ textAlign: "right", minWidth: 46, color: isFuture ? "#ccc" : "#555", fontWeight: 500 }}>{m.slice(0, 3)}</th>;
|
||
})}
|
||
<th style={{ textAlign: "right", minWidth: 52 }}>Total</th>
|
||
<th style={{ textAlign: "right", minWidth: 52, color: "#aaa" }}>{prevYear}</th>
|
||
<th style={{ textAlign: "right", minWidth: 46 }}>Δ</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{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 (
|
||
<tr key={emp.id}>
|
||
<td style={{ fontWeight: 500, fontSize: 12 }}>{emp.name}</td>
|
||
{monthly.map((h, i) => {
|
||
const isFuture = viewYear > curYear || (viewYear === curYear && i > curMonth);
|
||
return <td key={i} style={{ textAlign: "right", color: isFuture ? "#ddd" : h > 0 ? "#8a1a1a" : "#ddd" }}>{h > 0 && !isFuture ? `${h}h` : "—"}</td>;
|
||
})}
|
||
<td style={{ textAlign: "right", fontWeight: 700, color: total > 0 ? "#8a1a1a" : "#888" }}>{total > 0 ? `${total}h` : "—"}</td>
|
||
<td style={{ textAlign: "right", color: "#aaa" }}>{prevTotal > 0 ? `${prevTotal}h` : "—"}</td>
|
||
<td style={{ textAlign: "right", fontWeight: 500, color: delta > 0 ? "#8a1a1a" : delta < 0 ? "#2d6a4f" : "#888" }}>
|
||
{delta !== 0 ? `${delta > 0 ? "+" : ""}${delta}h` : "—"}
|
||
</td>
|
||
</tr>
|
||
);
|
||
})}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
|
||
{/* 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 (
|
||
<div className="card" style={{ padding: 0, marginTop: 20 }}>
|
||
<div style={{ padding: "12px 20px", fontSize: 11, letterSpacing: "0.1em", color: "#888", borderBottom: "1px solid #ece8e2", display: "flex", justifyContent: "space-between", alignItems: "center" }}>
|
||
<span>ABSENZEN NACH KATEGORIE {viewYear}</span>
|
||
<span style={{ fontWeight: 400, letterSpacing: 0, color: "#aaa" }}>vs. {prevYear}</span>
|
||
</div>
|
||
<table style={{ width: "100%", fontSize: 11 }}>
|
||
<thead>
|
||
<tr>
|
||
<th style={{ minWidth: 140 }}>Kategorie</th>
|
||
{months.map((m, i) => {
|
||
const isFuture = viewYear > curYear || (viewYear === curYear && i > curMonth);
|
||
return <th key={i} style={{ textAlign: "right", minWidth: 46, color: isFuture ? "#ccc" : "#555", fontWeight: 500 }}>{m.slice(0, 3)}</th>;
|
||
})}
|
||
<th style={{ textAlign: "right", minWidth: 52 }}>Total</th>
|
||
<th style={{ textAlign: "right", minWidth: 52, color: "#aaa" }}>{prevYear}</th>
|
||
<th style={{ textAlign: "right", minWidth: 46 }}>Δ</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{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 (
|
||
<tr key={t.id}>
|
||
<td style={{ fontSize: 12 }}>
|
||
<span style={{ display: "inline-block", width: 8, height: 8, borderRadius: "50%", background: t.color || "#888", marginRight: 7, verticalAlign: "middle" }} />
|
||
{t.label}
|
||
</td>
|
||
{monthly.map((h, i) => {
|
||
const isFuture = viewYear > curYear || (viewYear === curYear && i > curMonth);
|
||
return <td key={i} style={{ textAlign: "right", color: isFuture ? "#ddd" : h > 0 ? "#555" : "#ddd" }}>{h > 0 && !isFuture ? `${h}h` : "—"}</td>;
|
||
})}
|
||
<td style={{ textAlign: "right", fontWeight: 700 }}>{total > 0 ? `${total}h` : "—"}</td>
|
||
<td style={{ textAlign: "right", color: "#aaa" }}>{prevTotal > 0 ? `${prevTotal}h` : "—"}</td>
|
||
<td style={{ textAlign: "right", fontWeight: 500, color: delta > 0 ? "#8a1a1a" : delta < 0 ? "#2d6a4f" : "#888" }}>
|
||
{delta !== 0 ? `${delta > 0 ? "+" : ""}${delta}h` : "—"}
|
||
</td>
|
||
</tr>
|
||
);
|
||
})}
|
||
{/* 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 (
|
||
<tr style={{ borderTop: "1.5px solid #c8c0b8", background: "#faf8f5", fontWeight: 700 }}>
|
||
<td style={{ fontSize: 12 }}>Total</td>
|
||
{monthlyTotals.map((h, i) => {
|
||
const isFuture = viewYear > curYear || (viewYear === curYear && i > curMonth);
|
||
return <td key={i} style={{ textAlign: "right", color: isFuture ? "#ddd" : h > 0 ? "#1a1a18" : "#ddd" }}>{h > 0 && !isFuture ? `${h}h` : "—"}</td>;
|
||
})}
|
||
<td style={{ textAlign: "right" }}>{grandTotal > 0 ? `${grandTotal}h` : "—"}</td>
|
||
<td style={{ textAlign: "right", color: "#aaa", fontWeight: 400 }}>{prevGrand > 0 ? `${prevGrand}h` : "—"}</td>
|
||
<td style={{ textAlign: "right", color: delta > 0 ? "#8a1a1a" : delta < 0 ? "#2d6a4f" : "#888" }}>
|
||
{delta !== 0 ? `${delta > 0 ? "+" : ""}${delta}h` : "—"}
|
||
</td>
|
||
</tr>
|
||
);
|
||
})()}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
);
|
||
})()}
|
||
|
||
{/* 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 (
|
||
<div className="card" style={{ padding: 0, marginTop: 20 }}>
|
||
<div style={{ padding: "12px 20px", fontSize: 11, letterSpacing: "0.1em", color: "#888", borderBottom: "1px solid #ece8e2", display: "flex", justifyContent: "space-between", alignItems: "center" }}>
|
||
<span>INTERNE STUNDEN {viewYear} — ohne Projektzuweisung</span>
|
||
<span style={{ fontWeight: 400, letterSpacing: 0, color: "#aaa" }}>vs. {prevYear}</span>
|
||
</div>
|
||
<table style={{ width: "100%", fontSize: 11 }}>
|
||
<thead>
|
||
<tr>
|
||
<th style={{ minWidth: 140 }}>Mitarbeiter</th>
|
||
{months.map((m, i) => {
|
||
const isFuture = viewYear > curYear || (viewYear === curYear && i > curMonth);
|
||
return <th key={i} style={{ textAlign: "right", minWidth: 46, color: isFuture ? "#ccc" : "#555", fontWeight: 500 }}>{m.slice(0, 3)}</th>;
|
||
})}
|
||
<th style={{ textAlign: "right", minWidth: 52 }}>Total</th>
|
||
<th style={{ textAlign: "right", minWidth: 52, color: "#aaa" }}>{prevYear}</th>
|
||
<th style={{ textAlign: "right", minWidth: 46 }}>Δ</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{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 (
|
||
<tr key={emp.id}>
|
||
<td style={{ fontWeight: 500, fontSize: 12 }}>{emp.name}</td>
|
||
{monthly.map((h, i) => {
|
||
const isFuture = viewYear > curYear || (viewYear === curYear && i > curMonth);
|
||
return <td key={i} style={{ textAlign: "right", color: isFuture ? "#ddd" : h > 0 ? "#2d5a8e" : "#ddd" }}>{h > 0 && !isFuture ? `${h}h` : "—"}</td>;
|
||
})}
|
||
<td style={{ textAlign: "right", fontWeight: 700, color: total > 0 ? "#2d5a8e" : "#888" }}>{total > 0 ? `${total}h` : "—"}</td>
|
||
<td style={{ textAlign: "right", color: "#aaa" }}>{prevTotal > 0 ? `${prevTotal}h` : "—"}</td>
|
||
<td style={{ textAlign: "right", fontWeight: 500, color: delta > 0 ? "#b5621e" : delta < 0 ? "#2d6a4f" : "#888" }}>
|
||
{delta !== 0 ? `${delta > 0 ? "+" : ""}${delta}h` : "—"}
|
||
</td>
|
||
</tr>
|
||
);
|
||
})}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
);
|
||
})()}
|
||
</>
|
||
);
|
||
})()}
|
||
</>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{/* ── FERIEN ── */}
|
||
{tab === "ferien" && (
|
||
<div>
|
||
{/* 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 (
|
||
<div className="card" style={{ marginBottom: 20, borderLeft: "4px solid #b5621e", padding: "14px 20px" }}>
|
||
<div style={{ fontSize: 11, fontWeight: 600, letterSpacing: "0.1em", color: "#b5621e", marginBottom: 12 }}>
|
||
⏳ PENDENTE ANTRÄGE ({total})
|
||
</div>
|
||
<table style={{ fontSize: 12, width: "100%" }}>
|
||
<thead>
|
||
<tr style={{ borderBottom: "1px solid #ece8e2" }}>
|
||
<th style={{ padding: "4px 8px 4px 0", fontWeight: 500, color: "#888", fontSize: 10 }}>MITARBEITER</th>
|
||
<th style={{ padding: "4px 8px", fontWeight: 500, color: "#888", fontSize: 10 }}>TYP</th>
|
||
<th style={{ padding: "4px 8px", fontWeight: 500, color: "#888", fontSize: 10 }}>VON</th>
|
||
<th style={{ padding: "4px 8px", fontWeight: 500, color: "#888", fontSize: 10 }}>BIS</th>
|
||
<th style={{ padding: "4px 8px", fontWeight: 500, color: "#888", fontSize: 10 }}>NOTIZ</th>
|
||
<th style={{ padding: "4px 0", fontWeight: 500, color: "#888", fontSize: 10, textAlign: "right" }}>AKTION</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{pendingFerien.map(f => {
|
||
const emp = employees.find(e => e.id === f.employeeId);
|
||
return (
|
||
<tr key={f.id} style={{ borderBottom: "1px solid #f5f2ed" }}>
|
||
<td style={{ padding: "7px 8px 7px 0", fontWeight: 500 }}>{emp?.name || "—"}</td>
|
||
<td style={{ padding: "7px 8px" }}><span style={{ background: "#fff8e8", color: "#8a6a3a", border: "1px solid #f0d090", borderRadius: 3, padding: "2px 7px", fontSize: 10, fontWeight: 600 }}>🌴 Ferien</span></td>
|
||
<td style={{ padding: "7px 8px" }}>{formatDate(f.dateFrom)}</td>
|
||
<td style={{ padding: "7px 8px" }}>{formatDate(f.dateTo)}</td>
|
||
<td style={{ padding: "7px 8px", color: "#888" }}>{f.note || "—"}</td>
|
||
<td style={{ padding: "7px 0", textAlign: "right", whiteSpace: "nowrap" }}>
|
||
<button className="btn btn-primary" style={{ padding: "3px 10px", fontSize: 11, marginRight: 6, background: "#2d6a4f" }} onClick={() => approveFerien(f.id)}>✓ Genehmigen</button>
|
||
<button className="btn btn-danger" style={{ padding: "3px 10px", fontSize: 11 }} onClick={() => rejectFerien(f.id)}><span className="material-icons" style={{ fontSize: 16 }}>close</span> Ablehnen</button>
|
||
</td>
|
||
</tr>
|
||
);
|
||
})}
|
||
{pendingAbs.map(a => {
|
||
const emp = employees.find(e => e.id === a.employeeId);
|
||
const t = absenzTypes.find(x => x.id === a.type);
|
||
return (
|
||
<tr key={a.id} style={{ borderBottom: "1px solid #f5f2ed" }}>
|
||
<td style={{ padding: "7px 8px 7px 0", fontWeight: 500 }}>{emp?.name || "—"}</td>
|
||
<td style={{ padding: "7px 8px" }}><span className="tag" style={{ background: t?.color || "#888", fontSize: 10 }}>{t?.label || a.type || "Absenz"}</span></td>
|
||
<td style={{ padding: "7px 8px" }}>{formatDate(a.dateFrom)}</td>
|
||
<td style={{ padding: "7px 8px" }}>{formatDate(a.dateTo)}</td>
|
||
<td style={{ padding: "7px 8px", color: "#888" }}>{a.note || "—"}</td>
|
||
<td style={{ padding: "7px 0", textAlign: "right", whiteSpace: "nowrap" }}>
|
||
<button className="btn btn-primary" style={{ padding: "3px 10px", fontSize: 11, marginRight: 6, background: "#2d6a4f" }} onClick={() => approveAbs(a.id)}>✓ Genehmigen</button>
|
||
<button className="btn btn-danger" style={{ padding: "3px 10px", fontSize: 11 }} onClick={() => rejectAbs(a.id)}><span className="material-icons" style={{ fontSize: 16 }}>close</span> Ablehnen</button>
|
||
</td>
|
||
</tr>
|
||
);
|
||
})}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
);
|
||
})()}
|
||
|
||
<div style={{ display: "flex", justifyContent: "flex-end", gap: 10, marginBottom: 16 }}>
|
||
<select value={selectedEmpId || ""} onChange={e => setSelectedEmpId(e.target.value)} style={{ maxWidth: 200 }}>
|
||
<option value="">Alle Mitarbeiter</option>
|
||
{employees.map(e => <option key={e.id} value={e.id}>{e.name}</option>)}
|
||
</select>
|
||
<button className="btn btn-primary" onClick={() => { setFerienForm({ employeeId: selectedEmpId || (employees[0]?.id || ""), dateFrom: "", dateTo: "", note: "" }); setModal("ferien"); }}>+ Ferien eintragen</button>
|
||
</div>
|
||
|
||
<div className="card" style={{ padding: 0, marginBottom: 20 }}>
|
||
<div style={{ padding: "10px 16px", borderBottom: "1px solid #ece8e2", fontSize: 11, letterSpacing: "0.1em", color: "#888" }}>FERIENEINTRAGUNGEN</div>
|
||
|
||
<table>
|
||
<thead><tr><th>Mitarbeiter</th><th>Von</th><th>Bis</th><th>Notiz</th><th>Status</th><th></th></tr></thead>
|
||
<tbody>
|
||
{ferienEntries.filter(f => !selectedEmpId || f.employeeId === selectedEmpId).length === 0
|
||
? <tr><td colSpan={6} style={{ textAlign: "center", color: "#aaa", padding: 24 }}>Noch keine Ferien eingetragen</td></tr>
|
||
: 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"
|
||
? <span style={{ fontSize: 10, color: "#2d6a4f", background: "#e8f5ee", border: "1px solid #b0d8c0", borderRadius: 3, padding: "2px 7px" }}>✓ Genehmigt</span>
|
||
: f.status === "pending"
|
||
? <span style={{ fontSize: 10, color: "#b5621e", background: "#fff8f0", border: "1px solid #f0d0a0", borderRadius: 3, padding: "2px 7px" }}>⏳ Ausstehend</span>
|
||
: <span style={{ fontSize: 10, color: "#8a1a1a", background: "#f5e8e8", border: "1px solid #d0b0b0", borderRadius: 3, padding: "2px 7px" }}><span className="material-icons" style={{ fontSize: 12 }}>close</span> Abgelehnt</span>;
|
||
return (
|
||
<tr key={f.id} style={f.status === "rejected" ? { opacity: 0.5 } : {}}>
|
||
<td>{emp?.name || "—"}</td>
|
||
<td>{formatDate(f.dateFrom)}</td>
|
||
<td>{formatDate(f.dateTo)}</td>
|
||
<td style={{ color: "#888" }}>{f.note || "—"}</td>
|
||
<td>{statusBadge}</td>
|
||
<td style={{ textAlign: "right", whiteSpace: "nowrap" }}>
|
||
{(!f.status || f.status === "approved") && <button className="btn btn-ghost" style={{ padding: "2px 7px", fontSize: 11, marginRight: 4 }} onClick={() => { setFerienForm(f); setModal("ferien"); }}><span className="material-icons" style={{ fontSize: 16 }}>edit</span></button>}
|
||
<button className="btn btn-danger" style={{ padding: "2px 7px", fontSize: 11 }} onClick={() => delFerien(f.id)}><span className="material-icons" style={{ fontSize: 16 }}>close</span></button>
|
||
</td>
|
||
</tr>
|
||
);
|
||
})
|
||
}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
|
||
{/* 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 (
|
||
<div className="card" style={{ padding: 0, marginBottom: 20 }}>
|
||
<div style={{ padding: "10px 16px", borderBottom: "1px solid #ece8e2", fontSize: 11, letterSpacing: "0.1em", color: "#888" }}>INTERN / ABSENZEN</div>
|
||
<table>
|
||
<thead><tr><th>Mitarbeiter</th><th>Typ</th><th>Von</th><th>Bis</th><th>Notiz</th><th>Status</th><th></th></tr></thead>
|
||
<tbody>
|
||
{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"
|
||
? <span style={{ fontSize: 10, color: "#2d6a4f", background: "#e8f5ee", border: "1px solid #b0d8c0", borderRadius: 3, padding: "2px 7px" }}>✓ Genehmigt</span>
|
||
: a.status === "pending"
|
||
? <span style={{ fontSize: 10, color: "#b5621e", background: "#fff8f0", border: "1px solid #f0d0a0", borderRadius: 3, padding: "2px 7px" }}>⏳ Ausstehend</span>
|
||
: <span style={{ fontSize: 10, color: "#8a1a1a", background: "#f5e8e8", border: "1px solid #d0b0b0", borderRadius: 3, padding: "2px 7px" }}><span className="material-icons" style={{ fontSize: 12 }}>close</span> Abgelehnt</span>;
|
||
return (
|
||
<tr key={a.id} style={a.status === "rejected" ? { opacity: 0.5 } : {}}>
|
||
<td>{emp?.name || "—"}</td>
|
||
<td><span className="tag" style={{ background: t?.color || "#888", fontSize: 10 }}>{t?.label || a.type || "Absenz"}</span></td>
|
||
<td>{formatDate(a.dateFrom || a.date)}</td>
|
||
<td>{formatDate(a.dateTo || a.date)}</td>
|
||
<td style={{ color: "#888" }}>{a.note || "—"}</td>
|
||
<td>{statusBadge}</td>
|
||
<td style={{ textAlign: "right" }}>
|
||
<button className="btn btn-danger" style={{ padding: "2px 7px", fontSize: 11 }} onClick={() => delAbs(a.id)}><span className="material-icons" style={{ fontSize: 16 }}>close</span></button>
|
||
</td>
|
||
</tr>
|
||
);
|
||
})}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
);
|
||
})()}
|
||
|
||
{/* Ferien-Übersicht pro Mitarbeiter */}
|
||
{employees.length > 0 && (
|
||
<div className="card">
|
||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 16 }}>
|
||
<div style={{ fontSize: 11, letterSpacing: "0.1em", color: "#888" }}>FERIENANSPRUCH ÜBERSICHT</div>
|
||
<select value={viewYear} onChange={e => setViewYear(+e.target.value)} style={{ width: 90, fontSize: 12 }}>
|
||
{[viewYear - 1, viewYear, viewYear + 1].map(y => <option key={y} value={y}>{y}</option>)}
|
||
</select>
|
||
</div>
|
||
<table>
|
||
<thead><tr><th>Mitarbeiter</th><th>Pensum</th><th style={{ textAlign: "right" }}>Anspruch</th><th style={{ textAlign: "right" }}>Übertrag</th><th style={{ textAlign: "right" }}>Bezogen</th><th style={{ textAlign: "right" }}>Geplant</th><th style={{ textAlign: "right" }}>Rest</th></tr></thead>
|
||
<tbody>
|
||
{employees.map(emp => {
|
||
const fs = calcFerienYear(emp, viewYear);
|
||
return (
|
||
<tr key={emp.id}>
|
||
<td><strong>{emp.name}</strong></td>
|
||
<td style={{ color: "#888" }}>{emp.pensum || 100}%</td>
|
||
<td style={{ textAlign: "right" }}>{fs.anspruchH}h</td>
|
||
<td style={{ textAlign: "right", color: "#888" }}>{fs.ubertragH}h</td>
|
||
<td style={{ textAlign: "right" }}>{fs.bezogenH}h</td>
|
||
<td style={{ textAlign: "right", color: "#7a6a00" }}>{fs.zukunftH}h</td>
|
||
<td style={{ textAlign: "right", fontWeight: 700, color: fs.restH < 0 ? "#8a1a1a" : "#2d6a4f" }}>{fs.restH}h</td>
|
||
</tr>
|
||
);
|
||
})}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{/* ── MONATSABSCHLUSS ── */}
|
||
{tab === "monatsabschluss" && (
|
||
<div>
|
||
<div style={{ fontSize: 12, color: "#888", marginBottom: 16 }}>
|
||
Geschlossene Monate können in der Zeiterfassung nicht mehr bearbeitet werden. Monate können jederzeit wieder geöffnet werden.
|
||
</div>
|
||
{(() => {
|
||
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 (
|
||
<>
|
||
<div style={{ display: "flex", justifyContent: "flex-end", marginBottom: 16 }}>
|
||
<select value={viewYear} onChange={e => setViewYear(+e.target.value)} style={{ width: 90, fontSize: 12 }}>
|
||
{availableYears.map(y => <option key={y} value={y}>{y}</option>)}
|
||
</select>
|
||
</div>
|
||
<div className="card" style={{ padding: 0 }}>
|
||
{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 (
|
||
<div key={monthStr} style={{ display: "flex", alignItems: "center", justifyContent: "space-between", padding: "14px 20px", borderBottom: i < 11 ? "1px solid #ece8e2" : "none", background: isClosed ? "#f9f7f4" : "transparent", opacity: isFuture ? 0.4 : 1 }}>
|
||
<div style={{ display: "flex", alignItems: "center", gap: 12 }}>
|
||
<div style={{ fontSize: 13, fontWeight: 500, minWidth: 100 }}>{m} {viewYear}</div>
|
||
{isCurrent && <span style={{ fontSize: 10, color: "#1a4e8a", background: "#e8f0fa", padding: "2px 7px", borderRadius: 3 }}>Aktuell</span>}
|
||
{isClosed && <span style={{ fontSize: 10, fontWeight: 600, color: "#555", background: "#e0dbd4", padding: "2px 7px", borderRadius: 3 }}>● Geschlossen</span>}
|
||
{!isClosed && !isFuture && <span style={{ fontSize: 10, color: "#2d6a4f", background: "#e8f5ee", padding: "2px 7px", borderRadius: 3 }}>○ Offen</span>}
|
||
</div>
|
||
{!isFuture && (
|
||
<button
|
||
className={isClosed ? "btn btn-ghost" : "btn btn-ghost"}
|
||
style={{ fontSize: 11, padding: "4px 14px", color: isClosed ? "#2d6a4f" : "#8a1a1a", borderColor: isClosed ? "#b0d8c0" : "#e8b0b0" }}
|
||
onClick={() => toggleMonth(monthStr)}
|
||
>
|
||
{isClosed ? "Öffnen" : "Schliessen"}
|
||
</button>
|
||
)}
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
</>
|
||
);
|
||
})()}
|
||
</div>
|
||
)}
|
||
|
||
{/* ── MITARBEITER ── */}
|
||
{tab === "mitarbeiter" && (
|
||
<div>
|
||
<div style={{ display: "flex", justifyContent: "flex-end", marginBottom: 16 }}>
|
||
<button className="btn btn-primary" onClick={() => { setEmpForm({ pensum: 100, wochenstunden: data.settings.defaultWochenstunden || 35, ferienWochen: data.settings.defaultFerienWochen || 5, pkAGSatz: data.settings.defaultPkAGSatz || 8.0, ferienUebertragVorjahr: {}, personalNr: nextPersonalNr(), _appAccess: false, _appUsername: "", _appPassword: "", _appRoleId: (data.appRoles || []).find(r => r.id !== "r-admin")?.id || "", _appUserId: undefined }); setModal("emp"); }}>+ Mitarbeiter</button>
|
||
</div>
|
||
<div className="card" style={{ padding: 0 }}>
|
||
<table>
|
||
<thead><tr><th>Name</th><th>Pensum</th><th>Wochenstunden</th><th>Ferien (Wo)</th><th>Eintritt</th><th></th></tr></thead>
|
||
<tbody>
|
||
{employees.length === 0 && <tr><td colSpan={6} style={{ textAlign: "center", color: "#aaa", padding: 32 }}>Noch keine Mitarbeiter erfasst</td></tr>}
|
||
{employees.map(emp => (
|
||
<tr key={emp.id}>
|
||
<td><strong>{emp.name}</strong>{emp.role && <div style={{ fontSize: 11, color: "#888" }}>{emp.role}</div>}</td>
|
||
<td>{emp.pensum || 100}%</td>
|
||
<td>{((emp.wochenstunden || 35) * (emp.pensum || 100) / 100).toFixed(1)}h <span style={{ fontSize: 11, color: "#888" }}>({emp.wochenstunden || 35}h @ 100%)</span></td>
|
||
<td>{emp.ferienWochen || 4} Wo</td>
|
||
<td style={{ color: "#888" }}>{emp.eintrittsdatum ? formatDate(emp.eintrittsdatum) : "—"}</td>
|
||
<td style={{ textAlign: "right", whiteSpace: "nowrap" }}>
|
||
<button className="btn btn-ghost" style={{ padding: "5px 10px", fontSize: 12, marginRight: 6 }} onClick={() => {
|
||
const linked = (data.users || []).find(u => u.id === emp.appUserId);
|
||
setEmpForm({ ...emp, _appAccess: !!linked, _appUsername: linked?.username || "", _appPassword: "", _appRoleId: linked?.appRoleId || (data.appRoles || []).find(r => r.id !== "r-admin")?.id || "", _appUserId: linked?.id });
|
||
setModal("emp");
|
||
}}><span className="material-icons" style={{ fontSize: 16 }}>edit</span></button>
|
||
<button className="btn btn-danger" style={{ padding: "5px 10px", fontSize: 12 }} onClick={() => delEmp(emp.id)}><span className="material-icons" style={{ fontSize: 16 }}>close</span></button>
|
||
</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
|
||
{/* ── JAHRESABSCHLUSS ── */}
|
||
{tab === "jahresabschluss" && (
|
||
<div>
|
||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 20 }}>
|
||
<div style={{ fontSize: 12, color: "#888" }}>Ferienüberträge bestätigen und Überstunden abrechnen</div>
|
||
<select value={abschlussYear} onChange={e => setAbschlussYear(+e.target.value)} style={{ width: 100, fontSize: 12 }}>
|
||
{[...Array(5)].map((_, i) => { const y = new Date().getFullYear() - i; return <option key={y} value={y}>{y}</option>; })}
|
||
</select>
|
||
</div>
|
||
|
||
{employees.length === 0 && <div className="empty-state">Keine Mitarbeiter erfasst</div>}
|
||
|
||
{employees.filter(emp => emp.eintrittsdatum && emp.eintrittsdatum <= `${abschlussYear}-12-31`).length === 0 && employees.length > 0 && (
|
||
<div className="empty-state">Keine Mitarbeiter waren {abschlussYear} angestellt</div>
|
||
)}
|
||
|
||
{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 (
|
||
<div key={emp.id} className="card" style={{ marginBottom: 16 }}>
|
||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 16, paddingBottom: 12, borderBottom: "1px solid #ece8e2" }}>
|
||
<div>
|
||
<strong style={{ fontSize: 14 }}>{emp.name}</strong>
|
||
{emp.role && <span style={{ fontSize: 12, color: "#888", marginLeft: 10 }}>{emp.role}</span>}
|
||
</div>
|
||
<span style={{ fontSize: 11, color: "#888" }}>{emp.pensum || 100}% · {((emp.wochenstunden || 35) * (emp.pensum || 100) / 100).toFixed(1)}h/Wo</span>
|
||
</div>
|
||
|
||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 32 }}>
|
||
{/* Ferien */}
|
||
<div>
|
||
<div className="section-label" style={{ marginBottom: 10 }}>Ferien {abschlussYear}</div>
|
||
{[
|
||
{ 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 => (
|
||
<div key={r.label} style={{ display: "flex", justifyContent: "space-between", fontSize: 12, color: "#555", padding: "2px 0" }}>
|
||
<span>{r.label}</span><span>{r.value}</span>
|
||
</div>
|
||
))}
|
||
<div style={{ display: "flex", justifyContent: "space-between", fontSize: 13, fontWeight: 600, padding: "6px 0 0", marginTop: 4, borderTop: "1px solid #ece8e2" }}>
|
||
<span>Restanspruch</span>
|
||
<span style={{ color: fs.restH > 0 ? "#2d6a4f" : fs.restH < 0 ? "#8a1a1a" : "#888" }}>{fs.restH}h</span>
|
||
</div>
|
||
|
||
{fs.restH > 0 ? (
|
||
<div style={{ marginTop: 16 }}>
|
||
<div style={{ fontSize: 11, color: "#888", marginBottom: 6 }}>Übertrag ins {abschlussYear + 1}</div>
|
||
<div style={{ display: "flex", gap: 8, alignItems: "center" }}>
|
||
<input
|
||
type="number" min={0} max={fs.restH} step={0.5}
|
||
value={form.ferienUebertrag}
|
||
onChange={e => setForm({ ferienUebertrag: Math.min(+e.target.value, fs.restH) })}
|
||
style={{ width: 70, fontSize: 12 }}
|
||
/>
|
||
<span style={{ fontSize: 12, color: "#888" }}>h von {fs.restH}h</span>
|
||
</div>
|
||
<button className="btn btn-primary" style={{ fontSize: 11, padding: "4px 14px", marginTop: 10 }}
|
||
onClick={() => saveFerienUebertrag(emp, form.ferienUebertrag)}>
|
||
Bestätigen
|
||
</button>
|
||
{currentUebertrag !== undefined && (
|
||
<div style={{ fontSize: 11, color: "#2d6a4f", marginTop: 6 }}>✓ {currentUebertrag}h bestätigt für {abschlussYear + 1}</div>
|
||
)}
|
||
</div>
|
||
) : fs.restH < 0 ? (
|
||
<div style={{ fontSize: 11, color: "#8a1a1a", marginTop: 12 }}>Negativsaldo — Bezug übersteigt Anspruch</div>
|
||
) : (
|
||
<div style={{ fontSize: 11, color: "#888", marginTop: 12 }}>Kein Restanspruch — kein Übertrag nötig</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Überstunden */}
|
||
<div>
|
||
<div className="section-label" style={{ marginBottom: 10 }}>Überstunden (Saldo gesamt)</div>
|
||
<div style={{ display: "flex", justifyContent: "space-between", fontSize: 13, fontWeight: 600, marginBottom: 12 }}>
|
||
<span>Verbleibend</span>
|
||
<span style={{ color: ubSaldo > 0 ? "#2d6a4f" : ubSaldo < 0 ? "#8a1a1a" : "#888" }}>{ubSaldo}h</span>
|
||
</div>
|
||
|
||
{existingPayouts.length > 0 && (
|
||
<div style={{ marginBottom: 12 }}>
|
||
{existingPayouts.map(p => (
|
||
<div key={p.id} style={{ fontSize: 11, color: "#2d6a4f", background: "#e8f5ee", border: "1px solid #b0d8c0", borderRadius: 20, padding: "4px 12px", marginBottom: 4 }}>
|
||
✓ {p.hoursSettled}h ausbezahlt — {formatCHF(p.auszahlungBetrag)} ({p.zuschlagPercent}% Zuschlag)
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
|
||
{ubSaldo > 0 ? (
|
||
<>
|
||
<div style={{ fontSize: 11, color: "#888", marginBottom: 6 }}>Stunden ausbezahlen</div>
|
||
<div style={{ display: "flex", gap: 6, alignItems: "center", marginBottom: 6 }}>
|
||
<input
|
||
type="number" min={0.5} max={ubSaldo} step={0.5}
|
||
value={form.ubHours}
|
||
onChange={e => setForm({ ubHours: Math.min(+e.target.value, ubSaldo) })}
|
||
placeholder="Std."
|
||
style={{ width: 65, fontSize: 12 }}
|
||
/>
|
||
<span style={{ fontSize: 12, color: "#555" }}>h ×</span>
|
||
<input
|
||
type="number" min={0} step={1}
|
||
value={form.ubZuschlag}
|
||
onChange={e => setForm({ ubZuschlag: +e.target.value })}
|
||
style={{ width: 52, fontSize: 12 }}
|
||
/>
|
||
<span style={{ fontSize: 12, color: "#555" }}>% Zuschlag</span>
|
||
</div>
|
||
{form.ubZuschlag < 25 && (
|
||
<div style={{ fontSize: 10, color: "#b5621e", marginBottom: 6 }}>⚠ OR Art. 321c: mind. 25% Zuschlag empfohlen</div>
|
||
)}
|
||
{stundenlohn > 0 ? (
|
||
<div style={{ fontSize: 12, color: "#555", marginBottom: 10 }}>
|
||
{form.ubHours}h × {formatCHF(stundenlohn)}/h = <strong>{formatCHF(auszahlungBetrag)}</strong>
|
||
</div>
|
||
) : (
|
||
<div style={{ fontSize: 11, color: "#b5621e", marginBottom: 10 }}>Kein Monatslohn hinterlegt</div>
|
||
)}
|
||
<button
|
||
className="btn btn-primary"
|
||
style={{ fontSize: 11, padding: "4px 14px" }}
|
||
disabled={!stundenlohn || !(form.ubHours > 0)}
|
||
onClick={() => saveUbAbschluss(emp, form)}
|
||
>
|
||
Erfassen
|
||
</button>
|
||
<div style={{ fontSize: 11, color: "#aaa", marginTop: 6 }}>Restliche Stunden werden übertragen.</div>
|
||
</>
|
||
) : (
|
||
<div style={{ fontSize: 12, color: "#888" }}>Kein Überstundensaldo vorhanden.</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
)}
|
||
|
||
{/* ── MODALS ── */}
|
||
{modal === "emp" && (
|
||
<Modal title={empForm.id ? "Mitarbeiter bearbeiten" : "Neuer Mitarbeiter"} onClose={() => setModal(null)} onSave={saveEmp} wide>
|
||
<div style={{ fontSize: 11, letterSpacing: "0.08em", color: "#888", marginBottom: 10 }}>PERSONALIEN</div>
|
||
<div className="form-row">
|
||
<FormField label="Name *"><input value={empForm.name || ""} onChange={e => setEmpForm({ ...empForm, name: e.target.value })} autoFocus style={!empForm.name?.trim() ? { borderColor: "#b5621e" } : {}} /></FormField>
|
||
<FormField label="Funktion / Rolle"><input value={empForm.role || ""} onChange={e => setEmpForm({ ...empForm, role: e.target.value })} placeholder="z.B. Architekt, Praktikant…" /></FormField>
|
||
<FormField label="Personal-Nr.">
|
||
<div style={{ display: "flex", gap: 6, alignItems: "center" }}>
|
||
<input value={empForm.personalNr || ""} onChange={e => setEmpForm({ ...empForm, personalNr: e.target.value })} style={{ flex: 1 }} placeholder="001" />
|
||
{empForm.id && empForm.personalNr === "" && (
|
||
<button type="button" className="btn btn-ghost" style={{ padding: "0 10px", height: 36, fontSize: 11 }} onClick={() => setEmpForm({ ...empForm, personalNr: nextPersonalNr() })}>Auto</button>
|
||
)}
|
||
</div>
|
||
{empForm.personalNr && !/^\d+$/.test(empForm.personalNr) && (
|
||
<div style={{ fontSize: 10, color: "#b5621e", marginTop: 3 }}>Nicht-numerische Nr. wird nicht für Auto-Zählung verwendet</div>
|
||
)}
|
||
</FormField>
|
||
</div>
|
||
<div className="form-row">
|
||
<FormField label="Adresse"><input value={empForm.adresse || ""} onChange={e => setEmpForm({ ...empForm, adresse: e.target.value })} placeholder="Musterstrasse 1" /></FormField>
|
||
<FormField label="PLZ / Ort"><input value={empForm.ort || ""} onChange={e => setEmpForm({ ...empForm, ort: e.target.value })} placeholder="8001 Zürich" /></FormField>
|
||
</div>
|
||
<div className="form-row">
|
||
<FormField label="AHV-Nr."><input value={empForm.ahvNr || ""} onChange={e => setEmpForm({ ...empForm, ahvNr: e.target.value })} placeholder="756.1234.5678.90" /></FormField>
|
||
<FormField label="Geburtsdatum"><DateInput value={empForm.geburtsdatum || ""} onChange={e => setEmpForm({ ...empForm, geburtsdatum: e.target.value })} /></FormField>
|
||
<FormField label="E-Mail"><input type="email" value={empForm.email || ""} onChange={e => setEmpForm({ ...empForm, email: e.target.value })} /></FormField>
|
||
</div>
|
||
<div className="form-row">
|
||
<FormField label="Eintrittsdatum *"><DatePicker value={empForm.eintrittsdatum || ""} onChange={e => setEmpForm({ ...empForm, eintrittsdatum: e.target.value })} style={!empForm.eintrittsdatum ? { borderColor: "#b5621e" } : {}} /></FormField>
|
||
</div>
|
||
<div style={{ fontSize: 11, letterSpacing: "0.08em", color: "#888", margin: "14px 0 10px", paddingTop: 12, borderTop: "1px solid #ece8e2" }}>ARBEITSZEIT & FERIEN</div>
|
||
<div className="form-row">
|
||
<FormField label="Pensum %">
|
||
<input type="number" min={10} max={100} step={5} value={empForm.pensum || 100} onChange={e => setEmpForm({ ...empForm, pensum: +e.target.value })} />
|
||
</FormField>
|
||
<FormField label="Wochenstunden (100%)">
|
||
<input type="number" min={1} max={50} step={0.5} value={empForm.wochenstunden || data.settings.defaultWochenstunden || 35} onChange={e => setEmpForm({ ...empForm, wochenstunden: +e.target.value })} />
|
||
<div style={{ fontSize: 11, color: "#888", marginTop: 4 }}>Effektiv: {(((empForm.wochenstunden || 35) * (empForm.pensum || 100)) / 100).toFixed(1)}h/Wo</div>
|
||
</FormField>
|
||
<FormField label="Ferien (Wochen/Jahr)">
|
||
<input type="number" min={4} max={10} step={0.5} value={empForm.ferienWochen || data.settings.defaultFerienWochen || 5} onChange={e => setEmpForm({ ...empForm, ferienWochen: +e.target.value })} />
|
||
<div style={{ fontSize: 11, color: "#888", marginTop: 4 }}>Minimum: 4 Wochen (OR)</div>
|
||
</FormField>
|
||
</div>
|
||
<div style={{ fontSize: 11, letterSpacing: "0.08em", color: "#888", margin: "14px 0 10px", paddingTop: 12, borderTop: "1px solid #ece8e2" }}>LOHN & AUSZAHLUNG</div>
|
||
<div className="form-row">
|
||
<FormField label="Monatslohn Brutto CHF (100% Basis)">
|
||
<input type="number" min={0} step={100} value={empForm.monatslohn || ""} onChange={e => setEmpForm({ ...empForm, monatslohn: +e.target.value })} placeholder="z.B. 7000" />
|
||
{(empForm.pensum || 100) < 100 && empForm.monatslohn > 0 && (
|
||
<div style={{ fontSize: 11, color: "#888", marginTop: 3 }}>Effektiv bei {empForm.pensum}%: {formatCHF(Math.round(empForm.monatslohn * (empForm.pensum / 100) * 100) / 100)}</div>
|
||
)}
|
||
</FormField>
|
||
<FormField label="IBAN Lohnkonto">
|
||
<input
|
||
value={empForm.lohnIban || ""}
|
||
onChange={e => setEmpForm({ ...empForm, lohnIban: formatIban(e.target.value) })}
|
||
placeholder="CH00 0000 0000 0000 0000 0"
|
||
/>
|
||
</FormField>
|
||
<FormField label=" ">
|
||
<label style={{ display: "flex", alignItems: "center", gap: 8, height: 36, cursor: "pointer", textTransform: "none", fontSize: 13 }}>
|
||
<input type="checkbox" checked={!!empForm.dreizehnterLohn} onChange={e => setEmpForm({ ...empForm, dreizehnterLohn: e.target.checked })} style={{ width: "auto" }} />
|
||
13. Monatslohn
|
||
</label>
|
||
</FormField>
|
||
</div>
|
||
<div style={{ fontSize: 11, color: "#888", marginBottom: 8 }}>Sozialabzüge Arbeitnehmer (anpassbar)</div>
|
||
<div className="form-row">
|
||
<FormField label="AHV/IV/EO %">
|
||
<input type="number" step={0.01} min={0} value={empForm.ahvSatz ?? 5.3} onChange={e => setEmpForm({ ...empForm, ahvSatz: +e.target.value })} />
|
||
<div style={{ fontSize: 10, color: "#aaa", marginTop: 3 }}>Standard 5.30%</div>
|
||
</FormField>
|
||
<FormField label="ALV %">
|
||
<input type="number" step={0.01} min={0} value={empForm.alvSatz ?? 1.1} onChange={e => setEmpForm({ ...empForm, alvSatz: +e.target.value })} />
|
||
<div style={{ fontSize: 10, color: "#aaa", marginTop: 3 }}>Standard 1.10%</div>
|
||
</FormField>
|
||
<FormField label="BVG / PK AN %">
|
||
<input type="number" step={0.1} min={0} value={empForm.bvgSatz ?? 8.0} onChange={e => setEmpForm({ ...empForm, bvgSatz: +e.target.value })} />
|
||
<div style={{ fontSize: 10, color: "#aaa", marginTop: 3 }}>Arbeitnehmer-Anteil</div>
|
||
</FormField>
|
||
</div>
|
||
<div style={{ fontSize: 11, color: "#888", marginBottom: 8 }}>Sozialabgaben Arbeitgeber</div>
|
||
<div className="form-row">
|
||
<FormField label="PK / BVG AG %">
|
||
<input type="number" step={0.1} min={0} value={empForm.pkAGSatz ?? (data.settings.defaultPkAGSatz || 8.0)} onChange={e => setEmpForm({ ...empForm, pkAGSatz: +e.target.value })} />
|
||
<div style={{ fontSize: 10, color: "#aaa", marginTop: 3 }}>AG muss mind. AN-Anteil zahlen</div>
|
||
</FormField>
|
||
</div>
|
||
<div className="form-row">
|
||
<FormField label="NBU % (Nichtberufsunfall)">
|
||
<input type="number" step={0.01} min={0} value={empForm.nbuSatz ?? 1.5} onChange={e => setEmpForm({ ...empForm, nbuSatz: +e.target.value })} />
|
||
<div style={{ fontSize: 10, color: "#aaa", marginTop: 3 }}>trägt AN</div>
|
||
</FormField>
|
||
<FormField label="KTG % (Krankentaggeld)">
|
||
<input type="number" step={0.01} min={0} value={empForm.ktgSatz ?? 0.5} onChange={e => setEmpForm({ ...empForm, ktgSatz: +e.target.value })} />
|
||
<div style={{ fontSize: 10, color: "#aaa", marginTop: 3 }}>je nach Vertrag</div>
|
||
</FormField>
|
||
<FormField label=" ">
|
||
<label style={{ display: "flex", alignItems: "center", gap: 8, height: 36, cursor: "pointer", textTransform: "none", fontSize: 13 }}>
|
||
<input type="checkbox" checked={!!empForm.quellensteuerPflichtig} onChange={e => setEmpForm({ ...empForm, quellensteuerPflichtig: e.target.checked })} style={{ width: "auto" }} />
|
||
Quellensteuerpflichtig
|
||
</label>
|
||
{empForm.quellensteuerPflichtig && (
|
||
<input type="number" step={0.1} min={0} value={empForm.quellensteuerSatz ?? 10} onChange={e => setEmpForm({ ...empForm, quellensteuerSatz: +e.target.value })} placeholder="Satz %" style={{ marginTop: 4 }} />
|
||
)}
|
||
</FormField>
|
||
</div>
|
||
|
||
{/* APP-ZUGANG */}
|
||
<div style={{ fontSize: 11, letterSpacing: "0.08em", color: "#888", margin: "18px 0 12px", paddingTop: 14, borderTop: "1px solid #ece8e2" }}>APP-ZUGANG</div>
|
||
<div style={{ marginBottom: 14 }}>
|
||
<label style={{ display: "flex", alignItems: "center", gap: 10, cursor: "pointer", fontSize: 13, color: "#1a1a18" }}>
|
||
<input type="checkbox" checked={!!empForm._appAccess} onChange={e => setEmpForm({ ...empForm, _appAccess: e.target.checked })} style={{ width: "auto" }} />
|
||
Zugang zur App aktivieren
|
||
</label>
|
||
</div>
|
||
{empForm._appAccess && (
|
||
<>
|
||
<div className="form-row">
|
||
<FormField label="Benutzername *">
|
||
<input value={empForm._appUsername || ""} onChange={e => setEmpForm({ ...empForm, _appUsername: e.target.value })} placeholder="vorname.nachname" style={!empForm._appUsername?.trim() ? { borderColor: "#b5621e" } : {}} />
|
||
</FormField>
|
||
<FormField label={empForm._appUserId ? "Neues Passwort (leer = unverändert)" : "Passwort"}>
|
||
<input type="password" value={empForm._appPassword || ""} onChange={e => setEmpForm({ ...empForm, _appPassword: e.target.value })} placeholder={empForm._appUserId ? "••••••" : "Passwort vergeben"} />
|
||
</FormField>
|
||
</div>
|
||
<FormField label="Rolle">
|
||
<select value={empForm._appRoleId || ""} onChange={e => setEmpForm({ ...empForm, _appRoleId: e.target.value })}>
|
||
<option value="">— Rolle wählen —</option>
|
||
{(data.appRoles || []).map(r => (
|
||
<option key={r.id} value={r.id}>{r.name}{r.permissions === null ? " (Alle Rechte)" : ` (${(r.permissions || []).length} Bereiche)`}</option>
|
||
))}
|
||
</select>
|
||
{!empForm._appRoleId && <div style={{ fontSize: 10, color: "#b5621e", marginTop: 3 }}>Bitte eine Rolle wählen</div>}
|
||
{empForm._appRoleId && (() => {
|
||
const role = (data.appRoles || []).find(r => r.id === empForm._appRoleId);
|
||
if (!role) return null;
|
||
if (role.permissions === null) return <div style={{ fontSize: 10, color: "#2d6a4f", marginTop: 3 }}>Zugriff auf alle Bereiche</div>;
|
||
if (!role.permissions?.length) return <div style={{ fontSize: 10, color: "#b5621e", marginTop: 3 }}>⚠ Rolle hat keine Bereiche — Benutzer sieht nichts</div>;
|
||
return <div style={{ fontSize: 10, color: "#888", marginTop: 3 }}>{role.permissions.join(", ")}</div>;
|
||
})()}
|
||
</FormField>
|
||
<div style={{ fontSize: 10, color: "#aaa", marginTop: 4 }}>Rollen und Berechtigungen unter Einstellungen → App-Rollen verwalten.</div>
|
||
</>
|
||
)}
|
||
</Modal>
|
||
)}
|
||
|
||
{modal === "abs" && (
|
||
<Modal title={absForm.id ? "Absenz bearbeiten" : "Absenz erfassen"} onClose={() => setModal(null)} onSave={saveAbs}>
|
||
<div className="form-row">
|
||
<FormField label="Mitarbeiter">
|
||
<select value={absForm.employeeId || ""} onChange={e => setAbsForm({ ...absForm, employeeId: e.target.value })}>
|
||
<option value="">— wählen —</option>
|
||
{employees.map(e => <option key={e.id} value={e.id}>{e.name}</option>)}
|
||
</select>
|
||
</FormField>
|
||
<FormField label="Absenztyp">
|
||
<select value={absForm.type || ""} onChange={e => setAbsForm({ ...absForm, type: e.target.value })}>
|
||
<option value="">— wählen —</option>
|
||
{absenzTypes.map(t => <option key={t.id} value={t.id}>{t.label}</option>)}
|
||
</select>
|
||
</FormField>
|
||
</div>
|
||
<div className="form-row">
|
||
<FormField label="Von"><DateInput value={absForm.dateFrom || ""} onChange={e => setAbsForm({ ...absForm, dateFrom: e.target.value })} /></FormField>
|
||
<FormField label="Bis"><DateInput value={absForm.dateTo || ""} onChange={e => setAbsForm({ ...absForm, dateTo: e.target.value })} /></FormField>
|
||
</div>
|
||
<FormField label="Notiz (optional)"><input value={absForm.note || ""} onChange={e => setAbsForm({ ...absForm, note: e.target.value })} /></FormField>
|
||
</Modal>
|
||
)}
|
||
|
||
{modal === "ferien" && (
|
||
<Modal title={ferienForm.id ? "Ferien bearbeiten" : "Ferien eintragen"} onClose={() => setModal(null)} onSave={saveFerien}>
|
||
<FormField label="Mitarbeiter">
|
||
<select value={ferienForm.employeeId || ""} onChange={e => setFerienForm({ ...ferienForm, employeeId: e.target.value })}>
|
||
<option value="">— wählen —</option>
|
||
{employees.map(e => <option key={e.id} value={e.id}>{e.name}</option>)}
|
||
</select>
|
||
</FormField>
|
||
<div className="form-row">
|
||
<FormField label="Von"><DateInput value={ferienForm.dateFrom || ""} onChange={e => setFerienForm({ ...ferienForm, dateFrom: e.target.value })} /></FormField>
|
||
<FormField label="Bis"><DateInput value={ferienForm.dateTo || ""} onChange={e => setFerienForm({ ...ferienForm, dateTo: e.target.value })} /></FormField>
|
||
</div>
|
||
<FormField label="Notiz (optional)"><input value={ferienForm.note || ""} onChange={e => setFerienForm({ ...ferienForm, note: e.target.value })} /></FormField>
|
||
</Modal>
|
||
)}
|
||
|
||
</div>
|
||
);
|
||
} |