Files
RAPPORT/src/views/DeliveryNotes.jsx
T
karim 00f07d76f6 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>
2026-05-13 01:16:26 +02:00

295 lines
16 KiB
React
Executable File
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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