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 (
{title}
{children}
); } function FmtRow({ label, value, onChange, placeholder, preview }) { return (
{label}
onChange(e.target.value)} style={{ maxWidth: 160, fontFamily: "monospace" }} placeholder={placeholder} /> {preview && → {preview}}
); } 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 (
{saved ? "✓ Gespeichert" : "Speichern"} } /> {nav}
{userRec?.avatar ? Profil : (currentUser.displayName || currentUser.username).charAt(0).toUpperCase()}
{currentUser.displayName || currentUser.username}
{userRec?.avatar && ( )}
setEmpForm(f => ({ ...f, street: e.target.value }))} placeholder="z.B. Musterstrasse 1" />
setEmpForm(f => ({ ...f, zip: e.target.value }))} style={{ maxWidth: 120 }} /> setEmpForm(f => ({ ...f, city: e.target.value }))} />
{!emp && (
Kein verknüpfter Mitarbeitereintrag gefunden. Wende dich an den Administrator.
)}
setEmpForm(f => ({ ...f, lohnIban: formatIban(e.target.value) }))} placeholder="CH00 0000 0000 0000 0000 0" />
Diese Angabe wird für die Lohnüberweisung verwendet und ist nur für den Administrator sichtbar.
{!emp && (
Kein verknüpfter Mitarbeitereintrag gefunden.
)}
Skalierung / Zoom
{Math.round((uiZoom || 1) * 100)}%
{(uiZoom || 1) !== 1 && ( )}
); } 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 {})} />; } return (
{ConfirmModalEl} {tab !== "profil" && (
{TABS.map(t => ( ))}
)} {/* ── Profil-Tab ── */} {tab === "profil" && {})} nav={
{TABS.map(t => ( ))}
} />} {/* ── Settings-Tabs (studio / dokumente / team / system / kalender) ── */} {tab !== "profil" && <> {initModal && (
setInitModal(false)}>
e.stopPropagation()}>
Neu initialisieren

Die App wird auf den Ausgangszustand zurückgesetzt. Alle Projekte, Rechnungen, Mitarbeitende und Einstellungen gehen unwiderruflich verloren.

Backup vorher erstellen empfohlen.
)} {/* Feiertage / Absenztypen Modals */} {kalModal === "ft" && ( setKalModal(null)} onSave={saveFt}>
setFtForm({ ...ftForm, date: e.target.value })} autoFocus /> setFtForm({ ...ftForm, label: e.target.value })} placeholder="z.B. Nationalfeiertag" />
setFtForm({ ...ftForm, stundenDelta: +e.target.value })} />
z.B. −3.5 für Halbtag, −1 für 1h früher
)} {kalModal === "absenztype" && ( setKalModal(null)} onSave={saveAbsenzType}>
setAbsenzTypeForm({ ...absenzTypeForm, label: e.target.value })} autoFocus />
setAbsenzTypeForm({ ...absenzTypeForm, color: e.target.value })} style={{ width: 44, height: 36, padding: 2, border: "1px solid #e0dbd4", borderRadius: 4 }} /> setAbsenzTypeForm({ ...absenzTypeForm, color: e.target.value })} style={{ flex: 1 }} placeholder="#555" />
)} {/* Stable header — Speichern only for settings tabs */}
{saved ? "✓ Gespeichert" : isDirty ? "Speichern *" : "Gespeichert"} : null } /> {/* ── Tab: Studio ── */} {tab === "studio" && ( <>
Logo (SVG / PNG)
{s.logo && Logo} {s.logo && }
Logohöhe auf PDFs
setField({ logoSize: +e.target.value })} style={{ width: 70 }} /> px {(s.logoSize || 60) !== 60 && }
setField({ name: e.target.value })} />
setField({ street: e.target.value })} placeholder="z.B. Bahnhofstrasse 1" />
setField({ zip: e.target.value })} style={{ maxWidth: 120 }} /> setField({ city: e.target.value })} /> setField({ country: e.target.value.toUpperCase() })} maxLength={2} style={{ maxWidth: 80 }} />
setField({ email: e.target.value })} /> setField({ phone: e.target.value })} />
{ const f = formatIban(e.target.value); setField({ iban: f, ibanType: isQRIban(f) ? "qr" : "normal" }); }} placeholder="CH00 0000 0000 0000 0000 0" />
{isQRIban(s.iban) ? "✓ QR-IBAN — strukturierte Referenz verfügbar" : "Normale IBAN — ohne strukturierte Referenz"}
setField({ mwst: e.target.value })} />
setField({ mwstRate: +e.target.value })} /> setField({ defaultHourlyRate: +e.target.value })} />
Kategorien für Mitarbeiterspesen.
{(s.expenseCategories || []).map((cat, i) => (
setField({ expenseCategories: (s.expenseCategories || []).map((c, j) => j === i ? e.target.value : c) })} style={{ flex: 1, fontSize: 12 }} />
))}
Kategorien für interne Büroausgaben.
{(s.internalExpenseCategories || []).map((cat, i) => (
setField({ internalExpenseCategories: (s.internalExpenseCategories || []).map((c, j) => j === i ? e.target.value : c) })} style={{ flex: 1, fontSize: 12 }} />
))}
)} {/* ── Tab: Dokumente & Formate ── */} {tab === "dokumente" && (
YYYY = {_yyyy}   YY = {_yy}   MM = Monat   NN = laufende Nr.
setField({ projectNumberFormat: v })} placeholder="YYYY/NN" preview={applyProjectNumberFormat(s.projectNumberFormat || "YYYY/NN", 1)} /> setField({ invoiceNumberFormat: v })} placeholder="YYYY-NNN" preview={invFmtPreview} /> setField({ protokollNumberFormat: v })} placeholder="PP-TT-NN" preview={protoFmtPreview} />
PP = Projektnr. ohne Jahreszahl   PPP = Projektnr. komplett   TT = Sitzungstyp
setField({ pdfNameFormat: e.target.value })} style={{ width: "100%", fontFamily: "monospace" }} placeholder="{studio}_{typ}_{nummer}" />
{"{studio}"}   {"{typ}"}   {"{nummer}"}   {"{kunde}"}   {"{datum}"}
{[["pageMarginTop", "Oben"], ["pageMarginBottom", "Unten"], ["pageMarginLeft", "Links"], ["pageMarginRight", "Rechts"]].map(([key, label]) => (
{label}
setField({ [key]: +e.target.value })} style={{ width: "100%", fontFamily: "monospace" }} />
))}
Gilt für alle Dokumente ausser QR-Rechnung.
PROTOKOLL-TYPKÜRZEL
Kürzel für Platzhalter TT in der Protokollnummer.
{Object.entries(protoAbbr).map(([typName, kuerzel]) => (
{ 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 }} /> 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} />
))}
)} {/* ── Tab: Team & Rollen ── */} {tab === "team" && (
Vorausgefüllt bei neuen Mitarbeitern.
setField({ defaultWochenstunden: +e.target.value })} /> setField({ defaultFerienWochen: +e.target.value })} />
Minimum gesetzlich: 4 Wochen
setField({ defaultPkAGSatz: +e.target.value })} />
ROLLEN FÜR OFFERTEN
Kürzel, Bezeichnung und Stundensatz für Aufwandschätzungen in Offerten.
{(s.roles || []).map((r, idx) => (
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 }} /> setField({ roles: s.roles.map((x, i) => i === idx ? { ...x, label: e.target.value } : x) })} placeholder="Bezeichnung" style={{ flex: 1, fontSize: 12 }} /> setField({ roles: s.roles.map((x, i) => i === idx ? { ...x, rate: +e.target.value } : x) })} style={{ width: 72, textAlign: "right", fontSize: 12 }} /> CHF/h
))}
APP-ROLLEN & BERECHTIGUNGEN
Rollen bestimmen, welche Bereiche Mitarbeitende sehen können.
{appRoles.map(role => (
{role.name}
{role.permissions === null ? "Voller Zugriff" : `${(role.permissions || []).length} Bereiche`} {" · "}{(data.users || []).filter(u => u.appRoleId === role.id).length} Benutzer
{editingRoleId === role.id ? <> : } {role.id !== "r-admin" && }
{editingRoleId === role.id && roleForm && (
setRoleForm({ ...roleForm, name: e.target.value })} style={{ fontSize: 13, width: "100%", maxWidth: 300 }} />
{roleForm.permissions !== null && (
{PERMISSION_GROUPS.map(group => (
{group.label.toUpperCase()}
{group.items.map(perm => ( ))}
))}
)}
)}
))}
)} {/* ── Tab: Feiertage & Absenzen ── */} {tab === "kalender" && (
FEIERTAGE
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.).
{feiertage.length === 0 && } {[...feiertage].sort((a, b) => (a.date || "").slice(5).localeCompare((b.date || "").slice(5))).map(f => ( ))}
DatumBezeichnungDelta
Noch keine Feiertage erfasst
{f.repeatsYearly ? f.date.slice(5).replace("-", ".") + " ↻" : f.date ? new Date(f.date).toLocaleDateString("de-CH") : "—"} {f.label} {f.stundenDelta === 0 || f.stundenDelta === null ? "ganztag" : `${f.stundenDelta}h`}
{feiertage.length === 0 && ( )}
ABSENZTYPEN
{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 ( ); })}
BezeichnungFarbeTyp
{t.label} {t.color} {isDefault ? (isOverridden ? "Angepasst" : "Standard") : "Eigener"}
)} {/* ── Tab: System ── */} {tab === "system" && (

Alle Daten liegen ausschliesslich im Browser (localStorage). Regelmässige Backups sind empfohlen.

{[ { 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 => (
{r.label} {r.value}
))}
)} }
); }