Rapport 0.6 — Initial Public Release
Sicherheits-Hardening - Passwort-Hashing mit PBKDF2 (SHA-256, 100k Iterationen) inkl. transparenter Migration bestehender Klartext-Passwörter beim ersten Login - Login Brute-Force-Schutz (5 Fehlversuche → 60s Lockout), Constant-Time-Compare, Mindestpasswortlänge 8 Zeichen - HTML-Sanitizer für Brieftexte (Allowlist, entfernt javascript:/data:/vbscript:-URLs, Event-Handler, Script-Tags; rel=noopener für target=_blank) - Datenexport entfernt Legacy-Klartextpasswörter (Hashes bleiben) - Kryptografische IDs via crypto.randomUUID statt Math.random - sessionStorage speichert keine Credentials mehr GUI & Performance - Code-Splitting pro View via React.lazy + Suspense (Initial-Bundle 86 KB gzipped) - swissqrbill als lokale Dependency — QR-Rechnungen offline-fähig - Spesenbelege (Bild/PDF) direkt in der Tageserfassung mit Bildkomprimierung - Avatar-Upload: 256px-Skalierung + JPEG-Kompression, Typprüfung - Über-Rapport-Modal, einheitliche Bearbeiten-Icons, Pinnwand-Kategorien als Pills Bug-Fixes - Auto-überfällig-Routine läuft nur noch einmal pro Tag (verhindert Re-Render-Loop) Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Executable
+678
@@ -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, 2000–2099)
|
||||
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";
|
||||
}
|
||||
Reference in New Issue
Block a user