Files
RAPPORT/src/views/Projects.jsx
T
karim 00f07d76f6 Rapport 0.6 — Initial Public Release
Sicherheits-Hardening
- Passwort-Hashing mit PBKDF2 (SHA-256, 100k Iterationen) inkl. transparenter
  Migration bestehender Klartext-Passwörter beim ersten Login
- Login Brute-Force-Schutz (5 Fehlversuche → 60s Lockout), Constant-Time-Compare,
  Mindestpasswortlänge 8 Zeichen
- HTML-Sanitizer für Brieftexte (Allowlist, entfernt javascript:/data:/vbscript:-URLs,
  Event-Handler, Script-Tags; rel=noopener für target=_blank)
- Datenexport entfernt Legacy-Klartextpasswörter (Hashes bleiben)
- Kryptografische IDs via crypto.randomUUID statt Math.random
- sessionStorage speichert keine Credentials mehr

GUI & Performance
- Code-Splitting pro View via React.lazy + Suspense (Initial-Bundle 86 KB gzipped)
- swissqrbill als lokale Dependency — QR-Rechnungen offline-fähig
- Spesenbelege (Bild/PDF) direkt in der Tageserfassung mit Bildkomprimierung
- Avatar-Upload: 256px-Skalierung + JPEG-Kompression, Typprüfung
- Über-Rapport-Modal, einheitliche Bearbeiten-Icons, Pinnwand-Kategorien als Pills

Bug-Fixes
- Auto-überfällig-Routine läuft nur noch einmal pro Tag (verhindert Re-Render-Loop)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-13 01:16:26 +02:00

