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

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

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

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

867 lines
55 KiB
React
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { useState } from "react";
import { STORAGE_KEY, DEFAULT_ABSENZ_TYPES } from "../constants.js";
import { formatIban, isQRIban, applyProjectNumberFormat, applyProtoNumberFormat, generateId, getFeiertageForYear, getAbsenzTypes } from "../utils.js";
import { Header, FormField, Modal, DateInput, useConfirm } from "../components/UI.jsx";
const PERMISSION_GROUPS = [
{ label: "Grundmodule", items: [
{ id: "dashboard", label: "Übersicht" },
{ id: "projects", label: "Projekte" },
{ id: "time", label: "Zeiterfassung" },
{ id: "personen", label: "Kunden & Partner" },
]},
{ label: "Buchhaltung", items: [
{ id: "invoices", label: "Rechnungen" },
{ id: "expenses", label: "Spesen" },
{ id: "internal-expenses", label: "Ausgaben" },
{ id: "loehne", label: "Löhne" },
{ id: "studio-budget", label: "Budget" },
]},
{ label: "Dokumente", items: [
{ id: "quotes", label: "Offerten" },
{ id: "protokolle", label: "Protokolle" },
{ id: "lieferscheine", label: "Lieferscheine" },
{ id: "letters", label: "Briefe" },
]},
{ label: "Administration", items: [
{ id: "mitarbeiter", label: "Mitarbeiter (inkl. Zeiterfassung aller MA)" },
{ id: "settings", label: "Einstellungen / Mein Profil" },
{ id: "dashboard-vorlage", label: "Dashboard-Vorlagen speichern" },
{ id: "pinnwand-schreiben", label: "Pinnwand — Beiträge verfassen" },
]},
];
const TABS = [
{ id: "studio", label: "Studio" },
{ id: "dokumente", label: "Dokumente & Formate" },
{ id: "team", label: "Team & Rollen" },
{ id: "kalender", label: "Feiertage & Absenzen" },
{ id: "system", label: "System" },
{ id: "profil", label: "Mein Profil" },
];
const SETTINGS_TABS = new Set(["studio", "dokumente", "team", "system"]);
function Section({ title, children }) {
return (
<div style={{ marginBottom: 28 }}>
<div style={{ fontSize: 10, letterSpacing: "0.12em", color: "#aaa", fontWeight: 600, marginBottom: 14, paddingBottom: 8, borderBottom: "1px solid #ece8e2" }}>{title}</div>
{children}
</div>
);
}
function FmtRow({ label, value, onChange, placeholder, preview }) {
return (
<div style={{ marginBottom: 12 }}>
<div style={{ fontSize: 12, color: "#555", marginBottom: 5 }}>{label}</div>
<div style={{ display: "flex", gap: 8, alignItems: "center" }}>
<input value={value} onChange={e => onChange(e.target.value)} style={{ maxWidth: 160, fontFamily: "monospace" }} placeholder={placeholder} />
{preview && <span style={{ fontSize: 12, color: "#2d6a4f", fontWeight: 500 }}> {preview}</span>}
</div>
</div>
);
}
function PersonalSettings({ data, update, currentUser, uiZoom, setUiZoom, nav = null }) {
const emp = (data.employees || []).find(e => e.id === currentUser.employeeId || e.name === currentUser.displayName);
const userRec = (data.users || []).find(u => u.id === currentUser.id);
const [empForm, setEmpForm] = useState({
street: emp?.street || "",
zip: emp?.zip || "",
city: emp?.city || "",
lohnIban: emp?.lohnIban || "",
});
const [saved, setSaved] = useState(false);
const zoomStep = 0.05;
const saveProfile = () => {
if (emp) {
update("employees", (data.employees || []).map(e => e.id === emp.id ? { ...e, ...empForm } : e));
}
setSaved(true);
setTimeout(() => setSaved(false), 2500);
};
const uploadAvatar = (e) => {
const file = e.target.files?.[0]; if (!file) return;
if (!file.type.startsWith("image/")) { alert("Bitte ein Bild auswählen."); e.target.value = ""; return; }
const reader = new FileReader();
reader.onload = (ev) => {
const img = new Image();
img.onload = () => {
const max = 256; // avatar — keep tiny so localStorage stays slim
const scale = Math.min(1, max / Math.max(img.width, img.height));
const w = Math.round(img.width * scale);
const h = Math.round(img.height * scale);
const canvas = document.createElement("canvas");
canvas.width = w; canvas.height = h;
canvas.getContext("2d").drawImage(img, 0, 0, w, h);
const avatar = canvas.toDataURL("image/jpeg", 0.85);
update("users", (data.users || []).map(u => u.id === currentUser.id ? { ...u, avatar } : u));
};
img.src = ev.target.result;
};
reader.readAsDataURL(file);
e.target.value = "";
};
return (
<div>
<Header title="Mein Profil" action={
<button className="btn btn-primary" style={{ background: saved ? "#2d6a4f" : "#2a2a22", transition: "background 0.3s" }} onClick={saveProfile}>
{saved ? "✓ Gespeichert" : "Speichern"}
</button>
} />
{nav}
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 20 }} className="responsive-grid-2">
<div style={{ display: "flex", flexDirection: "column", gap: 20 }}>
<div className="card">
<Section title="PROFIL">
<div style={{ display: "flex", alignItems: "center", gap: 16, marginBottom: 6 }}>
<div style={{ width: 64, height: 64, borderRadius: "50%", background: "#f0ede8", border: "2px solid #ece8e2", display: "flex", alignItems: "center", justifyContent: "center", fontSize: 24, color: "#888", overflow: "hidden", flexShrink: 0 }}>
{userRec?.avatar
? <img src={userRec.avatar} alt="Profil" style={{ width: "100%", height: "100%", objectFit: "cover" }} />
: (currentUser.displayName || currentUser.username).charAt(0).toUpperCase()}
</div>
<div>
<div style={{ fontSize: 14, fontWeight: 500, color: "#1a1a18", marginBottom: 6 }}>{currentUser.displayName || currentUser.username}</div>
<div style={{ display: "flex", gap: 6, flexWrap: "wrap" }}>
<label className="btn btn-ghost" style={{ padding: "5px 12px", fontSize: 11, cursor: "pointer" }}>
{userRec?.avatar ? "Ersetzen" : "Foto hochladen"}
<input type="file" accept="image/*" style={{ display: "none" }} onChange={uploadAvatar} />
</label>
{userRec?.avatar && (
<button className="btn btn-danger" style={{ padding: "5px 10px", fontSize: 11 }}
onClick={() => update("users", (data.users || []).map(u => u.id === currentUser.id ? { ...u, avatar: null } : u))}>
<span className="material-icons" style={{ fontSize: 16 }}>close</span>
</button>
)}
</div>
</div>
</div>
</Section>
<Section title="MEINE ADRESSE">
<FormField label="Strasse + Nr.">
<input value={empForm.street} onChange={e => setEmpForm(f => ({ ...f, street: e.target.value }))} placeholder="z.B. Musterstrasse 1" />
</FormField>
<div className="form-row">
<FormField label="PLZ"><input value={empForm.zip} onChange={e => setEmpForm(f => ({ ...f, zip: e.target.value }))} style={{ maxWidth: 120 }} /></FormField>
<FormField label="Ort"><input value={empForm.city} onChange={e => setEmpForm(f => ({ ...f, city: e.target.value }))} /></FormField>
</div>
{!emp && (
<div style={{ fontSize: 11, color: "#b5621e", marginTop: 6, padding: "8px 12px", background: "#fdf0e8", borderRadius: 8 }}>
Kein verknüpfter Mitarbeitereintrag gefunden. Wende dich an den Administrator.
</div>
)}
</Section>
</div>
</div>
<div style={{ display: "flex", flexDirection: "column", gap: 20 }}>
<div className="card">
<Section title="LOHNKONTO">
<FormField label="IBAN">
<input value={empForm.lohnIban} onChange={e => setEmpForm(f => ({ ...f, lohnIban: formatIban(e.target.value) }))} placeholder="CH00 0000 0000 0000 0000 0" />
</FormField>
<div style={{ fontSize: 11, color: "#aaa", marginTop: 6, lineHeight: 1.6 }}>
Diese Angabe wird für die Lohnüberweisung verwendet und ist nur für den Administrator sichtbar.
</div>
{!emp && (
<div style={{ fontSize: 11, color: "#b5621e", marginTop: 6, padding: "8px 12px", background: "#fdf0e8", borderRadius: 8 }}>
Kein verknüpfter Mitarbeitereintrag gefunden.
</div>
)}
</Section>
</div>
<div className="card">
<Section title="DARSTELLUNG">
<div style={{ fontSize: 12, color: "#555", marginBottom: 12 }}>Skalierung / Zoom</div>
<div style={{ display: "flex", alignItems: "center", gap: 16 }}>
<button onClick={() => setUiZoom(z => Math.max(0.5, Math.round((z - zoomStep) * 100) / 100))} style={{ width: 36, height: 36, border: "1.5px solid #ddd8d0", borderRadius: "50%", background: "none", fontSize: 20, cursor: "pointer", display: "flex", alignItems: "center", justifyContent: "center", fontFamily: "inherit" }}></button>
<div style={{ flex: 1, textAlign: "center" }}>
<div style={{ fontSize: 22, fontWeight: 500, fontFamily: "monospace", color: "#1a1a18" }}>{Math.round((uiZoom || 1) * 100)}%</div>
</div>
<button onClick={() => setUiZoom(z => Math.min(1.5, Math.round((z + zoomStep) * 100) / 100))} style={{ width: 36, height: 36, border: "1.5px solid #ddd8d0", borderRadius: "50%", background: "none", fontSize: 20, cursor: "pointer", display: "flex", alignItems: "center", justifyContent: "center", fontFamily: "inherit" }}>+</button>
</div>
{(uiZoom || 1) !== 1 && (
<button className="btn btn-ghost" style={{ marginTop: 12, fontSize: 11, width: "100%" }} onClick={() => setUiZoom(1)}>
Auf 100% zurücksetzen
</button>
)}
</Section>
</div>
</div>
</div>
</div>
);
}
export default function Settings({ data, update, currentUser, uiZoom, setUiZoom }) {
const [s, setS] = useState(data.settings);
const [saved, setSaved] = useState(false);
const [initModal, setInitModal] = useState(false);
const [tab, setTab] = useState("studio");
const [editingRoleId, setEditingRoleId] = useState(null);
const [roleForm, setRoleForm] = useState(null);
const [ftForm, setFtForm] = useState({ date: "", label: "", stundenDelta: 0, repeatsYearly: true });
const [absenzTypeForm, setAbsenzTypeForm] = useState({ label: "", color: "#555" });
const [kalModal, setKalModal] = useState(null);
const { askConfirm, ConfirmModalEl } = useConfirm();
const isDirty = JSON.stringify(s) !== JSON.stringify(data.settings);
const isAdmin = !currentUser || currentUser.role === "admin";
const setField = (changes) => { setS(prev => ({ ...prev, ...changes })); setSaved(false); };
const save = () => {
update("settings", s);
setSaved(true);
setTimeout(() => setSaved(false), 2500);
};
const exportData = () => {
// Strip legacy plaintext passwords; PBKDF2 hashes (passwordHash + passwordSalt) are
// not reversible and remain in the backup so restored accounts keep working.
const sanitized = {
...data,
users: (data.users || []).map(u => {
// eslint-disable-next-line no-unused-vars
const { password, ...rest } = u;
return rest;
}),
};
const blob = new Blob([JSON.stringify(sanitized, null, 2)], { type: "application/json" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `studio-backup-${new Date().toISOString().slice(0, 10)}.json`;
a.click();
URL.revokeObjectURL(url);
};
const importData = (e) => {
const file = e.target.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = async evt => {
try {
const imported = JSON.parse(evt.target.result);
if (await askConfirm("Aktuelle Daten wirklich überschreiben?", "Überschreiben")) {
localStorage.setItem(STORAGE_KEY, JSON.stringify(imported));
window.location.reload();
}
} catch {
alert("Datei konnte nicht gelesen werden.");
}
};
reader.readAsText(file);
};
// Number format previews
const invFmt = s.invoiceNumberFormat || "YYYY-NNN";
const _now = new Date(); const _yyyy = String(_now.getFullYear()); const _yy = _yyyy.slice(2);
const _invPadLen = (invFmt.match(/N+/) || ["NNN"])[0].length;
const invFmtPreview = invFmt.replace(/YYYY/g, _yyyy).replace(/YY/g, _yy).replace(/N+/, String(1).padStart(_invPadLen, "0"));
const protoFmt = s.protokollNumberFormat || "PP-TT-NN";
const protoAbbr = s.protokollTypeAbbreviations || {};
const protoFmtPreview = applyProtoNumberFormat(protoFmt, {
date: new Date().toISOString().slice(0, 10),
projectNumber: "2026-01",
seq: 1,
typKuerzel: Object.values(protoAbbr)[0] || "BS",
});
// App-Rollen
const appRoles = data.appRoles || [];
const openEditRole = (role) => { setEditingRoleId(role.id); setRoleForm({ ...role, permissions: role.permissions ? [...role.permissions] : null }); };
const saveRole = () => { update("appRoles", appRoles.map(r => r.id === roleForm.id ? { ...roleForm } : r)); setEditingRoleId(null); };
const addRole = () => { const nr = { id: generateId(), name: "Neue Rolle", permissions: ["dashboard", "projects", "time"] }; update("appRoles", [...appRoles, nr]); openEditRole(nr); };
const deleteRole = async (id) => { if (!await askConfirm("Rolle löschen? Benutzer mit dieser Rolle verlieren ihren Zugang.")) return; update("appRoles", appRoles.filter(r => r.id !== id)); };
const togglePerm = (permId) => { if (!roleForm || roleForm.permissions === null) return; const cur = roleForm.permissions || []; setRoleForm({ ...roleForm, permissions: cur.includes(permId) ? cur.filter(p => p !== permId) : [...cur, permId] }); };
// Feiertage & Absenztypen
const feiertage = data.feiertage || [];
const absenzTypes = getAbsenzTypes(data);
const defaultAbsenzIds = new Set(DEFAULT_ABSENZ_TYPES.map(t => t.id));
const saveFt = () => {
const isNew = !ftForm.id;
const ft = { ...ftForm, id: ftForm.id || generateId() };
update("feiertage", isNew ? [...feiertage, ft] : feiertage.map(f => f.id === ft.id ? ft : f));
setKalModal(null);
};
const delFt = (id) => update("feiertage", feiertage.filter(f => f.id !== id));
const saveAbsenzType = () => {
const t = { ...absenzTypeForm, id: absenzTypeForm.id || generateId() };
const custom = data.absenzTypes || [];
const exists = custom.some(x => x.id === t.id);
update("absenzTypes", exists ? custom.map(x => x.id === t.id ? t : x) : [...custom, t]);
setKalModal(null);
};
const delAbsenzType = (id) => {
const inUse = (data.absences || []).some(a => a.type === id);
if (inUse) { alert("Dieser Absenztyp ist bereits vergeben und kann nicht gelöscht werden."); return; }
if (defaultAbsenzIds.has(id)) {
const custom = data.absenzTypes || [];
const exists = custom.some(x => x.id === id);
update("absenzTypes", exists ? custom.map(x => x.id === id ? { ...x, deleted: true } : x) : [...custom, { id, deleted: true }]);
} else {
update("absenzTypes", (data.absenzTypes || []).filter(t => t.id !== id));
}
};
if (!isAdmin) {
return <PersonalSettings data={data} update={update} currentUser={currentUser} uiZoom={uiZoom || 1} setUiZoom={setUiZoom || (() => {})} />;
}
return (
<div>
{ConfirmModalEl}
{tab !== "profil" && (
<div className="filter-bar" style={{ marginBottom: 20 }}>
{TABS.map(t => (
<button key={t.id} onClick={() => setTab(t.id)} className={`pill${tab === t.id ? " active" : ""}`}>{t.label}</button>
))}
</div>
)}
{/* ── Profil-Tab ── */}
{tab === "profil" && <PersonalSettings data={data} update={update} currentUser={currentUser} uiZoom={uiZoom || 1} setUiZoom={setUiZoom || (() => {})} nav={
<div className="filter-bar" style={{ marginBottom: 20 }}>
{TABS.map(t => (
<button key={t.id} onClick={() => setTab(t.id)} className={`pill${tab === t.id ? " active" : ""}`}>{t.label}</button>
))}
</div>
} />}
{/* ── Settings-Tabs (studio / dokumente / team / system / kalender) ── */}
{tab !== "profil" && <>
{initModal && (
<div className="modal-overlay" onClick={() => setInitModal(false)}>
<div className="modal" style={{ maxWidth: 440 }} onClick={e => e.stopPropagation()}>
<div style={{ fontFamily: "'Playfair Display',serif", fontSize: 20, marginBottom: 6 }}>Neu initialisieren</div>
<p style={{ fontSize: 13, color: "#666", lineHeight: 1.7, marginBottom: 16 }}>
Die App wird auf den Ausgangszustand zurückgesetzt. Alle Projekte, Rechnungen, Mitarbeitende und Einstellungen gehen unwiderruflich verloren.
</p>
<div style={{ padding: "10px 14px", background: "#fdf2f2", border: "1px solid #e0b0b0", borderRadius: 8, fontSize: 12, color: "#8a1a1a", marginBottom: 20, lineHeight: 1.6 }}>
Backup vorher erstellen empfohlen.
</div>
<button className="btn btn-primary" style={{ width: "100%", marginBottom: 10 }} onClick={exportData}> Backup erstellen</button>
<button className="btn btn-danger" style={{ width: "100%", marginBottom: 10 }} onClick={async () => {
setInitModal(false);
if (!await askConfirm("Wirklich initialisieren? Alle Daten gehen verloren.", "Initialisieren")) return;
localStorage.removeItem(STORAGE_KEY);
window.location.reload();
}}>Trotzdem initialisieren</button>
<button className="btn btn-ghost" style={{ width: "100%" }} onClick={() => setInitModal(false)}>Abbrechen</button>
</div>
</div>
)}
{/* Feiertage / Absenztypen Modals */}
{kalModal === "ft" && (
<Modal title={ftForm.id ? "Feiertag bearbeiten" : "Neuer Feiertag"} onClose={() => setKalModal(null)} onSave={saveFt}>
<div className="form-row">
<FormField label="Datum"><DateInput value={ftForm.date || ""} onChange={e => setFtForm({ ...ftForm, date: e.target.value })} autoFocus /></FormField>
<FormField label="Bezeichnung"><input value={ftForm.label || ""} onChange={e => setFtForm({ ...ftForm, label: e.target.value })} placeholder="z.B. Nationalfeiertag" /></FormField>
</div>
<div className="form-row">
<FormField label="Stunden-Delta (0 = ganzer Tag frei)">
<input type="number" step={0.5} value={ftForm.stundenDelta ?? 0} onChange={e => setFtForm({ ...ftForm, stundenDelta: +e.target.value })} />
<div style={{ fontSize: 11, color: "#888", marginTop: 4 }}>z.B. 3.5 für Halbtag, 1 für 1h früher</div>
</FormField>
<FormField label=" ">
<label style={{ display: "flex", alignItems: "center", gap: 8, height: 36, cursor: "pointer", textTransform: "none", fontSize: 13 }}>
<input type="checkbox" checked={!!ftForm.repeatsYearly} onChange={e => setFtForm({ ...ftForm, repeatsYearly: e.target.checked })} style={{ width: "auto" }} />
Jährlich wiederkehrend
</label>
</FormField>
</div>
</Modal>
)}
{kalModal === "absenztype" && (
<Modal title={absenzTypeForm.id ? "Absenztyp bearbeiten" : "Neuer Absenztyp"} onClose={() => setKalModal(null)} onSave={saveAbsenzType}>
<div className="form-row">
<FormField label="Bezeichnung"><input value={absenzTypeForm.label || ""} onChange={e => setAbsenzTypeForm({ ...absenzTypeForm, label: e.target.value })} autoFocus /></FormField>
<FormField label="Farbe">
<div style={{ display: "flex", gap: 8, alignItems: "center" }}>
<input type="color" value={absenzTypeForm.color || "#555"} onChange={e => setAbsenzTypeForm({ ...absenzTypeForm, color: e.target.value })} style={{ width: 44, height: 36, padding: 2, border: "1px solid #e0dbd4", borderRadius: 4 }} />
<input value={absenzTypeForm.color || ""} onChange={e => setAbsenzTypeForm({ ...absenzTypeForm, color: e.target.value })} style={{ flex: 1 }} placeholder="#555" />
</div>
</FormField>
</div>
</Modal>
)}
{/* Stable header — Speichern only for settings tabs */}
<Header title="Einstellungen" action={
SETTINGS_TABS.has(tab)
? <button className="btn btn-primary" style={{ background: saved ? "#2d6a4f" : isDirty ? "#8a6a3a" : "#2a2a22", transition: "background 0.3s" }} onClick={save}>
{saved ? "✓ Gespeichert" : isDirty ? "Speichern *" : "Gespeichert"}
</button>
: null
} />
{/* ── Tab: Studio ── */}
{tab === "studio" && (
<>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 20 }} className="responsive-grid-2">
<div className="card">
<Section title="STUDIO & ERSCHEINUNGSBILD">
<div style={{ marginBottom: 16 }}>
<div style={{ fontSize: 12, color: "#555", marginBottom: 6 }}>Logo (SVG / PNG)</div>
<div style={{ display: "flex", gap: 10, alignItems: "center" }}>
{s.logo && <img src={s.logo} alt="Logo" style={{ maxHeight: 40, maxWidth: 120, border: "1px solid #ece8e2", borderRadius: 4, padding: 4, background: "#fff" }} />}
<label className="btn btn-ghost" style={{ padding: "6px 12px", fontSize: 11, cursor: "pointer" }}>
{s.logo ? "Ersetzen" : "Hochladen"}
<input type="file" accept="image/svg+xml,image/png,image/jpeg" style={{ display: "none" }} onChange={e => {
const file = e.target.files?.[0]; if (!file) return;
const reader = new FileReader();
reader.onload = ev => setField({ logo: ev.target.result });
reader.readAsDataURL(file);
}} />
</label>
{s.logo && <button className="btn btn-danger" style={{ padding: "6px 10px", fontSize: 11 }} onClick={() => setField({ logo: null })}><span className="material-icons" style={{ fontSize: 16 }}>close</span></button>}
</div>
</div>
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", marginBottom: 14 }}>
<span style={{ fontSize: 12, color: "#555" }}>Logohöhe auf PDFs</span>
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<input type="number" min={20} max={200} step={5} value={s.logoSize || 60} onChange={e => setField({ logoSize: +e.target.value })} style={{ width: 70 }} />
<span style={{ fontSize: 11, color: "#888" }}>px</span>
{(s.logoSize || 60) !== 60 && <button className="btn btn-ghost" style={{ fontSize: 11, padding: "4px 10px" }} onClick={() => setField({ logoSize: 60 })}> Standard</button>}
</div>
</div>
<FormField label="Name / Firma"><input value={s.name} onChange={e => setField({ name: e.target.value })} /></FormField>
</Section>
<Section title="ADRESSE">
<FormField label="Strasse + Nr."><input value={s.street || ""} onChange={e => setField({ street: e.target.value })} placeholder="z.B. Bahnhofstrasse 1" /></FormField>
<div className="form-row">
<FormField label="PLZ"><input value={s.zip || ""} onChange={e => setField({ zip: e.target.value })} style={{ maxWidth: 120 }} /></FormField>
<FormField label="Ort"><input value={s.city || ""} onChange={e => setField({ city: e.target.value })} /></FormField>
<FormField label="Land"><input value={s.country || "CH"} onChange={e => setField({ country: e.target.value.toUpperCase() })} maxLength={2} style={{ maxWidth: 80 }} /></FormField>
</div>
<div className="form-row">
<FormField label="E-Mail"><input type="email" value={s.email} onChange={e => setField({ email: e.target.value })} /></FormField>
<FormField label="Telefon"><input value={s.phone} onChange={e => setField({ phone: e.target.value })} /></FormField>
</div>
</Section>
</div>
<div className="card">
<Section title="FINANZEN">
<FormField label="IBAN">
<input value={s.iban} onChange={e => { const f = formatIban(e.target.value); setField({ iban: f, ibanType: isQRIban(f) ? "qr" : "normal" }); }} placeholder="CH00 0000 0000 0000 0000 0" />
<div style={{ fontSize: 11, color: isQRIban(s.iban) ? "#2d6a4f" : "#b5621e", marginTop: 4 }}>
{isQRIban(s.iban) ? "✓ QR-IBAN — strukturierte Referenz verfügbar" : "Normale IBAN — ohne strukturierte Referenz"}
</div>
</FormField>
<FormField label="MWST-Nr."><input value={s.mwst} onChange={e => setField({ mwst: e.target.value })} /></FormField>
<div className="form-row">
<FormField label="MWST-Satz (%)">
<input type="number" step="0.1" value={s.mwstRate} onChange={e => setField({ mwstRate: +e.target.value })} />
</FormField>
<FormField label="Standard-Stundensatz (CHF)">
<input type="number" value={s.defaultHourlyRate} onChange={e => setField({ defaultHourlyRate: +e.target.value })} />
</FormField>
</div>
</Section>
</div>
</div>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 20, marginTop: 20 }} className="responsive-grid-2">
<div className="card">
<Section title="SPESENKATEGORIEN">
<div style={{ fontSize: 11, color: "#aaa", marginBottom: 12 }}>Kategorien für Mitarbeiterspesen.</div>
{(s.expenseCategories || []).map((cat, i) => (
<div key={i} style={{ display: "flex", gap: 6, marginBottom: 6, alignItems: "center" }}>
<input value={cat} onChange={e => setField({ expenseCategories: (s.expenseCategories || []).map((c, j) => j === i ? e.target.value : c) })} style={{ flex: 1, fontSize: 12 }} />
<button className="btn btn-danger" style={{ padding: "0 7px", height: 30, fontSize: 11 }} onClick={() => setField({ expenseCategories: (s.expenseCategories || []).filter((_, j) => j !== i) })}><span className="material-icons" style={{ fontSize: 16 }}>close</span></button>
</div>
))}
<button className="btn btn-ghost" style={{ marginTop: 4, fontSize: 11 }} onClick={() => setField({ expenseCategories: [...(s.expenseCategories || []), ""] })}>+ Kategorie</button>
</Section>
</div>
<div className="card">
<Section title="INTERNE AUSGABEN-KATEGORIEN">
<div style={{ fontSize: 11, color: "#aaa", marginBottom: 12 }}>Kategorien für interne Büroausgaben.</div>
{(s.internalExpenseCategories || []).map((cat, i) => (
<div key={i} style={{ display: "flex", gap: 6, marginBottom: 6, alignItems: "center" }}>
<input value={cat} onChange={e => setField({ internalExpenseCategories: (s.internalExpenseCategories || []).map((c, j) => j === i ? e.target.value : c) })} style={{ flex: 1, fontSize: 12 }} />
<button className="btn btn-danger" style={{ padding: "0 7px", height: 30, fontSize: 11 }} onClick={() => setField({ internalExpenseCategories: (s.internalExpenseCategories || []).filter((_, j) => j !== i) })}><span className="material-icons" style={{ fontSize: 16 }}>close</span></button>
</div>
))}
<button className="btn btn-ghost" style={{ marginTop: 4, fontSize: 11 }} onClick={() => setField({ internalExpenseCategories: [...(s.internalExpenseCategories || []), ""] })}>+ Kategorie</button>
</Section>
</div>
</div>
</>
)}
{/* ── Tab: Dokumente & Formate ── */}
{tab === "dokumente" && (
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 20 }} className="responsive-grid-2">
<div className="card">
<Section title="NUMMERNFORMATE">
<div style={{ fontSize: 11, color: "#aaa", marginBottom: 14 }}>
<code style={{ background: "#f0ede8", padding: "1px 4px", borderRadius: 2 }}>YYYY</code> = {_yyyy} &nbsp;
<code style={{ background: "#f0ede8", padding: "1px 4px", borderRadius: 2 }}>YY</code> = {_yy} &nbsp;
<code style={{ background: "#f0ede8", padding: "1px 4px", borderRadius: 2 }}>MM</code> = Monat &nbsp;
<code style={{ background: "#f0ede8", padding: "1px 4px", borderRadius: 2 }}>NN</code> = laufende Nr.
</div>
<FmtRow label="Projektnummer" value={s.projectNumberFormat || "YYYY/NN"} onChange={v => setField({ projectNumberFormat: v })} placeholder="YYYY/NN" preview={applyProjectNumberFormat(s.projectNumberFormat || "YYYY/NN", 1)} />
<FmtRow label="Rechnungsnummer" value={invFmt} onChange={v => setField({ invoiceNumberFormat: v })} placeholder="YYYY-NNN" preview={invFmtPreview} />
<FmtRow label="Protokollnummer" value={protoFmt} onChange={v => setField({ protokollNumberFormat: v })} placeholder="PP-TT-NN" preview={protoFmtPreview} />
<div style={{ fontSize: 11, color: "#aaa", marginTop: 8, lineHeight: 1.8 }}>
<code style={{ background: "#f0ede8", padding: "1px 4px", borderRadius: 2 }}>PP</code> = Projektnr. ohne Jahreszahl &nbsp;
<code style={{ background: "#f0ede8", padding: "1px 4px", borderRadius: 2 }}>PPP</code> = Projektnr. komplett &nbsp;
<code style={{ background: "#f0ede8", padding: "1px 4px", borderRadius: 2 }}>TT</code> = Sitzungstyp
</div>
</Section>
<Section title="PDF-DATEINAME">
<div style={{ marginBottom: 8 }}>
<input value={s.pdfNameFormat || "{studio}_{typ}_{nummer}"} onChange={e => setField({ pdfNameFormat: e.target.value })} style={{ width: "100%", fontFamily: "monospace" }} placeholder="{studio}_{typ}_{nummer}" />
</div>
<div style={{ fontSize: 11, color: "#aaa", lineHeight: 1.8 }}>
<code style={{ background: "#f0ede8", padding: "1px 4px", borderRadius: 2 }}>{"{studio}"}</code> &nbsp;
<code style={{ background: "#f0ede8", padding: "1px 4px", borderRadius: 2 }}>{"{typ}"}</code> &nbsp;
<code style={{ background: "#f0ede8", padding: "1px 4px", borderRadius: 2 }}>{"{nummer}"}</code> &nbsp;
<code style={{ background: "#f0ede8", padding: "1px 4px", borderRadius: 2 }}>{"{kunde}"}</code> &nbsp;
<code style={{ background: "#f0ede8", padding: "1px 4px", borderRadius: 2 }}>{"{datum}"}</code>
</div>
</Section>
</div>
<div style={{ display: "flex", flexDirection: "column", gap: 20 }}>
<div className="card">
<Section title="SEITENRÄNDER (MM)">
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "8px 16px" }}>
{[["pageMarginTop", "Oben"], ["pageMarginBottom", "Unten"], ["pageMarginLeft", "Links"], ["pageMarginRight", "Rechts"]].map(([key, label]) => (
<div key={key}>
<div style={{ fontSize: 11, color: "#888", marginBottom: 4 }}>{label}</div>
<input type="number" min={0} max={60} value={s[key] ?? 20} onChange={e => setField({ [key]: +e.target.value })} style={{ width: "100%", fontFamily: "monospace" }} />
</div>
))}
</div>
<div style={{ fontSize: 11, color: "#aaa", marginTop: 10 }}>Gilt für alle Dokumente ausser QR-Rechnung.</div>
</Section>
</div>
<div className="card">
<Section title="DRUCKOPTIONEN">
<label style={{ display: "flex", alignItems: "center", gap: 10, cursor: "pointer", fontSize: 13, color: "#1a1a18", marginBottom: 10 }}>
<input type="checkbox" checked={!!s.autoPrint} onChange={e => setField({ autoPrint: e.target.checked })} style={{ width: "auto" }} />
Druckdialog automatisch öffnen
</label>
<label style={{ display: "flex", alignItems: "center", gap: 10, cursor: "pointer", fontSize: 13, color: "#1a1a18" }}>
<input type="checkbox" checked={s.qrNewPage !== false} onChange={e => setField({ qrNewPage: e.target.checked })} style={{ width: "auto" }} />
QR-Rechnung auf separater Seite
</label>
</Section>
</div>
<div className="card">
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 14, paddingBottom: 8, borderBottom: "1px solid #ece8e2" }}>
<div style={{ fontSize: 10, letterSpacing: "0.12em", color: "#aaa", fontWeight: 600 }}>PROTOKOLL-TYPKÜRZEL</div>
<button className="btn btn-ghost" style={{ padding: "3px 10px", fontSize: 11 }} onClick={() => setField({ protokollTypeAbbreviations: { ...protoAbbr, "Neuer Typ": "NT" } })}>+ Typ</button>
</div>
<div style={{ fontSize: 11, color: "#aaa", marginBottom: 12 }}>
Kürzel für Platzhalter <code style={{ background: "#f0ede8", padding: "1px 4px", borderRadius: 2 }}>TT</code> in der Protokollnummer.
</div>
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
{Object.entries(protoAbbr).map(([typName, kuerzel]) => (
<div key={typName} style={{ display: "flex", gap: 6, alignItems: "center" }}>
<input defaultValue={typName} onBlur={e => {
const newName = e.target.value.trim();
if (!newName || newName === typName) { e.target.value = typName; return; }
const newAbbr = Object.fromEntries(Object.entries(protoAbbr).map(([k, v]) => [k === typName ? newName : k, v]));
setField({ protokollTypeAbbreviations: newAbbr });
}} placeholder="Sitzungstyp" style={{ flex: 1, fontSize: 12 }} />
<input value={kuerzel} onChange={e => setField({ protokollTypeAbbreviations: { ...protoAbbr, [typName]: e.target.value.toUpperCase().slice(0, 4) } })}
placeholder="BS" style={{ width: 52, textAlign: "center", fontWeight: 600, fontSize: 12, fontFamily: "monospace" }} maxLength={4} />
<button className="btn btn-danger" style={{ padding: "0 7px", height: 30, fontSize: 11 }} onClick={() => setField({ protokollTypeAbbreviations: Object.fromEntries(Object.entries(protoAbbr).filter(([k]) => k !== typName)) })}><span className="material-icons" style={{ fontSize: 16 }}>close</span></button>
</div>
))}
</div>
</div>
</div>
</div>
)}
{/* ── Tab: Team & Rollen ── */}
{tab === "team" && (
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 20 }} className="responsive-grid-2">
<div style={{ display: "flex", flexDirection: "column", gap: 20 }}>
<div className="card">
<Section title="MITARBEITER-STANDARDS">
<div style={{ fontSize: 11, color: "#aaa", marginBottom: 14 }}>Vorausgefüllt bei neuen Mitarbeitern.</div>
<FormField label="Wochenstunden (100%)">
<input type="number" min={1} max={50} step={0.5} value={s.defaultWochenstunden || 35} onChange={e => setField({ defaultWochenstunden: +e.target.value })} />
</FormField>
<FormField label="Ferienwochen / Jahr">
<input type="number" min={4} max={10} step={0.5} value={s.defaultFerienWochen || 5} onChange={e => setField({ defaultFerienWochen: +e.target.value })} />
<div style={{ fontSize: 11, color: "#aaa", marginTop: 3 }}>Minimum gesetzlich: 4 Wochen</div>
</FormField>
<FormField label="PK / BVG AG-Anteil %">
<input type="number" min={0} step={0.1} value={s.defaultPkAGSatz ?? 8.0} onChange={e => setField({ defaultPkAGSatz: +e.target.value })} />
</FormField>
</Section>
</div>
<div className="card">
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 14, paddingBottom: 8, borderBottom: "1px solid #ece8e2" }}>
<div style={{ fontSize: 10, letterSpacing: "0.12em", color: "#aaa", fontWeight: 600 }}>ROLLEN FÜR OFFERTEN</div>
<button className="btn btn-ghost" style={{ padding: "3px 10px", fontSize: 11 }} onClick={() => setField({ roles: [...(s.roles || []), { id: "", label: "", rate: s.defaultHourlyRate || 120 }] })}>+ Rolle</button>
</div>
<div style={{ fontSize: 11, color: "#aaa", marginBottom: 12 }}>Kürzel, Bezeichnung und Stundensatz für Aufwandschätzungen in Offerten.</div>
{(s.roles || []).map((r, idx) => (
<div key={idx} style={{ display: "flex", gap: 6, marginBottom: 8, alignItems: "center" }}>
<input value={r.id} onChange={e => setField({ roles: s.roles.map((x, i) => i === idx ? { ...x, id: e.target.value.toUpperCase().slice(0, 3) } : x) })} placeholder="PL" style={{ width: 46, textAlign: "center", fontSize: 11 }} />
<input value={r.label} onChange={e => setField({ roles: s.roles.map((x, i) => i === idx ? { ...x, label: e.target.value } : x) })} placeholder="Bezeichnung" style={{ flex: 1, fontSize: 12 }} />
<input type="number" value={r.rate} onChange={e => setField({ roles: s.roles.map((x, i) => i === idx ? { ...x, rate: +e.target.value } : x) })} style={{ width: 72, textAlign: "right", fontSize: 12 }} />
<span style={{ fontSize: 11, color: "#888" }}>CHF/h</span>
<button className="btn btn-danger" style={{ padding: "0 7px", height: 30, fontSize: 11 }} onClick={() => setField({ roles: s.roles.filter((_, i) => i !== idx) })}><span className="material-icons" style={{ fontSize: 16 }}>close</span></button>
</div>
))}
</div>
</div>
<div className="card">
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 8, paddingBottom: 8, borderBottom: "1px solid #ece8e2" }}>
<div style={{ fontSize: 10, letterSpacing: "0.12em", color: "#aaa", fontWeight: 600 }}>APP-ROLLEN & BERECHTIGUNGEN</div>
<button className="btn btn-ghost" style={{ padding: "3px 10px", fontSize: 11 }} onClick={addRole}>+ Rolle</button>
</div>
<div style={{ fontSize: 11, color: "#aaa", marginBottom: 16 }}>
Rollen bestimmen, welche Bereiche Mitarbeitende sehen können.
</div>
{appRoles.map(role => (
<div key={role.id} style={{ marginBottom: 10, border: "1.5px solid #ece8e2", borderRadius: 8, overflow: "hidden" }}>
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", padding: "10px 14px", background: "#faf8f5" }}>
<div>
<div style={{ fontSize: 13, fontWeight: 500 }}>{role.name}</div>
<div style={{ fontSize: 11, color: "#aaa", marginTop: 2 }}>
{role.permissions === null ? "Voller Zugriff" : `${(role.permissions || []).length} Bereiche`}
{" · "}{(data.users || []).filter(u => u.appRoleId === role.id).length} Benutzer
</div>
</div>
<div style={{ display: "flex", gap: 6 }}>
{editingRoleId === role.id
? <><button className="btn btn-primary" style={{ padding: "4px 12px", fontSize: 11 }} onClick={saveRole}>Speichern</button>
<button className="btn btn-ghost" style={{ padding: "4px 10px", fontSize: 11 }} onClick={() => setEditingRoleId(null)}>Abbrechen</button></>
: <button className="btn btn-ghost" style={{ padding: "4px 10px", fontSize: 11 }} onClick={() => openEditRole(role)}><span className="material-icons" style={{ fontSize: 16 }}>edit</span></button>
}
{role.id !== "r-admin" && <button className="btn btn-danger" style={{ padding: "4px 8px", fontSize: 11 }} onClick={() => deleteRole(role.id)}><span className="material-icons" style={{ fontSize: 16 }}>close</span></button>}
</div>
</div>
{editingRoleId === role.id && roleForm && (
<div style={{ padding: "14px 14px 10px", borderTop: "1px solid #ece8e2" }}>
<div style={{ marginBottom: 12 }}>
<label style={{ fontSize: 11, color: "#888", display: "block", marginBottom: 5 }}>Rollenname</label>
<input value={roleForm.name} onChange={e => setRoleForm({ ...roleForm, name: e.target.value })} style={{ fontSize: 13, width: "100%", maxWidth: 300 }} />
</div>
<div style={{ marginBottom: 14 }}>
<label style={{ display: "flex", alignItems: "center", gap: 8, cursor: "pointer", fontSize: 12, marginBottom: 10 }}>
<input type="checkbox" checked={roleForm.permissions === null}
onChange={e => setRoleForm({ ...roleForm, permissions: e.target.checked ? null : ["dashboard"] })}
style={{ width: "auto" }} />
Voller Zugriff (Administrator)
</label>
{roleForm.permissions !== null && (
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(180px, 1fr))", gap: "10px 20px" }}>
{PERMISSION_GROUPS.map(group => (
<div key={group.label}>
<div style={{ fontSize: 10, letterSpacing: "0.1em", color: "#aaa", marginBottom: 6, fontWeight: 600 }}>{group.label.toUpperCase()}</div>
{group.items.map(perm => (
<label key={perm.id} style={{ display: "flex", alignItems: "center", gap: 7, cursor: "pointer", fontSize: 12, marginBottom: 5 }}>
<input type="checkbox" checked={(roleForm.permissions || []).includes(perm.id)} onChange={() => togglePerm(perm.id)} style={{ width: "auto" }} />
{perm.label}
</label>
))}
</div>
))}
</div>
)}
</div>
<div style={{ paddingTop: 10, borderTop: "1px solid #ece8e2" }}>
<label style={{ fontSize: 11, color: "#888", display: "block", marginBottom: 6 }}>Dashboard-Vorlage</label>
<select value={roleForm.dashboardTemplateId || ""} onChange={e => setRoleForm({ ...roleForm, dashboardTemplateId: e.target.value || null })} style={{ width: "100%", maxWidth: 300 }}>
<option value=""> keine </option>
{(data.dashboardTemplates || []).filter(t => t.isPublic).map(t => <option key={t.id} value={t.id}>{t.name}</option>)}
</select>
</div>
</div>
)}
</div>
))}
</div>
</div>
)}
{/* ── Tab: Feiertage & Absenzen ── */}
{tab === "kalender" && (
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 20 }} className="responsive-grid-2">
<div className="card">
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 14, paddingBottom: 8, borderBottom: "1px solid #ece8e2" }}>
<div style={{ fontSize: 10, letterSpacing: "0.12em", color: "#aaa", fontWeight: 600 }}>FEIERTAGE</div>
<button className="btn btn-ghost" style={{ padding: "3px 10px", fontSize: 11 }} onClick={() => { setFtForm({ date: "", label: "", stundenDelta: 0, repeatsYearly: true }); setKalModal("ft"); }}>+ Feiertag</button>
</div>
<div style={{ fontSize: 11, color: "#aaa", marginBottom: 14, lineHeight: 1.6 }}>
Gelten für alle Mitarbeiter. Jährlich wiederkehrende werden automatisch übertragen. Mit «Stunden-Delta» lassen sich Halbfeiertage definieren (z.B. 3.5h für den 24.12.).
</div>
<div className="card" style={{ padding: 0, marginBottom: feiertage.length === 0 ? 12 : 0 }}>
<table>
<thead><tr><th>Datum</th><th>Bezeichnung</th><th style={{ textAlign: "right" }}>Delta</th><th></th></tr></thead>
<tbody>
{feiertage.length === 0 && <tr><td colSpan={4} style={{ textAlign: "center", color: "#aaa", padding: 24 }}>Noch keine Feiertage erfasst</td></tr>}
{[...feiertage].sort((a, b) => (a.date || "").slice(5).localeCompare((b.date || "").slice(5))).map(f => (
<tr key={f.id}>
<td style={{ fontSize: 12 }}>{f.repeatsYearly ? f.date.slice(5).replace("-", ".") + " ↻" : f.date ? new Date(f.date).toLocaleDateString("de-CH") : "—"}</td>
<td style={{ fontWeight: 500, fontSize: 12 }}>{f.label}</td>
<td style={{ textAlign: "right", fontSize: 12, color: f.stundenDelta < 0 ? "#b5621e" : "#aaa" }}>{f.stundenDelta === 0 || f.stundenDelta === null ? "ganztag" : `${f.stundenDelta}h`}</td>
<td style={{ textAlign: "right", whiteSpace: "nowrap" }}>
<button className="btn btn-ghost" style={{ padding: "3px 8px", fontSize: 11, marginRight: 4 }} onClick={() => { setFtForm({ ...f }); setKalModal("ft"); }}><span className="material-icons" style={{ fontSize: 16 }}>edit</span></button>
<button className="btn btn-danger" style={{ padding: "3px 8px", fontSize: 11 }} onClick={() => delFt(f.id)}><span className="material-icons" style={{ fontSize: 16 }}>close</span></button>
</td>
</tr>
))}
</tbody>
</table>
</div>
{feiertage.length === 0 && (
<button className="btn btn-ghost" style={{ fontSize: 11, width: "100%" }} onClick={() => {
const easterSunday = (year) => {
const a = year%19, b = Math.floor(year/100), c = year%100;
const d = Math.floor(b/4), e = b%4, f = Math.floor((b+8)/25);
const g = Math.floor((b-f+1)/3), h = (19*a+b-d-g+15)%30;
const i = Math.floor(c/4), k = c%4;
const l = (32+2*e+2*i-h-k)%7;
const m = Math.floor((a+11*h+22*l)/451);
const month = Math.floor((h+l-7*m+114)/31);
const day = ((h+l-7*m+114)%31)+1;
return new Date(year, month-1, day);
};
const addDays = (date, days) => { const d = new Date(date); d.setDate(d.getDate()+days); return d; };
const fmt = (date) => `${date.getFullYear()}-${String(date.getMonth()+1).padStart(2,'0')}-${String(date.getDate()).padStart(2,'0')}`;
const yr = new Date().getFullYear();
const fixe = [
{ label: "Neujahr", date: "2000-01-01", stundenDelta: 0, repeatsYearly: true },
{ label: "Berchtoldstag", date: "2000-01-02", stundenDelta: 0, repeatsYearly: true },
{ label: "Tag der Arbeit", date: "2000-05-01", stundenDelta: 0, repeatsYearly: true },
{ label: "Nationalfeiertag", date: "2000-08-01", stundenDelta: 0, repeatsYearly: true },
{ label: "Weihnachten", date: "2000-12-25", stundenDelta: 0, repeatsYearly: true },
{ label: "Stephanstag", date: "2000-12-26", stundenDelta: 0, repeatsYearly: true },
{ label: "Heiligabend (Halbtag)", date: "2000-12-24", stundenDelta: -3.5, repeatsYearly: true },
{ label: "Silvester (Halbtag)", date: "2000-12-31", stundenDelta: -3.5, repeatsYearly: true },
].map(f => ({ ...f, id: generateId() }));
const beweglich = [];
for (let y = yr; y <= yr+3; y++) {
const ostern = easterSunday(y);
[
{ label: "Karfreitag", offset: -2 },
{ label: "Ostermontag", offset: 1 },
{ label: "Auffahrt", offset: 39 },
{ label: "Auffahrt Vortag (Halbtag)", offset: 38, stundenDelta: -3.5 },
{ label: "Pfingstmontag", offset: 50 },
].forEach(({ label, offset, stundenDelta = 0 }) => {
beweglich.push({ id: generateId(), label, date: fmt(addDays(ostern, offset)), stundenDelta, repeatsYearly: false });
});
}
update("feiertage", [...feiertage, ...fixe, ...beweglich]);
}}> CH-Vorlagen laden (inkl. Ostern, Auffahrt, Pfingsten)</button>
)}
</div>
<div className="card">
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 14, paddingBottom: 8, borderBottom: "1px solid #ece8e2" }}>
<div style={{ fontSize: 10, letterSpacing: "0.12em", color: "#aaa", fontWeight: 600 }}>ABSENZTYPEN</div>
<button className="btn btn-ghost" style={{ padding: "3px 10px", fontSize: 11 }} onClick={() => { setAbsenzTypeForm({ label: "", color: "#555" }); setKalModal("absenztype"); }}>+ Typ</button>
</div>
<table>
<thead><tr><th>Bezeichnung</th><th>Farbe</th><th>Typ</th><th></th></tr></thead>
<tbody>
{absenzTypes.map(t => {
const inUse = (data.absences || []).some(a => a.type === t.id);
const isDefault = defaultAbsenzIds.has(t.id);
const isOverridden = isDefault && (data.absenzTypes || []).some(x => x.id === t.id);
return (
<tr key={t.id}>
<td><span className="tag" style={{ background: t.color, fontSize: 10 }}>{t.label}</span></td>
<td style={{ color: "#888", fontSize: 12 }}>{t.color}</td>
<td style={{ fontSize: 11, color: isDefault ? (isOverridden ? "#b07848" : "#aaa") : "#2d6a4f" }}>
{isDefault ? (isOverridden ? "Angepasst" : "Standard") : "Eigener"}
</td>
<td style={{ textAlign: "right", whiteSpace: "nowrap" }}>
<button className="btn btn-ghost" style={{ padding: "3px 8px", fontSize: 11, marginRight: 4 }} onClick={() => { setAbsenzTypeForm(t); setKalModal("absenztype"); }}><span className="material-icons" style={{ fontSize: 16 }}>edit</span></button>
<button className="btn btn-danger" style={{ padding: "3px 8px", fontSize: 11, opacity: inUse ? 0.35 : 1 }} onClick={() => delAbsenzType(t.id)} title={inUse ? "In Verwendung" : "Löschen"}><span className="material-icons" style={{ fontSize: 16 }}>close</span></button>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
)}
{/* ── Tab: System ── */}
{tab === "system" && (
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 20 }} className="responsive-grid-2">
<div className="card">
<Section title="DATEN & BACKUP">
<p style={{ fontSize: 13, color: "#666", lineHeight: 1.7, marginBottom: 16 }}>
Alle Daten liegen ausschliesslich im Browser (localStorage). Regelmässige Backups sind empfohlen.
</p>
<button className="btn btn-primary" style={{ width: "100%", marginBottom: 10 }} onClick={exportData}> Backup als JSON herunterladen</button>
<label className="btn btn-ghost" style={{ width: "100%", textAlign: "center", display: "block", marginBottom: 10 }}>
Backup importieren
<input type="file" accept=".json" onChange={importData} style={{ display: "none" }} />
</label>
<button className="btn btn-danger" style={{ width: "100%" }} onClick={() => setInitModal(true)}>
Neu initialisieren
</button>
</Section>
<Section title="DATENBANKÜBERSICHT">
{[
{ label: "Personen", value: (data.persons || []).length },
{ label: "Projekte", value: data.projects.length },
{ label: "Zeiteinträge", value: data.timeEntries.length },
{ label: "Rechnungen", value: data.invoices.length },
{ label: "Offerten", value: (data.quotes || []).length },
{ label: "Spesen", value: (data.expenses || []).length },
{ label: "Interne Ausgaben", value: (data.internalExpenses || []).length },
].map(r => (
<div key={r.label} style={{ display: "flex", justifyContent: "space-between", padding: "4px 0", fontSize: 12, borderBottom: "1px solid #f0ede8" }}>
<span style={{ color: "#888" }}>{r.label}</span>
<strong style={{ color: "#555" }}>{r.value}</strong>
</div>
))}
</Section>
</div>
<div className="card">
<Section title="ZEITERFASSUNG">
<label style={{ display: "flex", alignItems: "center", gap: 10, cursor: "pointer", fontSize: 13, color: "#1a1a18" }}>
<input type="checkbox" checked={s.blockMaiTag !== false} onChange={e => setField({ blockMaiTag: e.target.checked })} style={{ width: "auto" }} />
Tag der Arbeit (1. Mai) in Wochenansicht sperren
</label>
</Section>
</div>
</div>
)}
</>}
</div>
);
}