00f07d76f6
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>
115 lines
5.7 KiB
React
Executable File
115 lines
5.7 KiB
React
Executable File
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>
|
|
);
|
|
}
|