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>
195 lines
9.6 KiB
React
Executable File
195 lines
9.6 KiB
React
Executable File
import React from "react";
|
|
import { PROTOKOLL_TYPES } from "../constants.js";
|
|
import { formatDate } from "../utils.js";
|
|
import { Header } from "../components/UI.jsx";
|
|
|
|
export default function Documents({ data, setView }) {
|
|
const protocols = (data.protocols || []).sort((a, b) => (b.date || "").localeCompare(a.date || ""));
|
|
const deliveryNotes = (data.deliveryNotes || []).sort((a, b) => (b.date || "").localeCompare(a.date || ""));
|
|
const letterTemplates = data.letterTemplates || [];
|
|
|
|
const recentProtos = protocols.slice(0, 6);
|
|
const recentNotes = deliveryNotes.slice(0, 5);
|
|
|
|
const protoByType = PROTOKOLL_TYPES.map(t => ({
|
|
type: t,
|
|
count: protocols.filter(p => p.type === t).length,
|
|
})).filter(r => r.count > 0).sort((a, b) => b.count - a.count);
|
|
const maxTypeCount = protoByType[0]?.count || 1;
|
|
|
|
const getClient = (n) => {
|
|
if (n.clientId) return (data.persons||[]).filter(p=>p.isAuftraggeber).find(c => c.id === n.clientId)?.name || "—";
|
|
if (n.projectId) {
|
|
const proj = data.projects?.find(p => p.id === n.projectId);
|
|
if (proj?.clientId) return (data.persons||[]).filter(p=>p.isAuftraggeber).find(c => c.id === proj.clientId)?.name || "—";
|
|
}
|
|
return "—";
|
|
};
|
|
|
|
const getProject = (n) => {
|
|
if (!n.projectId) return null;
|
|
return data.projects?.find(p => p.id === n.projectId)?.name || null;
|
|
};
|
|
|
|
const SectionCard = ({ title, count, action, onAction, children, accent }) => (
|
|
<div className="card" style={{ padding: 0, marginBottom: 20 }}>
|
|
<div style={{ padding: "14px 20px", display: "flex", justifyContent: "space-between", alignItems: "center", borderBottom: "1px solid #ece8e2" }}>
|
|
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
|
|
<span style={{ fontSize: 11, letterSpacing: "0.1em", color: "#888" }}>{title}</span>
|
|
{count > 0 && (
|
|
<span style={{ fontSize: 10, fontWeight: 700, background: accent || "#ece8e2", color: "#1a1a18", padding: "2px 7px", borderRadius: 10 }}>{count}</span>
|
|
)}
|
|
</div>
|
|
<button className="btn btn-ghost" style={{ fontSize: 11, padding: "4px 12px" }} onClick={onAction}>
|
|
{action}
|
|
</button>
|
|
</div>
|
|
{children}
|
|
</div>
|
|
);
|
|
|
|
return (
|
|
<div>
|
|
<Header title="Dokumente" />
|
|
|
|
{/* KPI-Zeile */}
|
|
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr 1fr", gap: 16, marginBottom: 24 }}>
|
|
{[
|
|
{ label: "Protokolle", count: protocols.length, sub: `${recentProtos.length > 0 ? formatDate(recentProtos[0]?.date) : "—"} zuletzt`, view: "protokolle", color: "#2d6a4f", bg: "#e8f5ee" },
|
|
{ label: "Lieferscheine", count: deliveryNotes.length, sub: `${recentNotes.length > 0 ? formatDate(recentNotes[0]?.date) : "—"} zuletzt`, view: "lieferscheine", color: "#1a4e8a", bg: "#e8f0fa" },
|
|
{ label: "Briefvorlagen", count: letterTemplates.length, sub: "Vorlagen", view: "letters", color: "#7a3e8a", bg: "#f3eafa" },
|
|
].map(({ label, count, sub, view: v, color, bg }) => (
|
|
<div key={label} className="card" style={{ cursor: "pointer", transition: "box-shadow 0.15s" }}
|
|
onClick={() => setView(v)}>
|
|
<div style={{ fontSize: 10, letterSpacing: "0.1em", color: "#888", marginBottom: 8 }}>{label.toUpperCase()}</div>
|
|
<div style={{ fontSize: 36, fontFamily: "'Playfair Display', serif", fontWeight: 700, color, lineHeight: 1, marginBottom: 4 }}>{count}</div>
|
|
<div style={{ fontSize: 11, color: "#aaa" }}>{sub}</div>
|
|
<div style={{ marginTop: 12 }}>
|
|
<span style={{ fontSize: 10, fontWeight: 600, color, background: bg, padding: "3px 10px", borderRadius: 20 }}>→ Öffnen</span>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
<div style={{ display: "grid", gridTemplateColumns: "3fr 2fr", gap: 20, alignItems: "start" }}>
|
|
|
|
{/* Linke Spalte */}
|
|
<div>
|
|
{/* Letzte Protokolle */}
|
|
<SectionCard title="LETZTE PROTOKOLLE" count={protocols.length} action="Alle Protokolle →" onAction={() => setView("protokolle")} accent="#e8f5ee">
|
|
{recentProtos.length === 0 ? (
|
|
<div style={{ padding: "20px", fontSize: 12, color: "#aaa" }}>Noch keine Protokolle erfasst.</div>
|
|
) : (
|
|
<table style={{ tableLayout: "fixed" }}>
|
|
<thead>
|
|
<tr>
|
|
<th style={{ width: "11%" }}>Datum</th>
|
|
<th style={{ width: "18%" }}>Typ</th>
|
|
<th>Titel</th>
|
|
<th>Projekt</th>
|
|
<th style={{ width: "12%" }}>Einträge</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{recentProtos.map(p => {
|
|
const proj = getProject(p);
|
|
return (
|
|
<tr key={p.id}>
|
|
<td style={{ fontSize: 11, color: "#888" }}>{formatDate(p.date)}</td>
|
|
<td>
|
|
<span style={{ fontSize: 10, background: "#f5f2ec", color: "#555", padding: "2px 10px", borderRadius: 20 }}>{p.type || "—"}</span>
|
|
</td>
|
|
<td style={{ fontWeight: 500 }}>{p.title || <span style={{ color: "#ccc" }}>—</span>}</td>
|
|
<td style={{ fontSize: 11, color: "#888" }}>{proj || <span style={{ color: "#ddd" }}>—</span>}</td>
|
|
<td style={{ fontSize: 11, color: "#888", textAlign: "center" }}>{(p.entries || []).length}</td>
|
|
</tr>
|
|
);
|
|
})}
|
|
</tbody>
|
|
</table>
|
|
)}
|
|
</SectionCard>
|
|
|
|
{/* Letzte Lieferscheine */}
|
|
<SectionCard title="LETZTE LIEFERSCHEINE" count={deliveryNotes.length} action="Alle Lieferscheine →" onAction={() => setView("lieferscheine")} accent="#e8f0fa">
|
|
{recentNotes.length === 0 ? (
|
|
<div style={{ padding: "20px", fontSize: 12, color: "#aaa" }}>Noch keine Lieferscheine erfasst.</div>
|
|
) : (
|
|
<table style={{ tableLayout: "fixed" }}>
|
|
<thead>
|
|
<tr>
|
|
<th style={{ width: "14%" }}>Nr.</th>
|
|
<th style={{ width: "11%" }}>Datum</th>
|
|
<th>Kunde</th>
|
|
<th>Projekt</th>
|
|
<th style={{ width: "12%" }}>Positionen</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{recentNotes.map(n => (
|
|
<tr key={n.id}>
|
|
<td style={{ fontWeight: 600, fontSize: 12 }}>{n.number || "—"}</td>
|
|
<td style={{ fontSize: 11, color: "#888" }}>{formatDate(n.date)}</td>
|
|
<td style={{ fontSize: 12 }}>{getClient(n)}</td>
|
|
<td style={{ fontSize: 11, color: "#888" }}>{getProject(n) || <span style={{ color: "#ddd" }}>—</span>}</td>
|
|
<td style={{ fontSize: 11, color: "#888", textAlign: "center" }}>{(n.items || []).length}</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
)}
|
|
</SectionCard>
|
|
</div>
|
|
|
|
{/* Rechte Spalte */}
|
|
<div>
|
|
{/* Protokoll-Typen */}
|
|
{protoByType.length > 0 && (
|
|
<div className="card" style={{ marginBottom: 20 }}>
|
|
<div className="section-label">PROTOKOLLE NACH TYP</div>
|
|
{protoByType.map(({ type, count }) => {
|
|
const pct = (count / maxTypeCount) * 100;
|
|
return (
|
|
<div key={type} style={{ marginBottom: 10 }}>
|
|
<div style={{ display: "flex", justifyContent: "space-between", fontSize: 12, marginBottom: 3 }}>
|
|
<span style={{ color: "#555" }}>{type}</span>
|
|
<span style={{ fontWeight: 600 }}>{count}</span>
|
|
</div>
|
|
<div style={{ height: 5, background: "#ece8e2", borderRadius: 3, overflow: "hidden" }}>
|
|
<div style={{ width: `${pct}%`, height: "100%", background: "#2d6a4f", borderRadius: 3 }} />
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
|
|
{/* Briefvorlagen */}
|
|
<div className="card" style={{ padding: 0 }}>
|
|
<div style={{ padding: "14px 20px", display: "flex", justifyContent: "space-between", alignItems: "center", borderBottom: letterTemplates.length > 0 ? "1px solid #ece8e2" : "none" }}>
|
|
<span className="section-label" style={{ marginBottom: 0 }}>BRIEFVORLAGEN</span>
|
|
<button className="btn btn-ghost" style={{ fontSize: 11, padding: "4px 12px" }} onClick={() => setView("letters")}>
|
|
Verwalten →
|
|
</button>
|
|
</div>
|
|
{letterTemplates.length === 0 ? (
|
|
<div style={{ padding: "20px", fontSize: 12, color: "#aaa" }}>Noch keine Briefvorlagen erstellt.</div>
|
|
) : (
|
|
<div style={{ padding: "8px 0" }}>
|
|
{letterTemplates.map(t => (
|
|
<div key={t.id} style={{ padding: "8px 20px", borderBottom: "1px solid #f5f2ec", display: "flex", alignItems: "center", gap: 10 }}>
|
|
<span style={{ fontSize: 13, color: "#555", flex: 1 }}>{t.name || "Unbenannt"}</span>
|
|
<span style={{ fontSize: 10, color: "#aaa" }}>
|
|
{t.body ? `${t.body.replace(/<[^>]+>/g, "").slice(0, 40)}…` : "—"}
|
|
</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|