Rapport 0.6 — Initial Public Release
Sicherheits-Hardening - Passwort-Hashing mit PBKDF2 (SHA-256, 100k Iterationen) inkl. transparenter Migration bestehender Klartext-Passwörter beim ersten Login - Login Brute-Force-Schutz (5 Fehlversuche → 60s Lockout), Constant-Time-Compare, Mindestpasswortlänge 8 Zeichen - HTML-Sanitizer für Brieftexte (Allowlist, entfernt javascript:/data:/vbscript:-URLs, Event-Handler, Script-Tags; rel=noopener für target=_blank) - Datenexport entfernt Legacy-Klartextpasswörter (Hashes bleiben) - Kryptografische IDs via crypto.randomUUID statt Math.random - sessionStorage speichert keine Credentials mehr GUI & Performance - Code-Splitting pro View via React.lazy + Suspense (Initial-Bundle 86 KB gzipped) - swissqrbill als lokale Dependency — QR-Rechnungen offline-fähig - Spesenbelege (Bild/PDF) direkt in der Tageserfassung mit Bildkomprimierung - Avatar-Upload: 256px-Skalierung + JPEG-Kompression, Typprüfung - Über-Rapport-Modal, einheitliche Bearbeiten-Icons, Pinnwand-Kategorien als Pills Bug-Fixes - Auto-überfällig-Routine läuft nur noch einmal pro Tag (verhindert Re-Render-Loop) Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Executable
+978
@@ -0,0 +1,978 @@
|
||||
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 ──────────────────────────────────────────────────
|
||||
Reference in New Issue
Block a user