Files
RAPPORT/src/views/Quotes.jsx
T
karim 27b1057cd4 Release 0.8.0: Cloud-Variante (Supabase, Multi-Studio, Realtime, Web-Deploy)
Rapport ist jetzt dual: lokal (wie bisher) ODER Cloud auf eigenem Supabase-Server.
Beide Modi haben dieselben Funktionen, Cloud zusätzlich Multi-User + Live-Sync.

Storage-Architektur
- src/storage/adapter.js: einheitliche Promise-API, LocalStorage- und SupabaseAdapter
- src/storage/migrations.js: applyMigrations als reine Funktion, für beide Backends
- Konfig-driven: VITE_SUPABASE_URL im Production-Build → automatisch Cloud-Modus

Postgres-Schema (supabase/migrations/0001–0010)
- 29 Tabellen, multi-tenant via studio_id + Row-Level-Security
- Audit-Spalten (created_by/updated_by/at) + Trigger
- Seed-Trigger pro neuem Studio (Rollen, Templates, Absenz-Typen)
- Realtime-Publication für Live-Sync
- RPCs: ensure_profile, create_studio_with_admin (mit Personen-Sharing),
  list_studios, load_persons_for_studio, attach_user_to_studio

Cloud-Features (App)
- BackendChoice.jsx als Erst-Screen «Lokal oder Cloud»
- CloudSetup.jsx: 3-Schritt-Wizard für Erst-Einrichtung
- Login.jsx: Modus-Switcher + Server-URL + Studio-Dropdown + Passwort-Vergessen
- ResetPassword.jsx: empfängt Mail-Link-Klick via PASSWORD_RECOVERY-Event
- Realtime: Änderungen zwischen Browsern ohne Reload sichtbar
- Settings → System: Cloud-Verbindung, Studio-Switcher, weiteres Studio anlegen
- Settings → Team: Mitarbeiter via Email einladen (Admin-Aktion)
- Personen-Sharing: bei neuem Studio Personen aus anderen Studios übernehmen
- Reload-Resume: studio_id in sessionStorage, kein erneuter Login nötig

Web-Deploy
- deploy/docker-compose.yml + nginx.conf: dist/ via nginx-Container, Port 8080
- .env.production.example: Build-time Cloud-URL
- DEPLOY.md: Anleitung für LAN-only und extern via Nginx Proxy Manager

Doku
- README.md: Cloud-Variante prominent erklärt
- ARCHITECTURE.md: Storage-Adapter, Migrations, neue Views in Risiko-Tabelle
- DEPLOY.md: Schritt-für-Schritt für Mac Mini + NPM

Version-Bump auf 0.8.0 in package.json, src-tauri/tauri.conf.json, Cargo.toml.
Changelog-Entry im App.jsx-Modal (Karim sieht ihn beim ersten Start).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-23 19:08:00 +02:00

976 lines
60 KiB
React
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { useState, 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 }) => (
<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 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 (
<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 ──────────────────────────────────────────────────