Files
RAPPORT/src/views/Protocols.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

979 lines
52 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, useEffect, useRef } from "react";
import { PROTOKOLL_TYPES, PROTOKOLL_ENTRY_TYPES } from "../constants.js";
import { generateId, formatCHF, formatDate, formatHours, buildReminderLetter, getKW, getWeekNumber, formatKW, nextProtoNumber, nextProtoSeq, applyProtoNumberFormat } from "../utils.js";
import { Header, Modal, FormField, StatusBadge, useConfirm, RichEditor , DateInput } from "../components/UI.jsx";
export
function MahnModal({ inv, data, update, setPrintContent, onClose, mahnMode, setMahnMode, mahnSentDate, setMahnSentDate }) {
const reminders = inv.reminders || [];
const nextNr = reminders.length + 1;
const lastReminder = reminders.at(-1);
const nextLabel = nextNr === 1 ? "Zahlungserinnerung" : `${nextNr}. Mahnung`;
const nextColor = nextNr >= 3 ? "#8a1a1a" : nextNr === 2 ? "#b5621e" : "#7a6a00";
const confirm = () => {
if (mahnMode.startsWith("reprint-")) {
const idx = parseInt(mahnMode.split("-")[1]);
const r = reminders[idx];
const { client, subject, body } = buildReminderLetter(inv, r.nr, r.sentDate || r.date, (data.persons||[]).filter(p=>p.isAuftraggeber), data.settings);
setPrintContent({ type: "letter", client, subject, body, settings: data.settings });
} else {
const { client, subject, body } = buildReminderLetter(inv, nextNr, mahnSentDate, (data.persons||[]).filter(p=>p.isAuftraggeber), data.settings);
setPrintContent({ type: "letter", client, subject, body, settings: data.settings });
const newReminder = { nr: nextNr, date: new Date().toISOString().slice(0, 10), sentDate: mahnSentDate, daysPast: Math.floor((new Date() - new Date(inv.dueDate)) / 86400000) };
update("invoices", data.invoices.map(i => i.id === inv.id
? { ...i, status: "überfällig", reminders: [...reminders, newReminder] }
: i
));
}
onClose();
};
return (
<div className="modal-overlay" onClick={e => e.target === e.currentTarget && onClose()}>
<div className="modal" style={{ maxWidth: 480 }}>
<h2 style={{ fontFamily: "'Playfair Display', serif", fontWeight: 400, marginBottom: 6, fontSize: 22 }}>Mahnung</h2>
<div style={{ fontSize: 12, color: "var(--text4)", marginBottom: 20 }}>Rechnung {inv.number} · {formatCHF(inv.total)}</div>
{reminders.length > 0 && (
<div style={{ marginBottom: 20, padding: "10px 14px", background: "var(--surface2)", borderRadius: 6, border: "1px solid var(--border2)" }}>
<div style={{ fontSize: 10, letterSpacing: "0.08em", color: "var(--text4)", marginBottom: 8 }}>BISHERIGE MAHNUNGEN</div>
{reminders.map((r, i) => (
<div key={i} style={{ display: "flex", justifyContent: "space-between", fontSize: 12, padding: "3px 0", borderBottom: i < reminders.length - 1 ? "1px solid var(--border2)" : "none" }}>
<span style={{ color: "var(--text2)" }}>{i === 0 ? "Zahlungserinnerung" : `${r.nr}. Mahnung`}</span>
<span style={{ color: "var(--text4)" }}>gesendet {formatDate(r.sentDate || r.date)}</span>
</div>
))}
</div>
)}
<div style={{ display: "flex", flexDirection: "column", gap: 8, marginBottom: 20 }}>
{reminders.map((r, i) => {
const rLabel = i === 0 ? "Zahlungserinnerung" : `${r.nr}. Mahnung`;
const rMode = `reprint-${i}`;
return (
<label key={i} style={{ display: "flex", gap: 12, padding: "11px 14px", borderRadius: 6, border: `1.5px solid ${mahnMode === rMode ? "var(--text)" : "var(--border)"}`, cursor: "pointer", textTransform: "none", fontSize: 13, color: "var(--text)" }}>
<input type="radio" checked={mahnMode === rMode} onChange={() => setMahnMode(rMode)} style={{ width: "auto", marginTop: 2 }} />
<div>
<div style={{ fontWeight: 500 }}>{rLabel} nochmals drucken</div>
<div style={{ fontSize: 11, color: "var(--text4)", marginTop: 2 }}>Gesendet am {formatDate(r.sentDate || r.date)} · kein neuer Eintrag</div>
</div>
</label>
);
})}
<label style={{ display: "flex", gap: 12, padding: "11px 14px", borderRadius: 6, border: `1.5px solid ${mahnMode === "new" ? "var(--text)" : "var(--border)"}`, cursor: "pointer", textTransform: "none", fontSize: 13, color: "var(--text)" }}>
<input type="radio" checked={mahnMode === "new"} onChange={() => setMahnMode("new")} style={{ width: "auto", marginTop: 2 }} />
<div style={{ flex: 1 }}>
<div style={{ fontWeight: 500, color: nextColor }}>{nextLabel} auslösen</div>
<div style={{ fontSize: 11, color: "var(--text4)", marginTop: 2 }}>Wird als neuer Eintrag gespeichert</div>
{mahnMode === "new" && (
<div style={{ marginTop: 10, display: "flex", alignItems: "center", gap: 8 }}>
<label style={{ fontSize: 11, color: "var(--text4)", textTransform: "uppercase", letterSpacing: "0.06em", whiteSpace: "nowrap" }}>Sendedatum</label>
<DateInput value={mahnSentDate} onChange={e => setMahnSentDate(e.target.value)} style={{ flex: 1, height: 32, fontSize: 12 }} />
</div>
)}
</div>
</label>
</div>
<div style={{ display: "flex", gap: 10, justifyContent: "flex-end" }}>
<button className="btn btn-ghost" onClick={onClose}>Abbrechen</button>
<button className="btn btn-primary" onClick={confirm} style={{ background: mahnMode === "new" ? nextColor : "#2a2a22" }}>
Drucken{mahnMode === "new" ? " & speichern" : ""}
</button>
</div>
</div>
</div>
);
}
export default
function Protokolle({ data, update, saveAll, setPrintContent }) {
const today = new Date().toISOString().slice(0, 10);
const protokolle = data.protocols || [];
const [detailId, setDetailId] = useState(() => {
const id = window.__openProtokoll || null;
window.__openProtokoll = null;
return id;
});
const [filter, setFilter] = useState({ search: "", type: "", projectId: "" });
const [sort, setSort] = useState({ col: "date", dir: -1 });
const detail = detailId ? protokolle.find(p => p.id === detailId) : null;
// hooks must be called before any early return
const { askConfirm, ConfirmModalEl } = useConfirm();
const deleteProtokoll = async (id) => {
if (await askConfirm("Protokoll löschen?")) {
saveAll({ ...data, protocols: protokolle.filter(x => x.id !== id) });
}
};
// ── Detail-Ansicht ────────────────────────────────────────────
if (detail) {
return <ProtokollDetail
protokoll={detail}
data={data}
onBack={() => setDetailId(null)}
onSave={p => { saveAll({ ...data, protocols: protokolle.map(x => x.id === p.id ? p : x) }); }}
onDelete={id => { saveAll({ ...data, protocols: protokolle.filter(x => x.id !== id) }); setDetailId(null); }}
setPrintContent={setPrintContent}
saveAll={saveAll}
/>;
}
// ── Listenansicht ─────────────────────────────────────────────
const newProtokoll = () => {
const defaultType = PROTOKOLL_TYPES[0];
const abbr = data.settings.protokollTypeAbbreviations || {};
const typKuerzel = abbr[defaultType] || "SO";
const seq = nextProtoSeq(protokolle);
const nummer = applyProtoNumberFormat(data.settings.protokollNumberFormat || "YYYY-TT-NN", {
date: today, projectNumber: "", seq, typKuerzel,
});
const p = {
id: generateId(),
title: "",
type: defaultType,
date: today,
time: "10:00",
endTime: "",
location: "",
projectId: "",
projectManual: "",
nummer,
participants: [],
traktanden: [{ id: generateId(), nr: "1", title: "", items: [] }],
nextDate: "",
verteiler: "",
createdAt: new Date().toISOString(),
};
saveAll({ ...data, protocols: [...protokolle, p] });
setDetailId(p.id);
};
const filtered = protokolle.filter(p => {
if (filter.type && p.type !== filter.type) return false;
if (filter.projectId && p.projectId !== filter.projectId) return false;
if (filter.search) {
const q = filter.search.toLowerCase();
const proj = data.projects.find(x => x.id === p.projectId);
if (![p.title, p.nummer, p.type, proj?.name, p.location].filter(Boolean).join(" ").toLowerCase().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 === "type" ? (a.type || "") : sort.col === "nummer" ? (a.nummer || "") : (a.title || "");
const vb = sort.col === "date" ? (b.date || "") : sort.col === "type" ? (b.type || "") : sort.col === "nummer" ? (b.nummer || "") : (b.title || "");
return va.localeCompare(vb) * sort.dir;
});
// Offene Aufgaben über alle Protokolle
const alleTasks = protokolle.flatMap(p =>
(p.traktanden || []).flatMap(t =>
(t.items || []).filter(it => it.type === "aufgabe" && it.status !== "erledigt")
.map(it => ({ ...it, protokollId: p.id, protokollNr: p.nummer, protokollTitle: p.title, protokollDate: p.date }))
)
);
return (
<div>
{ConfirmModalEl}
<Header title="Protokolle" action={
<button className="btn btn-primary" onClick={newProtokoll}>+ Neues Protokoll</button>
} />
{/* Offene Aufgaben-Banner */}
{alleTasks.length > 0 && (
<div className="card" style={{ marginBottom: 20, borderLeft: "4px solid #b5621e", padding: "12px 20px" }}>
<div style={{ fontSize: 11, letterSpacing: "0.1em", color: "#b5621e", marginBottom: 10, fontWeight: 600 }}>
OFFENE AUFGABEN ({alleTasks.length})
</div>
<div style={{ display: "flex", flexWrap: "wrap", gap: 8 }}>
{alleTasks.slice(0, 6).map(t => {
const due = t.dueDateType === "kw" ? `KW ${t.dueKW}/${t.dueYear || new Date().getFullYear()}` : t.dueDate ? formatDate(t.dueDate) : "—";
const isLate = t.dueDate && t.dueDateType === "datum" && t.dueDate < today;
return (
<div key={t.id} onClick={() => setDetailId(t.protokollId)}
style={{ fontSize: 12, padding: "5px 10px", background: isLate ? "#fdf2f2" : "var(--surface2)", border: `1px solid ${isLate ? "#e8b0b0" : "var(--border)"}`, borderRadius: 4, cursor: "pointer" }}>
<span style={{ color: "var(--text4)", fontSize: 10, marginRight: 6 }}>{t.protokollNr}</span>
<strong>{t.text}</strong>
{t.responsible && <span style={{ color: "var(--text4)", marginLeft: 6 }}> {t.responsible}</span>}
<span style={{ color: isLate ? "#8a1a1a" : "var(--text4)", marginLeft: 6, fontSize: 10 }}>{due}</span>
</div>
);
})}
{alleTasks.length > 6 && <div style={{ fontSize: 12, color: "var(--text4)", padding: "5px 10px" }}>+{alleTasks.length - 6} weitere</div>}
</div>
</div>
)}
<div className="filter-bar">
<input className="pill" placeholder="Suche (Titel, Nr., Ort…)" value={filter.search} onChange={e => setFilter({ ...filter, search: e.target.value })} style={{ minWidth: 200 }} />
<select className="pill" value={filter.type} onChange={e => setFilter({ ...filter, type: e.target.value })}>
<option value="">Alle Typen</option>
{PROTOKOLL_TYPES.map(t => <option key={t} value={t}>{t}</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.type || filter.projectId) && (
<button className="btn btn-ghost" style={{ fontSize: 12 }} onClick={() => setFilter({ search: "", type: "", projectId: "" })}>Zurücksetzen</button>
)}
<div style={{ marginLeft: "auto", fontSize: 12, color: "#888" }}>
<strong style={{ color: "#1a1a18" }}>{filtered.length}</strong> Protokolle
</div>
</div>
{sorted.length === 0 ? (
<div className="card" style={{ padding: 0 }}>
<table>
<thead><tr><th style={{ width: 110 }}>Nr.</th><th style={{ width: 100 }}>Datum</th><th style={{ width: 160 }}>Typ</th><th>Titel</th><th style={{ width: 160 }}>Projekt</th><th style={{ width: 50 }}>TN</th><th style={{ width: 50 }}>📌</th><th style={{ width: 130 }}></th></tr></thead>
<tbody><tr><td colSpan={8} style={{ textAlign: "center", color: "#aaa", padding: 32, fontSize: 13 }}>{protokolle.length === 0 ? "Noch keine Protokolle" : "Keine Treffer"}</td></tr></tbody>
</table>
</div>
) : (
<div className="card" style={{ padding: 0 }}>
<table>
<thead>
<tr>
<SortTh col="nummer" style={{ width: 110 }}>Nr.</SortTh>
<SortTh col="date" style={{ width: 100 }}>Datum</SortTh>
<SortTh col="type" style={{ width: 160 }}>Typ</SortTh>
<SortTh col="title">Titel</SortTh>
<th style={{ width: 160 }}>Projekt</th>
<th style={{ width: 50, textAlign: "center" }}>TN</th>
<th style={{ width: 50, textAlign: "center" }}>📌</th>
<th style={{ width: 130 }}></th>
</tr>
</thead>
<tbody>
{sorted.map(p => {
const proj = data.projects.find(x => x.id === p.projectId);
const anwesend = (p.participants || []).filter(x => x.status === "anwesend").length;
const total = (p.participants || []).length;
const offeneTasks = (p.traktanden || []).flatMap(t => (t.items || []).filter(it => it.type === "aufgabe" && it.status !== "erledigt")).length;
return (
<tr key={p.id} onClick={() => setDetailId(p.id)} style={{ cursor: "pointer" }}>
<td><strong style={{ color: "#b07848" }}>{p.nummer}</strong></td>
<td>{formatDate(p.date)}</td>
<td><span style={{ fontSize: 11, color: "#555" }}>{p.type}</span></td>
<td><strong>{p.title || <span style={{ color: "#aaa", fontWeight: 400 }}>Kein Titel</span>}</strong></td>
<td style={{ color: "#888", fontSize: 12 }}>{proj?.name || p.projectManual || "—"}</td>
<td style={{ textAlign: "center", fontSize: 12, color: "#888" }}>{total > 0 ? `${anwesend}/${total}` : "—"}</td>
<td style={{ textAlign: "center" }}>
{offeneTasks > 0 && <span style={{ fontSize: 11, color: "#b5621e", fontWeight: 600 }}>{offeneTasks}</span>}
</td>
<td style={{ textAlign: "right", whiteSpace: "nowrap" }}>
<button className="btn btn-ghost" style={{ fontSize: 12, padding: "5px 10px", marginRight: 4 }}
onClick={e => { e.stopPropagation(); setPrintContent({ type: "protokoll", protokoll: p, data, settings: data.settings }); }}>PDF</button>
<button className="btn btn-ghost" style={{ fontSize: 12, padding: "5px 10px", marginRight: 4 }}
onClick={e => { e.stopPropagation(); setDetailId(p.id); }}><span className="material-icons" style={{ fontSize: 16 }}>edit</span></button>
<button className="btn btn-danger" style={{ fontSize: 12, padding: "5px 10px" }}
onClick={e => { e.stopPropagation(); deleteProtokoll(p.id); }}><span className="material-icons" style={{ fontSize: 16 }}>close</span></button>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</div>
);
}
export
function ItemEditor({ tId, item, today, onUpdate, onRemove }) {
const typeConfig = {
info: { icon: "", color: "#1a4e8a", label: "Information" },
beschluss:{ icon: "✓", color: "#2d6a4f", label: "Beschluss" },
aufgabe: { icon: "→", color: "#b5621e", label: "Aufgabe" },
};
const tc = typeConfig[item.type] || typeConfig.info;
return (
<div style={{ display: "flex", gap: 10, alignItems: "flex-start", flex: 1 }}>
<div style={{ flexShrink: 0, width: 24, height: 24, borderRadius: 4, background: tc.color, display: "flex", alignItems: "center", justifyContent: "center", fontSize: 12, marginTop: 6 }}>{tc.icon}</div>
<div style={{ flex: 1 }}>
<RichEditor value={item.text || ""} onChange={html => onUpdate({ text: html })} minHeight={60} compact />
{item.type === "beschluss" && (
<div style={{ display: "flex", gap: 8, marginTop: 6, alignItems: "center" }}>
<label style={{ fontSize: 11, color: "var(--text4)", textTransform: "uppercase", letterSpacing: "0.06em" }}>Beschlussdatum</label>
<DateInput value={item.date || today} onChange={e => onUpdate({ date: e.target.value })}
style={{ height: 28, fontSize: 11, width: 140 }} />
</div>
)}
{item.type === "aufgabe" && (
<div style={{ display: "flex", gap: 8, marginTop: 6, flexWrap: "wrap", alignItems: "center" }}>
<input value={item.responsible || ""} onChange={e => onUpdate({ responsible: e.target.value })}
placeholder="Verantwortliche/r" style={{ height: 28, fontSize: 11, flex: "1 1 140px", maxWidth: 200 }} />
<select value={item.dueDateType || "kw"} onChange={e => onUpdate({ dueDateType: e.target.value })}
style={{ height: 28, fontSize: 11, width: 70 }}>
<option value="kw">KW</option>
<option value="datum">Datum</option>
</select>
{(item.dueDateType || "kw") === "kw" ? (
<>
<input type="number" min={1} max={53} value={item.dueKW || ""} onChange={e => onUpdate({ dueKW: e.target.value })}
placeholder="KW" style={{ height: 28, fontSize: 11, width: 60 }} />
<input type="number" min={2024} max={2035} value={item.dueYear || new Date().getFullYear()} onChange={e => onUpdate({ dueYear: +e.target.value })}
style={{ height: 28, fontSize: 11, width: 72 }} />
</>
) : (
<DateInput value={item.dueDate || ""} onChange={e => onUpdate({ dueDate: e.target.value })}
style={{ height: 28, fontSize: 11, width: 140 }} />
)}
<select value={item.status || "offen"} onChange={e => onUpdate({ status: e.target.value })}
style={{ height: 28, fontSize: 11, width: 100,
background: item.status === "erledigt" ? "#e8f5ee" : item.status === "in Arbeit" ? "#fffbe6" : "var(--input-bg)",
color: item.status === "erledigt" ? "#2d6a4f" : item.status === "in Arbeit" ? "#7a6a00" : "#b5621e",
fontWeight: 600 }}>
<option value="offen">Offen</option>
<option value="in Arbeit">In Arbeit</option>
<option value="erledigt">Erledigt</option>
</select>
</div>
)}
</div>
<button onClick={onRemove}
style={{ background: "none", border: "none", color: "var(--text4)", cursor: "pointer", fontSize: 16, padding: 0, marginTop: 6, flexShrink: 0 }}><span className="material-icons" style={{ fontSize: 16 }}>close</span></button>
</div>
);
}
export
function ProtokollDetail({ protokoll, data, onBack, onSave, onDelete, setPrintContent, saveAll }) {
const [p, setP] = useState(() => JSON.parse(JSON.stringify(protokoll)));
const isDirty = JSON.stringify(p) !== JSON.stringify(protokoll);
const today = new Date().toISOString().slice(0, 10);
const [showFolge, setShowFolge] = useState(false);
const { askConfirm, ConfirmModalEl } = useConfirm();
const [folgeSelection, setFolgeSelection] = useState(null); // built on open
const save = () => onSave(p);
const setField = (k, v) => setP(prev => {
const updated = { ...prev, [k]: v };
if (k === "projectId" || k === "date" || k === "type") {
const abbr = data.settings.protokollTypeAbbreviations || {};
const proj = data.projects.find(x => x.id === updated.projectId);
const typKuerzel = abbr[updated.type] || "SO";
const groups = (prev.nummer || "").match(/\d+/g) || [];
const seq = (() => {
for (let i = groups.length - 1; i >= 0; i--) {
const n = parseInt(groups[i]);
if (!(groups[i].length === 4 && n >= 2000 && n <= 2099)) return n;
}
return 1;
})();
updated.nummer = applyProtoNumberFormat(data.settings.protokollNumberFormat || "YYYY-TT-NN", {
date: updated.date, projectNumber: proj?.number || "", seq, typKuerzel,
});
}
return updated;
});
// ── Teilnehmer ────────────────────────────────────────────────
const proj = data.projects.find(x => x.id === p.projectId);
const projectContactPersons = proj ? (proj.projectContacts || []).flatMap(pc => {
const firm = (data.persons || []).find(c => c.id === pc.contactId);
if (!firm) return [];
const firmPersons = firm.contacts || [];
if (pc.personIds && pc.personIds.length > 0) {
return firmPersons.filter(fp => pc.personIds.includes(fp.id)).map(fp => ({
id: `pc-${fp.id}`,
name: fp.name,
role: [fp.position, firm.name].filter(Boolean).join(" · "),
source: "extern",
}));
}
// No specific persons selected — show firm entry (if it has no contacts) or all persons
if (firmPersons.length === 0) {
return [{ id: `pcf-${firm.id}`, name: firm.name, role: firm.type || "Beteiligter", source: "extern" }];
}
return firmPersons.map(fp => ({
id: `pc-${fp.id}`,
name: fp.name,
role: [fp.position, firm.name].filter(Boolean).join(" · "),
source: "extern",
}));
}) : [];
const projectMembers = proj?.internalMembers?.length
? (data.employees || []).filter(e => proj.internalMembers.includes(e.id))
: (data.employees || []);
const allPersons = [
...projectMembers.map(e => ({ id: `emp-${e.id}`, name: e.name, role: e.role || "Mitarbeiter", source: "intern" })),
...projectContactPersons,
...(data.persons || []).filter(p => p.isAuftraggeber).flatMap(c => {
if (c.contacts && c.contacts.length > 0) {
return c.contacts.map(ct => ({
id: `cnt-${ct.id}`,
name: ct.name,
role: [ct.position, c.name].filter(Boolean).join(" · "),
source: "extern",
}));
}
return [{ id: `cli-${c.id}`, name: c.name, role: "Auftraggeber", source: "extern" }];
}),
];
const addParticipant = (personId) => {
if (!personId || p.participants.some(x => x.id === personId)) return;
const person = allPersons.find(x => x.id === personId);
if (!person) return;
setP(prev => ({ ...prev, participants: [...prev.participants, { ...person, status: "anwesend" }] }));
};
const addManualParticipant = () => {
const name = prompt("Name der Person:");
if (!name?.trim()) return;
const role = prompt("Funktion / Firma (optional):") || "";
setP(prev => ({ ...prev, participants: [...prev.participants, { id: generateId(), name: name.trim(), role, source: "manuell", status: "anwesend" }] }));
};
const setParticipantStatus = (id, status) =>
setP(prev => ({ ...prev, participants: prev.participants.map(x => x.id === id ? { ...x, status } : x) }));
const removeParticipant = (id) =>
setP(prev => ({ ...prev, participants: prev.participants.filter(x => x.id !== id) }));
// ── Traktanden ────────────────────────────────────────────────
const addTraktandum = () => {
const maxNr = Math.max(0, ...(p.traktanden || []).map(t => parseInt(t.nr) || 0));
setP(prev => ({ ...prev, traktanden: [...(prev.traktanden || []), { id: generateId(), nr: String(maxNr + 1), title: "", items: [] }] }));
};
const setTraktandum = (id, changes) =>
setP(prev => ({ ...prev, traktanden: (prev.traktanden || []).map(t => t.id === id ? { ...t, ...changes } : t) }));
const removeTraktandum = (id) =>
setP(prev => ({ ...prev, traktanden: (prev.traktanden || []).filter(t => t.id !== id) }));
const addItem = (tId, type) => {
const item = type === "aufgabe"
? { id: generateId(), type, text: "", responsible: "", dueDateType: "kw", dueKW: "", dueYear: new Date().getFullYear(), dueDate: "", status: "offen" }
: type === "beschluss"
? { id: generateId(), type, text: "", date: today }
: { id: generateId(), type: "info", text: "" };
setP(prev => ({ ...prev, traktanden: (prev.traktanden || []).map(t => t.id === tId ? { ...t, items: [...(t.items || []), item] } : t) }));
};
const setItem = (tId, iId, changes) =>
setP(prev => ({ ...prev, traktanden: (prev.traktanden || []).map(t => t.id === tId ? { ...t, items: (t.items || []).map(it => it.id === iId ? { ...it, ...changes } : it) } : t) }));
const removeItem = (tId, iId) =>
setP(prev => ({ ...prev, traktanden: (prev.traktanden || []).map(t => t.id === tId ? { ...t, items: (t.items || []).filter(it => it.id !== iId) } : t) }));
const statusConfig = {
anwesend: { label: "Anwesend", color: "#2d6a4f", bg: "#e8f5ee" },
entschuldigt: { label: "Entschuldigt", color: "#b5621e", bg: "#fdf0e8" },
abwesend: { label: "Abwesend", color: "#8a1a1a", bg: "#fdf2f2" },
eingeladen: { label: "Eingeladen", color: "#1a4e8a", bg: "#e8f0fa" },
};
const unaddedPersons = allPersons.filter(x => !p.participants.some(pt => pt.id === x.id));
// ── Drag & Drop ───────────────────────────────────────────────
// Pointer-based drag (reliable in Tauri/WKWebView — HTML5 drag API is not)
const dragItem = React.useRef(null); // { kind: "traktandum"|"item", idx, tId? }
const dragOver = React.useRef(null); // { idx, tId? }
const [dragOverTraktandum, setDragOverTraktandum] = React.useState(null);
const [dragOverItem, setDragOverItem] = React.useState(null); // { tId, idx }
const [draggingTraktandum, setDraggingTraktandum] = React.useState(null);
const [draggingItem, setDraggingItem] = React.useState(null); // { tId, idx }
const commitDrag = () => {
const { kind, idx: from, tId } = dragItem.current || {};
const to = dragOver.current?.idx;
dragItem.current = null;
dragOver.current = null;
setDragOverTraktandum(null);
setDragOverItem(null);
setDraggingTraktandum(null);
setDraggingItem(null);
if (from == null || to == null || from === to) return;
if (kind === "traktandum") {
setP(prev => {
const arr = [...(prev.traktanden || [])];
const [moved] = arr.splice(from, 1);
arr.splice(to, 0, moved);
return { ...prev, traktanden: arr };
});
} else if (kind === "item") {
setP(prev => ({
...prev,
traktanden: (prev.traktanden || []).map(t => {
if (t.id !== tId) return t;
const arr = [...(t.items || [])];
const [moved] = arr.splice(from, 1);
arr.splice(to, 0, moved);
return { ...t, items: arr };
}),
}));
}
};
React.useEffect(() => {
const up = () => { if (dragItem.current) commitDrag(); };
window.addEventListener("mouseup", up);
return () => window.removeEventListener("mouseup", up);
});
const startDragTraktandum = (idx) => {
dragItem.current = { kind: "traktandum", idx };
dragOver.current = { idx };
setDraggingTraktandum(idx);
};
const enterTraktandum = (idx) => {
if (dragItem.current?.kind !== "traktandum") return;
dragOver.current = { idx };
setDragOverTraktandum(idx);
};
const startDragItem = (tId, idx) => {
dragItem.current = { kind: "item", idx, tId };
dragOver.current = { idx };
setDraggingItem({ tId, idx });
};
const enterItem = (tId, idx) => {
if (dragItem.current?.kind !== "item" || dragItem.current?.tId !== tId) return;
dragOver.current = { idx };
setDragOverItem({ tId, idx });
};
return (
<div>
{ConfirmModalEl}
{/* Header */}
<div style={{ display: "flex", alignItems: "center", gap: 10, marginBottom: 4 }}>
<button onClick={onBack} style={{ background: "none", border: "none", fontSize: 12, color: "#888", cursor: "pointer", padding: 0, fontFamily: "inherit" }}> Protokolle</button>
{isDirty && <span style={{ fontSize: 11, color: "#b5621e" }}> Ungespeichert</span>}
</div>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start", marginBottom: 24 }}>
<div style={{ flex: 1, marginRight: 20 }}>
<input
value={p.title}
onChange={e => setField("title", e.target.value)}
placeholder="Protokolltitel…"
style={{ fontSize: 28, fontFamily: "'Playfair Display', serif", fontWeight: 400, background: "none", border: "none", borderBottom: "2px solid var(--border)", borderRadius: 0, padding: "4px 0", width: "100%", outline: "none", color: "var(--text)" }}
/>
<div style={{ fontSize: 12, color: "var(--text4)", marginTop: 6 }}>
{p.nummer}
{p.type && <span> · {p.type}</span>}
{(() => { const proj = data.projects.find(x => x.id === p.projectId); return proj?.number ? <span style={{ color: "#b07848", marginLeft: 8, fontWeight: 600 }}>{proj.number}</span> : null; })()}
</div>
</div>
<div style={{ display: "flex", gap: 8, flexShrink: 0 }}>
<button className="btn btn-ghost" onClick={async () => { if (await askConfirm("Protokoll löschen?")) onDelete(p.id); }}>Löschen</button>
<button className="btn btn-ghost" onClick={() => setShowFolge(true)} title="Neue Sitzung auf Basis dieses Protokolls erstellen"> Folgesitzung</button>
<button className="btn btn-ghost" onClick={() => setPrintContent({ type: "protokoll", protokoll: p, data, settings: data.settings })}>PDF</button>
<button className="btn btn-primary" onClick={save} style={isDirty ? { background: "#2d6a4f" } : {}}>
{isDirty ? "Speichern ●" : "Gespeichert"}
</button>
</div>
</div>
<div style={{ display: "grid", gridTemplateColumns: "1fr 340px", gap: 20, alignItems: "start" }}>
{/* ── LINKE SPALTE: Metadaten + Traktanden ── */}
<div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
{/* Kopfdaten */}
<div className="card">
<div style={{ fontSize: 11, letterSpacing: "0.1em", color: "var(--text4)", marginBottom: 14 }}>SITZUNGSDETAILS</div>
<div className="form-row">
<FormField label="Typ">
<select value={p.type} onChange={e => setField("type", e.target.value)}>
{PROTOKOLL_TYPES.map(t => <option key={t} value={t}>{t}</option>)}
</select>
</FormField>
<FormField label="Datum">
<DateInput value={p.date} onChange={e => setField("date", e.target.value)} />
</FormField>
<FormField label="Von">
<input type="time" value={p.time || ""} onChange={e => setField("time", e.target.value)} />
</FormField>
<FormField label="Bis">
<input type="time" value={p.endTime || ""} onChange={e => setField("endTime", e.target.value)} />
</FormField>
</div>
<div className="form-row">
<FormField label="Ort / Raum">
<input value={p.location || ""} onChange={e => setField("location", e.target.value)} placeholder="z.B. Büro Studio, Baustelle…" />
</FormField>
<FormField label="Projekt">
<select value={p.projectId || ""} onChange={e => setField("projectId", e.target.value)}>
<option value=""> kein Projekt </option>
{data.projects.map(x => <option key={x.id} value={x.id}>{x.name}</option>)}
</select>
</FormField>
</div>
{!p.projectId && (
<FormField label="Projekt (manuell)">
<input value={p.projectManual || ""} onChange={e => setField("projectManual", e.target.value)} placeholder="Projektbezeichnung" />
</FormField>
)}
<div className="form-row">
<FormField label="Nächste Sitzung">
<DateInput value={p.nextDate || ""} onChange={e => setField("nextDate", e.target.value)} />
</FormField>
<FormField label="Verteiler">
<input value={p.verteiler || ""} onChange={e => setField("verteiler", e.target.value)} placeholder="z.B. alle TN, Archiv…" />
</FormField>
</div>
</div>
{/* Traktanden draggable */}
{(p.traktanden || []).map((t, ti) => {
const isDragTarget = dragOverTraktandum === ti;
return (
<div
key={t.id}
onMouseEnter={() => enterTraktandum(ti)}
className="card"
style={{
borderLeft: "4px solid #b07848",
outline: isDragTarget ? "2px dashed #b07848" : "none",
outlineOffset: 2,
opacity: draggingTraktandum === ti ? 0.5 : 1,
transition: "outline 0.1s",
}}
>
{/* Traktandum-Header */}
<div style={{ display: "flex", gap: 10, alignItems: "center", marginBottom: 12 }}>
{/* Drag handle */}
<div
title="Verschieben"
style={{ cursor: "grab", color: "var(--text4)", fontSize: 14, flexShrink: 0, userSelect: "none", paddingTop: 2 }}
onMouseDown={e => { e.preventDefault(); startDragTraktandum(ti); }}
></div>
<input value={t.nr} onChange={e => setTraktandum(t.id, { nr: e.target.value })}
style={{ width: 48, height: 32, fontSize: 13, fontWeight: 700, textAlign: "center", background: "#b07848", color: "#1a1a18", border: "none", borderRadius: 4 }} />
<input value={t.title} onChange={e => setTraktandum(t.id, { title: e.target.value })}
placeholder="Traktandentitel…"
style={{ flex: 1, height: 32, fontSize: 14, fontWeight: 500, background: "none", border: "none", borderBottom: "1.5px solid var(--border)", borderRadius: 0, outline: "none", color: "var(--text)" }} />
{(p.traktanden || []).length > 1 && (
<button onClick={() => removeTraktandum(t.id)}
style={{ background: "none", border: "none", color: "var(--text4)", cursor: "pointer", fontSize: 16, padding: 0 }}><span className="material-icons" style={{ fontSize: 16 }}>close</span></button>
)}
</div>
{/* Items */}
{(t.items || []).map((item, ii) => {
const isItemTarget = dragOverItem?.tId === t.id && dragOverItem?.idx === ii;
return (
<div
key={item.id}
onMouseEnter={() => enterItem(t.id, ii)}
style={{
outline: isItemTarget ? "2px dashed #b07848" : "none",
outlineOffset: 1,
borderRadius: 4,
opacity: draggingItem?.tId === t.id && draggingItem?.idx === ii ? 0.4 : 1,
transition: "outline 0.1s",
}}
>
<div style={{ display: "flex", gap: 10, padding: "10px 0", borderBottom: "1px solid var(--border2)", alignItems: "flex-start" }}>
{/* Item drag handle */}
<div
title="Verschieben"
style={{ cursor: "grab", color: "var(--text4)", fontSize: 12, flexShrink: 0, marginTop: 8, userSelect: "none" }}
onMouseDown={e => { e.preventDefault(); startDragItem(t.id, ii); }}
></div>
<ItemEditor
tId={t.id}
item={item}
today={today}
onUpdate={changes => setItem(t.id, item.id, changes)}
onRemove={() => removeItem(t.id, item.id)}
/>
</div>
</div>
);
})}
{/* Item-Buttons */}
<div style={{ display: "flex", gap: 8, marginTop: 12 }}>
{[
{ type: "info", icon: "", label: "Info", color: "#1a4e8a" },
{ type: "beschluss", icon: "✓", label: "Beschluss", color: "#2d6a4f" },
{ type: "aufgabe", icon: "→", label: "Aufgabe", color: "#b5621e" },
].map(btn => (
<button key={btn.type} onClick={() => addItem(t.id, btn.type)} style={{
fontSize: 11, padding: "5px 12px", borderRadius: 4,
border: `1.5px solid ${btn.color}20`,
background: `${btn.color}10`,
color: btn.color, cursor: "pointer", fontFamily: "inherit", display: "flex", alignItems: "center", gap: 5,
}}>
{btn.icon} {btn.label}
</button>
))}
</div>
</div>
);
})}
<button className="btn btn-ghost" onClick={addTraktandum} style={{ alignSelf: "flex-start" }}>
+ Traktandum hinzufügen
</button>
</div>
{/* ── RECHTE SPALTE: Teilnehmer + Aufgaben-Zusammenfassung ── */}
<div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
{/* Teilnehmer */}
<div className="card">
<div style={{ fontSize: 11, letterSpacing: "0.1em", color: "var(--text4)", marginBottom: 14 }}>TEILNEHMER ({p.participants.length})</div>
{/* Hinzufügen */}
<div style={{ display: "flex", gap: 6, marginBottom: 12 }}>
<select defaultValue="" onChange={e => { addParticipant(e.target.value); e.target.value = ""; }}
style={{ flex: 1, height: 32, fontSize: 12 }}>
<option value="">+ Person hinzufügen</option>
{unaddedPersons.length > 0 && <optgroup label="Intern">
{unaddedPersons.filter(x => x.source === "intern").map(x => <option key={x.id} value={x.id}>{x.name}</option>)}
</optgroup>}
{unaddedPersons.filter(x => x.source === "extern").length > 0 && <optgroup label="Kunden">
{unaddedPersons.filter(x => x.source === "extern").map(x => <option key={x.id} value={x.id}>{x.name}</option>)}
</optgroup>}
</select>
<button className="btn btn-ghost" style={{ fontSize: 11, padding: "0 10px", whiteSpace: "nowrap", height: 32 }} onClick={addManualParticipant}>+ Manuell</button>
</div>
{/* Teilnehmerliste */}
{p.participants.length === 0 ? (
<div style={{ fontSize: 12, color: "var(--text4)", padding: "8px 0" }}>Noch keine Teilnehmer</div>
) : (
<div style={{ display: "flex", flexDirection: "column", gap: 4 }}>
{p.participants.map(tn => {
const sc = statusConfig[tn.status] || statusConfig.anwesend;
return (
<div key={tn.id} style={{ display: "flex", alignItems: "center", gap: 8, padding: "6px 8px", borderRadius: 6, background: sc.bg, border: `1px solid ${sc.color}30` }}>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 12, fontWeight: 500, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{tn.name}</div>
{tn.role && <div style={{ fontSize: 10, color: sc.color, opacity: 0.8 }}>{tn.role}</div>}
</div>
<select value={tn.status} onChange={e => setParticipantStatus(tn.id, e.target.value)}
style={{ height: 26, fontSize: 10, fontWeight: 600, color: sc.color, background: "transparent", border: `1px solid ${sc.color}40`, borderRadius: 3, padding: "0 4px", maxWidth: 110 }}>
{Object.entries(statusConfig).map(([k, v]) => <option key={k} value={k}>{v.label}</option>)}
</select>
<button onClick={() => removeParticipant(tn.id)}
style={{ background: "none", border: "none", color: "var(--text4)", cursor: "pointer", fontSize: 14, padding: 0, flexShrink: 0 }}><span className="material-icons" style={{ fontSize: 16 }}>close</span></button>
</div>
);
})}
</div>
)}
{/* Anwesenheits-Statistik */}
{p.participants.length > 0 && (
<div style={{ marginTop: 12, paddingTop: 10, borderTop: "1px solid var(--border2)", display: "flex", gap: 12, fontSize: 11 }}>
{Object.entries(statusConfig).map(([k, v]) => {
const count = p.participants.filter(x => x.status === k).length;
return count > 0 ? (
<div key={k} style={{ color: v.color, fontWeight: 600 }}>{count} {v.label}</div>
) : null;
})}
</div>
)}
</div>
{/* Aufgaben-Überblick */}
{(() => {
const tasks = (p.traktanden || []).flatMap(t => (t.items || []).filter(it => it.type === "aufgabe"));
if (tasks.length === 0) return null;
const offen = tasks.filter(t => t.status !== "erledigt");
const erledigt = tasks.filter(t => t.status === "erledigt");
return (
<div className="card">
<div style={{ fontSize: 11, letterSpacing: "0.1em", color: "var(--text4)", marginBottom: 14 }}>
AUFGABEN ({tasks.length})
</div>
<div style={{ display: "flex", gap: 12, marginBottom: 12, fontSize: 12 }}>
<div style={{ color: "#b5621e", fontWeight: 600 }}>{offen.length} offen</div>
<div style={{ color: "#2d6a4f", fontWeight: 600 }}>{erledigt.length} erledigt</div>
</div>
<div style={{ height: 6, background: "var(--border)", borderRadius: 3, overflow: "hidden", marginBottom: 14 }}>
<div style={{ width: `${tasks.length > 0 ? (erledigt.length / tasks.length) * 100 : 0}%`, height: "100%", background: "#2d6a4f", borderRadius: 3 }} />
</div>
{offen.map(t => {
const due = t.dueDateType === "kw" ? (t.dueKW ? `KW ${t.dueKW}/${t.dueYear || new Date().getFullYear()}` : "—") : t.dueDate ? formatDate(t.dueDate) : "—";
const isLate = t.dueDate && t.dueDateType === "datum" && t.dueDate < today;
return (
<div key={t.id} style={{ padding: "6px 0", borderBottom: "1px solid var(--border2)", fontSize: 12 }}>
<div style={{ fontWeight: 500, color: isLate ? "#8a1a1a" : "var(--text)" }}>{t.text || "—"}</div>
<div style={{ fontSize: 10, color: "var(--text4)", marginTop: 2, display: "flex", gap: 8 }}>
{t.responsible && <span> {t.responsible}</span>}
<span style={{ color: isLate ? "#8a1a1a" : "var(--text4)" }}>{due}</span>
</div>
</div>
);
})}
</div>
);
})()}
{/* Beschlüsse-Überblick */}
{(() => {
const beschluesse = (p.traktanden || []).flatMap(t => (t.items || []).filter(it => it.type === "beschluss"));
if (beschluesse.length === 0) return null;
return (
<div className="card">
<div style={{ fontSize: 11, letterSpacing: "0.1em", color: "var(--text4)", marginBottom: 14 }}>BESCHLÜSSE ({beschluesse.length})</div>
{beschluesse.map(b => (
<div key={b.id} style={{ padding: "6px 0", borderBottom: "1px solid var(--border2)", fontSize: 12 }}>
<div style={{ fontWeight: 500 }}>{b.text || "—"}</div>
<div style={{ fontSize: 10, color: "var(--text4)", marginTop: 2 }}>{b.date ? formatDate(b.date) : "—"}</div>
</div>
))}
</div>
);
})()}
</div>
</div>
{/* ── Folgesitzung-Dialog ── */}
{showFolge && (() => {
// Initialisiere Auswahl beim ersten Öffnen
if (!folgeSelection) {
const sel = (p.traktanden || []).map(t => ({
tId: t.id,
tTitle: t.title,
tNr: t.nr,
include: true,
items: (t.items || []).map(it => ({
id: it.id,
type: it.type,
text: it.text,
// Aufgaben: offen/in Arbeit → automatisch übernommen; erledigt → nicht
include: it.type !== "aufgabe" ? false : (it.status || "offen") !== "erledigt",
isErledigt: it.type === "aufgabe" && (it.status || "offen") === "erledigt",
original: it,
})),
}));
setFolgeSelection(sel);
return null;
}
const toggleTraktandum = (tId) => setFolgeSelection(prev => prev.map(t => t.tId === tId ? { ...t, include: !t.include } : t));
const toggleItem = (tId, iId) => setFolgeSelection(prev => prev.map(t => t.tId === tId ? { ...t, items: t.items.map(it => it.id === iId ? { ...it, include: !it.include } : it) } : t));
const createFolge = () => {
const allProts = data.protocols || [];
const abbr = data.settings.protokollTypeAbbreviations || {};
const typKuerzel = abbr[p.type] || "SO";
const folgeDate = p.nextDate || new Date().toISOString().slice(0, 10);
const proj = data.projects.find(x => x.id === p.projectId);
const seq = nextProtoSeq(allProts);
const newNummer = applyProtoNumberFormat(data.settings.protokollNumberFormat || "YYYY-TT-NN", {
date: folgeDate, projectNumber: proj?.number || "", seq, typKuerzel,
});
const newTraktanden = folgeSelection
.filter(t => t.include)
.map(t => ({
id: generateId(),
nr: t.tNr,
title: t.tTitle,
items: t.items
.filter(it => it.include)
.map(it => ({ ...it.original, id: generateId(), status: it.original.type === "aufgabe" ? (it.original.status === "erledigt" ? "erledigt" : it.original.status) : it.original.status })),
}));
if (newTraktanden.length === 0) newTraktanden.push({ id: generateId(), nr: "1", title: "", items: [] });
const folge = {
id: generateId(),
title: "",
type: p.type,
date: p.nextDate || new Date().toISOString().slice(0, 10),
time: p.time || "10:00",
endTime: p.endTime || "",
location: p.location || "",
projectId: p.projectId,
projectManual: p.projectManual || "",
nummer: newNummer,
participants: (p.participants || []).map(pt => ({ ...pt, status: "eingeladen" })),
traktanden: newTraktanden,
vorgaenger: p.id,
nextDate: "",
verteiler: p.verteiler || "",
createdAt: new Date().toISOString(),
};
const updated = { ...data, protocols: [...allProts, folge] };
// Save and navigate to new protokoll
if (typeof saveAll === "function") saveAll(updated);
setShowFolge(false);
setFolgeSelection(null);
onSave(p); // save current first
setTimeout(() => {
window.__openProtokoll = folge.id;
window.dispatchEvent(new CustomEvent("openProtokoll", { detail: { id: folge.id } }));
}, 100);
};
const typeIcons = { info: "", beschluss: "✅", aufgabe: "📌" };
const typeColors = { info: "#1a4e8a", beschluss: "#2d6a4f", aufgabe: "#b5621e" };
return (
<Modal title="Folgesitzung erstellen" onClose={() => { setShowFolge(false); setFolgeSelection(null); }} onSave={createFolge} saveLabel="Folgesitzung erstellen" wide>
<div style={{ fontSize: 13, color: "var(--text3)", marginBottom: 16 }}>
Wähle welche Traktanden und Punkte übernommen werden sollen.
<span style={{ color: "#2d6a4f", marginLeft: 8, fontSize: 12 }}> Offene Aufgaben sind vorausgewählt</span>
</div>
{folgeSelection.map(t => (
<div key={t.tId} style={{ marginBottom: 12, border: "1px solid var(--border)", borderRadius: 6, overflow: "hidden", opacity: t.include ? 1 : 0.5 }}>
{/* Traktandum-Header */}
<div style={{ display: "flex", alignItems: "center", gap: 10, padding: "10px 14px", background: t.include ? "#fffbe6" : "var(--surface2)", borderBottom: t.items.length > 0 ? "1px solid var(--border2)" : "none", cursor: "pointer" }}
onClick={() => toggleTraktandum(t.tId)}>
<input type="checkbox" checked={t.include} onChange={() => toggleTraktandum(t.tId)} onClick={e => e.stopPropagation()} style={{ width: "auto", flexShrink: 0 }} />
<span style={{ fontSize: 12, fontWeight: 700, color: "#b5861e", marginRight: 4 }}>{t.tNr}</span>
<span style={{ fontSize: 13, fontWeight: 500 }}>{t.tTitle || <span style={{ color: "var(--text4)", fontStyle: "italic" }}>Kein Titel</span>}</span>
<span style={{ fontSize: 11, color: "var(--text4)", marginLeft: "auto" }}>{t.items.length} Punkte</span>
</div>
{/* Items */}
{t.items.map(it => (
<div key={it.id} style={{
display: "flex", alignItems: "flex-start", gap: 10, padding: "8px 14px 8px 28px",
borderBottom: "1px solid var(--border2)",
background: it.isErledigt ? (it.include ? "#e8f5ee" : "var(--surface2)") : "transparent",
opacity: (!t.include || !it.include) ? 0.45 : 1,
}}>
<input type="checkbox" checked={it.include && t.include} disabled={!t.include}
onChange={() => toggleItem(t.tId, it.id)} style={{ width: "auto", flexShrink: 0, marginTop: 3 }} />
<span style={{ fontSize: 12, marginTop: 1, flexShrink: 0 }}>{typeIcons[it.type]}</span>
<div style={{ flex: 1 }}>
<span style={{ fontSize: 12 }}>{it.text || <span style={{ color: "var(--text4)", fontStyle: "italic" }}>Leer</span>}</span>
{it.isErledigt && <span style={{ fontSize: 10, color: "#2d6a4f", marginLeft: 8, fontWeight: 600 }}> erledigt</span>}
{it.original?.responsible && <span style={{ fontSize: 10, color: "var(--text4)", marginLeft: 8 }}> {it.original.responsible}</span>}
</div>
</div>
))}
</div>
))}
<div style={{ marginTop: 12, padding: "10px 14px", background: "var(--surface2)", borderRadius: 6, fontSize: 12, color: "var(--text4)" }}>
Alle Teilnehmer werden übernommen mit Status «Eingeladen». Datum wird auf «Nächste Sitzung» ({p.nextDate ? formatDate(p.nextDate) : "nicht gesetzt"}) gesetzt.
</div>
</Modal>
);
})()}
</div>
);
}
// ─── LIEFERSCHEINE ──────────────────────────────────────────────────