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:
karim gabriele varano
2026-05-13 01:16:26 +02:00
commit 8de93ff27f
65 changed files with 28010 additions and 0 deletions
Executable
+678
View File
@@ -0,0 +1,678 @@
// ─── UTILS ──────────────────────────────────────────────────
import { DEFAULT_ABSENZ_TYPES } from "./constants.js";
// SIA-Honorar-Formel: p = Z1 + Z2 / ∛B
export function calcSIAHours(baukosten, schwierigkeit, phasen) {
if (!baukosten || baukosten <= 0) return { p: 0, total: 0, phases: [] };
const cbrtB = Math.cbrt(baukosten);
const p = 0.062 + 10.58 / cbrtB;
const results = phasen.map(ph => {
const items = ph.items.map(it => {
if (it.enabled === false) return { ...it, hours: 0 };
const r = it.r ?? 1;
const hours = baukosten * (p / 100) * schwierigkeit * (it.pct / 100) * r;
return { ...it, hours: Math.round(hours * 10) / 10 };
});
const phaseHours = items.reduce((s, it) => s + it.hours, 0);
return { ...ph, items, hours: phaseHours };
});
const total = results.reduce((s, ph) => s + ph.hours, 0);
return { p: Math.round(p * 1000) / 1000, cbrtB, total, phases: results };
}
// Manuelle Aufwandschätzung: Stunden pro Rolle × Stundensatz
export function calcManualHours(phases, roles) {
const results = phases.filter(ph => ph.enabled).map(ph => {
const roleDetails = (roles || []).map(r => ({
...r, hours: ph.hoursByRole?.[r.id] || 0,
}));
const totalHours = roleDetails.reduce((s, r) => s + r.hours, 0);
const totalAmount = roleDetails.reduce((s, r) => s + r.hours * r.rate, 0);
return { ...ph, roleDetails, totalHours, totalAmount };
});
return {
phases: results,
totalHours: results.reduce((s, p) => s + p.totalHours, 0),
totalAmount: results.reduce((s, p) => s + p.totalAmount, 0),
};
}
// Berechnet Budget aus linkedQuotes-Array (Migration von sourceQuoteId)
export function migrateLinkedQuotes(project) {
if (project.linkedQuotes) return project.linkedQuotes;
if (project.sourceQuoteId) return [{ quoteId: project.sourceQuoteId, role: "Hauptofferte" }];
return [];
}
export function deriveQuoteBudget(linkedQuotes, allQuotes, settingsRoles) {
const quotes = (linkedQuotes || []).map(lq => allQuotes.find(q => q.id === lq.quoteId)).filter(Boolean);
let totalHours = 0;
let totalAmount = 0;
const phaseMap = {}; // phaseId -> hours (summed)
const enabledSet = new Set(); // phases enabled in quotes even with 0 hours
quotes.forEach(q => {
const roles = q.quoteRoles || settingsRoles || [];
if (q.mode === "sia") {
const calc = calcSIAHours(q.sia?.baukosten, q.sia?.schwierigkeit, q.sia?.phases || []);
totalHours += calc.total || 0;
totalAmount += (calc.total || 0) * (q.sia?.stundenansatz || 0);
(calc.phases || []).forEach(ph => {
if (ph.hours > 0) phaseMap[ph.id] = (phaseMap[ph.id] || 0) + ph.hours;
});
} else if (q.mode === "manual") {
const calc = calcManualHours(q.manualPhases || [], roles);
totalHours += calc.totalHours || 0;
totalAmount += calc.totalAmount || 0;
(q.manualPhases || []).filter(ph => ph.enabled).forEach(ph => {
enabledSet.add(ph.id);
const h = roles.reduce((s, r) => s + (ph.hoursByRole?.[r.id] || 0), 0);
if (h > 0) phaseMap[ph.id] = (phaseMap[ph.id] || 0) + h;
});
} else if (q.mode === "free") {
totalAmount += (q.freeItems || []).reduce((s, it) => s + (it.qty * it.price), 0);
}
});
const phasesBudget = Object.entries(phaseMap).map(([id, hours]) => ({ id, hours: Math.round(hours * 10) / 10 }));
const enabledPhases = [...new Set([...phasesBudget.map(p => p.id), ...enabledSet])];
return {
budgetHours: Math.round(totalHours * 10) / 10,
budgetAmount: Math.round(totalAmount),
phasesBudget,
enabledPhases,
hasFreeQuotes: quotes.some(q => q.mode === "free"),
hasHourQuotes: quotes.some(q => q.mode === "sia" || q.mode === "manual"),
};
}
export function generateId() {
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
return crypto.randomUUID();
}
// Fallback for very old environments — uses crypto.getRandomValues if available
if (typeof crypto !== "undefined" && typeof crypto.getRandomValues === "function") {
const buf = new Uint8Array(8);
crypto.getRandomValues(buf);
return Date.now().toString(36) + "-" + Array.from(buf).map(b => b.toString(16).padStart(2, "0")).join("");
}
return Date.now().toString(36) + Math.random().toString(36).slice(2, 10);
}
// ─── PASSWORT-HASHING (PBKDF2 via Web Crypto) ──────────────────────────────
// Stored shape on user: { passwordHash, passwordSalt } (both hex strings)
// Legacy users with plaintext { password } are accepted once and upgraded
// transparently after a successful login.
const PBKDF2_ITERATIONS = 100_000;
const PBKDF2_HASH = "SHA-256";
const PBKDF2_SALT_BYTES = 16;
const PBKDF2_KEY_BITS = 256;
function bytesToHex(bytes) {
let s = "";
for (const b of bytes) s += b.toString(16).padStart(2, "0");
return s;
}
function hexToBytes(hex) {
const out = new Uint8Array(hex.length / 2);
for (let i = 0; i < out.length; i++) out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);
return out;
}
export async function hashPassword(password, saltHex) {
const salt = saltHex
? hexToBytes(saltHex)
: crypto.getRandomValues(new Uint8Array(PBKDF2_SALT_BYTES));
const baseKey = await crypto.subtle.importKey(
"raw", new TextEncoder().encode(password ?? ""), "PBKDF2", false, ["deriveBits"]
);
const bits = await crypto.subtle.deriveBits(
{ name: "PBKDF2", salt, iterations: PBKDF2_ITERATIONS, hash: PBKDF2_HASH },
baseKey, PBKDF2_KEY_BITS
);
return { hash: bytesToHex(new Uint8Array(bits)), salt: bytesToHex(salt) };
}
// Constant-time string compare to avoid timing leaks
function constantTimeEq(a, b) {
if (typeof a !== "string" || typeof b !== "string" || a.length !== b.length) return false;
let r = 0;
for (let i = 0; i < a.length; i++) r |= a.charCodeAt(i) ^ b.charCodeAt(i);
return r === 0;
}
export async function verifyPassword(password, user) {
if (!user) return false;
if (user.passwordHash && user.passwordSalt) {
const { hash } = await hashPassword(password, user.passwordSalt);
return constantTimeEq(hash, user.passwordHash);
}
// Legacy plaintext fallback (transitional only)
if (typeof user.password === "string") {
return constantTimeEq(password ?? "", user.password);
}
return false;
}
// Returns a copy of user with hashed credentials (and the legacy plaintext field stripped).
export async function withHashedPassword(user, password) {
const { hash, salt } = await hashPassword(password);
// eslint-disable-next-line no-unused-vars
const { password: _legacy, ...rest } = user;
return { ...rest, passwordHash: hash, passwordSalt: salt };
}
// Strips all credential fields from a user-shaped object (for sessionStorage / props)
export function stripCredentials(user) {
if (!user) return user;
// eslint-disable-next-line no-unused-vars
const { password, passwordHash, passwordSalt, ...safe } = user;
return safe;
}
// HTML sanitizer for rich-text content (letter bodies, etc.).
// Allowlist of tags + attributes; strips script, event handlers, javascript: URLs.
const SAFE_TAGS = new Set([
"P", "BR", "DIV", "SPAN", "B", "STRONG", "I", "EM", "U", "S", "STRIKE",
"H1", "H2", "H3", "H4", "H5", "H6", "UL", "OL", "LI", "BLOCKQUOTE",
"FONT", "A", "HR", "SUB", "SUP",
]);
const SAFE_ATTRS = new Set([
"style", "color", "size", "face", "align", "href", "target", "rel",
]);
function isSafeUrl(url) {
if (!url) return false;
const v = String(url).trim().toLowerCase();
// block javascript:, data:text/html, vbscript:, etc. — allow http(s), mailto, tel, relative
return !/^(javascript|data|vbscript|file):/i.test(v);
}
export function sanitizeHtml(html) {
if (typeof html !== "string" || html === "") return "";
if (typeof DOMParser === "undefined") return ""; // SSR/safe fallback
const doc = new DOMParser().parseFromString(`<div>${html}</div>`, "text/html");
const root = doc.body.firstChild;
if (!root) return "";
const walk = (node) => {
// Iterate over a snapshot — children may be removed during walk
const kids = Array.from(node.childNodes);
for (const child of kids) {
if (child.nodeType === 1) {
const tag = child.tagName;
if (!SAFE_TAGS.has(tag)) {
// Replace disallowed tag with its sanitized children (drops <script>, <iframe>, etc.)
while (child.firstChild) node.insertBefore(child.firstChild, child);
node.removeChild(child);
continue;
}
// Strip disallowed attributes; sanitize hrefs
for (const attr of Array.from(child.attributes)) {
const name = attr.name.toLowerCase();
if (name.startsWith("on") || !SAFE_ATTRS.has(name)) {
child.removeAttribute(attr.name);
continue;
}
if (name === "href" && !isSafeUrl(attr.value)) {
child.removeAttribute(attr.name);
continue;
}
if (name === "style" && /expression\s*\(|javascript:|url\s*\(\s*['"]?\s*javascript:/i.test(attr.value)) {
child.removeAttribute(attr.name);
}
}
if (tag === "A" && child.getAttribute("target") === "_blank" && !child.getAttribute("rel")) {
child.setAttribute("rel", "noopener noreferrer");
}
walk(child);
} else if (child.nodeType !== 3 && child.nodeType !== 8) {
// Drop comments and other node types except text
node.removeChild(child);
}
}
};
walk(root);
return root.innerHTML;
}
export function formatCHF(amount) {
return new Intl.NumberFormat("de-CH", { style: "currency", currency: "CHF" }).format(amount || 0);
}
export function formatDate(dateStr) {
if (!dateStr) return "—";
return new Date(dateStr).toLocaleDateString("de-CH");
}
// Schweizer 5-Rappen-Rundung
export function roundCHF(amount) {
return Math.round((amount || 0) * 20) / 20;
}
// Absender-Adresse aus strukturierten Feldern bauen; Fallback auf altes Freitext-Feld
export function formatSenderAddress(settings) {
const parts = [];
if (settings.street) parts.push(settings.street);
const line2 = [settings.zip, settings.city].filter(Boolean).join(" ");
if (line2) parts.push(line2);
if (parts.length > 0) return parts.join("\n");
return settings.address || "";
}
// ─── QR-BILL HELPERS ──────────────────────────────────────────
// Prüft ob IBAN eine QR-IBAN ist (IID im Bereich 30000-31999)
export function isQRIban(iban) {
const clean = (iban || "").replace(/\s/g, "").toUpperCase();
if (!clean.startsWith("CH") && !clean.startsWith("LI")) return false;
const iid = parseInt(clean.slice(4, 9));
return iid >= 30000 && iid <= 31999;
}
// IBAN-Formatierung mit Leerzeichen alle 4 Stellen
export function formatIban(iban) {
const clean = (iban || "").replace(/\s/g, "").toUpperCase();
return clean.match(/.{1,4}/g)?.join(" ") || "";
}
// Modulo-10-Rekursiv Prüfziffer (für QR-Referenz)
export function mod10(input) {
const table = [[0,9,4,6,8,2,7,1,3,5],[9,4,6,8,2,7,1,3,5,0],[4,6,8,2,7,1,3,5,0,9],[6,8,2,7,1,3,5,0,9,4],[8,2,7,1,3,5,0,9,4,6],[2,7,1,3,5,0,9,4,6,8],[7,1,3,5,0,9,4,6,8,2],[1,3,5,0,9,4,6,8,2,7],[3,5,0,9,4,6,8,2,7,1],[5,0,9,4,6,8,2,7,1,3]];
let carry = 0;
for (const ch of input) {
const digit = parseInt(ch);
if (isNaN(digit)) continue;
carry = table[carry][digit];
}
return ((10 - carry) % 10).toString();
}
// Generiert 27-stellige QR-Referenz aus Rechnungsnummer
export function generateQRReference(invoiceNumber) {
// Nur Ziffern extrahieren, links mit Nullen auf 26 Stellen auffüllen, dann Prüfziffer
const digits = (invoiceNumber || "").replace(/\D/g, "").padStart(26, "0").slice(-26);
return digits + mod10(digits);
}
// Formatiert Referenz in 5er-Blöcken von rechts
export function formatReference(ref) {
const clean = (ref || "").replace(/\s/g, "");
const reversed = clean.split("").reverse().join("");
const blocks = reversed.match(/.{1,5}/g) || [];
return blocks.map(b => b.split("").reverse().join("")).reverse().join(" ");
}
export function formatHours(minutes) {
const h = Math.floor((minutes || 0) / 60);
const m = (minutes || 0) % 60;
return `${h}h${m > 0 ? " " + m + "m" : ""}`;
}
// Formatiert Projektnummer nach konfigurierbarem Format
// Platzhalter: YYYY=2025, YY=25, NN=01..99
export function applyProjectNumberFormat(fmt, seq) {
const now = new Date();
const yyyy = String(now.getFullYear());
const yy = yyyy.slice(2);
const nn = String(seq).padStart(2, "0");
return (fmt || "YYYY/NN").replace(/YYYY/g, yyyy).replace(/YY/g, yy).replace(/NN/g, nn);
}
// Parst die laufende Nummer aus einer gespeicherten Projektnummer anhand des Formats
export function parseSeqFromNumber(num, fmt) {
if (!num || !fmt) return null;
const now = new Date();
const yyyy = String(now.getFullYear());
const yy = yyyy.slice(2);
// Escape regex special chars, then replace placeholders with capture groups
const pattern = (fmt || "YYYY/NN")
.replace(/[-/\\^$*+?.()|[\]{}]/g, "\\$&")
.replace(/YYYY/g, yyyy)
.replace(/YY/g, yy)
.replace(/NN/g, "(\\d+)");
const match = num.match(new RegExp("^" + pattern + "$"));
return match ? parseInt(match[1]) : null;
}
export function exportBuchhaltungCSV(data, year = "") {
const sep = ";";
const q = (s) => `"${String(s || "").replace(/"/g, '""')}"`;
const mwstRate = data.settings.mwstRate || 8.1;
const rows = [];
// Einnahmen
rows.push(["EINNAHMEN", "", "", "", "", "", "", ""].map(q).join(sep));
rows.push(["Datum", "Rechnungs-Nr.", "Kunde", "Beschreibung", "Netto CHF", "MWST-Satz %", "MWST CHF", "Brutto CHF"].map(q).join(sep));
const paidInvoices = [...data.invoices].filter(i => !year || (i.date || "").startsWith(year)).sort((a, b) => (a.date || "").localeCompare(b.date || ""));
paidInvoices.forEach(inv => {
const client = (data.persons||[]).find(c => c.id === inv.clientId);
const desc = (inv.items || []).map(it => it.desc).filter(Boolean).join(", ");
const taxRate = inv.mwst ? mwstRate : 0;
rows.push([inv.date, inv.number, client?.name || "", desc, (inv.sub || 0).toFixed(2), taxRate, (inv.tax || 0).toFixed(2), (inv.total || 0).toFixed(2)].map(q).join(sep));
});
const totalNet = paidInvoices.reduce((s, i) => s + (i.sub || 0), 0);
const totalTax = paidInvoices.reduce((s, i) => s + (i.tax || 0), 0);
const totalGross = paidInvoices.reduce((s, i) => s + (i.total || 0), 0);
rows.push(["", "", "", "TOTAL", totalNet.toFixed(2), "", totalTax.toFixed(2), totalGross.toFixed(2)].map(q).join(sep));
rows.push([""].join(sep));
// Ausgaben
rows.push(["SPESEN (MITARBEITERBEZOGEN)", "", "", "", "", "", "", ""].map(q).join(sep));
rows.push(["Datum", "Kategorie", "Projekt", "Beschreibung", "Netto CHF", "MWST-Satz %", "MWST CHF", "Brutto CHF"].map(q).join(sep));
const sortedExp = [...(data.expenses || [])].filter(e => !year || (e.date || "").startsWith(year)).sort((a, b) => (a.date || "").localeCompare(b.date || ""));
sortedExp.forEach(exp => {
const proj = data.projects.find(p => p.id === exp.projectId);
const net = exp.inclMwst ? (exp.amount / (1 + (exp.mwstRate || 0) / 100)) : exp.amount;
const taxAmt = exp.amount - net;
rows.push([exp.date, exp.category, proj?.name || "", exp.description, net.toFixed(2), exp.mwstRate || 0, taxAmt.toFixed(2), exp.amount.toFixed(2)].map(q).join(sep));
});
const expTotal = sortedExp.reduce((s, e) => s + (e.amount || 0), 0);
const expTax = sortedExp.reduce((s, e) => { const net = e.inclMwst ? (e.amount / (1 + (e.mwstRate || 0) / 100)) : e.amount; return s + (e.amount - net); }, 0);
rows.push(["", "", "", "TOTAL", (expTotal - expTax).toFixed(2), "", expTax.toFixed(2), expTotal.toFixed(2)].map(q).join(sep));
rows.push([""].join(sep));
// Interne Ausgaben
rows.push(["INTERNE AUSGABEN", "", "", "", "", "", "", ""].map(q).join(sep));
rows.push(["Datum", "Kategorie", "Beschreibung", "Wiederkehrend", "Netto CHF", "MWST-Satz %", "MWST CHF", "Brutto CHF"].map(q).join(sep));
const sortedIntExp = [...(data.internalExpenses || [])].filter(e => !year || (e.date || "").startsWith(year)).sort((a, b) => (a.date || "").localeCompare(b.date || ""));
sortedIntExp.forEach(exp => {
const net = exp.inclMwst ? (exp.amount / (1 + (exp.mwstRate || 0) / 100)) : exp.amount;
const taxAmt = exp.amount - net;
rows.push([exp.date, exp.category, exp.description, exp.recurring ? (exp.recurringInterval || "monatlich") : "", net.toFixed(2), exp.mwstRate || 0, taxAmt.toFixed(2), exp.amount.toFixed(2)].map(q).join(sep));
});
const intExpTotal = sortedIntExp.reduce((s, e) => s + (e.amount || 0), 0);
const intExpTax = sortedIntExp.reduce((s, e) => { const net = e.inclMwst ? (e.amount / (1 + (e.mwstRate || 0) / 100)) : e.amount; return s + (e.amount - net); }, 0);
rows.push(["", "", "", "TOTAL", (intExpTotal - intExpTax).toFixed(2), "", intExpTax.toFixed(2), intExpTotal.toFixed(2)].map(q).join(sep));
rows.push([""].join(sep));
// Personalaufwand (Löhne)
const sortedLoehne = [...(data.lohnEntries || [])].filter(l => !year || l.monat.startsWith(year)).sort((a, b) => a.monat.localeCompare(b.monat));
if (sortedLoehne.length > 0) {
rows.push(["PERSONALAUFWAND / LÖHNE", "", "", "", "", "", "", "", ""].map(q).join(sep));
rows.push(["Monat", "Mitarbeiter", "Brutto", "Abzüge AN", "Netto", "Spesen", "Auszahlung AN", "PK AG-Anteil", "Gesamtlohnkosten"].map(q).join(sep));
sortedLoehne.forEach(l => {
const name = l.empSnapshot?.name || "";
const bvgAG = l.bvgAG || 0;
const gesamt = (l.auszahlung || 0) + bvgAG;
rows.push([l.monat, name, (l.bruttoTotal || 0).toFixed(2), (l.totalAbzuege || 0).toFixed(2), (l.netto || 0).toFixed(2), (l.spesenTotal || 0).toFixed(2), (l.auszahlung || 0).toFixed(2), bvgAG.toFixed(2), gesamt.toFixed(2)].map(q).join(sep));
});
const lohnTotal = sortedLoehne.reduce((s, l) => s + (l.auszahlung || 0) + (l.bvgAG || 0), 0);
rows.push(["", "", "", "", "", "", "", "TOTAL", lohnTotal.toFixed(2)].map(q).join(sep));
rows.push([""].join(sep));
}
// Zusammenfassung
const lohnTotalAll = sortedLoehne.reduce((s, l) => s + (l.auszahlung || 0) + (l.bvgAG || 0), 0);
rows.push(["ZUSAMMENFASSUNG", "", "", "", "", "", "", ""].map(q).join(sep));
rows.push(["", "", "", "Einnahmen (Netto)", totalNet.toFixed(2), "", "", ""].map(q).join(sep));
rows.push(["", "", "", "Spesen (Netto)", (expTotal - expTax).toFixed(2), "", "", ""].map(q).join(sep));
rows.push(["", "", "", "Interne Ausgaben (Netto)", (intExpTotal - intExpTax).toFixed(2), "", "", ""].map(q).join(sep));
rows.push(["", "", "", "Personalaufwand (Löhne)", lohnTotalAll.toFixed(2), "", "", ""].map(q).join(sep));
rows.push(["", "", "", "Ergebnis (Netto)", (totalNet - (expTotal - expTax) - (intExpTotal - intExpTax) - lohnTotalAll).toFixed(2), "", "", ""].map(q).join(sep));
rows.push(["", "", "", "MWST auf Einnahmen", "", "", totalTax.toFixed(2), ""].map(q).join(sep));
rows.push(["", "", "", "Vorsteuer (Spesen + Ausgaben)", "", "", (expTax + intExpTax).toFixed(2), ""].map(q).join(sep));
rows.push(["", "", "", "MWST-Schuld", "", "", (totalTax - expTax - intExpTax).toFixed(2), ""].map(q).join(sep));
const bom = "";
const blob = new Blob([bom + rows.join("\n")], { type: "text/csv;charset=utf-8;" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `buchhaltung-${year || "gesamt"}.csv`;
a.click();
URL.revokeObjectURL(url);
}
export function buildReminderLetter(inv, nr, sentDate, clients, settings) {
const client = clients.find(c => c.id === inv.clientId);
const existingReminders = inv.reminders || [];
const daysPast = Math.floor((new Date() - new Date(inv.dueDate)) / 86400000);
const intro = nr === 1
? `Bei einer Überprüfung unserer Buchhaltung stellen wir fest, dass die Rechnung Nr. ${inv.number} vom ${formatDate(inv.date)} über ${formatCHF(inv.total)} seit ${daysPast} Tagen (Fälligkeit: ${formatDate(inv.dueDate)}) noch nicht beglichen ist.\n\nWir bitten Sie höflich, den offenen Betrag innert 10 Tagen zu überweisen.`
: nr === 2
? `Leider mussten wir feststellen, dass unsere Zahlungserinnerung vom ${formatDate(existingReminders[0]?.sentDate || existingReminders[0]?.date)} bezüglich Rechnung Nr. ${inv.number} über ${formatCHF(inv.total)} bisher ohne Reaktion geblieben ist.\n\nWir fordern Sie hiermit auf, den ausstehenden Betrag innert 7 Tagen zu begleichen.`
: `Trotz unserer Zahlungserinnerungen vom ${existingReminders.map(r => formatDate(r.sentDate || r.date)).join(" und ")} ist die Rechnung Nr. ${inv.number} über ${formatCHF(inv.total)} nach wie vor unbeglichen.\n\nWir sehen uns gezwungen, bei weiterem Ausbleiben der Zahlung innerhalb von 5 Tagen rechtliche Schritte einzuleiten.`;
const body = `Sehr geehrte/r ${client?.name || "[Kunde]"}\n\n${intro}\n\nIBAN: ${settings.iban}\nReferenz: ${inv.number}\n\nFreundliche Grüsse\n${settings.name}`;
const subject = nr === 1 ? `Zahlungserinnerung Rechnung Nr. ${inv.number}` : `${nr}. Mahnung Rechnung Nr. ${inv.number}`;
return { client, subject, body };
}
export function getKW(dateStr) {
if (!dateStr) return "—";
const d = new Date(dateStr);
const jan4 = new Date(d.getFullYear(), 0, 4);
const startOfWeek = new Date(jan4);
startOfWeek.setDate(jan4.getDate() - ((jan4.getDay() + 6) % 7));
const kw = Math.ceil(((d - startOfWeek) / 86400000 + 1) / 7);
return `KW ${kw}`;
}
export function linkedClientForNote(n, data) {
if (n.clientId) return (data.persons||[]).find(c => c.id === n.clientId) || null;
return n.clientManual ? { name: n.clientManual, address: n.deliveryAddress || "" } : null;
}
export function getWeekNumber(date) {
const d = new Date(date);
d.setHours(0, 0, 0, 0);
d.setDate(d.getDate() + 3 - ((d.getDay() + 6) % 7));
const week1 = new Date(d.getFullYear(), 0, 4);
return Math.round(((d - week1) / 86400000 + ((week1.getDay() + 6) % 7) - 2) / 7) + 1;
}
export function formatKW(dateStr) {
if (!dateStr) return "—";
return `KW ${getWeekNumber(dateStr)} / ${new Date(dateStr).getFullYear()}`;
}
// Generiert nächste Protokollnummer (legacy, nicht mehr direkt genutzt)
export function nextProtoNumber(protocols, projectNumber) {
const prefix = projectNumber ? `${projectNumber}-P` : "P";
const existing = (protocols || []).map(p => {
const m = (p.nummer || p.number || "").match(/(\d+)$/);
return m ? parseInt(m[1]) : 0;
});
const max = existing.length ? Math.max(...existing) : 0;
return `${prefix}${String(max + 1).padStart(2, "0")}`;
}
// Nächste Sequenznummer aus bestehenden Protokollnummern ableiten
// Ignoriert Jahr-artige Zahlen (4-stellig, 20002099)
export function nextProtoSeq(protocols) {
let max = 0;
(protocols || []).forEach(p => {
const groups = (p.nummer || "").match(/\d+/g) || [];
groups.forEach(n => {
const num = parseInt(n);
if (n.length <= 3 || !(num >= 2000 && num <= 2099)) {
if (num > max) max = num;
}
});
});
return max + 1;
}
// Wendet konfigurierbares Protokollnummer-Format an
// Platzhalter: YYYY YY MM DD PP(Projektnr.) TT(Typkürzel) NN/NNN(Sequenz)
export function applyProtoNumberFormat(fmt, { date, projectNumber, seq, typKuerzel }) {
const d = date ? new Date(date + "T12:00:00") : new Date();
const yyyy = String(d.getFullYear());
const yy = yyyy.slice(2);
const mm = String(d.getMonth() + 1).padStart(2, "0");
const dd = String(d.getDate()).padStart(2, "0");
// Normalize separators in project number, then strip a leading year segment
// so PP can be combined with YYYY/YY without producing a double year.
const ppNorm = (projectNumber || "")
.replace(/[\/\s.]/g, "-")
.replace(/-+/g, "-")
.replace(/^-|-$/g, "");
const pp4 = ppNorm.replace(/^\d{4}-/, "");
const pp2 = ppNorm.replace(/^\d{2}-/, "");
const ppStripped = pp4 !== ppNorm ? pp4 : pp2 !== ppNorm ? pp2 : ppNorm;
const pp = ppStripped || ppNorm || "P";
const tt = typKuerzel || "SO";
const tokens = {
YYYY: yyyy, YY: yy, MM: mm, DD: dd,
PPP: ppNorm || "P", PP: pp, TT: tt,
NNN: String(seq).padStart(3, "0"),
NN: String(seq).padStart(2, "0"),
};
return (fmt || "PP-TT-NN").replace(/YYYY|YY|NNN|NN|MM|DD|PPP|PP|TT/g, m => tokens[m] ?? m);
}
// Convert plain text (legacy templates) to HTML
export function textToHtml(text) {
if (!text) return "";
if (text.trim().startsWith("<")) return text; // already HTML
return text
.split("\n\n")
.map(para => `<p>${para.replace(/\n/g, "<br>")}</p>`)
.join("");
}
// Convert HTML back to plain text for print (fallback)
export function htmlToText(html) {
const div = document.createElement("div");
div.innerHTML = html;
return div.innerText || div.textContent || "";
}
export function getFeiertageForYear(feiertage, year) {
return (feiertage || []).filter(f => {
if (f.repeatsYearly) return true;
return f.date.startsWith(String(year));
}).map(f => {
if (f.repeatsYearly) return { ...f, date: `${year}-${f.date.slice(5, 10)}` };
return f;
});
}
export function getAbsenzTypes(data) {
const custom = data.absenzTypes || [];
const overrideMap = new Map(custom.map(t => [t.id, t]));
const defaultIds = new Set(DEFAULT_ABSENZ_TYPES.map(t => t.id));
const defaults = DEFAULT_ABSENZ_TYPES
.map(t => overrideMap.get(t.id) || t)
.filter(t => !overrideMap.get(t.id)?.deleted);
const newCustom = custom.filter(t => !defaultIds.has(t.id) && !t.deleted);
return [...defaults, ...newCustom];
}
export function getWorkdaysInMonth(year, month, feiertage) {
// month: 0-based; use ISO string to avoid local-midnight timezone offset
const days = [];
const monthStr = `${year}-${String(month + 1).padStart(2, "0")}`;
const d = new Date(`${monthStr}-01`);
while (d.toISOString().slice(0, 7) === monthStr) {
const ds = d.toISOString().slice(0, 10);
const dow = d.getDay();
if (dow !== 0 && dow !== 6) {
const ft = (feiertage || []).find(f => f.date === ds);
days.push({ date: ds, feiertag: ft || null });
}
d.setDate(d.getDate() + 1);
}
return days;
}
export function getSollStunden(employee, date, feiertage) {
// Returns Soll-Stunden for a given workday
const pensum = (employee.pensum || 100) / 100;
const wochenstunden = (employee.wochenstunden || 35);
const tagessoll = (wochenstunden * pensum) / 5;
const ft = (feiertage || []).find(f => f.date === date);
if (ft) return tagessoll + (ft.stundenDelta || 0); // e.g. -1 for Halbfeiertag, 0 for full
return tagessoll;
}
// ─── LOHNBERECHNUNG HELPER ──────────────────────────────────────
export function calcLohn(emp, monat, spesen, bonus) {
const pensumFactor = (emp.pensum || 100) / 100;
const bruttoBase = emp.monatslohn || 0;
const brutto = Math.round(bruttoBase * pensumFactor * 100) / 100;
// 13. Monatslohn: 1/12 pro Monat
const dreizehnter = emp.dreizehnterLohn ? Math.round(brutto / 12 * 100) / 100 : 0;
const bonusBetrag = Math.round((bonus || 0) * 100) / 100;
const bruttoTotal = brutto + dreizehnter + bonusBetrag; // Bonus ist AHV-pflichtig
const ahv = Math.round(bruttoTotal * ((emp.ahvSatz ?? 5.3) / 100) * 100) / 100;
const alv = Math.round(bruttoTotal * ((emp.alvSatz ?? 1.1) / 100) * 100) / 100;
const bvg = Math.round(bruttoTotal * ((emp.bvgSatz ?? 8.0) / 100) * 100) / 100;
const nbu = Math.round(bruttoTotal * ((emp.nbuSatz ?? 1.5) / 100) * 100) / 100;
const ktg = Math.round(bruttoTotal * ((emp.ktgSatz ?? 0.5) / 100) * 100) / 100;
const qst = emp.quellensteuerPflichtig
? Math.round(bruttoTotal * ((emp.quellensteuerSatz ?? 10) / 100) * 100) / 100 : 0;
const bvgAG = Math.round(bruttoTotal * ((emp.pkAGSatz ?? 8.0) / 100) * 100) / 100;
const totalAbzuege = ahv + alv + bvg + nbu + ktg + qst;
const netto = Math.round((bruttoTotal - totalAbzuege) * 100) / 100;
const spesenTotal = Math.round((spesen || []).reduce((s, e) => s + (e.amount || 0), 0) * 100) / 100;
const auszahlung = Math.round((netto + spesenTotal) * 100) / 100;
return { brutto, bruttoBase, dreizehnter, bonusBetrag, bruttoTotal, ahv, alv, bvg, nbu, ktg, qst, totalAbzuege, netto, spesenTotal, auszahlung, bvgAG };
}
// ─── Dashboard layout helpers ──────────────────────────────────────────────────
const KPI_IDS = ["kpi-projekte","kpi-stunden","kpi-ausstehend","kpi-umsatz"];
const MID_IDS = ["aktive-projekte","unverrechnete-stunden","umsatz-sparkline","offene-offerten"];
const FULL_IDS = ["warnungen","letzte-zeiteintraege","meine-zeiteintraege"];
export function widgetsToRows(widgetIds) {
const uid = () => generateId().replace(/-/g, "").slice(0, 8);
const kpi = widgetIds.filter(w => KPI_IDS.includes(w));
const mid = widgetIds.filter(w => MID_IDS.includes(w));
const full = widgetIds.filter(w => FULL_IDS.includes(w));
const rows = [];
if (kpi.length) rows.push({ id: uid(), cols: Math.min(4, Math.max(2, kpi.length)), minH: 0, widgets: kpi });
for (let i = 0; i < mid.length; i += 3) {
const chunk = mid.slice(i, i + 3);
rows.push({ id: uid(), cols: Math.max(2, chunk.length), minH: 0, widgets: chunk });
}
full.forEach(w => rows.push({ id: uid(), cols: 1, minH: 0, widgets: [w] }));
return rows;
}
export function migrateDashboardLayout(val) {
if (!val || !Array.isArray(val) || val.length === 0) return null;
if (typeof val[0] === "object" && "widgets" in val[0]) return val;
return widgetsToRows(val);
}
// PDF-Dateiname aus Format und Content ableiten
export function buildPdfName(format, content, settings) {
const studio = (settings?.name || "RAPPORT").replace(/\s+/g, "-");
const typeLabels = {
"invoice": "Rechnung", "invoice+qr": "Rechnung", "qrbill": "QR-Rechnung",
"quote": "Offerte", "lieferschein": "Lieferschein", "protokoll": "Protokoll",
"lohn": "Lohnabrechnung", "letter": "Brief", "projectDetail": "Projekt",
"projectsOverview": "Projekte", "buchhaltung": "Buchhaltung", "studioBudget": "Budget",
};
const typ = typeLabels[content?.type] || content?.type || "Dokument";
let nummer = "";
let kunde = "";
let datum = new Date().toISOString().slice(0, 10);
if (content?.inv) {
nummer = content.inv.number || "";
datum = content.inv.date || datum;
kunde = content.client?.company || content.client?.name || "";
} else if (content?.quote) {
nummer = content.quote.number || "";
datum = content.quote.date || datum;
kunde = content.client?.company || content.client?.name || "";
} else if (content?.note) {
nummer = content.note.number || "";
kunde = content.client?.company || content.client?.name || "";
} else if (content?.protokoll) {
datum = content.protokoll.date || datum;
nummer = content.protokoll.number || "";
} else if (content?.emp) {
kunde = content.emp.name || "";
nummer = content.monatLabel || "";
} else if (content?.filterYear) {
nummer = String(content.filterYear);
}
const sanitize = (s) => (s || "").replace(/[^a-zA-Z0-9äöüÄÖÜ_\-\.]/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "");
const fmt = format || "{studio}_{typ}_{nummer}";
const result = fmt
.replace("{studio}", sanitize(studio))
.replace("{typ}", sanitize(typ))
.replace("{nummer}", sanitize(nummer))
.replace("{kunde}", sanitize(kunde))
.replace("{datum}", sanitize(datum));
return result.replace(/-+/g, "-").replace(/^-|-$/g, "") || "RAPPORT";
}