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>
979 lines
52 KiB
React
Executable File
979 lines
52 KiB
React
Executable File
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 ──────────────────────────────────────────────────
|