Files
RAPPORT/src/utils.js
T
karim 00f07d76f6 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>
2026-05-13 01:16:26 +02:00

679 lines
31 KiB
JavaScript
Executable File
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// ─── 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";
}