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
+114
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user