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

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

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

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

470 lines
19 KiB
React
Executable File
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { useState, 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 };
}