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