import React, { useState, useEffect, useCallback, useMemo } from "react"; import { SIA_PHASES, SIA_PHASE_WEIGHTS } 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, 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 }) => ( toggleSort(col)} style={{ cursor: "pointer", userSelect: "none", whiteSpace: "nowrap", ...style }}> {children} {sort.col === col ? (sort.dir === 1 ? "▲" : "▼") : "⇅"} ); 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 via saveAll (geht durch den Storage-Adapter) const updatedInvoices = [...data.invoices, newInv]; const updatedQuotes = (data.quotes || []).map(x => x.id === q.id ? { ...x, status: mode === "schluss" ? "angenommen" : x.status, } : x); saveAll({ ...data, invoices: updatedInvoices, quotes: updatedQuotes }); }; 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 (
{ConfirmModalEl}
+ Neue Offerte} />
setFilter({...filter, search: e.target.value})} style={{ minWidth: 180 }} />
{filtered.length} Offerte{filtered.length !== 1 ? "n" : ""} · {formatCHF(filtered.reduce((s,q)=>s+(q.total||0),0))}
GRUPPIEREN: {[{ id: "none", label: "Keine" }, { id: "date", label: "Monat" }, { id: "client", label: "Kunde" }, { id: "status", label: "Status" }].map(g => ( ))}
Nr.KundeDatumGültig bisHonorarStatus {filtered.length === 0 && } {groupedQuotes.map(group => ( {groupBy !== "none" && group.label && ( )} {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 ( ); })} ))}
Modus
{(data.quotes||[]).length === 0 ? "Noch keine Offerten" : "Keine Treffer"}
{group.label} {group.items.length} · {formatCHF(group.items.reduce((s, q) => s + (q.total || 0), 0))}
{q.number}{q.projectName &&
{q.projectName}
}
{cl?.name || "—"} {q.mode === "sia" ? "SIA 102" : q.mode === "free" ? "Freie Positionen" : "Aufwand"} {formatDate(q.date)} {formatDate(q.validUntil)} {formatCHF(q.total)} {qInvoiced > 0 &&
verrechnet {formatCHF(qInvoiced)}
}
setStatus(q.id, v)} /> {!q.convertedToInvoiceId && } {(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 ? : ; })()}
{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 (
e.target === e.currentTarget && setProjectModal(null)}>

Offerte verknüpft

OFFERTE
{q.number}{q.projectName ? " · " + q.projectName : ""}
VERKNÜPFTES PROJEKT
{alreadyLinkedProject.number ? alreadyLinkedProject.number + " · " : ""}{alreadyLinkedProject.name}
); } return ( setProjectModal(null)} onSave={doCreate} saveLabel={pmMode === "new" ? "Neues Projekt erstellen" : "Zu Projekt hinzufügen"}>
{q.number} · {q.mode === "sia" ? "SIA 102" : q.mode === "manual" ? "Aufwand" : "Pauschal"} {budgetH > 0 && {budgetH}h Soll-Budget} CHF {stundenansatz}/h {formatCHF(q.total)}
{q.projectName &&
Auftragsbezeichnung: {q.projectName}
}
{[{ id: "new", label: "Neues Projekt erstellen" }, { id: "attach", label: "Zu bestehendem Projekt" }].map(tab => ( ))}
{pmMode === "new" ? (
setPmName(e.target.value)} autoFocus placeholder="z.B. Umbau EFH Muster" />
Neues Projekt wird erstellt mit: Stundenbudget {budgetH}h, Stundensatz CHF {stundenansatz}/h, SIA-Phasen aus der Offerte.
) : (
{pmAttachId && (() => { const proj = data.projects.find(p => p.id === pmAttachId); const existing = migrateLinkedQuotes(proj); const alreadyLinked = existing.some(lq => lq.quoteId === q.id); return (
{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`}
); })()}
)}
); })()} {modal?.type === "quote" && ( setModal(null)} onSave={save} wide>
setForm({...form, number: e.target.value})} /> setForm({...form, date: e.target.value})} /> setForm({...form, validUntil: e.target.value})} />
setForm({...form, projectName: e.target.value})} placeholder="z.B. Umbau EFH Muster, Neubau MFH…" />
{/* Tab-Auswahl */}
{[{ id: "sia", label: "SIA 102 (Baukosten)" }, { id: "manual", label: "Aufwandschätzung (Stunden)" }, { id: "free", label: "Freie Positionen" }].map(tab => ( ))}
{/* Freier Modus */} {form.mode === "free" && (
Positionen frei definieren
{(form.freeItems || []).map((it, idx) => ( ))}
Beschreibung Menge Preis CHF Total
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 }} /> 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" }} /> 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" }} /> {formatCHF(it.qty * it.price)}
)} {/* SIA-Modus */} {form.mode === "sia" && siaCalc && (
setForm({...form, sia: {...form.sia, baukosten: +e.target.value}})} style={{ background: "#fff" }} /> setForm({...form, sia: {...form.sia, schwierigkeit: +e.target.value}})} style={{ background: "#fff" }} /> setForm({...form, sia: {...form.sia, stundenansatz: +e.target.value}})} style={{ background: "#fff" }} />
∛B = {siaCalc.cbrtB?.toFixed(1)}
p = {siaCalc.p}
Total {formatHours(Math.round(siaCalc.total * 60))} · {formatCHF(siaCalc.total * (form.sia.stundenansatz||0))}
{siaCalc.phases.map(ph => ( {ph.items.map((it, idx) => ( ))} ))}
Teilleistung q % r Stunden Honorar
PHASE {ph.id} · {ph.label} {formatHours(Math.round(ph.hours*60))} {formatCHF(ph.hours*(form.sia.stundenansatz||0))}
toggleSIAItem(ph.id, idx)} style={{ width: "auto" }} /> {it.label} updateSIAItem(ph.id, idx, { pct: +e.target.value })} disabled={it.enabled === false} style={{ width: 48, height: 26, fontSize: 11, textAlign: "right" }} /> updateSIAItem(ph.id, idx, { r: +e.target.value })} disabled={it.enabled === false} style={{ width: 48, height: 26, fontSize: 11, textAlign: "right" }} /> {formatHours(Math.round(it.hours*60))} {formatCHF(it.hours*(form.sia.stundenansatz||0))}
)} {/* Manueller Modus */} {form.mode === "manual" && manCalc && (
ROLLEN & STUNDENSÄTZE FÜR DIESE OFFERTE
{(form.quoteRoles || []).map((r, idx) => ( ))}
KÜRZEL BEZEICHNUNG CHF/h
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} /> 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" }} /> 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" }} />
{activeRoles.map(r => )} {form.manualPhases.map(rawPh => { const calcPh = manCalc.phases.find(p => p.id === rawPh.id); return ( {activeRoles.map(r => ( ))} ); })}
Phase{r.id}Std Honorar
toggleManualPhase(rawPh.id, e.target.checked)} style={{ width: "auto" }} /> {rawPh.label} 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 }} /> {calcPh ? formatHours(Math.round(calcPh.totalHours*60)) : "—"} {calcPh ? formatCHF(calcPh.totalAmount) : "—"}
Total: {formatHours(Math.round(manCalc.totalHours*60))} · {formatCHF(manCalc.totalAmount)}
)} {/* Total */}
Honorar netto {formatCHF(subTotal)}
{form.mwst &&
MWST {formatCHF(tax)}
}
Total {formatCHF(total)}