Rapport 0.6 — Initial Public Release

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

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

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

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-13 01:16:26 +02:00
commit 00f07d76f6
65 changed files with 28010 additions and 0 deletions
+980
View File
@@ -0,0 +1,980 @@
import React, { useState, useEffect, useCallback, useMemo } from "react";
import { SIA_PHASES, SIA_PHASE_WEIGHTS, STORAGE_KEY } from "../constants.js";
import { calcSIAHours, calcManualHours, generateId, formatCHF, formatDate, formatHours, roundCHF, applyProjectNumberFormat, migrateLinkedQuotes, deriveQuoteBudget } from "../utils.js";
import { Header, Modal, FormField, StatusBadge, StatusSelect, useConfirm , DateInput } from "../components/UI.jsx";
export default
function Quotes({ data, update, setData, saveAll, modal, setModal, setPrintContent, setView, onSelectProject }) {
const clients = (data.persons || []).filter(p => p.isAuftraggeber);
const roles = data.settings.roles || [];
const defaultRolesForPhase = () => {
const obj = {};
roles.forEach(r => { obj[r.id] = 0; });
return obj;
};
const emptyManualPhases = () => SIA_PHASES.map(p => ({
id: p.id, label: p.label, enabled: ["31","32","33","41","51","52","53"].includes(p.id),
hoursByRole: defaultRolesForPhase(),
}));
const emptySIAPhases = () => SIA_PHASE_WEIGHTS.map(ph => ({
id: ph.id, label: ph.label,
items: ph.items.map(it => ({ ...it, enabled: true, r: 1 })),
}));
const defaultQuoteRoles = () => (data.settings.roles || []).map(r => ({ ...r }));
const emptyForm = {
number: "", clientId: "", projectId: "", projectName: "", date: new Date().toISOString().slice(0,10),
validUntil: "", mode: "sia", notes: "", status: "entwurf", mwst: true,
manualPhases: emptyManualPhases(),
quoteRoles: defaultQuoteRoles(),
sia: { baukosten: 0, schwierigkeit: 1, stundenansatz: data.settings.defaultHourlyRate || 120, phases: emptySIAPhases() },
freeItems: [{ id: generateId(), desc: "", qty: 1, price: 0 }],
};
const [form, setForm] = useState(emptyForm);
const [filter, setFilter] = useState(() => { const cid = window.__navClientId || ""; window.__navClientId = null; return { status: "", search: "", clientId: cid, year: "" }; });
const [groupBy, setGroupBy] = useState("date");
const [sort, setSort] = useState({ col: "date", dir: -1 });
const [compact, setCompact] = useState(true);
const { askConfirm, ConfirmModalEl } = useConfirm();
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 nextNum = () => {
const y = new Date().getFullYear();
const nums = (data.quotes||[]).filter(q => q.number?.startsWith("O"+y+"-")).map(q => parseInt(q.number.split("-")[1]||"0")).filter(Boolean);
return "O"+y+"-"+String((nums.length ? Math.max(...nums)+1 : 1)).padStart(3,"0");
};
const openNew = () => {
const vd = new Date(); vd.setDate(vd.getDate()+60);
setForm({ ...emptyForm, number: nextNum(), validUntil: vd.toISOString().slice(0,10), manualPhases: emptyManualPhases(), quoteRoles: defaultQuoteRoles(), sia: { ...emptyForm.sia, phases: emptySIAPhases() } });
setModal({ type: "quote" });
};
const openEdit = (q) => {
setForm({ ...emptyForm, ...q, manualPhases: q.manualPhases || emptyManualPhases(), quoteRoles: q.quoteRoles || defaultQuoteRoles(), sia: q.sia || emptyForm.sia, freeItems: q.freeItems || [{ id: generateId(), desc: "", qty: 1, price: 0 }] });
setModal({ type: "quote", id: q.id });
};
const del = async (id) => { if (await askConfirm("Offerte löschen?")) update("quotes", (data.quotes||[]).filter(q => q.id !== id)); };
const setStatus = (id, st) => update("quotes", (data.quotes||[]).map(q => q.id === id ? { ...q, status: st } : q));
const createProjectFromQuote = (q) => {
const qRoles = q.quoteRoles || data.settings.roles || [];
const siaH = q.mode === "sia" ? calcSIAHours(q.sia?.baukosten, q.sia?.schwierigkeit, q.sia?.phases || []) : null;
const manH = q.mode === "manual" ? calcManualHours(q.manualPhases || [], qRoles) : null;
const budgetHours = q.mode === "sia" ? Math.round((siaH?.total || 0) * 10) / 10
: q.mode === "manual" ? Math.round((manH?.totalHours || 0) * 10) / 10 : 0;
const enabledPhases = q.mode === "sia"
? (siaH?.phases || []).filter(ph => ph.hours > 0).map(ph => ph.id)
: q.mode === "manual"
? (q.manualPhases || []).filter(ph => ph.enabled).map(ph => ph.id)
: [];
const phasesBudget = q.mode === "sia"
? (siaH?.phases || []).filter(ph => ph.hours > 0).map(ph => ({ id: ph.id, hours: Math.round(ph.hours * 10) / 10 }))
: q.mode === "manual"
? (q.manualPhases || []).filter(ph => ph.enabled).map(ph => ({
id: ph.id, hours: Math.round(qRoles.reduce((s, r) => s + (ph.hoursByRole?.[r.id] || 0), 0) * 10) / 10
}))
: [];
// Projektnummer generieren
const fmt = data.settings.projectNumberFormat || "YYYY/NN";
const currentYear = new Date().getFullYear();
const lastSeq = data.settings.lastProjectYear === currentYear ? (data.settings.lastProjectSeq || 0) : 0;
const nextSeq = lastSeq >= 99 ? 1 : lastSeq + 1;
const projNumber = applyProjectNumberFormat(fmt, nextSeq);
const existingProj = data.projects.find(p => p.id === q.projectId);
const projName = q.projectName || existingProj?.name || ("Projekt " + q.number);
const stundenansatz = q.mode === "sia" ? (q.sia?.stundenansatz || data.settings.defaultHourlyRate)
: q.mode === "manual" ? (qRoles[0]?.rate || data.settings.defaultHourlyRate)
: data.settings.defaultHourlyRate;
const billingType = q.mode === "manual" ? "stundensatz" : "pauschal";
const budgetFromQuote = q.mode === "free"
? (q.freeItems || []).reduce((s, it) => s + it.qty * it.price, 0)
: q.sub || 0;
const newProj = {
id: generateId(),
number: projNumber,
name: projName,
clientId: q.clientId || "",
category: existingProj?.category || "Direktauftrag",
billingType,
hourlyRate: stundenansatz,
budget: billingType === "pauschal" ? budgetFromQuote : 0,
status: "aktiv",
description: "",
startDate: new Date().toISOString().slice(0, 10),
enabledPhases,
budgetHours,
budgetAmount: budgetFromQuote,
phasesBudget,
linkedQuotes: [{ quoteId: q.id, role: "Hauptofferte" }],
createdAt: new Date().toISOString(),
};
const newSettings = { ...data.settings, lastProjectSeq: nextSeq, lastProjectYear: currentYear };
// Offerte mit neuem Projekt verknüpfen
const updatedQuotes = (data.quotes || []).map(x => x.id === q.id ? { ...x, projectId: newProj.id } : x);
saveAll({ ...data, projects: [...data.projects, newProj], quotes: updatedQuotes, settings: newSettings });
if (setView && onSelectProject) { setView("projects"); onSelectProject(newProj.id); }
};
// Berechnungen
const activeRoles = form.quoteRoles || roles;
const siaCalc = form.mode === "sia" ? calcSIAHours(form.sia.baukosten, form.sia.schwierigkeit, form.sia.phases) : null;
const manCalc = form.mode === "manual" ? calcManualHours(form.manualPhases, activeRoles) : null;
const freeSubTotal = form.mode === "free" ? (form.freeItems || []).reduce((s, it) => s + (it.qty * it.price), 0) : 0;
const subTotal = form.mode === "sia" ? (siaCalc?.total || 0) * (form.sia.stundenansatz || 0) : form.mode === "manual" ? (manCalc?.totalAmount || 0) : freeSubTotal;
const totalHours = form.mode === "sia" ? (siaCalc?.total || 0) : form.mode === "manual" ? (manCalc?.totalHours || 0) : 0;
const taxRate = data.settings.mwstRate || 8.1;
const tax = form.mwst ? subTotal * (taxRate / 100) : 0;
const total = roundCHF(subTotal + tax);
const save = () => {
if (!form.clientId) { alert("Bitte einen Kunden auswählen."); return; }
// Projektname oder verknüpftes Projekt
if (!form.projectId && !form.projectName?.trim()) {
alert("Bitte einen Projektnamen eingeben oder ein Projekt verknüpfen."); return;
}
// Mindestens eine Position
if (form.mode === "free") {
const hasItem = (form.freeItems || []).some(it => it.desc?.trim() || it.price > 0);
if (!hasItem) { alert("Bitte mindestens eine Position mit Beschreibung oder Betrag erfassen."); return; }
} else if (form.mode === "manual") {
const hasPhase = (form.manualPhases || []).some(ph => ph.enabled && Object.values(ph.hoursByRole || {}).some(h => h > 0));
if (!hasPhase) { alert("Bitte mindestens eine Phase mit Stunden erfassen."); return; }
} else if (form.mode === "sia") {
if (!form.sia?.baukosten || form.sia.baukosten <= 0) {
alert("Bitte Baukosten eingeben (SIA-Modus)."); return;
}
}
const q = { ...form, sub: subTotal, totalHours, tax, total, id: modal?.id || generateId(), createdAt: modal?.id ? form.createdAt : new Date().toISOString() };
const quotes = modal?.id ? (data.quotes||[]).map(x => x.id === modal.id ? q : x) : [...(data.quotes||[]), q];
update("quotes", quotes);
setModal(null);
};
const [projectModal, setProjectModal] = useState(null); // quote object when open
const [pmMode, setPmMode] = useState("new");
const [pmName, setPmName] = useState("");
const [pmAttachId, setPmAttachId] = useState("");
// Hilfsfunktion: erstellt die eigentliche Rechnung (UNUSED - kept for reference)
const createInvoice_UNUSED = (q, mode, value) => {
const existing = data.invoices.filter(i => i.quoteId === q.id);
const totalInvoiced = existing.reduce((s, i) => s + (i.sub || 0), 0);
const totalQuoteSub = q.sub || 0;
const remaining = Math.max(0, totalQuoteSub - totalInvoiced);
// Rechnungsnummer (format-aware)
const _fmt = data.settings.invoiceNumberFormat || "YYYY-NNN";
const _now = new Date();
const _yyyy = String(_now.getFullYear()); const _yy = _yyyy.slice(2);
const _pat = _fmt.replace(/[-/\^$*+?.()|[\]{}]/g,"\\$&").replace(/YYYY/g,_yyyy).replace(/YY/g,_yy).replace(/N+/,"(\\d+)");
const _rx = new RegExp("^"+_pat+"$");
const _nums = data.invoices.map(i => { const m=(i.number||"").match(_rx); return m?parseInt(m[1]):null; }).filter(n=>n!==null);
const _seq = _nums.length ? Math.max(..._nums)+1 : 1;
const _pad = (_fmt.match(/N+/)||["NNN"])[0].length;
const invNum = _fmt.replace(/YYYY/g,_yyyy).replace(/YY/g,_yy).replace(/N+/,String(_seq).padStart(_pad,"0"));
let items = [];
let invoiceKind = "voll";
if (mode === "voll") {
if (existing.length > 0) {
// Restbetrag als Schlussrechnung
invoiceKind = "schluss";
items = [{
id: generateId(),
desc: `Schlussrechnung gemäss Offerte ${q.number}`,
qty: 1, price: Math.round(remaining * 100) / 100, discount: 0,
}];
} else {
invoiceKind = "voll";
if (q.mode === "sia" && q.sia) {
const c = calcSIAHours(q.sia.baukosten, q.sia.schwierigkeit, q.sia.phases);
items = c.phases.filter(ph => ph.hours > 0).map(ph => ({
id: generateId(), desc: "Phase "+ph.id+" "+ph.label,
qty: Math.round(ph.hours*100)/100, price: q.sia.stundenansatz, discount: 0,
}));
} else if (q.mode === "manual") {
const c = calcManualHours(q.manualPhases, q.quoteRoles || roles);
items = c.phases.filter(ph => ph.totalHours > 0).map(ph => ({
id: generateId(), desc: ph.label,
qty: Math.round(ph.totalHours*100)/100,
price: ph.totalHours > 0 ? Math.round(ph.totalAmount/ph.totalHours*100)/100 : 0, discount: 0,
}));
} else if (q.mode === "free") {
items = (q.freeItems || []).filter(it => it.desc || it.price).map(it => ({
id: generateId(), desc: it.desc, qty: it.qty, price: it.price, discount: 0,
}));
}
}
} else if (mode === "akonto-percent") {
invoiceKind = "akonto";
const akontoBetrag = totalQuoteSub * (value / 100);
items = [{
id: generateId(),
desc: `Akontorechnung gemäss Offerte ${q.number} (${value.toFixed(1)}% des Gesamthonorars)`,
qty: 1, price: Math.round(akontoBetrag * 100) / 100, discount: 0,
}];
} else if (mode === "akonto-amount") {
invoiceKind = "akonto";
const pct = totalQuoteSub > 0 ? (value / totalQuoteSub) * 100 : 0;
items = [{
id: generateId(),
desc: `Akontorechnung gemäss Offerte ${q.number}${pct > 0 ? ` (${pct.toFixed(1)}% des Gesamthonorars)` : ""}`,
qty: 1, price: value, discount: 0,
}];
}
if (items.length === 0) { alert("Keine Positionen — Offerte ist leer oder fehlerhaft."); return; }
const due = new Date(); due.setDate(due.getDate()+30);
const sub = items.reduce((s,it) => s + it.qty*it.price, 0);
const t = q.mwst ? sub*(taxRate/100) : 0;
let notes = `Gemäss Offerte ${q.number}. Zahlbar innert 30 Tagen netto.`;
if (invoiceKind === "schluss" && existing.length > 0) {
const akontoListe = existing.map(i => ` ${i.number}: CHF ${(i.sub||0).toFixed(2)}`).join("\n");
notes = `Schlussrechnung gemäss Offerte ${q.number}.\nBisherige Akontorechnungen:\n${akontoListe}\n\nZahlbar innert 30 Tagen netto.`;
}
const newInv = {
id: generateId(), number: invNum, clientId: q.clientId, projectId: q.projectId, quoteId: q.id,
invoiceKind,
date: new Date().toISOString().slice(0,10), dueDate: due.toISOString().slice(0,10),
items, mwst: q.mwst, notes,
status: "entwurf", discountType: "none", discountValue: 0, discountLabel: "Rabatt",
sub, subAfterDisc: sub, globalDisc: 0, tax: t, total: roundCHF(sub+t), createdAt: new Date().toISOString(),
};
// Beide Updates atomar
setData(prev => {
const updatedInvoices = [...prev.invoices, newInv];
const updatedQuotes = (prev.quotes || []).map(x => x.id === q.id ? {
...x, status: mode === "schluss" ? "angenommen" : x.status,
} : x);
const next = { ...prev, invoices: updatedInvoices, quotes: updatedQuotes };
try { localStorage.setItem(STORAGE_KEY, JSON.stringify(next)); } catch {}
return next;
});
};
const convertToInvoice = (q) => {
setView("invoices");
setModal({ type: "newInvoice", quoteId: q.id });
};
// SIA-Editor Helpers
const toggleSIAItem = (phId, idx) => {
setForm(f => ({ ...f, sia: { ...f.sia, phases: f.sia.phases.map(p => p.id !== phId ? p : { ...p, items: p.items.map((it,i) => i === idx ? { ...it, enabled: !it.enabled } : it) }) } }));
};
const updateSIAItem = (phId, idx, changes) => {
setForm(f => ({ ...f, sia: { ...f.sia, phases: f.sia.phases.map(p => p.id !== phId ? p : { ...p, items: p.items.map((it,i) => i === idx ? { ...it, ...changes } : it) }) } }));
};
const updateManualHours = (phId, roleId, val) => {
setForm(f => ({ ...f, manualPhases: f.manualPhases.map(p => p.id !== phId ? p : { ...p, hoursByRole: { ...p.hoursByRole, [roleId]: Math.max(0,+val||0) } }) }));
};
const toggleManualPhase = (phId, enabled) => {
setForm(f => ({ ...f, manualPhases: f.manualPhases.map(p => p.id !== phId ? p : { ...p, enabled }) }));
};
const filtered = [...(data.quotes||[])].filter(q => {
if (filter.status && q.status !== filter.status) return false;
if (filter.clientId && q.clientId !== filter.clientId) return false;
if (filter.year && !(q.date||"").startsWith(filter.year)) return false;
if (filter.search) {
const s = filter.search.toLowerCase();
const cl = clients.find(c => c.id === q.clientId);
if (![q.number,cl?.name,cl?.company,q.notes,q.projectName].filter(Boolean).join(" ").toLowerCase().includes(s)) return false;
}
return true;
}).sort((a, b) => {
let va, vb;
if (sort.col === "number") { va = a.number || ""; vb = b.number || ""; }
else if (sort.col === "date") { va = a.date || ""; vb = b.date || ""; }
else if (sort.col === "validUntil") { va = a.validUntil || ""; vb = b.validUntil || ""; }
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 === "total") { va = a.total || 0; vb = b.total || 0; }
else if (sort.col === "status") { va = a.status || ""; vb = b.status || ""; }
else { va = a.date || ""; vb = b.date || ""; }
return typeof va === "number" ? (va - vb) * sort.dir : va.localeCompare(vb) * sort.dir;
});
const availableQuoteYears = Array.from(new Set((data.quotes||[]).map(q => (q.date||"").slice(0,4)).filter(Boolean))).sort().reverse();
// Gruppieren
const groupedQuotes = (() => {
if (groupBy === "date") {
const months = {};
filtered.forEach(q => {
const key = (q.date || "").slice(0, 7);
if (!months[key]) months[key] = [];
months[key].push(q);
});
return Object.entries(months).sort((a, b) => b[0].localeCompare(a[0])).map(([key, items]) => ({
key, label: key ? new Date(key + "-01").toLocaleDateString("de-CH", { month: "long", year: "numeric" }) : "Ohne Datum", items,
}));
}
if (groupBy === "client") {
const clients = {};
filtered.forEach(q => {
const cl = clients.find(c => c.id === q.clientId);
const key = q.clientId || "__none__";
const label = cl?.name || "Kein Kunde";
if (!clients[key]) clients[key] = { label, items: [] };
clients[key].items.push(q);
});
return Object.entries(clients).sort((a, b) => a[1].label.localeCompare(b[1].label)).map(([key, val]) => ({ key, label: val.label, items: val.items }));
}
if (groupBy === "status") {
const statuses = {};
filtered.forEach(q => {
const key = q.status || "unbekannt";
if (!statuses[key]) statuses[key] = [];
statuses[key].push(q);
});
return Object.entries(statuses).sort((a, b) => a[0].localeCompare(b[0])).map(([key, items]) => ({ key, label: key.charAt(0).toUpperCase() + key.slice(1), items }));
}
return [{ key: "all", label: "", items: filtered }];
})();
return (
<div>
{ConfirmModalEl}
<Header title="Offerten" action={<button className="btn btn-primary" onClick={openNew}>+ Neue Offerte</button>} />
<div className="filter-bar">
<input className="pill" placeholder="Suche…" value={filter.search} onChange={e => setFilter({...filter, search: e.target.value})} style={{ minWidth: 180 }} />
<select className="pill" value={filter.status} onChange={e => setFilter({...filter, status: e.target.value})}>
<option value="">Alle Status</option>
{["entwurf","gesendet","angenommen","abgelehnt","abgelaufen"].map(s => <option key={s}>{s}</option>)}
</select>
<select className="pill" value={filter.clientId || ""} onChange={e => setFilter({...filter, clientId: e.target.value})}>
<option value="">Alle Kunden</option>
{clients.map(c => <option key={c.id} value={c.id}>{c.name}</option>)}
</select>
<select className="pill" value={filter.year || ""} onChange={e => setFilter({...filter, year: e.target.value})}>
<option value="">Alle Jahre</option>
{availableQuoteYears.map(y => <option key={y} value={y}>{y}</option>)}
</select>
<div style={{ marginLeft: "auto", fontSize: 12, color: "var(--text4)" }}>{filtered.length} Offerte{filtered.length !== 1 ? "n" : ""} · {formatCHF(filtered.reduce((s,q)=>s+(q.total||0),0))}</div>
</div>
<div className="filter-bar">
<span className="filter-label">GRUPPIEREN:</span>
{[{ id: "none", label: "Keine" }, { id: "date", label: "Monat" }, { id: "client", label: "Kunde" }, { id: "status", label: "Status" }].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: 100 }}>Nr.</SortTh><SortTh col="client">Kunde</SortTh><th className={compact ? "hide-compact" : ""}>Modus</th><SortTh col="date" className={compact ? "hide-compact" : ""}>Datum</SortTh><SortTh col="validUntil" className={compact ? "hide-compact" : ""}>Gültig bis</SortTh><SortTh col="total">Honorar</SortTh><SortTh col="status">Status</SortTh><th></th></tr></thead>
<tbody>
{filtered.length === 0 && <tr><td colSpan={8} style={{ textAlign: "center", color: "#aaa", padding: 32, fontSize: 13 }}>{(data.quotes||[]).length === 0 ? "Noch keine Offerten" : "Keine Treffer"}</td></tr>}
{groupedQuotes.map(group => (
<React.Fragment key={group.key}>
{groupBy !== "none" && group.label && (
<tr>
<td colSpan={8} 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} · {formatCHF(group.items.reduce((s, q) => s + (q.total || 0), 0))}
</span>
</td>
</tr>
)}
{group.items.map(q => {
const cl = clients.find(c => c.id === q.clientId);
const qInvoiced = q.status === "angenommen"
? (data.invoices || []).filter(i => i.quoteId === q.id).reduce((s, i) => s + (i.sub || 0), 0)
: 0;
return (
<tr key={q.id}>
<td><strong>{q.number}</strong>{q.projectName && <div style={{ fontSize: 11, color: "#888", marginTop: 1 }}>{q.projectName}</div>}</td>
<td>{cl?.name || "—"}</td>
<td style={{ fontSize: 11, color: "#888" }}>{q.mode === "sia" ? "SIA 102" : q.mode === "free" ? "Freie Positionen" : "Aufwand"}</td>
<td>{formatDate(q.date)}</td>
<td>{formatDate(q.validUntil)}</td>
<td>
<strong>{formatCHF(q.total)}</strong>
{qInvoiced > 0 && <div style={{ fontSize: 10, color: "#2d6a4f", marginTop: 1 }}>verrechnet {formatCHF(qInvoiced)}</div>}
</td>
<td>
<StatusSelect value={q.status} options={["entwurf","gesendet","angenommen","abgelehnt","abgelaufen"]} onChange={v => setStatus(q.id, v)} />
</td>
<td style={{ textAlign: "right", whiteSpace: "nowrap" }}>
<button className="btn btn-ghost" style={{ marginRight: 6, padding: "5px 10px", fontSize: 12 }} onClick={() => setPrintContent({ type: "quote", quote: q, client: cl, settings: data.settings })}>PDF</button>
{!q.convertedToInvoiceId && <button className="btn btn-ghost" style={{ marginRight: 6, padding: "5px 10px", fontSize: 12, borderColor: "#2d6a4f", color: "#2d6a4f" }} onClick={() => convertToInvoice(q)}> Rechnung</button>}
{(q.mode === "sia" || q.mode === "manual" || q.mode === "free") && (() => {
const linkedProj = data.projects.find(p => migrateLinkedQuotes(p).some(lq => lq.quoteId === q.id));
return linkedProj
? <button className="btn btn-ghost" style={{ marginRight: 6, padding: "5px 10px", fontSize: 12, borderColor: "#1a4e8a", color: "#1a4e8a" }} onClick={() => { const suggested = q.projectName || linkedProj.name || ("Projekt " + q.number); setPmMode("new"); setPmName(suggested); setPmAttachId(linkedProj.id); setProjectModal(q); }}> {linkedProj.number || "Projekt"}</button>
: <button className="btn btn-ghost" style={{ marginRight: 6, padding: "5px 10px", fontSize: 12, borderColor: "#7a6a00", color: "#7a6a00" }} onClick={() => { const suggested = q.projectName || (data.projects.find(p => p.id === q.projectId)?.name) || ("Projekt " + q.number); setPmMode("new"); setPmName(suggested); setPmAttachId(q.projectId || ""); setProjectModal(q); }}> Projekt</button>;
})()}
<button className="btn btn-ghost" style={{ marginRight: 6, padding: "5px 10px", fontSize: 12 }} onClick={() => openEdit(q)}><span className="material-icons" style={{ fontSize: 16 }}>edit</span></button>
<button className="btn btn-danger" style={{ padding: "5px 10px", fontSize: 12 }} onClick={() => del(q.id)}><span className="material-icons" style={{ fontSize: 16 }}>close</span></button>
</td>
</tr>
);
})}
</React.Fragment>
))}
</tbody>
</table>
</div>
{projectModal && (() => {
const q = projectModal;
const qRoles = q.quoteRoles || data.settings.roles || [];
const siaH = q.mode === "sia" ? calcSIAHours(q.sia?.baukosten, q.sia?.schwierigkeit, q.sia?.phases || []) : null;
const manH = q.mode === "manual" ? calcManualHours(q.manualPhases || [], qRoles) : null;
const budgetH = q.mode === "sia" ? Math.round((siaH?.total||0)*10)/10 : q.mode === "manual" ? Math.round((manH?.totalHours||0)*10)/10 : 0;
const stundenansatz = q.mode === "sia" ? (q.sia?.stundenansatz || data.settings.defaultHourlyRate) : data.settings.defaultHourlyRate;
const alreadyLinkedProject = data.projects.find(p => migrateLinkedQuotes(p).some(lq => lq.quoteId === q.id));
// Offerte entkoppeln
const doUnlink = () => {
const updatedProjects = data.projects.map(p => {
const linked = migrateLinkedQuotes(p).filter(lq => lq.quoteId !== q.id);
if (linked.length === migrateLinkedQuotes(p).length) return p;
const derived = deriveQuoteBudget(linked, data.quotes || [], data.settings.roles || []);
return { ...p, linkedQuotes: linked, budgetHours: derived.budgetHours, budgetAmount: derived.budgetAmount, phasesBudget: derived.phasesBudget };
});
const updatedQuotes = (data.quotes || []).map(x => x.id === q.id ? { ...x, projectId: null } : x);
saveAll({ ...data, projects: updatedProjects, quotes: updatedQuotes });
setProjectModal(null);
};
const doCreate = () => {
if (pmMode === "new") {
createProjectFromQuote({ ...q, projectName: pmName });
} else {
if (!pmAttachId) return;
const proj = data.projects.find(p => p.id === pmAttachId);
if (!proj) return;
const existingLinked = migrateLinkedQuotes(proj);
if (existingLinked.some(lq => lq.quoteId === q.id)) { setProjectModal(null); return; }
const newLinked = [...existingLinked, { quoteId: q.id, role: existingLinked.length === 0 ? "Hauptofferte" : "Nachtrag" }];
const derived = deriveQuoteBudget(newLinked, data.quotes || [], data.settings.roles || []);
const updatedProjects = data.projects.map(p => p.id === pmAttachId ? {
...p, linkedQuotes: newLinked, budgetHours: derived.budgetHours,
budgetAmount: derived.budgetAmount, phasesBudget: derived.phasesBudget,
enabledPhases: [...new Set([...(p.enabledPhases || []), ...derived.enabledPhases])],
hourlyRate: p.hourlyRate || stundenansatz,
} : p);
const updatedQuotes = (data.quotes || []).map(x => x.id === q.id ? { ...x, projectId: pmAttachId } : x);
saveAll({ ...data, projects: updatedProjects, quotes: updatedQuotes });
if (setView && onSelectProject) { setView("projects"); onSelectProject(pmAttachId); }
}
setProjectModal(null);
};
// Wenn Offerte bereits verknüpft: Entkoppeln-Dialog zeigen
if (alreadyLinkedProject) {
return (
<div className="modal-overlay" onClick={e => e.target === e.currentTarget && setProjectModal(null)}>
<div className="modal">
<h2 style={{ fontFamily: "'Playfair Display', serif", fontWeight: 400, marginBottom: 16, fontSize: 22 }}>Offerte verknüpft</h2>
<div style={{ marginBottom: 20, padding: 12, background: "#faf8f5", borderRadius: 6, border: "1px solid #ece8e2", fontSize: 12 }}>
<div style={{ fontSize: 10, letterSpacing: "0.08em", color: "#888", marginBottom: 6 }}>OFFERTE</div>
<div><strong>{q.number}</strong>{q.projectName ? " · " + q.projectName : ""}</div>
<div style={{ marginTop: 8, fontSize: 10, letterSpacing: "0.08em", color: "#888", marginBottom: 4 }}>VERKNÜPFTES PROJEKT</div>
<div><strong>{alreadyLinkedProject.number ? alreadyLinkedProject.number + " · " : ""}{alreadyLinkedProject.name}</strong></div>
</div>
<div style={{ display: "flex", flexDirection: "column", gap: 10, marginBottom: 20 }}>
<button className="btn btn-primary" onClick={() => { setView("projects"); onSelectProject(alreadyLinkedProject.id); setProjectModal(null); }}
style={{ padding: "12px 18px", textAlign: "left", display: "flex", justifyContent: "space-between", alignItems: "center" }}>
<span>Zum Projekt navigieren</span>
<span style={{ opacity: 0.7 }}></span>
</button>
<button className="btn btn-ghost" onClick={doUnlink}
style={{ padding: "12px 18px", textAlign: "left", display: "flex", justifyContent: "space-between", alignItems: "center", borderColor: "#c0a0a0", color: "#8a1a1a" }}>
<span>Offerte entkoppeln</span>
<span style={{ opacity: 0.7, fontSize: 11 }}>Verknüpfung aufheben</span>
</button>
</div>
<div style={{ display: "flex", justifyContent: "flex-end" }}>
<button className="btn btn-ghost" onClick={() => setProjectModal(null)}>Abbrechen</button>
</div>
</div>
</div>
);
}
return (
<Modal title="Projekt aus Offerte" onClose={() => setProjectModal(null)} onSave={doCreate} saveLabel={pmMode === "new" ? "Neues Projekt erstellen" : "Zu Projekt hinzufügen"}>
<div style={{ marginBottom: 16, padding: 12, background: "#faf8f5", borderRadius: 6, border: "1px solid #ece8e2", fontSize: 12 }}>
<div style={{ display: "flex", gap: 16, marginBottom: 4 }}>
<span><strong>{q.number}</strong> · {q.mode === "sia" ? "SIA 102" : q.mode === "manual" ? "Aufwand" : "Pauschal"}</span>
{budgetH > 0 && <span style={{ color: "#888" }}>{budgetH}h Soll-Budget</span>}
<span style={{ color: "#888" }}>CHF {stundenansatz}/h</span>
<span style={{ fontWeight: 600 }}>{formatCHF(q.total)}</span>
</div>
{q.projectName && <div style={{ color: "#555" }}>Auftragsbezeichnung: <strong>{q.projectName}</strong></div>}
</div>
<div style={{ display: "flex", gap: 0, marginBottom: 16, borderBottom: "1.5px solid #e0dbd4" }}>
{[{ id: "new", label: "Neues Projekt erstellen" }, { id: "attach", label: "Zu bestehendem Projekt" }].map(tab => (
<button key={tab.id} onClick={() => setPmMode(tab.id)} style={{
padding: "9px 18px", background: "transparent", border: "none", fontFamily: "inherit",
borderBottom: pmMode === tab.id ? "2px solid #1a1a18" : "2px solid transparent",
marginBottom: -1.5, color: pmMode === tab.id ? "#1a1a18" : "#888",
fontSize: 12, fontWeight: 500, cursor: "pointer",
}}>{tab.label}</button>
))}
</div>
{pmMode === "new" ? (
<div>
<FormField label="Projektname">
<input value={pmName} onChange={e => setPmName(e.target.value)} autoFocus placeholder="z.B. Umbau EFH Muster" />
</FormField>
<div style={{ fontSize: 11, color: "#888", marginTop: 4 }}>
Neues Projekt wird erstellt mit: Stundenbudget {budgetH}h, Stundensatz CHF {stundenansatz}/h, SIA-Phasen aus der Offerte.
</div>
</div>
) : (
<div>
<FormField label="Projekt auswählen">
<select value={pmAttachId} onChange={e => setPmAttachId(e.target.value)} autoFocus>
<option value=""> Projekt wählen </option>
{data.projects.map(p => (
<option key={p.id} value={p.id}>{p.number ? `${p.number} · ` : ""}{p.name}</option>
))}
</select>
</FormField>
{pmAttachId && (() => {
const proj = data.projects.find(p => p.id === pmAttachId);
const existing = migrateLinkedQuotes(proj);
const alreadyLinked = existing.some(lq => lq.quoteId === q.id);
return (
<div style={{ fontSize: 11, color: alreadyLinked ? "#b5621e" : "#888", marginTop: 4 }}>
{alreadyLinked
? "⚠ Diese Offerte ist bereits mit diesem Projekt verknüpft."
: `Offerte wird als ${existing.length === 0 ? "Hauptofferte" : "Nachtrag"} hinzugefügt. Stundenbudget: ${proj.budgetHours || 0}h + ${budgetH}h = ${(proj.budgetHours || 0) + budgetH}h`}
</div>
);
})()}
</div>
)}
</Modal>
);
})()}
{modal?.type === "quote" && (
<Modal title={modal.id ? "Offerte bearbeiten" : "Neue Offerte"} onClose={() => setModal(null)} onSave={save} wide>
<div className="form-row">
<FormField label="Nr."><input value={form.number} onChange={e => setForm({...form, number: e.target.value})} /></FormField>
<FormField label="Datum"><DateInput value={form.date} onChange={e => setForm({...form, date: e.target.value})} /></FormField>
<FormField label="Gültig bis"><DateInput value={form.validUntil} onChange={e => setForm({...form, validUntil: e.target.value})} /></FormField>
</div>
<div className="form-row">
<FormField label="Kunde *">
<select value={form.clientId} onChange={e => setForm({...form, clientId: e.target.value})} style={!form.clientId ? { borderColor: "#b5621e" } : {}}>
<option value=""> Kunde wählen (Pflichtfeld) </option>
{clients.map(c => <option key={c.id} value={c.id}>{c.name}</option>)}
</select>
</FormField>
<FormField label="Auftragsbezeichnung / Projektname">
<input value={form.projectName || ""} onChange={e => setForm({...form, projectName: e.target.value})} placeholder="z.B. Umbau EFH Muster, Neubau MFH…" />
</FormField>
<FormField label="Projekt verknüpfen (optional)">
<select value={form.projectId} onChange={e => setForm({...form, projectId: e.target.value, projectName: e.target.value ? (data.projects.find(p => p.id === e.target.value)?.name || form.projectName) : form.projectName})}>
<option value=""> kein Projekt </option>
{data.projects.map(p => <option key={p.id} value={p.id}>{p.number ? `${p.number} · ` : ""}{p.name}</option>)}
</select>
</FormField>
</div>
{/* Tab-Auswahl */}
<div style={{ display: "flex", gap: 0, marginBottom: 16, borderBottom: "1.5px solid #e0dbd4" }}>
{[{ id: "sia", label: "SIA 102 (Baukosten)" }, { id: "manual", label: "Aufwandschätzung (Stunden)" }, { id: "free", label: "Freie Positionen" }].map(tab => (
<button key={tab.id} onClick={() => setForm({...form, mode: tab.id})} style={{
padding: "10px 20px", background: "transparent", border: "none", fontFamily: "inherit",
borderBottom: form.mode === tab.id ? "2px solid #1a1a18" : "2px solid transparent",
marginBottom: -1.5, color: form.mode === tab.id ? "#1a1a18" : "#888",
fontSize: 12, fontWeight: 500, cursor: "pointer",
}}>{tab.label}</button>
))}
</div>
{/* Freier Modus */}
{form.mode === "free" && (
<div style={{ marginBottom: 16 }}>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 8 }}>
<div style={{ fontSize: 11, color: "#888" }}>Positionen frei definieren</div>
<button className="btn btn-ghost" style={{ padding: "4px 12px", fontSize: 11 }} onClick={() => setForm({...form, freeItems: [...(form.freeItems||[]), { id: generateId(), desc: "", qty: 1, price: 0 }]})}>+ Position</button>
</div>
<div style={{ borderRadius: 6, border: "1px solid #ece8e2", overflow: "hidden" }}>
<table style={{ fontSize: 12 }}>
<thead>
<tr style={{ background: "#f5f0e8" }}>
<th style={{ padding: "8px 10px" }}>Beschreibung</th>
<th style={{ padding: "8px 6px", textAlign: "right", width: 75 }}>Menge</th>
<th style={{ padding: "8px 6px", textAlign: "right", width: 100 }}>Preis CHF</th>
<th style={{ padding: "8px 6px", textAlign: "right", width: 100 }}>Total</th>
<th style={{ width: 36 }}></th>
</tr>
</thead>
<tbody>
{(form.freeItems || []).map((it, idx) => (
<tr key={it.id} style={{ borderTop: "1px solid #ece8e2" }}>
<td style={{ padding: "4px 6px" }}>
<input value={it.desc} onChange={e => setForm({...form, freeItems: form.freeItems.map((x,i) => i===idx ? {...x, desc: e.target.value} : x)})} placeholder="Leistungsbeschreibung" style={{ border: "1px solid #e0dbd4", height: 28, fontSize: 12 }} />
</td>
<td style={{ padding: "4px 4px" }}>
<input type="number" step="0.25" min={0} value={it.qty} onChange={e => setForm({...form, freeItems: form.freeItems.map((x,i) => i===idx ? {...x, qty: +e.target.value} : x)})} style={{ border: "1px solid #e0dbd4", height: 28, fontSize: 12, textAlign: "right" }} />
</td>
<td style={{ padding: "4px 4px" }}>
<input type="number" step="0.05" min={0} value={it.price} onChange={e => setForm({...form, freeItems: form.freeItems.map((x,i) => i===idx ? {...x, price: +e.target.value} : x)})} style={{ border: "1px solid #e0dbd4", height: 28, fontSize: 12, textAlign: "right" }} />
</td>
<td style={{ padding: "4px 6px", textAlign: "right", color: "#888" }}>{formatCHF(it.qty * it.price)}</td>
<td style={{ padding: "4px 4px", textAlign: "center" }}>
<button className="btn btn-danger" style={{ padding: "0 7px", height: 26, fontSize: 11 }} onClick={() => setForm({...form, freeItems: form.freeItems.filter((_,i) => i!==idx)})}><span className="material-icons" style={{ fontSize: 16 }}>close</span></button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
{/* SIA-Modus */}
{form.mode === "sia" && siaCalc && (
<div>
<div style={{ display: "grid", gridTemplateColumns: "repeat(3, 1fr)", gap: 10, marginBottom: 14, padding: 14, background: "#fff8ed", borderRadius: 6, border: "1px solid #f0e4c4" }}>
<FormField label="Baukosten (CHF)">
<input type="number" value={form.sia.baukosten} onChange={e => setForm({...form, sia: {...form.sia, baukosten: +e.target.value}})} style={{ background: "#fff" }} />
</FormField>
<FormField label="Schwierigkeitsgrad n">
<input type="number" step="0.1" value={form.sia.schwierigkeit} onChange={e => setForm({...form, sia: {...form.sia, schwierigkeit: +e.target.value}})} style={{ background: "#fff" }} />
</FormField>
<FormField label="Stundenansatz CHF/h">
<input type="number" value={form.sia.stundenansatz} onChange={e => setForm({...form, sia: {...form.sia, stundenansatz: +e.target.value}})} style={{ background: "#fff" }} />
</FormField>
</div>
<div style={{ display: "flex", gap: 16, marginBottom: 12, padding: "6px 14px", background: "#faf8f5", borderRadius: 6, fontSize: 11, color: "#666" }}>
<div>B = <strong style={{ color: "#1a1a18" }}>{siaCalc.cbrtB?.toFixed(1)}</strong></div>
<div>p = <strong style={{ color: "#1a1a18" }}>{siaCalc.p}</strong></div>
<div style={{ marginLeft: "auto" }}>Total <strong style={{ color: "#1a1a18" }}>{formatHours(Math.round(siaCalc.total * 60))}</strong> · <strong style={{ color: "#1a1a18" }}>{formatCHF(siaCalc.total * (form.sia.stundenansatz||0))}</strong></div>
</div>
<div style={{ marginBottom: 16, borderRadius: 6, border: "1px solid #ece8e2", overflow: "hidden" }}>
<table style={{ fontSize: 12 }}>
<thead>
<tr style={{ background: "#f5f0e8" }}>
<th style={{ padding: "8px", width: 30 }}></th>
<th style={{ padding: "8px" }}>Teilleistung</th>
<th style={{ padding: "8px", textAlign: "right", width: 55 }}>q %</th>
<th style={{ padding: "8px", textAlign: "right", width: 55 }}>r</th>
<th style={{ padding: "8px", textAlign: "right", width: 75 }}>Stunden</th>
<th style={{ padding: "8px", textAlign: "right", width: 95 }}>Honorar</th>
</tr>
</thead>
<tbody>
{siaCalc.phases.map(ph => (
<React.Fragment key={ph.id}>
<tr style={{ background: "#faf8f5" }}>
<td colSpan={4} style={{ padding: "6px 8px", fontSize: 11, fontWeight: 600, color: "#555" }}>PHASE {ph.id} · {ph.label}</td>
<td style={{ padding: "6px 8px", textAlign: "right", fontWeight: 600, fontSize: 11 }}>{formatHours(Math.round(ph.hours*60))}</td>
<td style={{ padding: "6px 8px", textAlign: "right", fontWeight: 600, fontSize: 11 }}>{formatCHF(ph.hours*(form.sia.stundenansatz||0))}</td>
</tr>
{ph.items.map((it, idx) => (
<tr key={idx} style={{ opacity: it.enabled !== false ? 1 : 0.35 }}>
<td style={{ padding: "4px 8px" }}>
<input type="checkbox" checked={it.enabled !== false} onChange={() => toggleSIAItem(ph.id, idx)} style={{ width: "auto" }} />
</td>
<td style={{ padding: "4px 8px" }}>{it.label}</td>
<td style={{ padding: "4px 8px" }}>
<input type="number" step="0.5" value={it.pct} onChange={e => updateSIAItem(ph.id, idx, { pct: +e.target.value })} disabled={it.enabled === false} style={{ width: 48, height: 26, fontSize: 11, textAlign: "right" }} />
</td>
<td style={{ padding: "4px 8px" }}>
<input type="number" step="0.05" value={it.r ?? 1} onChange={e => updateSIAItem(ph.id, idx, { r: +e.target.value })} disabled={it.enabled === false} style={{ width: 48, height: 26, fontSize: 11, textAlign: "right" }} />
</td>
<td style={{ padding: "4px 8px", textAlign: "right", color: "#888" }}>{formatHours(Math.round(it.hours*60))}</td>
<td style={{ padding: "4px 8px", textAlign: "right", color: "#888" }}>{formatCHF(it.hours*(form.sia.stundenansatz||0))}</td>
</tr>
))}
</React.Fragment>
))}
</tbody>
</table>
</div>
</div>
)}
{/* Manueller Modus */}
{form.mode === "manual" && manCalc && (
<div>
<div style={{ marginBottom: 14, padding: 12, background: "#fff8ed", borderRadius: 6, border: "1px solid #f0e4c4" }}>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 8 }}>
<div style={{ fontSize: 11, fontWeight: 600, letterSpacing: "0.08em", color: "#888" }}>ROLLEN &amp; STUNDENSÄTZE FÜR DIESE OFFERTE</div>
<div style={{ display: "flex", gap: 6 }}>
<button className="btn btn-ghost" style={{ fontSize: 11, padding: "3px 10px" }}
title="Rollen aus Einstellungen zurücksetzen"
onClick={() => setForm(f => ({ ...f, quoteRoles: defaultQuoteRoles() }))}>
Reset
</button>
<button className="btn btn-ghost" style={{ fontSize: 11, padding: "3px 10px" }}
onClick={() => {
const newId = "R" + (form.quoteRoles.length + 1);
setForm(f => ({ ...f, quoteRoles: [...f.quoteRoles, { id: newId, label: "Neue Rolle", rate: 120 }] }));
}}>
+ Rolle
</button>
</div>
</div>
<table style={{ width: "100%", fontSize: 12, borderCollapse: "collapse" }}>
<thead>
<tr style={{ borderBottom: "1px solid #e0dbd4" }}>
<th style={{ textAlign: "left", padding: "4px 6px", fontSize: 10, color: "#888", fontWeight: 500, width: 70 }}>KÜRZEL</th>
<th style={{ textAlign: "left", padding: "4px 6px", fontSize: 10, color: "#888", fontWeight: 500 }}>BEZEICHNUNG</th>
<th style={{ textAlign: "right", padding: "4px 6px", fontSize: 10, color: "#888", fontWeight: 500, width: 110 }}>CHF/h</th>
<th style={{ width: 32 }}></th>
</tr>
</thead>
<tbody>
{(form.quoteRoles || []).map((r, idx) => (
<tr key={idx} style={{ borderBottom: "1px solid #f0e8d8" }}>
<td style={{ padding: "3px 6px" }}>
<input
value={r.id}
onChange={e => setForm(f => ({ ...f, quoteRoles: f.quoteRoles.map((x, i) => i === idx ? { ...x, id: e.target.value.toUpperCase().slice(0,4) } : x) }))}
style={{ width: 52, height: 26, fontSize: 12, fontWeight: 600, textAlign: "center", border: "1px solid #e0dbd4", background: "#fff" }}
maxLength={4}
/>
</td>
<td style={{ padding: "3px 6px" }}>
<input
value={r.label}
onChange={e => setForm(f => ({ ...f, quoteRoles: f.quoteRoles.map((x, i) => i === idx ? { ...x, label: e.target.value } : x) }))}
style={{ width: "100%", height: 26, fontSize: 12, border: "1px solid #e0dbd4", background: "#fff" }}
/>
</td>
<td style={{ padding: "3px 6px", textAlign: "right" }}>
<input
type="number"
min={0}
step={5}
value={r.rate}
onChange={e => setForm(f => ({ ...f, quoteRoles: f.quoteRoles.map((x, i) => i === idx ? { ...x, rate: +e.target.value } : x) }))}
style={{ width: 80, height: 26, fontSize: 12, textAlign: "right", border: "1px solid #e0dbd4", background: "#fff" }}
/>
</td>
<td style={{ padding: "3px 4px", textAlign: "center" }}>
<button className="btn btn-danger" style={{ padding: "0 7px", height: 26, fontSize: 11 }}
onClick={() => setForm(f => ({ ...f, quoteRoles: f.quoteRoles.filter((_, i) => i !== idx), manualPhases: f.manualPhases.map(p => { const h = { ...p.hoursByRole }; delete h[r.id]; return { ...p, hoursByRole: h }; }) }))}>
<span className="material-icons" style={{ fontSize: 16 }}>close</span>
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
<div style={{ marginBottom: 16, borderRadius: 6, border: "1px solid #ece8e2", overflow: "hidden" }}>
<table style={{ fontSize: 12 }}>
<thead>
<tr style={{ background: "#f5f0e8" }}>
<th style={{ padding: "8px", width: 30 }}></th>
<th style={{ padding: "8px" }}>Phase</th>
{activeRoles.map(r => <th key={r.id} style={{ padding: "8px", textAlign: "right", width: 65 }} title={r.label+" · CHF "+r.rate+"/h"}>{r.id}</th>)}
<th style={{ padding: "8px", textAlign: "right", width: 65 }}>Std</th>
<th style={{ padding: "8px", textAlign: "right", width: 95 }}>Honorar</th>
</tr>
</thead>
<tbody>
{form.manualPhases.map(rawPh => {
const calcPh = manCalc.phases.find(p => p.id === rawPh.id);
return (
<tr key={rawPh.id} style={{ opacity: rawPh.enabled ? 1 : 0.35 }}>
<td style={{ padding: "4px 8px" }}>
<input type="checkbox" checked={rawPh.enabled} onChange={e => toggleManualPhase(rawPh.id, e.target.checked)} style={{ width: "auto" }} />
</td>
<td style={{ padding: "4px 8px", fontSize: 11 }}>{rawPh.label}</td>
{activeRoles.map(r => (
<td key={r.id} style={{ padding: "4px 4px" }}>
<input type="number" min={0} step="0.5" value={rawPh.hoursByRole?.[r.id] || 0}
onChange={e => updateManualHours(rawPh.id, r.id, e.target.value)}
disabled={!rawPh.enabled}
style={{ width: 56, height: 26, fontSize: 11, textAlign: "right", background: rawPh.enabled ? "#fff8ed" : undefined }} />
</td>
))}
<td style={{ padding: "4px 8px", textAlign: "right", color: "#888" }}>{calcPh ? formatHours(Math.round(calcPh.totalHours*60)) : "—"}</td>
<td style={{ padding: "4px 8px", textAlign: "right", color: "#888" }}>{calcPh ? formatCHF(calcPh.totalAmount) : "—"}</td>
</tr>
);
})}
</tbody>
</table>
</div>
<div style={{ display: "flex", justifyContent: "flex-end", fontSize: 11, color: "#666", marginBottom: 10 }}>
Total: <strong style={{ marginLeft: 8, color: "#1a1a18" }}>{formatHours(Math.round(manCalc.totalHours*60))} · {formatCHF(manCalc.totalAmount)}</strong>
</div>
</div>
)}
{/* Total */}
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 14, padding: "12px 14px", background: "#faf8f5", borderRadius: 6, border: "1px solid #ece8e2" }}>
<label style={{ display: "flex", alignItems: "center", gap: 8, cursor: "pointer", textTransform: "none", fontSize: 13, color: "#1a1a18" }}>
<input type="checkbox" checked={form.mwst} onChange={e => setForm({...form, mwst: e.target.checked})} style={{ width: "auto" }} />
MWST {taxRate}% ausweisen
</label>
<div style={{ textAlign: "right", fontSize: 12 }}>
<div style={{ color: "#888" }}>Honorar netto {formatCHF(subTotal)}</div>
{form.mwst && <div style={{ color: "#888" }}>MWST {formatCHF(tax)}</div>}
<div style={{ fontSize: 16, fontWeight: 700, marginTop: 3 }}>Total {formatCHF(total)}</div>
</div>
</div>
<FormField label="Bemerkungen">
<textarea rows={3} value={form.notes} onChange={e => setForm({...form, notes: e.target.value})} style={{ resize: "vertical" }} />
</FormField>
</Modal>
)}
</div>
);
}
export
function ConvertQuoteModal({ quote, existingInvoices, taxRate, onClose, onConfirm }) {
const totalSub = quote.sub || 0;
const totalInvoiced = existingInvoices.reduce((s, i) => s + (i.sub || 0), 0);
const remaining = Math.max(0, totalSub - totalInvoiced);
const hasExisting = existingInvoices.length > 0;
const progressPct = totalSub > 0 ? (totalInvoiced / totalSub) * 100 : 0;
const [mode, setMode] = useState("voll");
const [percentValue, setPercentValue] = useState(30);
const [amountValue, setAmountValue] = useState(Math.round(remaining * 100) / 100);
// Vollrechnung = remaining when akonto exists, otherwise full total
const vollAmount = hasExisting ? remaining : totalSub;
let previewAmount = 0;
if (mode === "voll") previewAmount = vollAmount;
else if (mode === "akonto-percent") previewAmount = totalSub * (percentValue / 100);
else if (mode === "akonto-amount") previewAmount = amountValue;
const previewTax = quote.mwst ? previewAmount * (taxRate / 100) : 0;
const previewTotal = roundCHF(previewAmount + previewTax);
const canSubmit = previewAmount > 0;
const handleConfirm = () => {
let value = 0;
if (mode === "akonto-percent") value = percentValue;
else if (mode === "akonto-amount") value = amountValue;
onConfirm(quote, mode, value);
};
const Option = ({ id, title, desc, disabled }) => (
<label
onClick={() => !disabled && setMode(id)}
style={{
display: "block", padding: "12px 14px", marginBottom: 8,
borderRadius: 6, border: mode === id ? "2px solid #1a1a18" : "1.5px solid #ddd8d0",
cursor: disabled ? "not-allowed" : "pointer",
opacity: disabled ? 0.4 : 1, background: mode === id ? "#faf8f5" : "#fff",
textTransform: "none",
}}>
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
<div style={{
width: 16, height: 16, borderRadius: "50%",
border: mode === id ? "5px solid #1a1a18" : "2px solid #aaa",
flexShrink: 0,
}} />
<div>
<div style={{ fontSize: 13, fontWeight: 500, color: "#1a1a18" }}>{title}</div>
{desc && <div style={{ fontSize: 11, color: "#888", marginTop: 2 }}>{desc}</div>}
</div>
</div>
</label>
);
return (
<div className="modal-overlay" onClick={e => e.target === e.currentTarget && onClose()}>
<div className="modal" style={{ maxWidth: 540 }}>
<h2 style={{ fontFamily: "'Playfair Display', serif", fontWeight: 400, marginBottom: 8, fontSize: 22 }}>
Rechnung aus Offerte
</h2>
<div style={{ fontSize: 12, color: "#888", marginBottom: 18 }}>
Offerte {quote.number} · Honorar netto {formatCHF(totalSub)}
</div>
{/* Fortschritts-Anzeige */}
{hasExisting && (
<div style={{ marginBottom: 18, padding: 14, background: "#faf8f5", borderRadius: 6, border: "1px solid #ece8e2" }}>
<div style={{ display: "flex", justifyContent: "space-between", fontSize: 11, color: "#666", marginBottom: 6 }}>
<span>Bereits verrechnet</span>
<span><strong style={{ color: "#1a1a18" }}>{formatCHF(totalInvoiced)}</strong> · {progressPct.toFixed(0)}%</span>
</div>
<div style={{ height: 6, background: "#ece8e2", borderRadius: 3, overflow: "hidden", marginBottom: 8 }}>
<div style={{ width: `${progressPct}%`, height: "100%", background: "#2d6a4f" }}></div>
</div>
<div style={{ display: "flex", justifyContent: "space-between", fontSize: 11, color: "#666" }}>
<span>Restbetrag</span>
<span><strong style={{ color: "#b5621e" }}>{formatCHF(remaining)}</strong></span>
</div>
<div style={{ marginTop: 10, paddingTop: 10, borderTop: "1px solid #ece8e2", fontSize: 10, color: "#888" }}>
{existingInvoices.map(i => (
<div key={i.id} style={{ display: "flex", justifyContent: "space-between", padding: "2px 0" }}>
<span>{i.number} · {i.invoiceKind === "akonto" ? "Akonto" : i.invoiceKind} · {formatDate(i.date)}</span>
<span>{formatCHF(i.sub)}</span>
</div>
))}
</div>
</div>
)}
<div style={{ marginBottom: 14 }}>
<Option
id="voll"
title={hasExisting ? "Schlussrechnung / Restbetrag" : "Vollrechnung"}
desc={hasExisting
? `Noch offenes Honorar verrechnen: ${formatCHF(remaining)}`
: "Gesamtes Offerthonorar in einer Rechnung verrechnen"}
disabled={hasExisting && remaining <= 0}
/>
<Option
id="akonto-percent"
title="Akontorechnung (Prozent)"
desc={`Teilbetrag als % vom Gesamthonorar (${formatCHF(totalSub)})`}
/>
{mode === "akonto-percent" && (
<div style={{ marginLeft: 26, marginTop: -4, marginBottom: 12, padding: "10px 14px", background: "#fff8ed", borderRadius: 6, border: "1px solid #f0e4c4", display: "flex", alignItems: "center", gap: 10 }}>
<input type="number" min={1} max={100} step="1" value={percentValue} onChange={e => setPercentValue(Math.max(0, Math.min(100, +e.target.value || 0)))} style={{ width: 80, textAlign: "right", background: "#fff" }} />
<span style={{ fontSize: 13, color: "#666" }}>% des Gesamthonorars</span>
</div>
)}
<Option
id="akonto-amount"
title="Akontorechnung (Betrag)"
desc={`Spezifischer CHF-Betrag`}
/>
{mode === "akonto-amount" && (
<div style={{ marginLeft: 26, marginTop: -4, marginBottom: 12, padding: "10px 14px", background: "#fff8ed", borderRadius: 6, border: "1px solid #f0e4c4", display: "flex", alignItems: "center", gap: 10 }}>
<span style={{ fontSize: 13, color: "#666" }}>CHF</span>
<input type="number" min={0} step="50" value={amountValue} onChange={e => setAmountValue(Math.max(0, +e.target.value || 0))} style={{ width: 140, textAlign: "right", background: "#fff" }} />
</div>
)}
</div>
{/* Vorschau */}
<div style={{ padding: "12px 14px", background: "#f5f0e8", borderRadius: 6, marginBottom: 18 }}>
<div style={{ fontSize: 10, letterSpacing: "0.1em", color: "#888", marginBottom: 8 }}>VORSCHAU NEUE RECHNUNG</div>
<div style={{ display: "flex", justifyContent: "space-between", fontSize: 12, color: "#666", padding: "2px 0" }}>
<span>Netto</span><span>{formatCHF(previewAmount)}</span>
</div>
{quote.mwst && (
<div style={{ display: "flex", justifyContent: "space-between", fontSize: 12, color: "#666", padding: "2px 0" }}>
<span>MWST {taxRate}%</span><span>{formatCHF(previewTax)}</span>
</div>
)}
<div style={{ display: "flex", justifyContent: "space-between", marginTop: 4, paddingTop: 6, borderTop: "1px solid #d8d0c4", fontSize: 14, fontWeight: 700 }}>
<span>Total</span><span>{formatCHF(previewTotal)}</span>
</div>
</div>
<div style={{ display: "flex", gap: 10, justifyContent: "flex-end" }}>
<button className="btn btn-ghost" onClick={onClose}>Abbrechen</button>
<button className="btn btn-primary" onClick={handleConfirm} disabled={!canSubmit} style={{ opacity: canSubmit ? 1 : 0.4 }}>Rechnung erstellen</button>
</div>
</div>
</div>
);
}
// ─── CLIENTS ──────────────────────────────────────────────────