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:
Executable
+866
@@ -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}
|
||||
<code style={{ background: "#f0ede8", padding: "1px 4px", borderRadius: 2 }}>YY</code> = {_yy}
|
||||
<code style={{ background: "#f0ede8", padding: "1px 4px", borderRadius: 2 }}>MM</code> = Monat
|
||||
<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
|
||||
<code style={{ background: "#f0ede8", padding: "1px 4px", borderRadius: 2 }}>PPP</code> = Projektnr. komplett
|
||||
<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>
|
||||
<code style={{ background: "#f0ede8", padding: "1px 4px", borderRadius: 2 }}>{"{typ}"}</code>
|
||||
<code style={{ background: "#f0ede8", padding: "1px 4px", borderRadius: 2 }}>{"{nummer}"}</code>
|
||||
<code style={{ background: "#f0ede8", padding: "1px 4px", borderRadius: 2 }}>{"{kunde}"}</code>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user