// Dialoglogik der Rechnung „Rechnung schreiben“ (rein, ohne React und Appwrite; unter Node testbar). // Aufgaben: Projektwahl, waehlbare Rechnungsarten, gültige Auftragsbestätigungen (AB), Vorgaben für Anzahlung und Standardart, // Arbeitsblätter (Auswahl, Summen, Umwandlung aus Appwrite-Dokumenten) und die Ticketliste für „Weiteres Ticket hinzunehmen“. import { euro, dateDe, ymdBerlin } from './financeFormat' import { INVOICE_LIMITS } from './financeView' import { steuernummerFehlt } from './kundenFelder' import { worksheetMinutes } from './support' import { germanDateToIso } from './ticketForm' import { ticketsText } from './projektkontoView' export const ART_OPTION_LABEL = { frei: 'Freie Positionen', anzahlung: 'Anzahlung', schluss: 'Schlussrechnung', aufwand: 'Nach Aufwand', festbetrag: 'Festbetrag', } export const AB_HINWEIS = 'Anzahlung und Schlussrechnung brauchen eine Auftragsbestätigung.' export const ENTWURF_HINWEIS = 'Zu dieser Auftragsbestätigung gibt es noch einen Entwurf. Erst senden oder löschen.' export const OHNE_PROJEKT = 'Ohne Projekt' export const STRIPE_UNBEKANNT = 'Stripe nicht erreichbar – Beträge unbekannt' export const STEUERNUMMER_RECHNUNG_HINWEIS = 'Die Steuernummer fehlt noch – sie gehört auf jede Rechnung (§ 34a UStDV). Eintragen unter Finanzen → Einstellungen.' /** Hinweis im Rechnungsdialog, solange die Steuernummer fehlt; ohne Einstellungen (lädt, Ladefehler) kein Hinweis. */ export function steuernummerHinweis(einstellungen) { return steuernummerFehlt(einstellungen) ? STEUERNUMMER_RECHNUNG_HINWEIS : '' } const GUELTIG = ['versendet', 'bestaetigt'] const FINALISIERT = ['offen', 'ueberfaellig', 'bezahlt', 'uneinbringlich'] // --- Projektwahl -------------------------------------------------------------------------------------------- /** Vorauswahl der Aufrufer: undefined = keine (null), null = „Ohne Projekt“ (''), Text = dieses Projekt. */ export function startProjekt(propProjektId) { if (propProjektId === undefined) return null if (propProjektId === null) return '' return String(propProjektId) } /** Auswahlliste: Projekte mit Ticketnummer, am Ende „Ohne Projekt“. */ export function projektOptionen(konten) { const liste = Array.isArray(konten) ? konten : [] const optionen = liste .filter((k) => k && String(k.projektId ?? '') !== '') .map((k) => { const name = String(k.name ?? '').trim() || 'Projekt' const tickets = ticketsText(k.tickets) return { value: String(k.projektId), label: tickets ? `${name} · ${tickets}` : name } }) optionen.push({ value: '', label: OHNE_PROJEKT }) return optionen } /** null = noch zu wählen, '' = Ohne Projekt. Ein Kunde ohne Projekte hat nur „Ohne Projekt“. */ export function wirksamesProjekt(wahl, konten) { if (wahl === '') return '' if (typeof wahl === 'string' && wahl) { if (!Array.isArray(konten)) return wahl return konten.some((k) => k?.projektId === wahl) ? wahl : null } return Array.isArray(konten) && konten.length === 0 ? '' : null } // --- Auftragsbestätigungen ---------------------------------------------------------------------------------- function neuesteZuerst(liste) { const zeit = (a) => { const t = Date.parse(a?.gesendetAm) return Number.isFinite(t) ? t : 0 } return [...liste].sort((a, b) => zeit(b) - zeit(a) || String(b?.nummer ?? '').localeCompare(String(a?.nummer ?? ''))) } function gueltige(auftraege, art) { if (!Array.isArray(auftraege)) return [] return neuesteZuerst(auftraege.filter((a) => a && a.art === art && GUELTIG.includes(a.status))) } /** Gültige Festpreis-ABs (versendet oder bestätigt), neueste zuerst. */ export function gueltigeFestpreisAbs(auftraege) { return gueltige(auftraege, 'festpreis') } /** Jüngste gültige Aufwands-AB oder null. */ export function aufwandAuftrag(auftraege) { return gueltige(auftraege, 'aufwand')[0] ?? null } /** 'AB-2026-0002 vom 06.10.2026 · 800,00 €' bzw. '… · nach Aufwand'. */ export function abLabel(auftrag) { let text = String(auftrag?.nummer ?? '') const datum = dateDe(auftrag?.gesendetAm) if (datum !== '–') text += ` vom ${datum}` text += auftrag?.art === 'aufwand' ? ' · nach Aufwand' : ` · ${euro(auftrag?.summeCents)}` return text } /** Summe der finalisierten Rechnungen zu dieser AB (Cent). */ export function berechnetFuerAuftrag(rechnungen, auftragId) { if (!Array.isArray(rechnungen)) return 0 return rechnungen .filter((r) => r && r.auftragId === auftragId && FINALISIERT.includes(r.status)) .reduce((summe, r) => summe + (Number(r.totalCents) || 0), 0) } /** Gibt es zu dieser AB einen noch nicht gesendeten Entwurf? */ export function hatEntwurfZurAuftrag(rechnungen, auftragId) { return Array.isArray(rechnungen) && rechnungen.some((r) => r && r.auftragId === auftragId && r.status === 'entwurf') } /** Zeile unter der AB-Wahl. Ist Stripe nicht erreichbar, sind die berechneten Beträge unbekannt (nicht 0,00 €). */ export function abSummenText({ ab, rechnungen, stripeOk }) { const summe = `Auftragssumme ${euro(ab?.summeCents)}` if (stripeOk === false) return `${summe} · ${STRIPE_UNBEKANNT}` const berechnet = berechnetFuerAuftrag(rechnungen, ab?.id) return `${summe} · bereits berechnet ${euro(berechnet)} · noch nicht berechnet ${euro(Math.max(0, (Number(ab?.summeCents) || 0) - berechnet))}` } /** * Aufträge und Rechnungen für „Ohne Projekt“ aus der Auftragsliste des Kunden ({ auftraege }) und seiner Rechnungsliste * ({ rechnungen }): nur ABs ohne Projekt und die Rechnungen, die zu ihnen gehören. -> { auftraege, rechnungen } */ export function ohneProjektAbDaten(auftragsAntwort, rechnungsAntwort) { const abs = Array.isArray(auftragsAntwort?.auftraege) ? auftragsAntwort.auftraege.filter((a) => a && !a.projektId) : [] const ids = new Set(abs.map((a) => a.id)) const alle = Array.isArray(rechnungsAntwort?.rechnungen) ? rechnungsAntwort.rechnungen : [] return { auftraege: abs, rechnungen: alle.filter((r) => r && r.auftragId && ids.has(r.auftragId)) } } // --- Rechnungsarten ----------------------------------------------------------------------------------------- /** Wählbare Arten mit Sperrgrund: [{ key, label, enabled, hint }]. */ export function artOptionen({ projektId, auftraege, rechnungen, auftraegeAktiv }) { if (projektId === null || projektId === undefined) return [] const option = (key, enabled, hint = '') => ({ key, label: ART_OPTION_LABEL[key], enabled, hint: enabled ? '' : hint }) if (projektId === '') { // Eine gültige Festpreis-AB ohne Projekt erlaubt Anzahlung und Schlussrechnung (der Server nimmt sie ohne Projekt an) const ohne = auftraegeAktiv ? gueltigeFestpreisAbs(Array.isArray(auftraege) ? auftraege.filter((a) => a && !a.projektId) : []) : [] if (ohne.length === 0) return [option('frei', true)] const ohneEntwurf = ohne.filter((a) => !hatEntwurfZurAuftrag(rechnungen, a.id)) return [option('frei', true), option('anzahlung', true), option('schluss', ohneEntwurf.length > 0, ENTWURF_HINWEIS)] } const abs = auftraegeAktiv ? gueltigeFestpreisAbs(auftraege) : [] const freie = abs.filter((a) => !hatEntwurfZurAuftrag(rechnungen, a.id)) const schlussHinweis = abs.length === 0 ? AB_HINWEIS : ENTWURF_HINWEIS return [ option('anzahlung', abs.length > 0, AB_HINWEIS), option('schluss', freie.length > 0, schlussHinweis), option('aufwand', true), option('festbetrag', true), ] } /** Vorbelegte Art: Anzahlung, Schlussrechnung, Aufwand oder Festbetrag je nach Lage ('' ohne Wahl, auch bei Stripe-Ausfall). */ export function standardArt(optionen, { auftraege, rechnungen, offeneMinuten, stripeOk = true }) { const liste = Array.isArray(optionen) ? optionen : [] if (liste.length === 0) return '' if (liste.some((o) => o.key === 'frei' && o.enabled)) return 'frei' // ohne Projekt // Ist Stripe nicht erreichbar, sind die berechneten Beträge unbekannt: keine Art vorschlagen if (stripeOk === false) return '' const waehlbar = (key) => liste.some((o) => o.key === key && o.enabled) const jung = gueltigeFestpreisAbs(auftraege)[0] if ( waehlbar('anzahlung') && jung?.zahlungsplan?.art === 'anzahlung' && berechnetFuerAuftrag(rechnungen, jung.id) === 0 && !hatEntwurfZurAuftrag(rechnungen, jung.id) ) { return 'anzahlung' } if (waehlbar('schluss')) return 'schluss' return Number(offeneMinuten) > 0 ? 'aufwand' : 'festbetrag' } /** Vorbelegte AB: jüngste passende; bei Schlussrechnung ohne offenen Entwurf; sonst ''. */ export function standardAuftrag(art, auftraege, rechnungen = []) { const abs = gueltigeFestpreisAbs(auftraege) if (art === 'anzahlung') return abs[0]?.id ?? '' if (art === 'schluss') return abs.find((a) => !hatEntwurfZurAuftrag(rechnungen, a.id))?.id ?? '' return '' } /** * Vorbelegung „Zahlungsziel in Tagen“: bei Anzahlung und Schlussrechnung das Ziel der gewählten AB, bei Aufwand das der * jüngsten gültigen Aufwands-AB (`auftraege`), sonst 14. Nur ganze Zahlen von 0 bis 60 zählen. -> Text */ export function zahlungszielVorschlag({ art, ab = null, auftraege = [] } = {}) { const standard = String(INVOICE_LIMITS.defaultDays) let quelle = null if (art === 'anzahlung' || art === 'schluss') quelle = ab else if (art === 'aufwand') quelle = aufwandAuftrag(auftraege) const tage = quelle?.zahlungszielTage return Number.isSafeInteger(tage) && tage >= 0 && tage <= INVOICE_LIMITS.maxDays ? String(tage) : standard } /** Vorgabe der Anzahlung: Prozent aus dem Zahlungsplan, sonst 50. */ export function anzahlungStandard(auftrag) { const plan = auftrag?.zahlungsplan const prozent = plan?.art === 'anzahlung' && Number(plan.prozent) > 0 ? Number(plan.prozent) : 50 return { modus: 'prozent', wert: String(prozent).replace('.', ','), bezeichnung: 'Anzahlung' } } // --- Arbeitsblätter ----------------------------------------------------------------------------------------- /** Kennungen der gewählten Blätter (alle außer den abgewählten) in Reihenfolge der Liste. */ export function ausgewaehlteIds(blaetter, abgewaehlt) { if (!Array.isArray(blaetter)) return [] const weg = Array.isArray(abgewaehlt) ? abgewaehlt : [] return blaetter.map((b) => b.id).filter((id) => !weg.includes(id)) } /** Anzahl und Minutensumme der Blätter mit diesen Kennungen. */ export function blaetterSumme(blaetter, ids) { const liste = (Array.isArray(blaetter) ? blaetter : []).filter((b) => Array.isArray(ids) && ids.includes(b.id)) return { anzahl: liste.length, minuten: liste.reduce((s, b) => s + (Number(b.minuten) || 0), 0) } } /** Appwrite-Arbeitsblatt -> Blatt der Portal-Form; null, wenn es nicht abrechenbar ist oder schon abgerechnet wurde. */ export function blattAusAppwrite(ws, abgerechnet) { if (!ws || !ws.$id) return null if (ws.isComment || ws.countsToPlan) return null if (abgerechnet && Object.prototype.hasOwnProperty.call(abgerechnet, ws.$id)) return null const roh = worksheetMinutes(ws) if (!(roh > 0)) return null const zeile = String(ws.details ?? '') .split('\n') .map((z) => z.trim()) .find(Boolean) return { id: ws.$id, ticketId: ws.workorderId, woid: ws.woid, datum: germanDateToIso(ws.startDate || ws.endDate) || ymdBerlin(ws.$createdAt) || '', minuten: Math.ceil(roh / 15) * 15, text: (zeile ?? '').slice(0, 120), aboZeit: false, kommentar: false, } } /** Tickets des Kunden mit Nummer, ohne die schon gewählten. */ export function ticketOptionen(tickets, { ausgeschlossen = [] } = {}) { if (!Array.isArray(tickets)) return [] return tickets .filter((t) => t && t.$id && t.woid && !ausgeschlossen.includes(t.$id)) .map((t) => ({ value: t.$id, ticketId: t.$id, woid: t.woid, label: `#${t.woid} · ${String(t.topic || t.title || '').trim() || 'Kein Betreff'}`, })) }