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
+294
@@ -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
|
||||
Reference in New Issue
Block a user