Rapport 0.6 — Initial Public Release

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

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

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

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-13 01:16:26 +02:00
commit 00f07d76f6
65 changed files with 28010 additions and 0 deletions
+866
View File
@@ -0,0 +1,866 @@
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>
);
}