Initial commit — RAPPORT App (Stand Mai 2026)
This commit is contained in:
Executable
+847
@@ -0,0 +1,847 @@
|
||||
import React, { useState } from "react";
|
||||
import { generateId, formatCHF, calcLohn } from "../utils.js";
|
||||
import { Header, FormField } from "../components/UI.jsx";
|
||||
|
||||
export default
|
||||
function StudioBudget({ data, update, setView, setPrintContent }) {
|
||||
const employees = data.employees || [];
|
||||
const internalExpenses = data.internalExpenses || [];
|
||||
const currentYear = new Date().getFullYear();
|
||||
const saved = data.settings.studioBudget || {};
|
||||
|
||||
// ── Hilfsfunktion: Auto-Werte für einen MA berechnen ──────────
|
||||
const calcEmpAuto = (emp, agZuschlag) => {
|
||||
const pensum = (emp.pensum || 100) / 100;
|
||||
const brutto12 = (emp.monatslohn || 0) * 12;
|
||||
const brutto13 = emp.dreizehnterLohn ? brutto12 + (emp.monatslohn || 0) : brutto12;
|
||||
const agAnteil = brutto13 * ((agZuschlag || 20) / 100);
|
||||
const totalKosten = brutto13 + agAnteil;
|
||||
const ferienWochen = emp.ferienWochen || data.settings.defaultFerienWochen || 5;
|
||||
const wochenstunden = (emp.wochenstunden || data.settings.defaultWochenstunden || 35) * pensum;
|
||||
const nettoWochen = 52 - ferienWochen - 1.5;
|
||||
const jahresStunden = Math.round(wochenstunden * nettoWochen);
|
||||
return { brutto13, agAnteil, totalKosten, jahresStunden };
|
||||
};
|
||||
|
||||
// ── Hilfsfunktion: Auto-Werte für interne Ausgaben ───────────
|
||||
const calcFixAuto = () => {
|
||||
const rows = [];
|
||||
internalExpenses.forEach(e => {
|
||||
const year = (e.date || "").slice(0, 4);
|
||||
const net = e.inclMwst ? e.amount / (1 + (e.mwstRate || 0) / 100) : e.amount;
|
||||
let annualized = net;
|
||||
if (e.recurring) {
|
||||
if (e.recurringInterval === "monatlich") annualized = net * 12;
|
||||
else if (e.recurringInterval === "quartalsweise") annualized = net * 4;
|
||||
} else if (year !== String(currentYear)) return;
|
||||
rows.push({ id: e.id, label: e.description || e.category, cat: e.category, amount: annualized });
|
||||
});
|
||||
return rows;
|
||||
};
|
||||
|
||||
// ── State: ein Eintrag pro MA, pro Fixkostenposten, plus globale Parameter ──
|
||||
const initState = () => {
|
||||
const sv = saved;
|
||||
const agZuschlag = sv.agZuschlag ?? 20;
|
||||
|
||||
// Mitarbeiter-Zeilen
|
||||
const empRows = employees.map(emp => {
|
||||
const auto = calcEmpAuto(emp, agZuschlag);
|
||||
const saved_emp = (sv.empRows || []).find(r => r.id === emp.id) || {};
|
||||
return {
|
||||
id: emp.id,
|
||||
aktiv: saved_emp.aktiv ?? true,
|
||||
kostenOverride: saved_emp.kostenOverride ?? false,
|
||||
kostenManual: saved_emp.kostenManual ?? auto.totalKosten,
|
||||
stundenOverride: saved_emp.stundenOverride ?? false,
|
||||
stundenManual: saved_emp.stundenManual ?? auto.jahresStunden,
|
||||
};
|
||||
});
|
||||
|
||||
// Fixkosten-Zeilen (aus internen Ausgaben)
|
||||
const autoFix = calcFixAuto();
|
||||
const fixRows = autoFix.map(f => {
|
||||
const saved_fix = (sv.fixRows || []).find(r => r.id === f.id) || {};
|
||||
return {
|
||||
id: f.id,
|
||||
aktiv: saved_fix.aktiv ?? true,
|
||||
amountOverride: saved_fix.amountOverride ?? false,
|
||||
amountManual: saved_fix.amountManual ?? f.amount,
|
||||
};
|
||||
});
|
||||
|
||||
// Zusätzliche manuelle Fixkostenposten
|
||||
const extraRows = (sv.extraRows || []);
|
||||
|
||||
// Manuelle Einnahmen + Rechnungs-Toggles
|
||||
const extraIncome = sv.extraIncome || [];
|
||||
|
||||
return {
|
||||
agZuschlag,
|
||||
empRows,
|
||||
fixRows,
|
||||
extraRows, // { id, label, amount, aktiv }
|
||||
extraIncome, // { id, label, amount, aktiv }
|
||||
inkludiereRechnungen: sv.inkludiereRechnungen ?? true,
|
||||
inkludiereEntwuerfe: sv.inkludiereEntwuerfe ?? true,
|
||||
inkludiereOfferten: sv.inkludiereOfferten ?? false,
|
||||
inkludiereOffertEntwuerfe: sv.inkludiereOffertEntwuerfe ?? false,
|
||||
reserve: sv.reserve ?? 10,
|
||||
produktivQuote: sv.produktivQuote ?? 70,
|
||||
zielMarge: sv.zielMarge ?? 25,
|
||||
rateOverride: sv.rateOverride ?? false,
|
||||
rateManual: sv.rateManual ?? (data.settings.defaultHourlyRate || 120),
|
||||
};
|
||||
};
|
||||
|
||||
const [b, setB] = useState(initState);
|
||||
const [newExtra, setNewExtra] = useState({ label: "", amount: 0 });
|
||||
const [newIncome, setNewIncome] = useState({ label: "", amount: 0 });
|
||||
|
||||
const setField = (k, v) => setB(prev => ({ ...prev, [k]: v }));
|
||||
|
||||
const setEmpRow = (id, changes) => setB(prev => ({
|
||||
...prev,
|
||||
empRows: prev.empRows.map(r => r.id === id ? { ...r, ...changes } : r),
|
||||
}));
|
||||
const setFixRow = (id, changes) => setB(prev => ({
|
||||
...prev,
|
||||
fixRows: prev.fixRows.map(r => r.id === id ? { ...r, ...changes } : r),
|
||||
}));
|
||||
const addExtra = () => {
|
||||
if (!newExtra.label.trim() || !newExtra.amount) return;
|
||||
setB(prev => ({ ...prev, extraRows: [...prev.extraRows, { id: generateId(), label: newExtra.label, amount: newExtra.amount, aktiv: true }] }));
|
||||
setNewExtra({ label: "", amount: 0 });
|
||||
};
|
||||
const setExtraRow = (id, changes) => setB(prev => ({
|
||||
...prev,
|
||||
extraRows: prev.extraRows.map(r => r.id === id ? { ...r, ...changes } : r),
|
||||
}));
|
||||
const delExtra = (id) => setB(prev => ({ ...prev, extraRows: prev.extraRows.filter(r => r.id !== id) }));
|
||||
|
||||
const addIncome = () => {
|
||||
if (!newIncome.label.trim() || !newIncome.amount) return;
|
||||
setB(prev => ({ ...prev, extraIncome: [...(prev.extraIncome || []), { id: generateId(), label: newIncome.label, amount: newIncome.amount, aktiv: true }] }));
|
||||
setNewIncome({ label: "", amount: 0 });
|
||||
};
|
||||
const setIncomeRow = (id, changes) => setB(prev => ({
|
||||
...prev,
|
||||
extraIncome: (prev.extraIncome || []).map(r => r.id === id ? { ...r, ...changes } : r),
|
||||
}));
|
||||
const delIncome = (id) => setB(prev => ({ ...prev, extraIncome: (prev.extraIncome || []).filter(r => r.id !== id) }));
|
||||
|
||||
// ── Berechnungen ──────────────────────────────────────────────
|
||||
|
||||
const autoFix = calcFixAuto();
|
||||
|
||||
const empCalcRows = employees.map(emp => {
|
||||
const auto = calcEmpAuto(emp, b.agZuschlag);
|
||||
const row = b.empRows.find(r => r.id === emp.id) || { aktiv: true, kostenOverride: false, kostenManual: 0, stundenOverride: false, stundenManual: 0 };
|
||||
const kosten = row.kostenOverride ? (row.kostenManual || 0) : auto.totalKosten;
|
||||
const stunden = row.stundenOverride ? (row.stundenManual || 0) : auto.jahresStunden;
|
||||
return { emp, auto, row, kosten, stunden, aktiv: row.aktiv };
|
||||
});
|
||||
|
||||
const personalKosten = empCalcRows.filter(r => r.aktiv).reduce((s, r) => s + r.kosten, 0);
|
||||
const jahresStundenTotal = empCalcRows.filter(r => r.aktiv).reduce((s, r) => s + r.stunden, 0);
|
||||
const jahresStundenFallback = employees.length === 0 ? 1800 : jahresStundenTotal;
|
||||
|
||||
const fixCalcRows = autoFix.map(f => {
|
||||
const row = b.fixRows.find(r => r.id === f.id) || { aktiv: true, amountOverride: false, amountManual: f.amount };
|
||||
const amount = row.amountOverride ? (row.amountManual || 0) : f.amount;
|
||||
return { ...f, row, amount, aktiv: row.aktiv };
|
||||
});
|
||||
const fixKosten = fixCalcRows.filter(r => r.aktiv).reduce((s, r) => s + r.amount, 0);
|
||||
const extraKosten = b.extraRows.filter(r => r.aktiv).reduce((s, r) => s + (r.amount || 0), 0);
|
||||
|
||||
const basisKosten = personalKosten + fixKosten + extraKosten;
|
||||
const reserve = basisKosten * ((b.reserve || 0) / 100);
|
||||
const gesamtKosten = basisKosten + reserve;
|
||||
|
||||
const produktivStunden = Math.round(jahresStundenFallback * ((b.produktivQuote || 70) / 100));
|
||||
const selbstkosten = produktivStunden > 0 ? gesamtKosten / produktivStunden : 0;
|
||||
const zielHonorar = selbstkosten > 0 ? selbstkosten / (1 - (b.zielMarge || 25) / 100) : 0;
|
||||
|
||||
const defaultRate = data.settings.defaultHourlyRate || 120;
|
||||
const currentRate = b.rateOverride ? (b.rateManual || defaultRate) : defaultRate;
|
||||
|
||||
// ── Einnahmen ─────────────────────────────────────────────────
|
||||
const yearInvoices = (data.invoices || []).filter(i => (i.date || "").startsWith(String(currentYear)));
|
||||
const paidRevenue = yearInvoices.filter(i => i.status === "bezahlt").reduce((s, i) => s + (i.sub || 0), 0);
|
||||
const sentRevenue = yearInvoices.filter(i => i.status === "gesendet" || i.status === "überfällig").reduce((s, i) => s + (i.sub || 0), 0);
|
||||
const draftRevenue = yearInvoices.filter(i => i.status === "entwurf").reduce((s, i) => s + (i.sub || 0), 0);
|
||||
const invoiceAutoRevenue = b.inkludiereRechnungen
|
||||
? paidRevenue + sentRevenue + (b.inkludiereEntwuerfe ? draftRevenue : 0)
|
||||
: 0;
|
||||
|
||||
const yearQuotes = (data.quotes || []).filter(q => (q.date || "").startsWith(String(currentYear)));
|
||||
const acceptedQuoteRevenue = yearQuotes.filter(q => q.status === "angenommen").reduce((s, q) => s + (q.sub || 0), 0);
|
||||
const sentQuoteRevenue = yearQuotes.filter(q => q.status === "gesendet").reduce((s, q) => s + (q.sub || 0), 0);
|
||||
const draftQuoteRevenue = yearQuotes.filter(q => q.status === "entwurf").reduce((s, q) => s + (q.sub || 0), 0);
|
||||
const quoteAutoRevenue = b.inkludiereOfferten
|
||||
? acceptedQuoteRevenue + sentQuoteRevenue + (b.inkludiereOffertEntwuerfe ? draftQuoteRevenue : 0)
|
||||
: 0;
|
||||
|
||||
const autoRevenue = invoiceAutoRevenue + quoteAutoRevenue;
|
||||
const manualIncomeTotal = (b.extraIncome || []).filter(r => r.aktiv).reduce((s, r) => s + (r.amount || 0), 0);
|
||||
const totalRevenue = autoRevenue + manualIncomeTotal;
|
||||
const ergebnis = totalRevenue - gesamtKosten;
|
||||
|
||||
// ── IST-Daten aus Zeiterfassung ───────────────────────────────
|
||||
const NON_BILLING_CATEGORIES = ["Wettbewerb"];
|
||||
const projects = data.projects || [];
|
||||
const wbProjIds = new Set(projects.filter(p => NON_BILLING_CATEGORIES.includes(p.category)).map(p => p.id));
|
||||
const yearEntries = (data.timeEntries || []).filter(e => (e.date || "").startsWith(String(currentYear)));
|
||||
const totalTrackedMins = yearEntries.reduce((s, e) => s + (e.minutes || 0), 0);
|
||||
const billingMins = yearEntries.filter(e => e.projectId && !wbProjIds.has(e.projectId)).reduce((s, e) => s + (e.minutes || 0), 0);
|
||||
const wbTotalMins = yearEntries.filter(e => wbProjIds.has(e.projectId)).reduce((s, e) => s + (e.minutes || 0), 0);
|
||||
const noProjectMins = yearEntries.filter(e => !e.projectId).reduce((s, e) => s + (e.minutes || 0), 0);
|
||||
const istProduktivQuote = totalTrackedMins > 0 ? (billingMins / totalTrackedMins) * 100 : null;
|
||||
const wbProjects = projects.filter(p => NON_BILLING_CATEGORIES.includes(p.category));
|
||||
const wbRows = wbProjects.map(p => {
|
||||
const mins = yearEntries.filter(e => e.projectId === p.id).reduce((s, e) => s + (e.minutes || 0), 0);
|
||||
return { id: p.id, name: p.name, mins, hours: mins / 60, cost: (mins / 60) * selbstkosten };
|
||||
}).filter(r => r.mins > 0).sort((a, b) => b.mins - a.mins);
|
||||
const wbTotalHours = wbTotalMins / 60;
|
||||
const rateDiff = currentRate - zielHonorar;
|
||||
const rateOk = rateDiff >= 0;
|
||||
|
||||
// ── Versionen ────────────────────────────────────────────────
|
||||
const versions = data.settings.studioBudgetVersions || [];
|
||||
const [showVersions, setShowVersions] = useState(false);
|
||||
const [saveName, setSaveName] = useState("");
|
||||
|
||||
const buildSnapshot = (name) => ({
|
||||
id: generateId(),
|
||||
name: name || `Budget ${new Date().toLocaleDateString("de-CH")}`,
|
||||
savedAt: new Date().toISOString(),
|
||||
b: { agZuschlag: b.agZuschlag, empRows: b.empRows, fixRows: b.fixRows, extraRows: b.extraRows, extraIncome: b.extraIncome, inkludiereRechnungen: b.inkludiereRechnungen, inkludiereEntwuerfe: b.inkludiereEntwuerfe, inkludiereOfferten: b.inkludiereOfferten, inkludiereOffertEntwuerfe: b.inkludiereOffertEntwuerfe, reserve: b.reserve, produktivQuote: b.produktivQuote, zielMarge: b.zielMarge },
|
||||
results: { personalKosten, fixKosten, extraKosten, basisKosten, reserve: basisKosten * ((b.reserve || 0) / 100), gesamtKosten, totalRevenue, ergebnis, jahresStundenTotal: jahresStundenFallback, produktivStunden, selbstkosten, zielHonorar, currentRate },
|
||||
empSnapshot: empCalcRows.map(r => ({ name: r.emp.name, kosten: r.kosten, stunden: r.stunden, aktiv: r.aktiv })),
|
||||
fixSnapshot: [...fixCalcRows.filter(r => r.aktiv).map(r => ({ label: r.label, amount: r.amount })), ...b.extraRows.filter(r => r.aktiv).map(r => ({ label: r.label, amount: r.amount }))],
|
||||
});
|
||||
|
||||
const saveVersion = () => {
|
||||
const name = saveName.trim() || `Budget ${new Date().toLocaleDateString("de-CH")}`;
|
||||
const snap = buildSnapshot(name);
|
||||
update("settings", { ...data.settings, studioBudgetVersions: [...versions, snap], studioBudget: { agZuschlag: b.agZuschlag, empRows: b.empRows, fixRows: b.fixRows, extraRows: b.extraRows, reserve: b.reserve, produktivQuote: b.produktivQuote, zielMarge: b.zielMarge } });
|
||||
setSaveName("");
|
||||
};
|
||||
|
||||
const loadVersion = (v) => {
|
||||
setB(prev => ({ ...prev, ...v.b }));
|
||||
setShowVersions(false);
|
||||
};
|
||||
|
||||
const deleteVersion = (id) => {
|
||||
update("settings", { ...data.settings, studioBudgetVersions: versions.filter(v => v.id !== id) });
|
||||
};
|
||||
|
||||
const saveSettings = () => {
|
||||
update("settings", { ...data.settings, studioBudget: {
|
||||
agZuschlag: b.agZuschlag,
|
||||
empRows: b.empRows,
|
||||
fixRows: b.fixRows,
|
||||
extraRows: b.extraRows,
|
||||
extraIncome: b.extraIncome,
|
||||
inkludiereRechnungen: b.inkludiereRechnungen,
|
||||
inkludiereEntwuerfe: b.inkludiereEntwuerfe,
|
||||
inkludiereOfferten: b.inkludiereOfferten,
|
||||
inkludiereOffertEntwuerfe: b.inkludiereOffertEntwuerfe,
|
||||
reserve: b.reserve,
|
||||
produktivQuote: b.produktivQuote,
|
||||
zielMarge: b.zielMarge,
|
||||
rateOverride: b.rateOverride,
|
||||
rateManual: b.rateManual,
|
||||
}});
|
||||
};
|
||||
|
||||
// ── Kleines Inline-Override-Feld ─────────────────────────────
|
||||
const OverrideInput = ({ label, aktiv, checked, onToggle, value, onChange, autoVal, unit = "CHF", step = 100 }) => (
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 6, opacity: aktiv ? 1 : 0.4 }}>
|
||||
<label style={{ display: "flex", alignItems: "center", gap: 4, textTransform: "none", fontSize: 11, color: "var(--text4)", whiteSpace: "nowrap", cursor: "pointer" }}>
|
||||
<input type="checkbox" checked={checked} onChange={e => onToggle(e.target.checked)} style={{ width: "auto" }} disabled={!aktiv} />
|
||||
übersteuern
|
||||
</label>
|
||||
{checked && aktiv ? (
|
||||
<input type="number" step={step} value={value} onChange={e => onChange(+e.target.value)}
|
||||
style={{ width: 110, height: 28, fontSize: 12, padding: "0 8px" }} />
|
||||
) : (
|
||||
<span style={{ fontSize: 12, color: "var(--text3)" }}>{unit === "CHF" ? formatCHF(autoVal) : `${autoVal}h`}</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
const SectionLabel = ({ children }) => (
|
||||
<div className="section-label">{children}</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 10, marginBottom: 4 }}>
|
||||
|
||||
</div>
|
||||
<Header title="Budget" action={
|
||||
<div style={{ display: "flex", gap: 8, alignItems: "center" }}>
|
||||
<button className="btn btn-ghost" onClick={() => setShowVersions(v => !v)}>
|
||||
⊞ Versionen {versions.length > 0 && <span style={{ marginLeft: 4, background: "#b07848", color: "#1a1a18", borderRadius: 10, fontSize: 10, padding: "1px 6px", fontWeight: 700 }}>{versions.length}</span>}
|
||||
</button>
|
||||
<button className="btn btn-ghost" onClick={() => setPrintContent({ type: "studioBudget", snapshot: buildSnapshot("Aktuell"), settings: data.settings })}>PDF</button>
|
||||
<button className="btn btn-primary" onClick={saveSettings}>Speichern</button>
|
||||
</div>
|
||||
} />
|
||||
|
||||
{/* Versions-Panel */}
|
||||
{showVersions && (
|
||||
<div className="card" style={{ marginBottom: 20, borderLeft: "4px solid #b07848" }}>
|
||||
<div style={{ fontSize: 11, letterSpacing: "0.1em", color: "var(--text4)", marginBottom: 14 }}>VERSIONEN</div>
|
||||
|
||||
{/* Neue Version speichern */}
|
||||
<div style={{ display: "flex", gap: 8, marginBottom: 16, alignItems: "center" }}>
|
||||
<input
|
||||
value={saveName}
|
||||
onChange={e => setSaveName(e.target.value)}
|
||||
placeholder={`z.B. Budget ${new Date().getFullYear()}, Szenario A…`}
|
||||
style={{ flex: 1, height: 34, fontSize: 12 }}
|
||||
onKeyDown={e => e.key === "Enter" && saveVersion()}
|
||||
/>
|
||||
<button className="btn btn-primary" style={{ whiteSpace: "nowrap" }} onClick={saveVersion}>
|
||||
+ Als Version speichern
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{versions.length === 0 ? (
|
||||
<div style={{ fontSize: 12, color: "var(--text4)" }}>Noch keine Versionen gespeichert.</div>
|
||||
) : (
|
||||
<table style={{ width: "100%", borderCollapse: "collapse" }}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ fontSize: 10, color: "var(--text4)", fontWeight: 500, textAlign: "left", padding: "4px 8px 8px 0", borderBottom: "1px solid var(--border)" }}>Name</th>
|
||||
<th style={{ fontSize: 10, color: "var(--text4)", fontWeight: 500, textAlign: "right", padding: "4px 8px 8px", borderBottom: "1px solid var(--border)" }}>Selbstkosten/h</th>
|
||||
<th style={{ fontSize: 10, color: "var(--text4)", fontWeight: 500, textAlign: "right", padding: "4px 8px 8px", borderBottom: "1px solid var(--border)" }}>Ziel-Honorar/h</th>
|
||||
<th style={{ fontSize: 10, color: "var(--text4)", fontWeight: 500, textAlign: "right", padding: "4px 8px 8px", borderBottom: "1px solid var(--border)" }}>Gesamtkosten</th>
|
||||
<th style={{ fontSize: 10, color: "var(--text4)", fontWeight: 500, textAlign: "right", padding: "4px 0 8px 8px", borderBottom: "1px solid var(--border)" }}>Gespeichert</th>
|
||||
<th style={{ borderBottom: "1px solid var(--border)", width: 110 }}></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{[...versions].reverse().map(v => (
|
||||
<tr key={v.id}>
|
||||
<td style={{ padding: "8px 8px 8px 0", fontWeight: 500, fontSize: 13, borderBottom: "1px solid var(--border2)" }}>{v.name}</td>
|
||||
<td style={{ padding: "8px", textAlign: "right", fontSize: 12, borderBottom: "1px solid var(--border2)" }}>{formatCHF(Math.round(v.results?.selbstkosten || 0))}</td>
|
||||
<td style={{ padding: "8px", textAlign: "right", fontSize: 12, fontWeight: 600, color: "#b07848", borderBottom: "1px solid var(--border2)" }}>{formatCHF(Math.round(v.results?.zielHonorar || 0))}</td>
|
||||
<td style={{ padding: "8px", textAlign: "right", fontSize: 12, borderBottom: "1px solid var(--border2)" }}>{formatCHF(Math.round(v.results?.gesamtKosten || 0))}</td>
|
||||
<td style={{ padding: "8px 0 8px 8px", textAlign: "right", fontSize: 11, color: "var(--text4)", borderBottom: "1px solid var(--border2)" }}>
|
||||
{new Date(v.savedAt).toLocaleDateString("de-CH")}
|
||||
</td>
|
||||
<td style={{ padding: "8px 0", textAlign: "right", whiteSpace: "nowrap", borderBottom: "1px solid var(--border2)" }}>
|
||||
<button className="btn btn-ghost" style={{ fontSize: 11, padding: "4px 8px", marginRight: 4 }}
|
||||
onClick={() => setPrintContent({ type: "studioBudget", snapshot: v, settings: data.settings })}>PDF</button>
|
||||
<button className="btn btn-ghost" style={{ fontSize: 11, padding: "4px 8px", marginRight: 4 }}
|
||||
onClick={() => loadVersion(v)}>Laden</button>
|
||||
<button className="btn btn-danger" style={{ fontSize: 11, padding: "4px 8px" }}
|
||||
onClick={() => deleteVersion(v.id)}><span className="material-icons" style={{ fontSize: 16 }}>close</span></button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── ANALYSE — full width, ganz oben ── */}
|
||||
<div className="card" style={{ borderTop: `3px solid ${rateOk ? "#2d6a4f" : "#8a1a1a"}`, marginBottom: 20 }}>
|
||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr 1fr auto", gap: 0, alignItems: "stretch" }}>
|
||||
<div style={{ padding: "14px 20px 14px 0", borderRight: "1px solid var(--border2)" }}>
|
||||
<div style={{ fontSize: 10, color: "var(--text4)", letterSpacing: "0.1em", marginBottom: 8 }}>SELBSTKOSTENSATZ</div>
|
||||
<div style={{ fontSize: 32, fontFamily: "'Playfair Display', serif", fontWeight: 700, lineHeight: 1 }}>{formatCHF(Math.round(selbstkosten))}</div>
|
||||
<div style={{ fontSize: 10, color: "var(--text4)", marginTop: 6 }}>pro produktive Stunde</div>
|
||||
</div>
|
||||
<div style={{ padding: "14px 20px", borderRight: "1px solid var(--border2)" }}>
|
||||
<div style={{ fontSize: 10, color: "var(--text4)", letterSpacing: "0.1em", marginBottom: 8 }}>ZIEL-HONORAR</div>
|
||||
<div style={{ fontSize: 32, fontFamily: "'Playfair Display', serif", fontWeight: 700, color: "#b07848", lineHeight: 1 }}>{formatCHF(Math.round(zielHonorar))}</div>
|
||||
<div style={{ fontSize: 10, color: "var(--text4)", marginTop: 6 }}>inkl. {b.zielMarge}% Marge</div>
|
||||
</div>
|
||||
<div style={{ padding: "14px 20px", borderRight: "1px solid var(--border2)" }}>
|
||||
<div style={{ fontSize: 10, color: "var(--text4)", letterSpacing: "0.1em", marginBottom: 8 }}>GESAMTKOSTEN / JAHR</div>
|
||||
<div style={{ fontSize: 32, fontFamily: "'Playfair Display', serif", fontWeight: 700, lineHeight: 1 }}>{formatCHF(gesamtKosten)}</div>
|
||||
<div style={{ fontSize: 10, color: "var(--text4)", marginTop: 6 }}>{jahresStundenFallback}h verfügbar · {produktivStunden}h produktiv</div>
|
||||
</div>
|
||||
<div style={{ padding: "14px 0 14px 20px", display: "flex", flexDirection: "column", justifyContent: "center", minWidth: 180 }}>
|
||||
<div style={{ padding: "10px 14px", borderRadius: 6, border: `1.5px solid ${rateOk ? "#b8dcc8" : "#e8b0b0"}`, background: rateOk ? "#f0f8f4" : "#fdf2f2" }}>
|
||||
<div style={{ fontSize: 11, fontWeight: 600, color: rateOk ? "#2d6a4f" : "#8a1a1a", marginBottom: 4 }}>
|
||||
{rateOk ? "✓ Ansatz ausreichend" : "⚠ Ansatz zu tief"}
|
||||
</div>
|
||||
<div style={{ fontSize: 20, fontWeight: 700, fontFamily: "'Playfair Display', serif", color: rateOk ? "#2d6a4f" : "#8a1a1a" }}>
|
||||
{rateOk ? "+" : ""}{formatCHF(Math.round(rateDiff))}
|
||||
</div>
|
||||
<div style={{ fontSize: 10, color: "var(--text4)", marginTop: 3 }}>Aktuell {formatCHF(currentRate)}/h</div>
|
||||
</div>
|
||||
{selbstkosten > 0 && (
|
||||
<div style={{ marginTop: 10 }}>
|
||||
<div style={{ height: 4, background: "var(--border)", borderRadius: 2, overflow: "hidden" }}>
|
||||
<div style={{ width: `${Math.min((produktivStunden / Math.ceil(gesamtKosten / currentRate)) * 100, 100)}%`, height: "100%", background: rateOk ? "#2d6a4f" : "#b5621e", borderRadius: 2 }} />
|
||||
</div>
|
||||
<div style={{ fontSize: 10, color: "var(--text4)", marginTop: 3 }}>Break-even: {Math.ceil(gesamtKosten / currentRate)}h</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: "grid", gridTemplateColumns: "3fr 2fr", gap: 20, alignItems: "start" }}>
|
||||
|
||||
{/* ── LINKE SPALTE ── */}
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
|
||||
|
||||
{/* Personalkosten */}
|
||||
<div className="card">
|
||||
<SectionLabel>PERSONALKOSTEN / JAHR</SectionLabel>
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<FormField label="AG-Sozialleistungen %">
|
||||
<input type="number" step="0.5" min={0} max={50} value={b.agZuschlag}
|
||||
onChange={e => { const v = +e.target.value; setField("agZuschlag", v); setB(prev => ({ ...prev, agZuschlag: v })); }} />
|
||||
<div style={{ fontSize: 11, color: "var(--text4)", marginTop: 3 }}>AHV, UVG, BVG, KTG AG-Anteil (typisch 18–22%)</div>
|
||||
</FormField>
|
||||
</div>
|
||||
|
||||
{employees.length === 0 ? (
|
||||
<div style={{ fontSize: 12, color: "var(--text4)", padding: "8px 0" }}>Keine Mitarbeiter erfasst – unter «Mitarbeiter» Löhne hinterlegen.</div>
|
||||
) : (
|
||||
<table style={{ width: "100%", borderCollapse: "collapse", marginBottom: 4 }}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ fontSize: 10, color: "var(--text4)", fontWeight: 500, textAlign: "left", padding: "4px 6px 4px 0", borderBottom: "1px solid var(--border)" }}>Mitarbeiter</th>
|
||||
<th style={{ fontSize: 10, color: "var(--text4)", fontWeight: 500, textAlign: "right", padding: "4px 6px", borderBottom: "1px solid var(--border)" }}>Kosten/Jahr</th>
|
||||
<th style={{ fontSize: 10, color: "var(--text4)", fontWeight: 500, textAlign: "right", padding: "4px 0 4px 6px", borderBottom: "1px solid var(--border)" }}>Jahresstunden</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{empCalcRows.map(({ emp, auto, row, kosten, stunden, aktiv }) => (
|
||||
<tr key={emp.id} style={{ opacity: aktiv ? 1 : 0.45 }}>
|
||||
<td style={{ padding: "8px 6px 8px 0", borderBottom: "1px solid var(--border2)", verticalAlign: "top" }}>
|
||||
<label style={{ display: "flex", alignItems: "center", gap: 6, cursor: "pointer", textTransform: "none", fontSize: 12 }}>
|
||||
<input type="checkbox" checked={aktiv} onChange={e => setEmpRow(emp.id, { aktiv: e.target.checked })} style={{ width: "auto" }} />
|
||||
<div>
|
||||
<div style={{ fontWeight: 500 }}>{emp.name}</div>
|
||||
<div style={{ fontSize: 10, color: "var(--text4)" }}>{emp.pensum || 100}% · {emp.dreizehnterLohn ? "13 Mt." : "12 Mt."}</div>
|
||||
</div>
|
||||
</label>
|
||||
</td>
|
||||
<td style={{ padding: "8px 6px", borderBottom: "1px solid var(--border2)", verticalAlign: "top", textAlign: "right" }}>
|
||||
<OverrideInput aktiv={aktiv} checked={row.kostenOverride} onToggle={v => setEmpRow(emp.id, { kostenOverride: v })}
|
||||
value={row.kostenManual} onChange={v => setEmpRow(emp.id, { kostenManual: v })} autoVal={auto.totalKosten} step={500} />
|
||||
</td>
|
||||
<td style={{ padding: "8px 0 8px 6px", borderBottom: "1px solid var(--border2)", verticalAlign: "top", textAlign: "right" }}>
|
||||
<OverrideInput aktiv={aktiv} checked={row.stundenOverride} onToggle={v => setEmpRow(emp.id, { stundenOverride: v })}
|
||||
value={row.stundenManual} onChange={v => setEmpRow(emp.id, { stundenManual: v })} autoVal={auto.jahresStunden} unit="h" step={10} />
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
<div style={{ display: "flex", justifyContent: "space-between", fontWeight: 600, fontSize: 13, paddingTop: 8, borderTop: "1.5px solid var(--border)" }}>
|
||||
<span>Total Personalkosten</span>
|
||||
<span style={{ fontFamily: "'Playfair Display', serif" }}>{formatCHF(personalKosten)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Fixkosten */}
|
||||
<div className="card">
|
||||
<SectionLabel>FIXKOSTEN / JAHR</SectionLabel>
|
||||
|
||||
{fixCalcRows.length === 0 && b.extraRows.length === 0 ? (
|
||||
<div style={{ fontSize: 12, color: "var(--text4)", marginBottom: 12 }}>
|
||||
Keine internen Ausgaben erfasst – unter «Interne Ausgaben» hinterlegen oder manuell unten hinzufügen.
|
||||
</div>
|
||||
) : (
|
||||
<table style={{ width: "100%", borderCollapse: "collapse", marginBottom: 12 }}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ fontSize: 10, color: "var(--text4)", fontWeight: 500, textAlign: "left", padding: "4px 6px 4px 0", borderBottom: "1px solid var(--border)" }}>Posten</th>
|
||||
<th style={{ fontSize: 10, color: "var(--text4)", fontWeight: 500, textAlign: "right", padding: "4px 0", borderBottom: "1px solid var(--border)" }}>CHF/Jahr</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{fixCalcRows.map(f => (
|
||||
<tr key={f.id} style={{ opacity: f.aktiv ? 1 : 0.45 }}>
|
||||
<td style={{ padding: "7px 6px 7px 0", borderBottom: "1px solid var(--border2)", verticalAlign: "middle" }}>
|
||||
<label style={{ display: "flex", alignItems: "center", gap: 6, cursor: "pointer", textTransform: "none", fontSize: 12 }}>
|
||||
<input type="checkbox" checked={f.aktiv} onChange={e => setFixRow(f.id, { aktiv: e.target.checked })} style={{ width: "auto" }} />
|
||||
<div>
|
||||
<div style={{ fontWeight: 500 }}>{f.label}</div>
|
||||
<div style={{ fontSize: 10, color: "var(--text4)" }}>{f.cat}</div>
|
||||
</div>
|
||||
</label>
|
||||
</td>
|
||||
<td style={{ padding: "7px 0 7px 6px", borderBottom: "1px solid var(--border2)", textAlign: "right" }}>
|
||||
<OverrideInput aktiv={f.aktiv} checked={f.row.amountOverride} onToggle={v => setFixRow(f.id, { amountOverride: v })}
|
||||
value={f.row.amountManual} onChange={v => setFixRow(f.id, { amountManual: v })} autoVal={f.amount} step={100} />
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{b.extraRows.map(r => (
|
||||
<tr key={r.id} style={{ opacity: r.aktiv ? 1 : 0.45 }}>
|
||||
<td style={{ padding: "7px 6px 7px 0", borderBottom: "1px solid var(--border2)" }}>
|
||||
<label style={{ display: "flex", alignItems: "center", gap: 6, cursor: "pointer", textTransform: "none", fontSize: 12 }}>
|
||||
<input type="checkbox" checked={r.aktiv} onChange={e => setExtraRow(r.id, { aktiv: e.target.checked })} style={{ width: "auto" }} />
|
||||
<span style={{ fontWeight: 500, fontStyle: "italic", color: "var(--text3)" }}>{r.label}</span>
|
||||
</label>
|
||||
</td>
|
||||
<td style={{ padding: "7px 0 7px 6px", borderBottom: "1px solid var(--border2)", textAlign: "right" }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 6, justifyContent: "flex-end" }}>
|
||||
<input type="number" step="100" value={r.amount} onChange={e => setExtraRow(r.id, { amount: +e.target.value })}
|
||||
style={{ width: 110, height: 28, fontSize: 12, padding: "0 8px" }} />
|
||||
<button onClick={() => delExtra(r.id)} style={{ background: "none", border: "none", color: "var(--text4)", cursor: "pointer", fontSize: 14, padding: 0 }}><span className="material-icons" style={{ fontSize: 16 }}>close</span></button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
|
||||
{/* Neuen Posten hinzufügen */}
|
||||
<div style={{ display: "flex", gap: 6, alignItems: "center", marginBottom: 12 }}>
|
||||
<input value={newExtra.label} onChange={e => setNewExtra(p => ({ ...p, label: e.target.value }))}
|
||||
placeholder="Bezeichnung (z.B. Buchführung)" style={{ flex: 1, height: 32, fontSize: 12 }} />
|
||||
<input type="number" step="100" value={newExtra.amount || ""} onChange={e => setNewExtra(p => ({ ...p, amount: +e.target.value }))}
|
||||
placeholder="CHF/Jahr" style={{ width: 100, height: 32, fontSize: 12 }} />
|
||||
<button className="btn btn-ghost" style={{ height: 32, padding: "0 12px", fontSize: 12, whiteSpace: "nowrap" }} onClick={addExtra}>+ Hinzufügen</button>
|
||||
</div>
|
||||
|
||||
<div style={{ display: "flex", justifyContent: "space-between", fontWeight: 600, fontSize: 13, paddingTop: 8, borderTop: "1.5px solid var(--border)" }}>
|
||||
<span>Total Fixkosten</span>
|
||||
<span style={{ fontFamily: "'Playfair Display', serif" }}>{formatCHF(fixKosten + extraKosten)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Einnahmen */}
|
||||
<div className="card">
|
||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 10 }}>
|
||||
<SectionLabel>EINNAHMEN / JAHR</SectionLabel>
|
||||
</div>
|
||||
|
||||
<div style={{ display: "flex", gap: 12, marginBottom: 4, flexWrap: "wrap" }}>
|
||||
<label style={{ display: "flex", alignItems: "center", gap: 5, fontSize: 11, color: "var(--text3)", cursor: "pointer", textTransform: "none" }}>
|
||||
<input type="checkbox" checked={b.inkludiereRechnungen} onChange={e => setField("inkludiereRechnungen", e.target.checked)} style={{ width: "auto" }} />
|
||||
Rechnungen aus Buchhaltung
|
||||
</label>
|
||||
<label style={{ display: "flex", alignItems: "center", gap: 5, fontSize: 11, color: "var(--text3)", cursor: "pointer", textTransform: "none" }}>
|
||||
<input type="checkbox" checked={b.inkludiereOfferten} onChange={e => setField("inkludiereOfferten", e.target.checked)} style={{ width: "auto" }} />
|
||||
Offerten
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{b.inkludiereRechnungen && (
|
||||
<div style={{ marginBottom: 14, padding: "10px 12px", background: "var(--surface2)", borderRadius: 6 }}>
|
||||
<div style={{ fontSize: 10, letterSpacing: "0.08em", color: "var(--text4)", marginBottom: 8 }}>AUS RECHNUNGEN {currentYear}</div>
|
||||
{[
|
||||
{ label: "Bezahlt", value: paidRevenue, color: "#2d6a4f" },
|
||||
{ label: "Versendet / Überfällig", value: sentRevenue, color: "#b07848" },
|
||||
].map(({ label, value, color }) => value > 0 && (
|
||||
<div key={label} style={{ display: "flex", justifyContent: "space-between", fontSize: 12, marginBottom: 4 }}>
|
||||
<span style={{ color: "var(--text3)" }}>{label}</span>
|
||||
<span style={{ fontWeight: 500, color }}>{formatCHF(value)}</span>
|
||||
</div>
|
||||
))}
|
||||
{draftRevenue > 0 && (
|
||||
<div style={{ marginTop: 6, paddingTop: 6, borderTop: "1px solid var(--border2)" }}>
|
||||
<label style={{ display: "flex", alignItems: "center", justifyContent: "space-between", cursor: "pointer", textTransform: "none" }}>
|
||||
<span style={{ display: "flex", alignItems: "center", gap: 5, fontSize: 11, color: "var(--text3)" }}>
|
||||
<input type="checkbox" checked={b.inkludiereEntwuerfe} onChange={e => setField("inkludiereEntwuerfe", e.target.checked)} style={{ width: "auto" }} />
|
||||
Entwürfe einbeziehen
|
||||
</span>
|
||||
<span style={{ fontSize: 12, fontWeight: 500, color: b.inkludiereEntwuerfe ? "#b5621e" : "var(--text4)", fontStyle: b.inkludiereEntwuerfe ? "normal" : "italic" }}>
|
||||
{b.inkludiereEntwuerfe ? formatCHF(draftRevenue) : "—"}
|
||||
{b.inkludiereEntwuerfe && <span style={{ fontSize: 10, color: "var(--text4)", marginLeft: 4 }}>erwartet</span>}
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
{paidRevenue === 0 && sentRevenue === 0 && draftRevenue === 0 && (
|
||||
<div style={{ fontSize: 11, color: "var(--text4)" }}>Keine Rechnungen in {currentYear} gefunden.</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{b.inkludiereOfferten && (
|
||||
<div style={{ marginBottom: 14, padding: "10px 12px", background: "var(--surface2)", borderRadius: 6 }}>
|
||||
<div style={{ fontSize: 10, letterSpacing: "0.08em", color: "var(--text4)", marginBottom: 8 }}>AUS OFFERTEN {currentYear}</div>
|
||||
{[
|
||||
{ label: "Angenommen", value: acceptedQuoteRevenue, color: "#2d6a4f" },
|
||||
{ label: "Gesendet", value: sentQuoteRevenue, color: "#b07848" },
|
||||
].map(({ label, value, color }) => value > 0 && (
|
||||
<div key={label} style={{ display: "flex", justifyContent: "space-between", fontSize: 12, marginBottom: 4 }}>
|
||||
<span style={{ color: "var(--text3)" }}>{label}</span>
|
||||
<span style={{ fontWeight: 500, color }}>{formatCHF(value)}</span>
|
||||
</div>
|
||||
))}
|
||||
{draftQuoteRevenue > 0 && (
|
||||
<div style={{ marginTop: 6, paddingTop: 6, borderTop: "1px solid var(--border2)" }}>
|
||||
<label style={{ display: "flex", alignItems: "center", justifyContent: "space-between", cursor: "pointer", textTransform: "none" }}>
|
||||
<span style={{ display: "flex", alignItems: "center", gap: 5, fontSize: 11, color: "var(--text3)" }}>
|
||||
<input type="checkbox" checked={b.inkludiereOffertEntwuerfe} onChange={e => setField("inkludiereOffertEntwuerfe", e.target.checked)} style={{ width: "auto" }} />
|
||||
Entwürfe einbeziehen
|
||||
</span>
|
||||
<span style={{ fontSize: 12, fontWeight: 500, color: b.inkludiereOffertEntwuerfe ? "#b5621e" : "var(--text4)", fontStyle: b.inkludiereOffertEntwuerfe ? "normal" : "italic" }}>
|
||||
{b.inkludiereOffertEntwuerfe ? formatCHF(draftQuoteRevenue) : "—"}
|
||||
{b.inkludiereOffertEntwuerfe && <span style={{ fontSize: 10, color: "var(--text4)", marginLeft: 4 }}>erwartet</span>}
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
{acceptedQuoteRevenue === 0 && sentQuoteRevenue === 0 && draftQuoteRevenue === 0 && (
|
||||
<div style={{ fontSize: 11, color: "var(--text4)" }}>Keine Offerten in {currentYear} gefunden.</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(b.extraIncome || []).length > 0 && (
|
||||
<table style={{ width: "100%", borderCollapse: "collapse", marginBottom: 12 }}>
|
||||
<tbody>
|
||||
{(b.extraIncome || []).map(r => (
|
||||
<tr key={r.id} style={{ opacity: r.aktiv ? 1 : 0.45 }}>
|
||||
<td style={{ padding: "7px 6px 7px 0", borderBottom: "1px solid var(--border2)" }}>
|
||||
<label style={{ display: "flex", alignItems: "center", gap: 6, cursor: "pointer", textTransform: "none", fontSize: 12 }}>
|
||||
<input type="checkbox" checked={r.aktiv} onChange={e => setIncomeRow(r.id, { aktiv: e.target.checked })} style={{ width: "auto" }} />
|
||||
<span style={{ fontWeight: 500, fontStyle: "italic", color: "var(--text3)" }}>{r.label}</span>
|
||||
</label>
|
||||
</td>
|
||||
<td style={{ padding: "7px 0 7px 6px", borderBottom: "1px solid var(--border2)", textAlign: "right" }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 6, justifyContent: "flex-end" }}>
|
||||
<input type="number" step="100" value={r.amount} onChange={e => setIncomeRow(r.id, { amount: +e.target.value })}
|
||||
style={{ width: 110, height: 28, fontSize: 12, padding: "0 8px" }} />
|
||||
<button onClick={() => delIncome(r.id)} style={{ background: "none", border: "none", color: "var(--text4)", cursor: "pointer", fontSize: 14, padding: 0 }}><span className="material-icons" style={{ fontSize: 16 }}>close</span></button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
|
||||
<div style={{ display: "flex", gap: 6, alignItems: "center", marginBottom: 12 }}>
|
||||
<input value={newIncome.label} onChange={e => setNewIncome(p => ({ ...p, label: e.target.value }))}
|
||||
placeholder="z.B. Subvention, Förderung…" style={{ flex: 1, height: 32, fontSize: 12 }}
|
||||
onKeyDown={e => e.key === "Enter" && addIncome()} />
|
||||
<input type="number" step="100" value={newIncome.amount || ""} onChange={e => setNewIncome(p => ({ ...p, amount: +e.target.value }))}
|
||||
placeholder="CHF/Jahr" style={{ width: 100, height: 32, fontSize: 12 }} />
|
||||
<button className="btn btn-ghost" style={{ height: 32, padding: "0 12px", fontSize: 12, whiteSpace: "nowrap" }} onClick={addIncome}>+ Hinzufügen</button>
|
||||
</div>
|
||||
|
||||
<div style={{ display: "flex", justifyContent: "space-between", fontWeight: 600, fontSize: 13, paddingTop: 8, borderTop: "1.5px solid var(--border)" }}>
|
||||
<span>Total Einnahmen</span>
|
||||
<span style={{ fontFamily: "'Playfair Display', serif", color: "#2d6a4f" }}>{formatCHF(totalRevenue)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Reserve & Stunden & Marge — kompakt in einer Zeile */}
|
||||
<div className="card">
|
||||
<SectionLabel>PARAMETER</SectionLabel>
|
||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr 1fr", gap: 16, marginBottom: 16 }}>
|
||||
{[
|
||||
{ label: "Reserve %", key: "reserve", min: 0, max: 50, hint: "Puffer für Unvorhergesehenes" },
|
||||
{ label: "Produktiv-Quote %", key: "produktivQuote", min: 10, max: 100, hint: "Verrechenbare Stunden (60–75%)" },
|
||||
{ label: "Ziel-Marge %", key: "zielMarge", min: 0, max: 80, hint: "Aufschlag für Gewinn / Reinvestition" },
|
||||
].map(({ label, key, min, max, hint }) => (
|
||||
<div key={key}>
|
||||
<div style={{ fontSize: 11, color: "var(--text4)", marginBottom: 5 }}>{label}</div>
|
||||
<input type="number" step="1" min={min} max={max} value={b[key]}
|
||||
onChange={e => setField(key, +e.target.value)}
|
||||
style={{ width: "100%", height: 34, fontSize: 13, padding: "0 10px" }} />
|
||||
<div style={{ fontSize: 10, color: "var(--text4)", marginTop: 4 }}>{hint}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div style={{ paddingTop: 12, borderTop: "1px solid var(--border2)" }}>
|
||||
<div style={{ fontSize: 11, color: "var(--text4)", marginBottom: 6 }}>Aktueller Stundensatz (Vergleichswert)</div>
|
||||
<OverrideInput
|
||||
label="übersteuern"
|
||||
aktiv={true}
|
||||
checked={b.rateOverride}
|
||||
onToggle={v => setField("rateOverride", v)}
|
||||
value={b.rateManual}
|
||||
onChange={v => setField("rateManual", v)}
|
||||
autoVal={defaultRate}
|
||||
unit="CHF"
|
||||
step={5}
|
||||
/>
|
||||
<div style={{ fontSize: 10, color: "var(--text4)", marginTop: 4 }}>Standard aus Einstellungen: {formatCHF(defaultRate)}/h</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
{/* ── RECHTE SPALTE: Ergebnis ── */}
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
|
||||
|
||||
{/* Kostenstruktur */}
|
||||
<div className="card">
|
||||
<SectionLabel>KOSTENSTRUKTUR / JAHR</SectionLabel>
|
||||
{[
|
||||
{ label: "Personalkosten", value: personalKosten },
|
||||
{ label: "Fixkosten", value: fixKosten + extraKosten },
|
||||
{ label: "Basiskosten", value: basisKosten, bold: true },
|
||||
{ label: `+ Reserve (${b.reserve}%)`, value: reserve, indent: true },
|
||||
{ label: "Gesamtkosten", value: gesamtKosten, total: true },
|
||||
].map((r, i) => (
|
||||
<div key={i} style={{ display: "flex", justifyContent: "space-between", padding: r.total ? "8px 0 2px" : r.indent ? "2px 0 2px 12px" : "4px 0", borderTop: r.total ? "2px solid var(--text)" : "none", borderBottom: r.total ? "none" : "1px solid var(--border2)", marginTop: r.total ? 4 : 0 }}>
|
||||
<span style={{ fontSize: r.total ? 13 : r.indent ? 11 : 12, fontWeight: r.total || r.bold ? 700 : 400, color: r.total || r.bold ? "var(--text)" : "var(--text2)" }}>{r.label}</span>
|
||||
<span style={{ fontSize: r.total ? 15 : 12, fontWeight: r.total || r.bold ? 700 : 400, fontFamily: r.total || r.bold ? "'Playfair Display', serif" : "inherit" }}>{formatCHF(r.value)}</span>
|
||||
</div>
|
||||
))}
|
||||
{totalRevenue > 0 && (
|
||||
<>
|
||||
<div style={{ display: "flex", justifyContent: "space-between", padding: "8px 0 4px", marginTop: 8, borderTop: "1px dashed var(--border)" }}>
|
||||
<span style={{ fontSize: 12, color: "var(--text2)" }}>
|
||||
Einnahmen
|
||||
{b.inkludiereEntwuerfe && draftRevenue > 0 && b.inkludiereRechnungen && (
|
||||
<span style={{ fontSize: 10, color: "#b5621e", marginLeft: 5 }}>inkl. {formatCHF(draftRevenue)} erwartet (Rechnungen)</span>
|
||||
)}
|
||||
{b.inkludiereOffertEntwuerfe && draftQuoteRevenue > 0 && b.inkludiereOfferten && (
|
||||
<span style={{ fontSize: 10, color: "#b5621e", marginLeft: 5 }}>inkl. {formatCHF(draftQuoteRevenue)} erwartet (Offerten)</span>
|
||||
)}
|
||||
</span>
|
||||
<span style={{ fontSize: 12, fontWeight: 600, color: "#2d6a4f" }}>{formatCHF(totalRevenue)}</span>
|
||||
</div>
|
||||
<div style={{ display: "flex", justifyContent: "space-between", padding: "8px 0 2px", borderTop: "2px solid var(--text)", marginTop: 4 }}>
|
||||
<span style={{ fontSize: 13, fontWeight: 700 }}>Ergebnis</span>
|
||||
<span style={{ fontSize: 15, fontWeight: 700, fontFamily: "'Playfair Display', serif", color: ergebnis >= 0 ? "#2d6a4f" : "#8a1a1a" }}>
|
||||
{ergebnis >= 0 ? "+" : ""}{formatCHF(ergebnis)}
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Stunden — Soll + Ist zusammen */}
|
||||
<div className="card">
|
||||
<SectionLabel>STUNDEN</SectionLabel>
|
||||
<div style={{ display: "flex", justifyContent: "space-between", padding: "4px 0", borderBottom: "1px solid var(--border2)", fontSize: 12 }}>
|
||||
<span style={{ color: "var(--text2)" }}>Verfügbar</span>
|
||||
<span>{jahresStundenFallback}h {employees.length === 0 && <span style={{ color: "var(--text4)", fontSize: 10 }}>Fallback</span>}</span>
|
||||
</div>
|
||||
<div style={{ display: "flex", justifyContent: "space-between", padding: "4px 0", borderBottom: "1px solid var(--border2)", fontSize: 12 }}>
|
||||
<span style={{ color: "var(--text2)" }}>Produktiv ({b.produktivQuote}%)</span>
|
||||
<span style={{ fontWeight: 600 }}>{produktivStunden}h</span>
|
||||
</div>
|
||||
<div style={{ marginTop: 10 }}>
|
||||
<div style={{ height: 5, background: "var(--border)", borderRadius: 3, overflow: "hidden" }}>
|
||||
<div style={{ width: `${b.produktivQuote}%`, height: "100%", background: b.produktivQuote >= 80 ? "#8a1a1a" : b.produktivQuote >= 70 ? "#b5621e" : "#2d6a4f", borderRadius: 3 }} />
|
||||
</div>
|
||||
<div style={{ fontSize: 10, color: "var(--text4)", marginTop: 3 }}>Über 75% ist anspruchsvoll</div>
|
||||
</div>
|
||||
|
||||
{totalTrackedMins > 0 && (
|
||||
<div style={{ marginTop: 14, paddingTop: 14, borderTop: "1px solid var(--border2)" }}>
|
||||
<div style={{ fontSize: 10, letterSpacing: "0.08em", color: "var(--text4)", marginBottom: 10 }}>IST {currentYear}</div>
|
||||
{[
|
||||
{ label: "Verrechenbar", mins: billingMins, color: "#2d6a4f" },
|
||||
{ label: "Wettbewerbe", mins: wbTotalMins, color: "#b5621e" },
|
||||
{ label: "Ohne Projekt", mins: noProjectMins, color: "#bbb" },
|
||||
].map(({ label, mins, color }) => {
|
||||
if (mins === 0) return null;
|
||||
const pct = (mins / totalTrackedMins) * 100;
|
||||
return (
|
||||
<div key={label} style={{ marginBottom: 8 }}>
|
||||
<div style={{ display: "flex", justifyContent: "space-between", fontSize: 11, marginBottom: 3 }}>
|
||||
<span style={{ color: "var(--text2)" }}>{label}</span>
|
||||
<span>{Math.round(mins / 60)}h <span style={{ color: "var(--text4)" }}>({Math.round(pct)}%)</span></span>
|
||||
</div>
|
||||
<div style={{ height: 4, background: "var(--border)", borderRadius: 2, overflow: "hidden" }}>
|
||||
<div style={{ width: `${pct}%`, height: "100%", background: color, borderRadius: 2 }} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<div style={{ display: "flex", justifyContent: "space-between", fontSize: 12, paddingTop: 6, borderTop: "1px solid var(--border2)", marginTop: 2 }}>
|
||||
<span style={{ color: "var(--text2)" }}>Total erfasst</span>
|
||||
<span style={{ fontWeight: 600 }}>{Math.round(totalTrackedMins / 60)}h</span>
|
||||
</div>
|
||||
{istProduktivQuote !== null && (
|
||||
<div style={{ marginTop: 8, display: "flex", justifyContent: "space-between", fontSize: 11, padding: "6px 10px", borderRadius: 5, background: "var(--surface2)" }}>
|
||||
<span style={{ color: "var(--text3)" }}>Ist-Produktivquote</span>
|
||||
<span>
|
||||
<strong style={{ color: Math.abs(istProduktivQuote - b.produktivQuote) > 10 ? "#b5621e" : "var(--text)" }}>{Math.round(istProduktivQuote)}%</strong>
|
||||
<span style={{ color: "var(--text4)", marginLeft: 5 }}>Soll: {b.produktivQuote}%</span>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Wettbewerbe – Interner Aufwand */}
|
||||
{wbProjects.length > 0 && (
|
||||
<div className="card">
|
||||
<SectionLabel>WETTBEWERBE — INTERNER AUFWAND</SectionLabel>
|
||||
{wbRows.length === 0 ? (
|
||||
<div style={{ fontSize: 12, color: "var(--text4)" }}>Keine Wettbewerb-Stunden in {currentYear} erfasst.</div>
|
||||
) : (
|
||||
<>
|
||||
<table style={{ width: "100%", borderCollapse: "collapse", marginBottom: 10 }}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ fontSize: 10, color: "var(--text4)", fontWeight: 500, textAlign: "left", padding: "4px 6px 6px 0", borderBottom: "1px solid var(--border)" }}>Projekt</th>
|
||||
<th style={{ fontSize: 10, color: "var(--text4)", fontWeight: 500, textAlign: "right", padding: "4px 6px 6px", borderBottom: "1px solid var(--border)" }}>Std.</th>
|
||||
<th style={{ fontSize: 10, color: "var(--text4)", fontWeight: 500, textAlign: "right", padding: "4px 0 6px", borderBottom: "1px solid var(--border)" }}>Interner Aufwand</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{wbRows.map(r => (
|
||||
<tr key={r.id}>
|
||||
<td style={{ padding: "6px 6px 6px 0", borderBottom: "1px solid var(--border2)", fontSize: 12 }}>{r.name}</td>
|
||||
<td style={{ padding: "6px 6px", borderBottom: "1px solid var(--border2)", textAlign: "right", fontSize: 12 }}>{Math.round(r.hours * 10) / 10}h</td>
|
||||
<td style={{ padding: "6px 0", borderBottom: "1px solid var(--border2)", textAlign: "right", fontSize: 12 }}>{selbstkosten > 0 ? formatCHF(Math.round(r.cost)) : "—"}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr>
|
||||
<td style={{ padding: "7px 6px 2px 0", fontWeight: 600, fontSize: 12 }}>Total</td>
|
||||
<td style={{ padding: "7px 6px 2px", textAlign: "right", fontWeight: 600, fontSize: 12 }}>{Math.round(wbTotalHours * 10) / 10}h</td>
|
||||
<td style={{ padding: "7px 0 2px", textAlign: "right", fontWeight: 600, fontSize: 12 }}>{selbstkosten > 0 ? formatCHF(Math.round(wbTotalHours * selbstkosten)) : "—"}</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
{selbstkosten > 0 && (
|
||||
<div style={{ fontSize: 11, color: "var(--text4)", paddingTop: 8, borderTop: "1px solid var(--border2)" }}>
|
||||
Entgangenes Honorar: {Math.round(wbTotalHours)}h × {formatCHF(currentRate)}/h = <strong style={{ color: "var(--text3)" }}>{formatCHF(Math.round(wbTotalHours * currentRate))}</strong>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Rollenvergleich */}
|
||||
{(data.settings.roles || []).length > 0 && selbstkosten > 0 && (
|
||||
<div className="card">
|
||||
<SectionLabel>ROLLEN-VERGLEICH</SectionLabel>
|
||||
{(data.settings.roles || []).map(role => {
|
||||
const diff = role.rate - zielHonorar;
|
||||
const ok = diff >= 0;
|
||||
return (
|
||||
<div key={role.id} style={{ display: "flex", justifyContent: "space-between", alignItems: "center", padding: "5px 0", borderBottom: "1px solid var(--border2)" }}>
|
||||
<div>
|
||||
<span style={{ fontSize: 12 }}>{role.label}</span>
|
||||
<span style={{ fontSize: 10, color: "var(--text4)", marginLeft: 6 }}>{formatCHF(role.rate)}/h</span>
|
||||
</div>
|
||||
<span style={{ fontSize: 12, fontWeight: 600, color: ok ? "#2d6a4f" : "#8a1a1a" }}>
|
||||
{ok ? "+" : ""}{formatCHF(Math.round(diff))} {ok ? "▲" : "▼"}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<div style={{ fontSize: 10, color: "var(--text4)", marginTop: 8 }}>Differenz zum Ziel-Honorar von {formatCHF(Math.round(zielHonorar))}/h</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user