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
+294
View File
@@ -0,0 +1,294 @@
import React, { useState } from "react";
import { generateId, formatCHF, formatDate, linkedClientForNote } from "../utils.js";
import { Header, Modal, FormField, StatusBadge, useConfirm , DateInput } from "../components/UI.jsx";
export default
function DeliveryNotes({ data, update, saveAll, setPrintContent }) {
const today = new Date().toISOString().slice(0, 10);
const emptyForm = {
date: today,
number: "",
projectId: "",
clientId: "",
clientManual: "",
projectManual: "",
deliveryAddress: "",
notes: "",
items: [{ id: generateId(), desc: "", qty: 1, unit: "Stk.", note: "" }],
};
const [modal, setModal] = useState(null); // null | "new" | id
const [form, setForm] = useState(emptyForm);
const [filter, setFilter] = useState({ search: "", projectId: "", clientId: "" });
const [sort, setSort] = useState({ col: "date", dir: -1 });
const { askConfirm, ConfirmModalEl } = useConfirm();
const notes = data.deliveryNotes || [];
// Nummernvergabe
const nextNumber = () => {
const year = new Date().getFullYear();
const existing = notes.map(n => {
const m = (n.number || "").match(/LS[-]?(\d+)/i);
return m ? parseInt(m[1]) : 0;
});
const max = existing.length ? Math.max(...existing) : 0;
return `LS-${year}-${String(max + 1).padStart(3, "0")}`;
};
const openNew = () => {
setForm({ ...emptyForm, number: nextNumber() });
setModal("new");
};
const openEdit = (n) => { setForm({ ...n }); setModal(n.id); };
const closeModal = () => { setModal(null); setForm(emptyForm); };
const save = () => {
const isNew = modal === "new";
const entry = { ...form, id: isNew ? generateId() : modal };
const updated = isNew
? [...notes, { ...entry, createdAt: new Date().toISOString() }]
: notes.map(n => n.id === modal ? entry : n);
saveAll({ ...data, deliveryNotes: updated });
closeModal();
};
const del = async (id) => {
if (await askConfirm("Lieferschein löschen?"))
saveAll({ ...data, deliveryNotes: notes.filter(n => n.id !== id) });
};
const addItem = () => setForm(f => ({ ...f, items: [...f.items, { id: generateId(), desc: "", qty: 1, unit: "Stk.", note: "" }] }));
const updateItem = (id, changes) => setForm(f => ({ ...f, items: f.items.map(it => it.id === id ? { ...it, ...changes } : it) }));
const removeItem = (id) => setForm(f => ({ ...f, items: f.items.filter(it => it.id !== id) }));
// Ableitungen für Anzeige
const getClient = (n) => {
if (n.clientId) return ((data.persons||[]).filter(p=>p.isAuftraggeber)).find(c => c.id === n.clientId)?.name || "—";
return n.clientManual || "—";
};
const getProject = (n) => {
if (n.projectId) return data.projects.find(p => p.id === n.projectId)?.name || "—";
return n.projectManual || "—";
};
// Filter
const filtered = notes.filter(n => {
if (filter.projectId && n.projectId !== filter.projectId) return false;
if (filter.clientId && n.clientId !== filter.clientId) return false;
if (filter.search) {
const q = filter.search.toLowerCase();
const text = [n.number, getClient(n), getProject(n), n.notes, ...(n.items || []).map(it => it.desc)].join(" ").toLowerCase();
if (!text.includes(q)) return false;
}
return true;
});
const toggleSort = (col) => setSort(s => ({ col, dir: s.col === col ? -s.dir : -1 }));
const SortTh = ({ col, children, style }) => (
<th onClick={() => toggleSort(col)} style={{ cursor: "pointer", userSelect: "none", ...style }}>
{children} <span style={{ color: sort.col === col ? "#b07848" : "#ccc", fontSize: 10 }}>{sort.col === col ? (sort.dir === 1 ? "▲" : "▼") : "⇅"}</span>
</th>
);
const sorted = [...filtered].sort((a, b) => {
const va = sort.col === "date" ? (a.date || "") : sort.col === "number" ? (a.number || "") : sort.col === "client" ? getClient(a) : getProject(a);
const vb = sort.col === "date" ? (b.date || "") : sort.col === "number" ? (b.number || "") : sort.col === "client" ? getClient(b) : getProject(b);
return va.localeCompare(vb) * sort.dir;
});
const UNITS = ["Stk.", "m", "m²", "m³", "kg", "l", "Set", "Blatt", "Rolle", "Palette", "Pkg."];
// Client-Felder im Modal: verknüpft oder manuell
const linkedClient = form.clientId ? ((data.persons||[]).filter(p=>p.isAuftraggeber)).find(c => c.id === form.clientId) : null;
const linkedProject = form.projectId ? data.projects.find(p => p.id === form.projectId) : null;
return (
<div>
{ConfirmModalEl}
<Header title="Lieferscheine" action={
<button className="btn btn-primary" onClick={openNew}>+ Neuer Lieferschein</button>
} />
<div className="filter-bar">
<input className="pill" placeholder="Suche (Nr., Kunde, Projekt, Position…)" value={filter.search} onChange={e => setFilter({ ...filter, search: e.target.value })} style={{ minWidth: 220 }} />
<select className="pill" value={filter.clientId} onChange={e => setFilter({ ...filter, clientId: e.target.value })}>
<option value="">Alle Kunden</option>
{((data.persons||[]).filter(p=>p.isAuftraggeber)).map(c => <option key={c.id} value={c.id}>{c.name}</option>)}
</select>
<select className="pill" value={filter.projectId} onChange={e => setFilter({ ...filter, projectId: e.target.value })}>
<option value="">Alle Projekte</option>
{data.projects.map(p => <option key={p.id} value={p.id}>{p.name}</option>)}
</select>
{(filter.search || filter.clientId || filter.projectId) && (
<button className="btn btn-ghost" style={{ fontSize: 12 }} onClick={() => setFilter({ search: "", projectId: "", clientId: "" })}>Zurücksetzen</button>
)}
<div style={{ marginLeft: "auto", fontSize: 12, color: "#888" }}>
<strong style={{ color: "#1a1a18" }}>{filtered.length}</strong> {filtered.length === 1 ? "Lieferschein" : "Lieferscheine"}
</div>
</div>
{/* Tabelle */}
{sorted.length === 0 ? (
<div className="card" style={{ padding: 0 }}>
<table>
<thead><tr><th style={{ width: 120 }}>Nr.</th><th style={{ width: 100 }}>Datum</th><th style={{ width: 180 }}>Empfänger</th><th>Projekt</th><th style={{ width: 60 }}>Pos.</th><th style={{ width: 120 }}></th></tr></thead>
<tbody><tr><td colSpan={6} style={{ textAlign: "center", color: "#aaa", padding: 32, fontSize: 13 }}>{notes.length === 0 ? "Noch keine Lieferscheine" : "Keine Treffer"}</td></tr></tbody>
</table>
</div>
) : (
<div className="card" style={{ padding: 0 }}>
<table>
<thead>
<tr>
<SortTh col="number" style={{ width: 120 }}>Nr.</SortTh>
<SortTh col="date" style={{ width: 100 }}>Datum</SortTh>
<SortTh col="client" style={{ width: 180 }}>Empfänger</SortTh>
<SortTh col="project">Projekt</SortTh>
<th style={{ width: 60, textAlign: "center" }}>Pos.</th>
<th style={{ width: 120 }}></th>
</tr>
</thead>
<tbody>
{sorted.map(n => (
<tr key={n.id}>
<td><strong style={{ color: "#b07848" }}>{n.number || "—"}</strong></td>
<td>{formatDate(n.date)}</td>
<td>{getClient(n)}</td>
<td style={{ color: "#888" }}>{getProject(n)}</td>
<td style={{ textAlign: "center", color: "#888", fontSize: 12 }}>{(n.items || []).length}</td>
<td style={{ textAlign: "right", whiteSpace: "nowrap" }}>
<button className="btn btn-ghost" style={{ padding: "5px 10px", fontSize: 12, marginRight: 4 }}
onClick={() => { const client = linkedClientForNote(n, data); setPrintContent({ type: "lieferschein", note: n, client, settings: data.settings, data }); }}>
PDF
</button>
<button className="btn btn-ghost" style={{ padding: "5px 10px", fontSize: 12, marginRight: 4 }} onClick={() => openEdit(n)}><span className="material-icons" style={{ fontSize: 16 }}>edit</span></button>
<button className="btn btn-danger" style={{ padding: "5px 10px", fontSize: 12 }} onClick={() => del(n.id)}><span className="material-icons" style={{ fontSize: 16 }}>close</span></button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{/* Modal */}
{modal && (
<Modal title={modal === "new" ? "Neuer Lieferschein" : "Lieferschein bearbeiten"} onClose={closeModal} onSave={save} wide>
{/* Kopfdaten */}
<div style={{ fontSize: 11, letterSpacing: "0.08em", color: "#888", marginBottom: 10 }}>ALLGEMEIN</div>
<div className="form-row">
<FormField label="Nummer">
<input value={form.number} onChange={e => setForm({ ...form, number: e.target.value })} placeholder="LS-2025-001" />
</FormField>
<FormField label="Datum">
<DateInput value={form.date} onChange={e => setForm({ ...form, date: e.target.value })} />
</FormField>
</div>
{/* Empfänger */}
<div style={{ fontSize: 11, letterSpacing: "0.08em", color: "#888", margin: "14px 0 10px", paddingTop: 12, borderTop: "1px solid var(--border2)" }}>EMPFÄNGER</div>
<div className="form-row">
<FormField label="Kunde (verknüpft)">
<select value={form.clientId} onChange={e => {
const c = ((data.persons||[]).filter(p=>p.isAuftraggeber)).find(x => x.id === e.target.value);
setForm({ ...form, clientId: e.target.value, clientManual: "", deliveryAddress: "" });
}}>
<option value=""> manuell eingeben </option>
{((data.persons||[]).filter(p=>p.isAuftraggeber)).map(c => <option key={c.id} value={c.id}>{c.name}</option>)}
</select>
</FormField>
{!form.clientId && (
<FormField label="Empfänger (manuell)">
<input value={form.clientManual} onChange={e => setForm({ ...form, clientManual: e.target.value })} placeholder="Firmen- oder Personenname" />
</FormField>
)}
</div>
<FormField label="Lieferadresse">
{form.clientId ? (
<div style={{ padding: "8px 10px", background: "var(--surface2)", border: "1.5px solid var(--border)", borderRadius: 4, fontSize: 12, color: "var(--text2)", lineHeight: 1.6, minHeight: 60, whiteSpace: "pre-line" }}>
{linkedClient?.address || <span style={{ color: "var(--text4)", fontStyle: "italic" }}>Keine Adresse beim Kunden hinterlegt</span>}
<div style={{ fontSize: 10, color: "var(--text4)", marginTop: 6, borderTop: "1px solid var(--border2)", paddingTop: 4 }}>
Adresse aus Kundenstamm · unter «Kunden» bearbeitbar
</div>
</div>
) : (
<textarea
value={form.deliveryAddress}
onChange={e => setForm({ ...form, deliveryAddress: e.target.value })}
placeholder={"Strasse\nPLZ Ort"}
style={{ minHeight: 60, resize: "vertical" }}
/>
)}
</FormField>
{/* Projekt */}
<div style={{ fontSize: 11, letterSpacing: "0.08em", color: "#888", margin: "14px 0 10px", paddingTop: 12, borderTop: "1px solid var(--border2)" }}>PROJEKT</div>
<div className="form-row">
<FormField label="Projekt (verknüpft)">
<select value={form.projectId} onChange={e => setForm({ ...form, projectId: e.target.value, projectManual: "" })}>
<option value=""> manuell eingeben </option>
{data.projects.map(p => <option key={p.id} value={p.id}>{p.name}</option>)}
</select>
</FormField>
{!form.projectId && (
<FormField label="Projekt (manuell)">
<input value={form.projectManual} onChange={e => setForm({ ...form, projectManual: e.target.value })} placeholder="Projektbezeichnung" />
</FormField>
)}
</div>
{/* Positionen */}
<div style={{ fontSize: 11, letterSpacing: "0.08em", color: "#888", margin: "14px 0 10px", paddingTop: 12, borderTop: "1px solid var(--border2)" }}>POSITIONEN</div>
<table style={{ width: "100%", borderCollapse: "collapse", marginBottom: 10 }}>
<thead>
<tr>
<th style={{ fontSize: 10, color: "#888", fontWeight: 500, textAlign: "left", padding: "4px 6px 6px 0", width: "40%" }}>Beschreibung</th>
<th style={{ fontSize: 10, color: "#888", fontWeight: 500, textAlign: "right", padding: "4px 6px", width: 70 }}>Menge</th>
<th style={{ fontSize: 10, color: "#888", fontWeight: 500, textAlign: "left", padding: "4px 6px", width: 80 }}>Einheit</th>
<th style={{ fontSize: 10, color: "#888", fontWeight: 500, textAlign: "left", padding: "4px 6px" }}>Bemerkung</th>
<th style={{ width: 32 }}></th>
</tr>
</thead>
<tbody>
{form.items.map((it, i) => (
<tr key={it.id}>
<td style={{ padding: "4px 6px 4px 0" }}>
<input value={it.desc} onChange={e => updateItem(it.id, { desc: e.target.value })} placeholder={`Position ${i + 1}`} style={{ height: 32, fontSize: 12 }} autoFocus={i === form.items.length - 1 && form.items.length > 1} />
</td>
<td style={{ padding: "4px 6px" }}>
<input type="number" min={0} step="0.01" value={it.qty} onChange={e => updateItem(it.id, { qty: +e.target.value })} style={{ height: 32, fontSize: 12, textAlign: "right" }} />
</td>
<td style={{ padding: "4px 6px" }}>
<select value={it.unit} onChange={e => updateItem(it.id, { unit: e.target.value })} style={{ height: 32, fontSize: 12 }}>
{UNITS.map(u => <option key={u} value={u}>{u}</option>)}
</select>
</td>
<td style={{ padding: "4px 6px" }}>
<input value={it.note || ""} onChange={e => updateItem(it.id, { note: e.target.value })} placeholder="optional" style={{ height: 32, fontSize: 12 }} />
</td>
<td style={{ padding: "4px 0 4px 4px" }}>
{form.items.length > 1 && (
<button onClick={() => removeItem(it.id)} style={{ background: "none", border: "none", color: "#aaa", cursor: "pointer", fontSize: 16, padding: 0, lineHeight: 1 }}><span className="material-icons" style={{ fontSize: 16 }}>close</span></button>
)}
</td>
</tr>
))}
</tbody>
</table>
<button className="btn btn-ghost" style={{ fontSize: 12, marginBottom: 14 }} onClick={addItem}>+ Position hinzufügen</button>
{/* Notizen */}
<div style={{ fontSize: 11, letterSpacing: "0.08em", color: "#888", margin: "14px 0 10px", paddingTop: 12, borderTop: "1px solid var(--border2)" }}>NOTIZEN / BEMERKUNGEN</div>
<FormField label="">
<textarea value={form.notes || ""} onChange={e => setForm({ ...form, notes: e.target.value })} placeholder="Allgemeine Bemerkungen zum Lieferschein…" style={{ minHeight: 72, resize: "vertical" }} />
</FormField>
</Modal>
)}
</div>
);
}
// Hilfsfunktion für PDF-Knopf in Tabelle