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:
karim gabriele varano
2026-05-13 01:16:26 +02:00
commit 8de93ff27f
65 changed files with 28010 additions and 0 deletions
+469
View File
@@ -0,0 +1,469 @@
import React, { useState, useEffect, useRef } from "react";
import { STATUS_COLORS, STATUS_BG } from "../constants.js";
export function StatusBadge({ status }) {
const color = STATUS_COLORS[status] || "#888";
const bg = STATUS_BG[status] || "#f5f5f5";
return (
<span style={{
display: "inline-block", padding: "2px 10px", borderRadius: 20,
fontSize: 11, fontWeight: 600, letterSpacing: "0.04em",
color, background: bg, border: `1.5px solid ${color}40`,
}}>{status}</span>
);
}
export function StatusSelect({ value, options, onChange }) {
const color = STATUS_COLORS[value] || "#888";
const bg = STATUS_BG[value] || "#f5f5f5";
return (
<select
value={value}
onChange={e => onChange(e.target.value)}
onClick={e => e.stopPropagation()}
style={{
fontSize: 11, height: 26, padding: "0 20px 0 8px",
background: bg, color, border: `1.5px solid ${color}40`,
fontWeight: 600, borderRadius: 20, cursor: "pointer",
fontFamily: "inherit", appearance: "auto",
}}
>
{options.map(s => <option key={s} value={s}>{s}</option>)}
</select>
);
}
export function Header({ title, action }) {
return (
<div style={{ marginBottom: 10, paddingBottom: 7, borderBottom: "1px solid var(--border)" }}>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
<h1 style={{ fontFamily: "'Playfair Display', serif", fontSize: 30, fontWeight: 400, letterSpacing: "-0.02em", lineHeight: 1.1 }}>{title}</h1>
{action}
</div>
</div>
);
}
export function FormField({ label, children }) {
return (
<div className="form-group" style={{ marginBottom: 14 }}>
<label>{label}</label>
{children}
</div>
);
}
export function Modal({ title, onClose, onSave, saveLabel, hideSave, children, wide, overflow }) {
return (
<div className="modal-overlay" onClick={e => e.target === e.currentTarget && onClose()}>
<div className="modal" style={{ ...(wide ? { maxWidth: 740 } : {}), ...(overflow ? { overflow: "visible" } : {}) }}>
<h2 style={{ fontFamily: "'Playfair Display', serif", fontWeight: 400, marginBottom: 26, fontSize: 24, letterSpacing: "-0.01em" }}>{title}</h2>
{children}
<div style={{ display: "flex", gap: 10, justifyContent: "flex-end", marginTop: 24, paddingTop: 20, borderTop: "1px solid var(--border2)" }}>
<button className="btn btn-ghost" onClick={onClose}>Abbrechen</button>
{!hideSave && <button className="btn btn-primary" onClick={onSave}>{saveLabel || "Speichern"}</button>}
</div>
</div>
</div>
);
}
export function StudioLogo({ settings, size = 24 }) {
if (settings.logo) {
const h = settings.logoSize || 60;
return <img src={settings.logo} alt={settings.name} style={{ maxHeight: h, maxWidth: h * 5, display: "block" }} />;
}
return <div style={{ fontFamily: "'Playfair Display', serif", fontSize: size, fontWeight: 400, fontStyle: "italic", letterSpacing: "-0.01em" }}>{settings.name}</div>;
}
export function useConfirm() {
const [pending, setPending] = useState(null);
const askConfirm = (msg, confirmLabel = "Löschen") =>
new Promise(resolve => setPending({ msg, confirmLabel, resolve }));
const ConfirmModalEl = pending ? (
<div style={{ position: "fixed", inset: 0, background: "rgba(26,26,24,0.5)", zIndex: 2000, display: "flex", alignItems: "center", justifyContent: "center" }}>
<div style={{ background: "#f0ede8", borderRadius: 8, padding: "24px 28px", maxWidth: 400, width: "90%", fontFamily: "inherit", boxShadow: "0 8px 32px rgba(0,0,0,0.2)" }}>
<div style={{ fontSize: 13, lineHeight: 1.6, color: "#1a1a18", marginBottom: 22 }}>{pending.msg}</div>
<div style={{ display: "flex", gap: 8, justifyContent: "flex-end" }}>
<button className="btn btn-ghost" onClick={() => { pending.resolve(false); setPending(null); }}>Abbrechen</button>
<button className="btn btn-danger" onClick={() => { pending.resolve(true); setPending(null); }}>{pending.confirmLabel}</button>
</div>
</div>
</div>
) : null;
return { askConfirm, ConfirmModalEl };
}
export function DateInput({ value, onChange, style, ...props }) {
const toDE = (iso) => {
if (!iso || !/^\d{4}-\d{2}-\d{2}$/.test(iso)) return "";
return `${iso.slice(8, 10)}.${iso.slice(5, 7)}.${iso.slice(0, 4)}`;
};
const toISO = (de) => {
const m = de.match(/^(\d{1,2})\.(\d{1,2})\.(\d{4})$/);
if (!m) return null;
const iso = `${m[3]}-${m[2].padStart(2, "0")}-${m[1].padStart(2, "0")}`;
return isNaN(new Date(iso).getTime()) ? null : iso;
};
const [text, setText] = React.useState(() => toDE(value));
React.useEffect(() => { const de = toDE(value); if (de !== text) setText(de); }, [value]);
const handleChange = (e) => {
const digits = e.target.value.replace(/\D/g, "").slice(0, 8);
let fmt = "";
if (digits.length <= 2) fmt = digits;
else if (digits.length <= 4) fmt = `${digits.slice(0, 2)}.${digits.slice(2)}`;
else fmt = `${digits.slice(0, 2)}.${digits.slice(2, 4)}.${digits.slice(4)}`;
setText(fmt);
const iso = toISO(fmt);
if (iso) onChange({ target: { value: iso } });
};
return (
<input
type="text"
value={text}
onChange={handleChange}
onBlur={() => { if (!toISO(text) && value) setText(toDE(value)); }}
placeholder="TT.MM.JJJJ"
style={style}
{...props}
/>
);
}
export function RichEditor({ value, onChange, minHeight = 420, compact = false }) {
const editorRef = React.useRef(null);
const isInternalChange = React.useRef(false);
const lastValue = React.useRef(value);
const savedRange = React.useRef(null);
const colorInputRef = React.useRef(null);
const [colorValue, setColorValue] = React.useState("#000000");
// Only push content into DOM when value changes from outside (template load)
useEffect(() => {
if (!editorRef.current) return;
if (value !== lastValue.current && !isInternalChange.current) {
editorRef.current.innerHTML = value;
lastValue.current = value;
}
isInternalChange.current = false;
}, [value]);
const saveSelection = () => {
const sel = window.getSelection();
if (sel && sel.rangeCount > 0 && editorRef.current?.contains(sel.anchorNode)) {
savedRange.current = sel.getRangeAt(0).cloneRange();
}
};
const restoreSelection = () => {
editorRef.current?.focus();
if (!savedRange.current) {
// Place cursor at end if no saved range
const range = document.createRange();
range.selectNodeContents(editorRef.current);
range.collapse(false);
const sel = window.getSelection();
if (sel) { sel.removeAllRanges(); sel.addRange(range); }
return;
}
const sel = window.getSelection();
if (sel) { sel.removeAllRanges(); sel.addRange(savedRange.current); }
};
const exec = (cmd, val = null) => {
restoreSelection();
document.execCommand(cmd, false, val);
saveSelection();
// Sync content after command
isInternalChange.current = true;
const html = editorRef.current?.innerHTML || "";
lastValue.current = html;
onChange(html);
};
const TB = ({ cmd, val, title, style: s, children }) => (
<button
onMouseDown={e => { e.preventDefault(); exec(cmd, val); }}
title={title}
style={{
padding: "0 9px", height: 30, borderRadius: 4,
border: "none", cursor: "pointer", fontFamily: "inherit", fontSize: 12,
background: "transparent", color: "var(--text3)",
display: "flex", alignItems: "center", justifyContent: "center",
transition: "background 0.1s",
...s,
}}
onMouseEnter={e => e.currentTarget.style.background = "var(--border2)"}
onMouseLeave={e => e.currentTarget.style.background = "transparent"}
>{children}</button>
);
const Sep = () => <div style={{ width: 1, height: 16, background: "var(--border)", margin: "0 3px", flexShrink: 0 }} />;
return (
<div style={{ border: "1.5px solid var(--input-border)", borderRadius: 8, overflow: "hidden" }}>
<div style={{ display: "flex", alignItems: "center", gap: 1, padding: "5px 8px", borderBottom: "1px solid var(--border2)", background: "var(--surface2)" }}>
<TB cmd="bold" title="Fett (Ctrl+B)" s={{ fontWeight: 700, minWidth: 28 }}>B</TB>
<TB cmd="italic" title="Kursiv (Ctrl+I)" s={{ fontStyle: "italic", minWidth: 28 }}>I</TB>
<TB cmd="underline" title="Unterstrichen (Ctrl+U)" s={{ textDecoration: "underline", minWidth: 28 }}>U</TB>
<Sep />
<TB cmd="justifyLeft" title="Linksbündig" s={{ padding: "0 7px" }}>
<svg width="14" height="12" viewBox="0 0 14 12" fill="currentColor">
<rect x="0" y="0" width="14" height="1.5" rx="0.75"/>
<rect x="0" y="3.5" width="9" height="1.5" rx="0.75"/>
<rect x="0" y="7" width="14" height="1.5" rx="0.75"/>
<rect x="0" y="10.5" width="11" height="1.5" rx="0.75"/>
</svg>
</TB>
<TB cmd="justifyCenter" title="Zentriert" s={{ padding: "0 7px" }}>
<svg width="14" height="12" viewBox="0 0 14 12" fill="currentColor">
<rect x="0" y="0" width="14" height="1.5" rx="0.75"/>
<rect x="2.5" y="3.5" width="9" height="1.5" rx="0.75"/>
<rect x="0" y="7" width="14" height="1.5" rx="0.75"/>
<rect x="1.5" y="10.5" width="11" height="1.5" rx="0.75"/>
</svg>
</TB>
<TB cmd="justifyRight" title="Rechtsbündig" s={{ padding: "0 7px" }}>
<svg width="14" height="12" viewBox="0 0 14 12" fill="currentColor">
<rect x="0" y="0" width="14" height="1.5" rx="0.75"/>
<rect x="5" y="3.5" width="9" height="1.5" rx="0.75"/>
<rect x="0" y="7" width="14" height="1.5" rx="0.75"/>
<rect x="3" y="10.5" width="11" height="1.5" rx="0.75"/>
</svg>
</TB>
<Sep />
{/* Font size */}
<select
onMouseDown={saveSelection}
onChange={e => { exec("fontSize", e.target.value); e.target.value = ""; }}
title="Schriftgrösse"
style={{
width: 80, height: 26, border: "1px solid var(--border2)", borderRadius: 4,
background: "transparent", color: "var(--text3)", fontSize: 11,
padding: "0 4px", cursor: "pointer", outline: "none",
}}
>
<option value="">Grösse</option>
<option value="1">Klein</option>
<option value="3">Normal</option>
<option value="5">Gross</option>
<option value="7">Titel</option>
</select>
<Sep />
{/* Color */}
<button
onMouseDown={e => { e.preventDefault(); saveSelection(); colorInputRef.current?.click(); }}
title="Textfarbe"
style={{
padding: "0 8px", height: 30, borderRadius: 4, border: "none",
cursor: "pointer", background: "transparent",
display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "center",
gap: 2, transition: "background 0.1s",
}}
onMouseEnter={e => e.currentTarget.style.background = "var(--border2)"}
onMouseLeave={e => e.currentTarget.style.background = "transparent"}
>
<span style={{ fontSize: 13, fontWeight: 700, color: "var(--text3)", lineHeight: 1 }}>A</span>
<div style={{ width: 14, height: 3, borderRadius: 1, background: colorValue }} />
</button>
<input
ref={colorInputRef}
type="color"
value={colorValue}
onChange={e => { setColorValue(e.target.value); exec("foreColor", e.target.value); }}
style={{ position: "absolute", width: 0, height: 0, opacity: 0, pointerEvents: "none" }}
/>
</div>
{/* Editor */}
<div
ref={editorRef}
contentEditable
suppressContentEditableWarning
onInput={() => {
isInternalChange.current = true;
const html = editorRef.current?.innerHTML || "";
lastValue.current = html;
onChange(html);
}}
onKeyUp={saveSelection}
onMouseUp={saveSelection}
onBlur={saveSelection}
onPaste={e => {
e.preventDefault();
const text = e.clipboardData.getData("text/plain");
restoreSelection();
document.execCommand("insertText", false, text);
saveSelection();
}}
style={{
minHeight, padding: compact ? "8px 10px" : "20px 24px", outline: "none",
fontSize: compact ? 12 : 13, lineHeight: 1.8, color: "var(--text)",
background: "var(--surface)",
fontFamily: "'DM Mono', 'Courier New', monospace",
}}
/>
</div>
);
}
export function CalendarPopup({ value, onChange, onClose, align = "left", showClear = true }) {
const [viewYM, setViewYM] = useState(() => {
const d = value ? new Date(value + "T00:00:00") : new Date();
return { year: d.getFullYear(), month: d.getMonth() };
});
const todayStr = new Date().toISOString().slice(0, 10);
const { year, month } = viewYM;
const firstDay = new Date(year, month, 1);
const lastDay = new Date(year, month + 1, 0);
const startWeekday = (firstDay.getDay() + 6) % 7;
const cells = [];
for (let i = 0; i < startWeekday; i++) cells.push(null);
for (let d = 1; d <= lastDay.getDate(); d++) {
cells.push(`${year}-${String(month + 1).padStart(2, "0")}-${String(d).padStart(2, "0")}`);
}
const prevMonth = () => { const d = new Date(year, month - 1, 1); setViewYM({ year: d.getFullYear(), month: d.getMonth() }); };
const nextMonth = () => { const d = new Date(year, month + 1, 1); setViewYM({ year: d.getFullYear(), month: d.getMonth() }); };
const select = (ds) => { onChange(ds); onClose(); };
return (
<div
style={{
position: "absolute",
top: "calc(100% + 4px)",
[align === "right" ? "right" : "left"]: 0,
zIndex: 2000,
background: "#f0ede8",
border: "1px solid #e0dbd4",
borderRadius: 8,
padding: 12,
boxShadow: "0 8px 28px rgba(0,0,0,0.16)",
width: 232,
}}
onMouseDown={e => e.stopPropagation()}
>
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", marginBottom: 8 }}>
<button onMouseDown={e => { e.preventDefault(); prevMonth(); }} style={{ background: "none", border: "none", cursor: "pointer", padding: "2px 8px", fontSize: 14, color: "#888", lineHeight: 1 }}></button>
<div style={{ fontFamily: "'Playfair Display', serif", fontSize: 13, color: "#1a1a18" }}>
{firstDay.toLocaleDateString("de-CH", { month: "long", year: "numeric" })}
</div>
<button onMouseDown={e => { e.preventDefault(); nextMonth(); }} style={{ background: "none", border: "none", cursor: "pointer", padding: "2px 8px", fontSize: 14, color: "#888", lineHeight: 1 }}></button>
</div>
<div style={{ display: "grid", gridTemplateColumns: "repeat(7,1fr)", gap: 2, marginBottom: 3 }}>
{["Mo", "Di", "Mi", "Do", "Fr", "Sa", "So"].map(d => (
<div key={d} style={{ textAlign: "center", fontSize: 9, color: "#aaa", padding: "2px 0" }}>{d}</div>
))}
</div>
<div style={{ display: "grid", gridTemplateColumns: "repeat(7,1fr)", gap: 2 }}>
{cells.map((ds, i) => {
if (!ds) return <div key={i} />;
const dow = new Date(ds + "T00:00:00").getDay();
const isWeekend = dow === 0 || dow === 6;
const isSelected = ds === value;
const isToday = ds === todayStr;
return (
<button key={ds}
onMouseDown={e => { e.preventDefault(); select(ds); }}
style={{
aspectRatio: "1", fontSize: 11, cursor: "pointer",
fontFamily: "inherit", borderRadius: 4,
border: isToday && !isSelected ? "1.5px solid #b07848" : "1.5px solid transparent",
background: isSelected ? "#1a1a18" : "transparent",
color: isSelected ? "#b07848" : isWeekend ? "#bbb" : "#1a1a18",
fontWeight: isToday && !isSelected ? 600 : 400,
}}
onMouseEnter={e => { if (!isSelected) e.currentTarget.style.background = "#e8e3dc"; }}
onMouseLeave={e => { if (!isSelected) e.currentTarget.style.background = "transparent"; }}
>
{new Date(ds + "T00:00:00").getDate()}
</button>
);
})}
</div>
{showClear && value && (
<div style={{ marginTop: 8, paddingTop: 8, borderTop: "1px solid #e0dbd4", textAlign: "center" }}>
<button onMouseDown={e => { e.preventDefault(); onChange(""); onClose(); }}
style={{ fontSize: 10, color: "#aaa", background: "none", border: "none", cursor: "pointer", fontFamily: "inherit" }}>
Datum löschen
</button>
</div>
)}
</div>
);
}
export function DatePicker({ value, onChange, style, placeholder, align = "left", ...props }) {
const [open, setOpen] = useState(false);
const wrapRef = useRef(null);
const toDE = (iso) => {
if (!iso || !/^\d{4}-\d{2}-\d{2}$/.test(iso)) return "";
return `${iso.slice(8, 10)}.${iso.slice(5, 7)}.${iso.slice(0, 4)}`;
};
useEffect(() => {
if (!open) return;
const close = (e) => { if (!wrapRef.current?.contains(e.target)) setOpen(false); };
document.addEventListener("mousedown", close);
return () => document.removeEventListener("mousedown", close);
}, [open]);
return (
<div ref={wrapRef} style={{ position: "relative", display: "inline-block", width: "100%" }}>
<input
type="text"
value={toDE(value)}
readOnly
onClick={() => setOpen(o => !o)}
placeholder={placeholder || "TT.MM.JJJJ"}
style={{ cursor: "pointer", ...style }}
{...props}
/>
{open && (
<CalendarPopup
value={value}
onChange={ds => onChange({ target: { value: ds } })}
onClose={() => setOpen(false)}
align={align}
/>
)}
</div>
);
}
export function NavArrows({ onPrev, onNext, disabledNext = false }) {
const btn = (onClick, disabled, children) => (
<button
onClick={disabled ? undefined : onClick}
disabled={disabled}
style={{
background: "none", border: "none", padding: "0 11px", height: 30,
cursor: disabled ? "default" : "pointer",
color: disabled ? "var(--border2)" : "var(--text3)",
fontSize: 17, lineHeight: 1, display: "flex", alignItems: "center",
fontFamily: "inherit", flexShrink: 0,
}}
>{children}</button>
);
return (
<div style={{ display: "flex", border: "1px solid var(--border2)", borderRadius: 20, overflow: "hidden" }}>
{btn(onPrev, false, "")}
<div style={{ width: 1, background: "var(--border2)", alignSelf: "stretch" }} />
{btn(onNext, disabledNext, "")}
</div>
);
}
export function useCalendarNav() {
const [open, setOpen] = useState(false);
const ref = useRef(null);
useEffect(() => {
if (!open) return;
const close = (e) => { if (!ref.current?.contains(e.target)) setOpen(false); };
document.addEventListener("mousedown", close);
return () => document.removeEventListener("mousedown", close);
}, [open]);
return { open, setOpen, ref };
}