1782 lines
116 KiB
React
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { useState } from "react";
import { SIA_PHASES, PROJECT_TYPES, PROTOKOLL_TYPES } from "../constants.js";
const NON_BILLING_CATEGORIES = ["Wettbewerb"];
import { calcSIAHours, calcManualHours, deriveQuoteBudget, migrateLinkedQuotes, generateId, formatCHF, formatDate, formatHours, applyProjectNumberFormat, parseSeqFromNumber, nextProtoSeq, applyProtoNumberFormat } from "../utils.js";
import { Header, Modal, FormField, StatusBadge, useConfirm , DateInput } from "../components/UI.jsx";
function ProjectEditForm({ form, setForm, data }) {
const [newPhaseLabel, setNewPhaseLabel] = useState("");
const togglePhase = (phaseId) => {
setForm(prev => {
const phases = prev.enabledPhases || [];
return { ...prev, enabledPhases: phases.includes(phaseId) ? phases.filter(p => p !== phaseId) : [...phases, phaseId] };
});
};
return (
<>
<div className="form-row">
<FormField label="Projektnummer">
<input value={form.number} onChange={e => setForm({ ...form, number: e.target.value })} placeholder={`z.B. ${new Date().getFullYear()}/01`} style={{ maxWidth: 140 }} />
</FormField>
<FormField label="Projektname *" style={{ flex: 3 }}>
<input value={form.name} onChange={e => setForm({ ...form, name: e.target.value })} placeholder="z.B. Wohnhaus Müller" autoFocus style={!form.name?.trim() ? { borderColor: "#b5621e" } : {}} />
</FormField>
</div>
<div className="form-row">
<FormField label="Kategorie">
<select value={form.category} onChange={e => setForm({ ...form, category: e.target.value })}>
{PROJECT_TYPES.map(t => <option key={t} value={t}>{t}</option>)}
</select>
</FormField>
<FormField label="Auftraggeber">
<select value={form.clientId} onChange={e => setForm({ ...form, clientId: e.target.value })}>
<option value=""> kein Auftraggeber </option>
{(data.persons||[]).filter(p=>p.isAuftraggeber).map(c => <option key={c.id} value={c.id}>{c.name}</option>)}
</select>
</FormField>
<FormField label="Status">
<select value={form.status} onChange={e => setForm({ ...form, status: e.target.value })}>
{["aktiv", "pausiert", "abgeschlossen"].map(s => <option key={s}>{s}</option>)}
</select>
</FormField>
</div>
<div className="form-row">
{NON_BILLING_CATEGORIES.includes(form.category) ? (
<div style={{ flex: 1, display: "flex", alignItems: "center", gap: 8, padding: "8px 12px", background: "#faf8f5", borderRadius: 6, border: "1px solid #e0dbd4", fontSize: 12, color: "#888" }}>
<span style={{ fontSize: 16 }}></span>
<span><strong style={{ color: "#1a1a18" }}>Nicht verrechenbar</strong> Stunden werden erfasst, aber nicht fakturiert.</span>
</div>
) : (
<>
<FormField label="Abrechnungstyp">
<select value={form.billingType} onChange={e => setForm({ ...form, billingType: e.target.value })}>
<option value="stundensatz">Stundensatz</option>
<option value="pauschal">Pauschal</option>
</select>
</FormField>
{form.billingType === "stundensatz"
? <FormField label="Stundensatz (CHF)"><input type="number" value={form.hourlyRate} onChange={e => setForm({ ...form, hourlyRate: +e.target.value })} /></FormField>
: <FormField label="Pauschalpreis (CHF)"><input type="number" value={form.budget} onChange={e => setForm({ ...form, budget: +e.target.value })} /></FormField>}
</>
)}
<FormField label="Startdatum"><DateInput value={form.startDate} onChange={e => setForm({ ...form, startDate: e.target.value })} /></FormField>
</div>
<div className="form-row">
<FormField label="Stundenbudget (Soll-Stunden)">
<input type="number" step="0.5" min={0} value={form.budgetHours || 0}
onChange={e => setForm({ ...form, budgetHours: +e.target.value, linkedQuotes: [], phasesBudget: [] })}
placeholder="0 = aus Offerten berechnet" />
</FormField>
</div>
{(() => {
const linked = form.linkedQuotes || [];
const ROLES = ["Hauptofferte", "Nachtrag", "Referenz"];
const buildPositions = (newLinked, existingPositions) => {
const manual = (existingPositions || []).filter(p => !p.quoteId);
const nachtraege = newLinked.filter(lq => lq.role === "Nachtrag").map((lq, idx) => {
const existing = (existingPositions || []).find(p => p.quoteId === lq.quoteId);
const q = (data.quotes || []).find(x => x.id === lq.quoteId);
const sd = deriveQuoteBudget([lq], data.quotes || [], data.settings.roles || []);
return { code: existing?.code || `N${idx + 1}`, label: existing !== undefined ? existing.label : (q?.projectName || q?.number || ""), enabledPhases: existing?.enabledPhases || sd.enabledPhases, quoteId: lq.quoteId };
});
return [...manual, ...nachtraege];
};
const addQuote = (quoteId) => {
if (!quoteId || linked.some(lq => lq.quoteId === quoteId)) return;
const isFirst = linked.length === 0;
const newLinked = [...linked, { quoteId, role: isFirst ? "Hauptofferte" : "Nachtrag" }];
const d = deriveQuoteBudget(newLinked, data.quotes || [], data.settings.roles || []);
const q = (data.quotes || []).find(x => x.id === quoteId);
setForm(f => ({
...f, linkedQuotes: newLinked, budgetHours: d.budgetHours, budgetAmount: d.budgetAmount, phasesBudget: d.phasesBudget,
enabledPhases: isFirst ? [...new Set([...(f.enabledPhases || []), ...d.enabledPhases])] : (f.enabledPhases || []),
billingType: isFirst ? (q?.mode === "manual" ? "stundensatz" : "pauschal") : f.billingType,
positions: buildPositions(newLinked, f.positions || []),
}));
};
const removeQuote = (quoteId) => {
const newLinked = linked.filter(lq => lq.quoteId !== quoteId);
const d = newLinked.length > 0 ? deriveQuoteBudget(newLinked, data.quotes || [], data.settings.roles || []) : { budgetHours: 0, budgetAmount: 0, phasesBudget: [] };
setForm(f => ({ ...f, linkedQuotes: newLinked, budgetHours: d.budgetHours, budgetAmount: d.budgetAmount, phasesBudget: d.phasesBudget, positions: buildPositions(newLinked, (f.positions || []).filter(p => p.quoteId !== quoteId)) }));
};
const changeRole = (quoteId, role) => setForm(f => {
const newLinked = f.linkedQuotes.map(lq => lq.quoteId === quoteId ? { ...lq, role } : lq);
return { ...f, linkedQuotes: newLinked, positions: buildPositions(newLinked, f.positions || []) };
});
const updateNachtragPos = (quoteId, updates) => setForm(f => ({ ...f, positions: (f.positions || []).map(p => p.quoteId === quoteId ? { ...p, ...updates } : p) }));
const toggleHOPhase = (phId) => setForm(f => { const phases = f.enabledPhases || []; return { ...f, enabledPhases: phases.includes(phId) ? phases.filter(p => p !== phId) : [...phases, phId] }; });
const toggleNTPhase = (quoteId, phId) => setForm(f => ({ ...f, positions: (f.positions || []).map(p => { if (p.quoteId !== quoteId) return p; const phases = p.enabledPhases || []; return { ...p, enabledPhases: phases.includes(phId) ? phases.filter(x => x !== phId) : [...phases, phId] }; }) }));
const alreadyInOtherProjects = new Set((data.projects || []).filter(p => p.id !== form.id).flatMap(p => migrateLinkedQuotes(p).map(lq => lq.quoteId)));
const unlinkedQuotes = (data.quotes || []).filter(q => !linked.some(lq => lq.quoteId === q.id) && !alreadyInOtherProjects.has(q.id));
const sortedLinked = [...linked.filter(lq => lq.role === "Hauptofferte"), ...linked.filter(lq => lq.role !== "Hauptofferte")];
return (
<div style={{ marginBottom: 14 }}>
<div style={{ fontSize: 11, fontWeight: 600, letterSpacing: "0.08em", color: "#888", marginBottom: 10 }}>VERKNÜPFTE HONORAROFFERTEN</div>
{sortedLinked.map(lq => {
const q = (data.quotes || []).find(x => x.id === lq.quoteId);
if (!q) return null;
const isNT = lq.role === "Nachtrag";
const pos = isNT ? (form.positions || []).find(p => p.quoteId === lq.quoteId) : null;
const currentPhases = isNT ? (pos?.enabledPhases || []) : (form.enabledPhases || []);
const qRoles = q.quoteRoles || data.settings.roles || [];
const qH = q.mode === "sia" ? (calcSIAHours(q.sia?.baukosten, q.sia?.schwierigkeit, q.sia?.phases || []).total || 0) : q.mode === "manual" ? (calcManualHours(q.manualPhases || [], qRoles).totalHours || 0) : 0;
const bgColor = isNT ? "#f0f5fd" : "#faf8f5";
const borderColor = isNT ? "#c8d8ee" : "#ddd8d0";
const accentColor = isNT ? "#1a4e8a" : "#2d6a4f";
return (
<div key={lq.quoteId} style={{ marginBottom: 10, border: `1px solid ${borderColor}`, borderRadius: 8, overflow: "hidden" }}>
<div style={{ display: "flex", alignItems: "center", gap: 8, padding: "10px 12px", background: bgColor, flexWrap: "wrap" }}>
{isNT && pos ? (
<>
<input value={pos.code || ""} onChange={e => updateNachtragPos(lq.quoteId, { code: e.target.value.toUpperCase().replace(/\s/g, "").slice(0, 6) })} placeholder="N1" style={{ width: 52, fontWeight: 700, fontSize: 12, height: 26, padding: "0 6px" }} />
<input value={pos.label || ""} onChange={e => updateNachtragPos(lq.quoteId, { label: e.target.value })} placeholder="z.B. Nachtrag Fassade" style={{ flex: 1, minWidth: 100, fontSize: 12, height: 26, padding: "0 6px" }} />
</>
) : (
<span style={{ fontSize: 10, fontWeight: 700, background: "#e8f5ee", color: accentColor, padding: "2px 8px", borderRadius: 3 }}> HO</span>
)}
<span style={{ fontWeight: 600, color: "#b07848", fontSize: 12 }}>{q.number}</span>
<span style={{ color: "#888", fontSize: 11 }}>{q.mode === "sia" ? "SIA 102" : q.mode === "manual" ? "Aufwand" : "Pauschal"}</span>
{qH > 0 && <span style={{ color: "#555", fontSize: 11 }}>{qH.toFixed(1)}h</span>}
<div style={{ marginLeft: "auto", display: "flex", alignItems: "center", gap: 6 }}>
<select value={lq.role} onChange={e => changeRole(lq.quoteId, e.target.value)} style={{ fontSize: 11, height: 26, padding: "0 6px" }}>
{ROLES.map(r => <option key={r}>{r}</option>)}
</select>
<button className="btn btn-danger" style={{ padding: "0 6px", height: 24, fontSize: 10 }} onClick={() => removeQuote(lq.quoteId)}><span className="material-icons" style={{ fontSize: 16 }}>close</span></button>
</div>
</div>
<div style={{ padding: "10px 12px", background: "#fff" }}>
<div style={{ fontSize: 10, color: "#888", marginBottom: 6, letterSpacing: "0.07em" }}>AKTIVIERTE SIA-PHASEN FÜR ZEITERFASSUNG</div>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 3 }}>
{SIA_PHASES.map(ph => (
<label key={ph.id} style={{ display: "flex", alignItems: "center", gap: 6, cursor: "pointer", fontSize: 11, textTransform: "none", color: "#1a1a18" }}>
<input type="checkbox" checked={currentPhases.includes(ph.id)} onChange={() => isNT ? toggleNTPhase(lq.quoteId, ph.id) : toggleHOPhase(ph.id)} style={{ width: "auto" }} />
{ph.label}
</label>
))}
</div>
</div>
</div>
);
})}
{linked.length === 0 && <div style={{ fontSize: 11, color: "#aaa", marginBottom: 8 }}>Noch keine Offerte verknüpft.</div>}
{unlinkedQuotes.length > 0 && (
<select defaultValue="" onChange={e => { addQuote(e.target.value); e.target.value = ""; }} style={{ fontSize: 12, width: "100%" }}>
<option value="">{linked.length === 0 ? "Offerte verknüpfen…" : "+ Nachtrag / weitere Offerte hinzufügen…"}</option>
{unlinkedQuotes.map(q => <option key={q.id} value={q.id}>{q.number} {q.mode === "sia" ? "SIA 102" : q.mode === "manual" ? "Aufwand" : "Pauschal"} · {formatCHF(q.total)}</option>)}
</select>
)}
</div>
);
})()}
<FormField label="Beschreibung"><textarea rows={2} value={form.description} onChange={e => setForm({ ...form, description: e.target.value })} style={{ resize: "vertical" }} /></FormField>
<div className="form-group" style={{ marginTop: 6 }}>
<label>Eigene Phasen <span style={{ fontWeight: 400, fontSize: 11, color: "#aaa" }}>(z.B. für Wettbewerbe)</span></label>
<div style={{ padding: "8px 10px", background: "#faf8f5", borderRadius: 6, border: "1px solid #ece8e2" }}>
{(form.customPhases || []).map(cp => (
<div key={cp.id} style={{ display: "flex", justifyContent: "space-between", alignItems: "center", padding: "3px 0", borderBottom: "1px solid #ece8e2" }}>
<span style={{ fontSize: 12 }}>{cp.label}</span>
<button onClick={() => setForm(f => ({ ...f, customPhases: (f.customPhases || []).filter(c => c.id !== cp.id) }))} style={{ background: "none", border: "none", color: "#bbb", cursor: "pointer", fontSize: 15, padding: "0 2px", lineHeight: 1 }}>×</button>
</div>
))}
<div style={{ display: "flex", gap: 6, alignItems: "center", marginTop: (form.customPhases || []).length > 0 ? 7 : 0 }}>
<input value={newPhaseLabel} onChange={e => setNewPhaseLabel(e.target.value)} placeholder="z.B. Visualisierung, Modell, Präsentation…" style={{ flex: 1, height: 28, fontSize: 12 }}
onKeyDown={e => { if (e.key === "Enter" && newPhaseLabel.trim()) { setForm(f => ({ ...f, customPhases: [...(f.customPhases || []), { id: "cp_" + generateId(), label: newPhaseLabel.trim() }] })); setNewPhaseLabel(""); }}} />
<button className="btn btn-ghost" style={{ height: 28, padding: "0 10px", fontSize: 11, whiteSpace: "nowrap" }}
onClick={() => { if (!newPhaseLabel.trim()) return; setForm(f => ({ ...f, customPhases: [...(f.customPhases || []), { id: "cp_" + generateId(), label: newPhaseLabel.trim() }] })); setNewPhaseLabel(""); }}>+ Hinzufügen</button>
</div>
</div>
</div>
{(form.linkedQuotes || []).length === 0 && (
<div className="form-group" style={{ marginTop: 6 }}>
<label>Relevante SIA-Phasen (nur angewählte sind bei der Zeiterfassung verfügbar)</label>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 6, marginTop: 6, padding: 12, background: "#faf8f5", borderRadius: 6, border: "1px solid #ece8e2" }}>
{SIA_PHASES.map(ph => (
<label key={ph.id} style={{ display: "flex", alignItems: "center", gap: 8, cursor: "pointer", fontSize: 12, textTransform: "none", color: "#1a1a18", padding: "4px 0" }}>
<input type="checkbox" checked={(form.enabledPhases || []).includes(ph.id)} onChange={() => togglePhase(ph.id)} style={{ width: "auto" }} />
{ph.label}
</label>
))}
</div>
<div style={{ fontSize: 10, color: "#aaa", marginTop: 4 }}>Leer lassen, wenn keine Phasen-Unterteilung gewünscht.</div>
</div>
)}
</>
);
}
export
function Projects({ data, update, saveAll, modal, setModal, onSelect, setPrintContent, currentUser }) {
const canSeeQuotes = !currentUser || currentUser.role === "admin" || !currentUser.permissions || currentUser.permissions.includes("quotes");
const canSeeInvoices = !currentUser || currentUser.role === "admin" || !currentUser.permissions || currentUser.permissions.includes("invoices");
const clients = (data.persons || []).filter(p => p.isAuftraggeber);
const emptyForm = { number: "", name: "", clientId: "", category: "Direktauftrag", billingType: "stundensatz", hourlyRate: data.settings.defaultHourlyRate, budget: 0, status: "aktiv", description: "", startDate: "", enabledPhases: [], budgetHours: 0, linkedQuotes: [], positions: [], customPhases: [], projectContacts: [], internalMembers: [] };
const [form, setForm] = useState(emptyForm);
const [filter, setFilter] = useState(() => { const cid = window.__navClientId || ""; window.__navClientId = null; return { category: "", status: "", search: "", clientId: cid }; });
const [groupBy, setGroupBy] = useState("status");
const [compact, setCompact] = useState(true);
const { askConfirm, ConfirmModalEl } = useConfirm();
const [showAddBeteiligter, setShowAddBeteiligter] = useState(false);
const [editingContactId, setEditingContactId] = useState(null);
const [addContactId, setAddContactId] = useState("");
const [addPersonIds, setAddPersonIds] = useState([]);
const nextProjectNumber = () => {
const fmt = data.settings.projectNumberFormat || "YYYY/NN";
const currentYear = new Date().getFullYear();
const lastSeq = data.settings.lastProjectYear === currentYear ? (data.settings.lastProjectSeq || 0) : 0;
return applyProjectNumberFormat(fmt, lastSeq >= 99 ? 1 : lastSeq + 1);
};
const saveProject = () => {
if (!form.name?.trim()) { alert("Bitte einen Projektnamen eingeben."); return; }
const isNew = !modal?.id;
const projects = isNew
? [...data.projects, { ...form, id: generateId(), createdAt: new Date().toISOString() }]
: data.projects.map(p => p.id === modal.id ? { ...p, ...form } : p);
let settings = data.settings;
if (isNew) {
const seq = parseSeqFromNumber(form.number, data.settings.projectNumberFormat || "YYYY/NN");
if (seq !== null) settings = { ...data.settings, lastProjectSeq: seq, lastProjectYear: new Date().getFullYear() };
}
saveAll({ ...data, projects, settings });
setModal(null);
};
const resetBeteiligter = () => { setShowAddBeteiligter(false); setEditingContactId(null); setAddContactId(""); setAddPersonIds([]); };
const openEdit = (p, e) => { e?.stopPropagation(); setForm({ ...emptyForm, ...p, enabledPhases: p.enabledPhases || [], projectContacts: p.projectContacts || [] }); resetBeteiligter(); setModal({ type: "project", id: p.id }); };
const openNew = () => { setForm({ ...emptyForm, number: nextProjectNumber(), startDate: new Date().toISOString().slice(0, 10) }); resetBeteiligter(); setModal({ type: "project" }); };
const del = async (id, e) => { e?.stopPropagation(); if (await askConfirm("Projekt wirklich löschen?")) update("projects", data.projects.filter(p => p.id !== id)); };
// Projekt aus Offerte erstellen
const createFromQuote = (quote) => {
const linkedQuotes = [{ quoteId: quote.id, role: "Hauptofferte" }];
const derived = deriveQuoteBudget(linkedQuotes, data.quotes || [], data.settings.roles || []);
const newProj = {
...emptyForm,
id: generateId(),
number: nextProjectNumber(),
name: (() => { const proj = data.projects.find(p => p.id === quote.projectId); return proj?.name || "Projekt aus Offerte " + quote.number; })(),
clientId: quote.clientId || "",
startDate: new Date().toISOString().slice(0, 10),
billingType: "pauschal",
hourlyRate: quote.mode === "sia" ? (quote.sia?.stundenansatz || data.settings.defaultHourlyRate) : data.settings.defaultHourlyRate,
linkedQuotes,
budgetHours: derived.budgetHours,
phasesBudget: derived.phasesBudget,
enabledPhases: derived.enabledPhases,
createdAt: new Date().toISOString(),
};
const seq = parseSeqFromNumber(newProj.number, data.settings.projectNumberFormat || "YYYY/NN");
const newSettings = seq !== null ? { ...data.settings, lastProjectSeq: seq, lastProjectYear: new Date().getFullYear() } : data.settings;
saveAll({ ...data, projects: [...data.projects, newProj], settings: newSettings });
return newProj.id;
};
const projectMinutes = (id) => data.timeEntries.filter(e => e.projectId === id).reduce((s, e) => s + (e.minutes || 0), 0);
const [sort, setSort] = useState({ col: "number", dir: 1 });
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", whiteSpace: "nowrap", ...style }}>
{children} <span style={{ color: sort.col === col ? "#b07848" : "#ccc", fontSize: 10 }}>{sort.col === col ? (sort.dir === 1 ? "▲" : "▼") : "⇅"}</span>
</th>
);
const filtered = data.projects.filter(p => {
if (filter.clientId && p.clientId !== filter.clientId) return false;
if (filter.category && p.category !== filter.category) return false;
if (filter.status && p.status !== filter.status) return false;
if (filter.search) {
const q = filter.search.toLowerCase();
const client = clients.find(c => c.id === p.clientId);
return (p.name || "").toLowerCase().includes(q) || (p.number || "").toLowerCase().includes(q) || (client?.name || "").toLowerCase().includes(q) || (p.description || "").toLowerCase().includes(q);
}
return true;
});
const sorted = [...filtered].sort((a, b) => {
let va, vb;
if (sort.col === "number") { va = a.number || ""; vb = b.number || ""; }
else if (sort.col === "name") { va = a.name || ""; vb = b.name || ""; }
else if (sort.col === "category") { va = a.category || ""; vb = b.category || ""; }
else if (sort.col === "client") { va = (clients.find(c => c.id === a.clientId)?.name || ""); vb = (clients.find(c => c.id === b.clientId)?.name || ""); }
else if (sort.col === "hours") { va = projectMinutes(a.id); vb = projectMinutes(b.id); }
else if (sort.col === "status") { va = a.status || ""; vb = b.status || ""; }
else { va = ""; vb = ""; }
return typeof va === "number" ? (va - vb) * sort.dir : va.localeCompare(vb) * sort.dir;
});
// Gruppieren
const groupedProjects = (() => {
if (groupBy === "status") {
const groups = {};
sorted.forEach(p => { const key = p.status || "unbekannt"; if (!groups[key]) groups[key] = []; groups[key].push(p); });
const order = ["aktiv", "pausiert", "abgeschlossen", "unbekannt"];
return order.filter(k => groups[k]).map(key => ({ key, label: key.charAt(0).toUpperCase() + key.slice(1), items: groups[key] }));
}
if (groupBy === "category") {
const groups = {};
sorted.forEach(p => { const key = p.category || "—"; if (!groups[key]) groups[key] = []; groups[key].push(p); });
return Object.entries(groups).sort((a, b) => a[0].localeCompare(b[0])).map(([key, items]) => ({ key, label: key, items }));
}
if (groupBy === "client") {
const groups = {};
sorted.forEach(p => { const key = p.clientId || "__none__"; const label = clients.find(c => c.id === p.clientId)?.name || "Kein Auftraggeber"; if (!groups[key]) groups[key] = { label, items: [] }; groups[key].items.push(p); });
return Object.entries(groups).sort((a, b) => a[1].label.localeCompare(b[1].label)).map(([key, val]) => ({ key, label: val.label, items: val.items }));
}
if (groupBy === "year") {
const groups = {};
sorted.forEach(p => { const key = (p.startDate || p.createdAt || "").slice(0, 4) || "—"; if (!groups[key]) groups[key] = []; groups[key].push(p); });
return Object.entries(groups).sort((a, b) => b[0].localeCompare(a[0])).map(([key, items]) => ({ key, label: key, items }));
}
return [{ key: "all", label: "", items: sorted }];
})();
return (
<div>
{ConfirmModalEl}
<Header title="Projekte" action={
<div style={{ display: "flex", gap: 8 }}>
<button className="btn btn-ghost" onClick={() => setPrintContent({ type: "projectsOverview", projects: data.projects, data })}>Gesamt-PDF</button>
<button className="btn btn-primary" onClick={openNew}>+ Neues Projekt</button>
</div>
} />
<div className="filter-bar">
<input className="pill" value={filter.search} onChange={e => setFilter({ ...filter, search: e.target.value })} placeholder="Suchen…" style={{ minWidth: 180 }} />
<select className="pill" value={filter.clientId} onChange={e => setFilter({ ...filter, clientId: e.target.value })}>
<option value="">Alle Auftraggeber</option>
{clients.map(c => <option key={c.id} value={c.id}>{c.name}</option>)}
</select>
<select className="pill" value={filter.category} onChange={e => setFilter({ ...filter, category: e.target.value })}>
<option value="">Alle Kategorien</option>
{PROJECT_TYPES.map(t => <option key={t} value={t}>{t}</option>)}
</select>
<select className="pill" value={filter.status} onChange={e => setFilter({ ...filter, status: e.target.value })}>
<option value="">Alle Status</option>
{["aktiv", "pausiert", "abgeschlossen"].map(s => <option key={s} value={s}>{s}</option>)}
</select>
</div>
<div className="filter-bar">
<span className="filter-label">GRUPPIEREN:</span>
{[{ id: "none", label: "Keine" }, { id: "status", label: "Status" }, { id: "category", label: "Kategorie" }, { id: "client", label: "Auftraggeber" }, { id: "year", label: "Jahr" }].map(g => (
<button key={g.id} className={`pill${groupBy === g.id ? " active" : ""}`} onClick={() => setGroupBy(g.id)}>{g.label}</button>
))}
</div>
<div className="card" style={{ padding: 0 }}>
<table>
<thead><tr>
<SortTh col="number" style={{ width: 90 }}>Nr.</SortTh>
<SortTh col="name">Projekt</SortTh>
<SortTh col="category">Kategorie</SortTh>
<SortTh col="client">Kunde</SortTh>
<SortTh col="hours">Stunden</SortTh>
<SortTh col="status">Status</SortTh>
<th></th>
</tr></thead>
<tbody>
{sorted.length === 0 && <tr><td colSpan={7} style={{ textAlign: "center", color: "#aaa", padding: 32, fontSize: 13 }}>{data.projects.length === 0 ? "Noch keine Projekte" : "Keine Treffer"}</td></tr>}
{groupedProjects.map(group => (
<React.Fragment key={group.key}>
{groupBy !== "none" && group.label && (
<tr>
<td colSpan={7} style={{ background: "#f5f0e8", padding: "6px 12px", fontSize: 10, letterSpacing: "0.1em", color: "#888", fontWeight: 600, textTransform: "uppercase", borderTop: "1px solid #e0dbd4" }}>
{group.label}
<span style={{ float: "right", fontWeight: 400, letterSpacing: 0, textTransform: "none", fontSize: 11, color: "#aaa" }}>
{group.items.length} Projekt{group.items.length !== 1 ? "e" : ""}
</span>
</td>
</tr>
)}
{group.items.map(p => {
const client = clients.find(c => c.id === p.clientId);
const usedMins = projectMinutes(p.id);
const usedHours = usedMins / 60;
const budget = p.budgetHours || 0;
const pct = budget > 0 ? (usedHours / budget) * 100 : 0;
const barColor = pct >= 100 ? "#8a1a1a" : pct >= 80 ? "#b5621e" : "#2d6a4f";
return (
<tr key={p.id} onClick={() => onSelect(p.id)} style={{ cursor: "pointer" }}>
<td style={{ fontSize: 11, color: "#888", fontWeight: 500 }}>{p.number || "—"}</td>
<td><strong>{p.name}</strong>{p.description && <div style={{ fontSize: 11, color: "#aaa", marginTop: 2 }}>{p.description.slice(0, 60)}</div>}</td>
<td style={{ fontSize: 12 }}>{p.category || "—"}</td>
<td>{client?.name || "—"}</td>
<td>
<div style={{ fontSize: 12, fontWeight: 500, color: budget > 0 ? barColor : "#1a1a18" }}>
{formatHours(usedMins)}{budget > 0 && ` / ${budget}h`}
</div>
{budget > 0 && (
<div style={{ marginTop: 4, height: 4, background: "#ece8e2", borderRadius: 2, width: 80, overflow: "hidden" }}>
<div style={{ width: `${Math.min(pct, 100)}%`, height: "100%", background: barColor, borderRadius: 2, transition: "width 0.3s" }} />
</div>
)}
</td>
<td><StatusBadge status={p.status} /></td>
<td style={{ textAlign: "right", whiteSpace: "nowrap" }}>
<button className="btn btn-ghost" style={{ marginRight: 6, padding: "5px 12px", fontSize: 12 }} onClick={(e) => openEdit(p, e)}><span className="material-icons" style={{ fontSize: 16 }}>edit</span></button>
<button className="btn btn-danger" style={{ padding: "5px 12px", fontSize: 12 }} onClick={(e) => del(p.id, e)}><span className="material-icons" style={{ fontSize: 16 }}>close</span></button>
</td>
</tr>
);
})}
</React.Fragment>
))}
</tbody>
</table>
</div>
{modal?.type === "project" && (() => {
const projectContacts = form.projectContacts || [];
const allContacts = (data.persons || []).filter(p => p.isPartner);
const alreadyAdded = new Set(projectContacts.map(pc => pc.contactId));
const selectedFirm = allContacts.find(c => c.id === addContactId);
const addBeteiligte = () => {
if (!addContactId) return;
setForm(f => ({ ...f, projectContacts: [...(f.projectContacts || []), { contactId: addContactId, personIds: addPersonIds }] }));
resetBeteiligter();
};
const saveEditedBeteiligte = (contactId) => {
setForm(f => ({ ...f, projectContacts: (f.projectContacts || []).map(pc => pc.contactId === contactId ? { ...pc, personIds: addPersonIds } : pc) }));
setEditingContactId(null); setAddPersonIds([]);
};
const removeBeteiligte = (contactId) => {
setForm(f => ({ ...f, projectContacts: (f.projectContacts || []).filter(pc => pc.contactId !== contactId) }));
};
const togglePerson = (personId) => setAddPersonIds(prev =>
prev.includes(personId) ? prev.filter(id => id !== personId) : [...prev, personId]);
return (
<Modal title={modal.id ? "Projekt bearbeiten" : "Neues Projekt"} onClose={() => { setModal(null); resetBeteiligter(); }} onSave={saveProject} wide>
<ProjectEditForm form={form} setForm={setForm} data={data} />
<div style={{ marginTop: 20, paddingTop: 16, borderTop: "1px solid #ece8e2" }}>
<div style={{ fontSize: 11, fontWeight: 600, letterSpacing: "0.08em", color: "#888", marginBottom: 12 }}>INTERNE PROJEKTBETEILIGUNG</div>
{(data.employees || []).length === 0
? <div style={{ fontSize: 12, color: "#aaa", marginBottom: 10 }}>Noch keine Mitarbeitenden erfasst.</div>
: <div style={{ display: "flex", flexWrap: "wrap", gap: 8, marginBottom: 4 }}>
{(data.employees || []).map(emp => {
const checked = (form.internalMembers || []).includes(emp.id);
return (
<label key={emp.id} style={{ display: "flex", alignItems: "center", gap: 6, cursor: "pointer", fontSize: 13, background: checked ? "#f0ede8" : "#faf9f7", border: `1px solid ${checked ? "#c8b89a" : "#ece8e2"}`, borderRadius: 8, padding: "4px 10px" }}>
<input type="checkbox" checked={checked} onChange={() => setForm(f => ({ ...f, internalMembers: checked ? (f.internalMembers || []).filter(id => id !== emp.id) : [...(f.internalMembers || []), emp.id] }))} style={{ margin: 0 }} />
<span>{emp.name}</span>
{emp.role && <span style={{ fontSize: 10, color: "#aaa" }}>{emp.role}</span>}
</label>
);
})}
</div>
}
</div>
<div style={{ marginTop: 20, paddingTop: 16, borderTop: "1px solid #ece8e2" }}>
<div style={{ fontSize: 11, fontWeight: 600, letterSpacing: "0.08em", color: "#888", marginBottom: 12 }}>PROJEKTBETEILIGTE</div>
{projectContacts.map(pc => {
const firm = allContacts.find(c => c.id === pc.contactId);
if (!firm) return null;
const selectedPersons = (firm.contacts || []).filter(p => pc.personIds.includes(p.id));
const isEditing = editingContactId === pc.contactId;
return (
<div key={pc.contactId} style={{ padding: "10px 0", borderBottom: "1px solid #f5f2ec" }}>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start" }}>
<div>
{firm.type && <div style={{ fontSize: 10, color: "#aaa", marginBottom: 2, letterSpacing: "0.06em" }}>{firm.type.toUpperCase()}</div>}
<div style={{ fontWeight: 600, fontSize: 13 }}>{firm.name}</div>
{!isEditing && (selectedPersons.length > 0
? <div style={{ marginTop: 4, display: "flex", flexWrap: "wrap", gap: 6 }}>
{selectedPersons.map(p => <span key={p.id} style={{ fontSize: 11, background: "#f5f2ec", color: "#555", padding: "2px 8px", borderRadius: 10 }}>{p.name}{p.position ? " · " + p.position : ""}</span>)}
</div>
: <div style={{ fontSize: 11, color: "#aaa", marginTop: 3 }}>Alle Ansprechpartner</div>
)}
</div>
<div style={{ display: "flex", gap: 4 }}>
{(firm.contacts || []).length > 0 && !isEditing && (
<button className="btn btn-ghost" style={{ fontSize: 11, padding: "3px 8px" }}
onClick={() => { setEditingContactId(pc.contactId); setAddPersonIds(pc.personIds || []); setShowAddBeteiligter(false); }}><span className="material-icons" style={{ fontSize: 16 }}>edit</span></button>
)}
<button className="btn btn-ghost" style={{ fontSize: 11, padding: "3px 8px", color: "#888" }} onClick={() => removeBeteiligte(pc.contactId)}><span className="material-icons" style={{ fontSize: 16 }}>close</span></button>
</div>
</div>
{isEditing && (
<div style={{ marginTop: 8 }}>
<div style={{ fontSize: 11, color: "#888", marginBottom: 6 }}>Personen (leer = alle):</div>
{(firm.contacts || []).map(p => (
<label key={p.id} style={{ display: "flex", alignItems: "center", gap: 8, cursor: "pointer", fontSize: 13, marginBottom: 4 }}>
<input type="checkbox" checked={addPersonIds.includes(p.id)} onChange={() => togglePerson(p.id)} />
<span>{p.name}</span>
{p.position && <span style={{ fontSize: 11, color: "#888" }}>{p.position}</span>}
</label>
))}
<div style={{ display: "flex", gap: 8, marginTop: 8 }}>
<button className="btn btn-primary" style={{ fontSize: 12 }} onClick={() => saveEditedBeteiligte(pc.contactId)}>Speichern</button>
<button className="btn btn-ghost" style={{ fontSize: 12 }} onClick={() => { setEditingContactId(null); setAddPersonIds([]); }}>Abbrechen</button>
</div>
</div>
)}
</div>
);
})}
{projectContacts.length === 0 && !showAddBeteiligter && allContacts.length === 0 && (
<div style={{ fontSize: 12, color: "#aaa", marginBottom: 10 }}>Keine Partner erfasst. Zuerst unter «Personen» Partner hinzufügen.</div>
)}
{showAddBeteiligter ? (
<div style={{ marginTop: 12 }}>
<select value={addContactId} onChange={e => { setAddContactId(e.target.value); setAddPersonIds([]); }} style={{ width: "100%", height: 34, marginBottom: 10 }}>
<option value=""> Firma wählen </option>
{allContacts.filter(c => !alreadyAdded.has(c.id)).sort((a, b) => a.name.localeCompare(b.name)).map(c => (
<option key={c.id} value={c.id}>{c.type ? "[" + c.type + "] " : ""}{c.name}</option>
))}
</select>
{selectedFirm && (selectedFirm.contacts || []).length > 0 && (
<div style={{ marginBottom: 10 }}>
<div style={{ fontSize: 11, color: "#888", marginBottom: 6 }}>Personen (leer = alle):</div>
{(selectedFirm.contacts || []).map(p => (
<label key={p.id} style={{ display: "flex", alignItems: "center", gap: 8, cursor: "pointer", fontSize: 13, marginBottom: 4 }}>
<input type="checkbox" checked={addPersonIds.includes(p.id)} onChange={() => togglePerson(p.id)} />
<span>{p.name}</span>
{p.position && <span style={{ fontSize: 11, color: "#888" }}>{p.position}</span>}
</label>
))}
</div>
)}
<div style={{ display: "flex", gap: 8 }}>
<button className="btn btn-primary" style={{ fontSize: 12 }} onClick={addBeteiligte} disabled={!addContactId}>Hinzufügen</button>
<button className="btn btn-ghost" style={{ fontSize: 12 }} onClick={resetBeteiligter}>Abbrechen</button>
</div>
</div>
) : allContacts.length > 0 && (
<button className="btn btn-ghost" style={{ fontSize: 12, marginTop: 8 }} onClick={() => setShowAddBeteiligter(true)}>+ Beteiligten hinzufügen</button>
)}
</div>
</Modal>
);
})()}
</div>
);
}
export
function ProjectDetail({ data, update, saveAll, projectId, onBack, setPrintContent, modal, setModal, currentUser }) {
const isAdmin = !currentUser || currentUser.role === "admin" || !currentUser.permissions;
const canSeeQuotes = isAdmin || currentUser.permissions.includes("quotes");
const canSeeInvoices = isAdmin || currentUser.permissions.includes("invoices");
const canSeeMitarbeiter = isAdmin || currentUser.permissions.includes("mitarbeiter");
const project = data.projects.find(p => p.id === projectId);
const emptySettingsForm = { number: "", name: "", clientId: "", category: "Direktauftrag", billingType: "stundensatz", hourlyRate: data.settings.defaultHourlyRate, budget: 0, status: "aktiv", description: "", startDate: "", enabledPhases: [], budgetHours: 0, linkedQuotes: [], positions: [], customPhases: [], internalMembers: [] };
const [budgetModal, setBudgetModal] = useState(false);
const [settingsOpen, setSettingsOpen] = useState(false);
const [budgetChartView, setBudgetChartView] = useState("einzeln");
const [phaseChartView, setPhaseChartView] = useState("einzeln");
const [settingsForm, setSettingsForm] = useState(() => project ? { ...emptySettingsForm, ...project, enabledPhases: project.enabledPhases || [] } : emptySettingsForm);
const [showAddBeteiligter, setShowAddBeteiligter] = useState(false);
const [editingContactId, setEditingContactId] = useState(null);
const [addContactId, setAddContactId] = useState("");
const [addPersonIds, setAddPersonIds] = useState([]);
const openSettings = () => {
setSettingsForm({ ...emptySettingsForm, ...project, enabledPhases: project.enabledPhases || [], projectContacts: project.projectContacts || [], internalMembers: project.internalMembers || [] });
setShowAddBeteiligter(false);
setEditingContactId(null);
setAddContactId("");
setAddPersonIds([]);
setSettingsOpen(true);
};
const saveSettings = () => {
if (!settingsForm.name?.trim()) { alert("Bitte einen Projektnamen eingeben."); return; }
update("projects", data.projects.map(p => p.id === projectId ? { ...p, ...settingsForm } : p));
setSettingsOpen(false);
};
const buildNachtragPositions = (linked, existingPos) => {
const manual = (existingPos || []).filter(p => !p.quoteId);
const nachtrag = linked.filter(lq => lq.role === "Nachtrag").map((lq, idx) => {
const existing = (existingPos || []).find(p => p.quoteId === lq.quoteId);
const q = (data.quotes || []).find(x => x.id === lq.quoteId);
const sd = deriveQuoteBudget([lq], data.quotes || [], data.settings.roles || []);
return { code: existing?.code || `N${idx + 1}`, label: existing !== undefined ? existing.label : (q?.projectName || q?.number || ""), enabledPhases: existing?.enabledPhases || sd.enabledPhases, quoteId: lq.quoteId };
});
return [...manual, ...nachtrag];
};
const [budgetForm, setBudgetForm] = useState(() => ({
budgetHours: project?.budgetHours || 0,
budgetAmount: project?.budgetAmount || 0,
linkedQuotes: project ? migrateLinkedQuotes(project) : [],
phasesBudget: project?.phasesBudget || [],
enabledPhases: project?.enabledPhases || [],
billingType: project?.billingType || "stundensatz",
positions: project?.positions || [],
}));
if (!project) return <div style={{ padding: 32 }}>Projekt nicht gefunden. <button className="btn btn-ghost" onClick={onBack}>Zurück</button></div>;
const openBudgetModal = () => {
const linked = migrateLinkedQuotes(project);
setBudgetForm({
budgetHours: project.budgetHours || 0,
budgetAmount: project.budgetAmount || 0,
linkedQuotes: linked,
phasesBudget: project.phasesBudget || [],
enabledPhases: project.enabledPhases || [],
billingType: project.billingType || "stundensatz",
positions: buildNachtragPositions(linked, project.positions || []),
});
};
const addLinkedQuote = (quoteId) => {
if (!quoteId || budgetForm.linkedQuotes.some(lq => lq.quoteId === quoteId)) return;
const isFirst = budgetForm.linkedQuotes.length === 0;
const newLq = { quoteId, role: isFirst ? "Hauptofferte" : "Nachtrag" };
const newLinked = [...budgetForm.linkedQuotes, newLq];
const d = deriveQuoteBudget(newLinked, data.quotes || [], data.settings.roles || []);
const q = (data.quotes || []).find(x => x.id === quoteId);
setBudgetForm(f => {
const positions = isFirst ? (f.positions || []) : buildNachtragPositions(newLinked, f.positions || []);
return {
...f,
linkedQuotes: newLinked,
budgetHours: d.budgetHours,
budgetAmount: d.budgetAmount,
phasesBudget: d.phasesBudget,
enabledPhases: isFirst ? [...new Set([...(f.enabledPhases || []), ...d.enabledPhases])] : (f.enabledPhases || []),
billingType: isFirst ? (q?.mode === "manual" ? "stundensatz" : "pauschal") : f.billingType,
positions,
};
});
};
const removeLinkedQuote = (quoteId) => {
const newLinked = budgetForm.linkedQuotes.filter(lq => lq.quoteId !== quoteId);
const d = newLinked.length > 0 ? deriveQuoteBudget(newLinked, data.quotes || [], data.settings.roles || []) : { budgetHours: 0, budgetAmount: 0, phasesBudget: [] };
setBudgetForm(f => ({
...f, linkedQuotes: newLinked, budgetHours: d.budgetHours, budgetAmount: d.budgetAmount, phasesBudget: d.phasesBudget,
positions: buildNachtragPositions(newLinked, f.positions || []),
}));
};
const changeLinkedRole = (quoteId, role) => setBudgetForm(f => {
const newLinked = f.linkedQuotes.map(lq => lq.quoteId === quoteId ? { ...lq, role } : lq);
return { ...f, linkedQuotes: newLinked, positions: buildNachtragPositions(newLinked, f.positions || []) };
});
const saveBudget = () => {
update("projects", data.projects.map(p => p.id === projectId ? { ...p, ...budgetForm } : p));
setBudgetModal(false);
};
const clients = (data.persons || []).filter(p => p.isAuftraggeber);
const client = clients.find(c => c.id === project.clientId);
const entries = data.timeEntries.filter(e => e.projectId === projectId).sort((a, b) => (b.date || "").localeCompare(a.date || ""));
const totalMinutes = entries.reduce((s, e) => s + (e.minutes || 0), 0);
const totalHours = totalMinutes / 60;
const billingType = project.billingType || project.type || "stundensatz";
const isNonBilling = NON_BILLING_CATEGORIES.includes(project.category);
const isCostControl = billingType !== "stundensatz" || isNonBilling;
const totalAmount = billingType === "stundensatz" ? (totalMinutes / 60) * project.hourlyRate : project.budget;
const budget = project.budgetHours || 0;
const budgetPct = budget > 0 ? (totalHours / budget) * 100 : 0;
const budgetColor = budgetPct >= 100 ? "#8a1a1a" : budgetPct >= 80 ? "#b5621e" : "#2d6a4f";
const invoicedMinutes = entries.filter(e => e.invoiceId).reduce((s, e) => s + (e.minutes || 0), 0);
const openMinutes = totalMinutes - invoicedMinutes;
// Phasen-Auswertung (nur aktivierte Phasen; aufgeteilt nach HO + Positionen)
const positions = project.positions || [];
const phaseStats = (project.enabledPhases || []).map(phaseId => {
const phase = SIA_PHASES.find(p => p.id === phaseId);
const phaseEntries = entries.filter(e => e.phaseId === phaseId);
const hoMins = phaseEntries.filter(e => !e.positionId).reduce((s, e) => s + (e.minutes || 0), 0);
const totalPhaseMins = phaseEntries.reduce((s, e) => s + (e.minutes || 0), 0);
const phaseBudget = (project.phasesBudget || []).find(pb => pb.id === phaseId)?.hours || 0;
const byPosition = positions.map(pos => ({
code: pos.code, label: pos.label,
mins: phaseEntries.filter(e => e.positionId === pos.code).reduce((s, e) => s + (e.minutes || 0), 0),
})).filter(p => p.mins > 0);
return { ...phase, minutes: totalPhaseMins, hoMins, hours: totalPhaseMins / 60, phaseBudget, percent: totalMinutes > 0 ? (totalPhaseMins / totalMinutes) * 100 : 0, entries: phaseEntries.length, byPosition };
});
const unassignedMins = entries.filter(e => !e.phaseId).reduce((s, e) => s + (e.minutes || 0), 0);
const customPhaseStats = (project.customPhases || []).map(cp => {
const mins = entries.filter(e => e.phaseId === cp.id).reduce((s, e) => s + (e.minutes || 0), 0);
return { id: cp.id, label: cp.label, mins };
});
const customMinsTotal = customPhaseStats.reduce((s, cp) => s + cp.mins, 0);
// Rechnungen zum Projekt
const projectInvoices = data.invoices.filter(i => i.projectId === projectId);
// Offen-Anzeige: nur bei Stundensatz-Projekten oder wenn ein Nachtrag stundensatz-basiert ist
const hasStundensatzNachtrag = positions.some(pos => {
if (!pos.quoteId) return false;
const q = (data.quotes || []).find(q => q.id === pos.quoteId);
return q?.mode === "manual";
});
const showOffen = billingType === "stundensatz" || hasStundensatzNachtrag;
const paidInvoices = projectInvoices.filter(i => i.status === "bezahlt");
const openInvoices = projectInvoices.filter(i => i.status === "gesendet" || i.status === "überfällig");
const paidTotal = paidInvoices.reduce((s, i) => s + (i.total || 0), 0);
const openTotal = openInvoices.reduce((s, i) => s + (i.total || 0), 0);
return (
<div>
<button className="btn btn-ghost" onClick={onBack} style={{ marginBottom: 18, padding: "6px 14px", fontSize: 12 }}> Alle Projekte</button>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start", marginBottom: 24 }}>
<div>
<div style={{ fontSize: 11, letterSpacing: "0.1em", color: "#888", marginBottom: 6 }}>{project.number && <span style={{ color: "#b07848", marginRight: 8 }}>{project.number}</span>}{(project.category || "—").toUpperCase()} · {client?.name || "Kein Auftraggeber"}</div>
<h1 style={{ fontFamily: "'Playfair Display', serif", fontSize: 34, fontWeight: 400, letterSpacing: "-0.01em" }}>{project.name}</h1>
{project.description && <div style={{ fontSize: 13, color: "#666", marginTop: 6, maxWidth: 600 }}>{project.description}</div>}
</div>
<div style={{ display: "flex", gap: 8 }}>
{isAdmin && <button className="btn btn-ghost" onClick={openSettings}>Einstellungen</button>}
{canSeeQuotes && <button className="btn btn-ghost" onClick={() => { openBudgetModal(); setBudgetModal(true); }}>Budget / Offerten</button>}
<button className="btn btn-ghost" onClick={() => setPrintContent({ type: "projectDetail", project, client, entries, phaseStats, unassignedMins, totalMinutes, totalAmount, billingType, invoices: projectInvoices, data })}>PDF</button>
</div>
</div>
<div className="responsive-grid-4" style={{ display: "grid", gridTemplateColumns: "repeat(4, 1fr)", gap: 16, marginBottom: 28 }}>
{(() => {
const internalRate = project.hourlyRate || data.settings.defaultHourlyRate || 0;
const internalCost = (totalMinutes / 60) * internalRate;
if (isCostControl) return [
{ label: "STUNDEN TOTAL", value: formatHours(totalMinutes), sub: budget > 0 ? `von ${budget}h Soll` : `${entries.length} Einträge`, color: "#b07848" },
isNonBilling
? { label: "NICHT VERRECHENBAR", value: project.category, sub: "Stunden fliessen ins Studio-Budget", color: "#aaa" }
: { label: "INTERNER AUFWAND", value: internalRate > 0 ? formatCHF(internalCost) : "—", sub: internalRate > 0 ? `bei ${formatCHF(internalRate)}/h · Kostenkontrolle` : "Kein Stundensatz hinterlegt", color: "#888" },
{ label: "BUDGET GENUTZT", value: budget > 0 ? `${Math.min(Math.round(budgetPct), 999)}%` : "—", sub: budget > 0 ? `${totalHours.toFixed(1)}h / ${budget}h` : "Kein Stundenbudget", color: budget > 0 ? budgetColor : "#aaa" },
{ label: "STATUS", value: <StatusBadge status={project.status} />, color: "#b07848" },
];
return [
{ label: "STUNDEN TOTAL", value: formatHours(totalMinutes), sub: budget > 0 ? `von ${budget}h Soll` : `${entries.length} Einträge`, color: "#b07848" },
{ label: "VERRECHNET", value: formatHours(invoicedMinutes), sub: formatCHF(invoicedMinutes / 60 * project.hourlyRate), color: "#2d6a4f" },
{ label: "OFFEN", value: formatHours(openMinutes), sub: formatCHF(openMinutes / 60 * project.hourlyRate), color: openMinutes > 0 ? "#b5621e" : "#aaa" },
{ label: "STATUS", value: <StatusBadge status={project.status} />, color: "#b07848" },
];
})().map(c => (
<div key={c.label} className="card" style={{ borderTop: `3px solid ${c.color}` }}>
<div style={{ fontSize: 10, color: "#888", letterSpacing: "0.12em", marginBottom: 10 }}>{c.label}</div>
<div style={{ fontSize: 22, fontFamily: "'Playfair Display', serif", fontWeight: 700 }}>{c.value}</div>
{c.sub && <div style={{ fontSize: 11, color: "#aaa", marginTop: 4 }}>{c.sub}</div>}
</div>
))}
</div>
{/* Projektbeteiligte */}
{(project.projectContacts || []).length > 0 && (
<div style={{ display: "flex", gap: 10, flexWrap: "wrap", alignItems: "center", marginBottom: 20 }}>
<span style={{ fontSize: 10, color: "#aaa", letterSpacing: "0.1em", fontWeight: 600, flexShrink: 0 }}>BETEILIGTE</span>
{(project.projectContacts || []).map(pc => {
const firm = (data.persons || []).find(p => p.id === pc.contactId);
if (!firm) return null;
const selectedPersons = (firm.contacts || []).filter(p => (pc.personIds || []).includes(p.id));
return (
<div key={pc.contactId} style={{ display: "inline-flex", alignItems: "center", gap: 6, background: "var(--surface2)", border: "1px solid var(--border)", borderRadius: 4, padding: "4px 10px" }}>
<span style={{ fontSize: 12, fontWeight: 600, color: "var(--text2)" }}>{firm.name}</span>
{firm.type && <span style={{ fontSize: 10, color: "#aaa" }}>{firm.type}</span>}
{selectedPersons.length > 0 && (
<span style={{ fontSize: 11, color: "var(--text3)", borderLeft: "1px solid var(--border)", paddingLeft: 6 }}>
{selectedPersons.map(p => p.name).join(", ")}
</span>
)}
</div>
);
})}
</div>
)}
{/* Stundenbudget-Anzeige */}
{(() => {
const linked = migrateLinkedQuotes(project);
const derived = linked.length > 0 ? deriveQuoteBudget(linked, data.quotes || [], data.settings.roles || []) : null;
const amountBudget = project.budgetAmount || derived?.budgetAmount || 0;
const invoicedAmount = projectInvoices.reduce((s, i) => s + (i.sub || 0), 0);
if (budget === 0 && amountBudget === 0) return (
<div className="card" style={{ marginBottom: 20, borderLeft: "4px solid #ddd", padding: "12px 20px" }}>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
<span style={{ fontSize: 12, color: "#aaa" }}>Kein Budget gesetzt</span>
<button className="btn btn-ghost" style={{ fontSize: 11, padding: "4px 12px" }}
onClick={() => { openBudgetModal(); setBudgetModal(true); }}>
+ Budget / Offerte verknüpfen
</button>
</div>
</div>
);
// Pro Offerte einzeln berechnen
const quoteRows = linked.map(lq => {
const q = (data.quotes || []).find(x => x.id === lq.quoteId);
if (!q) return null;
const roles = q.quoteRoles || data.settings.roles || [];
let qHours = 0, qAmount = 0;
if (q.mode === "sia") {
const calc = calcSIAHours(q.sia?.baukosten, q.sia?.schwierigkeit, q.sia?.phases || []);
qHours = calc.total || 0;
qAmount = qHours * (q.sia?.stundenansatz || 0);
} else if (q.mode === "manual") {
const calc = calcManualHours(q.manualPhases || [], roles);
qHours = calc.totalHours || 0;
qAmount = calc.totalAmount || 0;
} else if (q.mode === "free") {
qAmount = (q.freeItems || []).reduce((s, it) => s + (it.qty * it.price), 0);
}
return { lq, q, qHours: Math.round(qHours * 10) / 10, qAmount: Math.round(qAmount) };
}).filter(Boolean);
const isNachtrag = (role) => role && role !== "Hauptofferte";
const nachtragRows = quoteRows.filter(r => isNachtrag(r.lq.role));
const sortedQuoteRows = [
...quoteRows.filter(r => !isNachtrag(r.lq.role)),
...nachtragRows,
];
const hauptTotal = quoteRows.filter(r => !isNachtrag(r.lq.role)).reduce((s, r) => s + r.qHours, 0);
const nachtragTotal = nachtragRows.reduce((s, r) => s + r.qHours, 0);
// resolve per-row posCode from project.positions (custom codes)
const rowsWithPos = sortedQuoteRows.map(({ lq, q, qHours, qAmount }) => {
const isNT = isNachtrag(lq.role);
const pos = isNT ? positions.find(p => p.quoteId === lq.quoteId) : null;
const ntIdx = isNT ? nachtragRows.findIndex(r => r.lq.quoteId === lq.quoteId) : -1;
const posCode = pos?.code || (isNT ? `N${ntIdx + 1}` : null);
const usedMins = isNT
? entries.filter(e => posCode && e.positionId === posCode).reduce((s, e) => s + (e.minutes || 0), 0)
: entries.filter(e => !e.positionId).reduce((s, e) => s + (e.minutes || 0), 0);
return { lq, q, qHours, qAmount, isNT, posCode, usedH: usedMins / 60 };
});
const totalBudgetH = rowsWithPos.filter(r => r.qHours > 0).reduce((s, r) => s + r.qHours, 0);
const remaining = Math.max(0, budget - totalHours);
return (
<div className="card" style={{ marginBottom: 20, borderLeft: `4px solid ${budgetColor}`, padding: "16px 20px" }}>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 14 }}>
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
<div style={{ fontSize: 11, letterSpacing: "0.1em", color: "#888" }}>HONORAR- UND STUNDENBUDGET</div>
{quoteRows.length > 1 && (
<div style={{ display: "flex", gap: 1, background: "#ece8e2", borderRadius: 4, padding: 2 }}>
{[["einzeln", "Einzeln"], ["gesamt", "Gesamt"]].map(([v, l]) => (
<button key={v} onClick={() => setBudgetChartView(v)} style={{ fontSize: 10, padding: "2px 9px", borderRadius: 3, border: "none", cursor: "pointer", background: budgetChartView === v ? "#fff" : "transparent", color: budgetChartView === v ? "#1a1a18" : "#888", fontWeight: budgetChartView === v ? 600 : 400 }}>{l}</button>
))}
</div>
)}
</div>
{canSeeInvoices && <div style={{ fontSize: 11, color: "#888" }}>
Total verrechnet: <strong style={{ color: "#1a1a18" }}>{formatCHF(invoicedAmount)}</strong>
{amountBudget > 0 && <span> / {formatCHF(amountBudget)}</span>}
</div>}
</div>
{budgetChartView === "gesamt" && quoteRows.length > 1 ? (
<div>
{/* Segmentierter Balken */}
<div style={{ display: "flex", height: 28, borderRadius: 4, overflow: "hidden", marginBottom: 10, background: "#ece8e2" }}>
{rowsWithPos.filter(r => r.qHours > 0).map((r, idx, arr) => {
const segW = (r.qHours / totalBudgetH) * 100;
const fillW = Math.min((r.usedH / r.qHours) * 100, 100);
const isOver = r.usedH > r.qHours;
const segBg = r.isNT ? "#c8d8ee" : "#b8dcc8";
const fillColor = isOver ? "#8a1a1a" : (r.isNT ? "#1a4e8a" : "#2d6a4f");
const label = r.posCode || (r.isNT ? "NT" : "HO");
return (
<div key={r.lq.quoteId} style={{ width: `${segW}%`, position: "relative", background: segBg, borderRight: idx < arr.length - 1 ? "2px solid #fff" : "none", overflow: "hidden", flexShrink: 0 }}>
<div style={{ width: `${fillW}%`, height: "100%", background: fillColor, transition: "width 0.4s" }} />
{segW > 6 && <div style={{ position: "absolute", inset: 0, display: "flex", alignItems: "center", justifyContent: "center", fontSize: 9, fontWeight: 700, color: "#fff", pointerEvents: "none", textShadow: "0 1px 2px rgba(0,0,0,0.4)" }}>{label}</div>}
</div>
);
})}
</div>
{/* Legende */}
<div style={{ display: "flex", gap: 14, flexWrap: "wrap", marginBottom: 12 }}>
{rowsWithPos.map(r => {
const color = r.isNT ? "#1a4e8a" : "#2d6a4f";
const label = r.posCode || (r.isNT ? "NT" : "HO");
const isOver = r.usedH > r.qHours && r.qHours > 0;
return (
<div key={r.lq.quoteId} style={{ display: "flex", alignItems: "center", gap: 5, fontSize: 11 }}>
<div style={{ width: 10, height: 10, borderRadius: 2, background: isOver ? "#8a1a1a" : color, flexShrink: 0 }} />
<span style={{ color: "#888" }}>{label}:</span>
<span style={{ fontWeight: 600, color: isOver ? "#8a1a1a" : color }}>{r.usedH.toFixed(1)}h</span>
{r.qHours > 0 && <span style={{ color: "#aaa", fontSize: 10 }}>/ {r.qHours}h</span>}
{r.qAmount > 0 && <span style={{ color: "#bbb", fontSize: 10 }}> · {formatCHF(r.qAmount)}</span>}
</div>
);
})}
</div>
{/* Gesamt-Summe */}
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", fontSize: 12, paddingTop: 10, borderTop: "1px solid var(--border2)" }}>
<div style={{ color: "#888", display: "flex", gap: 16 }}>
<span>Verbraucht: <strong style={{ color: budgetColor }}>{totalHours.toFixed(1)}h</strong></span>
{budget > 0 && <span>Verfügbar: <strong style={{ color: remaining === 0 ? "#8a1a1a" : "#2d6a4f" }}>{remaining.toFixed(1)}h</strong></span>}
</div>
<span style={{ fontWeight: 700, color: budgetColor }}>
{budget > 0 ? `${totalHours.toFixed(1)}h / ${budget}h — ${budgetPct.toFixed(0)}%` : `${totalHours.toFixed(1)}h total`}
{budgetPct >= 100 && <span style={{ marginLeft: 6, fontSize: 11, color: "#8a1a1a" }}> überschritten</span>}
</span>
</div>
</div>
) : (
<>
{/* Einzeln-Ansicht */}
{rowsWithPos.map(({ lq, q, qHours, qAmount, isNT, usedH }) => {
const barPct = qHours > 0 ? Math.min((usedH / qHours) * 100, 100) : 0;
const barColor = barPct >= 100 ? "#8a1a1a" : barPct >= 80 ? "#b5621e" : isNT ? "#1a4e8a" : "#2d6a4f";
return (
<div key={lq.quoteId} style={{ marginBottom: 14, paddingBottom: 14, borderBottom: "1px solid var(--border2)" }}>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 6 }}>
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<span style={{ fontSize: 10, fontWeight: 600, padding: "2px 7px", borderRadius: 3, letterSpacing: "0.04em", background: isNT ? "#e8f0fa" : "#f0f8f4", color: isNT ? "#1a4e8a" : "#2d6a4f", border: `1px solid ${isNT ? "#b0c8e8" : "#b8dcc8"}` }}>
{isNT ? "↪ " : "⬡ "}{lq.role}
</span>
<span style={{ fontSize: 12, color: "#555" }}>
{q.number && <span style={{ color: "#b07848", marginRight: 6, fontWeight: 600 }}>{q.number}</span>}
{q.projectName || "—"}
</span>
</div>
<div style={{ fontSize: 12, textAlign: "right" }}>
{qHours > 0 && <span style={{ fontWeight: 600, color: barColor }}>{usedH.toFixed(1)}h / {qHours}h<span style={{ fontWeight: 400, color: "#888", marginLeft: 6 }}>({barPct.toFixed(0)}%)</span></span>}
{qAmount > 0 && <span style={{ color: "#888", fontSize: 11, marginLeft: 10 }}>{formatCHF(qAmount)}</span>}
</div>
</div>
{qHours > 0 && (
<>
<div style={{ height: 7, background: "var(--border)", borderRadius: 4, overflow: "hidden" }}>
<div style={{ width: `${barPct}%`, height: "100%", background: barColor, borderRadius: 4, transition: "width 0.4s" }} />
</div>
{barPct >= 80 && <div style={{ fontSize: 10, color: barColor, marginTop: 3 }}>{barPct >= 100 ? "⚠ Budget überschritten" : "⚠ Budget fast ausgeschöpft"}</div>}
</>
)}
</div>
);
})}
{quoteRows.length > 1 && (
<div style={{ display: "flex", justifyContent: "space-between", fontSize: 12, paddingTop: 4 }}>
<span style={{ color: "#888" }}>
{hauptTotal > 0 && <span>Hauptofferte {hauptTotal.toFixed(1)}h</span>}
{nachtragTotal > 0 && <span style={{ marginLeft: 8, color: "#1a4e8a" }}>+ Nachträge {nachtragTotal.toFixed(1)}h</span>}
</span>
<span style={{ fontWeight: 700, color: budgetColor }}>
{totalHours.toFixed(1)}h / {budget}h {budgetPct.toFixed(0)}%
{budgetPct >= 100 && <span style={{ marginLeft: 6, fontSize: 11, color: "#8a1a1a" }}> überschritten</span>}
</span>
</div>
)}
</>
)}
{/* Fallback wenn kein linked quote aber manuelles Budget */}
{quoteRows.length === 0 && budget > 0 && (
<div>
<div style={{ display: "flex", justifyContent: "space-between", fontSize: 12, marginBottom: 4 }}>
<span style={{ color: "#888" }}>Stunden</span>
<span style={{ fontWeight: 700, color: budgetColor }}>{totalHours.toFixed(1)}h / {budget}h {budgetPct.toFixed(0)}%</span>
</div>
<div style={{ height: 8, background: "#ece8e2", borderRadius: 4, overflow: "hidden" }}>
<div style={{ width: `${Math.min(budgetPct, 100)}%`, height: "100%", background: budgetColor, borderRadius: 4 }} />
</div>
</div>
)}
</div>
);
})()}
{/* Aufwand pro Phase — Hauptofferte + Nachträge in einer Karte */}
{((project.enabledPhases || []).length > 0 || positions.some(pos => entries.some(e => e.positionId === pos.code)) || customPhaseStats.length > 0) && (() => {
const linked = migrateLinkedQuotes(project);
const hauptLq = linked.find(lq => lq.role === "Hauptofferte");
const hauptQ = hauptLq ? (data.quotes || []).find(q => q.id === hauptLq.quoteId) : null;
const hoMinsTotal = entries.filter(e => !e.positionId).reduce((s, e) => s + (e.minutes || 0), 0);
const hasPositions = positions.some(pos => entries.some(e => e.positionId === pos.code));
return (
<div className="card" style={{ marginBottom: 20 }}>
<div style={{ display: "flex", alignItems: "center", gap: 10, marginBottom: 16 }}>
<div style={{ fontSize: 11, letterSpacing: "0.1em", color: "#888" }}>AUFWAND PRO PHASE</div>
{hasPositions && (project.enabledPhases || []).length > 0 && (
<div style={{ display: "flex", gap: 1, background: "#ece8e2", borderRadius: 4, padding: 2 }}>
{[["einzeln", "Einzeln"], ["gesamt", "Gesamt"]].map(([v, l]) => (
<button key={v} onClick={() => setPhaseChartView(v)} style={{ fontSize: 10, padding: "2px 9px", borderRadius: 3, border: "none", cursor: "pointer", background: phaseChartView === v ? "#fff" : "transparent", color: phaseChartView === v ? "#1a1a18" : "#888", fontWeight: phaseChartView === v ? 600 : 400 }}>{l}</button>
))}
</div>
)}
</div>
{/* ─ Eigene Phasen (immer einzeln) ─ */}
{customPhaseStats.length > 0 && (
<div style={{ marginBottom: (project.enabledPhases || []).length > 0 || hasPositions ? 20 : 0 }}>
<div style={{ display: "flex", alignItems: "center", gap: 8, marginBottom: 12 }}>
<span style={{ fontSize: 10, fontWeight: 700, background: "#fdf0e8", color: "#b5621e", padding: "2px 8px", borderRadius: 3 }}>EIGENE</span>
<span style={{ marginLeft: "auto", fontSize: 12, color: "#888" }}>{formatHours(customMinsTotal)}</span>
</div>
{customPhaseStats.map(cp => {
const pct = customMinsTotal > 0 ? (cp.mins / customMinsTotal) * 100 : 0;
return (
<div key={cp.id} style={{ marginBottom: 10 }}>
<div style={{ display: "flex", justifyContent: "space-between", fontSize: 12, marginBottom: 4 }}>
<span style={{ fontWeight: 500 }}>{cp.label}</span>
<span style={{ color: cp.mins > 0 ? "#b5621e" : "#bbb", fontWeight: 500 }}>{formatHours(cp.mins)}</span>
</div>
<div style={{ height: 6, background: "#fdf0e8", borderRadius: 3, overflow: "hidden" }}>
<div style={{ width: `${pct}%`, height: "100%", background: cp.mins > 0 ? "#b5621e" : "transparent" }} />
</div>
</div>
);
})}
</div>
)}
{phaseChartView === "gesamt" && hasPositions && (project.enabledPhases || []).length > 0 ? (() => {
const allPhaseIds = [...new Set([
...(project.enabledPhases || []),
...positions.flatMap(pos => pos.enabledPhases || []),
...entries.map(e => e.phaseId).filter(id => id && SIA_PHASES.some(p => p.id === id)),
])];
const segments = [
{ code: null, label: "HO", color: "#2d6a4f" },
...positions.map(pos => ({ code: pos.code, label: pos.code || "NT", color: "#1a4e8a" })),
];
// Phasenbudgets der Nachträge vorberechnen
const posPhasesBudget = positions.map(pos => {
const linkedQ = pos.quoteId ? (data.quotes || []).find(q => q.id === pos.quoteId) : null;
if (!linkedQ) return { code: pos.code, phases: [] };
const roles = linkedQ.quoteRoles || data.settings.roles || [];
let phases = [];
if (linkedQ.mode === "sia") {
const calc = calcSIAHours(linkedQ.sia?.baukosten, linkedQ.sia?.schwierigkeit, linkedQ.sia?.phases || []);
phases = (calc.phases || []).filter(p => p.hours > 0).map(p => ({ id: p.id, hours: p.hours }));
} else if (linkedQ.mode === "manual") {
phases = (linkedQ.manualPhases || []).filter(ph => ph.enabled).map(ph => {
const h = roles.reduce((s, r) => s + (ph.hoursByRole?.[r.id] || 0), 0);
return { id: ph.id, hours: Math.round(h * 10) / 10 };
}).filter(p => p.hours > 0);
}
return { code: pos.code, phases };
});
const phaseRows = allPhaseIds.map(phId => {
const ph = SIA_PHASES.find(p => p.id === phId);
if (!ph) return null;
const phEntries = entries.filter(e => e.phaseId === phId);
const segMins = segments.map(seg => ({
...seg,
mins: seg.code === null
? phEntries.filter(e => !e.positionId).reduce((s, e) => s + (e.minutes || 0), 0)
: phEntries.filter(e => e.positionId === seg.code).reduce((s, e) => s + (e.minutes || 0), 0),
}));
const totalMins = segMins.reduce((s, s2) => s + s2.mins, 0);
const hoBudget = phaseStats.find(ps => ps.id === phId)?.phaseBudget || 0;
const ntBudget = posPhasesBudget.reduce((s, p) => s + (p.phases.find(p2 => p2.id === phId)?.hours || 0), 0);
const totalPhaseBudget = hoBudget + ntBudget;
const remainingH = totalPhaseBudget > 0 ? Math.max(0, totalPhaseBudget - totalMins / 60) : 0;
return { ph, segMins, totalMins, totalPhaseBudget, remainingH };
}).filter(Boolean).filter(r => r.totalPhaseBudget > 0 || r.totalMins > 0);
const activeSeg = segments.filter(seg =>
phaseRows.some(r => r.segMins.find(s => s.code === seg.code)?.mins > 0)
);
return (
<div>
<div style={{ display: "flex", gap: 12, flexWrap: "wrap", marginBottom: 14 }}>
{activeSeg.map(seg => (
<div key={seg.label} style={{ display: "flex", alignItems: "center", gap: 5, fontSize: 11 }}>
<div style={{ width: 10, height: 10, borderRadius: 2, background: seg.color }} />
<span style={{ color: "#888" }}>{seg.label}</span>
</div>
))}
{phaseRows.some(r => r.remainingH > 0) && (
<div style={{ display: "flex", alignItems: "center", gap: 5, fontSize: 11 }}>
<div style={{ width: 10, height: 10, borderRadius: 2, background: "#d0ccc8" }} />
<span style={{ color: "#888" }}>Verfügbar</span>
</div>
)}
</div>
{phaseRows.map(({ ph, segMins, totalMins, totalPhaseBudget, remainingH }) => {
const totalUsedH = totalMins / 60;
const isOver = totalPhaseBudget > 0 && totalUsedH > totalPhaseBudget;
const budgetPctPh = totalPhaseBudget > 0 ? (totalUsedH / totalPhaseBudget) * 100 : 0;
const labelColor = isOver ? "#8a1a1a" : budgetPctPh >= 80 ? "#b5621e" : "#1a1a18";
// Balken-Basis: bei Budget = Budget*60min, sonst = verwendete Minuten
const barBaseMins = totalPhaseBudget > 0 ? totalPhaseBudget * 60 : totalMins;
const remainingBarMins = totalPhaseBudget > 0 ? Math.max(0, totalPhaseBudget * 60 - totalMins) : 0;
return (
<div key={ph.id} style={{ marginBottom: 14 }}>
<div style={{ display: "flex", justifyContent: "space-between", fontSize: 12, marginBottom: 5 }}>
<span style={{ fontWeight: 500 }}>{ph.label}</span>
<span style={{ color: labelColor, fontWeight: 500 }}>
{formatHours(totalMins)}
{totalPhaseBudget > 0 && (
<span style={{ fontWeight: 400, color: "#888", marginLeft: 6 }}>
/ {totalPhaseBudget}h
{remainingH > 0
? <span style={{ color: "#2d6a4f" }}> · {remainingH.toFixed(1)}h offen</span>
: isOver
? <span style={{ color: "#8a1a1a" }}> · überschritten</span>
: null}
</span>
)}
</span>
</div>
<div style={{ display: "flex", height: 8, borderRadius: 3, overflow: "hidden", background: "#ece8e2" }}>
{barBaseMins > 0 && segMins.filter(s => s.mins > 0).map((seg, idx, arr) => (
<div key={seg.label} style={{ width: `${(seg.mins / barBaseMins) * 100}%`, height: "100%", background: isOver ? "#8a1a1a" : seg.color, borderRight: idx < arr.length - 1 ? "1px solid rgba(255,255,255,0.4)" : "none", flexShrink: 0 }} />
))}
{remainingBarMins > 0 && (
<div style={{ width: `${(remainingBarMins / barBaseMins) * 100}%`, height: "100%", background: "#d0ccc8", flexShrink: 0 }} />
)}
</div>
</div>
);
})}
{unassignedMins > 0 && <div style={{ fontSize: 11, color: "#b5621e", marginTop: 4 }}> {formatHours(unassignedMins)} ohne Phasen-Zuordnung</div>}
</div>
);
})() : (
<>
{/* ─ Einzeln: Hauptauftrag ─ */}
{(project.enabledPhases || []).length > 0 && (
<div style={{ marginBottom: hasPositions ? 20 : 0 }}>
<div style={{ display: "flex", alignItems: "center", gap: 8, marginBottom: 12 }}>
<span style={{ fontSize: 10, fontWeight: 700, background: "#e8f5ee", color: "#2d6a4f", padding: "2px 8px", borderRadius: 3 }}> HO</span>
{hauptQ?.number && <span style={{ fontWeight: 600, color: "#b07848", fontSize: 12 }}>{hauptQ.number}</span>}
<span style={{ fontSize: 12, color: "#555" }}>Hauptauftrag</span>
<span style={{ marginLeft: "auto", fontSize: 12, color: "#888" }}>{formatHours(hoMinsTotal)}</span>
</div>
{phaseStats.map(ps => {
const hoMins = ps.hoMins;
const hoHours = hoMins / 60;
const phasePct = ps.phaseBudget > 0 ? (hoHours / ps.phaseBudget) * 100 : (hoMinsTotal > 0 ? (hoMins / hoMinsTotal) * 100 : 0);
const barColor = ps.phaseBudget > 0 ? (phasePct >= 100 ? "#8a1a1a" : phasePct >= 80 ? "#b5621e" : "#2d6a4f") : "#2d6a4f";
return (
<div key={ps.id} style={{ marginBottom: 12 }}>
<div style={{ display: "flex", justifyContent: "space-between", fontSize: 12, marginBottom: 4 }}>
<span style={{ fontWeight: 500 }}>{ps.label}</span>
<span style={{ color: barColor, fontWeight: 500 }}>
{formatHours(hoMins)}
{ps.phaseBudget > 0 && ` / ${ps.phaseBudget}h (${phasePct.toFixed(0)}%)`}
</span>
</div>
<div style={{ height: 6, background: "#ece8e2", borderRadius: 3, overflow: "hidden" }}>
<div style={{ width: `${Math.min(phasePct, 100)}%`, height: "100%", background: barColor }} />
</div>
</div>
);
})}
{unassignedMins > 0 && <div style={{ fontSize: 11, color: "#b5621e", marginTop: 4 }}> {formatHours(unassignedMins)} ohne Phasen-Zuordnung</div>}
</div>
)}
{/* ─ Einzeln: Nachträge / Positionen ─ */}
{positions.map(pos => {
const posEntries = entries.filter(e => e.positionId === pos.code);
const posMins = posEntries.reduce((s, e) => s + (e.minutes || 0), 0);
const enabledPhaseIds = pos.enabledPhases || [];
const extraPhaseIds = [...new Set(posEntries.map(e => e.phaseId).filter(Boolean).filter(id => !enabledPhaseIds.includes(id)))];
const allPhaseIds = [...enabledPhaseIds, ...extraPhaseIds];
if (allPhaseIds.length === 0 && posMins === 0) return null;
const linkedQ = pos.quoteId ? (data.quotes || []).find(q => q.id === pos.quoteId) : null;
let nachtragPhasesBudget = [];
if (linkedQ) {
const roles = linkedQ.quoteRoles || data.settings.roles || [];
if (linkedQ.mode === "sia") {
const calc = calcSIAHours(linkedQ.sia?.baukosten, linkedQ.sia?.schwierigkeit, linkedQ.sia?.phases || []);
nachtragPhasesBudget = (calc.phases || []).filter(p => p.hours > 0).map(p => ({ id: p.id, hours: p.hours }));
} else if (linkedQ.mode === "manual") {
nachtragPhasesBudget = (linkedQ.manualPhases || []).filter(ph => ph.enabled).map(ph => {
const h = roles.reduce((s, r) => s + (ph.hoursByRole?.[r.id] || 0), 0);
return { id: ph.id, hours: Math.round(h * 10) / 10 };
}).filter(p => p.hours > 0);
}
}
return (
<div key={pos.code} style={{ paddingTop: 20, borderTop: "1px solid #ece8e2" }}>
<div style={{ display: "flex", alignItems: "center", gap: 8, marginBottom: 12 }}>
<span style={{ fontSize: 10, fontWeight: 700, background: "#e8f0fa", color: "#1a4e8a", padding: "2px 8px", borderRadius: 3 }}> {pos.code}</span>
{linkedQ?.number && <span style={{ fontWeight: 600, color: "#b07848", fontSize: 12 }}>{linkedQ.number}</span>}
<span style={{ fontSize: 12, color: "#555" }}>{pos.label || "Nachtrag"}</span>
<span style={{ marginLeft: "auto", fontSize: 12, color: "#1a4e8a", fontWeight: 600 }}>{formatHours(posMins)}</span>
</div>
{allPhaseIds.length > 0 ? allPhaseIds.map(phId => {
const ph = SIA_PHASES.find(p => p.id === phId);
if (!ph) return null;
const phMins = posEntries.filter(e => e.phaseId === phId).reduce((s, e) => s + (e.minutes || 0), 0);
const phaseBudgetHours = nachtragPhasesBudget.find(p => p.id === phId)?.hours || 0;
const phHours = phMins / 60;
const phPct = phaseBudgetHours > 0 ? (phHours / phaseBudgetHours) * 100 : (posMins > 0 ? (phMins / posMins) * 100 : 0);
const barColor = phaseBudgetHours > 0 ? (phPct >= 100 ? "#8a1a1a" : phPct >= 80 ? "#b5621e" : "#1a4e8a") : (phMins > 0 ? "#1a4e8a" : "#ccc");
return (
<div key={phId} style={{ marginBottom: 12 }}>
<div style={{ display: "flex", justifyContent: "space-between", fontSize: 12, marginBottom: 4 }}>
<span style={{ fontWeight: 500 }}>{ph.label}</span>
<span style={{ color: barColor, fontWeight: 500 }}>
{formatHours(phMins)}
{phaseBudgetHours > 0 && ` / ${phaseBudgetHours}h (${phPct.toFixed(0)}%)`}
</span>
</div>
<div style={{ height: 6, background: "#e8f0fa", borderRadius: 3, overflow: "hidden" }}>
<div style={{ width: `${Math.min(phPct, 100)}%`, height: "100%", background: barColor }} />
</div>
</div>
);
}) : (
<div style={{ fontSize: 12, color: "#aaa" }}>Noch keine Stunden erfasst</div>
)}
</div>
);
})}
</>
)}
</div>
);
})()}
{/* Mitarbeiter-Auswertung */}
{canSeeMitarbeiter && (() => {
const employees = data.employees || [];
// Gruppe nach Mitarbeiter
const byEmp = {};
entries.forEach(e => {
const empId = e.employeeId || "__none__";
if (!byEmp[empId]) byEmp[empId] = { mins: 0, entries: [] };
byEmp[empId].mins += e.minutes || 0;
byEmp[empId].entries.push(e);
});
const empRows = Object.entries(byEmp).map(([empId, d]) => {
const emp = empId === "__none__" ? null : employees.find(e => e.id === empId);
// Aufschlüsselung nach Phase
const byPhase = {};
d.entries.forEach(e => {
const k = e.phaseId || "__none__";
byPhase[k] = (byPhase[k] || 0) + (e.minutes || 0);
});
const amount = billingType === "stundensatz" ? (d.mins / 60) * project.hourlyRate : null;
return { empId, emp, mins: d.mins, amount, byPhase, count: d.entries.length };
}).sort((a, b) => b.mins - a.mins);
if (empRows.length === 0) return null;
const totalMins = empRows.reduce((s, r) => s + r.mins, 0);
return (
<div className="card" style={{ marginBottom: 20 }}>
<div style={{ fontSize: 11, letterSpacing: "0.1em", color: "#888", marginBottom: 14 }}>MITARBEITER-AUSWERTUNG</div>
<table>
<thead>
<tr>
<th>Mitarbeiter</th>
<th style={{ textAlign: "right" }}>Stunden</th>
<th style={{ textAlign: "right" }}>Anteil</th>
{billingType === "stundensatz" && <th style={{ textAlign: "right" }}>Betrag</th>}
<th>Einträge</th>
<th>Phasen</th>
</tr>
</thead>
<tbody>
{empRows.map(row => {
const pct = totalMins > 0 ? Math.round(row.mins / totalMins * 100) : 0;
const phases = Object.entries(row.byPhase).map(([phId, mins]) => {
const ph = SIA_PHASES.find(p => p.id === phId);
return ph ? `${ph.id} (${formatHours(mins)})` : `— (${formatHours(mins)})`;
}).join(", ");
return (
<tr key={row.empId}>
<td>
<strong>{row.emp?.name || "Ohne Mitarbeiter"}</strong>
{row.emp?.role && <div style={{ fontSize: 11, color: "#888" }}>{row.emp.role}</div>}
</td>
<td style={{ textAlign: "right", fontWeight: 600 }}>{formatHours(row.mins)}</td>
<td style={{ textAlign: "right" }}>
<div style={{ display: "flex", alignItems: "center", gap: 6, justifyContent: "flex-end" }}>
<div style={{ width: 60, height: 6, background: "#ece8e2", borderRadius: 3, overflow: "hidden" }}>
<div style={{ width: `${pct}%`, height: "100%", background: "#b07848", borderRadius: 3 }} />
</div>
<span style={{ fontSize: 12, color: "#888", minWidth: 32 }}>{pct}%</span>
</div>
</td>
{billingType === "stundensatz" && <td style={{ textAlign: "right" }}>{row.amount !== null ? formatCHF(row.amount) : "—"}</td>}
<td style={{ color: "#888", fontSize: 12 }}>{row.count} Einträge</td>
<td style={{ fontSize: 11, color: "#888" }}>{phases || "—"}</td>
</tr>
);
})}
</tbody>
<tfoot>
<tr style={{ borderTop: "1.5px solid #1a1a18" }}>
<td><strong>Total</strong></td>
<td style={{ textAlign: "right", fontWeight: 700 }}>{formatHours(totalMins)}</td>
<td style={{ textAlign: "right", fontSize: 12, color: "#888" }}>100%</td>
{billingType === "stundensatz" && <td style={{ textAlign: "right", fontWeight: 700 }}>{formatCHF(empRows.reduce((s, r) => s + (r.amount || 0), 0))}</td>}
<td style={{ color: "#888", fontSize: 12 }}>{entries.length} Einträge</td>
<td></td>
</tr>
</tfoot>
</table>
</div>
);
})()}
<div className="card" style={{ padding: 0, marginBottom: 20 }}>
<div style={{ padding: "16px 20px", fontSize: 11, letterSpacing: "0.1em", color: "#888", borderBottom: "1px solid #ece8e2" }}>ZEITEINTRÄGE</div>
<table style={{ tableLayout: "fixed" }}>
<thead><tr>
<th style={{ width: "12%" }}>Datum</th>
{canSeeMitarbeiter && <th style={{ width: "14%" }}>Mitarbeiter</th>}
<th style={{ width: "15%" }}>Phase / Position</th>
<th>Tätigkeit</th>
<th style={{ width: "10%" }}>Dauer</th>
{canSeeInvoices && <th style={{ width: "11%" }}>Betrag</th>}
{canSeeInvoices && <th style={{ width: "12%" }}>Rechnung</th>}
</tr></thead>
<tbody>
{entries.length === 0 && <tr><td colSpan={canSeeMitarbeiter && canSeeInvoices ? 7 : 4} style={{ textAlign: "center", color: "#aaa", padding: 24 }}>Noch keine Zeiteinträge</td></tr>}
{entries.map(e => {
const phase = SIA_PHASES.find(p => p.id === e.phaseId);
const pos = positions.find(p => p.code === e.positionId);
const emp = e.employeeId ? (data.employees || []).find(em => em.id === e.employeeId) : null;
const amount = billingType === "stundensatz" ? (e.minutes / 60) * project.hourlyRate : null;
const invoice = e.invoiceId ? data.invoices.find(i => i.id === e.invoiceId) : null;
const posQuote = pos?.quoteId ? (data.quotes || []).find(q => q.id === pos.quoteId) : null;
const isStundensatzEntry = e.positionId
? posQuote?.mode === "manual"
: billingType === "stundensatz";
return (
<tr key={e.id} style={{ opacity: invoice && canSeeInvoices ? 0.75 : 1 }}>
<td>{formatDate(e.date)}</td>
{canSeeMitarbeiter && <td style={{ fontSize: 11, color: "#666" }}>{emp?.name || <span style={{ color: "#ccc" }}></span>}</td>}
<td style={{ fontSize: 12 }}>
{isAdmin ? (
<select
value={e.phaseId ? (e.positionId ? e.phaseId + "|" + e.positionId : e.phaseId) : ""}
onChange={ev => {
const val = ev.target.value;
if (!val) { update("timeEntries", data.timeEntries.map(te => te.id === e.id ? { ...te, phaseId: "", positionId: "" } : te)); return; }
const [ph, p] = val.split("|");
update("timeEntries", data.timeEntries.map(te => te.id === e.id ? { ...te, phaseId: ph, positionId: p || "" } : te));
}}
disabled={!!e.invoiceId}
style={{ border: "1px solid #e0dbd4", height: 26, fontSize: 11, maxWidth: "100%", opacity: e.invoiceId ? 0.6 : 1 }}
>
<option value=""></option>
{(project.customPhases || []).map(cp => <option key={cp.id} value={cp.id}>{cp.label}</option>)}
{positions.length === 0 && (project.enabledPhases || []).map(phId => { const ph = SIA_PHASES.find(p => p.id === phId); return ph ? <option key={ph.id} value={ph.id}>{ph.id}</option> : null; }).filter(Boolean)}
{positions.length > 0 && (project.enabledPhases || []).map(phId => { const ph = SIA_PHASES.find(p => p.id === phId); return ph ? <option key={ph.id} value={ph.id}>{ph.id} HO</option> : null; }).filter(Boolean)}
{positions.flatMap(pos => (pos.enabledPhases || []).map(phId => { const ph = SIA_PHASES.find(p => p.id === phId); return ph ? <option key={ph.id + "|" + pos.code} value={ph.id + "|" + pos.code}>{pos.code}·{ph.id}</option> : null; }).filter(Boolean))}
</select>
) : phase ? (
<span>
<span style={{ color: "#666" }}>{phase.label.split(" ")[0]}</span>
{pos && <span style={{ fontWeight: 600, color: "#7a6a00", background: "#fffbe8", padding: "1px 5px", borderRadius: 2, marginLeft: 5, fontSize: 11 }}>{pos.code}</span>}
</span>
) : <span style={{ color: "#ccc" }}></span>}
</td>
<td style={{ color: "#666" }}>{e.description || "—"}</td>
<td>{formatHours(e.minutes)}</td>
{canSeeInvoices && <td>{amount !== null ? formatCHF(amount) : "—"}</td>}
{canSeeInvoices && <td style={{ fontSize: 11 }}>
{invoice
? <span className="tag" style={{ background: "#2d6a4f", fontSize: 10 }}>{invoice.number}</span>
: isStundensatzEntry
? <span style={{ color: "#aaa" }}>offen</span>
: null}
</td>}
</tr>
);
})}
</tbody>
</table>
</div>
{canSeeQuotes && (() => {
const projectQuotes = (data.quotes || []).filter(q => q.projectId === projectId);
if (projectQuotes.length === 0) return null;
return (
<div className="card" style={{ padding: 0, marginBottom: 20 }}>
<div style={{ padding: "16px 20px", fontSize: 11, letterSpacing: "0.1em", color: "#888", borderBottom: "1px solid #ece8e2" }}>OFFERTEN</div>
<table style={{ tableLayout: "fixed" }}>
<thead><tr><th style={{ width: "18%" }}>Nr.</th><th style={{ width: "14%" }}>Datum</th><th>Modus</th><th style={{ width: "14%" }}>Status</th><th style={{ width: "16%" }}>Honorar</th><th style={{ width: "7%" }}></th></tr></thead>
<tbody>
{projectQuotes.map(q => (
<tr key={q.id}>
<td><strong>{q.number}</strong></td>
<td>{formatDate(q.date)}</td>
<td style={{ fontSize: 11, color: "#888" }}>{q.mode === "sia" ? "SIA 102" : "Aufwand"}</td>
<td><StatusBadge status={q.status} /></td>
<td><strong>{formatCHF(q.total)}</strong></td>
<td style={{ textAlign: "right" }}>
<button className="btn btn-ghost" style={{ padding: "5px 10px", fontSize: 12 }} onClick={() => setPrintContent({ type: "quote", quote: q, client, settings: data.settings })}>PDF</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
);
})()}
{canSeeInvoices && projectInvoices.length > 0 && (
<div className="card" style={{ padding: 0 }}>
<div style={{ padding: "16px 20px", fontSize: 11, letterSpacing: "0.1em", color: "#888", borderBottom: "1px solid #ece8e2" }}>RECHNUNGEN</div>
<table style={{ tableLayout: "fixed" }}>
<thead><tr><th style={{ width: "18%" }}>Nr.</th><th style={{ width: "14%" }}>Datum</th><th>Art</th><th style={{ width: "14%" }}>Status</th><th style={{ width: "16%" }}>Betrag</th><th style={{ width: "7%" }}></th></tr></thead>
<tbody>
{projectInvoices.map(inv => (
<tr key={inv.id}>
<td><strong>{inv.number}</strong></td>
<td>{formatDate(inv.date)}</td>
<td style={{ fontSize: 11, color: "#888" }}>{inv.invoiceKind === "akonto" ? "Akonto" : (inv.invoiceKind === "schluss" ? "Schluss" : "—")}</td>
<td><StatusBadge status={inv.status} /></td>
<td><strong>{formatCHF(inv.total)}</strong></td>
<td style={{ textAlign: "right" }}>
<button className="btn btn-ghost" style={{ padding: "5px 10px", fontSize: 12 }} onClick={() => setPrintContent({ type: "invoice", inv, client })}>PDF</button>
</td>
</tr>
))}
</tbody>
</table>
{showOffen && (paidTotal > 0 || openTotal > 0) && (
<div style={{ padding: "10px 20px", borderTop: "1px solid #ece8e2", display: "flex", gap: 24, fontSize: 12 }}>
<span style={{ color: "#4a7c59" }}>Bezahlt: <strong>{formatCHF(paidTotal)}</strong></span>
{openTotal > 0 && (
<span style={{ color: "#b5621e" }}>Noch offen: <strong>{formatCHF(openTotal)}</strong></span>
)}
{openTotal === 0 && paidTotal > 0 && (
<span style={{ color: "#888", fontSize: 11 }}>Alles bezahlt</span>
)}
</div>
)}
</div>
)}
{/* Protokolle zum Projekt */}
{(() => {
const projProtokolle = (data.protocols || [])
.filter(p => p.projectId === projectId)
.sort((a, b) => (b.date || "").localeCompare(a.date || ""));
return (
<div className="card" style={{ padding: 0, marginTop: 20 }}>
<div style={{ padding: "16px 20px", display: "flex", justifyContent: "space-between", alignItems: "center", borderBottom: projProtokolle.length > 0 ? "1px solid #ece8e2" : "none" }}>
<div style={{ fontSize: 11, letterSpacing: "0.1em", color: "#888" }}>PROTOKOLLE ({projProtokolle.length})</div>
<button className="btn btn-ghost" style={{ fontSize: 11, padding: "4px 12px" }} onClick={() => {
const p = {
id: generateId(),
title: "",
type: PROTOKOLL_TYPES[0],
date: new Date().toISOString().slice(0, 10),
time: "10:00",
endTime: "",
location: "",
projectId,
projectManual: "",
nummer: (() => {
const all = data.protocols || [];
const abbr = data.settings.protokollTypeAbbreviations || {};
const typKuerzel = abbr[PROTOKOLL_TYPES[0]] || "SO";
const proj = data.projects.find(x => x.id === projectId);
const seq = nextProtoSeq(all);
return applyProtoNumberFormat(data.settings.protokollNumberFormat || "PP-TT-NN", {
date: new Date().toISOString().slice(0, 10),
projectNumber: proj?.number || "",
seq,
typKuerzel,
});
})(),
participants: [],
traktanden: [{ id: generateId(), nr: "1", title: "", items: [] }],
nextDate: "",
verteiler: "",
createdAt: new Date().toISOString(),
};
saveAll({ ...data, protocols: [...(data.protocols || []), p] });
// navigate to protokolle view signal via a custom event
window.__openProtokoll = p.id;
window.dispatchEvent(new CustomEvent("openProtokoll", { detail: { id: p.id } }));
}}>+ Neues Protokoll</button>
</div>
{projProtokolle.length > 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: 60, textAlign: "center" }}>TN</th>
<th style={{ width: 50, textAlign: "center" }}>📌</th>
<th style={{ width: 80 }}></th>
</tr>
</thead>
<tbody>
{projProtokolle.map(proto => {
const anwesend = (proto.participants || []).filter(x => x.status === "anwesend").length;
const total = (proto.participants || []).length;
const offene = (proto.traktanden || []).flatMap(t => (t.items || []).filter(it => it.type === "aufgabe" && it.status !== "erledigt")).length;
return (
<tr key={proto.id}>
<td><strong style={{ color: "#b07848" }}>{proto.nummer}</strong></td>
<td>{formatDate(proto.date)}</td>
<td style={{ fontSize: 11, color: "#888" }}>{proto.type}</td>
<td>{proto.title || <span style={{ color: "#aaa" }}>Kein Titel</span>}</td>
<td style={{ textAlign: "center", fontSize: 12, color: "#888" }}>{total > 0 ? `${anwesend}/${total}` : "—"}</td>
<td style={{ textAlign: "center" }}>{offene > 0 && <span style={{ fontSize: 11, color: "#b5621e", fontWeight: 600 }}>{offene}</span>}</td>
<td style={{ textAlign: "right" }}>
<button className="btn btn-ghost" style={{ fontSize: 11, padding: "4px 10px" }}
onClick={() => { window.__openProtokoll = proto.id; window.dispatchEvent(new CustomEvent("openProtokoll", { detail: { id: proto.id } })); }}>
Öffnen
</button>
</td>
</tr>
);
})}
</tbody>
</table>
)}
</div>
);
})()}
{/* Einstellungen Modal */}
{settingsOpen && (() => {
const projectContacts = settingsForm.projectContacts || [];
const allContacts = (data.persons || []).filter(p => p.isPartner);
const alreadyAdded = new Set(projectContacts.map(pc => pc.contactId));
const selectedFirm = allContacts.find(c => c.id === addContactId);
const addBeteiligte = () => {
if (!addContactId) return;
setSettingsForm(f => ({ ...f, projectContacts: [...(f.projectContacts || []), { contactId: addContactId, personIds: addPersonIds }] }));
setShowAddBeteiligter(false); setAddContactId(""); setAddPersonIds([]);
};
const saveEditedBeteiligte = (contactId) => {
setSettingsForm(f => ({ ...f, projectContacts: (f.projectContacts || []).map(pc => pc.contactId === contactId ? { ...pc, personIds: addPersonIds } : pc) }));
setEditingContactId(null); setAddPersonIds([]);
};
const removeBeteiligte = (contactId) => {
setSettingsForm(f => ({ ...f, projectContacts: (f.projectContacts || []).filter(pc => pc.contactId !== contactId) }));
};
const togglePerson = (personId) => setAddPersonIds(prev =>
prev.includes(personId) ? prev.filter(id => id !== personId) : [...prev, personId]);
return (
<Modal title="Projekteinstellungen" onClose={() => setSettingsOpen(false)} onSave={saveSettings} wide>
<ProjectEditForm form={settingsForm} setForm={setSettingsForm} data={data} />
<div style={{ marginTop: 20, paddingTop: 16, borderTop: "1px solid #ece8e2" }}>
<div style={{ fontSize: 11, fontWeight: 600, letterSpacing: "0.08em", color: "#888", marginBottom: 12 }}>INTERNE PROJEKTBETEILIGUNG</div>
{(data.employees || []).length === 0
? <div style={{ fontSize: 12, color: "#aaa", marginBottom: 10 }}>Noch keine Mitarbeitenden erfasst.</div>
: <div style={{ display: "flex", flexWrap: "wrap", gap: 8, marginBottom: 4 }}>
{(data.employees || []).map(emp => {
const checked = (settingsForm.internalMembers || []).includes(emp.id);
return (
<label key={emp.id} style={{ display: "flex", alignItems: "center", gap: 6, cursor: "pointer", fontSize: 13, background: checked ? "#f0ede8" : "#faf9f7", border: `1px solid ${checked ? "#c8b89a" : "#ece8e2"}`, borderRadius: 8, padding: "4px 10px" }}>
<input type="checkbox" checked={checked} onChange={() => setSettingsForm(f => ({ ...f, internalMembers: checked ? (f.internalMembers || []).filter(id => id !== emp.id) : [...(f.internalMembers || []), emp.id] }))} style={{ margin: 0 }} />
<span>{emp.name}</span>
{emp.role && <span style={{ fontSize: 10, color: "#aaa" }}>{emp.role}</span>}
</label>
);
})}
</div>
}
</div>
<div style={{ marginTop: 20, paddingTop: 16, borderTop: "1px solid #ece8e2" }}>
<div style={{ fontSize: 11, fontWeight: 600, letterSpacing: "0.08em", color: "#888", marginBottom: 12 }}>PROJEKTBETEILIGTE</div>
{projectContacts.map(pc => {
const firm = allContacts.find(c => c.id === pc.contactId);
if (!firm) return null;
const selectedPersons = (firm.contacts || []).filter(p => pc.personIds.includes(p.id));
const isEditing = editingContactId === pc.contactId;
return (
<div key={pc.contactId} style={{ padding: "10px 0", borderBottom: "1px solid #f5f2ec" }}>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start" }}>
<div>
{firm.type && <div style={{ fontSize: 10, color: "#aaa", marginBottom: 2, letterSpacing: "0.06em" }}>{firm.type.toUpperCase()}</div>}
<div style={{ fontWeight: 600, fontSize: 13 }}>{firm.name}</div>
{!isEditing && (selectedPersons.length > 0
? <div style={{ marginTop: 4, display: "flex", flexWrap: "wrap", gap: 6 }}>
{selectedPersons.map(p => <span key={p.id} style={{ fontSize: 11, background: "#f5f2ec", color: "#555", padding: "2px 8px", borderRadius: 10 }}>{p.name}{p.position ? " · " + p.position : ""}</span>)}
</div>
: <div style={{ fontSize: 11, color: "#aaa", marginTop: 3 }}>Alle Ansprechpartner</div>
)}
</div>
<div style={{ display: "flex", gap: 4 }}>
{(firm.contacts || []).length > 0 && !isEditing && (
<button className="btn btn-ghost" style={{ fontSize: 11, padding: "3px 8px" }}
onClick={() => { setEditingContactId(pc.contactId); setAddPersonIds(pc.personIds || []); setShowAddBeteiligter(false); }}><span className="material-icons" style={{ fontSize: 16 }}>edit</span></button>
)}
<button className="btn btn-ghost" style={{ fontSize: 11, padding: "3px 8px", color: "#888" }} onClick={() => removeBeteiligte(pc.contactId)}><span className="material-icons" style={{ fontSize: 16 }}>close</span></button>
</div>
</div>
{isEditing && (
<div style={{ marginTop: 8 }}>
<div style={{ fontSize: 11, color: "#888", marginBottom: 6 }}>Personen (leer = alle):</div>
{(firm.contacts || []).map(p => (
<label key={p.id} style={{ display: "flex", alignItems: "center", gap: 8, cursor: "pointer", fontSize: 13, marginBottom: 4 }}>
<input type="checkbox" checked={addPersonIds.includes(p.id)} onChange={() => togglePerson(p.id)} />
<span>{p.name}</span>
{p.position && <span style={{ fontSize: 11, color: "#888" }}>{p.position}</span>}
</label>
))}
<div style={{ display: "flex", gap: 8, marginTop: 8 }}>
<button className="btn btn-primary" style={{ fontSize: 12 }} onClick={() => saveEditedBeteiligte(pc.contactId)}>Speichern</button>
<button className="btn btn-ghost" style={{ fontSize: 12 }} onClick={() => { setEditingContactId(null); setAddPersonIds([]); }}>Abbrechen</button>
</div>
</div>
)}
</div>
);
})}
{projectContacts.length === 0 && !showAddBeteiligter && (
<div style={{ fontSize: 12, color: "#aaa", marginBottom: 10 }}>Noch keine Beteiligten erfasst.</div>
)}
{showAddBeteiligter ? (
<div style={{ marginTop: 12 }}>
<select value={addContactId} onChange={e => { setAddContactId(e.target.value); setAddPersonIds([]); }} style={{ width: "100%", height: 34, marginBottom: 10 }}>
<option value=""> Firma wählen </option>
{allContacts.filter(c => !alreadyAdded.has(c.id)).sort((a, b) => a.name.localeCompare(b.name)).map(c => (
<option key={c.id} value={c.id}>{c.type ? "[" + c.type + "] " : ""}{c.name}</option>
))}
</select>
{selectedFirm && (selectedFirm.contacts || []).length > 0 && (
<div style={{ marginBottom: 10 }}>
<div style={{ fontSize: 11, color: "#888", marginBottom: 6 }}>Personen (leer = alle):</div>
{(selectedFirm.contacts || []).map(p => (
<label key={p.id} style={{ display: "flex", alignItems: "center", gap: 8, cursor: "pointer", fontSize: 13, marginBottom: 4 }}>
<input type="checkbox" checked={addPersonIds.includes(p.id)} onChange={() => togglePerson(p.id)} />
<span>{p.name}</span>
{p.position && <span style={{ fontSize: 11, color: "#888" }}>{p.position}</span>}
</label>
))}
</div>
)}
<div style={{ display: "flex", gap: 8 }}>
<button className="btn btn-primary" style={{ fontSize: 12 }} onClick={addBeteiligte} disabled={!addContactId}>Hinzufügen</button>
<button className="btn btn-ghost" style={{ fontSize: 12 }} onClick={() => { setShowAddBeteiligter(false); setAddContactId(""); setAddPersonIds([]); }}>Abbrechen</button>
</div>
</div>
) : (
<button className="btn btn-ghost" style={{ fontSize: 12, marginTop: 10 }} onClick={() => setShowAddBeteiligter(true)}>+ Beteiligten hinzufügen</button>
)}
</div>
</Modal>
);
})()}
{/* Budget / Offerten-Modal */}
{budgetModal && (() => {
const ROLES = ["Hauptofferte", "Nachtrag", "Referenz"];
const linked = budgetForm.linkedQuotes || [];
const nachtragLinked = linked.filter(lq => lq.role !== "Hauptofferte" && lq.role !== "Referenz");
const alreadyInOtherProjects = new Set((data.projects || []).filter(p => p.id !== projectId).flatMap(p => migrateLinkedQuotes(p).map(lq => lq.quoteId)));
const unlinked = (data.quotes || []).filter(q => !linked.some(lq => lq.quoteId === q.id) && !alreadyInOtherProjects.has(q.id));
const sortedLinked = [
...linked.filter(lq => lq.role === "Hauptofferte"),
...linked.filter(lq => lq.role !== "Hauptofferte"),
];
return (
<Modal title="Budget & Honorarofferten" onClose={() => setBudgetModal(false)} onSave={saveBudget} wide>
<div style={{ fontSize: 11, fontWeight: 600, letterSpacing: "0.08em", color: "#888", marginBottom: 10 }}>VERKNÜPFTE OFFERTEN</div>
{sortedLinked.map((lq, lqIdx) => {
const q = (data.quotes || []).find(x => x.id === lq.quoteId);
if (!q) return null;
const isNT = lq.role !== "Hauptofferte";
const ntIdx = isNT ? nachtragLinked.findIndex(x => x.quoteId === lq.quoteId) : -1;
const posCode = isNT ? `N${ntIdx + 1}` : null;
const pos = isNT
? ((budgetForm.positions || []).find(p => p.quoteId === lq.quoteId) || (posCode ? (budgetForm.positions || []).find(p => p.code === posCode) : null))
: null;
const currentPhases = isNT ? (pos?.enabledPhases || []) : (budgetForm.enabledPhases || []);
const togglePhase = (phId) => {
if (isNT) {
setBudgetForm(f => ({
...f,
positions: f.positions.map(p =>
(p.quoteId ? p.quoteId === lq.quoteId : p.code === posCode)
? { ...p, enabledPhases: (p.enabledPhases || []).includes(phId) ? (p.enabledPhases || []).filter(x => x !== phId) : [...(p.enabledPhases || []), phId] }
: p
),
}));
} else {
setBudgetForm(f => {
const phases = f.enabledPhases || [];
return { ...f, enabledPhases: phases.includes(phId) ? phases.filter(p => p !== phId) : [...phases, phId] };
});
}
};
const qRoles = q.quoteRoles || data.settings.roles || [];
const qH = q.mode === "sia" ? (calcSIAHours(q.sia?.baukosten, q.sia?.schwierigkeit, q.sia?.phases || []).total || 0)
: q.mode === "manual" ? (calcManualHours(q.manualPhases || [], qRoles).totalHours || 0) : 0;
const bgColor = isNT ? "#f0f5fd" : "#faf8f5";
const borderColor = isNT ? "#c8d8ee" : "#ddd8d0";
const accentColor = isNT ? "#1a4e8a" : "#2d6a4f";
return (
<div key={lq.quoteId} style={{ marginBottom: 12, border: `1px solid ${borderColor}`, borderRadius: 8, overflow: "hidden" }}>
<div style={{ display: "flex", alignItems: "center", gap: 8, padding: "10px 14px", background: bgColor, flexWrap: "wrap" }}>
{isNT && pos ? (
<>
<input value={pos.code || ""} onChange={e => setBudgetForm(f => ({ ...f, positions: f.positions.map(p => p.quoteId === lq.quoteId ? { ...p, code: e.target.value.toUpperCase().replace(/\s/g, "").slice(0, 6) } : p) }))} placeholder="N1" style={{ width: 52, fontWeight: 700, fontSize: 12, height: 26, padding: "0 6px" }} />
<input value={pos.label || ""} onChange={e => setBudgetForm(f => ({ ...f, positions: f.positions.map(p => p.quoteId === lq.quoteId ? { ...p, label: e.target.value } : p) }))} placeholder="z.B. Nachtrag Fassade" style={{ flex: 1, minWidth: 80, fontSize: 12, height: 26, padding: "0 6px" }} />
</>
) : (
<span style={{ fontSize: 10, fontWeight: 700, background: "#e8f5ee", color: accentColor, padding: "2px 8px", borderRadius: 3 }}> HO</span>
)}
<span style={{ fontWeight: 600, color: "#b07848" }}>{q.number}</span>
<span style={{ color: "#888", fontSize: 11 }}>{q.mode === "sia" ? "SIA 102" : q.mode === "manual" ? "Aufwand" : "Pauschal"}</span>
{qH > 0 && <span style={{ color: "#555", fontSize: 11 }}>{qH.toFixed(1)}h · {formatCHF(q.sub || q.total || 0)}</span>}
<div style={{ marginLeft: "auto", display: "flex", alignItems: "center", gap: 6 }}>
<select value={lq.role} onChange={e => changeLinkedRole(lq.quoteId, e.target.value)} style={{ fontSize: 11, height: 26, padding: "0 6px" }}>
{ROLES.map(r => <option key={r}>{r}</option>)}
</select>
<button className="btn btn-danger" style={{ padding: "0 6px", height: 24, fontSize: 10 }} onClick={() => removeLinkedQuote(lq.quoteId)}><span className="material-icons" style={{ fontSize: 16 }}>close</span></button>
</div>
</div>
<div style={{ padding: "10px 14px", background: "#fff" }}>
<div style={{ fontSize: 10, color: "#888", marginBottom: 6, letterSpacing: "0.07em" }}>AKTIVIERTE SIA-PHASEN FÜR ZEITERFASSUNG</div>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 4 }}>
{SIA_PHASES.map(ph => (
<label key={ph.id} style={{ display: "flex", alignItems: "center", gap: 8, cursor: "pointer", fontSize: 12, textTransform: "none", color: "#1a1a18", padding: "3px 0" }}>
<input type="checkbox" checked={currentPhases.includes(ph.id)} onChange={() => togglePhase(ph.id)} style={{ width: "auto" }} />
{ph.label}
</label>
))}
</div>
</div>
</div>
);
})}
{linked.length === 0 && (
<div style={{ fontSize: 12, color: "#aaa", marginBottom: 10 }}>Noch keine Offerte verknüpft. SIA, Aufwand und Pauschal-Offerten können verknüpft werden.</div>
)}
{unlinked.length > 0 && (
<select defaultValue="" onChange={e => { if (e.target.value) { addLinkedQuote(e.target.value); e.target.value = ""; } }} style={{ fontSize: 12, width: "100%", marginBottom: 16 }}>
<option value="">{linked.length === 0 ? "Offerte verknüpfen…" : "+ Nachtrag / weitere Offerte hinzufügen…"}</option>
{unlinked.map(q => <option key={q.id} value={q.id}>{q.number} {q.mode === "sia" ? "SIA 102" : q.mode === "manual" ? "Aufwand" : "Pauschal"} · {formatCHF(q.total)}</option>)}
</select>
)}
<FormField label="Stundenbudget manuell überschreiben (leert Offerten-Verknüpfung)">
<input type="number" step="0.5" min={0} value={budgetForm.budgetHours || 0}
onChange={e => setBudgetForm(f => ({ ...f, budgetHours: +e.target.value, linkedQuotes: [], phasesBudget: [] }))}
placeholder="0 = aus Offerten berechnet" />
</FormField>
</Modal>
);
})()}
</div>
);
}