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:
Executable
+980
@@ -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 & 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 ──────────────────────────────────────────────────
|
||||
Reference in New Issue
Block a user