Rapport 0.6 — Initial Public Release

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

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

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

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-13 01:16:26 +02:00
commit 00f07d76f6
65 changed files with 28010 additions and 0 deletions
+114
View File
@@ -0,0 +1,114 @@
import React, { useState, useEffect } from "react";
import { generateId, textToHtml, htmlToText } from "../utils.js";
import { Header, FormField, RichEditor, useConfirm } from "../components/UI.jsx";
export default
function Letters({ data, update, setPrintContent }) {
const [selectedTemplate, setSelectedTemplate] = useState(data.letterTemplates[0]?.id || "");
const [clientId, setClientId] = useState("");
const [projectId, setProjectId] = useState("");
const [body, setBody] = useState(() => textToHtml(data.letterTemplates[0]?.body || ""));
const { askConfirm, ConfirmModalEl } = useConfirm();
const [subject, setSubject] = useState(data.letterTemplates[0]?.name || "");
const prevTemplate = React.useRef(selectedTemplate);
useEffect(() => {
const tpl = data.letterTemplates.find(t => t.id === selectedTemplate);
if (tpl) {
const client = ((data.persons||[]).filter(p=>p.isAuftraggeber)).find(c => c.id === clientId);
const proj = data.projects.find(p => p.id === projectId);
let text = tpl.body || "";
text = text.replace(/\{\{client\}\}/g, client?.name || "[Kunde]");
text = text.replace(/\{\{project\}\}/g, proj?.name || "[Projekt]");
setBody(textToHtml(text));
setSubject(tpl.name);
}
}, [selectedTemplate, clientId, projectId]);
const client = ((data.persons||[]).filter(p=>p.isAuftraggeber)).find(c => c.id === clientId);
const saveAsTemplate = () => {
const name = prompt("Name der neuen Vorlage?");
if (!name) return;
const tpl = { id: generateId(), name, body };
update("letterTemplates", [...data.letterTemplates, tpl]);
setSelectedTemplate(tpl.id);
};
const updateTemplate = () => {
update("letterTemplates", data.letterTemplates.map(t => t.id === selectedTemplate ? { ...t, body } : t));
};
const deleteTemplate = async () => {
if (!(await askConfirm("Vorlage löschen?"))) return;
const remaining = data.letterTemplates.filter(t => t.id !== selectedTemplate);
update("letterTemplates", remaining);
setSelectedTemplate(remaining[0]?.id || "");
};
return (
<div>
{ConfirmModalEl}
<Header title="Briefe" action={
<button className="btn btn-primary" onClick={() => setPrintContent({ type: "letter", client, subject, body, isHtml: true, settings: data.settings })}>
Drucken / PDF
</button>
} />
<div style={{ display: "grid", gridTemplateColumns: "280px 1fr", gap: 20, alignItems: "start" }}>
{/* Linke Spalte */}
<div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
<div className="card">
<div style={{ fontSize: 10, letterSpacing: "0.12em", color: "var(--text4)", fontWeight: 600, marginBottom: 12 }}>VORLAGE</div>
<select value={selectedTemplate} onChange={e => setSelectedTemplate(e.target.value)} style={{ marginBottom: 10 }}>
{data.letterTemplates.map(t => <option key={t.id} value={t.id}>{t.name}</option>)}
</select>
<div style={{ display: "flex", gap: 6 }}>
<button className="btn btn-ghost" style={{ flex: 1, fontSize: 11, padding: "5px 8px" }} onClick={updateTemplate}>Überschreiben</button>
<button className="btn btn-ghost" style={{ flex: 1, fontSize: 11, padding: "5px 8px" }} onClick={saveAsTemplate}>Neu speichern</button>
<button className="btn btn-ghost" style={{ padding: "5px 8px", fontSize: 11 }} onClick={deleteTemplate}><span className="material-icons" style={{ fontSize: 16 }}>close</span></button>
</div>
</div>
<div className="card">
<div style={{ fontSize: 10, letterSpacing: "0.12em", color: "var(--text4)", fontWeight: 600, marginBottom: 12 }}>EMPFÄNGER</div>
<FormField label="Kunde">
<select value={clientId} onChange={e => setClientId(e.target.value)}>
<option value=""> wählen </option>
{((data.persons||[]).filter(p=>p.isAuftraggeber)).map(c => <option key={c.id} value={c.id}>{c.name}</option>)}
</select>
</FormField>
<FormField label="Projekt (optional)">
<select value={projectId} onChange={e => setProjectId(e.target.value)}>
<option value=""> keines </option>
{data.projects.map(p => <option key={p.id} value={p.id}>{p.name}</option>)}
</select>
</FormField>
</div>
<div className="card" style={{ fontSize: 11, color: "var(--text5)", lineHeight: 1.7 }}>
<div style={{ fontSize: 10, letterSpacing: "0.12em", color: "var(--text4)", fontWeight: 600, marginBottom: 8 }}>PLATZHALTER</div>
<code style={{ fontSize: 10, background: "var(--surface2)", padding: "1px 5px", borderRadius: 3 }}>{"{{client}}"}</code> Kundenname<br/>
<code style={{ fontSize: 10, background: "var(--surface2)", padding: "1px 5px", borderRadius: 3, marginTop: 4, display: "inline-block" }}>{"{{project}}"}</code> Projektname
</div>
</div>
{/* Editor */}
<div className="card" style={{ padding: 0, overflow: "hidden" }}>
<div style={{ padding: "16px 20px", borderBottom: "1px solid var(--border2)" }}>
<input
value={subject}
onChange={e => setSubject(e.target.value)}
placeholder="Betreff / Titel"
style={{ fontSize: 15, fontWeight: 500, border: "none", background: "transparent", color: "var(--text)", width: "100%", outline: "none", height: "auto" }}
/>
</div>
<div style={{ padding: "0" }}>
<RichEditor value={body} onChange={setBody} />
</div>
</div>
</div>
</div>
);
}