setAuftragsDialog(null)}
+ onSaved={handleSaved}
+ kunde={kunde}
+ projektId={auftragsDialog.projektId}
+ auftrag={auftragsDialog.auftrag}
+ konten={ansicht.konten}
+ onShowOverview={onShowOverview}
+ />
+ )}
+ >
+ )
+}
diff --git a/src/components/auftraege/AuftragDialog.jsx b/src/components/auftraege/AuftragDialog.jsx
new file mode 100644
index 0000000..2bded8c
--- /dev/null
+++ b/src/components/auftraege/AuftragDialog.jsx
@@ -0,0 +1,275 @@
+import { useEffect, useRef, useState } from 'react'
+import { Button, Dialog } from '../ui'
+import { createAuftrag, getAuftragPdf, getSettings, patchAuftrag, sendAuftrag } from '../../lib/financeApi'
+import { alsPdfBlob } from '../../lib/auftragPdf'
+import {
+ SCHRITTE,
+ auftragBestaetigung,
+ auftragFehler,
+ auftragHinweise,
+ auftragZuFormular,
+ formularZuNutzlast,
+ heuteBerlin,
+ kundenartWirksam,
+ neueSperre,
+ neuesFormular,
+ pruefeSchritt,
+ sendeGate,
+ zahlungsplanOptionen,
+} from '../../lib/auftragView'
+import { SchrittAuftrag, SchrittProjekt, SchrittZahlung } from './AuftragSchritte'
+import AuftragVorschau from './AuftragVorschau'
+import './auftrag.css'
+
+/**
+ * Dialog „Auftragsbestätigung“ in vier Schritten (Projekt, Auftrag, Zahlung und Termine, Vorschau). Beim Wechsel nach
+ * Schritt 4 wird der Entwurf angelegt bzw. aktualisiert und das PDF geholt (die Vorschau gibt es nur zu einem gespeicherten
+ * Entwurf; archiviert wird sie nicht). „Senden“ fragt vorher nach und ist gesperrt, solange Kundendaten fehlen; Hinweise
+ * (Steuernummer, „Vor Ort“ bei Privatperson) blockieren nie. Nach Speichern oder Senden ruft der Dialog onSaved(auftrag) und
+ * danach immer onClose(): der Container schließt, lädt neu und meldet. Nur Entwürfe lassen sich bearbeiten.
+ */
+export default function AuftragDialog({ open, onClose, onSaved, kunde, projektId, auftrag, konten, onShowOverview }) {
+ if (!open) return null
+ if (auftrag && auftrag.status !== 'entwurf') {
+ return (
+ Schließen}>
+
+ Nur Entwürfe lassen sich bearbeiten. Eine gesendete Auftragsbestätigung lässt sich nur über „Neue Fassung“ in der Liste ändern.
+
+
+ )
+ }
+ return (
+
+ )
+}
+
+// Ids der Felder (Task 6): wenige Felder haben eine abweichende id
+function feldId(feld, art) {
+ if (feld === 'art') return 'ab-art-festpreis'
+ if (feld === 'zahlungsart') return `ab-zahlungsart-${zahlungsplanOptionen(art)[0].value}`
+ return `ab-${feld}`
+}
+
+function AuftragForm({ onClose, onSaved, kunde, projektId, auftrag, konten, onShowOverview }) {
+ const [schritt, setSchritt] = useState(1)
+ const [form, setForm] = useState(() => (auftrag ? auftragZuFormular(auftrag) : neuesFormular({ projektId: projektId || '', konten, heute: heuteBerlin() })))
+ const [fehler, setFehler] = useState({})
+ const [meldung, setMeldung] = useState(null) // { text, zurUebersicht }
+ const [entwurf, setEntwurf] = useState(auftrag || null)
+ const [signatur, setSignatur] = useState('')
+ const [pdfUrl, setPdfUrl] = useState('')
+ const [phase, setPhase] = useState('') // '' | 'speichern' | 'pdf' | 'senden'
+ const [einstellungen, setEinstellungen] = useState(undefined) // undefined = lädt, null = nicht ladbar
+ const [fokus, setFokus] = useState(null) // { id }
+ const sperre = useRef(neueSperre()).current
+ const aliveRef = useRef(true)
+ const schmutzigRef = useRef(false) // es wurde gespeichert und noch nicht gemeldet
+ const urlRef = useRef('')
+
+ useEffect(() => {
+ aliveRef.current = true
+ getSettings()
+ .then((d) => {
+ if (aliveRef.current) setEinstellungen(d?.einstellungen ?? null)
+ })
+ .catch(() => {
+ if (aliveRef.current) setEinstellungen(null)
+ })
+ return () => {
+ aliveRef.current = false
+ if (urlRef.current) URL.revokeObjectURL(urlRef.current)
+ }
+ }, [])
+
+ useEffect(() => {
+ if (fokus) document.getElementById(fokus.id)?.focus()
+ }, [fokus])
+
+ const hinweise = auftragHinweise({ kunde, form, einstellungen })
+ const gate = sendeGate(kunde, form.kundenart)
+ const privat = kundenartWirksam(kunde, form.kundenart) === 'privat'
+ const beschaeftigt = phase !== ''
+
+ function zeigeFehler(schrittNr, r, art) {
+ setSchritt(schrittNr)
+ setFehler(r.fehler)
+ setFokus({ id: feldId(r.erstes, art) })
+ }
+
+ function weiter() {
+ const r = pruefeSchritt(schritt, form, { kunde })
+ if (!r.ok) {
+ setFehler(r.fehler)
+ setFokus({ id: feldId(r.erstes, form.art) })
+ return
+ }
+ setFehler({})
+ if (schritt === 3) zurVorschau()
+ else setSchritt(schritt + 1)
+ }
+
+ function zurueck() {
+ setMeldung(null)
+ setSchritt(Math.max(1, schritt - 1))
+ }
+
+ function zurVorschau() {
+ const r = formularZuNutzlast(form, { kunde })
+ if (!r.ok) {
+ zeigeFehler(r.schritt, r, form.art)
+ return
+ }
+ setSchritt(4)
+ vorschauLaden(r.payload)
+ }
+
+ async function vorschauLaden(payload) {
+ const neu = JSON.stringify(payload)
+ if (neu === signatur && pdfUrl) return
+ if (sperre.belegt) return
+ await sperre.run(async () => {
+ if (urlRef.current) URL.revokeObjectURL(urlRef.current)
+ urlRef.current = ''
+ setPdfUrl('')
+ setPhase('speichern')
+ setMeldung(null)
+ try {
+ const data = entwurf ? await patchAuftrag(entwurf.id, payload) : await createAuftrag({ kundenId: kunde.$id, ...payload })
+ if (!aliveRef.current) return
+ setEntwurf(data.auftrag)
+ schmutzigRef.current = true
+ setSignatur(neu)
+ setPhase('pdf')
+ const blob = await getAuftragPdf(data.auftrag.id)
+ const url = URL.createObjectURL(alsPdfBlob(blob))
+ if (!aliveRef.current) {
+ URL.revokeObjectURL(url)
+ return
+ }
+ urlRef.current = url
+ setPdfUrl(url)
+ } catch (err) {
+ if (aliveRef.current) setMeldung(auftragFehler(err)) // ein schon gespeicherter Entwurf bleibt
+ } finally {
+ if (aliveRef.current) setPhase('')
+ }
+ })
+ }
+
+ function speichernUndSchliessen() {
+ schmutzigRef.current = false
+ if (entwurf) onSaved(entwurf)
+ onClose()
+ }
+
+ function schliessen() {
+ if (beschaeftigt) return
+ if (schmutzigRef.current && entwurf) onSaved(entwurf)
+ schmutzigRef.current = false
+ onClose()
+ }
+
+ async function senden() {
+ if (sperre.belegt || !entwurf) return
+ const g = sendeGate(kunde, form.kundenart)
+ if (!g.erlaubt) {
+ setMeldung({ text: g.grund, zurUebersicht: g.zurUebersicht })
+ return
+ }
+ if (!window.confirm(auftragBestaetigung('senden', entwurf, { email: kunde?.email }))) return
+ await sperre.run(async () => {
+ setPhase('senden')
+ setMeldung(null)
+ try {
+ const data = await sendAuftrag(entwurf.id)
+ schmutzigRef.current = false
+ onSaved(data.auftrag)
+ onClose()
+ } catch (err) {
+ if (aliveRef.current) setMeldung(auftragFehler(err))
+ } finally {
+ if (aliveRef.current) setPhase('')
+ }
+ })
+ }
+
+ const fuss = (
+
+ {schritt > 1 && (
+ Zurück
+ )}
+
+ {schritt < 4 ? (
+ <>
+ Abbrechen
+ {schritt === 3 ? 'Zur Vorschau' : 'Weiter'}
+ >
+ ) : (
+ <>
+ Als Entwurf speichern
+
+ {phase === 'senden' ? 'Sende …' : 'Senden'}
+
+ >
+ )}
+
+ )
+
+ return (
+ {
+ e.preventDefault()
+ if (schritt < 4) weiter()
+ }}
+ footer={fuss}
+ >
+
+ {SCHRITTE.map((s) => (
+
+ {s.id}
+ {s.label}
+
+ ))}
+
+ {schritt === 1 && }
+ {schritt === 2 && }
+ {schritt === 3 && }
+ {schritt === 4 && (
+ {
+ schliessen()
+ onShowOverview()
+ }}
+ />
+ )}
+
+ )
+}
diff --git a/src/components/auftraege/AuftragListe.jsx b/src/components/auftraege/AuftragListe.jsx
new file mode 100644
index 0000000..d66ba50
--- /dev/null
+++ b/src/components/auftraege/AuftragListe.jsx
@@ -0,0 +1,244 @@
+import { useEffect, useMemo, useRef, useState } from 'react'
+import { Badge, Button, Card, EmptyState } from '../ui'
+import { noticeProps } from '../../lib/financeUi'
+import {
+ deleteAuftrag,
+ erinnereAuftrag,
+ getAuftragPdf,
+ neueFassungAuftrag,
+ ordneAuftragProjektZu,
+ sendAuftrag,
+ storniereAuftrag,
+} from '../../lib/financeApi'
+import { oeffnePdfImTab } from '../../lib/auftragPdf'
+import {
+ auftragBestaetigung,
+ auftragErfolg,
+ auftragFehler,
+ auftragZeilen,
+ neueSperre,
+ zuordnenOptionen,
+} from '../../lib/auftragView'
+import './auftrag.css'
+
+const LAUFEND = { senden: 'Sende …', erinnern: 'Sende …', stornieren: 'Storniere …', loeschen: 'Lösche …', neueFassung: 'Lege an …', pdf: 'Öffne …' }
+const AUFRUFE = {
+ senden: sendAuftrag,
+ erinnern: erinnereAuftrag,
+ stornieren: storniereAuftrag,
+ loeschen: deleteAuftrag,
+ neueFassung: neueFassungAuftrag,
+}
+
+/**
+ * Karte „Aufträge“ im Kundenreiter: Auftragsbestätigungen (AB) mit Status, Zusatzzeilen und den Aktionen je Status.
+ * Die Regeln stecken in src/lib/auftragView.js. Nach jeder Aktion ruft die Liste onChanged() (auch nach einem Fehler:
+ * der Stand kann ein anderer sein). „Bearbeiten“ und „Neue Fassung“ geben den Entwurf über onBearbeiten an den Container,
+ * „Zur Übersicht“ in Meldungen läuft über onShowOverview. Eine Sperre fängt den Doppelklick ab (eine Anfrage, eine Rückfrage).
+ * Schmal (Wrapper .auftrag-body bis 860 px) wird jede Zeile ein Raster.
+ */
+export default function AuftragListe({ auftraege, konten, onChanged, onBearbeiten, onShowOverview }) {
+ const zeilen = useMemo(() => auftragZeilen(auftraege, { konten }), [auftraege, konten])
+ const optionen = useMemo(() => zuordnenOptionen(konten), [konten])
+ const [busy, setBusy] = useState(null) // `${id}:${aktion}` der laufenden Anfrage
+ const [message, setMessage] = useState(null) // { tone, text, zurUebersicht? }
+ const [zuordnen, setZuordnen] = useState(null) // { id, projektId } der Zeile mit offener Auswahl
+ const sperre = useRef(neueSperre()).current
+ const aliveRef = useRef(true)
+
+ useEffect(() => {
+ aliveRef.current = true
+ return () => {
+ aliveRef.current = false
+ }
+ }, [])
+
+ const zeigeFehler = (err) => {
+ const f = auftragFehler(err)
+ if (aliveRef.current) setMessage({ tone: 'warn', text: f.text, zurUebersicht: f.zurUebersicht })
+ }
+
+ async function run(zeile, aktion) {
+ if (aktion.key === 'bearbeiten') {
+ onBearbeiten(zeile.auftrag)
+ return
+ }
+ if (aktion.key === 'projekt') {
+ setZuordnen({ id: zeile.id, projektId: '' })
+ setMessage(null)
+ return
+ }
+ if (sperre.belegt) return
+ const frage = auftragBestaetigung(aktion.key, zeile.auftrag)
+ if (frage && !window.confirm(frage)) return
+ await sperre.run(async () => {
+ setBusy(`${zeile.id}:${aktion.key}`)
+ setMessage(null)
+ try {
+ if (aktion.key === 'pdf') {
+ // kein await davor: der neue Tab muss im Klick aufgehen
+ await oeffnePdfImTab(() => getAuftragPdf(zeile.id))
+ return
+ }
+ const data = await AUFRUFE[aktion.key](zeile.id)
+ if (aliveRef.current) setMessage(auftragErfolg(aktion.key, data?.auftrag || zeile.auftrag, { mailFehler: data?.mailFehler }))
+ onChanged()
+ if (aktion.key === 'neueFassung' && data?.auftrag) onBearbeiten(data.auftrag)
+ } catch (err) {
+ if (aktion.key === 'pdf') {
+ if (aliveRef.current) setMessage({ tone: 'warn', text: auftragFehler(err).text })
+ return
+ }
+ zeigeFehler(err)
+ onChanged() // der Stand kann ein anderer sein
+ } finally {
+ if (aliveRef.current) setBusy(null)
+ }
+ })
+ }
+
+ async function ordneZu(zeile) {
+ const gewaehlt = optionen.find((o) => o.value === zuordnen?.projektId)
+ if (!gewaehlt || sperre.belegt) return
+ const frage = auftragBestaetigung('projekt', zeile.auftrag, { projektName: gewaehlt.label })
+ if (frage && !window.confirm(frage)) return
+ await sperre.run(async () => {
+ setBusy(`${zeile.id}:projekt`)
+ setMessage(null)
+ try {
+ const data = await ordneAuftragProjektZu(zeile.id, gewaehlt.value)
+ if (aliveRef.current) {
+ setMessage(auftragErfolg('projekt', data?.auftrag || zeile.auftrag, { projektName: gewaehlt.label }))
+ setZuordnen(null)
+ }
+ onChanged()
+ } catch (err) {
+ zeigeFehler(err)
+ if (aliveRef.current) setZuordnen(null)
+ onChanged()
+ } finally {
+ if (aliveRef.current) setBusy(null)
+ }
+ })
+ }
+
+ return (
+
+
+ {message && (
+
+ {message.text}
+ {message.zurUebersicht && (
+ <>
+ {' '}
+
+ Zur Übersicht
+
+ >
+ )}
+
+ )}
+
+ {zeilen.length === 0 ? (
+
+ ) : (
+
+
+ Auftragsbestätigungen, neueste zuerst
+
+
+ Nummer
+ Projekt / Titel
+ Datum
+ Summe
+ Status
+ Aktionen
+
+
+
+ {zeilen.map((zeile) => {
+ const offen = zuordnen?.id === zeile.id
+ const wahl = offen && optionen.some((o) => o.value === zuordnen.projektId) ? zuordnen.projektId : ''
+ const ordnet = busy === `${zeile.id}:projekt`
+ return (
+
+
+ {zeile.istEntwurf ? {zeile.nummerText} : zeile.nummerText}
+
+
+ {zeile.titel}
+ {zeile.projektText && {zeile.projektText}
}
+
+ {zeile.datumText}
+
+ {zeile.summeText}
+ {zeile.summeSub && {zeile.summeSub}
}
+
+
+ {zeile.statusLabel}
+ {zeile.notizen.map((note) => (
+
+ {note.text}
+
+ ))}
+
+
+
+ {zeile.aktionen.map((aktion) => {
+ const label = `${aktion.label}: ${zeile.nummerText}, ${zeile.titel}`
+ const text = busy === `${zeile.id}:${aktion.key}` && LAUFEND[aktion.key] ? LAUFEND[aktion.key] : aktion.label
+ if (aktion.danger) {
+ return (
+ run(zeile, aktion)} disabled={busy !== null} aria-label={label}>
+ {text}
+
+ )
+ }
+ return (
+ run(zeile, aktion)}
+ disabled={busy !== null}
+ aria-label={label}
+ aria-expanded={aktion.key === 'projekt' ? offen : undefined}
+ >
+ {text}
+
+ )
+ })}
+
+ {offen && (
+
+ setZuordnen({ id: zeile.id, projektId: e.target.value })}
+ >
+ Projekt wählen …
+ {optionen.map((o) => (
+ {o.label}
+ ))}
+
+ ordneZu(zeile)} disabled={wahl === '' || busy !== null}>
+ {ordnet ? 'Ordne zu …' : 'Zuordnen'}
+
+ setZuordnen(null)}>
+ Abbrechen
+
+
+ )}
+
+
+ )
+ })}
+
+
+
+ )}
+
+
+ )
+}
diff --git a/src/components/auftraege/AuftragSchritte.jsx b/src/components/auftraege/AuftragSchritte.jsx
new file mode 100644
index 0000000..088de25
--- /dev/null
+++ b/src/components/auftraege/AuftragSchritte.jsx
@@ -0,0 +1,321 @@
+import { Badge } from '../ui'
+import FormField from '../finance/FormField'
+import { useWorkorders } from '../../hooks/useWorkorders'
+import { euro } from '../../lib/financeFormat'
+import { customerName, lineTotalCents } from '../../lib/financeView'
+import { KUNDENART_OPTIONEN, kundenartLabel } from '../../lib/kundenFelder'
+import {
+ AUFTRAG_LIMITS,
+ AUFTRAG_WEGE,
+ formularSummeCents,
+ kundenartWirksam,
+ neuePosition,
+ parseProzent,
+ parseTage,
+ schalteZeitquelle,
+ vorOrtHinweis,
+ waehleProjekt,
+ wechselArt,
+ zahlungsplanOptionen,
+ zahlungsplanVorschau,
+ zeitquelleOptionen,
+} from '../../lib/auftragView'
+import './auftrag.css'
+
+/** Meldung eines Feldes ohne FormField (Art, Positionen, Zahlungsplan, Zeitquelle). */
+function FeldFehler({ text }) {
+ return text ? {text}
: null
+}
+
+/** Schritt 1: Projekt, Titel und (falls am Kunden noch leer) die Kundenart. */
+export function SchrittProjekt({ form, onChange, fehler, kunde, konten, neueFassung = false }) {
+ const name = customerName(kunde) || 'Kunde'
+ return (
+
+
+ Kunde: {name}
+ {kunde?.email ? ` · ${kunde.email}` : ''}
+
+ {neueFassung &&
Neue Fassung: Beim Senden ersetzt sie die bisherige Auftragsbestätigung.
}
+
+ onChange(waehleProjekt(form, e.target.value, konten))}>
+ Kein Projekt (freier Titel)
+ {(Array.isArray(konten) ? konten : []).map((k) => (
+ {k.name}
+ ))}
+
+
+
+ onChange({ ...form, titel: e.target.value, titelGeaendert: true })}
+ />
+
+ {!kunde?.kundenart ? (
+
+ onChange({ ...form, kundenart: e.target.value })}>
+ Bitte wählen
+ {KUNDENART_OPTIONEN.slice(1).map((o) => (
+ {o.label}
+ ))}
+
+
+ ) : (
+
Kundenart: {kundenartLabel(kunde.kundenart)}. Änderbar in der Übersicht des Kunden.
+ )}
+
+ )
+}
+
+/** Schritt 2: Absprache (Datum, Weg), Art des Auftrags und Positionen bzw. Stundensatz. */
+export function SchrittAuftrag({ form, onChange, fehler, kunde }) {
+ const vorOrt = vorOrtHinweis(form.auftragWeg, kundenartWirksam(kunde, form.kundenart))
+ const positionen = form.positionen
+ const setPosition = (uid, feld, wert) =>
+ onChange({ ...form, positionen: positionen.map((p) => (p.uid === uid ? { ...p, [feld]: wert } : p)) })
+ const entferne = (uid) => onChange({ ...form, positionen: positionen.filter((p) => p.uid !== uid) })
+ const summe = formularSummeCents(form)
+
+ return (
+
+
+
+ onChange({ ...form, auftragVom: e.target.value })} />
+
+
+ onChange({ ...form, auftragWeg: e.target.value })}>
+ Bitte wählen
+ {AUFTRAG_WEGE.map((w) => (
+ {w.label}
+ ))}
+
+
+
+ {vorOrt &&
{vorOrt}
}
+
+
+ Art des Auftrags
+
+ onChange(wechselArt(form, 'festpreis'))} />
+ Festpreis
+ Feste Positionen mit Preisen.
+
+
+ onChange(wechselArt(form, 'aufwand'))} />
+ Nach Aufwand
+ Stundensatz, abgerechnet nach erfasster Arbeitszeit.
+
+
+
+
+ {form.art === 'festpreis' && (
+
+ Positionen
+ {positionen.map((p, i) => {
+ const n = i + 1
+ const zeile = lineTotalCents(p)
+ return (
+
+
+ Beschreibung
+ setPosition(p.uid, 'description', e.target.value)}
+ />
+
+
+ Menge
+ setPosition(p.uid, 'quantity', e.target.value)}
+ />
+
+
+ Einzelpreis in €
+ setPosition(p.uid, 'price', e.target.value)}
+ />
+
+
+ Summe
+ {zeile === null ? '–' : euro(zeile)}
+
+
+ entferne(p.uid)}
+ >
+ Entfernen
+
+
+
+ )
+ })}
+
+ = AUFTRAG_LIMITS.maxPositionen}
+ onClick={() => onChange({ ...form, positionen: [...positionen, neuePosition()] })}
+ >
+ + Position
+
+
+
+ Summe
+ {euro(summe)}
+
+ Gemäß § 19 UStG ohne Umsatzsteuer.
+
+
+ )}
+
+ {form.art === 'aufwand' && (
+
+
+ onChange({ ...form, stundensatz: e.target.value })} />
+
+
+ onChange({ ...form, schaetzung: e.target.value })}
+ />
+
+
+ )}
+
+ )
+}
+
+/** Zeitquelle: Tickets des Kunden, deren Arbeitsblätter zum Auftrag zählen (höchstens 10). */
+function ZeitquelleAuswahl({ kundeId, form, onChange, projektTicketIds, fehler }) {
+ const { workorders, loading, error } = useWorkorders({ customerId: kundeId, limit: 100 })
+ const optionen = zeitquelleOptionen(workorders, form.zeitquelle, projektTicketIds)
+ const voll = form.zeitquelle.length >= AUFTRAG_LIMITS.maxZeitquellen
+ return (
+
+ Zeiterfassung aus diesen Tickets
+ Arbeitsblätter dieser Tickets zählen zum Auftrag (Projektkonto, Aufwandsrechnung). Höchstens 10.
+ {loading && optionen.length === 0 && Tickets werden geladen …
}
+ {error && Die Tickets konnten nicht geladen werden: {error}. Die bisherige Auswahl bleibt.
}
+ {!loading && !error && optionen.length === 0 && Dieser Kunde hat noch keine Tickets.
}
+ {optionen.length > 0 && (
+
+ {optionen.map((o) => (
+
+ onChange({ ...form, zeitquelle: schalteZeitquelle(form.zeitquelle, o.id, e.target.checked), zeitquelleGeaendert: true })}
+ />
+ {o.label}
+ {o.projekt && Ticket des Projekts }
+
+ ))}
+
+ )}
+
+
+ )
+}
+
+/** Schritt 3: Zahlungsplan, Zahlungsziel, Termine und Zeitquelle. */
+export function SchrittZahlung({ form, onChange, fehler, kunde, konten }) {
+ const konto = (Array.isArray(konten) ? konten : []).find((k) => k.projektId && k.projektId === form.projektId)
+ const projektTicketIds = konto && Array.isArray(konto.tickets) ? konto.tickets.map((t) => t.id) : []
+ const vorschau = zahlungsplanVorschau({
+ art: form.zahlungsart,
+ prozent: parseProzent(form.prozent),
+ text: form.zahlungstext,
+ summeCents: formularSummeCents(form),
+ zielTage: parseTage(form.zahlungsziel),
+ })
+ return (
+
+ )
+}
diff --git a/src/components/auftraege/AuftragVorschau.jsx b/src/components/auftraege/AuftragVorschau.jsx
new file mode 100644
index 0000000..28dd59a
--- /dev/null
+++ b/src/components/auftraege/AuftragVorschau.jsx
@@ -0,0 +1,80 @@
+import { useRef } from 'react'
+import { Button } from '../ui'
+import { useScrollIntoView } from '../../hooks/useScrollIntoView'
+import './auftrag.css'
+
+/**
+ * Schritt 4 des Dialogs als reine Darstellung (kein eigener Zustand): Lade- und Fehlerzustand, Hinweise und die PDF-Vorschau.
+ * Alles, was vor dem Senden zu lesen ist, steht über dem hohen PDF-Rahmen. Hinweise sind reine Texte und sperren nichts;
+ * nur `gate` (Kundendaten) hält das Senden auf, der Entwurf lässt sich trotzdem speichern.
+ *
+ * phase: '' | 'speichern' | 'pdf' | 'senden'; meldung: { text, zurUebersicht? } | null; gate: { erlaubt, grund, zurUebersicht };
+ * hinweise: [{ key, text }]; privat: Kundenart Privatperson.
+ */
+export default function AuftragVorschau({ phase, pdfUrl, meldung, gate, privat, hinweise = [], onRetry, onShowOverview }) {
+ const errorRef = useRef(null)
+ useScrollIntoView(errorRef, meldung)
+ const laedt = phase === 'speichern' || phase === 'pdf'
+
+ return (
+
+ {phase === 'speichern' &&
Entwurf wird gespeichert …
}
+ {phase === 'pdf' &&
Vorschau wird erstellt …
}
+
+ {meldung && (
+
+ {meldung.text}
+ {meldung.zurUebersicht && (
+ <>
+ {' '}
+
+ Zur Übersicht
+
+ >
+ )}
+
+ )}
+ {meldung && !pdfUrl && !laedt && phase !== 'senden' && (
+
+ Erneut versuchen
+
+ )}
+
+ {pdfUrl &&
Entwurf gespeichert. Er bleibt in der Liste, bis er gesendet oder gelöscht wird.
}
+
+ {gate && !gate.erlaubt && (
+
+ {gate.grund}
+ {gate.zurUebersicht && (
+ <>
+ {' '}
+
+ Zur Übersicht
+
+ >
+ )}
+
+ )}
+
+ {hinweise.map((h) => (
+
{h.text}
+ ))}
+
+ {privat && (
+
+ Privatperson: Die Auftragsbestätigung enthält die Widerrufsbelehrung. Mit der Arbeit darf erst nach Ablauf der Widerrufsfrist begonnen
+ werden, es sei denn, der Kunde verlangt es ausdrücklich (Häkchen auf der Bestätigungsseite).
+
+ )}
+
+ {pdfUrl && (
+ <>
+
+
+ PDF in neuem Tab öffnen
+
+ >
+ )}
+
+ )
+}
diff --git a/src/components/auftraege/AufwandAuswahl.jsx b/src/components/auftraege/AufwandAuswahl.jsx
new file mode 100644
index 0000000..9a144bc
--- /dev/null
+++ b/src/components/auftraege/AufwandAuswahl.jsx
@@ -0,0 +1,103 @@
+import { Field } from '../ui'
+import { dateDe } from '../../lib/financeFormat'
+import { stundenDe } from '../../lib/projektkontoView'
+import { ausgewaehlteIds, blaetterSumme } from '../../lib/rechnungDialogView'
+
+/**
+ * Auswahl der offenen Arbeitsblätter für eine Rechnung nach Aufwand, dazu „Weiteres Ticket des Kunden hinzunehmen“.
+ * Rein darstellend. Props: blaetter (alle angebotenen Blätter), abgewaehlt (Kennungen), onToggle(id), satzInfo,
+ * ticketOptionen ([{ value, ticketId, woid, label }]), ticketsLaden, ticketsFehler, weitereTickets ([{ ticketId, woid, titel }]),
+ * weitereZustand ({ [ticketId]: { status, message } }), onAddTicket(option), onRemoveTicket(ticketId), disabled.
+ */
+export default function AufwandAuswahl({
+ blaetter,
+ abgewaehlt,
+ onToggle,
+ satzInfo,
+ ticketOptionen,
+ ticketsLaden,
+ ticketsFehler,
+ weitereTickets,
+ weitereZustand,
+ onAddTicket,
+ onRemoveTicket,
+ disabled = false,
+}) {
+ const liste = Array.isArray(blaetter) ? blaetter : []
+ const summe = blaetterSumme(liste, ausgewaehlteIds(liste, abgewaehlt))
+ const mehrereTickets = new Set(liste.map((b) => b.ticketId)).size > 1
+ const optionen = Array.isArray(ticketOptionen) ? ticketOptionen : []
+ const weitere = Array.isArray(weitereTickets) ? weitereTickets : []
+ const zustand = weitereZustand || {}
+
+ return (
+
+ Arbeitsblätter
+
+ {satzInfo &&
{satzInfo}
}
+ {liste.length === 0 ? (
+
Keine offenen Arbeitsblätter.
+ ) : (
+
+ {liste.map((b) => (
+
+ onToggle(b.id)} disabled={disabled} />
+
+ {dateDe(b.datum)} {b.text || 'Arbeitszeit'}
+ {mehrereTickets && #{b.woid} }
+
+ {stundenDe(b.minuten)}
+
+ ))}
+
+ )}
+
+ {summe.anzahl} von {liste.length} ausgewählt · {stundenDe(summe.minuten)}
+
+
+ {
+ const option = optionen.find((o) => o.value === e.target.value)
+ if (option) onAddTicket(option)
+ }}
+ >
+ {ticketsLaden ? 'Tickets werden geladen …' : 'Ticket wählen …'}
+ {optionen.map((o) => (
+
+ {o.label}
+
+ ))}
+
+ {ticketsFehler && (
+
+ {ticketsFehler}
+
+ )}
+
+ {weitere.map((t) => {
+ const z = zustand[t.ticketId]
+ return (
+
+
+ #{t.woid} · {t.titel}
+
+ {z?.status === 'laedt' && Arbeitsblätter werden geladen … }
+ {z?.status === 'fehler' && (
+
+ {z.message}
+
+ )}
+ onRemoveTicket(t.ticketId)}>
+ Entfernen
+
+
+ )
+ })}
+
+
+ )
+}
diff --git a/src/components/auftraege/KontoZusammenfassung.jsx b/src/components/auftraege/KontoZusammenfassung.jsx
new file mode 100644
index 0000000..782eec9
--- /dev/null
+++ b/src/components/auftraege/KontoZusammenfassung.jsx
@@ -0,0 +1,36 @@
+/**
+ * Zusammenfassung des Projektkontos im Rechnungsdialog: Vereinbart, Berechnet (darunter bezahlt), Noch offen, Zeit erfasst.
+ * Rein darstellend. Props: zeile (aus kontoZeile) und hinweis (aus stripeHinweis, leer = kein Hinweis).
+ */
+export default function KontoZusammenfassung({ zeile, hinweis }) {
+ return (
+ <>
+
+
+
Vereinbart
+
{zeile.vereinbart}
+
+
+
Berechnet
+
{zeile.berechnet}
+ {zeile.bezahlt &&
{zeile.bezahlt}
}
+
+
+
Noch offen
+
{zeile.offen}
+
+
+
Zeit erfasst
+
{zeile.zeit.stunden}
+
{zeile.zeit.wert}
+ {zeile.zeit.abo &&
{zeile.zeit.abo}
}
+
+
+ {hinweis && (
+
+ {hinweis}
+
+ )}
+ >
+ )
+}
diff --git a/src/components/auftraege/ProjektkontoListe.jsx b/src/components/auftraege/ProjektkontoListe.jsx
new file mode 100644
index 0000000..73da2f5
--- /dev/null
+++ b/src/components/auftraege/ProjektkontoListe.jsx
@@ -0,0 +1,81 @@
+import './projektkonto.css'
+import { Card } from '../ui'
+import { kontoZeile, ohneProjektZeile, stripeHinweis, KEINE_PROJEKTE_HINWEIS } from '../../lib/projektkontoView'
+
+/**
+ * Abschnitt „Projekte“ im Kundenreiter „Aufträge und Rechnungen“: je Projekt eine Zeile mit Vereinbart, Berechnet
+ * (darunter bezahlt), Noch offen und erfasster Zeit, am Ende die feste Zeile „Ohne Projekt“.
+ * Props: konten (Projektkonten, darf fehlen), ohneProjekt ({ berechnetCents, bezahltCents, anzahl }), auftraegeAktiv,
+ * stripeOk, onRechnung(projektId) und onAuftrag(projektId). Die Kennung ist die des Projekts, `null` die Zeile „Ohne Projekt“.
+ * Die Überschrift „Projekte“ rendert die Komponente selbst; die Anzeigelogik steht in lib/projektkontoView.js.
+ */
+export default function ProjektkontoListe({ konten, ohneProjekt, auftraegeAktiv, stripeOk, onRechnung, onAuftrag }) {
+ const liste = Array.isArray(konten) ? konten : []
+ const zeilen = [...liste.map((k) => kontoZeile(k, { stripeOk })), ohneProjektZeile(ohneProjekt, { stripeOk })]
+ const hinweis = stripeHinweis(stripeOk)
+
+ return (
+
+
+ {hinweis && (
+
+ {hinweis}
+
+ )}
+ {liste.length === 0 &&
{KEINE_PROJEKTE_HINWEIS}
}
+
+ Projektkonten
+
+
+ Projekt
+ Vereinbart
+ Berechnet
+ Noch offen
+ Zeit erfasst
+ Aktionen
+
+
+
+ {zeilen.map((z) => {
+ const id = z.projektId || null
+ const fuer = id === null ? 'ohne Projekt' : `für ${z.name}`
+ return (
+
+
+ {z.name}
+ {z.meta && {z.meta}
}
+
+ {z.vereinbart}
+
+ {z.berechnet}
+ {z.bezahlt && {z.bezahlt}
}
+
+
+ {z.offen}
+
+
+ {z.zeit.stunden}
+ {z.zeit.wert && {z.zeit.wert}
}
+ {z.zeit.abo && {z.zeit.abo}
}
+
+
+
+ {auftraegeAktiv && (
+ onAuftrag(id)}>
+ Auftragsbestätigung
+
+ )}
+ onRechnung(id)}>
+ Rechnung
+
+
+
+
+ )
+ })}
+
+
+
+
+ )
+}
diff --git a/src/components/auftraege/RechnungDialog.jsx b/src/components/auftraege/RechnungDialog.jsx
new file mode 100644
index 0000000..4c61533
--- /dev/null
+++ b/src/components/auftraege/RechnungDialog.jsx
@@ -0,0 +1,631 @@
+import './rechnung-dialog.css'
+import { useEffect, useMemo, useReducer, useRef, useState } from 'react'
+import { AutocompleteInput, Button, Dialog, Field } from '../ui'
+import AddressWarning from '../finance/AddressWarning'
+import KontoZusammenfassung from './KontoZusammenfassung'
+import RechnungPositionen from './RechnungPositionen'
+import RechnungVorschau from './RechnungVorschau'
+import AufwandAuswahl from './AufwandAuswahl'
+import { useCustomers } from '../../hooks/useCustomers'
+import { useScrollIntoView } from '../../hooks/useScrollIntoView'
+import {
+ useKundenTickets,
+ useEinstellungen,
+ useOhneProjektAbs,
+ useProjektkontoDetail,
+ useProjektkonten,
+ useRechnungVorschau,
+ useWeitereBlaetter,
+} from '../../hooks/useRechnungDaten'
+import { createInvoice } from '../../lib/financeApi'
+import { canInvoice } from '../../lib/financeFormat'
+import {
+ ADDRESS_INCOMPLETE_TEXT,
+ INVOICE_LIMITS,
+ customerName,
+ customerPickerOptions,
+ customerSuggestions,
+ invoiceSendGate,
+ invoiceTotalCents,
+ resolveCustomerText,
+} from '../../lib/financeView'
+import { kontoZeile, satzText, stripeHinweis, KEINE_PROJEKTE_HINWEIS } from '../../lib/projektkontoView'
+import {
+ abLabel,
+ anzahlungStandard,
+ artOptionen,
+ abSummenText,
+ aufwandAuftrag,
+ gueltigeFestpreisAbs,
+ projektOptionen,
+ standardArt,
+ standardAuftrag,
+ startProjekt,
+ steuernummerHinweis,
+ ticketOptionen,
+ wirksamesProjekt,
+ zahlungszielVorschlag,
+} from '../../lib/rechnungDialogView'
+import {
+ BEZEICHNUNG_MAX,
+ VORSCHAU_ARTEN,
+ createSubmitter,
+ darfSpeichern,
+ rechnungAnfrage,
+ vorschauAnfrage,
+ vorschauSchluessel,
+ vorschauStatus,
+} from '../../lib/rechnungAnfrage'
+import { anfangszustand, reduce } from '../../lib/rechnungDialogState'
+
+/**
+ * Dialog „Rechnung schreiben“. Ablauf von oben nach unten: Kunde (fest oder per Suche), Projekt,
+ * Projektkonto (Vereinbart, Berechnet, Noch offen, Zeit), Art der Rechnung (Anzahlung, Schlussrechnung, nach Aufwand, Festbetrag,
+ * ohne Projekt nur freie Positionen), Inhalt je Art mit Vorschau vom Server, Leistungszeitraum, Zahlungsziel und Notiz.
+ * „Als Entwurf speichern“ und „Senden“ bleiben gesperrt, solange die Vorschau nicht zur aktuellen Eingabe gehört.
+ *
+ * Props: open, onClose, onCreated(rechnung, info), kunde (Kundendokument; null = Kundenwahl im Dialog), projektId (undefined =
+ * keine Vorauswahl, null = „Ohne Projekt“, Text = dieses Projekt), auftraegeAktiv (nur der Startwert, danach gilt
+ * getProjektkonten), onShowOverview (optional, „Zur Übersicht“ in der Adresswarnung).
+ * onCreated bekommt wie im bisherigen Dialog ({ senden }) bzw. bei festgeschriebener, nicht versendeter Rechnung
+ * (502 mit invoiceId) (null, { senden: true, notice, tone: 'warn', finalizedFilter }); danach ruft der Dialog onClose().
+ * Enter sendet nie (das Formular hat keinen Absenden-Knopf); „Senden“ fragt vorher nach.
+ */
+export default function RechnungDialog({ open, onClose, onCreated, kunde = null, projektId, auftraegeAktiv, onShowOverview = null }) {
+ if (!open) return null
+ const gemeinsam = { onClose, onCreated, projektId, auftraegeAktiv, onShowOverview }
+ // Die Kundenliste (Appwrite, bis 5000 Dokumente) wird nur geholt, wenn der Kunde nicht feststeht
+ return kunde ? :
+}
+
+function KundenWahl(props) {
+ const { customers, loading, error } = useCustomers()
+ return
+}
+
+const ZEITRAUM_PLATZHALTER = 'z. B. 01.10.–20.10.2026'
+
+function RechnungFormular({ onClose, onCreated, projektId, auftraegeAktiv, onShowOverview, festerKunde, quelle }) {
+ const [state, dispatch] = useReducer(reduce, startProjekt(projektId), (p) => anfangszustand({ projektId: p }))
+ const [customerText, setCustomerText] = useState('')
+ const [error, setError] = useState('')
+ const [busy, setBusy] = useState(null) // null | 'draft' | 'send'
+ const submitter = useMemo(() => createSubmitter({ create: createInvoice, confirm: (t) => window.confirm(t) }), [])
+ const errorRef = useRef(null)
+ const aliveRef = useRef(true)
+ const kundeIdRef = useRef('')
+ const neuGeladenRef = useRef(null)
+ useScrollIntoView(errorRef, error)
+ useEffect(() => {
+ aliveRef.current = true
+ return () => {
+ aliveRef.current = false
+ }
+ }, [])
+
+ const act = (aktion) => {
+ if (error) setError('')
+ dispatch(aktion)
+ }
+
+ // --- Abgeleitete Werte ------------------------------------------------------------------------------------
+ const options = useMemo(() => customerPickerOptions(quelle?.customers), [quelle?.customers])
+ const resolved = festerKunde
+ ? { state: canInvoice(festerKunde).ok ? 'ok' : 'blocked', option: null }
+ : resolveCustomerText(options, customerText)
+ const kundeDoc = festerKunde || resolved.option?.customer || null
+ const kunde = kundeDoc && canInvoice(kundeDoc).ok ? kundeDoc : null
+ const blockiert = Boolean(kundeDoc) && !kunde
+
+ const einstellungen = useEinstellungen()
+ const konten = useProjektkonten(kunde?.$id)
+ const projekt = wirksamesProjekt(state.projektId, konten.data?.konten)
+ const aktiv = konten.data ? Boolean(konten.data.auftraegeAktiv) : Boolean(auftraegeAktiv)
+
+ const detail = useProjektkontoDetail(projekt || '')
+ // „Ohne Projekt“: gültige ABs ohne Projekt (der Server nimmt Anzahlung und Schlussrechnung dazu ohne Projekt an)
+ const ohne = useOhneProjektAbs(kunde?.$id, projekt === '' && aktiv)
+ const quelleDaten = projekt === '' ? ohne.data : detail.data
+ const auftraege = quelleDaten?.auftraege || []
+ const rechnungen = quelleDaten?.rechnungen || []
+ const datenNeuLaden = projekt === '' ? ohne.reload : detail.reload
+
+ let optionen = []
+ if (projekt === '') optionen = artOptionen({ projektId: '', auftraege, rechnungen, auftraegeAktiv: aktiv })
+ else if (projekt && detail.data) optionen = artOptionen({ projektId: projekt, auftraege, rechnungen, auftraegeAktiv: aktiv })
+
+ const stripeOk = detail.data?.stripeOk
+ const art = optionen.some((o) => o.key === state.art && o.enabled)
+ ? state.art
+ : standardArt(optionen, { auftraege, rechnungen, offeneMinuten: detail.data?.konto?.zeit?.offeneMinuten || 0, stripeOk })
+
+ const festpreisAbs = gueltigeFestpreisAbs(auftraege)
+ const auftragId =
+ (art === 'anzahlung' || art === 'schluss') && festpreisAbs.some((a) => a.id === state.auftragId)
+ ? state.auftragId
+ : standardAuftrag(art, auftraege, rechnungen)
+ const ab = auftraege.find((a) => a.id === auftragId)
+
+ const weitereTickets = state.weitereTickets
+ const weitere = useWeitereBlaetter(weitereTickets)
+ const blaetter = [...(detail.data?.offeneArbeitsblaetter || []), ...weitereTickets.flatMap((t) => weitere[t.ticketId]?.blaetter || [])]
+ const kundenTickets = useKundenTickets(kunde?.$id, art === 'aufwand')
+
+ const e = {
+ kunde,
+ art,
+ projektId: projekt ?? '',
+ auftragId,
+ items: state.items,
+ zusatz: state.zusatz,
+ blaetter,
+ abgewaehlt: state.abgewaehlt,
+ anzahlung: { ...anzahlungStandard(ab), ...state.anzahlung },
+ zeilentexte: state.zeilentexte,
+ zeitraum: state.zeitraum,
+ zeitraumGeaendert: state.zeitraumGeaendert,
+ days: state.days,
+ memo: state.memo,
+ }
+ const vorschauReq = vorschauAnfrage(e)
+ const schluessel = vorschauReq.ok ? vorschauSchluessel(vorschauReq.body) : ''
+ const vorschau = useRechnungVorschau({ anfrage: vorschauReq, schluessel, aktiv: VORSCHAU_ARTEN.includes(art) })
+ const status = vorschauStatus({ art, anfrage: vorschauReq, vorschau, schluessel })
+ const anfrage = rechnungAnfrage(e)
+
+ let totalCents = null
+ if (art === 'frei' || art === 'festbetrag') totalCents = invoiceTotalCents(state.items)
+ else if (status.state === 'bereit') totalCents = vorschau.daten.summeCents
+ const gate = invoiceSendGate(kunde, totalCents)
+ const darf = darfSpeichern(status) && busy === null && Boolean(kunde) && projekt !== null && art !== ''
+
+ // Das Zahlungsziel der gewählten AB (bzw. 14); der Reducer lässt eine Eingabe des Nutzers unberührt
+ const tageVorschlag = zahlungszielVorschlag({ art, ab, auftraege })
+ useEffect(() => {
+ dispatch({ type: 'tage-vorschlag', wert: tageVorschlag })
+ }, [tageVorschlag])
+
+ // --- Effekte auf die Vorschau -----------------------------------------------------------------------------
+ const vorschauZeitraum = status.state === 'bereit' ? vorschau.daten.leistungszeitraum || '' : null
+ useEffect(() => {
+ // Der Vorschlag überschreibt nie eine Eingabe des Nutzers (der Reducer prüft das)
+ if (art === 'aufwand' && vorschauZeitraum !== null) dispatch({ type: 'zeitraum-vorschlag', text: vorschauZeitraum })
+ }, [art, vorschauZeitraum])
+
+ const detailReloadRef = useRef(datenNeuLaden)
+ detailReloadRef.current = datenNeuLaden
+ useEffect(() => {
+ // 409 der Vorschau: ein Arbeitsblatt ist inzwischen abgerechnet, das Projektkonto neu laden (einmal je Fehlerobjekt)
+ if (vorschau?.status === 'fehler' && vorschau.httpStatus === 409 && neuGeladenRef.current !== vorschau) {
+ neuGeladenRef.current = vorschau
+ detailReloadRef.current()
+ }
+ }, [vorschau])
+
+ // --- Aktionen ---------------------------------------------------------------------------------------------
+ async function abschicken(senden) {
+ setBusy(senden ? 'send' : 'draft')
+ setError('')
+ const r = await submitter({ anfrage, senden, kunde, totalCents })
+ if (aliveRef.current) setBusy(null)
+ if (r.status === 'fehler') {
+ if (aliveRef.current) setError(r.message)
+ if (r.httpStatus === 409) datenNeuLaden()
+ } else if (r.status === 'festgeschrieben') {
+ if (onCreated) onCreated(null, { senden: true, notice: r.notice, tone: 'warn', finalizedFilter: r.finalizedFilter })
+ onClose()
+ } else if (r.status === 'ok') {
+ if (onCreated) onCreated(r.rechnung, { senden: r.senden })
+ onClose()
+ }
+ }
+
+ const close = () => {
+ if (busy !== null) return
+ onClose()
+ }
+
+ function kundeGeaendert(text) {
+ setCustomerText(text)
+ if (error) setError('')
+ const neu = resolveCustomerText(options, text).option?.customer?.$id || ''
+ if (neu !== kundeIdRef.current) {
+ kundeIdRef.current = neu
+ dispatch({ type: 'projekt', projektId: null })
+ }
+ }
+
+ const gesperrt = busy !== null
+ const footer = (
+
+
+ Abbrechen
+
+ abschicken(false)}>
+ {busy === 'draft' ? 'Speichere …' : 'Als Entwurf speichern'}
+
+ abschicken(true)}
+ >
+ {busy === 'send' ? 'Sende …' : 'Senden'}
+
+
+ )
+
+ // --- Abschnitte -------------------------------------------------------------------------------------------
+ const mitPositionen = (liste, legende, labelPrefix, idPrefix, minZeilen, addLabel, zeigeSumme) => (
+ act({ type: 'position', liste, uid, feld, wert })}
+ onAdd={() => act({ type: 'position+', liste })}
+ onRemove={(uid) => act({ type: 'position-', liste, uid })}
+ addLabel={addLabel}
+ zeigeSumme={zeigeSumme}
+ />
+ )
+
+ const abBlock = () => {
+ if (!ab) return null
+ return (
+
+ {festpreisAbs.length > 1 ? (
+
+ act({ type: 'auftrag', auftragId: ev.target.value })}>
+ {festpreisAbs.map((a) => (
+
+ {abLabel(a)}
+
+ ))}
+
+
+ ) : (
+
{abLabel(ab)}
+ )}
+
{abSummenText({ ab, rechnungen, stripeOk })}
+
+ )
+ }
+
+ const anzahlungFelder = () => {
+ const a = e.anzahlung
+ const prozent = a.modus !== 'betrag'
+ return (
+
+ )
+ }
+
+ const statusBereich = () => (
+ <>
+ {status.state === 'laedt' && (
+
+ Vorschau wird aktualisiert …
+
+ )}
+ {status.state === 'ungueltig' && status.message && {status.message}
}
+ {status.state === 'fehler' && (
+
+ {status.message}
+
+ )}
+ >
+ )
+
+ const vorschauZeilen = () => {
+ const daten = vorschau?.daten
+ if ((status.state !== 'bereit' && status.state !== 'laedt') || !daten) return null
+ return (
+ act({ type: 'zeilentext', schluessel: schl, text, original })}
+ abgeblendet={status.state === 'laedt'}
+ />
+ )
+ }
+
+ const aufwandBlock = () => {
+ const konto = detail.data.konto
+ const laut = aufwandAuftrag(auftraege)
+ const satzInfo = `Stundensatz ${satzText(konto.zeit?.satzCents)}${laut ? `, laut ${laut.nummer}` : ''}`
+ const ausgeschlossen = [...(konto.zeitquelle || []), ...(konto.tickets || []).map((t) => t.id), ...weitereTickets.map((t) => t.ticketId)]
+ return (
+ <>
+ act({ type: 'blatt', id })}
+ satzInfo={satzInfo}
+ ticketOptionen={ticketOptionen(kundenTickets.tickets, { ausgeschlossen })}
+ ticketsLaden={kundenTickets.loading}
+ ticketsFehler={kundenTickets.error}
+ weitereTickets={weitereTickets}
+ weitereZustand={weitere}
+ onAddTicket={(o) =>
+ act({ type: 'ticket+', ticket: { ticketId: o.ticketId, woid: o.woid, titel: o.label.replace(/^#\S+ · /, '') } })
+ }
+ onRemoveTicket={(id) => act({ type: 'ticket-', ticketId: id, blattIds: (weitere[id]?.blaetter || []).map((b) => b.id) })}
+ />
+ {statusBereich()}
+ {vorschauZeilen()}
+ {mitPositionen('zusatz', 'Zusätzliche Positionen', 'Zusatzposition', 'rd-zus', 0, '+ Zusatzposition', false)}
+ >
+ )
+ }
+
+ const inhalt = () => {
+ if (art === 'frei' || art === 'festbetrag') return mitPositionen('items', 'Positionen', 'Position', 'rd-pos', 1, '+ Position', true)
+ if (art === 'aufwand') return aufwandBlock()
+ if (art === 'anzahlung') {
+ return (
+ <>
+ {abBlock()}
+ {anzahlungFelder()}
+ {statusBereich()}
+ {vorschauZeilen()}
+ >
+ )
+ }
+ if (art === 'schluss') {
+ return (
+ <>
+ {abBlock()}
+ {mitPositionen('zusatz', 'Zusätzliche Positionen (Nachträge)', 'Zusatzposition', 'rd-zus', 0, '+ Zusatzposition', false)}
+ {statusBereich()}
+ {vorschauZeilen()}
+ >
+ )
+ }
+ return null
+ }
+
+ const projektBereit = projekt !== null
+ const kontoBereit = projekt === '' || Boolean(detail.data)
+ const hinweise = [...new Set(optionen.filter((o) => !o.enabled && o.hint).map((o) => o.hint))]
+
+ // Enter sendet nie (das wäre eine Mail an den Kunden): das Formular hat keinen Absenden-Knopf, jeder Knopf darin ist type="button"
+ return (
+ ev.preventDefault()} footer={footer}>
+ {error && (
+
+ {error}
+
+ )}
+
+
+
+ {festerKunde ? (
+
+ {customerName(festerKunde) || 'Kunde'}
+ {festerKunde.email ? · {festerKunde.email} : null}
+
+ ) : quelle?.loading ? (
+
+ Kunden werden geladen …
+
+ ) : quelle?.error ? (
+
+ Die Kunden konnten nicht geladen werden: {quelle.error}
+
+ ) : (
+ customerSuggestions(options, t)}
+ placeholder="Kunde suchen …"
+ />
+ )}
+
+ {blockiert && (
+
+ {customerName(kundeDoc)}: {festerKunde ? canInvoice(festerKunde).reason : resolved.option?.reason}. Für diesen Kunden kann keine Rechnung geschrieben werden.
+
+ )}
+ {!festerKunde && resolved.state === 'unknown' && (
+
Bitte einen Kunden aus der Liste wählen. Nur feste Kunden mit E-Mail-Adresse erscheinen dort.
+ )}
+ {!festerKunde && kunde && kunde.email &&
Die Rechnung geht an {kunde.email}.
}
+ {steuernummerHinweis(einstellungen) && (
+
+ {steuernummerHinweis(einstellungen)}
+
+ )}
+ {kunde && gate.warn && (
+
{
+ if (busy !== null) return
+ onClose()
+ onShowOverview()
+ }
+ : undefined
+ }
+ />
+ )}
+
+ {kunde && (
+ <>
+
+ act({ type: 'projekt', projektId: ev.target.value })}
+ >
+ {projekt === null && (
+
+ Projekt wählen …
+
+ )}
+ {projektOptionen(konten.data?.konten).map((o) => (
+
+ {o.label}
+
+ ))}
+
+
+ {konten.loading && (
+
+ Projekte werden geladen …
+
+ )}
+ {konten.error && (
+
+ Die Projekte konnten nicht geladen werden: {konten.error}{' '}
+
+ Erneut versuchen
+
+
+ )}
+ {konten.data && konten.data.konten.length === 0 && {KEINE_PROJEKTE_HINWEIS}
}
+ >
+ )}
+
+ {kunde && projekt === '' && ohne.loading && (
+
+ Aufträge werden geladen …
+
+ )}
+ {kunde && projekt === '' && ohne.error && (
+
+ Die Aufträge ohne Projekt konnten nicht geladen werden: {ohne.error}{' '}
+
+ Erneut versuchen
+
+
+ )}
+
+ {kunde && projektBereit && projekt !== '' && (
+ <>
+ {detail.loading && !detail.data && (
+
+ Projektkonto wird geladen …
+
+ )}
+ {detail.error && !detail.data && (
+
+ Das Projektkonto konnte nicht geladen werden: {detail.error}{' '}
+
+ Erneut versuchen
+
+
+ )}
+ {detail.data && (
+
+ )}
+ >
+ )}
+
+ {kunde && projektBereit && kontoBereit && optionen.length > 1 && (
+
+ Art der Rechnung
+
+
+ {optionen.map((o) => (
+ act({ type: 'art', art: o.key })}
+ >
+ {o.label}
+
+ ))}
+
+ {hinweise.map((h) => (
+
+ {h}
+
+ ))}
+
+
+ )}
+
+ {kunde && projektBereit && kontoBereit && art !== '' && inhalt()}
+
+ {kunde && projektBereit && kontoBereit && art !== '' && (
+ <>
+
+ act({ type: 'zeitraum', text: ev.target.value })}
+ />
+
+
+
+ act({ type: 'tage', wert: ev.target.value })}
+ />
+
+
+
+
+ >
+ )}
+
+
+ )
+}
diff --git a/src/components/auftraege/RechnungPositionen.jsx b/src/components/auftraege/RechnungPositionen.jsx
new file mode 100644
index 0000000..44cf029
--- /dev/null
+++ b/src/components/auftraege/RechnungPositionen.jsx
@@ -0,0 +1,100 @@
+import { Button } from '../ui'
+import { euro } from '../../lib/financeFormat'
+import { INVOICE_LIMITS, lineTotalCents, invoiceTotalCents } from '../../lib/financeView'
+
+/**
+ * Positionszeilen des Rechnungsdialogs (Beschreibung, Menge, Einzelpreis, Summe, Entfernen) mit „+ Position“-Knopf.
+ * Rein darstellend, hält keinen Zustand. Props: legende, labelPrefix (z. B. „Position“ oder „Zusatzposition“), idPrefix,
+ * zeilen ([{ uid, description, quantity, price }]), minZeilen, onChange(uid, feld, wert), onAdd(), onRemove(uid), addLabel,
+ * zeigeSumme (Gesamtsumme und Hinweis zu § 19 UStG).
+ */
+export default function RechnungPositionen({
+ legende,
+ labelPrefix,
+ idPrefix,
+ zeilen,
+ minZeilen = 0,
+ onChange,
+ onAdd,
+ onRemove,
+ addLabel,
+ zeigeSumme = false,
+}) {
+ return (
+
+ {legende}
+
+ {zeilen.map((zeile, index) => {
+ const n = index + 1
+ const summe = lineTotalCents(zeile)
+ return (
+
+
+ Beschreibung
+ onChange(zeile.uid, 'description', e.target.value)}
+ />
+
+
+ Menge
+ onChange(zeile.uid, 'quantity', e.target.value)}
+ />
+
+
+ Einzelpreis in €
+ onChange(zeile.uid, 'price', e.target.value)}
+ />
+
+
+ Summe
+ {summe === null ? '–' : euro(summe)}
+
+
+ onRemove(zeile.uid)}
+ >
+ Entfernen
+
+
+
+ )
+ })}
+
+
= INVOICE_LIMITS.maxItems}>
+ {addLabel}
+
+ {zeigeSumme && (
+
+ Summe
+ {euro(invoiceTotalCents(zeilen))}
+
+ )}
+
+ {zeigeSumme &&
Gemäß § 19 UStG ohne Umsatzsteuer.
}
+
+
+ )
+}
diff --git a/src/components/auftraege/RechnungVorschau.jsx b/src/components/auftraege/RechnungVorschau.jsx
new file mode 100644
index 0000000..2491fd9
--- /dev/null
+++ b/src/components/auftraege/RechnungVorschau.jsx
@@ -0,0 +1,69 @@
+import { euro } from '../../lib/financeFormat'
+import { isNegText } from '../../lib/financeUi'
+import { zeileTeilen, zeileBearbeitbar, maxVorn } from '../../lib/rechnungAnfrage'
+
+/**
+ * Vorschau der Rechnungszeilen, wie der Server sie baut. Zeigt immer alle Positionen der Antwort (eine Schlussrechnung hat
+ * bis zu 40). Rein darstellend. Props: positionen ([{ schluessel, beschreibung, menge, einzelpreisCents, betragCents }]),
+ * summeCents, bearbeitbar (Tages- und Wochenzeilen der Aufwandsrechnung lassen den Text vor dem letzten „ – “ ändern),
+ * zeilentexte ({ [schluessel]: Text }), onZeilentext(schluessel, text, original), abgeblendet (Vorschau wird gerade aktualisiert).
+ */
+export default function RechnungVorschau({ positionen, summeCents, bearbeitbar = false, zeilentexte = {}, onZeilentext, abgeblendet = false }) {
+ const liste = Array.isArray(positionen) ? positionen : []
+ const mitEingabe = bearbeitbar && liste.some((p) => zeileBearbeitbar(p))
+ return (
+
+
+
+ Summe
+ {euro(summeCents)}
+
+
Gemäß § 19 UStG ohne Umsatzsteuer.
+ {mitEingabe &&
Die Beschreibung der Zeilen lässt sich ändern, Stunden und Beträge stehen fest.
}
+
+ )
+}
diff --git a/src/components/auftraege/auftrag.css b/src/components/auftraege/auftrag.css
new file mode 100644
index 0000000..e1ba3f5
--- /dev/null
+++ b/src/components/auftraege/auftrag.css
@@ -0,0 +1,95 @@
+/* Auftragsbestaetigungen (W2): Liste, Dialog-Schritte, Vorschau. Nur Tokens, alle Klassen beginnen mit auftrag-.
+ Die Datei wird von der Komponente geladen, global.css kommt danach: darum nie eine globale Klasse ueberschreiben. */
+
+/* Liste */
+.auftrag-body { container-type: inline-size; }
+.auftrag-notice { margin: 0 0 8px; }
+.auftrag-table-wrap { overflow-x: auto; }
+.auftrag-sr { position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0; }
+.auftrag-nummer { white-space: nowrap; }
+.auftrag-titel { overflow-wrap: anywhere; }
+.auftrag-table th.auftrag-amount { text-align: right; }
+.auftrag-amount { text-align: right; font-variant-numeric: tabular-nums; white-space: nowrap; }
+.auftrag-note { margin-top: 2px; font-size: 13px; overflow-wrap: anywhere; }
+.auftrag-actions { display: flex; flex-wrap: wrap; gap: 4px 14px; }
+.auftrag-zuordnen { padding: 2px; display: flex; flex-wrap: wrap; align-items: center; gap: 8px 12px; margin-top: 8px; }
+.auftrag-zuordnen .select { flex: 1 1 180px; min-width: 0; max-width: 100%; }
+.auftrag-liste button.link:disabled, .auftrag-liste button.link-danger:disabled { cursor: not-allowed; }
+
+@container (max-width: 860px) {
+ .auftrag-table thead { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
+ .auftrag-table, .auftrag-table tbody { display: block; }
+ .auftrag-liste .auftrag-table tr {
+ display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 4px 16px; align-items: baseline;
+ grid-template-areas: "nummer status" "titel titel" "datum summe" "aktionen aktionen";
+ padding: 10px 0; border-bottom: 1px solid var(--line);
+ }
+ .auftrag-liste .auftrag-table td, .auftrag-liste .auftrag-table td:first-child, .auftrag-liste .auftrag-table td:last-child { display: block; padding: 0; border: 0; min-width: 0; }
+ .auftrag-nummer { grid-area: nummer; }
+ .auftrag-status { grid-area: status; text-align: right; }
+ .auftrag-titel { grid-area: titel; }
+ .auftrag-datum { grid-area: datum; }
+ .auftrag-liste .auftrag-table td.auftrag-amount { grid-area: summe; }
+ .auftrag-liste .auftrag-table td.auftrag-aktionen { grid-area: aktionen; padding-top: 4px; }
+ .auftrag-table td[data-label]::before { content: attr(data-label) " "; color: var(--text-2); font-weight: 500; }
+}
+
+/* Dialog: Schritte 1 bis 3 */
+.auftrag-schritt { display: grid; gap: 16px; min-width: 0; }
+.auftrag-schritt .field { min-width: 0; }
+.auftrag-art { border: 0; padding: 0; margin: 0; display: grid; gap: 8px; min-width: 0; }
+.auftrag-art legend { font-weight: 500; padding: 0; margin-bottom: 4px; }
+.auftrag-art .check { flex-wrap: wrap; }
+.auftrag-check-text { flex: 1 1 0; min-width: 0; overflow-wrap: anywhere; }
+.auftrag-zeitquelle-liste .auftrag-check-text { flex: 0 1 auto; }
+.auftrag-art .check .hint { flex-basis: 100%; padding-left: 23px; font-size: 13px; color: var(--text-2); }
+.auftrag-items { border: 0; padding: 0; margin: 0; display: grid; gap: 12px; min-width: 0; }
+.auftrag-items legend { font-weight: 500; padding: 0; margin-bottom: 4px; }
+.auftrag-item {
+ display: grid; grid-template-columns: minmax(0, 1fr) 80px 120px 110px auto; gap: 8px 12px; align-items: end;
+ grid-template-areas: "desc qty price sum remove";
+}
+.auftrag-item .input { width: 100%; }
+.auftrag-item > div { display: grid; gap: 4px; min-width: 0; }
+.auftrag-item-desc { grid-area: desc; }
+.auftrag-item-qty { grid-area: qty; }
+.auftrag-item-price { grid-area: price; }
+.auftrag-item-sum { grid-area: sum; }
+.auftrag-item-remove { grid-area: remove; }
+.auftrag-item-qty .input, .auftrag-item-price .input { text-align: right; font-variant-numeric: tabular-nums; }
+.auftrag-item-wert { min-height: 36px; display: flex; align-items: center; justify-content: flex-end; font-variant-numeric: tabular-nums; white-space: nowrap; }
+.auftrag-item-remove { min-height: 36px; align-content: center; }
+.auftrag-item-label { font-size: 12px; color: var(--text-2); }
+.auftrag-item + .auftrag-item .auftrag-item-label { position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0; }
+.auftrag-total { display: flex; justify-content: space-between; gap: 16px; font-weight: 600; }
+.auftrag-narrow { max-width: 120px; }
+.auftrag-plan-vorschau { margin: 0; overflow-wrap: anywhere; }
+.auftrag-hinweis { margin: 0; overflow-wrap: anywhere; }
+.auftrag-zeitquelle { border: 0; padding: 0; margin: 0; display: grid; gap: 6px; min-width: 0; }
+.auftrag-zeitquelle legend { font-weight: 500; padding: 0; margin-bottom: 4px; }
+.auftrag-zeitquelle-liste { display: grid; gap: 6px; max-height: 220px; overflow-y: auto; border-top: 1px solid var(--line); border-bottom: 1px solid var(--line); padding: 6px 0; }
+.auftrag-zeitquelle-liste .check { flex-wrap: wrap; overflow-wrap: anywhere; }
+
+@media (max-width: 860px) {
+ .auftrag-item {
+ grid-template-columns: minmax(0, .7fr) minmax(0, 1fr) minmax(0, 1fr) auto;
+ grid-template-areas: "desc desc desc desc" "qty price sum remove";
+ }
+ .auftrag-item + .auftrag-item .auftrag-item-label, .auftrag-item-label { position: static; width: auto; height: auto; margin: 0; overflow: visible; clip: auto; white-space: normal; }
+ .auftrag-narrow { max-width: none; }
+}
+
+/* Dialog: Schrittleiste, Vorschau, Fuss */
+.auftrag-schritte { list-style: none; margin: 0 0 16px; padding: 0; display: flex; flex-wrap: wrap; gap: 8px 20px; font-size: 13px; color: var(--text-2); }
+.auftrag-schritte li[aria-current="step"] { color: var(--text); font-weight: 600; }
+.auftrag-schritt-nr { margin-right: 6px; }
+.auftrag-pdf { width: 100%; height: min(70vh, 640px); border: 1px solid var(--line); border-radius: var(--radius); background: var(--bg-subtle); }
+.auftrag-foot { display: flex; flex: 1; flex-wrap: wrap; align-items: center; gap: 8px; }
+.auftrag-foot .grow { flex: 1; }
+
+@media (max-width: 860px) {
+ .auftrag-schritt-text { display: none; }
+ .auftrag-schritte li[aria-current="step"] .auftrag-schritt-text { display: inline; }
+ .auftrag-pdf { height: 50vh; }
+ .auftrag-foot .btn { flex: 1 1 auto; }
+}
diff --git a/src/components/auftraege/projektkonto.css b/src/components/auftraege/projektkonto.css
new file mode 100644
index 0000000..7c38af1
--- /dev/null
+++ b/src/components/auftraege/projektkonto.css
@@ -0,0 +1,30 @@
+/* Projektliste im Kundenreiter „Aufträge und Rechnungen“. .pk ist der Container der Abfrage unten (in W1 nur hier
+ container-type; der Rechnungsdialog liegt nie darin). Jede Regel trägt .pk und überstimmt so .table td aus global.css. */
+.pk { container-type: inline-size; }
+.pk .pk-hinweis { margin: 0 0 8px; }
+.pk .pk-table th, .pk .pk-table td { vertical-align: top; }
+.pk .pk-name { overflow-wrap: anywhere; } /* lange Projektnamen ohne Umbruchstelle dürfen die Spalte nie aufweiten */
+.pk .pk-table td .cell-sub { overflow-wrap: break-word; }
+/* Beträge und Stunden brechen nie um: das € bleibt am Betrag */
+.pk .pk-table .num, .pk .pk-berechnet .cell-sub, .pk .pk-zeit .cell-sub { white-space: nowrap; }
+.pk .pk-table .num { font-variant-numeric: tabular-nums; }
+.pk .pk-actions { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 4px 14px; }
+.pk .pk-sr { position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0; }
+@container (max-width: 860px) {
+ .pk .pk-table thead { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
+ .pk .pk-table, .pk .pk-table tbody { display: block; }
+ .pk .pk-table tr {
+ display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px 16px; align-items: start;
+ grid-template-areas: "name name" "vereinbart berechnet" "offen zeit" "actions actions";
+ padding: 12px 0; border-bottom: 1px solid var(--line);
+ }
+ .pk .pk-table td, .pk .pk-table td:first-child, .pk .pk-table td:last-child { display: block; padding: 0; border: 0; min-width: 0; }
+ .pk .pk-name { grid-area: name; }
+ .pk .pk-vereinbart { grid-area: vereinbart; }
+ .pk .pk-berechnet { grid-area: berechnet; }
+ .pk .pk-offen { grid-area: offen; }
+ .pk .pk-zeit { grid-area: zeit; }
+ .pk .pk-actions-cell { grid-area: actions; }
+ .pk .pk-actions { justify-content: flex-start; }
+ .pk .pk-table td[data-label]::before { content: attr(data-label); display: block; font-size: 12px; font-weight: 500; color: var(--text-2); }
+}
diff --git a/src/components/auftraege/rechnung-dialog.css b/src/components/auftraege/rechnung-dialog.css
new file mode 100644
index 0000000..83300d4
--- /dev/null
+++ b/src/components/auftraege/rechnung-dialog.css
@@ -0,0 +1,59 @@
+/* Rechnung schreiben (Dialog). Dieses Stylesheet steht im Bundle vor global.css: Regeln, die globale Klassen überstimmen
+ müssen, tragen zwei Klassen. Task 9 hängt am Ende die Spalten der Rechnungstabelle an. */
+.rd-group { border: 0; margin: 0; padding: 0; min-width: 0; }
+.rd-group > legend { padding: 0; margin: 0 0 6px; font-weight: 500; }
+.rd-stack { display: grid; grid-template-columns: minmax(0, 1fr); gap: 8px; }
+.rd-facts.facts { margin: 0; }
+.seg.rd-seg { flex-wrap: wrap; max-width: 100%; justify-self: start; }
+.rd-hint { margin: 0; font-size: 13px; color: var(--text-2); overflow-wrap: break-word; }
+
+/* Positionen */
+.rd-item { display: grid; grid-template-columns: minmax(0, 1fr) 80px 120px 110px auto; gap: 8px 12px; align-items: end; }
+.rd-item .input { width: 100%; }
+.rd-item-field { min-width: 0; display: grid; gap: 4px; }
+.rd-item-label { font-size: 12px; color: var(--text-2); }
+.rd-item-sum { text-align: right; white-space: nowrap; padding-bottom: 6px; }
+.rd-items-foot { display: flex; justify-content: space-between; align-items: center; gap: 12px; flex-wrap: wrap; }
+.rd-total { display: flex; gap: 16px; font-weight: 600; }
+@media (max-width: 860px) {
+ .rd-item { grid-template-columns: minmax(0, 0.7fr) minmax(0, 1fr) minmax(0, 1fr) auto; grid-template-areas: "desc desc desc desc" "qty price sum remove"; }
+ .rd-item-desc { grid-area: desc; }
+ .rd-item-qty { grid-area: qty; }
+ .rd-item-price { grid-area: price; }
+ .rd-item-sumfeld { grid-area: sum; }
+ .rd-item-remove { grid-area: remove; }
+}
+
+/* Arbeitsblätter */
+.rd-blaetter { border-top: 1px solid var(--line); }
+.rd-blatt { display: grid; grid-template-columns: auto minmax(0, 1fr) auto; gap: 4px 12px; align-items: start; padding: 8px 0; border-bottom: 1px solid var(--line); cursor: pointer; }
+.rd-blatt input { accent-color: var(--accent); width: 15px; height: 15px; margin-top: 3px; }
+.rd-blatt-text { min-width: 0; overflow-wrap: break-word; }
+.rd-blatt-zeit { white-space: nowrap; font-variant-numeric: tabular-nums; }
+.rd-weiter { display: flex; justify-content: space-between; align-items: baseline; gap: 12px; flex-wrap: wrap; }
+
+/* Vorschau der Rechnungszeilen */
+.rd-lines { table-layout: fixed; }
+.rd-lines .rd-line-text { overflow-wrap: break-word; }
+.rd-lines .fin-amount { width: 7.5rem; }
+.rd-lines th.fin-amount { text-align: right; }
+.rd-line-input { width: 100%; min-width: 0; }
+.rd-line-tail { display: block; font-size: 13px; color: var(--text-2); overflow-wrap: break-word; }
+.rd-dim { opacity: 0.5; }
+
+/* nur für Screenreader sichtbar */
+.rd-sr { position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0; }
+
+/* Fuß des Dialogs */
+.rd-foot { display: flex; flex: 1; flex-wrap: wrap; justify-content: flex-end; gap: 8px; }
+@media (max-width: 860px) { .rd-foot .btn { flex: 1 1 auto; } }
+
+/* Rechnungstabelle (InvoiceTable): Spalten Projekt und Art. Zwei Klassen vor dem Elementnamen, damit diese Rasterflächen
+ die Regel .fin-invoices tr aus global.css überstimmen (dieses Stylesheet steht davor im Bundle). */
+.fin-block .fin-inv-project { overflow-wrap: break-word; }
+.fin-block .fin-inv-art { white-space: nowrap; }
+@container (max-width: 860px) {
+ .fin-block .fin-invoices tr { grid-template-areas: "number status" "customer customer" "projekt art" "created due" "total remaining" "actions actions"; }
+ .fin-block .fin-inv-project { grid-area: projekt; }
+ .fin-block .fin-inv-art { grid-area: art; }
+}
diff --git a/src/components/finance/CustomerInvoices.jsx b/src/components/finance/CustomerInvoices.jsx
deleted file mode 100644
index 0cb8e33..0000000
--- a/src/components/finance/CustomerInvoices.jsx
+++ /dev/null
@@ -1,29 +0,0 @@
-import { Button, EmptyState } from '../ui'
-import AddressWarning from './AddressWarning'
-import InvoiceTable from './InvoiceTable'
-import { customerInvoicesGate } from '../../lib/financeView'
-
-/**
- * Reiter „Rechnungen“ am Kunden. Kann für den Kunden keine Rechnung geschrieben werden (noch Lead, abgesagt, keine
- * E-Mail), steht der Grund da, mit „Daten erfassen“ (onShowOverview wechselt zum Reiter Übersicht). Sonst die
- * Rechnungen dieses Kunden mit „Rechnung schreiben“ (der Kunde steht im Dialog fest). Fehlt dem Kunden die Anschrift
- * (Straße, PLZ, Ort), steht darüber eine Warnung mit Knopf zur Übersicht; „Senden“ ist dann über 250 € gesperrt.
- */
-export default function CustomerInvoices({ customer, onShowOverview }) {
- const gate = customerInvoicesGate(customer)
- if (!gate.ok) {
- return (
- Daten erfassen : null}
- />
- )
- }
- return (
- <>
-
-
- >
- )
-}
diff --git a/src/components/finance/InvoiceCreateModal.jsx b/src/components/finance/InvoiceCreateModal.jsx
deleted file mode 100644
index 44ecf91..0000000
--- a/src/components/finance/InvoiceCreateModal.jsx
+++ /dev/null
@@ -1,320 +0,0 @@
-import { useEffect, useMemo, useRef, useState } from 'react'
-import { AutocompleteInput, Button, Dialog, Field } from '../ui'
-import AddressWarning from './AddressWarning'
-import { useCustomers } from '../../hooks/useCustomers'
-import { useScrollIntoView } from '../../hooks/useScrollIntoView'
-import { createInvoice } from '../../lib/financeApi'
-import { canInvoice, euro } from '../../lib/financeFormat'
-import {
- ADDRESS_INCOMPLETE_TEXT,
- INVOICE_LIMITS,
- customerName,
- customerPickerOptions,
- customerSuggestions,
- finalizedFilter,
- invoiceFailureMessage,
- invoiceSendConfirmText,
- invoiceSendGate,
- invoiceTotalCents,
- isFinalizedNotSent,
- lineTotalCents,
- resolveCustomerText,
- validateInvoiceForm,
-} from '../../lib/financeView'
-
-let itemCounter = 0
-const newItem = () => ({ uid: ++itemCounter, description: '', quantity: '1', price: '' })
-
-/**
- * Dialog „Rechnung schreiben“. Kunde (nur feste Kunden mit E-Mail wählbar; mit `fixedCustomer` steht er fest),
- * Positionen (Beschreibung, Menge, Einzelpreis in Euro), Zahlungsziel (Standard 14 Tage), Notiz, Summe und der
- * Hinweis nach § 19 UStG. „Als Entwurf speichern“ und „Senden“ sperren sich gegenseitig, solange die Anfrage läuft;
- * Fehler des Servers stehen im Dialog (und werden in den sichtbaren Bereich geholt). „Senden“ fragt vorher nach
- * (die Rechnung wird festgeschrieben und per Mail an den Kunden geschickt). Fehlt dem Kunden die Anschrift (Straße,
- * PLZ, Ort), steht eine Warnung mit Weg zur Übersicht da, und „Senden“ ist über 250 € gesperrt (§ 14 Abs. 4 Nr. 1
- * UStG; Entwürfe bleiben möglich). `onShowOverview` (Reiter am Kunden) wechselt zur Übersicht, sonst führt ein Link
- * zur Kundenseite.
- *
- * onCreated(rechnung, { senden }) nach Erfolg; wurde die Rechnung festgeschrieben, aber nicht versendet (502 mit
- * invoiceId), kommt onCreated(null, { senden: true, notice, tone: 'warn', finalizedFilter }) und der Dialog schließt
- * (die Rechnung steht dann „offen“ in der Liste und lässt sich dort erneut senden). Danach ruft der Dialog onClose().
- */
-export default function InvoiceCreateModal({ open, onClose, onCreated, fixedCustomer = null, onShowOverview = null }) {
- if (!open) return null
- // Die Kundenliste (Appwrite, bis 5000 Dokumente) wird nur geholt, wenn der Kunde nicht feststeht
- return fixedCustomer ? (
-
- ) : (
-
- )
-}
-
-function PickerForm({ onClose, onCreated }) {
- const { customers, loading, error } = useCustomers()
- return
-}
-
-function InvoiceForm({ onClose, onCreated, fixedCustomer, source, onShowOverview }) {
- const [customerText, setCustomerText] = useState('')
- const [items, setItems] = useState(() => [newItem()])
- const [days, setDays] = useState(String(INVOICE_LIMITS.defaultDays))
- const [memo, setMemo] = useState('')
- const [error, setError] = useState('')
- const [busy, setBusy] = useState(null) // 'draft' | 'send'
- const lockRef = useRef(false)
- const aliveRef = useRef(true)
- const errorRef = useRef(null)
-
- // Der Dialog kann höher sein als das Fenster: eine Fehlermeldung oben soll nicht außerhalb des Bildes stehen
- useScrollIntoView(errorRef, error)
-
- useEffect(() => {
- aliveRef.current = true
- return () => {
- aliveRef.current = false
- }
- }, [])
-
- // × und Esc kommen vom Dialog (er ignoriert Esc, wenn die Kundenliste die Taste schon behandelt hat, und solange `busy` gilt).
- // Die Sperre deckt den Augenblick zwischen Klick und erstem Zeichnen von `busy` ab.
- const close = () => {
- if (lockRef.current) return
- onClose()
- }
-
- const options = useMemo(() => customerPickerOptions(source?.customers), [source?.customers])
- const resolved = fixedCustomer
- ? { state: canInvoice(fixedCustomer).ok ? 'ok' : 'blocked', option: null }
- : resolveCustomerText(options, customerText)
- const customer = fixedCustomer || resolved.option?.customer || null
- const customerBlocked = fixedCustomer ? canInvoice(fixedCustomer) : resolved.option ? { ok: resolved.option.ok, reason: resolved.option.reason } : { ok: true }
- const total = invoiceTotalCents(items)
- // Anschrift des Empfängers (§ 14 Abs. 4 Nr. 1 UStG): ohne sie ist „Senden“ nur bis 250 € möglich
- const gate = invoiceSendGate(customerBlocked.ok ? customer : null, total)
-
- const updateItem = (uid, field, value) => {
- setItems((list) => list.map((it) => (it.uid === uid ? { ...it, [field]: value } : it)))
- if (error) setError('')
- }
-
- async function submit(senden) {
- if (lockRef.current) return
- const check = validateInvoiceForm({ customer, items, days, memo })
- if (!check.ok) {
- setError(check.error)
- return
- }
- if (senden) {
- if (!invoiceSendGate(customer, check.totalCents).sendAllowed) {
- setError(ADDRESS_INCOMPLETE_TEXT)
- return
- }
- // Senden schreibt die Rechnung fest und schickt sie an den Kunden: erst nach Rückfrage
- const sure = window.confirm(
- invoiceSendConfirmText({ name: customerName(customer), email: customer.email, totalCents: check.totalCents })
- )
- if (!sure) return
- }
- lockRef.current = true
- setBusy(senden ? 'send' : 'draft')
- setError('')
- try {
- const data = await createInvoice({ ...check.payload, senden })
- if (onCreated) onCreated(data?.rechnung || null, { senden })
- onClose()
- } catch (err) {
- if (isFinalizedNotSent(err)) {
- // Die Rechnung steht bei Stripe: keine zweite anlegen, die Liste zeigt sie
- if (onCreated) onCreated(null, { senden: true, notice: invoiceFailureMessage(err), tone: 'warn', finalizedFilter: finalizedFilter(err) })
- onClose()
- return
- }
- if (aliveRef.current) setError(invoiceFailureMessage(err))
- } finally {
- lockRef.current = false
- if (aliveRef.current) setBusy(null)
- }
- }
-
- const disabled = busy !== null
-
- const footer = (
-
- Abbrechen
- submit(false)} disabled={disabled}>
- {busy === 'draft' ? 'Speichere …' : 'Als Entwurf speichern'}
-
- submit(true)}
- disabled={disabled || !gate.sendAllowed}
- title={gate.sendAllowed ? undefined : ADDRESS_INCOMPLETE_TEXT}
- >
- {busy === 'send' ? 'Sende …' : 'Senden'}
-
-
- )
-
- // Enter sendet nie (das wäre eine Mail an den Kunden): das Formular hat keinen Absenden-Knopf, jeder Knopf darin ist type="button"
- return (
- e.preventDefault()} footer={footer}>
- {error && (
-
- {error}
-
- )}
-
-
- {fixedCustomer ? (
-
- {customerName(fixedCustomer) || 'Kunde'}
- {fixedCustomer.email ? · {fixedCustomer.email} : null}
-
- ) : source?.loading ? (
- Kunden werden geladen …
- ) : source?.error ? (
- Die Kunden konnten nicht geladen werden: {source.error}
- ) : (
- {
- setCustomerText(v)
- if (error) setError('')
- }}
- getSuggestions={(t) => customerSuggestions(options, t)}
- placeholder="Kunde suchen …"
- />
- )}
- {!customerBlocked.ok && (
-
- {customerName(customer)}: {customerBlocked.reason}. Für diesen Kunden kann keine Rechnung geschrieben werden.
-
- )}
- {!fixedCustomer && resolved.state === 'unknown' && (
- Bitte einen Kunden aus der Liste wählen. Nur feste Kunden mit E-Mail-Adresse erscheinen dort.
- )}
- {customer && customerBlocked.ok && customer.email && !fixedCustomer && (
- Die Rechnung geht an {customer.email}.
- )}
- {gate.warn && (
- {
- if (lockRef.current) return
- onClose()
- onShowOverview()
- }
- : undefined
- }
- />
- )}
-
-
-
- Positionen
- {items.map((item, index) => {
- const line = lineTotalCents(item)
- const n = index + 1
- return (
-
-
- Beschreibung
- updateItem(item.uid, 'description', e.target.value)}
- aria-label={`Position ${n}: Beschreibung`}
- placeholder="z. B. Webdesign Startseite"
- />
-
-
- Menge
- updateItem(item.uid, 'quantity', e.target.value)}
- aria-label={`Position ${n}: Menge`}
- />
-
-
- Einzelpreis in €
- updateItem(item.uid, 'price', e.target.value)}
- aria-label={`Position ${n}: Einzelpreis in Euro`}
- placeholder="0,00"
- />
-
-
- Summe
- {line === null ? '–' : euro(line)}
-
-
- setItems((list) => (list.length > 1 ? list.filter((it) => it.uid !== item.uid) : list))}
- disabled={items.length <= 1}
- aria-label={`Position ${n} entfernen`}
- >
- Entfernen
-
-
-
- )
- })}
-
-
setItems((list) => (list.length < INVOICE_LIMITS.maxItems ? [...list, newItem()] : list))}
- disabled={items.length >= INVOICE_LIMITS.maxItems}
- >
- + Position
-
-
- Summe
- {euro(total)}
-
-
- Gemäß § 19 UStG ohne Umsatzsteuer.
-
-
-
-
- {
- setDays(e.target.value)
- if (error) setError('')
- }}
- />
-
-
-
-
-
- )
-}
diff --git a/src/components/finance/InvoiceTable.jsx b/src/components/finance/InvoiceTable.jsx
index 3d84c7f..a9a0931 100644
--- a/src/components/finance/InvoiceTable.jsx
+++ b/src/components/finance/InvoiceTable.jsx
@@ -1,10 +1,11 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Badge, Button, Card, EmptyState, Stat } from '../ui'
-import InvoiceCreateModal from './InvoiceCreateModal'
+import RechnungDialog from '../auftraege/RechnungDialog'
import Pager from './Pager'
import { deleteInvoice, getInvoices, resendInvoice, sendInvoice, voidInvoice } from '../../lib/financeApi'
import { pageOf } from '../../lib/financeFormat'
import { noticeProps } from '../../lib/financeUi'
+import { artLabel, projektSpalte } from '../../lib/projektkontoView'
import {
ADDRESS_INCOMPLETE_TEXT,
DEFAULT_INVOICE_FILTER,
@@ -37,6 +38,8 @@ const CALLS = { send: sendInvoice, resend: resendInvoice, void: voidInvoice, del
* wird jede Zeile ein Raster: Nummer und Status / Kunde / Datum, Fällig / Betrag, Offen / Aktionen. „Senden“ eines Entwurfs
* fragt vorher nach. In der Kundenansicht ist „Senden“ gesperrt, solange dem Kunden die Anschrift fehlt und der
* Entwurf über 250 € liegt (§ 14 Abs. 4 Nr. 1 UStG); `onShowOverview` führt dann im Dialog zur Übersicht.
+ * Die Tabelle zeigt zusätzlich die Spalten Projekt und Art; „Rechnung schreiben“ öffnet den Dialog `RechnungDialog`
+ * (Projektwahl, Arten, Vorschau).
*
* „Offen“ zeigt offene und überfällige Rechnungen (zwei Abrufe, zusammengeführt, neueste zuerst). Ändert sich
* `refreshToken` (die Seite lädt nach Zuordnen, Bankabruf usw. alles neu), lädt auch die Liste neu.
@@ -204,6 +207,8 @@ export default function InvoiceTable({ altersgruppen = null, customerId = null,
Nr.
Kunde
+ Projekt
+ Art
Datum
Fällig
Betrag
@@ -220,6 +225,8 @@ export default function InvoiceTable({ altersgruppen = null, customerId = null,
{row.isAbo && · Abo }
{row.customerText}
+ {projektSpalte(byId[row.id])}
+ {artLabel(byId[row.id]?.art)}
{row.createdText}
{row.dueText}
{row.totalText}
@@ -290,11 +297,11 @@ export default function InvoiceTable({ altersgruppen = null, customerId = null,
- setCreateOpen(false)}
onCreated={handleCreated}
- fixedCustomer={customer}
+ kunde={customer}
onShowOverview={onShowOverview}
/>
diff --git a/src/components/finance/SettingsCard.jsx b/src/components/finance/SettingsCard.jsx
index 4e87f4a..9570475 100644
--- a/src/components/finance/SettingsCard.jsx
+++ b/src/components/finance/SettingsCard.jsx
@@ -13,6 +13,7 @@ import {
settingsToForm,
validateSettingsForm,
} from '../../lib/financeSettings'
+import { STEUERNUMMER_HINWEIS, steuernummerAenderung } from '../../lib/kundenFelder'
const LOAD_ERROR = 'Die Einstellungen konnten nicht geladen werden.'
const fieldId = (name) => `fin-set-${name}`
@@ -44,6 +45,8 @@ export default function SettingsCard() {
function SettingsForm({ initial }) {
const [form, setForm] = useState(() => settingsToForm(initial))
+ const [steuernummer, setSteuernummer] = useState(() => (typeof initial?.steuernummer === 'string' ? initial.steuernummer : ''))
+ const gespeichertRef = useRef(typeof initial?.steuernummer === 'string' ? initial.steuernummer : '')
const [errors, setErrors] = useState({})
const [busy, setBusy] = useState(false)
const [notice, setNotice] = useState(null) // { tone: 'ok' | 'warn', text }
@@ -68,10 +71,12 @@ function SettingsForm({ initial }) {
e.preventDefault()
if (lockRef.current) return
const check = validateSettingsForm(form)
- if (!check.ok) {
- setErrors(check.errors)
+ const stn = steuernummerAenderung(steuernummer, gespeichertRef.current)
+ if (!check.ok || !stn.ok) {
+ const fehler = { ...(check.errors || {}), ...(stn.ok ? {} : { steuernummer: stn.fehler }) }
+ setErrors(fehler)
setNotice({ tone: 'warn', text: 'Bitte die markierten Felder prüfen. Es wurde nichts gespeichert.' })
- const first = SETTINGS_FIELD_ORDER.find((name) => check.errors[name])
+ const first = [...SETTINGS_FIELD_ORDER, 'steuernummer'].find((name) => fehler[name])
if (first) document.getElementById(fieldId(first))?.focus()
return
}
@@ -80,9 +85,12 @@ function SettingsForm({ initial }) {
setErrors({})
setNotice(null)
try {
- const data = await patchSettings(check.payload)
+ const data = await patchSettings({ ...check.payload, ...stn.payload })
if (!aliveRef.current) return
if (data?.einstellungen) setForm(settingsToForm(data.einstellungen))
+ const gespeichert = typeof data?.einstellungen?.steuernummer === 'string' ? data.einstellungen.steuernummer : steuernummer.trim()
+ setSteuernummer(gespeichert)
+ gespeichertRef.current = gespeichert
setNotice({ tone: 'ok', text: 'Gespeichert.' })
} catch (err) {
if (aliveRef.current) setNotice({ tone: 'warn', text: apiErrorMessage(err) })
@@ -148,6 +156,19 @@ function SettingsForm({ initial }) {
{length} / {SETTINGS_LIMITS.maxBankDetails} Zeichen
+
+ {
+ setSteuernummer(e.target.value)
+ setErrors((er) => (er.steuernummer ? { ...er, steuernummer: undefined } : er))
+ if (notice) setNotice(null)
+ }}
+ placeholder="z. B. 12/345/67890 oder DE123456789"
+ />
+
{notice && {notice.text}
}
diff --git a/src/hooks/useAbgerechnet.js b/src/hooks/useAbgerechnet.js
new file mode 100644
index 0000000..934489b
--- /dev/null
+++ b/src/hooks/useAbgerechnet.js
@@ -0,0 +1,21 @@
+import { useEffect, useState } from 'react'
+import { getAbgerechnet } from '../lib/teamApi'
+
+const NICHTS = Object.freeze({})
+
+/**
+ * Arbeitsblatt-Kennung -> Rechnungsnummer oder „Entwurf“ für die Tickets in `ticketIds` (Team-Route, alle Mitarbeiter,
+ * ohne Beträge). Ein Fehler verbirgt nur die Abzeichen, das Arbeitsblatt bleibt bedienbar; der Hook wirft nie.
+ */
+export function useAbgerechnet(ticketIds) {
+ const key = (ticketIds || []).join(',')
+ const [state, setState] = useState({ key: '', map: NICHTS })
+ useEffect(() => {
+ if (!key) return undefined
+ let cancelled = false
+ Promise.all(key.split(',').map((id) => getAbgerechnet(id).then((r) => r?.abgerechnet || NICHTS).catch(() => NICHTS)))
+ .then((maps) => { if (!cancelled) setState({ key, map: Object.assign({}, ...maps) }) })
+ return () => { cancelled = true }
+ }, [key])
+ return state.key === key ? state.map : NICHTS
+}
diff --git a/src/hooks/useRechnungDaten.js b/src/hooks/useRechnungDaten.js
new file mode 100644
index 0000000..a352af5
--- /dev/null
+++ b/src/hooks/useRechnungDaten.js
@@ -0,0 +1,227 @@
+// Hooks des Rechnungsdialogs: Projektliste, Projektkonto, Vorschau, Tickets des Kunden und Arbeitsblätter weiterer Tickets.
+// Überall zählt nur die jüngste Antwort; nach dem Ausbau eines Hooks zählt keine mehr.
+import { useCallback, useEffect, useRef, useState } from 'react'
+import { getInvoices, getProjektkonten, getProjektkonto, getSettings, listAuftraege, previewInvoice } from '../lib/financeApi'
+import { getAbgerechnet } from '../lib/teamApi'
+import { listKundenTickets, listTicketBlaetter } from '../lib/rechnungDialogApi'
+import { blattAusAppwrite, ohneProjektAbDaten } from '../lib/rechnungDialogView'
+import { createRequestGuard } from '../lib/requestGuard'
+
+const VORSCHAU_WARTEZEIT_MS = 350
+
+/**
+ * Lädt eine Ressource zu einem Schlüssel (Kunde oder Projekt). Ohne Schlüssel wird nichts geladen. Die Daten gehören zu
+ * ihrem Schlüssel: nach einem Wechsel ist sofort `data: null` und `loading: true`. `reload()` behält die Daten stehen und
+ * liefert ein Promise, das nie wirft.
+ */
+function useGeladen(laden, schluessel, fallbackError) {
+ const [state, setState] = useState({ key: null, data: null, loading: Boolean(schluessel), error: '' })
+ const guardRef = useRef(null)
+ if (!guardRef.current) guardRef.current = createRequestGuard()
+ const ladenRef = useRef(laden)
+ ladenRef.current = laden
+ const lebtRef = useRef(true)
+
+ const lade = useCallback(
+ async (key) => {
+ const guard = guardRef.current
+ const id = guard.next()
+ setState((s) => (s.key === key ? { ...s, loading: true, error: '' } : { key, data: null, loading: true, error: '' }))
+ const gilt = () => lebtRef.current && guard.isCurrent(id)
+ try {
+ const data = await ladenRef.current()
+ if (gilt()) setState({ key, data: data && typeof data === 'object' ? data : {}, loading: false, error: '' })
+ } catch (err) {
+ if (gilt()) setState((s) => ({ ...s, key, loading: false, error: err?.message || fallbackError }))
+ }
+ },
+ [fallbackError]
+ )
+
+ useEffect(() => {
+ lebtRef.current = true
+ if (!schluessel) {
+ guardRef.current.next()
+ setState({ key: null, data: null, loading: false, error: '' })
+ } else {
+ lade(schluessel)
+ }
+ return () => {
+ lebtRef.current = false
+ }
+ }, [schluessel, lade])
+
+ const reload = useCallback(() => (schluessel ? lade(schluessel) : Promise.resolve()), [schluessel, lade])
+ const passt = Boolean(schluessel) && state.key === schluessel
+ return {
+ data: passt ? state.data : null,
+ loading: Boolean(schluessel) && (!passt || state.loading),
+ error: passt ? state.error : '',
+ reload,
+ }
+}
+
+/** Finanzeinstellungen, einmal geladen: undefined = lädt, null = nicht ladbar, sonst das Objekt `einstellungen`. */
+export function useEinstellungen() {
+ const [einstellungen, setEinstellungen] = useState(undefined)
+ useEffect(() => {
+ let aktuell = true
+ getSettings().then(
+ (d) => aktuell && setEinstellungen(d?.einstellungen ?? null),
+ () => aktuell && setEinstellungen(null)
+ )
+ return () => {
+ aktuell = false
+ }
+ }, [])
+ return einstellungen
+}
+
+/** Projektkonten eines Kunden: { auftraegeAktiv, kunde, konten, ohneProjekt, stripeOk }. */
+export function useProjektkonten(kundeId) {
+ return useGeladen(() => getProjektkonten(kundeId), kundeId, 'Die Projekte konnten nicht geladen werden.')
+}
+
+/** Detail eines Projektkontos: { konto, auftraege, rechnungen, offeneArbeitsblaetter, stripeOk }. */
+export function useProjektkontoDetail(projektId) {
+ return useGeladen(() => getProjektkonto(projektId), projektId, 'Das Projektkonto konnte nicht geladen werden.')
+}
+
+/**
+ * ABs ohne Projekt und ihre Rechnungen für „Ohne Projekt“ im Rechnungsdialog: { auftraege, rechnungen }. Geladen wird nur,
+ * wenn `aktiv` wahr ist (Schalter an und „Ohne Projekt“ gewählt).
+ */
+export function useOhneProjektAbs(kundeId, aktiv) {
+ return useGeladen(
+ async () => {
+ const [abs, rechnungen] = await Promise.all([listAuftraege(kundeId), getInvoices({ status: 'alle', kunde: kundeId })])
+ return ohneProjektAbDaten(abs, rechnungen)
+ },
+ aktiv ? kundeId : '',
+ 'Die Aufträge konnten nicht geladen werden.'
+ )
+}
+
+/**
+ * Vorschau der Rechnung zum Körper der Anfrage. `vorschau = { key, status: 'laedt' | 'ok' | 'fehler', daten?, message?, httpStatus? }`.
+ * Der Körper wird über einen Ref gelesen, der Effekt hängt nur an [aktiv, schluessel]; nach 350 ms Ruhe geht die Anfrage raus.
+ */
+export function useRechnungVorschau({ anfrage, schluessel, aktiv }) {
+ const [vorschau, setVorschau] = useState(null)
+ const bodyRef = useRef(null)
+ bodyRef.current = anfrage?.ok ? anfrage.body : null
+ const datenRef = useRef(null)
+ const kontextRef = useRef('')
+
+ useEffect(() => {
+ if (!aktiv || !bodyRef.current || !schluessel) {
+ datenRef.current = null
+ kontextRef.current = ''
+ return undefined
+ }
+ // Vorherige Zeilen bleiben nur innerhalb derselben Rechnung (Kunde, Art, Projekt, AB) abgeblendet stehen
+ const { customerId, art, projektId, auftragId } = bodyRef.current
+ const kontext = JSON.stringify([customerId, art, projektId, auftragId])
+ if (kontext !== kontextRef.current) datenRef.current = null
+ kontextRef.current = kontext
+ let aktuell = true
+ setVorschau({ key: schluessel, status: 'laedt', daten: datenRef.current })
+ const timer = setTimeout(async () => {
+ try {
+ const daten = await previewInvoice(bodyRef.current)
+ if (!aktuell) return
+ datenRef.current = daten
+ setVorschau({ key: schluessel, status: 'ok', daten })
+ } catch (err) {
+ if (!aktuell) return
+ setVorschau({
+ key: schluessel,
+ status: 'fehler',
+ message: err?.message || 'Die Vorschau konnte nicht geladen werden.',
+ httpStatus: err?.status,
+ daten: datenRef.current,
+ })
+ }
+ }, VORSCHAU_WARTEZEIT_MS)
+ return () => {
+ aktuell = false
+ clearTimeout(timer)
+ }
+ }, [aktiv, schluessel])
+
+ return vorschau
+}
+
+/** Tickets des Kunden (einmal je Kunde, sobald `aktiv` wahr ist). */
+export function useKundenTickets(kundeId, aktiv) {
+ const [state, setState] = useState({ key: null, tickets: [], loading: false, error: '' })
+ useEffect(() => {
+ if (!aktiv || !kundeId) return undefined
+ let aktuell = true
+ setState({ key: kundeId, tickets: [], loading: true, error: '' })
+ listKundenTickets(kundeId).then(
+ (tickets) => aktuell && setState({ key: kundeId, tickets, loading: false, error: '' }),
+ () => aktuell && setState({ key: kundeId, tickets: [], loading: false, error: 'Die Tickets konnten nicht geladen werden.' })
+ )
+ return () => {
+ aktuell = false
+ }
+ }, [kundeId, aktiv])
+ const passt = state.key === kundeId
+ return { tickets: passt ? state.tickets : [], loading: Boolean(aktiv && kundeId) && (!passt || state.loading), error: passt ? state.error : '' }
+}
+
+/**
+ * Arbeitsblätter weiterer Tickets: je Ticket einmal Blätter und Abgerechnet-Liste laden.
+ * Ergebnis: { [ticketId]: { status: 'laedt' | 'ok' | 'fehler', blaetter, message } }; entfernte Tickets verschwinden.
+ */
+export function useWeitereBlaetter(weitereTickets) {
+ const [ergebnis, setErgebnis] = useState({})
+ const angefragtRef = useRef(new Set())
+ const lebtRef = useRef(true)
+
+ useEffect(() => {
+ lebtRef.current = true
+ return () => {
+ lebtRef.current = false
+ }
+ }, [])
+
+ useEffect(() => {
+ const liste = Array.isArray(weitereTickets) ? weitereTickets : []
+ const ids = new Set(liste.map((t) => t.ticketId))
+ for (const id of [...angefragtRef.current]) if (!ids.has(id)) angefragtRef.current.delete(id)
+ for (const ticket of liste) {
+ if (angefragtRef.current.has(ticket.ticketId)) continue
+ angefragtRef.current.add(ticket.ticketId)
+ const gilt = () => lebtRef.current && angefragtRef.current.has(ticket.ticketId)
+ setErgebnis((r) => ({ ...r, [ticket.ticketId]: { status: 'laedt', blaetter: [], message: '' } }))
+ Promise.all([listTicketBlaetter(ticket.woid), getAbgerechnet(ticket.ticketId)]).then(
+ ([dokumente, antwort]) => {
+ if (!gilt()) return
+ const blaetter = dokumente
+ .map((ws) => blattAusAppwrite(ws, antwort?.abgerechnet))
+ .filter(Boolean)
+ .sort((a, b) => String(a.datum).localeCompare(String(b.datum)) || String(a.id).localeCompare(String(b.id)))
+ setErgebnis((r) => ({ ...r, [ticket.ticketId]: { status: 'ok', blaetter, message: '' } }))
+ },
+ () => {
+ if (!gilt()) return
+ setErgebnis((r) => ({
+ ...r,
+ [ticket.ticketId]: { status: 'fehler', blaetter: [], message: `Die Arbeitsblätter von #${ticket.woid} konnten nicht geladen werden.` },
+ }))
+ }
+ )
+ }
+ setErgebnis((r) => {
+ const keys = Object.keys(r).filter((k) => !ids.has(k))
+ if (keys.length === 0) return r
+ const rest = { ...r }
+ for (const k of keys) delete rest[k]
+ return rest
+ })
+ }, [weitereTickets])
+
+ return ergebnis
+}
diff --git a/src/lib/auftraegeAnsicht.js b/src/lib/auftraegeAnsicht.js
new file mode 100644
index 0000000..a643356
--- /dev/null
+++ b/src/lib/auftraegeAnsicht.js
@@ -0,0 +1,128 @@
+// Ansichtslogik des Reiters „Aufträge und Rechnungen“ (rein, ohne React und ohne Appwrite; unter Node testbar).
+// Die API-Funktionen kommen als Argument. Der Container (AuftraegeUndRechnungen.jsx) verdrahtet diese Funktionen mit
+// useFinanceResource und den Abschnitten des Reiters.
+import { invoiceDoneMessage } from './financeView'
+
+export const KONTEN_FEHLER = 'Die Projektübersicht konnte nicht geladen werden.'
+export const AUFTRAEGE_FEHLER = 'Die Aufträge konnten nicht geladen werden.'
+
+const istObjekt = (wert) => wert !== null && typeof wert === 'object' && !Array.isArray(wert)
+const ohneProjektLeer = () => ({ berechnetCents: 0, bezahltCents: 0, anzahl: 0 })
+
+/**
+ * Lädt Projektkonten und, nur bei eingeschaltetem Schalter (`auftraegeAktiv === true`), die Aufträge des Kunden.
+ * Scheitert nur listAuftraege, bleiben die Projektkonten und der Fehler steht eigens da.
+ * Scheitert getProjektkonten, wird die Auftragsliste trotzdem geholt (der Server prüft den Schalter ohnehin und antwortet bei
+ * „aus“ mit 404): kommt sie, bleibt der Abschnitt Aufträge bedienbar und der Fehler steht als `kontenFehler` daneben;
+ * kommt sie nicht, geht der Fehler der Projektkonten weiter.
+ * -> { konten, auftraege: Liste | null, auftraegeFehler: Text | '', kontenFehler?: Text (nur bei ausgefallenen Projektkonten) }
+ */
+export async function ladeAuftraegeAnsicht(kundeId, { getProjektkonten, listAuftraege }) {
+ let antwort
+ try {
+ antwort = await getProjektkonten(kundeId)
+ } catch (kontenErr) {
+ let liste
+ try {
+ liste = await listAuftraege(kundeId)
+ } catch {
+ throw kontenErr
+ }
+ return {
+ konten: {},
+ kontenFehler: (kontenErr && kontenErr.message) || KONTEN_FEHLER,
+ auftraege: Array.isArray(liste?.auftraege) ? liste.auftraege : [],
+ auftraegeFehler: '',
+ }
+ }
+ const konten = istObjekt(antwort) ? antwort : {}
+ if (konten.auftraegeAktiv !== true) return { konten, auftraege: null, auftraegeFehler: '' }
+ try {
+ const liste = await listAuftraege(kundeId)
+ return { konten, auftraege: Array.isArray(liste?.auftraege) ? liste.auftraege : [], auftraegeFehler: '' }
+ } catch (err) {
+ return { konten, auftraege: null, auftraegeFehler: (err && err.message) || AUFTRAEGE_FEHLER }
+ }
+}
+
+/**
+ * Zustand der Ansicht aus dem Ergebnis von useFinanceResource ({ data, error }).
+ * -> { projekteStatus: 'laden' | 'fehler' | 'bereit', fehler, nachladeFehler,
+ * auftraegeAktiv, auftraegeStatus: 'aus' | 'bereit' | 'fehler', auftraegeFehler,
+ * konten, ohneProjekt, stripeOk, kunde, auftraege }
+ * Ohne Daten gibt es keinen Aufträge-Abschnitt (der Schalter ist noch unbekannt). Scheitert das Neuladen, nachdem Daten
+ * da sind, bleiben die alten Daten stehen und die Meldung kommt als `nachladeFehler` daneben. Waren die Projektkonten
+ * ausgefallen und die Aufträge trotzdem da (`data.kontenFehler`), ist `projekteStatus` „fehler“ und die Aufträge sind bereit.
+ */
+export function ansichtsZustand({ data, error } = {}) {
+ if (!data) {
+ return {
+ projekteStatus: error ? 'fehler' : 'laden',
+ fehler: error || '',
+ nachladeFehler: '',
+ auftraegeAktiv: false,
+ auftraegeStatus: 'aus',
+ auftraegeFehler: '',
+ konten: [],
+ ohneProjekt: ohneProjektLeer(),
+ stripeOk: true,
+ kunde: null,
+ auftraege: [],
+ }
+ }
+ const k = istObjekt(data.konten) ? data.konten : {}
+ const kontenFehler = typeof data.kontenFehler === 'string' ? data.kontenFehler : ''
+ // Mit ausgefallenen Projektkonten gilt der Schalter als an, wenn der Server die Auftragsliste geliefert hat
+ const auftraegeAktiv = kontenFehler ? Array.isArray(data.auftraege) : k.auftraegeAktiv === true
+ const auftraegeStatus = !auftraegeAktiv ? 'aus' : data.auftraegeFehler ? 'fehler' : 'bereit'
+ return {
+ projekteStatus: kontenFehler ? 'fehler' : 'bereit',
+ fehler: kontenFehler ? error || kontenFehler : '',
+ nachladeFehler: kontenFehler ? '' : error || '',
+ auftraegeAktiv,
+ auftraegeStatus,
+ auftraegeFehler: auftraegeStatus === 'fehler' ? data.auftraegeFehler : '',
+ konten: Array.isArray(k.konten) ? k.konten : [],
+ ohneProjekt: istObjekt(k.ohneProjekt) ? k.ohneProjekt : ohneProjektLeer(),
+ stripeOk: k.stripeOk !== false,
+ kunde: istObjekt(k.kunde) ? k.kunde : null,
+ auftraege: auftraegeAktiv && Array.isArray(data.auftraege) ? data.auftraege : [],
+ }
+}
+
+// Wertebereich von customers.kundenart (V1); bewusst lokal, auftragView.js gehört dem Strang W2
+const KUNDENARTEN = ['unternehmen', 'privat']
+
+/**
+ * Kundendokument mit der frischen Kundenart des Portals (getProjektkonten -> kunde.kundenart). Gilt nur ein gültiger Wert
+ * („unternehmen“ oder „privat“), kommt eine Kopie mit diesem Wert zurück, das Dokument der Seite bleibt unverändert.
+ * Sonst dieselbe Referenz (null bleibt null). So fragt der AB-Dialog nicht erneut, wenn die Kundenart eben gespeichert wurde
+ * und die Kundenseite noch den alten Datensatz hält.
+ */
+export function kundeMitKundenart(customer, kundeInfo) {
+ const art = istObjekt(kundeInfo) ? kundeInfo.kundenart : undefined
+ if (!customer || !KUNDENARTEN.includes(art)) return customer
+ return { ...customer, kundenart: art }
+}
+
+/**
+ * Rückmeldung nach dem Anlegen einer Rechnung (wie InvoiceTable.handleCreated): `info.notice` (festgeschrieben, aber nicht
+ * versendet) gewinnt mit seinem Ton, sonst Entwurf oder gesendet. `info` darf fehlen oder null sein. -> { tone, text }
+ */
+export function erstelltMeldung(rechnung, info) {
+ const i = istObjekt(info) ? info : {}
+ if (typeof i.notice === 'string' && i.notice) return { tone: i.tone || 'ok', text: i.notice }
+ return { tone: 'ok', text: invoiceDoneMessage(i.senden ? 'create-send' : 'create-draft', rechnung) }
+}
+
+/** Rückmeldung nach dem Speichern oder Senden einer Auftragsbestätigung (onSaved des AB-Dialogs). -> { tone, text } */
+export function auftragMeldung(auftrag) {
+ const a = istObjekt(auftrag) ? auftrag : {}
+ const name = a.nummer ? `Auftragsbestätigung ${a.nummer}` : 'Die Auftragsbestätigung'
+ if (a.status === 'entwurf') return { tone: 'ok', text: 'Der Entwurf wurde gespeichert.' }
+ if (a.mailStatus === 'fehler') {
+ return { tone: 'warn', text: `${name} ist versendet, die Mail an den Kunden wurde aber nicht zugestellt. Bitte in der Liste erneut senden.` }
+ }
+ if (a.nummer) return { tone: 'ok', text: `${name} wurde${a.gesendetAn ? ` an ${a.gesendetAn}` : ''} gesendet.` }
+ return { tone: 'ok', text: 'Die Auftragsbestätigung wurde gespeichert.' }
+}
diff --git a/src/lib/auftraegeEndpoints.js b/src/lib/auftraegeEndpoints.js
new file mode 100644
index 0000000..a0f6b54
--- /dev/null
+++ b/src/lib/auftraegeEndpoints.js
@@ -0,0 +1,52 @@
+// Pfade und Methoden der neuen Portal-Aufrufe für Projektkonten, Auftragsbestätigungen (AB) und die Rechnungsvorschau
+// (V5/V8 des Vorhabens „Aufträge und Rechnungen“). Rein (ohne Appwrite-Import): `request` kommt aus createFinanceRequest
+// (financeRequest.js) und wird injiziert, damit Pfade, Methoden und Körper unter Node mit einer Fetch-Attrappe testbar sind.
+// financeApi.js verdrahtet die Funktionen mit dem Appwrite-JWT. Alle Pfade gelten relativ zu /api/admin/finanzen.
+
+const enc = encodeURIComponent
+
+/**
+ * Pfad der abgerechneten Arbeitsblätter eines Tickets, relativ zu /api/team (teamApi.js: getAbgerechnet).
+ * Antwort: { abgerechnet: { : '' | 'Entwurf' } }
+ */
+export const abgerechnetPfad = (ticketId) => `/arbeitsblaetter/abgerechnet?ticketId=${enc(ticketId)}`
+
+/** createAuftraegeEndpoints(request) -> die 14 Funktionen von V8 (jede Kennung wird URL-kodiert). */
+export function createAuftraegeEndpoints(request) {
+ const post = (path, body) => request(path, { method: 'POST', ...(body === undefined ? {} : { body }) })
+
+ return {
+ // --- Projektkonten ---
+ /** { auftraegeAktiv, kunde, konten, ohneProjekt, stripeOk } */
+ getProjektkonten: (kundeId) => request(`/projektkonten?kunde=${enc(kundeId)}`),
+ /** { konto, auftraege, rechnungen, offeneArbeitsblaetter, stripeOk } */
+ getProjektkonto: (projektId) => request(`/projektkonten/${enc(projektId)}`),
+
+ // --- Rechnungsvorschau (ohne Schreibzugriff, ohne Schlüssel) ---
+ /** { positionen, summeCents, leistungszeitraum, customFields } */
+ previewInvoice: (body) => post('/rechnungen/vorschau', body),
+
+ // --- Auftragsbestätigungen ---
+ /** { auftraege } (404, wenn der Schalter AUFTRAEGE_ENABLED für den Kunden aus ist) */
+ listAuftraege: (kundeId) => request(`/auftraege?kunde=${enc(kundeId)}`),
+ getAuftrag: (id) => request(`/auftraege/${enc(id)}`),
+ /** { auftrag } */
+ createAuftrag: (body) => post('/auftraege', body),
+ /** { auftrag } */
+ patchAuftrag: (id, body) => request(`/auftraege/${enc(id)}`, { method: 'PATCH', body }),
+ /** { ok: true } */
+ deleteAuftrag: (id) => request(`/auftraege/${enc(id)}`, { method: 'DELETE' }),
+ /** { auftrag, mailFehler } */
+ sendAuftrag: (id) => post(`/auftraege/${enc(id)}/senden`),
+ /** { auftrag, mailFehler } */
+ erinnereAuftrag: (id) => post(`/auftraege/${enc(id)}/erinnern`),
+ /** { auftrag } (neuer Entwurf) */
+ neueFassungAuftrag: (id) => post(`/auftraege/${enc(id)}/neue-fassung`),
+ /** { auftrag } */
+ storniereAuftrag: (id) => post(`/auftraege/${enc(id)}/stornieren`),
+ /** { auftrag }; 409, wenn die AB schon ein Projekt hat oder storniert, ersetzt oder widerrufen ist */
+ ordneAuftragProjektZu: (id, projektId) => post(`/auftraege/${enc(id)}/projekt`, { projektId }),
+ /** PDF der AB (gesendet: gespeicherte Datei, Entwurf: Vorschau) als Blob */
+ getAuftragPdf: (id) => request(`/auftraege/${enc(id)}/pdf`, { as: 'blob' }),
+ }
+}
diff --git a/src/lib/auftragPdf.js b/src/lib/auftragPdf.js
new file mode 100644
index 0000000..3a3f1f4
--- /dev/null
+++ b/src/lib/auftragPdf.js
@@ -0,0 +1,23 @@
+// PDF der Auftragsbestaetigung in einem neuen Tab oeffnen. Rein (ohne React), damit unter Node testbar. Der Tab geht
+// synchron im Klick auf (sonst blockiert der Browser das Pop-up), das PDF kommt erst nach der Anfrage ans Portal.
+import { openInNewTab } from './openInNewTab'
+
+/** Objekt-URLs bleiben fuenf Minuten gueltig, dann werden sie freigegeben. */
+export const PDF_FREIGABE_NACH_MS = 300000
+
+/** Blob mit dem Typ application/pdf (damit der Browser es anzeigt); ein passender Blob bleibt unveraendert. */
+export function alsPdfBlob(blob) {
+ return blob.type === 'application/pdf' ? blob : new Blob([blob], { type: 'application/pdf' })
+}
+
+/** holeBlob: () => Promise. Fehler und ein blockiertes Pop-up behandelt openInNewTab (Tab zu, Fehler weitergereicht). */
+export async function oeffnePdfImTab(holeBlob, { win = globalThis.window, urlApi = globalThis.URL, timer = globalThis.setTimeout } = {}) {
+ await openInNewTab(
+ async () => {
+ const url = urlApi.createObjectURL(alsPdfBlob(await holeBlob()))
+ timer(() => urlApi.revokeObjectURL(url), PDF_FREIGABE_NACH_MS)
+ return url
+ },
+ { win }
+ )
+}
diff --git a/src/lib/auftragView.js b/src/lib/auftragView.js
new file mode 100644
index 0000000..834d516
--- /dev/null
+++ b/src/lib/auftragView.js
@@ -0,0 +1,650 @@
+// Reine Ansichtslogik der Auftragsbestaetigungen (AB) in WOMS: Eingaben pruefen, Cent und Minuten, Formular <-> Anfrage,
+// Zahlungsplan-Vorschau, Hinweise, Status und Aktionen der Liste, Meldungen und Fehlerklassen. Kein React, kein Appwrite,
+// unter Node testbar. Betraege sind ganze Cent, in Formularen Euro-Texte (de-DE); Texte deutsch und sachlich (keine Anrede).
+// Die Grenzen entsprechen dem Vertrag V2 (LIMITS) der Uebersicht, der Server entscheidet.
+import { canInvoice, dateDe, euro, ymdBerlin } from './financeFormat'
+import { ADDRESS_INCOMPLETE_TEXT, addressComplete, invoiceTotalCents, parseQuantity, parseUnitPrice } from './financeView'
+import { euroField } from './financeSettings'
+import { ticketNumber } from './ticketView'
+import { KUNDENARTEN, STEUERNUMMER_FEHLT_HINWEIS, steuernummerFehlt } from './kundenFelder'
+
+// --- Konstanten --------------------------------------------------------------------------------------------------------
+
+export const AUFTRAG_LIMITS = {
+ maxPositionen: 20,
+ maxBeschreibung: 200,
+ maxMenge: 999,
+ maxEinzelpreisCents: 10_000_000,
+ maxSummeCents: 99_999_999,
+ maxTitel: 200,
+ maxZahlungsplanText: 500,
+ maxZahlungszielTage: 60,
+ standardZahlungszielTage: 14,
+ standardAnzahlungProzent: 50,
+ maxZeitquellen: 10,
+ maxSchaetzungMinuten: 99_960,
+ maxLeistungszeitraum: 140,
+}
+
+export const STUNDENSATZ_STANDARD_CENTS = 10000
+
+export const AUFTRAG_WEGE = [
+ { value: 'muendlich', label: 'Mündlich' },
+ { value: 'telefon', label: 'Telefonisch' },
+ { value: 'email', label: 'Per E-Mail' },
+ { value: 'vor_ort', label: 'Vor Ort' },
+ { value: 'chat', label: 'Im Chat' },
+]
+
+export const ZAHLUNGSPLAENE = {
+ nach_abnahme: 'Alles nach Abnahme',
+ anzahlung: 'Anzahlung bei Auftrag, Rest nach Abnahme',
+ monatlich: 'Monatlich nach Aufwand',
+ frei: 'Frei (eigener Text)',
+}
+
+export const SCHRITTE = [
+ { id: 1, label: 'Projekt' },
+ { id: 2, label: 'Auftrag' },
+ { id: 3, label: 'Zahlung und Termine' },
+ { id: 4, label: 'Vorschau' },
+]
+
+const ARTEN = ['festpreis', 'aufwand']
+
+/** Auswahl des Zahlungsplans je Art: „Anzahlung“ nur beim Festpreis, „Monatlich“ nur nach Aufwand. */
+export function zahlungsplanOptionen(art) {
+ const werte = art === 'aufwand' ? ['nach_abnahme', 'monatlich', 'frei'] : ['nach_abnahme', 'anzahlung', 'frei']
+ return werte.map((value) => ({ value, label: ZAHLUNGSPLAENE[value] }))
+}
+
+// --- Hilfen ------------------------------------------------------------------------------------------------------------
+
+const text = (v) => (typeof v === 'string' ? v : '')
+const trimmed = (v) => text(v).trim()
+const istObjekt = (v) => Boolean(v) && typeof v === 'object' && !Array.isArray(v)
+const objekte = (v) => (Array.isArray(v) ? v.filter(istObjekt) : [])
+
+/** 'YYYY-MM-DD' und ein wirklich existierender Tag. */
+function istTag(wert) {
+ return /^\d{4}-\d{2}-\d{2}$/.test(text(wert)) && dateDe(wert) !== '–'
+}
+
+// --- Eingaben ----------------------------------------------------------------------------------------------------------
+
+/** Stunden mit Komma in 15-Minuten-Schritten („10“, „12,5“, „2 Std.“) -> Minuten; ungueltig, leer oder 0 -> null. */
+export function parseStunden(eingabe) {
+ const s = trimmed(eingabe).replace(/\s*(std\.?|h)$/i, '')
+ const m = /^(\d{1,4})(?:,(\d{1,2}))?$/.exec(s)
+ if (!m) return null
+ const hundertstel = Number(m[1]) * 60 * 100 + Number((m[2] || '').padEnd(2, '0') || 0) * 60
+ if (hundertstel % 100 !== 0) return null
+ const minuten = hundertstel / 100
+ if (!(minuten > 0) || minuten % 15 !== 0 || minuten > AUFTRAG_LIMITS.maxSchaetzungMinuten) return null
+ return minuten
+}
+
+/** Minuten -> Stunden mit Komma ohne ueberfluessige Nullen (150 -> „2,5“); keine Zahl -> „–“. */
+export function stundenText(minuten) {
+ if (typeof minuten !== 'number' || !Number.isFinite(minuten)) return '–'
+ return String(Number((minuten / 60).toFixed(2))).replace('.', ',')
+}
+
+/** „50“ oder „30 %“ -> 1..99, sonst null. */
+export function parseProzent(eingabe) {
+ const s = trimmed(eingabe).replace(/\s*%$/, '')
+ if (!/^\d{1,2}$/.test(s)) return null
+ const n = Number(s)
+ return n >= 1 && n <= 99 ? n : null
+}
+
+/** Zahlungsziel in Tagen 0..60, sonst null. */
+export function parseTage(eingabe) {
+ const s = trimmed(eingabe)
+ if (!/^\d{1,3}$/.test(s)) return null
+ const n = Number(s)
+ return n <= AUFTRAG_LIMITS.maxZahlungszielTage ? n : null
+}
+
+/** Berliner Kalendertag („YYYY-MM-DD“), nicht der UTC-Tag. */
+export function heuteBerlin(now = Date.now()) {
+ return ymdBerlin(now)
+}
+
+// --- Formular ----------------------------------------------------------------------------------------------------------
+
+let naechsteUid = 1
+
+/** Leere Position des Formulars; `uid` ist nur der React-Schluessel. */
+export function neuePosition() {
+ const uid = naechsteUid
+ naechsteUid += 1
+ return { uid, description: '', quantity: '1', price: '' }
+}
+
+const kontoMit = (konten, projektId) =>
+ projektId && Array.isArray(konten) ? objekte(konten).find((k) => k.projektId === projektId) || null : null
+
+const ticketIds = (konto) => (konto && Array.isArray(konto.tickets) ? konto.tickets.map((t) => t?.id).filter((id) => typeof id === 'string' && id) : [])
+
+/** Neues Formular; ein vorgewaehltes Projekt fuellt Titel und Zeitquelle. */
+export function neuesFormular({ projektId = '', konten = [], heute = heuteBerlin() } = {}) {
+ const konto = kontoMit(konten, projektId)
+ return {
+ projektId: konto ? konto.projektId : '',
+ titel: konto ? text(konto.name) : '',
+ titelGeaendert: false,
+ kundenart: '',
+ art: 'festpreis',
+ auftragVom: heute,
+ auftragWeg: '',
+ positionen: [neuePosition()],
+ stundensatz: euroField(STUNDENSATZ_STANDARD_CENTS),
+ schaetzung: '',
+ zahlungsart: 'nach_abnahme',
+ prozent: String(AUFTRAG_LIMITS.standardAnzahlungProzent),
+ zahlungstext: '',
+ zahlungsziel: String(AUFTRAG_LIMITS.standardZahlungszielTage),
+ startAm: '',
+ fertigBis: '',
+ zeitquelle: ticketIds(konto),
+ zeitquelleGeaendert: false,
+ }
+}
+
+/** Entwurf vom Server -> Formular (Euro- und Stundentexte). Titel und Zeitquelle gelten als von Hand gesetzt. */
+export function auftragZuFormular(auftrag) {
+ const a = istObjekt(auftrag) ? auftrag : {}
+ const positionen = objekte(a.positionen).map((p) => ({
+ uid: neuePosition().uid,
+ description: text(p.beschreibung),
+ quantity: String(p.menge ?? ''),
+ price: euroField(p.einzelpreisCents),
+ }))
+ const plan = istObjekt(a.zahlungsplan) ? a.zahlungsplan : {}
+ return {
+ projektId: text(a.projektId),
+ titel: text(a.titel),
+ titelGeaendert: true,
+ kundenart: '',
+ art: ARTEN.includes(a.art) ? a.art : 'festpreis',
+ auftragVom: text(a.auftragVom),
+ auftragWeg: text(a.auftragWeg),
+ positionen: positionen.length ? positionen : [neuePosition()],
+ stundensatz: euroField(typeof a.stundensatzCents === 'number' ? a.stundensatzCents : STUNDENSATZ_STANDARD_CENTS),
+ schaetzung: typeof a.schaetzungMinuten === 'number' ? stundenText(a.schaetzungMinuten) : '',
+ zahlungsart: text(plan.art) || 'nach_abnahme',
+ prozent: typeof plan.prozent === 'number' ? String(plan.prozent) : String(AUFTRAG_LIMITS.standardAnzahlungProzent),
+ zahlungstext: text(plan.text),
+ zahlungsziel: String(typeof a.zahlungszielTage === 'number' ? a.zahlungszielTage : AUFTRAG_LIMITS.standardZahlungszielTage),
+ startAm: text(a.startAm),
+ fertigBis: text(a.fertigBis),
+ zeitquelle: Array.isArray(a.zeitquelle) ? a.zeitquelle.filter((id) => typeof id === 'string') : [],
+ zeitquelleGeaendert: true,
+ }
+}
+
+/** Projekt waehlen: Titel und Zeitquelle folgen dem Projekt, solange sie nicht von Hand geaendert wurden. */
+export function waehleProjekt(form, projektId, konten) {
+ const konto = kontoMit(konten, projektId)
+ const neu = { ...form, projektId: konto ? konto.projektId : '' }
+ if (!form.titelGeaendert) neu.titel = konto ? text(konto.name) : ''
+ if (!form.zeitquelleGeaendert) neu.zeitquelle = ticketIds(konto)
+ return neu
+}
+
+/** Art wechseln; ein unpassender Zahlungsplan wird zu „Alles nach Abnahme“. */
+export function wechselArt(form, art) {
+ if (!ARTEN.includes(art)) return form
+ const passt = zahlungsplanOptionen(art).some((o) => o.value === form.zahlungsart)
+ return { ...form, art, zahlungsart: passt ? form.zahlungsart : 'nach_abnahme' }
+}
+
+/** Summe der gueltigen Positionen in Cent (Festpreis), bei Aufwand null. */
+export function formularSummeCents(form) {
+ return form?.art === 'festpreis' ? invoiceTotalCents(form.positionen) : null
+}
+
+// --- Pruefen -----------------------------------------------------------------------------------------------------------
+
+const MELDUNG = {
+ titel: `Bitte einen Titel eingeben (höchstens ${AUFTRAG_LIMITS.maxTitel} Zeichen).`,
+ kundenart: 'Bitte die Kundenart wählen.',
+ auftragVom: 'Bitte das Datum der Absprache angeben.',
+ auftragWeg: 'Bitte den Weg der Absprache wählen.',
+ art: 'Bitte die Art des Auftrags wählen.',
+ anzahl: `Eine Auftragsbestätigung braucht 1 bis ${AUFTRAG_LIMITS.maxPositionen} Positionen.`,
+ menge: `Die Menge muss eine ganze Zahl von 1 bis ${AUFTRAG_LIMITS.maxMenge} sein.`,
+ preis: 'Der Einzelpreis muss ein Betrag zwischen 0,01 € und 100.000,00 € sein (z. B. 120,00).',
+ summe: `Die Summe ist zu hoch (höchstens ${euro(AUFTRAG_LIMITS.maxSummeCents)}).`,
+ stundensatz: 'Der Stundensatz muss ein Betrag zwischen 0,01 € und 100.000,00 € sein (z. B. 100,00).',
+ schaetzung: `Die Schätzung geht in Schritten von 15 Minuten (z. B. 10 oder 12,5 Stunden, höchstens ${AUFTRAG_LIMITS.maxSchaetzungMinuten / 60}).`,
+ zahlungsart: 'Dieser Zahlungsplan passt nicht zur Art des Auftrags.',
+ prozent: 'Die Anzahlung muss eine ganze Zahl von 1 bis 99 Prozent sein.',
+ zahlungstext: `Bitte den Zahlungsplan als Text eingeben (höchstens ${AUFTRAG_LIMITS.maxZahlungsplanText} Zeichen).`,
+ zahlungsziel: `Das Zahlungsziel muss eine ganze Zahl von 0 bis ${AUFTRAG_LIMITS.maxZahlungszielTage} Tagen sein.`,
+ datum: 'Bitte ein gültiges Datum wählen.',
+ fertigVorStart: 'Die Fertigstellung liegt vor dem Start.',
+ zeitquelle: `Höchstens ${AUFTRAG_LIMITS.maxZeitquellen} Tickets als Zeitquelle.`,
+}
+
+function positionenFehler(positionen) {
+ const liste = objekte(positionen)
+ if (liste.length < 1 || liste.length > AUFTRAG_LIMITS.maxPositionen) return MELDUNG.anzahl
+ for (let i = 0; i < liste.length; i += 1) {
+ const p = liste[i]
+ const vor = `Position ${i + 1}: `
+ const beschreibung = trimmed(p.description)
+ if (!beschreibung) return `${vor}Bitte eine Beschreibung eingeben.`
+ if (beschreibung.length > AUFTRAG_LIMITS.maxBeschreibung) return `${vor}Die Beschreibung darf höchstens ${AUFTRAG_LIMITS.maxBeschreibung} Zeichen lang sein.`
+ if (parseQuantity(p.quantity) === null) return `${vor}${MELDUNG.menge}`
+ if (parseUnitPrice(p.price) === null) return `${vor}${MELDUNG.preis}`
+ }
+ if (invoiceTotalCents(liste) > AUFTRAG_LIMITS.maxSummeCents) return MELDUNG.summe
+ return ''
+}
+
+/** Prueft einen Schritt (1 bis 3). Ergebnis { ok, fehler: { feld: Meldung }, erstes } (erstes = erstes fehlerhaftes Feld). */
+export function pruefeSchritt(schritt, form, { kunde } = {}) {
+ const f = istObjekt(form) ? form : {}
+ const fehler = {}
+ if (schritt === 1) {
+ const titel = trimmed(f.titel)
+ if (titel.length < 1 || titel.length > AUFTRAG_LIMITS.maxTitel) fehler.titel = MELDUNG.titel
+ if (!trimmed(kunde?.kundenart) && !KUNDENARTEN.includes(f.kundenart)) fehler.kundenart = MELDUNG.kundenart
+ } else if (schritt === 2) {
+ if (!istTag(f.auftragVom)) fehler.auftragVom = MELDUNG.auftragVom
+ if (!AUFTRAG_WEGE.some((w) => w.value === f.auftragWeg)) fehler.auftragWeg = MELDUNG.auftragWeg
+ if (!ARTEN.includes(f.art)) {
+ fehler.art = MELDUNG.art
+ } else if (f.art === 'festpreis') {
+ const meldung = positionenFehler(f.positionen)
+ if (meldung) fehler.positionen = meldung
+ } else {
+ if (parseUnitPrice(f.stundensatz) === null) fehler.stundensatz = MELDUNG.stundensatz
+ if (trimmed(f.schaetzung) !== '' && parseStunden(f.schaetzung) === null) fehler.schaetzung = MELDUNG.schaetzung
+ }
+ } else if (schritt === 3) {
+ if (!zahlungsplanOptionen(f.art).some((o) => o.value === f.zahlungsart)) fehler.zahlungsart = MELDUNG.zahlungsart
+ if (f.zahlungsart === 'anzahlung' && parseProzent(f.prozent) === null) fehler.prozent = MELDUNG.prozent
+ if (f.zahlungsart === 'frei') {
+ const t = trimmed(f.zahlungstext)
+ if (t.length < 1 || t.length > AUFTRAG_LIMITS.maxZahlungsplanText) fehler.zahlungstext = MELDUNG.zahlungstext
+ }
+ if (parseTage(f.zahlungsziel) === null) fehler.zahlungsziel = MELDUNG.zahlungsziel
+ const start = trimmed(f.startAm)
+ const fertig = trimmed(f.fertigBis)
+ if (start !== '' && !istTag(start)) fehler.startAm = MELDUNG.datum
+ if (fertig !== '' && !istTag(fertig)) fehler.fertigBis = MELDUNG.datum
+ if (start !== '' && fertig !== '' && istTag(start) && istTag(fertig) && fertig < start) fehler.fertigBis = MELDUNG.fertigVorStart
+ if (Array.isArray(f.zeitquelle) && f.zeitquelle.length > AUFTRAG_LIMITS.maxZeitquellen) fehler.zeitquelle = MELDUNG.zeitquelle
+ }
+ const erstes = Object.keys(fehler)[0] || ''
+ return { ok: erstes === '', fehler, erstes }
+}
+
+/**
+ * Formular -> Anfrage fuer POST/PATCH …/auftraege. Prueft die Schritte 1 bis 3 der Reihe nach.
+ * { ok: true, payload, summeCents } oder { ok: false, schritt, erstes, fehler }. `kundenId` setzt der Aufrufer.
+ */
+export function formularZuNutzlast(form, { kunde } = {}) {
+ for (const schritt of [1, 2, 3]) {
+ const r = pruefeSchritt(schritt, form, { kunde })
+ if (!r.ok) return { ok: false, schritt, erstes: r.erstes, fehler: r.fehler }
+ }
+ const festpreis = form.art === 'festpreis'
+ const plan = form.zahlungsart
+ const schaetzung = trimmed(form.schaetzung)
+ const payload = {
+ projektId: text(form.projektId),
+ titel: trimmed(form.titel),
+ art: form.art,
+ positionen: festpreis
+ ? form.positionen.map((p) => ({ beschreibung: trimmed(p.description), menge: parseQuantity(p.quantity), einzelpreisCents: parseUnitPrice(p.price) }))
+ : [],
+ stundensatzCents: festpreis ? null : parseUnitPrice(form.stundensatz),
+ schaetzungMinuten: festpreis || schaetzung === '' ? null : parseStunden(schaetzung),
+ zahlungsplan: {
+ art: plan,
+ prozent: plan === 'anzahlung' ? parseProzent(form.prozent) : null,
+ text: plan === 'frei' ? trimmed(form.zahlungstext) : '',
+ },
+ zahlungszielTage: parseTage(form.zahlungsziel),
+ auftragVom: trimmed(form.auftragVom),
+ auftragWeg: form.auftragWeg,
+ startAm: trimmed(form.startAm),
+ fertigBis: trimmed(form.fertigBis),
+ zeitquelle: [...form.zeitquelle],
+ }
+ if (!trimmed(kunde?.kundenart)) payload.kundenart = form.kundenart
+ return { ok: true, payload, summeCents: festpreis ? invoiceTotalCents(form.positionen) : null }
+}
+
+// --- Texte -------------------------------------------------------------------------------------------------------------
+
+/** Sinngemaesser Zahlungsplan wie in der AB; massgeblich bleibt der Wortlaut im PDF. Unbekannt oder unvollstaendig: ''. */
+export function zahlungsplanVorschau({ art, prozent, text: freitext, summeCents, zielTage } = {}) {
+ const tage = Number.isInteger(zielTage) ? zielTage : AUFTRAG_LIMITS.standardZahlungszielTage
+ const ziel = tage === 0 ? 'sofort nach Rechnung' : tage === 1 ? 'innerhalb von 1 Tag nach Rechnung' : `innerhalb von ${tage} Tagen nach Rechnung`
+ const summe = Number.isInteger(summeCents) ? summeCents : null
+ if (art === 'nach_abnahme') {
+ return `${summe === null ? 'Alles' : `Gesamtbetrag (${euro(summe)})`} nach Abnahme; zahlbar ${ziel}.`
+ }
+ if (art === 'anzahlung') {
+ if (!Number.isInteger(prozent)) return ''
+ if (summe === null) return `Anzahlung ${prozent} % nach Auftragsbestätigung, Rest nach Abnahme; zahlbar jeweils ${ziel}.`
+ const anzahlung = Math.round((summe * prozent) / 100)
+ return `Anzahlung ${prozent} % (${euro(anzahlung)}) nach Auftragsbestätigung, Rest (${euro(summe - anzahlung)}) nach Abnahme; zahlbar jeweils ${ziel}.`
+ }
+ if (art === 'monatlich') return `Monatlich nach Aufwand; zahlbar ${ziel}.`
+ if (art === 'frei') {
+ const t = trimmed(freitext)
+ return t ? `${t} (zahlbar ${ziel}).` : ''
+ }
+ return ''
+}
+
+/**
+ * Darf gesendet werden? { erlaubt, grund, zurUebersicht }. Der Entwurf laesst sich immer speichern; `zurUebersicht`
+ * heisst: der Grund liegt in den Kundendaten (Reiter Uebersicht).
+ */
+export function sendeGate(kunde, kundenart = '') {
+ if (!kunde) return { erlaubt: false, grund: 'Kein Kunde ausgewählt', zurUebersicht: false }
+ const check = canInvoice(kunde)
+ if (!check.ok) return { erlaubt: false, grund: check.reason, zurUebersicht: true }
+ if (!addressComplete(kunde)) return { erlaubt: false, grund: ADDRESS_INCOMPLETE_TEXT, zurUebersicht: true }
+ if (!trimmed(kunde.kundenart) && !trimmed(kundenart)) {
+ return { erlaubt: false, grund: 'Die Kundenart fehlt. Bitte im ersten Schritt wählen.', zurUebersicht: false }
+ }
+ return { erlaubt: true, grund: '', zurUebersicht: false }
+}
+
+// --- Zeitquelle --------------------------------------------------------------------------------------------------------
+
+/**
+ * Tickets der Zeitquelle: erst die Tickets des Projekts, dann die uebrigen gewaehlten, dann alle weiteren. Eine gewaehlte
+ * Kennung, die nicht (mehr) in `tickets` steht, bleibt sichtbar („Ticket “).
+ */
+export function zeitquelleOptionen(tickets, auswahl, projektTicketIds) {
+ const liste = objekte(tickets)
+ const gewaehlt = Array.isArray(auswahl) ? auswahl : []
+ const projekt = Array.isArray(projektTicketIds) ? projektTicketIds : []
+ const nachId = new Map(liste.map((t) => [t.$id, t]))
+ const reihenfolge = []
+ const gesehen = new Set()
+ for (const id of [...projekt, ...gewaehlt, ...liste.map((t) => t.$id)]) {
+ if (typeof id !== 'string' || !id || gesehen.has(id)) continue
+ gesehen.add(id)
+ reihenfolge.push(id)
+ }
+ return reihenfolge.map((id) => {
+ const ticket = nachId.get(id)
+ const titel = ticket ? trimmed(ticket.topic) || trimmed(ticket.title) : ''
+ const label = ticket ? `#${ticketNumber(ticket)}${titel ? ` ${titel}` : ''}` : `Ticket ${id.slice(-5)}`
+ return { id, label, gewaehlt: gewaehlt.includes(id), projekt: projekt.includes(id) }
+ })
+}
+
+/** Ticket zur Auswahl hinzufuegen (hoechstens 10, keine Doppelten) oder entfernen; liefert eine neue Liste. */
+export function schalteZeitquelle(auswahl, id, an) {
+ const liste = Array.isArray(auswahl) ? [...auswahl] : []
+ if (!an) return liste.filter((x) => x !== id)
+ if (liste.includes(id) || liste.length >= AUFTRAG_LIMITS.maxZeitquellen) return liste
+ return [...liste, id]
+}
+
+// --- Hinweise (nie blockierend) ----------------------------------------------------------------------------------------
+
+/** Wirksame Kundenart: die am Kunden gespeicherte hat Vorrang (der Server schreibt sie nur, wenn sie dort leer ist). */
+export function kundenartWirksam(kunde, kundenart) {
+ if (KUNDENARTEN.includes(kunde?.kundenart)) return kunde.kundenart
+ if (KUNDENARTEN.includes(kundenart)) return kundenart
+ return ''
+}
+
+/** § 312f Abs. 1 BGB: bei ausserhalb von Geschaeftsraeumen geschlossenen Vertraegen mit Verbrauchern eine Papierfassung geben. */
+export const VOR_ORT_HINWEIS = 'Bei Aufträgen vor Ort mit Privatpersonen die Auftragsbestätigung zusätzlich ausdrucken und übergeben.'
+
+export function vorOrtHinweis(weg, kundenart) {
+ return weg === 'vor_ort' && kundenart === 'privat' ? VOR_ORT_HINWEIS : ''
+}
+
+/** Hinweise fuer Schritt 4 (Steuernummer fehlt; „Vor Ort“ bei Privatperson). Reine Texte, sie sperren nichts. */
+export function auftragHinweise({ kunde, form, einstellungen } = {}) {
+ const liste = []
+ if (steuernummerFehlt(einstellungen)) liste.push({ key: 'steuernummer', text: STEUERNUMMER_FEHLT_HINWEIS })
+ const vorOrt = vorOrtHinweis(form?.auftragWeg, kundenartWirksam(kunde, form?.kundenart))
+ if (vorOrt) liste.push({ key: 'vorOrt', text: vorOrt })
+ return liste
+}
+
+// --- Liste: Status, Aktionen, Zusatzzeilen -----------------------------------------------------------------------------
+
+/** Status der AB (Vertrag V2) mit Beschriftung und Ton des Abzeichens. */
+export const AUFTRAG_STATUS = {
+ entwurf: { label: 'Entwurf', tone: 'muted' },
+ versendet: { label: 'Versendet', tone: 'info' },
+ bestaetigt: { label: 'Bestätigt', tone: 'ok' },
+ abgerechnet: { label: 'Abgerechnet', tone: 'violet' },
+ storniert: { label: 'Storniert', tone: 'plain' },
+ ersetzt: { label: 'Ersetzt', tone: 'plain' },
+ widerrufen: { label: 'Widerrufen', tone: 'danger' }, // rot: die Erstattung folgt von Hand
+}
+
+/** Status, in denen „Projekt zuordnen“ gilt (der Server lehnt die uebrigen mit 409 ab). */
+export const PROJEKT_ZUORDNEN_STATUS = ['entwurf', 'versendet', 'bestaetigt', 'abgerechnet']
+
+export function istEntwurf(auftrag) {
+ return auftrag?.status === 'entwurf'
+}
+
+/** Projekte zur Auswahl aus den Projektkonten: [{ value: projektId, label: Name }]. */
+export function zuordnenOptionen(konten) {
+ if (!Array.isArray(konten)) return []
+ return konten
+ .filter((k) => istObjekt(k) && typeof k.projektId === 'string' && k.projektId !== '')
+ .map((k) => ({ value: k.projektId, label: trimmed(k.name) || k.projektId }))
+}
+
+/**
+ * Gibt es „Projekt zuordnen“? Nur fuer eine AB ohne Projekt in einem der Status aus PROJEKT_ZUORDNEN_STATUS und nur, wenn
+ * es mindestens ein Projekt zur Auswahl gibt. Hat die AB schon eine Kennung (auch eine unbekannte), lehnt der Server mit 409 ab.
+ */
+export function projektZuordnenMoeglich(auftrag, konten) {
+ if (!istObjekt(auftrag)) return false
+ if (!PROJEKT_ZUORDNEN_STATUS.includes(auftrag.status)) return false
+ if (typeof auftrag.projektId === 'string' ? auftrag.projektId !== '' : Boolean(auftrag.projektId)) return false
+ return zuordnenOptionen(konten).length > 0
+}
+
+/**
+ * Aktionen einer Zeile [{ key, label, danger? }]. Bearbeiten, Senden und Loeschen gibt es nur beim Entwurf; danach nur
+ * „Neue Fassung“. „Projekt zuordnen“ steht unmittelbar vor der ersten gefaehrlichen Aktion (sonst am Ende).
+ */
+export function auftragAktionen(auftrag, { konten } = {}) {
+ if (!istObjekt(auftrag)) return []
+ const status = auftrag.status
+ const pdf = (label) => ({ key: 'pdf', label })
+ let liste
+ if (status === 'entwurf') {
+ liste = [{ key: 'bearbeiten', label: 'Bearbeiten' }, { key: 'senden', label: 'Senden' }, pdf('Vorschau'), { key: 'loeschen', label: 'Löschen', danger: true }]
+ } else {
+ liste = []
+ if (auftrag.pdfVorhanden === true) liste.push(pdf('PDF'))
+ const erinnern = { key: 'erinnern', label: auftrag.mailStatus === 'fehler' ? 'Erneut senden' : 'Erinnerung senden' }
+ if (status === 'versendet') liste.push(erinnern, { key: 'neueFassung', label: 'Neue Fassung' }, { key: 'stornieren', label: 'Stornieren', danger: true })
+ else if (status === 'bestaetigt') liste.push({ key: 'neueFassung', label: 'Neue Fassung' }, { key: 'stornieren', label: 'Stornieren', danger: true })
+ else if (status === 'abgerechnet') liste.push({ key: 'neueFassung', label: 'Neue Fassung' })
+ }
+ if (projektZuordnenMoeglich(auftrag, konten)) {
+ const aktion = { key: 'projekt', label: 'Projekt zuordnen' }
+ const gefahr = liste.findIndex((a) => a.danger)
+ if (gefahr === -1) liste.push(aktion)
+ else liste.splice(gefahr, 0, aktion)
+ }
+ return liste
+}
+
+/** Zusatzzeilen unter dem Status [{ key, tone, text }]; `alle` sind alle ABs der Liste (fuer Nummern der Fassungen). */
+export function auftragNotizen(auftrag, alle = []) {
+ if (!istObjekt(auftrag)) return []
+ const a = auftrag
+ const status = a.status
+ const liste = []
+ const nummerVon = (id) => {
+ const treffer = objekte(alle).find((x) => x.id === id)
+ return treffer && trimmed(treffer.nummer) ? treffer.nummer : ''
+ }
+ if (status === 'versendet' && a.mailStatus === 'fehler') liste.push({ key: 'mail', tone: 'warn', text: 'Mail nicht zugestellt' })
+ if (a.bestaetigtAm && (status === 'bestaetigt' || status === 'abgerechnet')) {
+ liste.push({ key: 'bestaetigt', tone: 'muted', text: `Bestätigt am ${dateDe(a.bestaetigtAm)}` })
+ }
+ if (a.fruehbeginn === true && status !== 'entwurf') liste.push({ key: 'fruehbeginn', tone: 'muted', text: 'Frühbeginn verlangt' })
+ if (a.fruehesterBeginn && ['versendet', 'bestaetigt', 'abgerechnet'].includes(status)) {
+ liste.push({ key: 'beginn', tone: 'muted', text: `Arbeitsbeginn frühestens am ${dateDe(a.fruehesterBeginn)}` })
+ }
+ if (a.ersetztId) {
+ const nr = nummerVon(a.ersetztId)
+ liste.push({ key: 'fassung', tone: 'muted', text: nr ? `Neue Fassung zu ${nr}` : 'Neue Fassung' })
+ }
+ if (a.ersetztDurchId) {
+ const nr = nummerVon(a.ersetztDurchId)
+ liste.push({ key: 'ersetzt', tone: 'muted', text: nr ? `Ersetzt durch ${nr}` : 'Ersetzt durch eine neue Fassung' })
+ }
+ if (status === 'widerrufen' && a.widerrufenAm) liste.push({ key: 'widerruf', tone: 'muted', text: `Widerrufen am ${dateDe(a.widerrufenAm)}` })
+ return liste
+}
+
+/** Zeilen der Tabelle in der Reihenfolge des Servers (neueste zuerst). */
+export function auftragZeilen(auftraege, { konten = [] } = {}) {
+ const liste = objekte(auftraege)
+ return liste.map((a) => {
+ const konto = objekte(konten).find((k) => k.projektId && k.projektId === a.projektId)
+ const projektName = konto ? trimmed(konto.name) : ''
+ const aufwand = a.art === 'aufwand'
+ let summeSub = ''
+ if (aufwand && typeof a.stundensatzCents === 'number') {
+ summeSub = `${euro(a.stundensatzCents)} pro Std.`
+ if (typeof a.schaetzungMinuten === 'number') summeSub += `, Schätzung etwa ${stundenText(a.schaetzungMinuten)} Std.`
+ }
+ const status = AUFTRAG_STATUS[a.status] || { label: text(a.status), tone: 'muted' }
+ return {
+ key: a.id,
+ id: a.id,
+ auftrag: a,
+ nummerText: trimmed(a.nummer) || 'Entwurf',
+ istEntwurf: istEntwurf(a),
+ titel: text(a.titel),
+ projektText: projektName && projektName !== trimmed(a.titel) ? projektName : '',
+ datumText: dateDe(a.gesendetAm || a.erstelltAm),
+ summeText: aufwand ? 'nach Aufwand' : euro(a.summeCents),
+ summeSub,
+ statusLabel: status.label,
+ statusTone: status.tone,
+ notizen: auftragNotizen(a, liste),
+ aktionen: auftragAktionen(a, { konten }),
+ }
+ })
+}
+
+// --- Rueckfragen, Meldungen, Fehler ------------------------------------------------------------------------------------
+
+/** Rueckfrage vor einer Aktion; '' = keine Rueckfrage. */
+export function auftragBestaetigung(key, auftrag, { email, projektName } = {}) {
+ const a = istObjekt(auftrag) ? auftrag : {}
+ const titel = `„${trimmed(a.titel) || 'ohne Titel'}“`
+ const nummer = trimmed(a.nummer)
+ const ab = `Die Auftragsbestätigung${nummer ? ` ${nummer}` : ''}`
+ if (key === 'senden') {
+ return `Auftragsbestätigung ${titel} jetzt festschreiben und an ${trimmed(email) || 'den Kunden'} senden? Danach lässt sie sich nicht mehr ändern, nur noch ersetzen oder stornieren.`
+ }
+ if (key === 'loeschen') return `Den Entwurf ${titel} wirklich löschen? Das lässt sich nicht rückgängig machen.`
+ if (key === 'stornieren') {
+ return `${ab} ${titel} wirklich stornieren? Der Kunde bekommt keine Mail, der Bestätigungslink wird ungültig. Das lässt sich nicht rückgängig machen.`
+ }
+ if (key === 'erinnern') return `${ab} jetzt noch einmal an den Kunden senden? Der bisherige Bestätigungslink gilt danach nicht mehr.`
+ if (key === 'projekt') {
+ if (istEntwurf(a)) return '' // ein Entwurf bleibt ueber „Bearbeiten“ aenderbar
+ const ziel = trimmed(projektName) ? `dem Projekt „${trimmed(projektName)}“` : 'einem Projekt'
+ return `${ab} ${titel} ${ziel} zuordnen? Das Projekt lässt sich danach nur noch über eine neue Fassung ändern.`
+ }
+ return ''
+}
+
+/** Meldung nach einer Aktion { tone, text }; „senden“ im selben Wortlaut wie der Container (auftragMeldung in W0). */
+export function auftragErfolg(key, auftrag, { mailFehler = false, projektName = '' } = {}) {
+ const a = istObjekt(auftrag) ? auftrag : {}
+ const nummer = trimmed(a.nummer)
+ const name = nummer ? `Auftragsbestätigung ${nummer}` : 'Die Auftragsbestätigung'
+ const an = trimmed(a.gesendetAn)
+ if (key === 'senden') {
+ if (mailFehler) return { tone: 'warn', text: `${name} ist versendet, die Mail an den Kunden wurde aber nicht zugestellt. Bitte in der Liste erneut senden.` }
+ return { tone: 'ok', text: an ? `${name} wurde an ${an} gesendet.` : `${name} wurde gesendet.` }
+ }
+ if (key === 'erinnern') {
+ const wen = nummer || 'der Auftragsbestätigung'
+ if (mailFehler) return { tone: 'warn', text: `Die Erinnerung zu ${wen} konnte nicht zugestellt werden. „Erneut senden“ versucht es noch einmal.` }
+ return { tone: 'ok', text: `Die Erinnerung zu ${wen} wurde an ${an || 'den Kunden'} gesendet.` }
+ }
+ if (key === 'stornieren') return { tone: 'ok', text: `${name} wurde storniert.` }
+ if (key === 'loeschen') return { tone: 'ok', text: 'Der Entwurf wurde gelöscht.' }
+ if (key === 'neueFassung') return { tone: 'ok', text: 'Neue Fassung als Entwurf angelegt.' }
+ if (key === 'projekt') {
+ const ziel = trimmed(projektName) ? `dem Projekt „${trimmed(projektName)}“` : 'einem Projekt'
+ return { tone: 'ok', text: `${name} wurde ${ziel} zugeordnet.` }
+ }
+ return { tone: 'ok', text: '' }
+}
+
+const KUNDENDATEN_FEHLER = {
+ kundenart: 'Die Kundenart des Kunden fehlt. Bitte in der Übersicht des Kunden festlegen (Feld „Kundenart“) oder über „Bearbeiten“ und dann erneut senden.',
+ email: 'Dem Kunden fehlt die E-Mail-Adresse. Bitte in der Übersicht eintragen und dann erneut senden.',
+ kunde: 'Das ist noch kein fester Kunde. Bitte in der Übersicht zum festen Kunden machen und dann erneut senden.',
+}
+
+/**
+ * Fehler des Servers -> { art, text, zurUebersicht }. Fehler in den Kundendaten fuehren zur Uebersicht, die uebrigen zeigen
+ * die Meldung des Servers (Texte: Plan L3 „Meldungstexte“, Plan L4 „Entwurf unvollstaendig“).
+ */
+export function auftragFehler(err) {
+ const status = err?.status
+ const meldung = trimmed(err?.message)
+ const ergebnis = (art, t, zurUebersicht = false) => ({ art, text: t, zurUebersicht })
+ if (status === 409 && /^Entwurf unvollständig/i.test(meldung)) return ergebnis('entwurf', meldung)
+ if (status === 409 && /adresse/i.test(meldung)) return ergebnis('adresse', ADDRESS_INCOMPLETE_TEXT, true)
+ if (status === 400 && /kundenart/i.test(meldung)) return ergebnis('kundenart', KUNDENDATEN_FEHLER.kundenart, true)
+ if (status === 400 && /e-?mail/i.test(meldung)) return ergebnis('email', KUNDENDATEN_FEHLER.email, true)
+ if (status === 400 && /feste[rn]? kunde|interessent|\blead\b/i.test(meldung)) return ergebnis('kunde', KUNDENDATEN_FEHLER.kunde, true)
+ if (status === 409) return ergebnis('stand', `${meldung} Die Liste zeigt den aktuellen Stand.`.trim())
+ if (status === 400) return ergebnis('eingabe', meldung || 'Die Eingabe ist ungültig.')
+ if (status === 500 && /pdf/i.test(meldung)) {
+ return ergebnis('pdf', `${meldung} Die Auftragsbestätigung bleibt ein Entwurf, es wurde keine Nummer vergeben.`)
+ }
+ if (typeof status === 'number' && status >= 500) {
+ return ergebnis('server', `${meldung || 'Interner Fehler.'} Bitte in der Liste prüfen, ob die Auftragsbestätigung gesendet wurde, und sonst erneut versuchen.`)
+ }
+ if (status === 0) return ergebnis('netz', meldung || 'Das Kundenportal ist gerade nicht erreichbar.')
+ return ergebnis('sonst', meldung || 'Die Aktion ist fehlgeschlagen.')
+}
+
+/**
+ * Sperre gegen Doppelklick: `run(fn)` fuehrt fn nur aus, wenn gerade nichts laeuft (`belegt` gilt sofort beim Aufruf);
+ * sonst { uebersprungen: true }. Ein Fehler von fn gibt die Sperre frei und wird weitergereicht.
+ */
+export function neueSperre() {
+ let belegt = false
+ return {
+ get belegt() {
+ return belegt
+ },
+ async run(fn) {
+ if (belegt) return { uebersprungen: true }
+ belegt = true
+ try {
+ return { uebersprungen: false, wert: await fn() }
+ } finally {
+ belegt = false
+ }
+ },
+ }
+}
diff --git a/src/lib/customerTabs.js b/src/lib/customerTabs.js
index 3e3b12b..48ee6d9 100644
--- a/src/lib/customerTabs.js
+++ b/src/lib/customerTabs.js
@@ -1,10 +1,10 @@
-// Reiter der Kundenansicht (rein, ohne React; unter Node testbar). „Rechnungen“ ruft die Finanzen-API des Portals auf,
+// Reiter der Kundenansicht (rein, ohne React; unter Node testbar). „Aufträge und Rechnungen“ ruft die Finanzen-API des Portals auf,
// die nur Admins freigibt: für alle anderen gibt es den Reiter nicht.
const BASE_TABS = [
{ id: 'overview', label: 'Übersicht' },
{ id: 'tickets', label: 'Tickets' },
- { id: 'invoices', label: 'Rechnungen', adminOnly: true },
+ { id: 'invoices', label: 'Aufträge und Rechnungen', adminOnly: true },
{ id: 'projects', label: 'Projekte' },
// Chat mit dem Kunden (derselbe Gespraechsbaustein wie im Messenger unter Kunden -> Chat)
{ id: 'chat', label: 'Chat' },
@@ -16,7 +16,7 @@ const BASE_TABS = [
{ id: 'protokoll', label: 'Protokoll' },
]
-/** Reiter für Tabs: [{ id, label }]; „Rechnungen“ nur für Admins. */
+/** Reiter für Tabs: [{ id, label }]; „Aufträge und Rechnungen“ nur für Admins. */
export function customerTabs(isAdmin) {
return BASE_TABS.filter((t) => !t.adminOnly || isAdmin === true).map(({ id, label }) => ({ id, label }))
}
diff --git a/src/lib/financeApi.js b/src/lib/financeApi.js
index d74ae1e..fd1f2cf 100644
--- a/src/lib/financeApi.js
+++ b/src/lib/financeApi.js
@@ -1,5 +1,6 @@
import { account } from './appwrite'
import { createFinanceRequest } from './financeRequest'
+import { createAuftraegeEndpoints } from './auftraegeEndpoints'
/**
* Client fuer die Finanzen-API des Kundenportals (/api/admin/finanzen).
@@ -26,6 +27,8 @@ export function getInvoices({ status, kunde } = {}) {
}
// Jeder Aufruf bekommt einen neuen Schluessel, damit ein Doppelklick keine zweite Rechnung erzeugt.
+// Koerper: { customerId, art, projektId, auftragId, items, arbeitsblattIds, anzahlung, zeilentexte, leistungszeitraum,
+// daysUntilDue, memo, senden } (wird unveraendert durchgereicht)
export const createInvoice = (body) =>
request('/rechnungen', { method: 'POST', body: { ...body, idempotencyKey: crypto.randomUUID() } })
export const sendInvoice = (id) => request(`/rechnungen/${enc(id)}/senden`, { method: 'POST' })
@@ -34,6 +37,24 @@ export const resendInvoice = sendInvoice
export const voidInvoice = (id) => request(`/rechnungen/${enc(id)}/stornieren`, { method: 'POST' })
export const deleteInvoice = (id) => request(`/rechnungen/${enc(id)}`, { method: 'DELETE' })
+// --- Projektkonten, Auftraege und Rechnungsvorschau (Pfade und Methoden: auftraegeEndpoints.js, getestet) ---
+export const {
+ getProjektkonten,
+ getProjektkonto,
+ previewInvoice,
+ listAuftraege,
+ getAuftrag,
+ createAuftrag,
+ patchAuftrag,
+ deleteAuftrag,
+ sendAuftrag,
+ erinnereAuftrag,
+ neueFassungAuftrag,
+ storniereAuftrag,
+ ordneAuftragProjektZu,
+ getAuftragPdf,
+} = createAuftraegeEndpoints(request)
+
// --- Buchungen ---
export const getTransactions = () => request('/buchungen')
// target: genau eins von { invoiceId } | { payoutId } | { fixedCostId } | { category }, optional note
diff --git a/src/lib/financeRequest.js b/src/lib/financeRequest.js
index 6f09cf9..c8e6baf 100644
--- a/src/lib/financeRequest.js
+++ b/src/lib/financeRequest.js
@@ -9,13 +9,14 @@ export const SESSION_EXPIRED_TEXT = 'Anmeldung abgelaufen. Bitte neu anmelden.'
const statusError = (message, status) => Object.assign(new Error(message), { status })
/**
- * request(path, { method, body }) -> geparstes JSON oder ein Error mit `status` (und bei 502 nach dem Festschreiben
+ * request(path, { method, body, as }) -> geparstes JSON oder ein Error mit `status` (und bei 502 nach dem Festschreiben
* `invoiceId` / `invoiceNumber`). Meldungen:
* - fetch wirft (Netz, Portal aus): „Das Kundenportal ist gerade nicht erreichbar.“ (status 0)
* - 502/503/504 ohne JSON-Antwort (Proxy-Fehlerseite): dieselbe Meldung (status = HTTP-Status)
* - createJWT scheitert (Sitzung abgelaufen): „Anmeldung abgelaufen. Bitte neu anmelden.“ (status 401)
* - sonst die Meldung des Portals (`error`), ohne sie „API-Fehler “
* Das JWT wird 10 Minuten gecacht; bei 401 einmal mit frischem JWT wiederholt.
+ * Mit `as: 'blob'` liefert eine erfolgreiche Antwort den rohen Inhalt (Blob); Fehler kommen weiter als Meldung des Portals.
*/
export function createFinanceRequest({ baseUrl, createJWT, fetchImpl = (...args) => globalThis.fetch(...args), now }) {
// JWT 10 Min. gecacht (Appwrite begrenzt das Erzeugen pro Nutzer)
@@ -39,7 +40,7 @@ export function createFinanceRequest({ baseUrl, createJWT, fetchImpl = (...args)
}
}
- return async function request(path, { method = 'GET', body } = {}) {
+ return async function request(path, { method = 'GET', body, as } = {}) {
const res = await fetchWithRetry(jwtCache, (jwt) =>
portalFetch(`${baseUrl}${path}`, {
method,
@@ -47,6 +48,8 @@ export function createFinanceRequest({ baseUrl, createJWT, fetchImpl = (...args)
body: body === undefined ? undefined : JSON.stringify(body),
})
)
+ // Dateien (z. B. das PDF einer Auftragsbestätigung) kommen roh; Fehlerantworten bleiben JSON mit Meldung
+ if (as === 'blob' && res.ok) return res.blob()
let data = null
try {
data = await res.json()
diff --git a/src/lib/kundenFelder.js b/src/lib/kundenFelder.js
new file mode 100644
index 0000000..acaca55
--- /dev/null
+++ b/src/lib/kundenFelder.js
@@ -0,0 +1,67 @@
+// Reine Helfer fuer zwei Felder der Auftragsbestaetigung: die Kundenart am Kunden und die Steuernummer in den
+// Finanz-Einstellungen; kein React, kein Appwrite, unter Node testbar. Texte deutsch, sachlich.
+
+/** Werte der Kundenart (Vertrag V2); '' = nicht festgelegt. */
+export const KUNDENARTEN = ['unternehmen', 'privat']
+
+export const KUNDENART_OPTIONEN = [
+ { value: '', label: 'Nicht festgelegt' },
+ { value: 'unternehmen', label: 'Unternehmen' },
+ { value: 'privat', label: 'Privatperson' },
+]
+
+export const KUNDENART_HINWEIS = 'Für Auftragsbestätigungen: Privatpersonen bekommen die Widerrufsbelehrung.'
+
+const text = (v) => (typeof v === 'string' ? v : '')
+
+/** Beschriftung der Kundenart; alles Unbekannte heisst „Nicht festgelegt“. */
+export function kundenartLabel(value) {
+ const treffer = KUNDENART_OPTIONEN.find((o) => o.value !== '' && o.value === value)
+ return treffer ? treffer.label : KUNDENART_OPTIONEN[0].label
+}
+
+/**
+ * Was beim Speichern der Kundendaten zusaetzlich ins Dokument soll: nur eine geaenderte, bekannte Kundenart
+ * (so scheitert das Speichern der uebrigen Stammdaten nie an einem noch nicht angelegten Attribut).
+ */
+export function kundenartAenderung(neu, gespeichert) {
+ const n = text(neu)
+ const alt = text(gespeichert)
+ if (n !== '' && !KUNDENARTEN.includes(n)) return {}
+ if (n === alt) return {}
+ return { kundenart: n }
+}
+
+export const STEUERNUMMER_MAX = 40
+
+export const STEUERNUMMER_HINWEIS = 'Steht auf jeder Auftragsbestätigung. Für Rechnungen bitte zusätzlich im Stripe-Dashboard hinterlegen.'
+
+export const STEUERNUMMER_FEHLT_HINWEIS =
+ 'Die Steuernummer fehlt noch – sie steht dann nicht auf der Auftragsbestätigung. Eintragen unter Finanzen → Einstellungen.'
+
+const STEUERNUMMER_ZEICHEN = /^[A-Za-z0-9/ .-]*$/
+
+/**
+ * Steuernummer fuer PATCH /einstellungen. Der Client prueft wie der Server (Vertrag V4): getrimmt hoechstens 40 Zeichen aus
+ * ASCII-Buchstaben, Ziffern, Leerzeichen, „/“, „-“ und „.“; leer loescht den Wert. Unveraendert wird nichts gesendet.
+ */
+export function steuernummerAenderung(eingabe, gespeichert) {
+ const neu = text(eingabe).trim()
+ const alt = text(gespeichert).trim()
+ if (neu === alt) return { ok: true, payload: {} }
+ if (neu.length > STEUERNUMMER_MAX) return { ok: false, fehler: `Die Steuernummer darf höchstens ${STEUERNUMMER_MAX} Zeichen lang sein.` }
+ if (!STEUERNUMMER_ZEICHEN.test(neu)) {
+ return { ok: false, fehler: 'Die Steuernummer darf nur Buchstaben (ohne Umlaute), Ziffern, Leerzeichen, „/“, „-“ und „.“ enthalten.' }
+ }
+ return { ok: true, payload: { steuernummer: neu } }
+}
+
+/**
+ * Fehlt die Steuernummer? Nur wahr, wenn die Einstellungen da sind (Objekt) und der Wert leer ist; „noch nicht geladen“
+ * (undefined) und „nicht ladbar“ (null) behaupten nichts.
+ */
+export function steuernummerFehlt(einstellungen) {
+ if (!einstellungen || typeof einstellungen !== 'object' || Array.isArray(einstellungen)) return false
+ const wert = einstellungen.steuernummer
+ return typeof wert !== 'string' || wert.trim() === ''
+}
diff --git a/src/lib/projektkontoView.js b/src/lib/projektkontoView.js
new file mode 100644
index 0000000..52f3b7f
--- /dev/null
+++ b/src/lib/projektkontoView.js
@@ -0,0 +1,154 @@
+// Anzeigelogik des Projektkontos (rein, ohne React und Appwrite; unter Node testbar).
+// Aufgaben: Zeilen der Projektliste im Kundenreiter „Aufträge und Rechnungen“, die Spalten „Projekt“ und „Art“ der
+// Rechnungstabelle und das Abzeichen „abgerechnet“ der Arbeitsblätter. Alle Beträge sind Cent; Ausgaben sind Zeichenketten.
+import { euro, euroWhole } from './financeFormat'
+
+const PLATZHALTER = '–'
+
+export const STRIPE_HINWEIS = 'Stripe ist gerade nicht erreichbar. Berechnete und bezahlte Beträge sind deshalb nicht verfügbar.'
+export const KEINE_PROJEKTE_HINWEIS = 'Dieser Kunde hat noch keine Projekte. Rechnungen entstehen ohne Projektbezug.'
+
+const ART_LABEL = {
+ frei: 'Ohne Projekt',
+ festbetrag: 'Festbetrag',
+ aufwand: 'Aufwand',
+ anzahlung: 'Anzahlung',
+ schluss: 'Schlussrechnung',
+ abo: 'Abo',
+}
+
+function istZahl(v) {
+ return typeof v === 'number' && Number.isFinite(v)
+}
+
+function eigen(obj, key) {
+ return obj !== null && typeof obj === 'object' && Object.prototype.hasOwnProperty.call(obj, key)
+}
+
+/** 750 -> '12,5 Std.' (Dezimalstunden, höchstens zwei Nachkommastellen, ohne überflüssige Nullen). */
+export function stundenDe(minuten) {
+ const min = istZahl(minuten) ? minuten : 0
+ const h = Math.round((min / 60) * 100) / 100
+ return `${String(h).replace('.', ',')} Std.`
+}
+
+/** 10000 -> '100 €/Std.', 8550 -> '85,50 €/Std.', keine Zahl -> '–'. */
+export function satzText(cents) {
+ if (!istZahl(cents)) return PLATZHALTER
+ const ganz = Math.round(cents) % 100 === 0
+ return `${ganz ? euroWhole(cents) : euro(cents)}/Std.`
+}
+
+/** Ticketnummern mit vorangestellter Raute, durch Komma getrennt; Tickets ohne Nummer fallen weg. */
+export function ticketsText(tickets) {
+ if (!Array.isArray(tickets)) return ''
+ return tickets
+ .map((t) => String(t?.woid ?? '').trim())
+ .filter(Boolean)
+ .map((w) => `#${w}`)
+ .join(', ')
+}
+
+/** Hinweis nur, wenn Stripe ausdrücklich nicht erreichbar war. */
+export function stripeHinweis(stripeOk) {
+ return stripeOk === false ? STRIPE_HINWEIS : ''
+}
+
+/** Text der Spalte „Vereinbart“: Festpreis, Aufwand mit Satz und Schätzung, beides oder „keine AB“. */
+export function vereinbartText(vereinbart) {
+ const teile = []
+ if (istZahl(vereinbart?.festpreisCents)) teile.push(euro(vereinbart.festpreisCents))
+ const aufwand = vereinbart?.aufwand
+ if (aufwand) {
+ let t = `nach Aufwand, ${satzText(aufwand.stundensatzCents)}`
+ if (istZahl(aufwand.schaetzungMinuten) && aufwand.schaetzungMinuten > 0) t += `, Schätzung ${stundenDe(aufwand.schaetzungMinuten)}`
+ teile.push(t)
+ }
+ return teile.length ? teile.join(' und ') : 'keine AB'
+}
+
+/** Eine Zeile der Projektliste. `stripeOk: false` ersetzt Berechnet, bezahlt und (Festpreis) Noch offen durch „–“. */
+export function kontoZeile(konto, { stripeOk = true } = {}) {
+ const stripe = stripeOk !== false
+ const k = konto || {}
+ const zeit = k.zeit || {}
+ const vereinbart = k.vereinbart || {}
+ const name = String(k.name ?? '').trim() || 'Projekt'
+ const meta = [String(k.projektTyp ?? '').trim(), ticketsText(k.tickets)].filter(Boolean).join(' · ')
+ let offen = PLATZHALTER
+ if (istZahl(vereinbart.festpreisCents)) offen = stripe ? euro(k.offenCents) : PLATZHALTER
+ else if (vereinbart.aufwand) offen = euro(zeit.offenerWertCents)
+ return {
+ key: k.projektId,
+ projektId: k.projektId,
+ name,
+ meta,
+ vereinbart: vereinbartText(vereinbart),
+ berechnet: stripe ? euro(k.berechnetCents) : PLATZHALTER,
+ bezahlt: stripe ? `bezahlt ${euro(k.bezahltCents)}` : '',
+ offen,
+ zeit: {
+ stunden: stundenDe(zeit.minuten),
+ wert: `Wert ${euro(zeit.wertCents)}`,
+ abo: istZahl(zeit.aboMinuten) && zeit.aboMinuten > 0 ? `über Abo: ${stundenDe(zeit.aboMinuten)}` : '',
+ },
+ }
+}
+
+/** Die feste Zeile „Ohne Projekt“ am Ende der Liste. */
+export function ohneProjektZeile(ohne, { stripeOk = true } = {}) {
+ const stripe = stripeOk !== false
+ const anzahl = istZahl(ohne?.anzahl) ? ohne.anzahl : 0
+ const meta = anzahl <= 0 ? 'keine Rechnungen' : anzahl === 1 ? '1 Rechnung' : `${anzahl} Rechnungen`
+ return {
+ key: 'ohne-projekt',
+ projektId: '',
+ name: 'Ohne Projekt',
+ meta,
+ vereinbart: PLATZHALTER,
+ berechnet: stripe ? euro(ohne?.berechnetCents ?? 0) : PLATZHALTER,
+ bezahlt: stripe ? `bezahlt ${euro(ohne?.bezahltCents ?? 0)}` : '',
+ offen: PLATZHALTER,
+ zeit: { stunden: PLATZHALTER, wert: '', abo: '' },
+ }
+}
+
+/** Spalte „Art“ der Rechnungstabelle. */
+export function artLabel(art) {
+ return typeof art === 'string' && eigen(ART_LABEL, art) ? ART_LABEL[art] : PLATZHALTER
+}
+
+/** Spalte „Projekt“ der Rechnungstabelle. */
+export function projektSpalte(invoice) {
+ return String(invoice?.projektName ?? '').trim() || PLATZHALTER
+}
+
+function abgerechnetWert(abgerechnet, worksheetId) {
+ if (!eigen(abgerechnet, worksheetId)) return null
+ const wert = abgerechnet[worksheetId]
+ return typeof wert === 'string' && wert.trim() ? wert.trim() : null
+}
+
+/** Abzeichen am Arbeitsblatt: 'abgerechnet (WEBKLAR-0009)' oder 'abgerechnet (Entwurf)'; sonst null. */
+export function abgerechnetText(abgerechnet, worksheetId) {
+ const wert = abgerechnetWert(abgerechnet, worksheetId)
+ return wert ? `abgerechnet (${wert})` : null
+}
+
+/** Warnung beim Ändern der Zeit eines abgerechneten Arbeitsblatts; sonst null. */
+export function abgerechnetWarnung(abgerechnet, worksheetId) {
+ const wert = abgerechnetWert(abgerechnet, worksheetId)
+ return wert ? `Dieses Arbeitsblatt ist abgerechnet (${wert}). Eine Änderung ändert die Rechnung nicht.` : null
+}
+
+/** Eindeutige Ticket-Kennungen der Arbeitsblätter in Reihenfolge des ersten Auftretens, höchstens fünf. */
+export function ticketIdsOf(worksheets) {
+ if (!Array.isArray(worksheets)) return []
+ const ids = []
+ for (const w of worksheets) {
+ const id = String(w?.workorderId ?? '').trim()
+ if (id && !ids.includes(id)) ids.push(id)
+ if (ids.length >= 5) break
+ }
+ return ids
+}
diff --git a/src/lib/rechnungAnfrage.js b/src/lib/rechnungAnfrage.js
new file mode 100644
index 0000000..e03e5c3
--- /dev/null
+++ b/src/lib/rechnungAnfrage.js
@@ -0,0 +1,221 @@
+// Anfragen des Rechnungsdialogs (rein, ohne React und Appwrite; unter Node testbar).
+// Aufgaben: Körper für die Vorschau und für „Rechnung anlegen“ bauen und prüfen, Status der Vorschau, Zeilentexte der
+// Aufwandsrechnung und das Absenden mit Rückfrage, Adress-Sperre und Doppelklick-Sperre.
+// Die Eingabe `e` des Dialogs ist ein flaches Objekt (siehe rechnungAnfrage). Beträge sind Cent.
+import {
+ INVOICE_LIMITS,
+ ADDRESS_INCOMPLETE_TEXT,
+ customerName,
+ finalizedFilter,
+ invoiceFailureMessage,
+ invoiceSendConfirmText,
+ invoiceSendGate,
+ isFinalizedNotSent,
+ parseQuantity,
+ parseUnitPrice,
+ validateInvoiceForm,
+} from './financeView'
+import { ausgewaehlteIds } from './rechnungDialogView'
+
+export const LEISTUNGSZEITRAUM_MAX = 140
+export const BEZEICHNUNG_MAX = 60
+export const VORSCHAU_ARTEN = ['aufwand', 'anzahlung', 'schluss']
+
+const ZEILEN_ART = /^(\d{4}-\d{2}-\d{2}|KW-\d{4}-\d{2})$/
+const TRENNER = ' – ' // Leerzeichen, Gedankenstrich (U+2013), Leerzeichen
+
+const text = (v) => String(v ?? '').trim()
+
+/** Zusatzzeilen prüfen; eine Zeile ohne Beschreibung und ohne Preis zählt nicht. */
+function zusatzPruefen(rows, label = 'Zusatzposition') {
+ const items = []
+ const liste = Array.isArray(rows) ? rows : []
+ for (let i = 0; i < liste.length; i += 1) {
+ const row = liste[i] || {}
+ const description = text(row.description)
+ if (!description && !text(row.price)) continue
+ const name = `${label} ${i + 1}`
+ if (!description) return { ok: false, error: `${name}: Bitte eine Beschreibung eingeben.` }
+ if (description.length > INVOICE_LIMITS.maxDescription) {
+ return { ok: false, error: `${name}: Die Beschreibung darf höchstens ${INVOICE_LIMITS.maxDescription} Zeichen lang sein.` }
+ }
+ const quantity = parseQuantity(row.quantity)
+ if (quantity === null) return { ok: false, error: `${name}: Die Menge muss eine ganze Zahl von 1 bis ${INVOICE_LIMITS.maxQuantity} sein.` }
+ const unit = parseUnitPrice(row.price)
+ if (unit === null) return { ok: false, error: `${name}: Der Einzelpreis muss ein Betrag zwischen 0,01 € und 100.000,00 € sein (z. B. 120,00).` }
+ items.push({ description, quantity, unitAmountCents: unit })
+ }
+ return { ok: true, items }
+}
+
+/** '33,5' -> 33.5; nur 0 < Wert <= 100 mit höchstens zwei Nachkommastellen, sonst null. */
+function parseProzent(value) {
+ const s = text(value).replace(',', '.')
+ if (!/^[0-9]{1,3}(\.[0-9]{1,2})?$/.test(s)) return null
+ const n = Number(s)
+ return n > 0 && n <= 100 ? n : null
+}
+
+/** Körper für die Vorschau; `error: ''` heißt „keine Vorschau für diese Art“. */
+export function vorschauAnfrage(e) {
+ const art = e?.art
+ if (!VORSCHAU_ARTEN.includes(art)) return { ok: false, error: '' }
+ if (!e.kunde) return { ok: false, error: 'Bitte einen Kunden wählen.' }
+ const body = { customerId: e.kunde.$id, art, projektId: e.projektId }
+
+ if (art === 'aufwand') {
+ const ids = ausgewaehlteIds(e.blaetter, e.abgewaehlt)
+ if (ids.length === 0) return { ok: false, error: 'Bitte mindestens ein Arbeitsblatt auswählen.' }
+ body.arbeitsblattIds = ids
+ } else {
+ if (!e.auftragId) return { ok: false, error: 'Bitte eine Auftragsbestätigung wählen.' }
+ body.auftragId = e.auftragId
+ }
+
+ if (art === 'anzahlung') {
+ const a = e.anzahlung || {}
+ const betrag = {}
+ if (a.modus === 'betrag') {
+ const cents = parseUnitPrice(a.wert)
+ if (cents === null) return { ok: false, error: 'Der Betrag muss zwischen 0,01 € und 100.000,00 € liegen.' }
+ betrag.betragCents = cents
+ } else {
+ const prozent = parseProzent(a.wert)
+ if (prozent === null) return { ok: false, error: 'Der Prozentsatz muss größer als 0 und höchstens 100 sein.' }
+ betrag.prozent = prozent
+ }
+ const bezeichnung = text(a.bezeichnung)
+ if (!bezeichnung || bezeichnung.length > BEZEICHNUNG_MAX) {
+ return { ok: false, error: `Bitte eine Bezeichnung eingeben (höchstens ${BEZEICHNUNG_MAX} Zeichen).` }
+ }
+ body.anzahlung = { ...betrag, bezeichnung }
+ } else {
+ const z = zusatzPruefen(e.zusatz)
+ if (!z.ok) return z
+ if (z.items.length) body.items = z.items
+ }
+
+ if (art === 'aufwand') {
+ const zeilentexte = {}
+ for (const [schluessel, t] of Object.entries(e.zeilentexte || {})) {
+ if (text(t)) zeilentexte[schluessel] = text(t)
+ }
+ if (Object.keys(zeilentexte).length) body.zeilentexte = zeilentexte
+ }
+
+ const zeitraum = leistungszeitraumFeld(e)
+ if (zeitraum.error) return { ok: false, error: zeitraum.error }
+ if (zeitraum.wert) body.leistungszeitraum = zeitraum.wert
+ return { ok: true, body }
+}
+
+function leistungszeitraumFeld(e) {
+ const wert = e?.zeitraumGeaendert ? text(e.zeitraum) : ''
+ if (wert.length > LEISTUNGSZEITRAUM_MAX) {
+ return { error: `Der Leistungszeitraum darf höchstens ${LEISTUNGSZEITRAUM_MAX} Zeichen lang sein.`, wert: '' }
+ }
+ return { error: '', wert }
+}
+
+/** Körper für „Rechnung anlegen“ (ohne `senden`); `totalCents` nur bei freien Positionen und Festbetrag. */
+export function rechnungAnfrage(e) {
+ const art = e?.art
+ if (art === 'frei' || art === 'festbetrag') {
+ // die Sätze und Grenzen für Kunde, Positionen, Zahlungsziel und Notiz gelten unverändert aus dem bisherigen Dialog
+ const form = validateInvoiceForm({ customer: e.kunde, items: e.items, days: e.days, memo: e.memo })
+ if (!form.ok) return { ok: false, error: form.error }
+ if (art === 'festbetrag' && !e.projektId) return { ok: false, error: 'Bitte ein Projekt wählen.' }
+ const zeitraum = leistungszeitraumFeld(e)
+ if (zeitraum.error) return { ok: false, error: zeitraum.error }
+ const body = { ...form.payload, art }
+ if (art === 'festbetrag') body.projektId = e.projektId
+ if (zeitraum.wert) body.leistungszeitraum = zeitraum.wert
+ return { ok: true, body, totalCents: form.totalCents }
+ }
+ const v = vorschauAnfrage(e)
+ if (!v.ok) return v
+ const form = validateInvoiceForm({
+ customer: e.kunde,
+ items: [{ description: 'x', quantity: '1', price: '1' }],
+ days: e.days,
+ memo: e.memo,
+ })
+ if (!form.ok) return { ok: false, error: form.error }
+ const body = { ...v.body, daysUntilDue: form.payload.daysUntilDue }
+ if (form.payload.memo) body.memo = form.payload.memo
+ return { ok: true, body, totalCents: null }
+}
+
+/** Schlüssel der Eingabe, die Zahlen der Vorschau ändert (Texte und Leistungszeitraum zählen nicht). */
+export function vorschauSchluessel(body) {
+ if (!body) return ''
+ const { zeilentexte, leistungszeitraum, ...rest } = body
+ void zeilentexte
+ void leistungszeitraum
+ if (Array.isArray(rest.arbeitsblattIds)) rest.arbeitsblattIds = [...rest.arbeitsblattIds].sort()
+ return JSON.stringify(rest)
+}
+
+/** Zustand der Vorschau: nicht-noetig | ungueltig | laedt | fehler | bereit. */
+export function vorschauStatus({ art, anfrage, vorschau, schluessel }) {
+ if (!VORSCHAU_ARTEN.includes(art)) return { state: 'nicht-noetig', message: '' }
+ if (!anfrage?.ok) return { state: 'ungueltig', message: anfrage?.error || '' }
+ if (!vorschau || vorschau.key !== schluessel || vorschau.status === 'laedt') return { state: 'laedt', message: '' }
+ if (vorschau.status === 'fehler') return { state: 'fehler', message: vorschau.message || 'Die Vorschau konnte nicht geladen werden.' }
+ if (!(Number(vorschau.daten?.summeCents) > 0)) return { state: 'fehler', message: 'Nichts mehr offen.' }
+ return { state: 'bereit', message: '' }
+}
+
+/** Entwurf und Senden sind nur erlaubt, wenn keine Vorschau nötig ist oder sie zur aktuellen Eingabe gehört. */
+export function darfSpeichern(status) {
+ return status?.state === 'bereit' || status?.state === 'nicht-noetig'
+}
+
+/** Zeile der Aufwandsrechnung am letzten ' – ' teilen: vorn bearbeitbar, hinten fest. */
+export function zeileTeilen(beschreibung) {
+ const s = String(beschreibung ?? '')
+ const i = s.lastIndexOf(TRENNER)
+ if (i < 0) return { vorn: s, hinten: '' }
+ return { vorn: s.slice(0, i), hinten: s.slice(i) }
+}
+
+/** Nur Zeilen mit Tag oder Kalenderwoche als Schlüssel und festem Hinten-Teil sind bearbeitbar. */
+export function zeileBearbeitbar(position) {
+ return ZEILEN_ART.test(String(position?.schluessel ?? '')) && zeileTeilen(position?.beschreibung).hinten !== ''
+}
+
+/** Höchstlänge des bearbeitbaren Teils, damit die ganze Zeile bei 200 Zeichen bleibt. */
+export function maxVorn(hinten) {
+ return Math.max(1, INVOICE_LIMITS.maxDescription - String(hinten ?? '').length)
+}
+
+/**
+ * Absenden mit Rückfrage und Sperren. `submit({ anfrage, senden, kunde, totalCents })` liefert
+ * ok | abgebrochen | laeuft | fehler | festgeschrieben. Ein zweiter Aufruf, solange der erste läuft, wird verworfen.
+ */
+export function createSubmitter({ create, confirm }) {
+ let running = false
+ return async function submit({ anfrage, senden, kunde, totalCents }) {
+ if (running) return { status: 'laeuft' }
+ if (!anfrage?.ok) return { status: 'fehler', message: anfrage?.error || '' }
+ if (senden) {
+ if (!invoiceSendGate(kunde, totalCents).sendAllowed) return { status: 'fehler', message: ADDRESS_INCOMPLETE_TEXT }
+ const frage = invoiceSendConfirmText({ name: customerName(kunde), email: kunde?.email, totalCents })
+ if (!confirm(frage)) return { status: 'abgebrochen' }
+ }
+ running = true
+ try {
+ const data = await create({ ...anfrage.body, senden: Boolean(senden) })
+ return { status: 'ok', rechnung: data?.rechnung || null, senden: Boolean(senden) }
+ } catch (err) {
+ if (isFinalizedNotSent(err)) {
+ return { status: 'festgeschrieben', notice: invoiceFailureMessage(err), finalizedFilter: finalizedFilter(err) }
+ }
+ const out = { status: 'fehler', message: invoiceFailureMessage(err) }
+ if (Number.isInteger(err?.status)) out.httpStatus = err.status
+ return out
+ } finally {
+ running = false
+ }
+ }
+}
diff --git a/src/lib/rechnungDialogApi.js b/src/lib/rechnungDialogApi.js
new file mode 100644
index 0000000..61c007c
--- /dev/null
+++ b/src/lib/rechnungDialogApi.js
@@ -0,0 +1,21 @@
+import { databases, DATABASE_ID, COLLECTIONS, Query } from './appwrite'
+
+/** Tickets eines Kunden, neueste zuerst (für „Weiteres Ticket hinzunehmen“). */
+export async function listKundenTickets(customerId) {
+ const res = await databases.listDocuments(DATABASE_ID, COLLECTIONS.WORKORDERS, [
+ Query.equal('customerId', customerId),
+ Query.orderDesc('$createdAt'),
+ Query.limit(100),
+ ])
+ return res.documents
+}
+
+/** Alle Arbeitsblätter eines Tickets (nach Ticketnummer `woid`). */
+export async function listTicketBlaetter(woid) {
+ const res = await databases.listDocuments(DATABASE_ID, COLLECTIONS.WORKSHEETS, [
+ Query.equal('woid', woid),
+ Query.orderAsc('$createdAt'),
+ Query.limit(5000),
+ ])
+ return res.documents
+}
diff --git a/src/lib/rechnungDialogState.js b/src/lib/rechnungDialogState.js
new file mode 100644
index 0000000..d5ba14b
--- /dev/null
+++ b/src/lib/rechnungDialogState.js
@@ -0,0 +1,98 @@
+// Zustand des Rechnungsdialogs „Rechnung schreiben“ (rein, ohne React; unter Node testbar).
+// `anfangszustand` liefert den leeren Zustand, `reduce` verarbeitet die Aktionen des Dialogs. Unbekannte Aktionen,
+// unbekannte Listen und Felder sowie Aktionen ohne Wirkung geben denselben Zustand (dieselbe Referenz) zurück.
+import { INVOICE_LIMITS } from './financeView'
+
+const LISTEN = ['items', 'zusatz']
+const FELDER = ['description', 'quantity', 'price']
+const MINDESTENS = { items: 1, zusatz: 0 }
+
+const leereZeile = (uid) => ({ uid, description: '', quantity: '1', price: '' })
+
+/** Startzustand. projektId: null = noch zu wählen, '' = Ohne Projekt, Text = dieses Projekt. */
+export function anfangszustand({ projektId = null } = {}) {
+ return {
+ projektId,
+ art: '',
+ auftragId: '',
+ items: [leereZeile(1)],
+ zusatz: [],
+ abgewaehlt: [],
+ weitereTickets: [],
+ anzahlung: {},
+ zeilentexte: {},
+ zeitraum: '',
+ zeitraumGeaendert: false,
+ days: String(INVOICE_LIMITS.defaultDays),
+ tageGeaendert: false,
+ memo: '',
+ }
+}
+
+function neueUid(s) {
+ return 1 + Math.max(0, ...s.items.map((z) => z.uid), ...s.zusatz.map((z) => z.uid))
+}
+
+export function reduce(s, a) {
+ switch (a?.type) {
+ case 'projekt':
+ return { ...anfangszustand({ projektId: a.projektId }), days: s.days, tageGeaendert: s.tageGeaendert, memo: s.memo }
+ case 'art':
+ return {
+ ...s,
+ art: a.art,
+ auftragId: '',
+ zeilentexte: {},
+ zeitraum: s.zeitraumGeaendert ? s.zeitraum : '',
+ }
+ case 'auftrag':
+ return { ...s, auftragId: a.auftragId, anzahlung: {} }
+ case 'position': {
+ if (!LISTEN.includes(a.liste) || !FELDER.includes(a.feld)) return s
+ return { ...s, [a.liste]: s[a.liste].map((z) => (z.uid === a.uid ? { ...z, [a.feld]: a.wert } : z)) }
+ }
+ case 'position+': {
+ if (!LISTEN.includes(a.liste) || s[a.liste].length >= INVOICE_LIMITS.maxItems) return s
+ return { ...s, [a.liste]: [...s[a.liste], leereZeile(neueUid(s))] }
+ }
+ case 'position-': {
+ if (!LISTEN.includes(a.liste) || s[a.liste].length <= MINDESTENS[a.liste]) return s
+ return { ...s, [a.liste]: s[a.liste].filter((z) => z.uid !== a.uid) }
+ }
+ case 'blatt':
+ return { ...s, abgewaehlt: s.abgewaehlt.includes(a.id) ? s.abgewaehlt.filter((id) => id !== a.id) : [...s.abgewaehlt, a.id] }
+ case 'ticket+':
+ return s.weitereTickets.some((t) => t.ticketId === a.ticket.ticketId) ? s : { ...s, weitereTickets: [...s.weitereTickets, a.ticket] }
+ case 'ticket-': {
+ const weg = Array.isArray(a.blattIds) ? a.blattIds : []
+ return {
+ ...s,
+ weitereTickets: s.weitereTickets.filter((t) => t.ticketId !== a.ticketId),
+ abgewaehlt: s.abgewaehlt.filter((id) => !weg.includes(id)),
+ }
+ }
+ case 'anzahlung':
+ return { ...s, anzahlung: { ...s.anzahlung, ...a.patch } }
+ case 'zeilentext': {
+ const zeilentexte = { ...s.zeilentexte }
+ if (!String(a.text ?? '').trim() || a.text === a.original) delete zeilentexte[a.schluessel]
+ else zeilentexte[a.schluessel] = a.text
+ return { ...s, zeilentexte }
+ }
+ case 'zeitraum':
+ return { ...s, zeitraum: a.text, zeitraumGeaendert: true }
+ case 'zeitraum-vorschlag':
+ if (s.zeitraumGeaendert || s.zeitraum === a.text) return s
+ return { ...s, zeitraum: a.text }
+ case 'tage':
+ return { ...s, days: a.wert, tageGeaendert: true }
+ case 'tage-vorschlag':
+ // das Zahlungsziel der gewählten AB; eine Eingabe des Nutzers überschreibt er nie
+ if (s.tageGeaendert || s.days === a.wert) return s
+ return { ...s, days: a.wert }
+ case 'notiz':
+ return { ...s, memo: a.wert }
+ default:
+ return s
+ }
+}
diff --git a/src/lib/rechnungDialogView.js b/src/lib/rechnungDialogView.js
new file mode 100644
index 0000000..705d18d
--- /dev/null
+++ b/src/lib/rechnungDialogView.js
@@ -0,0 +1,254 @@
+// 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'}`,
+ }))
+}
diff --git a/src/lib/teamApi.js b/src/lib/teamApi.js
index 5d02ee5..e8cc91a 100644
--- a/src/lib/teamApi.js
+++ b/src/lib/teamApi.js
@@ -1,6 +1,7 @@
import { account } from './appwrite'
import { createFinanceRequest } from './financeRequest'
import { createJwtCache } from './serverpulsAuth'
+import { abgerechnetPfad } from './auftraegeEndpoints'
import { anleitungPfad, featureBody, entscheidungBody } from './funktionenView'
import { ersatzPfad, zeitenTeamPfad } from './zeitenView'
@@ -127,6 +128,10 @@ export const getZeitenErsatz = (customerId, projectId, plan) => request(ersatzPf
/** Team-Einstellungen (Rumpf: teamBody in zeitenView): { team, teamVersion }; 400 ungueltig, 409 veraltet (err.code ZEITEN_VERALTET). PATCH, weil das Ticketsystem kein PUT durchlaesst. */
export const patchZeitenTeam = (customerId, body) => request(zeitenTeamPfad(customerId), { method: 'PATCH', body })
+// --- Abgerechnete Arbeitsblaetter (alle Mitarbeiter, ohne Betraege) ---
+/** { abgerechnet: { : '' | 'Entwurf' } }; unbekanntes Ticket: { abgerechnet: {} } */
+export const getAbgerechnet = (ticketId) => request(abgerechnetPfad(ticketId))
+
// --- Abo-Status (alle Mitarbeiter) ---
/** { productKey, active, status, storageUnits, startedAt, serviceStart, currentPeriodEnd, cancelAt } */
diff --git a/src/pages/CustomerDetailPage.jsx b/src/pages/CustomerDetailPage.jsx
index 3bf4f91..54c0d18 100644
--- a/src/pages/CustomerDetailPage.jsx
+++ b/src/pages/CustomerDetailPage.jsx
@@ -3,7 +3,7 @@ import { useParams, Link, useNavigate, useSearchParams } from 'react-router-dom'
import { databases, DATABASE_ID, COLLECTIONS, Query } from '../lib/appwrite'
import { PageHeader, Card, Button, Badge, Tabs, EmptyState, Dialog, Field } from '../components/ui'
import { StatusPill, UrgentPill } from '../components/ui/StatusPill'
-import CustomerInvoices from '../components/finance/CustomerInvoices'
+import AuftraegeUndRechnungen from '../components/auftraege/AuftraegeUndRechnungen'
import AssignedProjectCard from '../components/AssignedProjectCard'
import CopyableCredential, { getCustomerPortalPassword } from '../components/CopyableCredential'
import { updateCustomerWithPortalAccess, deleteCustomerWithPortalAccess } from '../lib/customerAdminApi'
@@ -12,6 +12,7 @@ import { useWebsiteProjects } from '../hooks/useWebsiteProjects'
import { customerTabs, resolveCustomerTab } from '../lib/customerTabs'
import { isInvoiceReady, setCustomerArchived, upgradeToCustomer, markLost, reopenAsLead } from '../lib/leads'
import { lastRejection } from '../lib/leadDuplicates'
+import { KUNDENART_OPTIONEN, KUNDENART_HINWEIS, kundenartAenderung } from '../lib/kundenFelder'
import { customerStage, isArchived, customerBadges, customerEyebrow, customerTitle } from '../lib/customerList'
import { ticketPath, ticketRef } from '../lib/ticketRoutes'
import { ticketNumber } from '../lib/ticketView'
@@ -121,7 +122,7 @@ export default function CustomerDetailPage() {
{/* key: nach Absage/Umstufung neu aufbauen, sonst ueberschreibt ein spaeteres Speichern die neuen Notizen */}
{activeTab === 'overview' && }
{activeTab === 'tickets' && }
- {activeTab === 'invoices' && setTab('overview')} />}
+ {activeTab === 'invoices' && setTab('overview')} />}
{activeTab === 'projects' && }
{activeTab === 'chat' && }
{activeTab === 'abo' && (
@@ -154,11 +155,15 @@ function OverviewTab({ customer, stage, onSaved }) {
notes: customer.notes || '',
importantCustomer: !!customer.importantCustomer,
})
+ const [kundenart, setKundenart] = useState(customer.kundenart || '')
const [busy, setBusy] = useState(false)
const [msg, setMsg] = useState(null) // { tone: 'ok' | 'err' | 'warn', text }
const [showLost, setShowLost] = useState(false)
const { user, employee } = useAuth()
+ // Die Kundenart geht nur ins Dokument, wenn sie geaendert wurde (kein Fehler bei noch nicht angelegtem Attribut)
+ const felder = () => ({ ...form, ...kundenartAenderung(kundenart, customer.kundenart) })
+
const ready = isInvoiceReady({ ...customer, ...form })
// Absage: ungespeicherte Notizen gehen mit, damit der Vermerk nichts ueberschreibt
@@ -182,7 +187,7 @@ function OverviewTab({ customer, stage, onSaved }) {
const save = async () => {
setBusy(true); setMsg(null)
try {
- await databases.updateDocument(DATABASE_ID, COLLECTIONS.CUSTOMERS, customer.$id, form)
+ await databases.updateDocument(DATABASE_ID, COLLECTIONS.CUSTOMERS, customer.$id, felder())
setMsg({ tone: 'ok', text: 'Gespeichert' })
onSaved()
} catch (err) {
@@ -194,7 +199,7 @@ function OverviewTab({ customer, stage, onSaved }) {
if (!ready) { setMsg({ tone: 'warn', text: 'Bitte zuerst alle Pflichtfelder (Unternehmen, E-Mail, Strasse, PLZ, Stadt) ausfuellen.' }); return }
setBusy(true); setMsg(null)
try {
- await upgradeToCustomer(customer, form)
+ await upgradeToCustomer(customer, felder())
setMsg({ tone: 'ok', text: 'Zum festen Kunden gemacht.' })
onSaved()
} catch (err) {
@@ -249,6 +254,13 @@ function OverviewTab({ customer, stage, onSaved }) {
{!ready && Adresse unvollständig
}
+ Kundenart
+
+ setKundenart(e.target.value)}>
+ {KUNDENART_OPTIONEN.map((o) => {o.label} )}
+
+ {KUNDENART_HINWEIS}
+
Strasse + Nr.
setForm({ ...form, street: e.target.value })} />
PLZ
diff --git a/tests/unit/auftraegeAnsicht.test.mjs b/tests/unit/auftraegeAnsicht.test.mjs
new file mode 100644
index 0000000..3844096
--- /dev/null
+++ b/tests/unit/auftraegeAnsicht.test.mjs
@@ -0,0 +1,215 @@
+import test from 'node:test'
+import assert from 'node:assert/strict'
+import { loadModule } from './load.mjs'
+
+const {
+ KONTEN_FEHLER, AUFTRAEGE_FEHLER, ladeAuftraegeAnsicht, ansichtsZustand, kundeMitKundenart, erstelltMeldung, auftragMeldung,
+} = await loadModule('src/lib/auftraegeAnsicht.js')
+
+// Antwort von getProjektkonten (V5) mit den Feldern, die die Ansicht braucht
+const konten = (extra = {}) => ({
+ auftraegeAktiv: false,
+ kunde: { id: 'k1', name: 'Anelia Wolf', kundenart: 'privat' },
+ konten: [{ projektId: 'p1', name: 'Website' }],
+ ohneProjekt: { berechnetCents: 5000, bezahltCents: 0, anzahl: 1 },
+ stripeOk: true,
+ ...extra,
+})
+const OHNE_PROJEKT_LEER = { berechnetCents: 0, bezahltCents: 0, anzahl: 0 }
+
+// Attrappe für getProjektkonten und listAuftraege: gibt Werte zurück, wirft Fehler und merkt sich die Aufrufe
+function fakeApi({ projektkonten, auftraege } = {}) {
+ const calls = []
+ const antwort = (wert) => {
+ if (wert instanceof Error) throw wert
+ return wert
+ }
+ return {
+ calls,
+ getProjektkonten: async (id) => { calls.push(['konten', id]); return antwort(projektkonten) },
+ listAuftraege: async (id) => { calls.push(['auftraege', id]); return antwort(auftraege) },
+ }
+}
+
+test('Schalter aus: die Aufträge werden gar nicht erst abgerufen', async () => {
+ const api = fakeApi({ projektkonten: konten({ auftraegeAktiv: false }), auftraege: new Error('404 darf nie ankommen') })
+ const result = await ladeAuftraegeAnsicht('k1', api)
+ assert.deepEqual(api.calls, [['konten', 'k1']])
+ assert.equal(result.konten.auftraegeAktiv, false)
+ assert.equal(result.auftraege, null)
+ assert.equal(result.auftraegeFehler, '')
+ // ein fehlendes Feld gilt wie „aus“, nur true schaltet an; eine Antwort ohne Objekt wird zu {}
+ const fehlt = fakeApi({ projektkonten: { konten: [] } })
+ assert.equal((await ladeAuftraegeAnsicht('k1', fehlt)).auftraege, null)
+ assert.deepEqual(fehlt.calls, [['konten', 'k1']])
+ const nichts = await ladeAuftraegeAnsicht('k1', fakeApi({ projektkonten: null }))
+ assert.deepEqual(nichts, { konten: {}, auftraege: null, auftraegeFehler: '' })
+})
+
+test('Schalter an: erst die Projektkonten, dann die Aufträge des Kunden', async () => {
+ const liste = [{ id: 'a1', nummer: 'AB-2026-0001', status: 'versendet' }]
+ const api = fakeApi({ projektkonten: konten({ auftraegeAktiv: true }), auftraege: { auftraege: liste } })
+ const result = await ladeAuftraegeAnsicht('k1', api)
+ assert.deepEqual(api.calls, [['konten', 'k1'], ['auftraege', 'k1']])
+ assert.deepEqual(result.auftraege, liste)
+ assert.equal(result.auftraegeFehler, '')
+ // eine Antwort ohne Liste gibt eine leere Liste statt eines Absturzes
+ const leer = await ladeAuftraegeAnsicht('k1', fakeApi({ projektkonten: konten({ auftraegeAktiv: true }), auftraege: {} }))
+ assert.deepEqual(leer.auftraege, [])
+})
+
+test('Aufträge scheitern allein: die Projektkonten bleiben, der Fehler steht eigens da', async () => {
+ const api = fakeApi({
+ projektkonten: konten({ auftraegeAktiv: true }),
+ auftraege: Object.assign(new Error('Interner Fehler.'), { status: 500 }),
+ })
+ const result = await ladeAuftraegeAnsicht('k1', api)
+ assert.equal(result.konten.konten.length, 1)
+ assert.equal(result.auftraege, null)
+ assert.equal(result.auftraegeFehler, 'Interner Fehler.')
+ const ohneText = await ladeAuftraegeAnsicht('k1', fakeApi({ projektkonten: konten({ auftraegeAktiv: true }), auftraege: new Error('') }))
+ assert.equal(ohneText.auftraegeFehler, AUFTRAEGE_FEHLER)
+ assert.equal(AUFTRAEGE_FEHLER, 'Die Aufträge konnten nicht geladen werden.')
+})
+
+test('Projektkonten scheitern, Aufträge laden: der Abschnitt Aufträge bleibt bedienbar, der Konten-Fehler steht eigens da', async () => {
+ const fehler = Object.assign(new Error('Interner Fehler.'), { status: 500 })
+ const liste = [{ id: 'a1', nummer: 'AB-2026-0001', status: 'versendet' }]
+ const api = fakeApi({ projektkonten: fehler, auftraege: { auftraege: liste } })
+ const result = await ladeAuftraegeAnsicht('k1', api)
+ assert.deepEqual(api.calls, [['konten', 'k1'], ['auftraege', 'k1']])
+ assert.deepEqual(result, { konten: {}, kontenFehler: 'Interner Fehler.', auftraege: liste, auftraegeFehler: '' })
+ const ohneText = await ladeAuftraegeAnsicht('k1', fakeApi({ projektkonten: new Error(''), auftraege: { auftraege: liste } }))
+ assert.equal(ohneText.kontenFehler, KONTEN_FEHLER)
+})
+
+test('Projektkonten und Aufträge scheitern: der Fehler der Projektkonten geht weiter (Schalter aus gibt 404)', async () => {
+ const fehler = Object.assign(new Error('Interner Fehler.'), { status: 500 })
+ const api = fakeApi({ projektkonten: fehler, auftraege: Object.assign(new Error('Nicht gefunden.'), { status: 404 }) })
+ await assert.rejects(ladeAuftraegeAnsicht('k1', api), (e) => e === fehler)
+ assert.deepEqual(api.calls, [['konten', 'k1'], ['auftraege', 'k1']])
+})
+
+test('ansichtsZustand: Projekte-Fehler mit geladenen Aufträgen – Projekte „fehler“, Aufträge „bereit“', () => {
+ const liste = [{ id: 'a1' }]
+ const z = ansichtsZustand({ data: { konten: {}, kontenFehler: 'Interner Fehler.', auftraege: liste, auftraegeFehler: '' }, error: '' })
+ assert.equal(z.projekteStatus, 'fehler')
+ assert.equal(z.fehler, 'Interner Fehler.')
+ assert.equal(z.auftraegeAktiv, true) // der Server hat die Liste geliefert, der Schalter ist also an
+ assert.equal(z.auftraegeStatus, 'bereit')
+ assert.deepEqual(z.auftraege, liste)
+ assert.deepEqual(z.konten, [])
+ assert.equal(z.nachladeFehler, '')
+ // scheitert auch ein späteres Neuladen, steht dessen Meldung im Fehlerkasten; die Aufträge bleiben stehen
+ const spaeter = ansichtsZustand({ data: { konten: {}, kontenFehler: 'Interner Fehler.', auftraege: liste, auftraegeFehler: '' }, error: 'Netz weg' })
+ assert.equal(spaeter.projekteStatus, 'fehler')
+ assert.equal(spaeter.fehler, 'Netz weg')
+ assert.equal(spaeter.auftraegeStatus, 'bereit')
+})
+
+test('ansichtsZustand: vor der ersten Antwort „laden“, nach einem Fehler „fehler“ mit der Meldung', () => {
+ const laden = ansichtsZustand({ data: null, error: '' })
+ assert.equal(laden.projekteStatus, 'laden')
+ assert.equal(laden.auftraegeStatus, 'aus') // der Schalter ist noch unbekannt: kein Abschnitt, kein Flackern
+ assert.deepEqual(laden.konten, [])
+ assert.equal(ansichtsZustand().projekteStatus, 'laden')
+ const fehler = ansichtsZustand({ data: null, error: KONTEN_FEHLER })
+ assert.equal(fehler.projekteStatus, 'fehler')
+ assert.equal(fehler.fehler, KONTEN_FEHLER)
+ assert.equal(fehler.auftraegeStatus, 'aus') // ohne Projektkonten kein Schalter, also auch keine Aufträge
+ assert.equal(KONTEN_FEHLER, 'Die Projektübersicht konnte nicht geladen werden.')
+})
+
+test('ansichtsZustand: Schalter aus – Projekte bereit, kein Aufträge-Abschnitt', () => {
+ const z = ansichtsZustand({ data: { konten: konten({ auftraegeAktiv: false }), auftraege: null, auftraegeFehler: '' }, error: '' })
+ assert.equal(z.projekteStatus, 'bereit')
+ assert.equal(z.auftraegeAktiv, false)
+ assert.equal(z.auftraegeStatus, 'aus')
+ assert.equal(z.konten.length, 1)
+ assert.deepEqual(z.ohneProjekt, { berechnetCents: 5000, bezahltCents: 0, anzahl: 1 })
+ assert.equal(z.stripeOk, true)
+ assert.deepEqual(z.kunde, { id: 'k1', name: 'Anelia Wolf', kundenart: 'privat' })
+ assert.equal(z.nachladeFehler, '')
+})
+
+test('ansichtsZustand: Schalter an – Aufträge bereit oder mit eigenem Fehler, die Projekte bleiben', () => {
+ const liste = [{ id: 'a1' }]
+ const bereit = ansichtsZustand({ data: { konten: konten({ auftraegeAktiv: true }), auftraege: liste, auftraegeFehler: '' }, error: '' })
+ assert.equal(bereit.auftraegeAktiv, true)
+ assert.equal(bereit.auftraegeStatus, 'bereit')
+ assert.deepEqual(bereit.auftraege, liste)
+ const kaputt = ansichtsZustand({
+ data: { konten: konten({ auftraegeAktiv: true }), auftraege: null, auftraegeFehler: 'Interner Fehler.' },
+ error: '',
+ })
+ assert.equal(kaputt.projekteStatus, 'bereit')
+ assert.equal(kaputt.auftraegeStatus, 'fehler')
+ assert.equal(kaputt.auftraegeFehler, 'Interner Fehler.')
+ assert.deepEqual(kaputt.auftraege, [])
+})
+
+test('ansichtsZustand: Neuladen scheitert – die alten Daten bleiben, die Meldung steht daneben', () => {
+ const data = { konten: konten({ auftraegeAktiv: true }), auftraege: [{ id: 'a1' }], auftraegeFehler: '' }
+ const z = ansichtsZustand({ data, error: 'Das Kundenportal ist gerade nicht erreichbar.' })
+ assert.equal(z.projekteStatus, 'bereit')
+ assert.equal(z.fehler, '')
+ assert.equal(z.nachladeFehler, 'Das Kundenportal ist gerade nicht erreichbar.')
+ assert.equal(z.konten.length, 1)
+ assert.equal(z.auftraegeStatus, 'bereit')
+ assert.equal(z.auftraege.length, 1)
+})
+
+test('ansichtsZustand: unvollständige Antworten brechen die Ansicht nicht', () => {
+ for (const data of [{}, { konten: null }, { konten: {} }, { konten: { konten: 'x', ohneProjekt: 5, kunde: 'k' } }]) {
+ const z = ansichtsZustand({ data, error: '' })
+ assert.equal(z.projekteStatus, 'bereit')
+ assert.deepEqual(z.konten, [])
+ assert.deepEqual(z.ohneProjekt, OHNE_PROJEKT_LEER)
+ assert.equal(z.stripeOk, true)
+ assert.equal(z.kunde, null)
+ assert.equal(z.auftraegeAktiv, false)
+ assert.equal(z.auftraegeStatus, 'aus')
+ }
+ assert.equal(ansichtsZustand({ data: { konten: konten({ stripeOk: false }) }, error: '' }).stripeOk, false)
+})
+
+test('kundeMitKundenart: ein gültiger Wert des Portals gewinnt gegen das veraltete Kundendokument', () => {
+ const doc = { $id: 'k1', name: 'Anelia Wolf', kundenart: '' }
+ assert.deepEqual(kundeMitKundenart(doc, { id: 'k1', kundenart: 'privat' }), { ...doc, kundenart: 'privat' })
+ assert.equal(doc.kundenart, '') // das Dokument der Seite wird nicht verändert
+ assert.equal(kundeMitKundenart({ ...doc, kundenart: 'unternehmen' }, { kundenart: 'privat' }).kundenart, 'privat')
+})
+
+test('kundeMitKundenart: ohne gültigen Wert vom Portal bleibt das Dokument (dieselbe Referenz)', () => {
+ const doc = { $id: 'k1', kundenart: 'unternehmen' }
+ for (const info of [null, undefined, {}, { kundenart: '' }, { kundenart: 'firma' }, { kundenart: 5 }, 'privat']) {
+ assert.equal(kundeMitKundenart(doc, info), doc)
+ }
+ assert.equal(kundeMitKundenart(null, { kundenart: 'privat' }), null)
+})
+
+test('erstelltMeldung: gesendet, Entwurf und die Meldung nach festgeschriebener, nicht gesendeter Rechnung', () => {
+ assert.deepEqual(
+ erstelltMeldung({ number: 'WEBKLAR-0009', customerEmail: 'a@b.de' }, { senden: true }),
+ { tone: 'ok', text: 'Rechnung WEBKLAR-0009 wurde an a@b.de gesendet.' },
+ )
+ assert.deepEqual(erstelltMeldung({ id: 'in_1' }, { senden: false }), { tone: 'ok', text: 'Der Entwurf wurde gespeichert.' })
+ assert.deepEqual(erstelltMeldung(null, undefined), { tone: 'ok', text: 'Der Entwurf wurde gespeichert.' })
+ assert.deepEqual(erstelltMeldung(null, null), { tone: 'ok', text: 'Der Entwurf wurde gespeichert.' })
+ const text = 'Rechnung WEBKLAR-0010 wurde festgeschrieben, aber nicht versendet. Bitte in der Liste erneut senden.'
+ assert.deepEqual(erstelltMeldung(null, { senden: true, notice: text, tone: 'warn', finalizedFilter: 'offen' }), { tone: 'warn', text })
+ assert.deepEqual(erstelltMeldung(null, { senden: true, notice: text }), { tone: 'ok', text })
+})
+
+test('auftragMeldung: Entwurf, gesendet und gesendet ohne zugestellte Mail', () => {
+ assert.deepEqual(auftragMeldung({ status: 'entwurf', nummer: '' }), { tone: 'ok', text: 'Der Entwurf wurde gespeichert.' })
+ assert.deepEqual(
+ auftragMeldung({ status: 'versendet', nummer: 'AB-2026-0001', gesendetAn: 'k@x.de', mailStatus: 'gesendet' }),
+ { tone: 'ok', text: 'Auftragsbestätigung AB-2026-0001 wurde an k@x.de gesendet.' },
+ )
+ assert.deepEqual(
+ auftragMeldung({ status: 'versendet', nummer: 'AB-2026-0001', gesendetAn: 'k@x.de', mailStatus: 'fehler' }),
+ { tone: 'warn', text: 'Auftragsbestätigung AB-2026-0001 ist versendet, die Mail an den Kunden wurde aber nicht zugestellt. Bitte in der Liste erneut senden.' },
+ )
+ assert.deepEqual(auftragMeldung(null), { tone: 'ok', text: 'Die Auftragsbestätigung wurde gespeichert.' })
+})
diff --git a/tests/unit/auftraegeEndpoints.test.mjs b/tests/unit/auftraegeEndpoints.test.mjs
new file mode 100644
index 0000000..2935f10
--- /dev/null
+++ b/tests/unit/auftraegeEndpoints.test.mjs
@@ -0,0 +1,86 @@
+import test from 'node:test'
+import assert from 'node:assert/strict'
+import { loadModule } from './load.mjs'
+
+const { createAuftraegeEndpoints, abgerechnetPfad } = await loadModule('src/lib/auftraegeEndpoints.js')
+const { createFinanceRequest } = await loadModule('src/lib/financeRequest.js')
+
+const BASE = 'https://portal.example.invalid/api/admin/finanzen'
+
+// Echte Anfrage-Schicht mit Fetch-Attrappe: geprüft wird, was das Portal erreicht (URL, Methode, Kopf, JSON-Körper)
+function setup(answer = () => ({ ok: true, status: 200, json: async () => ({ ok: true }) })) {
+ const calls = []
+ const request = createFinanceRequest({
+ baseUrl: BASE,
+ createJWT: async () => ({ jwt: 'jwt-1' }),
+ fetchImpl: async (url, init) => {
+ calls.push({ url, method: init.method, body: init.body, auth: init.headers.Authorization })
+ return answer()
+ },
+ })
+ return { api: createAuftraegeEndpoints(request), calls }
+}
+
+// [Funktion, Argumente, Methode, Pfad ab BASE, Körper]
+const FAELLE = [
+ ['getProjektkonten', ['k1'], 'GET', '/projektkonten?kunde=k1', undefined],
+ ['getProjektkonto', ['p1'], 'GET', '/projektkonten/p1', undefined],
+ ['previewInvoice', [{ customerId: 'k1', art: 'aufwand', projektId: 'p1' }], 'POST', '/rechnungen/vorschau', { customerId: 'k1', art: 'aufwand', projektId: 'p1' }],
+ ['listAuftraege', ['k1'], 'GET', '/auftraege?kunde=k1', undefined],
+ ['getAuftrag', ['a1'], 'GET', '/auftraege/a1', undefined],
+ ['createAuftrag', [{ kundenId: 'k1', titel: 'Website' }], 'POST', '/auftraege', { kundenId: 'k1', titel: 'Website' }],
+ ['patchAuftrag', ['a1', { titel: 'Neu' }], 'PATCH', '/auftraege/a1', { titel: 'Neu' }],
+ ['deleteAuftrag', ['a1'], 'DELETE', '/auftraege/a1', undefined],
+ ['sendAuftrag', ['a1'], 'POST', '/auftraege/a1/senden', undefined],
+ ['erinnereAuftrag', ['a1'], 'POST', '/auftraege/a1/erinnern', undefined],
+ ['neueFassungAuftrag', ['a1'], 'POST', '/auftraege/a1/neue-fassung', undefined],
+ ['storniereAuftrag', ['a1'], 'POST', '/auftraege/a1/stornieren', undefined],
+ ['ordneAuftragProjektZu', ['a1', 'p1'], 'POST', '/auftraege/a1/projekt', { projektId: 'p1' }],
+]
+
+test('alle 14 Funktionen sind da (V8 samt „Projekt zuordnen“ aus V5)', () => {
+ const { api } = setup()
+ assert.deepEqual(Object.keys(api).sort(), [
+ 'createAuftrag', 'deleteAuftrag', 'erinnereAuftrag', 'getAuftrag', 'getAuftragPdf', 'getProjektkonten', 'getProjektkonto',
+ 'listAuftraege', 'neueFassungAuftrag', 'ordneAuftragProjektZu', 'patchAuftrag', 'previewInvoice', 'sendAuftrag', 'storniereAuftrag',
+ ])
+})
+
+test('Pfad, Methode, Kopf und Körper jeder Funktion (die Vorschau trägt keinen Schlüssel dazu)', async () => {
+ for (const [name, args, method, path, body] of FAELLE) {
+ const { api, calls } = setup()
+ await api[name](...args)
+ assert.equal(calls.length, 1, name)
+ assert.equal(calls[0].url, `${BASE}${path}`, name)
+ assert.equal(calls[0].method, method, name)
+ assert.equal(calls[0].auth, 'Bearer jwt-1', name)
+ assert.equal(calls[0].body, body === undefined ? undefined : JSON.stringify(body), name)
+ }
+})
+
+test('Kennungen werden URL-kodiert, auch im Pfad der abgerechneten Arbeitsblätter', async () => {
+ const { api, calls } = setup()
+ await api.getProjektkonten('k 1&x=2')
+ await api.getAuftrag('a/b?c')
+ await api.storniereAuftrag('a#1')
+ await api.ordneAuftragProjektZu('a b', 'p/1')
+ assert.deepEqual(calls.map((c) => c.url.slice(BASE.length)), [
+ '/projektkonten?kunde=k%201%26x%3D2',
+ '/auftraege/a%2Fb%3Fc',
+ '/auftraege/a%231/stornieren',
+ '/auftraege/a%20b/projekt',
+ ])
+ assert.equal(calls[3].body, JSON.stringify({ projektId: 'p/1' })) // die Projektkennung steht im Körper, nicht im Pfad
+ assert.equal(abgerechnetPfad('100131'), '/arbeitsblaetter/abgerechnet?ticketId=100131')
+ assert.equal(abgerechnetPfad('t 1/2'), '/arbeitsblaetter/abgerechnet?ticketId=t%201%2F2')
+})
+
+test('getAuftragPdf liefert das PDF als Blob; eine Fehlerantwort kommt als Meldung des Portals', async () => {
+ const datei = new Blob(['%PDF-1.7'], { type: 'application/pdf' })
+ const ok = setup(() => ({ ok: true, status: 200, json: async () => assert.fail('kein JSON lesen'), blob: async () => datei }))
+ assert.equal(await ok.api.getAuftragPdf('a1'), datei)
+ assert.equal(ok.calls[0].url, `${BASE}/auftraege/a1/pdf`)
+ assert.equal(ok.calls[0].method, 'GET')
+ const fehler = setup(() => ({ ok: false, status: 404, json: async () => ({ error: 'PDF nicht gefunden.' }), blob: async () => assert.fail('kein Blob lesen') }))
+ await assert.rejects(fehler.api.getAuftragPdf('a1'), (e) => e.message === 'PDF nicht gefunden.' && e.status === 404)
+})
diff --git a/tests/unit/auftragPdf.test.mjs b/tests/unit/auftragPdf.test.mjs
new file mode 100644
index 0000000..31f8202
--- /dev/null
+++ b/tests/unit/auftragPdf.test.mjs
@@ -0,0 +1,51 @@
+import test from 'node:test'
+import assert from 'node:assert/strict'
+import { loadModule } from './load.mjs'
+
+const { PDF_FREIGABE_NACH_MS, alsPdfBlob, oeffnePdfImTab } = await loadModule('src/lib/auftragPdf.js')
+
+// Fester Ablauf: Tab zuerst (der Browser blockiert sonst das Pop-up), dann Blob holen, Objekt-URL erzeugen, später freigeben
+function umgebung() {
+ const ablauf = []
+ const tab = { location: {}, opener: 'woms', close: () => ablauf.push('schliessen') }
+ const win = { open: (...args) => { ablauf.push('oeffnen'); win.args = args; return tab } }
+ const urlApi = {
+ createObjectURL: () => { ablauf.push('url'); return 'blob:https://woms.test/abc' },
+ revokeObjectURL: (url) => ablauf.push(`frei:${url}`),
+ }
+ const timer = (fn, ms) => { ablauf.push(`timer:${ms}`); timer.fn = fn }
+ return { ablauf, tab, win, urlApi, timer }
+}
+
+test('alsPdfBlob: setzt den Typ application/pdf, ein passender Blob bleibt unverändert', async () => {
+ const roh = new Blob(['%PDF-1.4'], { type: 'application/octet-stream' })
+ const pdf = alsPdfBlob(roh)
+ assert.equal(pdf.type, 'application/pdf')
+ assert.equal(await pdf.text(), '%PDF-1.4')
+ const schon = new Blob(['x'], { type: 'application/pdf' })
+ assert.equal(alsPdfBlob(schon), schon)
+})
+
+test('oeffnePdfImTab: Tab zuerst, dann Blob, Objekt-URL im Tab, später freigeben', async () => {
+ const u = umgebung()
+ await oeffnePdfImTab(async () => { u.ablauf.push('holen'); return new Blob(['%PDF'], { type: 'application/pdf' }) }, u)
+ assert.equal(PDF_FREIGABE_NACH_MS, 300000)
+ assert.deepEqual(u.ablauf, ['oeffnen', 'holen', 'url', 'timer:300000'])
+ assert.equal(u.tab.location.href, 'blob:https://woms.test/abc')
+ assert.equal(u.tab.opener, null)
+ u.timer.fn()
+ assert.equal(u.ablauf.at(-1), 'frei:blob:https://woms.test/abc')
+})
+
+test('oeffnePdfImTab: Fehler beim Holen schließt den Tab, es entsteht keine URL', async () => {
+ const u = umgebung()
+ await assert.rejects(() => oeffnePdfImTab(async () => { u.ablauf.push('holen'); throw new Error('PDF nicht gefunden.') }, u), /PDF nicht gefunden/)
+ assert.deepEqual(u.ablauf, ['oeffnen', 'holen', 'schliessen'])
+})
+
+test('oeffnePdfImTab: blockiertes Pop-up, der Blob wird gar nicht geholt', async () => {
+ let geholt = false
+ const win = { open: () => null }
+ await assert.rejects(() => oeffnePdfImTab(async () => { geholt = true; return new Blob() }, { win }), { message: 'Neuer Tab wurde vom Browser blockiert.' })
+ assert.equal(geholt, false)
+})
diff --git a/tests/unit/auftragView.test.mjs b/tests/unit/auftragView.test.mjs
new file mode 100644
index 0000000..1845a09
--- /dev/null
+++ b/tests/unit/auftragView.test.mjs
@@ -0,0 +1,680 @@
+import test from 'node:test'
+import assert from 'node:assert/strict'
+import { loadModule } from './load.mjs'
+
+const {
+ AUFTRAG_LIMITS, STUNDENSATZ_STANDARD_CENTS, AUFTRAG_WEGE, SCHRITTE, zahlungsplanOptionen,
+ parseStunden, stundenText, parseProzent, parseTage, heuteBerlin,
+ neuePosition, neuesFormular, auftragZuFormular, waehleProjekt, wechselArt, formularSummeCents,
+ pruefeSchritt, formularZuNutzlast, zahlungsplanVorschau, sendeGate, zeitquelleOptionen, schalteZeitquelle,
+ kundenartWirksam, VOR_ORT_HINWEIS, vorOrtHinweis, auftragHinweise,
+ AUFTRAG_STATUS, PROJEKT_ZUORDNEN_STATUS, istEntwurf, zuordnenOptionen, projektZuordnenMoeglich, auftragAktionen,
+ auftragNotizen, auftragZeilen, auftragBestaetigung, auftragErfolg, auftragFehler, neueSperre,
+} = await loadModule('src/lib/auftragView.js')
+
+const KONTEN = [
+ { projektId: 'p1', name: 'Website Praxis Müller', tickets: [{ id: 't1', woid: '100131' }] },
+ { projektId: 'p2', name: 'Shop Relaunch', tickets: [{ id: 't2', woid: '100140' }, { id: 't3', woid: '100141' }] },
+]
+const KUNDE = { $id: 'c1', name: 'Praxis Müller', email: 'info@praxis.test', street: 'Hauptstr. 1', postalCode: '67722', city: 'Winnweiler', kundenart: '', customerStatus: 'customer' }
+const festpreisForm = (over = {}) => ({
+ ...neuesFormular({ heute: '2026-10-06' }),
+ titel: 'Website', kundenart: 'privat', auftragWeg: 'telefon',
+ positionen: [{ uid: 1, description: 'Startseite', quantity: '1', price: '800,00' }],
+ ...over,
+})
+const aufwandForm = (over = {}) => festpreisForm({ art: 'aufwand', positionen: [], ...over })
+const OK = { ok: true, fehler: {}, erstes: '' }
+
+test('Konstanten gleichen dem Vertrag V2 und der Spec (Limits, Wege, Zahlungspläne, Schritte)', () => {
+ assert.deepEqual(AUFTRAG_LIMITS, {
+ maxPositionen: 20, maxBeschreibung: 200, maxMenge: 999, maxEinzelpreisCents: 10_000_000,
+ maxSummeCents: 99_999_999, maxTitel: 200, maxZahlungsplanText: 500, maxZahlungszielTage: 60,
+ standardZahlungszielTage: 14, standardAnzahlungProzent: 50, maxZeitquellen: 10,
+ maxSchaetzungMinuten: 99_960, maxLeistungszeitraum: 140,
+ })
+ assert.equal(STUNDENSATZ_STANDARD_CENTS, 10000)
+ assert.deepEqual(AUFTRAG_WEGE, [
+ { value: 'muendlich', label: 'Mündlich' }, { value: 'telefon', label: 'Telefonisch' },
+ { value: 'email', label: 'Per E-Mail' }, { value: 'vor_ort', label: 'Vor Ort' }, { value: 'chat', label: 'Im Chat' },
+ ])
+ assert.deepEqual(zahlungsplanOptionen('festpreis'), [
+ { value: 'nach_abnahme', label: 'Alles nach Abnahme' },
+ { value: 'anzahlung', label: 'Anzahlung bei Auftrag, Rest nach Abnahme' },
+ { value: 'frei', label: 'Frei (eigener Text)' },
+ ])
+ assert.deepEqual(zahlungsplanOptionen('aufwand'), [
+ { value: 'nach_abnahme', label: 'Alles nach Abnahme' },
+ { value: 'monatlich', label: 'Monatlich nach Aufwand' },
+ { value: 'frei', label: 'Frei (eigener Text)' },
+ ])
+ assert.deepEqual(SCHRITTE, [
+ { id: 1, label: 'Projekt' }, { id: 2, label: 'Auftrag' }, { id: 3, label: 'Zahlung und Termine' }, { id: 4, label: 'Vorschau' },
+ ])
+})
+
+test('parseStunden: Stunden mit Komma in 15-Minuten-Schritten werden Minuten', () => {
+ const gut = { '10': 600, '10,25': 615, '10,5': 630, '10,50': 630, '10,75': 645, '0,25': 15, '1': 60, '12,5 h': 750, '2 Std.': 120, ' 8 ': 480, '1666': 99960 }
+ for (const [text, minuten] of Object.entries(gut)) assert.equal(parseStunden(text), minuten, text)
+ for (const schlecht of ['', '0', '0,00', '10,3', '10,30', '10,1', '10.5', '-1', 'abc', '1666,25', '1667', '10,255', '1e3', ',5', '10 Minuten', null, undefined]) {
+ assert.equal(parseStunden(schlecht), null, String(schlecht))
+ }
+})
+
+test('stundenText: Minuten als Stunden mit Komma, ohne überflüssige Nullen', () => {
+ assert.deepEqual([15, 30, 45, 60, 75, 120, 135, 150, 600, 99960].map(stundenText), ['0,25', '0,5', '0,75', '1', '1,25', '2', '2,25', '2,5', '10', '1666'])
+ assert.equal(stundenText(null), '–')
+ assert.equal(stundenText('x'), '–')
+})
+
+test('parseProzent und parseTage', () => {
+ assert.deepEqual(['50', ' 30 % ', '1', '99'].map(parseProzent), [50, 30, 1, 99])
+ for (const schlecht of ['', '0', '100', '-5', '33,3', '5a', 'x', null]) assert.equal(parseProzent(schlecht), null, String(schlecht))
+ assert.deepEqual(['14', '0', '60', ' 7 '].map(parseTage), [14, 0, 60, 7])
+ for (const schlecht of ['', '61', '-1', '1,5', 'x', '1000', null]) assert.equal(parseTage(schlecht), null, String(schlecht))
+})
+
+test('heuteBerlin: Berliner Kalendertag, nicht der UTC-Tag', () => {
+ assert.equal(heuteBerlin(Date.parse('2026-10-05T22:30:00.000Z')), '2026-10-06')
+ assert.equal(heuteBerlin(Date.parse('2026-10-05T21:59:00.000Z')), '2026-10-05')
+ assert.match(heuteBerlin(), /^\d{4}-\d{2}-\d{2}$/)
+})
+
+test('neuesFormular: Vorgaben; ein Projekt füllt Titel und Zeitquelle', () => {
+ const f = neuesFormular({ projektId: 'p2', konten: KONTEN, heute: '2026-10-06' })
+ const { positionen, ...rest } = f
+ assert.equal(positionen.length, 1)
+ const { uid, ...zeile } = positionen[0]
+ assert.equal(typeof uid, 'number')
+ assert.deepEqual(zeile, { description: '', quantity: '1', price: '' })
+ assert.deepEqual(rest, {
+ projektId: 'p2', titel: 'Shop Relaunch', titelGeaendert: false, kundenart: '', art: 'festpreis',
+ auftragVom: '2026-10-06', auftragWeg: '',
+ stundensatz: '100,00', schaetzung: '',
+ zahlungsart: 'nach_abnahme', prozent: '50', zahlungstext: '', zahlungsziel: '14',
+ startAm: '', fertigBis: '', zeitquelle: ['t2', 't3'], zeitquelleGeaendert: false,
+ })
+ assert.deepEqual(neuesFormular({ konten: KONTEN, heute: '2026-10-06' }).zeitquelle, [])
+ assert.equal(neuesFormular({ projektId: 'gibtsnicht', konten: KONTEN, heute: '2026-10-06' }).projektId, '')
+ assert.equal(neuesFormular({ projektId: 'p1', heute: '2026-10-06' }).projektId, '')
+ assert.notEqual(neuePosition().uid, neuePosition().uid)
+})
+
+test('auftragZuFormular: Entwurf vom Server wird zum Formular (Euro- und Stundentexte)', () => {
+ const entwurf = {
+ id: 'a1', status: 'entwurf', projektId: 'p1', titel: 'Website', art: 'festpreis',
+ positionen: [{ beschreibung: 'Startseite', menge: 2, einzelpreisCents: 40000 }],
+ stundensatzCents: null, schaetzungMinuten: null, summeCents: 80000,
+ zahlungsplan: { art: 'anzahlung', prozent: 30, text: '' }, zahlungszielTage: 21,
+ auftragVom: '2026-10-05', auftragWeg: 'email', startAm: '2026-11-01', fertigBis: '', zeitquelle: ['t1'],
+ }
+ const f = auftragZuFormular(entwurf)
+ assert.deepEqual(f.positionen.map(({ uid, ...p }) => p), [{ description: 'Startseite', quantity: '2', price: '400,00' }])
+ const { positionen, ...rest } = f
+ assert.deepEqual(rest, {
+ projektId: 'p1', titel: 'Website', titelGeaendert: true, kundenart: '', art: 'festpreis',
+ auftragVom: '2026-10-05', auftragWeg: 'email',
+ stundensatz: '100,00', schaetzung: '',
+ zahlungsart: 'anzahlung', prozent: '30', zahlungstext: '', zahlungsziel: '21',
+ startAm: '2026-11-01', fertigBis: '', zeitquelle: ['t1'], zeitquelleGeaendert: true,
+ })
+ const aufwand = auftragZuFormular({
+ ...entwurf, art: 'aufwand', positionen: [], stundensatzCents: 9550, schaetzungMinuten: 750, summeCents: null,
+ zahlungsplan: { art: 'frei', prozent: null, text: 'Nach Absprache' },
+ })
+ assert.equal(aufwand.stundensatz, '95,50')
+ assert.equal(aufwand.schaetzung, '12,5')
+ assert.equal(aufwand.positionen.length, 1) // eine leere Zeile für den Wechsel auf Festpreis
+ assert.deepEqual([aufwand.zahlungsart, aufwand.prozent, aufwand.zahlungstext], ['frei', '50', 'Nach Absprache'])
+})
+
+test('waehleProjekt: Titel und Zeitquelle folgen dem Projekt, bis sie von Hand geändert wurden', () => {
+ const f0 = neuesFormular({ konten: KONTEN, heute: '2026-10-06' })
+ const f1 = waehleProjekt(f0, 'p1', KONTEN)
+ assert.deepEqual([f1.projektId, f1.titel, f1.zeitquelle], ['p1', 'Website Praxis Müller', ['t1']])
+ const f2 = waehleProjekt(f1, 'p2', KONTEN)
+ assert.deepEqual([f2.titel, f2.zeitquelle], ['Shop Relaunch', ['t2', 't3']])
+ const f3 = waehleProjekt(f2, '', KONTEN)
+ assert.deepEqual([f3.projektId, f3.titel, f3.zeitquelle], ['', '', []])
+ assert.equal(waehleProjekt(f1, 'gibtsnicht', KONTEN).projektId, '')
+ const hand = { ...f1, titel: 'Eigener Titel', titelGeaendert: true, zeitquelle: ['t7'], zeitquelleGeaendert: true }
+ const f4 = waehleProjekt(hand, 'p2', KONTEN)
+ assert.deepEqual([f4.projektId, f4.titel, f4.zeitquelle], ['p2', 'Eigener Titel', ['t7']])
+ assert.equal(f0.projektId, '') // die Eingabe bleibt unverändert
+})
+
+test('wechselArt: ein unpassender Zahlungsplan wird zurückgesetzt', () => {
+ const f = { ...neuesFormular({ heute: '2026-10-06' }), zahlungsart: 'anzahlung' }
+ assert.equal(wechselArt(f, 'aufwand').art, 'aufwand')
+ assert.equal(wechselArt(f, 'aufwand').zahlungsart, 'nach_abnahme')
+ assert.equal(wechselArt(f, 'festpreis').zahlungsart, 'anzahlung')
+ assert.equal(wechselArt({ ...f, art: 'aufwand', zahlungsart: 'monatlich' }, 'festpreis').zahlungsart, 'nach_abnahme')
+ assert.equal(wechselArt({ ...f, zahlungsart: 'frei' }, 'aufwand').zahlungsart, 'frei')
+ assert.equal(wechselArt(f, 'quatsch'), f)
+})
+
+test('formularSummeCents: Festpreis summiert gültige Positionen, Aufwand hat keine Summe', () => {
+ const f = festpreisForm({
+ positionen: [
+ { uid: 1, description: 'a', quantity: '2', price: '100,00' },
+ { uid: 2, description: 'b', quantity: '1', price: '50' },
+ { uid: 3, description: 'c', quantity: 'x', price: '9,99' },
+ ],
+ })
+ assert.equal(formularSummeCents(f), 25000)
+ assert.equal(formularSummeCents(festpreisForm({ positionen: [] })), 0)
+ assert.equal(formularSummeCents(aufwandForm()), null)
+})
+
+test('pruefeSchritt 1: Titel ist Pflicht, die Kundenart nur, wenn sie am Kunden fehlt', () => {
+ const f = neuesFormular({ heute: '2026-10-06' })
+ const r = pruefeSchritt(1, f, { kunde: KUNDE })
+ assert.equal(r.ok, false)
+ assert.deepEqual(r.fehler, { titel: 'Bitte einen Titel eingeben (höchstens 200 Zeichen).', kundenart: 'Bitte die Kundenart wählen.' })
+ assert.equal(r.erstes, 'titel')
+ assert.deepEqual(pruefeSchritt(1, { ...f, titel: ' Website ', kundenart: 'privat' }, { kunde: KUNDE }), OK)
+ assert.deepEqual(pruefeSchritt(1, { ...f, titel: 'x' }, { kunde: { ...KUNDE, kundenart: 'privat' } }), OK)
+ assert.equal(pruefeSchritt(1, { ...f, titel: 'x'.repeat(201), kundenart: 'privat' }, { kunde: KUNDE }).ok, false)
+ assert.equal(pruefeSchritt(1, { ...f, titel: 'x'.repeat(200), kundenart: 'privat' }, { kunde: KUNDE }).ok, true)
+ assert.equal(pruefeSchritt(1, { ...f, titel: 'x', kundenart: 'firma' }, { kunde: KUNDE }).fehler.kundenart, 'Bitte die Kundenart wählen.')
+})
+
+test('pruefeSchritt 2 (Festpreis): Datum, Weg, Positionen und Summe', () => {
+ const pruefe = (over) => pruefeSchritt(2, festpreisForm(over), { kunde: KUNDE })
+ assert.deepEqual(pruefe(), OK)
+ const r = pruefe({ auftragVom: '', auftragWeg: '' })
+ assert.deepEqual(r.fehler, { auftragVom: 'Bitte das Datum der Absprache angeben.', auftragWeg: 'Bitte den Weg der Absprache wählen.' })
+ assert.equal(r.erstes, 'auftragVom')
+ for (const schlecht of ['2026-02-30', '06.10.2026', '2026-1-1', 'morgen']) {
+ assert.equal(pruefe({ auftragVom: schlecht }).fehler.auftragVom, 'Bitte das Datum der Absprache angeben.', schlecht)
+ }
+ assert.equal(pruefe({ auftragWeg: 'brieftaube' }).fehler.auftragWeg, 'Bitte den Weg der Absprache wählen.')
+ assert.equal(pruefe({ art: 'xyz' }).fehler.art, 'Bitte die Art des Auftrags wählen.')
+
+ const pos = (o = {}) => ({ uid: 1, description: 'x', quantity: '1', price: '10,00', ...o })
+ const meldung = (positionen) => pruefe({ positionen }).fehler.positionen
+ const PREIS = 'Der Einzelpreis muss ein Betrag zwischen 0,01 € und 100.000,00 € sein (z. B. 120,00).'
+ assert.equal(meldung([pos({ description: ' ' })]), 'Position 1: Bitte eine Beschreibung eingeben.')
+ assert.equal(meldung([pos(), pos({ description: 'y'.repeat(201) })]), 'Position 2: Die Beschreibung darf höchstens 200 Zeichen lang sein.')
+ assert.equal(meldung([pos({ quantity: '0' })]), 'Position 1: Die Menge muss eine ganze Zahl von 1 bis 999 sein.')
+ assert.equal(meldung([pos({ quantity: '1000' })]), 'Position 1: Die Menge muss eine ganze Zahl von 1 bis 999 sein.')
+ assert.equal(meldung([pos({ price: '' })]), `Position 1: ${PREIS}`)
+ assert.equal(meldung([pos({ price: '100.000,01' })]), `Position 1: ${PREIS}`)
+ assert.equal(meldung([]), 'Eine Auftragsbestätigung braucht 1 bis 20 Positionen.')
+ assert.equal(meldung(Array.from({ length: 21 }, () => pos())), 'Eine Auftragsbestätigung braucht 1 bis 20 Positionen.')
+ assert.equal(meldung(Array.from({ length: 20 }, () => pos())), undefined)
+ // Summe: genau 999.999,99 € gehen, 1.000.000,00 € nicht
+ const neun = Array.from({ length: 9 }, () => pos({ price: '100.000,00' }))
+ assert.equal(meldung([...neun, pos({ price: '99.999,99' })]), undefined)
+ assert.equal(meldung([...neun, pos({ price: '100.000,00' })]), 'Die Summe ist zu hoch (höchstens 999.999,99 €).')
+})
+
+test('pruefeSchritt 2 (Aufwand): Stundensatz und Schätzung, Positionen zählen nicht', () => {
+ const pruefe = (over) => pruefeSchritt(2, aufwandForm(over), { kunde: KUNDE })
+ const SATZ = 'Der Stundensatz muss ein Betrag zwischen 0,01 € und 100.000,00 € sein (z. B. 100,00).'
+ assert.deepEqual(pruefe(), OK)
+ assert.equal(pruefe({ schaetzung: '12,5' }).ok, true)
+ assert.equal(pruefe({ stundensatz: '' }).fehler.stundensatz, SATZ)
+ assert.equal(pruefe({ stundensatz: '12.50' }).fehler.stundensatz, SATZ)
+ assert.equal(pruefe({ schaetzung: '10,3' }).fehler.schaetzung, 'Die Schätzung geht in Schritten von 15 Minuten (z. B. 10 oder 12,5 Stunden, höchstens 1666).')
+ assert.equal(pruefe({ positionen: [{ uid: 1, description: '', quantity: 'x', price: '' }] }).ok, true)
+})
+
+test('pruefeSchritt 3: Zahlungsplan, Zahlungsziel, Termine, Zeitquelle', () => {
+ const pruefe = (f) => pruefeSchritt(3, f, { kunde: KUNDE })
+ assert.deepEqual(pruefe(festpreisForm()), OK)
+ assert.equal(pruefe(festpreisForm({ zahlungsart: 'anzahlung', prozent: '50' })).ok, true)
+ for (const schlecht of ['', '0', '100', '33,3']) {
+ assert.equal(pruefe(festpreisForm({ zahlungsart: 'anzahlung', prozent: schlecht })).fehler.prozent, 'Die Anzahlung muss eine ganze Zahl von 1 bis 99 Prozent sein.', schlecht)
+ }
+ assert.equal(pruefe(festpreisForm({ zahlungsart: 'nach_abnahme', prozent: 'x' })).ok, true) // Prozent zählt nur bei „Anzahlung“
+ const TEXT = 'Bitte den Zahlungsplan als Text eingeben (höchstens 500 Zeichen).'
+ assert.equal(pruefe(festpreisForm({ zahlungsart: 'frei', zahlungstext: ' ' })).fehler.zahlungstext, TEXT)
+ assert.equal(pruefe(festpreisForm({ zahlungsart: 'frei', zahlungstext: 'x'.repeat(501) })).fehler.zahlungstext, TEXT)
+ assert.equal(pruefe(festpreisForm({ zahlungsart: 'frei', zahlungstext: 'x'.repeat(500) })).ok, true)
+ for (const schlecht of ['', '61', '-1', '1,5']) {
+ assert.equal(pruefe(festpreisForm({ zahlungsziel: schlecht })).fehler.zahlungsziel, 'Das Zahlungsziel muss eine ganze Zahl von 0 bis 60 Tagen sein.', schlecht)
+ }
+ assert.equal(pruefe(festpreisForm({ zahlungsziel: '0' })).ok, true)
+ assert.equal(pruefe(festpreisForm({ startAm: '2026-11-01', fertigBis: '2026-11-30' })).ok, true)
+ assert.equal(pruefe(festpreisForm({ startAm: '2026-11-01', fertigBis: '2026-11-01' })).ok, true)
+ assert.equal(pruefe(festpreisForm({ startAm: '2026-11-02', fertigBis: '2026-11-01' })).fehler.fertigBis, 'Die Fertigstellung liegt vor dem Start.')
+ assert.equal(pruefe(festpreisForm({ startAm: '2026-13-01' })).fehler.startAm, 'Bitte ein gültiges Datum wählen.')
+ assert.equal(pruefe(festpreisForm({ fertigBis: 'bald' })).fehler.fertigBis, 'Bitte ein gültiges Datum wählen.')
+ const ids = (n) => Array.from({ length: n }, (_, i) => `t${i}`)
+ assert.equal(pruefe(festpreisForm({ zeitquelle: ids(10) })).ok, true)
+ assert.equal(pruefe(festpreisForm({ zeitquelle: ids(11) })).fehler.zeitquelle, 'Höchstens 10 Tickets als Zeitquelle.')
+ const PASST = 'Dieser Zahlungsplan passt nicht zur Art des Auftrags.'
+ assert.equal(pruefe(festpreisForm({ zahlungsart: 'monatlich' })).fehler.zahlungsart, PASST)
+ assert.equal(pruefe(aufwandForm({ zahlungsart: 'anzahlung' })).fehler.zahlungsart, PASST)
+})
+
+test('formularZuNutzlast: Festpreis in Cent, getrimmte Texte, Kundenart nur wenn sie am Kunden fehlt', () => {
+ const f = festpreisForm({
+ titel: ' Website Praxis ', projektId: 'p1',
+ positionen: [
+ { uid: 1, description: ' Startseite ', quantity: '2', price: '400,00' },
+ { uid: 2, description: 'Kontaktformular', quantity: '1', price: '1.234,50' },
+ ],
+ zahlungsart: 'anzahlung', prozent: '30', zahlungsziel: '21',
+ startAm: '2026-11-01', fertigBis: '2026-11-30', zeitquelle: ['t1', 't2'],
+ })
+ const r = formularZuNutzlast(f, { kunde: KUNDE })
+ assert.equal(r.ok, true)
+ assert.equal(r.summeCents, 203450)
+ assert.deepEqual(r.payload, {
+ projektId: 'p1', titel: 'Website Praxis', kundenart: 'privat', art: 'festpreis',
+ positionen: [
+ { beschreibung: 'Startseite', menge: 2, einzelpreisCents: 40000 },
+ { beschreibung: 'Kontaktformular', menge: 1, einzelpreisCents: 123450 },
+ ],
+ stundensatzCents: null, schaetzungMinuten: null,
+ zahlungsplan: { art: 'anzahlung', prozent: 30, text: '' }, zahlungszielTage: 21,
+ auftragVom: '2026-10-06', auftragWeg: 'telefon', startAm: '2026-11-01', fertigBis: '2026-11-30',
+ zeitquelle: ['t1', 't2'],
+ })
+ assert.equal('kundenart' in formularZuNutzlast(f, { kunde: { ...KUNDE, kundenart: 'unternehmen' } }).payload, false)
+ const frei = formularZuNutzlast(festpreisForm({ zahlungsart: 'frei', zahlungstext: ' Zahlung in zwei Raten ' }), { kunde: KUNDE })
+ assert.deepEqual(frei.payload.zahlungsplan, { art: 'frei', prozent: null, text: 'Zahlung in zwei Raten' })
+ assert.equal(frei.payload.projektId, '')
+ assert.deepEqual([frei.payload.startAm, frei.payload.fertigBis], ['', ''])
+})
+
+test('formularZuNutzlast: Aufwand mit Satz in Cent und Schätzung in Minuten, ohne Positionen und Summe', () => {
+ const r = formularZuNutzlast(aufwandForm({ stundensatz: '95,50', schaetzung: '12,5', zahlungsart: 'monatlich' }), { kunde: { ...KUNDE, kundenart: 'privat' } })
+ assert.equal(r.ok, true)
+ assert.equal(r.summeCents, null)
+ assert.deepEqual(r.payload.positionen, [])
+ assert.equal(r.payload.art, 'aufwand')
+ assert.equal(r.payload.stundensatzCents, 9550)
+ assert.equal(r.payload.schaetzungMinuten, 750)
+ assert.deepEqual(r.payload.zahlungsplan, { art: 'monatlich', prozent: null, text: '' })
+ assert.equal(formularZuNutzlast(aufwandForm({ schaetzung: '' }), { kunde: KUNDE }).payload.schaetzungMinuten, null)
+})
+
+test('formularZuNutzlast: Fehler nennen den Schritt und das erste Feld', () => {
+ const r1 = formularZuNutzlast(festpreisForm({ titel: '', zahlungsziel: '99' }), { kunde: KUNDE })
+ assert.deepEqual([r1.ok, r1.schritt, r1.erstes], [false, 1, 'titel'])
+ assert.equal(r1.fehler.titel, 'Bitte einen Titel eingeben (höchstens 200 Zeichen).')
+ const r2 = formularZuNutzlast(festpreisForm({ auftragWeg: '' }), { kunde: KUNDE })
+ assert.deepEqual([r2.ok, r2.schritt, r2.erstes], [false, 2, 'auftragWeg'])
+ const r3 = formularZuNutzlast(festpreisForm({ zahlungsziel: '99' }), { kunde: KUNDE })
+ assert.deepEqual([r3.ok, r3.schritt, r3.erstes], [false, 3, 'zahlungsziel'])
+})
+
+test('zahlungsplanVorschau: Wortlaut sinngemäß wie in der Auftragsbestätigung', () => {
+ const v = (o) => zahlungsplanVorschau({ art: 'nach_abnahme', prozent: null, text: '', summeCents: 80000, zielTage: 14, ...o })
+ assert.equal(v({}), 'Gesamtbetrag (800,00 €) nach Abnahme; zahlbar innerhalb von 14 Tagen nach Rechnung.')
+ assert.equal(v({ summeCents: null }), 'Alles nach Abnahme; zahlbar innerhalb von 14 Tagen nach Rechnung.')
+ assert.equal(v({ art: 'anzahlung', prozent: 50 }), 'Anzahlung 50 % (400,00 €) nach Auftragsbestätigung, Rest (400,00 €) nach Abnahme; zahlbar jeweils innerhalb von 14 Tagen nach Rechnung.')
+ // Rundung wie im Vertrag V3: Anzahlung = Math.round(Summe × Prozent / 100), Rest = Summe − Anzahlung
+ assert.equal(v({ art: 'anzahlung', prozent: 50, summeCents: 33333 }), 'Anzahlung 50 % (166,67 €) nach Auftragsbestätigung, Rest (166,66 €) nach Abnahme; zahlbar jeweils innerhalb von 14 Tagen nach Rechnung.')
+ assert.equal(v({ art: 'anzahlung', prozent: 33, summeCents: 33333 }), 'Anzahlung 33 % (110,00 €) nach Auftragsbestätigung, Rest (223,33 €) nach Abnahme; zahlbar jeweils innerhalb von 14 Tagen nach Rechnung.')
+ assert.equal(v({ art: 'anzahlung', prozent: 50, summeCents: null }), 'Anzahlung 50 % nach Auftragsbestätigung, Rest nach Abnahme; zahlbar jeweils innerhalb von 14 Tagen nach Rechnung.')
+ assert.equal(v({ art: 'anzahlung', prozent: null }), '')
+ assert.equal(v({ art: 'monatlich', summeCents: null }), 'Monatlich nach Aufwand; zahlbar innerhalb von 14 Tagen nach Rechnung.')
+ assert.equal(v({ art: 'frei', text: ' Zwei Raten, je zur Hälfte ' }), 'Zwei Raten, je zur Hälfte (zahlbar innerhalb von 14 Tagen nach Rechnung).')
+ assert.equal(v({ art: 'frei', text: '' }), '')
+ assert.equal(v({ zielTage: 0 }), 'Gesamtbetrag (800,00 €) nach Abnahme; zahlbar sofort nach Rechnung.')
+ assert.equal(v({ zielTage: 1 }), 'Gesamtbetrag (800,00 €) nach Abnahme; zahlbar innerhalb von 1 Tag nach Rechnung.')
+ assert.equal(v({ zielTage: null }), 'Gesamtbetrag (800,00 €) nach Abnahme; zahlbar innerhalb von 14 Tagen nach Rechnung.')
+ assert.equal(v({ art: 'unbekannt' }), '')
+})
+
+test('sendeGate: Lead, E-Mail, Anschrift und Kundenart halten das Senden auf, der Entwurf bleibt möglich', () => {
+ assert.deepEqual(sendeGate(KUNDE, 'privat'), { erlaubt: true, grund: '', zurUebersicht: false })
+ assert.deepEqual(sendeGate({ ...KUNDE, kundenart: 'privat' }, ''), { erlaubt: true, grund: '', zurUebersicht: false })
+ assert.deepEqual(sendeGate(KUNDE, ''), { erlaubt: false, grund: 'Die Kundenart fehlt. Bitte im ersten Schritt wählen.', zurUebersicht: false })
+ assert.deepEqual(sendeGate({ ...KUNDE, street: '' }, 'privat'), {
+ erlaubt: false, grund: 'Adresse des Kunden unvollständig (Straße, PLZ, Ort). Für den Versand bitte zuerst ergänzen.', zurUebersicht: true,
+ })
+ assert.deepEqual(sendeGate({ ...KUNDE, email: ' ' }, 'privat'), { erlaubt: false, grund: 'E-Mail fehlt', zurUebersicht: true })
+ assert.deepEqual(sendeGate({ ...KUNDE, customerStatus: 'lead' }, 'privat'), { erlaubt: false, grund: 'Noch kein fester Kunde', zurUebersicht: true })
+ assert.deepEqual(sendeGate(null, 'privat'), { erlaubt: false, grund: 'Kein Kunde ausgewählt', zurUebersicht: false })
+})
+
+const TICKETS = [
+ { $id: 't9', woid: '100149', topic: 'Neueste Anfrage' },
+ { $id: 't2', woid: '100140', topic: 'Shop Relaunch' },
+ { $id: 't1', woid: '100131', topic: 'Website Praxis' },
+ { $id: 'xyz12345', woid: '', title: 'Ohne Nummer' },
+]
+
+test('zeitquelleOptionen: Projekt-Tickets zuerst, dann gewählte, dann der Rest; unbekannte Auswahl bleibt sichtbar', () => {
+ const o = zeitquelleOptionen(TICKETS, ['t9', 'alt77'], ['t1'])
+ assert.deepEqual(o.map((x) => x.id), ['t1', 't9', 'alt77', 't2', 'xyz12345'])
+ assert.deepEqual(o.map((x) => x.gewaehlt), [false, true, true, false, false])
+ assert.deepEqual(o.map((x) => x.projekt), [true, false, false, false, false])
+ assert.equal(o[0].label, '#100131 Website Praxis')
+ assert.equal(o[1].label, '#100149 Neueste Anfrage')
+ assert.equal(o[2].label, 'Ticket alt77')
+ assert.equal(o[4].label, '#12345 Ohne Nummer')
+ assert.deepEqual(zeitquelleOptionen(null, [], []), [])
+})
+
+test('schalteZeitquelle: höchstens 10, keine Doppelten, Reihenfolge der Auswahl', () => {
+ assert.deepEqual(schalteZeitquelle(['a'], 'b', true), ['a', 'b'])
+ assert.deepEqual(schalteZeitquelle(['a', 'b'], 'a', false), ['b'])
+ assert.deepEqual(schalteZeitquelle(['a'], 'a', true), ['a'])
+ assert.deepEqual(schalteZeitquelle(undefined, 'a', true), ['a'])
+ const zehn = Array.from({ length: 10 }, (_, i) => `t${i}`)
+ assert.deepEqual(schalteZeitquelle(zehn, 'neu', true), zehn)
+ assert.deepEqual(schalteZeitquelle(zehn, 't3', false), zehn.filter((id) => id !== 't3'))
+})
+
+test('kundenartWirksam: die Kundenart am Kunden gilt, sonst die im Formular gewählte', () => {
+ assert.equal(kundenartWirksam({ kundenart: 'unternehmen' }, 'privat'), 'unternehmen')
+ assert.equal(kundenartWirksam({ kundenart: '' }, 'privat'), 'privat')
+ assert.equal(kundenartWirksam({}, 'unternehmen'), 'unternehmen')
+ assert.equal(kundenartWirksam(null, 'privat'), 'privat')
+ assert.equal(kundenartWirksam({ kundenart: 'privat' }, ''), 'privat')
+ assert.equal(kundenartWirksam({ kundenart: 'firma' }, ''), '')
+ assert.equal(kundenartWirksam({ kundenart: '' }, 'firma'), '')
+ assert.equal(kundenartWirksam(null, undefined), '')
+})
+
+test('vorOrtHinweis: nur bei „Vor Ort“ und Privatperson (§ 312f Abs. 1 BGB), nie als Sperre', () => {
+ const TEXT = 'Bei Aufträgen vor Ort mit Privatpersonen die Auftragsbestätigung zusätzlich ausdrucken und übergeben.'
+ assert.equal(VOR_ORT_HINWEIS, TEXT)
+ assert.equal(vorOrtHinweis('vor_ort', 'privat'), TEXT)
+ for (const [weg, art] of [['vor_ort', 'unternehmen'], ['vor_ort', ''], ['telefon', 'privat'], ['muendlich', 'privat'], ['email', 'privat'], ['chat', 'privat'], ['', 'privat'], [undefined, 'privat']]) {
+ assert.equal(vorOrtHinweis(weg, art), '', `${weg}/${art}`)
+ }
+})
+
+test('auftragHinweise: fehlende Steuernummer, dann „vor Ort“ bei Privatperson; sonst nichts', () => {
+ const STN = 'Die Steuernummer fehlt noch – sie steht dann nicht auf der Auftragsbestätigung. Eintragen unter Finanzen → Einstellungen.'
+ const VOR = 'Bei Aufträgen vor Ort mit Privatpersonen die Auftragsbestätigung zusätzlich ausdrucken und übergeben.'
+ const hinweise = (o) => auftragHinweise({ kunde: KUNDE, form: festpreisForm(), einstellungen: { steuernummer: '12/345/67890' }, ...o })
+ assert.deepEqual(hinweise(), []) // festpreisForm: Privatperson, Weg „telefon“, Steuernummer vorhanden
+ assert.deepEqual(hinweise({ einstellungen: { steuernummer: '' } }), [{ key: 'steuernummer', text: STN }])
+ assert.deepEqual(hinweise({ einstellungen: {} }).map((h) => h.key), ['steuernummer'])
+ // Einstellungen noch nicht geladen (undefined) oder nicht ladbar (null): keine Behauptung
+ assert.deepEqual(hinweise({ einstellungen: undefined }), [])
+ assert.deepEqual(hinweise({ einstellungen: null }), [])
+ assert.deepEqual(hinweise({ form: festpreisForm({ auftragWeg: 'vor_ort' }) }), [{ key: 'vorOrt', text: VOR }])
+ assert.deepEqual(hinweise({ form: festpreisForm({ auftragWeg: 'vor_ort', kundenart: 'unternehmen' }) }), [])
+ assert.deepEqual(hinweise({ form: festpreisForm({ auftragWeg: 'vor_ort', kundenart: '' }) }), [])
+ // die Kundenart des Kunden hat Vorrang vor dem Formular
+ const privatAmKunden = { kunde: { ...KUNDE, kundenart: 'privat' }, form: festpreisForm({ auftragWeg: 'vor_ort', kundenart: '' }) }
+ assert.deepEqual(hinweise(privatAmKunden).map((h) => h.key), ['vorOrt'])
+ assert.deepEqual(hinweise({ ...privatAmKunden, einstellungen: { steuernummer: '' } }).map((h) => h.key), ['steuernummer', 'vorOrt'])
+})
+
+// --- Liste -------------------------------------------------------------------------------------------------------------
+
+const A = (o = {}) => ({
+ id: 'a1', nummer: 'AB-2026-0002', status: 'versendet', kundenId: 'c1', projektId: 'p1', titel: 'Website Praxis Müller',
+ art: 'festpreis', stundensatzCents: null, schaetzungMinuten: null, summeCents: 80000,
+ gesendetAm: '2026-10-06T08:15:00.000+00:00', gesendetAn: 'info@praxis.test', mailStatus: 'gesendet', mailFehler: '',
+ bestaetigtAm: null, bestaetigtUeber: '', fruehbeginn: false, ersetztId: '', ersetztDurchId: '', widerrufenAm: null,
+ erstelltAm: '2026-10-05T16:00:00.000+00:00', geaendertAm: '2026-10-06T08:15:00.000+00:00',
+ pdfVorhanden: true, fruehesterBeginn: null, zahlungsplanText: '', aufwandText: null, ...o,
+})
+
+test('Status: Beschriftung und Ton für alle sieben Status (Vertrag V2)', () => {
+ assert.deepEqual(Object.entries(AUFTRAG_STATUS).map(([k, v]) => [k, v.label]), [
+ ['entwurf', 'Entwurf'], ['versendet', 'Versendet'], ['bestaetigt', 'Bestätigt'], ['abgerechnet', 'Abgerechnet'],
+ ['storniert', 'Storniert'], ['ersetzt', 'Ersetzt'], ['widerrufen', 'Widerrufen'],
+ ])
+ assert.deepEqual(Object.values(AUFTRAG_STATUS).map((v) => v.tone), ['muted', 'info', 'ok', 'violet', 'plain', 'plain', 'danger'])
+ // „Projekt zuordnen“ gilt für diese Status (Übersicht, Nachträge; V5 `POST …/projekt` lehnt die übrigen mit 409 ab)
+ assert.deepEqual(PROJEKT_ZUORDNEN_STATUS, ['entwurf', 'versendet', 'bestaetigt', 'abgerechnet'])
+})
+
+test('auftragZeilen: Nummer, Titel, Datum, Summe und Status', () => {
+ const [z] = auftragZeilen([A()])
+ assert.deepEqual([z.id, z.nummerText, z.titel, z.datumText], ['a1', 'AB-2026-0002', 'Website Praxis Müller', '06.10.2026'])
+ assert.deepEqual([z.summeText, z.summeSub, z.istEntwurf], ['800,00 €', '', false])
+ assert.deepEqual([z.statusLabel, z.statusTone], ['Versendet', 'info'])
+ assert.equal(z.auftrag.id, 'a1')
+})
+
+test('auftragZeilen: Entwurf ohne Nummer, nach Aufwand, Projektname nur wenn er vom Titel abweicht', () => {
+ const konten = [{ projektId: 'p1', name: 'Praxis Müller – Relaunch' }]
+ const entwurf = A({
+ id: 'a2', nummer: '', status: 'entwurf', gesendetAm: null, pdfVorhanden: false, art: 'aufwand', summeCents: null,
+ stundensatzCents: 10000, schaetzungMinuten: 600, erstelltAm: '2026-10-05T22:30:00.000+00:00',
+ })
+ const [z] = auftragZeilen([entwurf], { konten })
+ assert.deepEqual([z.nummerText, z.istEntwurf, z.datumText], ['Entwurf', true, '06.10.2026'])
+ assert.deepEqual([z.summeText, z.summeSub], ['nach Aufwand', '100,00 € pro Std., Schätzung etwa 10 Std.'])
+ assert.equal(z.projektText, 'Praxis Müller – Relaunch')
+ assert.equal(auftragZeilen([A()], { konten: [{ projektId: 'p1', name: 'Website Praxis Müller' }] })[0].projektText, '')
+ assert.equal(auftragZeilen([A({ projektId: '' })], { konten })[0].projektText, '')
+ assert.equal(auftragZeilen([A({ projektId: 'p9' })], { konten })[0].projektText, '')
+ assert.equal(auftragZeilen([A()])[0].projektText, '')
+ assert.equal(auftragZeilen([A({ art: 'aufwand', summeCents: null, stundensatzCents: 9550, schaetzungMinuten: null })])[0].summeSub, '95,50 € pro Std.')
+ assert.equal(auftragZeilen([A({ summeCents: null })])[0].summeText, '–')
+})
+
+test('auftragZeilen: Notizen und Aktionen hängen an der Zeile, die Reihenfolge des Servers bleibt', () => {
+ const zeilen = auftragZeilen([A({ id: 'x2', mailStatus: 'fehler' }), A({ id: 'x1', status: 'entwurf', nummer: '' })])
+ assert.deepEqual(zeilen.map((z) => z.id), ['x2', 'x1'])
+ assert.deepEqual(zeilen[0].notizen.map((n) => n.text), ['Mail nicht zugestellt'])
+ assert.equal(zeilen[0].aktionen[1].label, 'Erneut senden')
+ assert.equal(zeilen[1].istEntwurf, true)
+ assert.deepEqual(auftragZeilen(null), [])
+ assert.deepEqual(auftragZeilen([null, 'x', A()]).map((z) => z.id), ['a1'])
+})
+
+test('Zusatzzeilen: Mail, bestätigt am, Frühbeginn, Arbeitsbeginn, Fassungen, Widerruf', () => {
+ const texte = (o, alle) => auftragNotizen(A(o), alle).map((n) => n.text)
+ const BESTAETIGT = '2026-10-07T09:00:00.000+00:00'
+ assert.deepEqual(texte({}), [])
+ assert.deepEqual(texte({ mailStatus: 'fehler', mailFehler: 'SMTP' }), ['Mail nicht zugestellt'])
+ assert.deepEqual(auftragNotizen(A({ mailStatus: 'fehler' })).map((n) => n.tone), ['warn'])
+ // nach der Bestätigung zählt ein früherer Mailfehler nicht mehr
+ assert.deepEqual(texte({ status: 'bestaetigt', mailStatus: 'fehler', bestaetigtAm: BESTAETIGT }), ['Bestätigt am 07.10.2026'])
+ assert.deepEqual(texte({ fruehesterBeginn: '2026-10-21' }), ['Arbeitsbeginn frühestens am 21.10.2026'])
+ assert.deepEqual(texte({ status: 'bestaetigt', fruehbeginn: true, bestaetigtAm: BESTAETIGT }), ['Bestätigt am 07.10.2026', 'Frühbeginn verlangt'])
+ assert.deepEqual(texte({ status: 'bestaetigt', fruehesterBeginn: '2026-10-21', bestaetigtAm: BESTAETIGT }), ['Bestätigt am 07.10.2026', 'Arbeitsbeginn frühestens am 21.10.2026'])
+ // ohne Wirkung bei Entwurf und bei beendeten ABs
+ assert.deepEqual(texte({ status: 'entwurf', fruehesterBeginn: '2026-10-21', fruehbeginn: true }), [])
+ assert.deepEqual(texte({ status: 'storniert', fruehesterBeginn: '2026-10-21' }), [])
+ assert.deepEqual(texte({ status: 'widerrufen', widerrufenAm: '2026-10-09T10:00:00.000+00:00' }), ['Widerrufen am 09.10.2026'])
+ const alle = [
+ A({ id: 'a1', nummer: 'AB-2026-0002', status: 'ersetzt', ersetztDurchId: 'a3' }),
+ A({ id: 'a3', nummer: 'AB-2026-0004', ersetztId: 'a1' }),
+ A({ id: 'a4', nummer: '', status: 'entwurf', ersetztId: 'a3' }),
+ ]
+ assert.deepEqual(auftragNotizen(alle[0], alle).map((n) => n.text), ['Ersetzt durch AB-2026-0004'])
+ assert.deepEqual(auftragNotizen(alle[1], alle).map((n) => n.text), ['Neue Fassung zu AB-2026-0002'])
+ assert.deepEqual(auftragNotizen(alle[2], alle).map((n) => n.text), ['Neue Fassung zu AB-2026-0004'])
+ assert.deepEqual(texte({ ersetztId: 'fremd' }, []), ['Neue Fassung'])
+ assert.deepEqual(texte({ status: 'ersetzt', ersetztDurchId: 'fremd' }, []), ['Ersetzt durch eine neue Fassung'])
+})
+
+test('auftragAktionen: je Status genau die erlaubten, nach dem Senden nur noch „Neue Fassung“ statt Bearbeiten', () => {
+ const keys = (o) => auftragAktionen(A(o)).map((a) => a.key)
+ assert.deepEqual(keys({ status: 'entwurf', pdfVorhanden: false }), ['bearbeiten', 'senden', 'pdf', 'loeschen'])
+ assert.deepEqual(keys({ status: 'versendet' }), ['pdf', 'erinnern', 'neueFassung', 'stornieren'])
+ assert.deepEqual(keys({ status: 'bestaetigt' }), ['pdf', 'neueFassung', 'stornieren'])
+ assert.deepEqual(keys({ status: 'abgerechnet' }), ['pdf', 'neueFassung'])
+ for (const status of ['storniert', 'ersetzt', 'widerrufen']) assert.deepEqual(keys({ status }), ['pdf'], status)
+ for (const status of ['versendet', 'bestaetigt', 'abgerechnet', 'storniert', 'ersetzt', 'widerrufen']) {
+ assert.equal(keys({ status }).some((k) => ['bearbeiten', 'senden', 'loeschen'].includes(k)), false, status)
+ }
+ // ohne gespeichertes PDF keine PDF-Aktion (beim Entwurf gibt es die Vorschau immer)
+ assert.deepEqual(keys({ status: 'versendet', pdfVorhanden: false }), ['erinnern', 'neueFassung', 'stornieren'])
+ assert.deepEqual(keys({ status: 'storniert', pdfVorhanden: false }), [])
+ assert.deepEqual(auftragAktionen(A({ status: 'entwurf' })).map((a) => a.label), ['Bearbeiten', 'Senden', 'Vorschau', 'Löschen'])
+ assert.deepEqual(auftragAktionen(A()).map((a) => a.label), ['PDF', 'Erinnerung senden', 'Neue Fassung', 'Stornieren'])
+ assert.equal(auftragAktionen(A({ mailStatus: 'fehler' }))[1].label, 'Erneut senden')
+ assert.deepEqual(auftragAktionen(A({ status: 'entwurf' })).filter((a) => a.danger).map((a) => a.key), ['loeschen'])
+ assert.deepEqual(auftragAktionen(A()).filter((a) => a.danger).map((a) => a.key), ['stornieren'])
+ assert.deepEqual(auftragAktionen(A({ status: 'xyz', pdfVorhanden: false })), [])
+ assert.deepEqual(auftragAktionen(null), [])
+ assert.equal(istEntwurf(A({ status: 'entwurf' })), true)
+ assert.equal(istEntwurf(A()), false)
+ assert.equal(istEntwurf(null), false)
+})
+
+test('Projekt zuordnen: Auswahl aus den Konten, Verfügbarkeit nach Status und Projekt', () => {
+ const konten = [{ projektId: 'p1', name: 'Website Praxis Müller' }, { projektId: 'p2', name: ' Shop Relaunch ' }, { projektId: 'p3', name: '' }, { name: 'ohne Kennung' }, null]
+ assert.deepEqual(zuordnenOptionen(konten), [
+ { value: 'p1', label: 'Website Praxis Müller' }, { value: 'p2', label: 'Shop Relaunch' }, { value: 'p3', label: 'p3' },
+ ])
+ assert.deepEqual(zuordnenOptionen(undefined), [])
+ assert.deepEqual(zuordnenOptionen('x'), [])
+ const ohne = (o) => A({ projektId: '', ...o })
+ for (const status of ['entwurf', 'versendet', 'bestaetigt', 'abgerechnet']) {
+ assert.equal(projektZuordnenMoeglich(ohne({ status }), konten), true, status)
+ }
+ for (const status of ['storniert', 'ersetzt', 'widerrufen', 'xyz']) {
+ assert.equal(projektZuordnenMoeglich(ohne({ status }), konten), false, status)
+ }
+ assert.equal(projektZuordnenMoeglich(ohne({ projektId: null }), konten), true)
+ assert.equal(projektZuordnenMoeglich(ohne({ projektId: undefined }), konten), true)
+ // hat die AB schon ein Projekt (auch ein unbekanntes), gibt es die Aktion nicht: der Server lehnt sie mit 409 ab
+ assert.equal(projektZuordnenMoeglich(A({ projektId: 'p1' }), konten), false)
+ assert.equal(projektZuordnenMoeglich(A({ projektId: 'p9' }), konten), false)
+ // ohne Projekt zur Auswahl ist nichts zu tun
+ assert.equal(projektZuordnenMoeglich(ohne({}), []), false)
+ assert.equal(projektZuordnenMoeglich(ohne({}), undefined), false)
+ assert.equal(projektZuordnenMoeglich(null, konten), false)
+})
+
+test('auftragAktionen mit Konten: „Projekt zuordnen“ nur ohne Projekt, vor den gefährlichen Aktionen', () => {
+ const konten = [{ projektId: 'p1', name: 'Website' }]
+ const keys = (o, k = konten) => auftragAktionen(A({ projektId: '', ...o }), { konten: k }).map((a) => a.key)
+ assert.deepEqual(keys({ status: 'entwurf', pdfVorhanden: false }), ['bearbeiten', 'senden', 'pdf', 'projekt', 'loeschen'])
+ assert.deepEqual(keys({ status: 'versendet' }), ['pdf', 'erinnern', 'neueFassung', 'projekt', 'stornieren'])
+ assert.deepEqual(keys({ status: 'bestaetigt' }), ['pdf', 'neueFassung', 'projekt', 'stornieren'])
+ assert.deepEqual(keys({ status: 'abgerechnet' }), ['pdf', 'neueFassung', 'projekt'])
+ assert.deepEqual(keys({ status: 'versendet', pdfVorhanden: false }), ['erinnern', 'neueFassung', 'projekt', 'stornieren'])
+ for (const status of ['storniert', 'ersetzt', 'widerrufen']) assert.deepEqual(keys({ status }), ['pdf'], status)
+ // mit Projekt, ohne Konten und ohne Angabe der Konten gibt es die Aktion nicht
+ assert.deepEqual(auftragAktionen(A({ status: 'versendet' }), { konten }).map((a) => a.key), ['pdf', 'erinnern', 'neueFassung', 'stornieren'])
+ assert.deepEqual(keys({ status: 'versendet' }, []), ['pdf', 'erinnern', 'neueFassung', 'stornieren'])
+ assert.deepEqual(auftragAktionen(A({ projektId: '' })).map((a) => a.key), ['pdf', 'erinnern', 'neueFassung', 'stornieren'])
+ // Beschriftung, nie gefährlich; Bearbeiten, Senden und Löschen bleiben dem Entwurf vorbehalten
+ const aktion = auftragAktionen(A({ projektId: '' }), { konten }).find((a) => a.key === 'projekt')
+ assert.equal(aktion.label, 'Projekt zuordnen')
+ assert.notEqual(aktion.danger, true)
+ assert.equal(keys({ status: 'versendet' }).some((k) => ['bearbeiten', 'senden', 'loeschen'].includes(k)), false)
+ // die Zeilen geben die Konten weiter
+ assert.deepEqual(auftragZeilen([A({ projektId: '' })], { konten })[0].aktionen.map((a) => a.key), ['pdf', 'erinnern', 'neueFassung', 'projekt', 'stornieren'])
+ assert.deepEqual(auftragZeilen([A({ projektId: '' })])[0].aktionen.map((a) => a.key), ['pdf', 'erinnern', 'neueFassung', 'stornieren'])
+})
+
+test('Rückfragen: Senden, Löschen, Stornieren, Erinnerung, Projekt zuordnen', () => {
+ assert.equal(
+ auftragBestaetigung('senden', A({ status: 'entwurf', nummer: '' }), { email: 'info@praxis.test' }),
+ 'Auftragsbestätigung „Website Praxis Müller“ jetzt festschreiben und an info@praxis.test senden? Danach lässt sie sich nicht mehr ändern, nur noch ersetzen oder stornieren.'
+ )
+ assert.equal(
+ auftragBestaetigung('senden', A({ status: 'entwurf', nummer: '' })),
+ 'Auftragsbestätigung „Website Praxis Müller“ jetzt festschreiben und an den Kunden senden? Danach lässt sie sich nicht mehr ändern, nur noch ersetzen oder stornieren.'
+ )
+ assert.equal(auftragBestaetigung('loeschen', A({ status: 'entwurf' })), 'Den Entwurf „Website Praxis Müller“ wirklich löschen? Das lässt sich nicht rückgängig machen.')
+ assert.equal(
+ auftragBestaetigung('stornieren', A()),
+ 'Die Auftragsbestätigung AB-2026-0002 „Website Praxis Müller“ wirklich stornieren? Der Kunde bekommt keine Mail, der Bestätigungslink wird ungültig. Das lässt sich nicht rückgängig machen.'
+ )
+ assert.equal(
+ auftragBestaetigung('erinnern', A()),
+ 'Die Auftragsbestätigung AB-2026-0002 jetzt noch einmal an den Kunden senden? Der bisherige Bestätigungslink gilt danach nicht mehr.'
+ )
+ assert.equal(
+ auftragBestaetigung('projekt', A({ projektId: '' }), { projektName: 'Physio-Website' }),
+ 'Die Auftragsbestätigung AB-2026-0002 „Website Praxis Müller“ dem Projekt „Physio-Website“ zuordnen? Das Projekt lässt sich danach nur noch über eine neue Fassung ändern.'
+ )
+ assert.match(auftragBestaetigung('projekt', A({ projektId: '' })), /„Website Praxis Müller“ einem Projekt zuordnen\? /)
+ // ein Entwurf bleibt über „Bearbeiten“ änderbar: keine Rückfrage
+ assert.equal(auftragBestaetigung('projekt', A({ status: 'entwurf', nummer: '', projektId: '' }), { projektName: 'Physio-Website' }), '')
+ for (const key of ['bearbeiten', 'pdf', 'neueFassung', 'gibtsnicht']) assert.equal(auftragBestaetigung(key, A()), '', key)
+ assert.match(auftragBestaetigung('senden', A({ titel: '' })), /„ohne Titel“/)
+})
+
+test('Erfolgsmeldungen (Wortlaut des Containers von W0 für „senden“)', () => {
+ const gesendet = A({ nummer: 'AB-2026-0003' }) // gesendetAn: info@praxis.test
+ assert.deepEqual(auftragErfolg('senden', gesendet), { tone: 'ok', text: 'Auftragsbestätigung AB-2026-0003 wurde an info@praxis.test gesendet.' })
+ assert.deepEqual(auftragErfolg('senden', A({ nummer: 'AB-2026-0003', gesendetAn: '' })), { tone: 'ok', text: 'Auftragsbestätigung AB-2026-0003 wurde gesendet.' })
+ assert.deepEqual(auftragErfolg('senden', gesendet, { mailFehler: true }), {
+ tone: 'warn',
+ text: 'Auftragsbestätigung AB-2026-0003 ist versendet, die Mail an den Kunden wurde aber nicht zugestellt. Bitte in der Liste erneut senden.',
+ })
+ assert.deepEqual(auftragErfolg('erinnern', gesendet), { tone: 'ok', text: 'Die Erinnerung zu AB-2026-0003 wurde an info@praxis.test gesendet.' })
+ assert.deepEqual(auftragErfolg('erinnern', gesendet, { mailFehler: true }), {
+ tone: 'warn', text: 'Die Erinnerung zu AB-2026-0003 konnte nicht zugestellt werden. „Erneut senden“ versucht es noch einmal.',
+ })
+ assert.deepEqual(auftragErfolg('stornieren', gesendet), { tone: 'ok', text: 'Auftragsbestätigung AB-2026-0003 wurde storniert.' })
+ assert.deepEqual(auftragErfolg('loeschen', A({ nummer: '' })), { tone: 'ok', text: 'Der Entwurf wurde gelöscht.' })
+ assert.deepEqual(auftragErfolg('neueFassung', A({ nummer: '' })), { tone: 'ok', text: 'Neue Fassung als Entwurf angelegt.' })
+ assert.deepEqual(auftragErfolg('projekt', gesendet, { projektName: 'Physio-Website' }), {
+ tone: 'ok', text: 'Auftragsbestätigung AB-2026-0003 wurde dem Projekt „Physio-Website“ zugeordnet.',
+ })
+ assert.deepEqual(auftragErfolg('projekt', gesendet), { tone: 'ok', text: 'Auftragsbestätigung AB-2026-0003 wurde einem Projekt zugeordnet.' })
+ assert.deepEqual(auftragErfolg('projekt', A({ nummer: '' }), { projektName: 'Physio-Website' }), {
+ tone: 'ok', text: 'Die Auftragsbestätigung wurde dem Projekt „Physio-Website“ zugeordnet.',
+ })
+ assert.deepEqual(auftragErfolg('senden', null), { tone: 'ok', text: 'Die Auftragsbestätigung wurde gesendet.' })
+ assert.deepEqual(auftragErfolg('senden', null, { mailFehler: true }), {
+ tone: 'warn', text: 'Die Auftragsbestätigung ist versendet, die Mail an den Kunden wurde aber nicht zugestellt. Bitte in der Liste erneut senden.',
+ })
+ assert.deepEqual(auftragErfolg('gibtsnicht', gesendet), { tone: 'ok', text: '' })
+})
+
+test('auftragFehler: Kundendaten-Fehler führen zur Übersicht, andere zeigen die Meldung des Servers (Texte aus Plan L3)', () => {
+ const fehler = (status, message) => auftragFehler(Object.assign(new Error(message), { status }))
+ assert.deepEqual(fehler(409, 'Adresse des Kunden unvollständig (Straße, PLZ, Ort). Für den Versand bitte zuerst ergänzen.'), {
+ art: 'adresse', text: 'Adresse des Kunden unvollständig (Straße, PLZ, Ort). Für den Versand bitte zuerst ergänzen.', zurUebersicht: true,
+ })
+ assert.deepEqual(fehler(400, 'Bitte zuerst die Kundenart angeben (Unternehmen oder Privatperson).'), {
+ art: 'kundenart',
+ text: 'Die Kundenart des Kunden fehlt. Bitte in der Übersicht des Kunden festlegen (Feld „Kundenart“) oder über „Bearbeiten“ und dann erneut senden.',
+ zurUebersicht: true,
+ })
+ assert.deepEqual(fehler(400, 'Für den Versand fehlt die E-Mail-Adresse des Kunden.'), {
+ art: 'email', text: 'Dem Kunden fehlt die E-Mail-Adresse. Bitte in der Übersicht eintragen und dann erneut senden.', zurUebersicht: true,
+ })
+ assert.deepEqual(fehler(400, 'Auftragsbestätigungen nur für feste Kunden.'), {
+ art: 'kunde', text: 'Das ist noch kein fester Kunde. Bitte in der Übersicht zum festen Kunden machen und dann erneut senden.', zurUebersicht: true,
+ })
+ // Entwurf unvollständig (L4, wird von L3 beim Senden und bei der Vorschau durchgereicht): Meldung unverändert, kein „aktueller Stand“
+ assert.deepEqual(fehler(409, 'Entwurf unvollständig: Titel fehlt.'), {
+ art: 'entwurf', text: 'Entwurf unvollständig: Titel fehlt.', zurUebersicht: false,
+ })
+ assert.equal(fehler(409, 'Entwurf unvollständig: Kundendaten fehlen.').art, 'entwurf')
+ assert.deepEqual(fehler(409, 'Nur ein Entwurf lässt sich senden.'), {
+ art: 'stand', text: 'Nur ein Entwurf lässt sich senden. Die Liste zeigt den aktuellen Stand.', zurUebersicht: false,
+ })
+ assert.deepEqual(fehler(400, 'Titel: Bitte einen Titel angeben oder ein Projekt wählen.'), {
+ art: 'eingabe', text: 'Titel: Bitte einen Titel angeben oder ein Projekt wählen.', zurUebersicht: false,
+ })
+ assert.deepEqual(fehler(500, 'Das PDF konnte nicht erzeugt werden.'), {
+ art: 'pdf', text: 'Das PDF konnte nicht erzeugt werden. Die Auftragsbestätigung bleibt ein Entwurf, es wurde keine Nummer vergeben.', zurUebersicht: false,
+ })
+ assert.deepEqual(fehler(500, 'Interner Fehler.'), {
+ art: 'server',
+ text: 'Interner Fehler. Bitte in der Liste prüfen, ob die Auftragsbestätigung gesendet wurde, und sonst erneut versuchen.',
+ zurUebersicht: false,
+ })
+ assert.deepEqual(fehler(0, 'Das Kundenportal ist gerade nicht erreichbar.'), { art: 'netz', text: 'Das Kundenportal ist gerade nicht erreichbar.', zurUebersicht: false })
+ assert.deepEqual(fehler(404, 'Auftragsbestätigung nicht gefunden.'), { art: 'sonst', text: 'Auftragsbestätigung nicht gefunden.', zurUebersicht: false })
+ assert.deepEqual(auftragFehler(new Error('')), { art: 'sonst', text: 'Die Aktion ist fehlgeschlagen.', zurUebersicht: false })
+ assert.deepEqual(auftragFehler(null), { art: 'sonst', text: 'Die Aktion ist fehlgeschlagen.', zurUebersicht: false })
+})
+
+test('neueSperre: ein zweiter Aufruf während des ersten wird ignoriert (Doppelklick)', async () => {
+ const sperre = neueSperre()
+ let aufrufe = 0
+ let fertig
+ const warten = new Promise((r) => { fertig = r })
+ const erster = sperre.run(async () => { aufrufe += 1; await warten; return 'ok' })
+ const zweiter = await sperre.run(async () => { aufrufe += 1; return 'doppelt' })
+ assert.deepEqual(zweiter, { uebersprungen: true })
+ assert.equal(sperre.belegt, true)
+ fertig()
+ assert.deepEqual(await erster, { uebersprungen: false, wert: 'ok' })
+ assert.equal(aufrufe, 1)
+ assert.equal(sperre.belegt, false)
+ assert.deepEqual(await sperre.run(async () => 'wieder'), { uebersprungen: false, wert: 'wieder' })
+})
+
+test('neueSperre: nach einem Fehler wieder frei, der Fehler wird weitergereicht', async () => {
+ const sperre = neueSperre()
+ await assert.rejects(() => sperre.run(async () => { throw new Error('kaputt') }), /kaputt/)
+ assert.equal(sperre.belegt, false)
+})
diff --git a/tests/unit/customerTabs.test.mjs b/tests/unit/customerTabs.test.mjs
index e0f8b58..618ab47 100644
--- a/tests/unit/customerTabs.test.mjs
+++ b/tests/unit/customerTabs.test.mjs
@@ -4,11 +4,11 @@ import { loadModule } from './load.mjs'
const { customerTabs, resolveCustomerTab } = await loadModule('src/lib/customerTabs.js')
-test('customerTabs: volle Reiterfolge der Spec, Rechnungen nur für Admins', () => {
+test('customerTabs: volle Reiterfolge der Spec, „Aufträge und Rechnungen“ nur für Admins', () => {
assert.deepEqual(customerTabs(true), [
{ id: 'overview', label: 'Übersicht' },
{ id: 'tickets', label: 'Tickets' },
- { id: 'invoices', label: 'Rechnungen' },
+ { id: 'invoices', label: 'Aufträge und Rechnungen' },
{ id: 'projects', label: 'Projekte' },
{ id: 'chat', label: 'Chat' },
{ id: 'abo', label: 'Abo & Angebote' },
diff --git a/tests/unit/financeRequestBlob.test.mjs b/tests/unit/financeRequestBlob.test.mjs
new file mode 100644
index 0000000..435dde8
--- /dev/null
+++ b/tests/unit/financeRequestBlob.test.mjs
@@ -0,0 +1,78 @@
+import test from 'node:test'
+import assert from 'node:assert/strict'
+import { loadModule } from './load.mjs'
+
+const { createFinanceRequest, PORTAL_DOWN_TEXT } = await loadModule('src/lib/financeRequest.js')
+
+const BASE = 'https://portal.example.invalid/api/admin/finanzen'
+
+/** Antwort wie fetch: { ok, status, json(), blob() }; ohne `json` ist der Körper kein JSON (json() wirft). */
+const reply = (status, { json, blob } = {}) => ({
+ ok: status >= 200 && status < 300,
+ status,
+ json: async () => {
+ if (json === undefined) throw new SyntaxError('Unexpected token % in JSON')
+ return json
+ },
+ blob: async () => blob,
+})
+
+function client({ responses = [], createJWT } = {}) {
+ const calls = []
+ const queue = [...responses]
+ const api = createFinanceRequest({
+ baseUrl: BASE,
+ createJWT: createJWT || (async () => ({ jwt: 'jwt-1' })),
+ fetchImpl: async (url, init) => {
+ calls.push({ url, init })
+ return queue.shift() || reply(200, { json: {} })
+ },
+ })
+ return { api, calls }
+}
+
+const fails = async (promise) => promise.then(() => assert.fail('hätte scheitern müssen'), (err) => err)
+const pdf = () => new Blob(['%PDF-1.7 Probe'], { type: 'application/pdf' })
+
+test('as blob: Erfolg liefert den rohen Inhalt als Blob, mit Bearer-JWT, ohne JSON zu lesen', async () => {
+ const { api, calls } = client({
+ responses: [{ ...reply(200, { blob: pdf() }), json: async () => assert.fail('json() darf bei as blob nicht gelesen werden') }],
+ })
+ const result = await api('/auftraege/a1/pdf', { as: 'blob' })
+ assert.ok(result instanceof Blob)
+ assert.equal(result.type, 'application/pdf')
+ assert.equal(await result.text(), '%PDF-1.7 Probe')
+ assert.equal(calls[0].url, `${BASE}/auftraege/a1/pdf`)
+ assert.equal(calls[0].init.method, 'GET')
+ assert.equal(calls[0].init.headers.Authorization, 'Bearer jwt-1')
+})
+
+test('as blob: Fehler kommen weiter als Meldung des Portals mit Status (kein kaputtes PDF)', async () => {
+ const { api } = client({ responses: [reply(404, { json: { error: 'PDF nicht gefunden.' } })] })
+ const err = await fails(api('/auftraege/a1/pdf', { as: 'blob' }))
+ assert.equal(err.message, 'PDF nicht gefunden.')
+ assert.equal(err.status, 404)
+ // Proxy-Fehlerseite ohne JSON: dieselbe Meldung wie bei JSON-Anfragen
+ const { api: api2 } = client({ responses: [reply(502)] })
+ assert.equal((await fails(api2('/auftraege/a1/pdf', { as: 'blob' }))).message, PORTAL_DOWN_TEXT)
+})
+
+test('as blob: bei 401 einmal mit frischem JWT wiederholt, dann kommt der Blob', async () => {
+ let n = 0
+ const datei = pdf()
+ const { api, calls } = client({
+ createJWT: async () => ({ jwt: `jwt-${++n}` }),
+ responses: [reply(401, { json: { error: 'abgelaufen' } }), reply(200, { blob: datei })],
+ })
+ assert.equal(await api('/auftraege/a1/pdf', { as: 'blob' }), datei)
+ assert.deepEqual(calls.map((c) => c.init.headers.Authorization), ['Bearer jwt-1', 'Bearer jwt-2'])
+})
+
+test('ohne as bleibt es JSON (der Blob wird nicht gelesen), ein unbekannter Wert verhält sich ebenso', async () => {
+ const { api } = client({
+ responses: [{ ...reply(200, { json: { ok: 1 } }), blob: async () => assert.fail('blob() darf ohne as nicht gelesen werden') }],
+ })
+ assert.deepEqual(await api('/x'), { ok: 1 })
+ const { api: api2 } = client({ responses: [reply(200, { json: { ok: 2 } })] })
+ assert.deepEqual(await api2('/x', { as: 'text' }), { ok: 2 })
+})
diff --git a/tests/unit/kundenFelder.test.mjs b/tests/unit/kundenFelder.test.mjs
new file mode 100644
index 0000000..2904c3f
--- /dev/null
+++ b/tests/unit/kundenFelder.test.mjs
@@ -0,0 +1,79 @@
+import test from 'node:test'
+import assert from 'node:assert/strict'
+import { loadModule } from './load.mjs'
+
+const {
+ KUNDENARTEN, KUNDENART_OPTIONEN, KUNDENART_HINWEIS, kundenartLabel, kundenartAenderung,
+ STEUERNUMMER_MAX, STEUERNUMMER_HINWEIS, steuernummerAenderung, STEUERNUMMER_FEHLT_HINWEIS, steuernummerFehlt,
+} = await loadModule('src/lib/kundenFelder.js')
+
+test('Kundenart: Werte (Vertrag V2), Auswahl und Beschriftung', () => {
+ assert.deepEqual(KUNDENARTEN, ['unternehmen', 'privat'])
+ assert.deepEqual(KUNDENART_OPTIONEN, [
+ { value: '', label: 'Nicht festgelegt' },
+ { value: 'unternehmen', label: 'Unternehmen' },
+ { value: 'privat', label: 'Privatperson' },
+ ])
+ assert.equal(KUNDENART_HINWEIS, 'Für Auftragsbestätigungen: Privatpersonen bekommen die Widerrufsbelehrung.')
+ assert.equal(kundenartLabel('unternehmen'), 'Unternehmen')
+ assert.equal(kundenartLabel('privat'), 'Privatperson')
+ for (const v of ['', undefined, null, 'firma']) assert.equal(kundenartLabel(v), 'Nicht festgelegt')
+})
+
+test('kundenartAenderung: nur eine geänderte, bekannte Kundenart wird geschrieben', () => {
+ assert.deepEqual(kundenartAenderung('', ''), {})
+ assert.deepEqual(kundenartAenderung('', undefined), {})
+ assert.deepEqual(kundenartAenderung('privat', 'privat'), {})
+ assert.deepEqual(kundenartAenderung('privat', ''), { kundenart: 'privat' })
+ assert.deepEqual(kundenartAenderung('privat', undefined), { kundenart: 'privat' })
+ assert.deepEqual(kundenartAenderung('unternehmen', 'privat'), { kundenart: 'unternehmen' })
+ assert.deepEqual(kundenartAenderung('', 'privat'), { kundenart: '' })
+ // unbekannte Werte werden nie geschrieben
+ assert.deepEqual(kundenartAenderung('firma', ''), {})
+ assert.deepEqual(kundenartAenderung('firma', 'privat'), {})
+})
+
+test('Steuernummer: Hinweis und Grenze', () => {
+ assert.equal(STEUERNUMMER_HINWEIS, 'Steht auf jeder Auftragsbestätigung. Für Rechnungen bitte zusätzlich im Stripe-Dashboard hinterlegen.')
+ assert.equal(STEUERNUMMER_MAX, 40)
+})
+
+test('steuernummerAenderung: unverändert sendet nichts, geändert den getrimmten Wert, leer löscht', () => {
+ assert.deepEqual(steuernummerAenderung('12/345/67890', '12/345/67890'), { ok: true, payload: {} })
+ assert.deepEqual(steuernummerAenderung('', ''), { ok: true, payload: {} })
+ assert.deepEqual(steuernummerAenderung('', null), { ok: true, payload: {} })
+ assert.deepEqual(steuernummerAenderung(' 12/345/67890 ', '12/345/67890'), { ok: true, payload: {} })
+ assert.deepEqual(steuernummerAenderung(' 12/345/67890 ', ''), { ok: true, payload: { steuernummer: '12/345/67890' } })
+ assert.deepEqual(steuernummerAenderung('', '12/345/67890'), { ok: true, payload: { steuernummer: '' } })
+ assert.equal(steuernummerAenderung('123 456 789-0', '').ok, true)
+ assert.equal(steuernummerAenderung('1'.repeat(40), '').ok, true)
+})
+
+test('steuernummerAenderung: Buchstaben, Ziffern, Leerzeichen, „/“, „-“ und „.“ sind erlaubt wie auf dem Server (V4)', () => {
+ for (const gut of ['12/345/67890', '12.345.67890', 'DE123456789', 'de 123 456 789', 'DE-123.456/789', 'a', 'USt-IdNr 12/345']) {
+ assert.deepEqual(steuernummerAenderung(gut, ''), { ok: true, payload: { steuernummer: gut } }, gut)
+ }
+ assert.deepEqual(steuernummerAenderung(' DE123456789 ', ''), { ok: true, payload: { steuernummer: 'DE123456789' } })
+ assert.deepEqual(steuernummerAenderung('A'.repeat(40), ''), { ok: true, payload: { steuernummer: 'A'.repeat(40) } })
+})
+
+test('steuernummerAenderung: andere Zeichen und zu lang', () => {
+ const zeichen = 'Die Steuernummer darf nur Buchstaben (ohne Umlaute), Ziffern, Leerzeichen, „/“, „-“ und „.“ enthalten.'
+ for (const schlecht of ['12_345', '12,345', '12#34', 'Müller 12', 'ß12', '12\t34', '12\n34', '12+34', '123']) {
+ assert.deepEqual(steuernummerAenderung(schlecht, ''), { ok: false, fehler: zeichen }, JSON.stringify(schlecht))
+ }
+ const LANG = 'Die Steuernummer darf höchstens 40 Zeichen lang sein.'
+ assert.deepEqual(steuernummerAenderung('1'.repeat(41), ''), { ok: false, fehler: LANG })
+ assert.deepEqual(steuernummerAenderung('A'.repeat(41), ''), { ok: false, fehler: LANG })
+ assert.equal(steuernummerAenderung('_'.repeat(41), '').fehler, LANG) // die Länge wird zuerst geprüft
+})
+
+test('steuernummerFehlt: Hinweis nur, wenn die Einstellungen da sind und die Steuernummer leer ist', () => {
+ assert.equal(STEUERNUMMER_FEHLT_HINWEIS, 'Die Steuernummer fehlt noch – sie steht dann nicht auf der Auftragsbestätigung. Eintragen unter Finanzen → Einstellungen.')
+ for (const leer of [{ steuernummer: '' }, { steuernummer: ' ' }, { steuernummer: null }, { steuernummer: 12 }, {}, { bankDetailsText: 'IBAN' }]) {
+ assert.equal(steuernummerFehlt(leer), true, JSON.stringify(leer))
+ }
+ for (const da of [{ steuernummer: '12/345/67890' }, { steuernummer: ' DE123456789 ' }]) assert.equal(steuernummerFehlt(da), false)
+ // noch nicht geladen oder nicht ladbar: es wird nichts behauptet
+ for (const unbekannt of [undefined, null, 'x', 5, []]) assert.equal(steuernummerFehlt(unbekannt), false, String(unbekannt))
+})
diff --git a/tests/unit/projektkontoView.test.mjs b/tests/unit/projektkontoView.test.mjs
new file mode 100644
index 0000000..4eb367e
--- /dev/null
+++ b/tests/unit/projektkontoView.test.mjs
@@ -0,0 +1,160 @@
+import test from 'node:test'
+import assert from 'node:assert/strict'
+import { loadModule } from './load.mjs'
+
+const {
+ STRIPE_HINWEIS, KEINE_PROJEKTE_HINWEIS,
+ stundenDe, satzText, ticketsText, stripeHinweis, vereinbartText, kontoZeile, ohneProjektZeile,
+ artLabel, projektSpalte, abgerechnetText, abgerechnetWarnung, ticketIdsOf,
+} = await loadModule('src/lib/projektkontoView.js')
+
+const konto = (over = {}) => ({
+ projektId: 'p1',
+ name: 'Website Relaunch',
+ projektTyp: 'Website',
+ tickets: [{ id: 't1', woid: '100131' }],
+ zeitquelle: ['t1'],
+ vereinbart: { festpreisCents: 120000, aufwand: null },
+ berechnetCents: 50000,
+ bezahltCents: 20000,
+ offenCents: 70000,
+ zeit: { minuten: 750, aboMinuten: 0, offeneMinuten: 750, satzCents: 10000, wertCents: 125000, offenerWertCents: 125000 },
+ ...over,
+})
+const AUFWAND = { festpreisCents: null, aufwand: { stundensatzCents: 10000, schaetzungMinuten: 600 } }
+
+test('stundenDe: Dezimalstunden mit Komma, ohne überflüssige Nullen', () => {
+ assert.equal(stundenDe(750), '12,5 Std.')
+ assert.equal(stundenDe(135), '2,25 Std.')
+ assert.equal(stundenDe(60), '1 Std.')
+ assert.equal(stundenDe(20), '0,33 Std.')
+ assert.equal(stundenDe(0), '0 Std.')
+ assert.equal(stundenDe(null), '0 Std.')
+ assert.equal(stundenDe('abc'), '0 Std.')
+})
+
+test('satzText: ganze Euro ohne Cent, sonst mit Cent', () => {
+ assert.equal(satzText(10000), '100 €/Std.')
+ assert.equal(satzText(8550), '85,50 €/Std.')
+ assert.equal(satzText(null), '–')
+})
+
+test('ticketsText: Ticketnummern mit Raute, ohne Nummer fällt der Eintrag weg', () => {
+ assert.equal(ticketsText([{ id: 'a', woid: '100131' }]), '#100131')
+ assert.equal(ticketsText([{ id: 'a', woid: '100131' }, { id: 'b', woid: '100140' }]), '#100131, #100140')
+ assert.equal(ticketsText([{ id: 'a' }, null]), '')
+ assert.equal(ticketsText(undefined), '')
+})
+
+test('vereinbartText: Festpreis, Aufwand mit Schätzung, beides, keine AB', () => {
+ assert.equal(vereinbartText({ festpreisCents: 120000, aufwand: null }), '1.200,00 €')
+ assert.equal(vereinbartText(AUFWAND), 'nach Aufwand, 100 €/Std., Schätzung 10 Std.')
+ assert.equal(vereinbartText({ festpreisCents: null, aufwand: { stundensatzCents: 8550, schaetzungMinuten: null } }), 'nach Aufwand, 85,50 €/Std.')
+ assert.equal(vereinbartText({ festpreisCents: 120000, aufwand: { stundensatzCents: 10000, schaetzungMinuten: null } }), '1.200,00 € und nach Aufwand, 100 €/Std.')
+ assert.equal(vereinbartText({ festpreisCents: null, aufwand: null }), 'keine AB')
+ assert.equal(vereinbartText(undefined), 'keine AB')
+})
+
+test('kontoZeile: Festpreis-AB mit Berechnet, bezahlt, Noch offen und Zeit', () => {
+ assert.deepEqual(kontoZeile(konto()), {
+ key: 'p1',
+ projektId: 'p1',
+ name: 'Website Relaunch',
+ meta: 'Website · #100131',
+ vereinbart: '1.200,00 €',
+ berechnet: '500,00 €',
+ bezahlt: 'bezahlt 200,00 €',
+ offen: '700,00 €',
+ zeit: { stunden: '12,5 Std.', wert: 'Wert 1.250,00 €', abo: '' },
+ })
+ // ohne Art und ohne Ticket bleibt nur der Name
+ assert.equal(kontoZeile(konto({ projektTyp: '', tickets: [] })).meta, '')
+})
+
+test('kontoZeile: Aufwand-AB (Noch offen = Wert der offenen Zeit) und Abo-Zeit getrennt', () => {
+ const z = kontoZeile(konto({
+ vereinbart: AUFWAND,
+ offenCents: null,
+ zeit: { minuten: 300, aboMinuten: 90, offeneMinuten: 180, satzCents: 10000, wertCents: 50000, offenerWertCents: 30000 },
+ }))
+ assert.equal(z.vereinbart, 'nach Aufwand, 100 €/Std., Schätzung 10 Std.')
+ assert.equal(z.offen, '300,00 €')
+ assert.deepEqual(z.zeit, { stunden: '5 Std.', wert: 'Wert 500,00 €', abo: 'über Abo: 1,5 Std.' })
+})
+
+test('kontoZeile: ohne AB steht „keine AB“ und Noch offen ist „–“', () => {
+ const z = kontoZeile(konto({ vereinbart: { festpreisCents: null, aufwand: null }, offenCents: null }))
+ assert.equal(z.vereinbart, 'keine AB')
+ assert.equal(z.offen, '–')
+})
+
+test('kontoZeile: Stripe nicht erreichbar zeigt „–“ statt Nullen, Zeit und Vereinbartes bleiben', () => {
+ const z = kontoZeile(konto({ berechnetCents: 0, bezahltCents: 0, offenCents: null }), { stripeOk: false })
+ assert.deepEqual([z.vereinbart, z.berechnet, z.bezahlt, z.offen], ['1.200,00 €', '–', '', '–'])
+ assert.equal(z.zeit.wert, 'Wert 1.250,00 €')
+ // Aufwand: der Wert der offenen Zeit braucht kein Stripe
+ const a = kontoZeile(konto({ vereinbart: AUFWAND, offenCents: null }), { stripeOk: false })
+ assert.equal(a.offen, '1.250,00 €')
+ assert.equal(stripeHinweis(false), STRIPE_HINWEIS)
+ assert.equal(stripeHinweis(true), '')
+ assert.equal(stripeHinweis(undefined), '')
+ assert.equal(STRIPE_HINWEIS, 'Stripe ist gerade nicht erreichbar. Berechnete und bezahlte Beträge sind deshalb nicht verfügbar.')
+})
+
+test('ohneProjektZeile: Rechnungen ohne Projekt, ohne Vereinbart und Zeit', () => {
+ assert.deepEqual(ohneProjektZeile({ berechnetCents: 30000, bezahltCents: 10000, anzahl: 3 }), {
+ key: 'ohne-projekt',
+ projektId: '',
+ name: 'Ohne Projekt',
+ meta: '3 Rechnungen',
+ vereinbart: '–',
+ berechnet: '300,00 €',
+ bezahlt: 'bezahlt 100,00 €',
+ offen: '–',
+ zeit: { stunden: '–', wert: '', abo: '' },
+ })
+ assert.equal(ohneProjektZeile({ berechnetCents: 0, bezahltCents: 0, anzahl: 1 }).meta, '1 Rechnung')
+ assert.equal(ohneProjektZeile({ berechnetCents: 0, bezahltCents: 0, anzahl: 0 }).meta, 'keine Rechnungen')
+ assert.equal(ohneProjektZeile(undefined).meta, 'keine Rechnungen')
+ const s = ohneProjektZeile({ berechnetCents: 30000, bezahltCents: 10000, anzahl: 3 }, { stripeOk: false })
+ assert.deepEqual([s.berechnet, s.bezahlt], ['–', ''])
+})
+
+test('Hinweis für Kunden ohne Projekte', () => {
+ assert.equal(KEINE_PROJEKTE_HINWEIS, 'Dieser Kunde hat noch keine Projekte. Rechnungen entstehen ohne Projektbezug.')
+})
+
+test('artLabel: Spalte Art der Rechnungstabelle', () => {
+ assert.deepEqual(
+ ['frei', 'festbetrag', 'aufwand', 'anzahlung', 'schluss', 'abo'].map((a) => artLabel(a)),
+ ['Ohne Projekt', 'Festbetrag', 'Aufwand', 'Anzahlung', 'Schlussrechnung', 'Abo']
+ )
+ assert.equal(artLabel(undefined), '–')
+ assert.equal(artLabel('unbekannt'), '–')
+ assert.equal(artLabel('constructor'), '–')
+})
+
+test('projektSpalte: Projektname der Rechnung oder „–“', () => {
+ assert.equal(projektSpalte({ projektName: ' Website Relaunch ' }), 'Website Relaunch')
+ assert.equal(projektSpalte({ projektName: '' }), '–')
+ assert.equal(projektSpalte({}), '–')
+ assert.equal(projektSpalte(null), '–')
+})
+
+test('abgerechnetText und abgerechnetWarnung: Rechnungsnummer oder Entwurf', () => {
+ const map = { w1: 'WEBKLAR-0009', w2: 'Entwurf' }
+ assert.equal(abgerechnetText(map, 'w1'), 'abgerechnet (WEBKLAR-0009)')
+ assert.equal(abgerechnetText(map, 'w2'), 'abgerechnet (Entwurf)')
+ assert.equal(abgerechnetText(map, 'w3'), null)
+ assert.equal(abgerechnetText(map, 'constructor'), null)
+ assert.equal(abgerechnetText(null, 'w1'), null)
+ assert.equal(abgerechnetWarnung(map, 'w1'), 'Dieses Arbeitsblatt ist abgerechnet (WEBKLAR-0009). Eine Änderung ändert die Rechnung nicht.')
+ assert.equal(abgerechnetWarnung(map, 'w2'), 'Dieses Arbeitsblatt ist abgerechnet (Entwurf). Eine Änderung ändert die Rechnung nicht.')
+ assert.equal(abgerechnetWarnung(map, 'w3'), null)
+})
+
+test('ticketIdsOf: eindeutige Ticket-Kennungen der Arbeitsblätter, höchstens fünf', () => {
+ assert.deepEqual(ticketIdsOf([{ workorderId: 't1' }, { workorderId: 't1' }, { workorderId: 't2' }, {}, null]), ['t1', 't2'])
+ assert.deepEqual(ticketIdsOf(undefined), [])
+ assert.equal(ticketIdsOf(Array.from({ length: 7 }, (_, i) => ({ workorderId: `t${i}` }))).length, 5)
+})
diff --git a/tests/unit/rechnungAnfrage.test.mjs b/tests/unit/rechnungAnfrage.test.mjs
new file mode 100644
index 0000000..855c31f
--- /dev/null
+++ b/tests/unit/rechnungAnfrage.test.mjs
@@ -0,0 +1,281 @@
+import test from 'node:test'
+import assert from 'node:assert/strict'
+import { loadModule } from './load.mjs'
+
+const {
+ LEISTUNGSZEITRAUM_MAX, BEZEICHNUNG_MAX,
+ vorschauAnfrage, rechnungAnfrage, vorschauSchluessel, vorschauStatus, darfSpeichern,
+ zeileTeilen, zeileBearbeitbar, maxVorn, createSubmitter,
+} = await loadModule('src/lib/rechnungAnfrage.js')
+const { ADDRESS_INCOMPLETE_TEXT } = await loadModule('src/lib/financeView.js')
+
+const kunde = { $id: 'c1', name: 'Anelia Wolf', email: 'anelia@example.de', street: 'Hauptstr. 1', postalCode: '67655', city: 'Kaiserslautern', customerStatus: 'customer' }
+const zeile = (uid, description, quantity, price) => ({ uid, description, quantity, price })
+const bl = (id, minuten) => ({ id, ticketId: 't1', woid: '100131', datum: '2026-10-06', minuten, text: 'Startseite', aboZeit: false, kommentar: false })
+const B = [bl('b1', 60), bl('b2', 90), bl('b3', 45)]
+const basis = (over = {}) => ({
+ kunde, art: 'festbetrag', projektId: 'p1', auftragId: '',
+ items: [zeile(1, 'Erstellung der Website, Pauschalpreis laut Absprache', '1', '500,00')], zusatz: [],
+ blaetter: [], abgewaehlt: [], anzahlung: { modus: 'prozent', wert: '50', bezeichnung: 'Anzahlung' },
+ zeilentexte: {}, zeitraum: '', zeitraumGeaendert: false, days: '14', memo: '', ...over,
+})
+
+test('rechnungAnfrage: Festbetrag ohne AB (500 € laut Absprache)', () => {
+ const r = rechnungAnfrage(basis())
+ assert.equal(r.ok, true)
+ assert.deepEqual(r.body, {
+ customerId: 'c1', art: 'festbetrag', projektId: 'p1', daysUntilDue: 14,
+ items: [{ description: 'Erstellung der Website, Pauschalpreis laut Absprache', quantity: 1, unitAmountCents: 50000 }],
+ })
+ assert.equal(r.totalCents, 50000)
+})
+
+test('rechnungAnfrage: ohne Projekt ist die Art frei und der Körper trägt kein projektId', () => {
+ const r = rechnungAnfrage(basis({ art: 'frei', projektId: '' }))
+ assert.equal(r.ok, true)
+ assert.equal(r.body.art, 'frei')
+ assert.equal('projektId' in r.body, false)
+})
+
+test('rechnungAnfrage: Notiz und selbst geänderter Leistungszeitraum gehen mit, ein Vorschlag nicht', () => {
+ assert.equal(LEISTUNGSZEITRAUM_MAX, 140)
+ const r = rechnungAnfrage(basis({ memo: ' Danke ', zeitraum: ' Oktober 2026 ', zeitraumGeaendert: true }))
+ assert.equal(r.body.memo, 'Danke')
+ assert.equal(r.body.leistungszeitraum, 'Oktober 2026')
+ assert.equal('leistungszeitraum' in rechnungAnfrage(basis({ zeitraum: '01.10.–20.10.2026', zeitraumGeaendert: false })).body, false)
+ assert.equal('leistungszeitraum' in rechnungAnfrage(basis({ zeitraum: ' ', zeitraumGeaendert: true })).body, false)
+})
+
+test('rechnungAnfrage: Festbetrag prüft Kunde, Projekt, Positionen, Zahlungsziel und Zeitraum', () => {
+ assert.deepEqual(rechnungAnfrage(basis({ projektId: '' })), { ok: false, error: 'Bitte ein Projekt wählen.' })
+ assert.equal(rechnungAnfrage(basis({ kunde: null })).error, 'Bitte einen Kunden wählen.')
+ assert.equal(rechnungAnfrage(basis({ items: [zeile(1, '', '1', '10,00')] })).error, 'Position 1: Bitte eine Beschreibung eingeben.')
+ assert.equal(rechnungAnfrage(basis({ days: 'abc' })).error, 'Das Zahlungsziel muss eine ganze Zahl von 0 bis 60 Tagen sein.')
+ assert.equal(
+ rechnungAnfrage(basis({ zeitraum: 'x'.repeat(141), zeitraumGeaendert: true })).error,
+ 'Der Leistungszeitraum darf höchstens 140 Zeichen lang sein.'
+ )
+})
+
+test('rechnungAnfrage: Aufwand sendet nur die gewählten Arbeitsblätter, Zusatzpositionen und Zeilentexte', () => {
+ const r = rechnungAnfrage(basis({
+ art: 'aufwand', blaetter: B, abgewaehlt: ['b2'],
+ zeilentexte: { '2026-10-06': '06.10.2026: Startseite', leer: ' ' },
+ zusatz: [zeile(2, 'Domain-Registrierung', '1', '12,00'), zeile(3, '', '1', '')],
+ }))
+ assert.equal(r.ok, true)
+ assert.deepEqual(r.body, {
+ customerId: 'c1', art: 'aufwand', projektId: 'p1', daysUntilDue: 14,
+ arbeitsblattIds: ['b1', 'b3'],
+ items: [{ description: 'Domain-Registrierung', quantity: 1, unitAmountCents: 1200 }],
+ zeilentexte: { '2026-10-06': '06.10.2026: Startseite' },
+ })
+ assert.equal(r.totalCents, null)
+})
+
+test('rechnungAnfrage: Aufwand ohne Arbeitsblatt und unvollständige Zusatzpositionen', () => {
+ assert.deepEqual(
+ rechnungAnfrage(basis({ art: 'aufwand', blaetter: B, abgewaehlt: ['b1', 'b2', 'b3'] })),
+ { ok: false, error: 'Bitte mindestens ein Arbeitsblatt auswählen.' }
+ )
+ assert.equal(
+ rechnungAnfrage(basis({ art: 'aufwand', blaetter: B, zusatz: [zeile(2, 'Domain', '1', '')] })).error,
+ 'Zusatzposition 1: Der Einzelpreis muss ein Betrag zwischen 0,01 € und 100.000,00 € sein (z. B. 120,00).'
+ )
+ assert.equal(
+ rechnungAnfrage(basis({ art: 'aufwand', blaetter: B, zusatz: [zeile(2, '', '1', '5,00')] })).error,
+ 'Zusatzposition 1: Bitte eine Beschreibung eingeben.'
+ )
+ // eine ganz leere Zeile zählt nicht
+ const r = rechnungAnfrage(basis({ art: 'aufwand', blaetter: B, zusatz: [zeile(2, '', '1', '')] }))
+ assert.equal(r.ok, true)
+ assert.equal('items' in r.body, false)
+})
+
+test('rechnungAnfrage: Anzahlung in Prozent oder in Euro mit Bezeichnung', () => {
+ const prozent = rechnungAnfrage(basis({ art: 'anzahlung', auftragId: 'a1', anzahlung: { modus: 'prozent', wert: '33,5', bezeichnung: ' Teilrechnung ' } }))
+ assert.deepEqual(prozent.body, {
+ customerId: 'c1', art: 'anzahlung', projektId: 'p1', auftragId: 'a1', daysUntilDue: 14,
+ anzahlung: { prozent: 33.5, bezeichnung: 'Teilrechnung' },
+ })
+ const betrag = rechnungAnfrage(basis({ art: 'anzahlung', auftragId: 'a1', anzahlung: { modus: 'betrag', wert: '400,00', bezeichnung: 'Anzahlung' } }))
+ assert.deepEqual(betrag.body.anzahlung, { betragCents: 40000, bezeichnung: 'Anzahlung' })
+ assert.equal(BEZEICHNUNG_MAX, 60)
+})
+
+test('rechnungAnfrage: Anzahlung prüft AB, Prozent, Betrag und Bezeichnung', () => {
+ const an = (anzahlung, auftragId = 'a1') => rechnungAnfrage(basis({ art: 'anzahlung', auftragId, anzahlung }))
+ assert.equal(an({ modus: 'prozent', wert: '50', bezeichnung: 'Anzahlung' }, '').error, 'Bitte eine Auftragsbestätigung wählen.')
+ for (const wert of ['0', '0,00', '101', 'abc', '']) {
+ assert.equal(an({ modus: 'prozent', wert, bezeichnung: 'Anzahlung' }).error, 'Der Prozentsatz muss größer als 0 und höchstens 100 sein.', wert)
+ }
+ for (const wert of ['0,5', '100']) assert.equal(an({ modus: 'prozent', wert, bezeichnung: 'Anzahlung' }).ok, true, wert)
+ assert.equal(an({ modus: 'betrag', wert: 'abc', bezeichnung: 'Anzahlung' }).error, 'Der Betrag muss zwischen 0,01 € und 100.000,00 € liegen.')
+ assert.equal(an({ modus: 'prozent', wert: '50', bezeichnung: ' ' }).error, 'Bitte eine Bezeichnung eingeben (höchstens 60 Zeichen).')
+ assert.equal(an({ modus: 'prozent', wert: '50', bezeichnung: 'x'.repeat(61) }).error, 'Bitte eine Bezeichnung eingeben (höchstens 60 Zeichen).')
+})
+
+test('rechnungAnfrage: Schlussrechnung mit Nachträgen; die Vorschau trägt weder Zahlungsziel noch Notiz', () => {
+ const e = basis({ art: 'schluss', auftragId: 'a1', zusatz: [zeile(2, 'Nachtrag Logo', '1', '150,00')], memo: 'Danke' })
+ const items = [{ description: 'Nachtrag Logo', quantity: 1, unitAmountCents: 15000 }]
+ assert.deepEqual(rechnungAnfrage(e).body, {
+ customerId: 'c1', art: 'schluss', projektId: 'p1', auftragId: 'a1', items, daysUntilDue: 14, memo: 'Danke',
+ })
+ assert.deepEqual(vorschauAnfrage(e), { ok: true, body: { customerId: 'c1', art: 'schluss', projektId: 'p1', auftragId: 'a1', items } })
+ assert.deepEqual(rechnungAnfrage(basis({ art: 'schluss', auftragId: '' })), { ok: false, error: 'Bitte eine Auftragsbestätigung wählen.' })
+})
+
+test('vorschauAnfrage: Festbetrag hat keine Vorschau; Zahlungsziel und Notiz blockieren sie nie', () => {
+ assert.deepEqual(vorschauAnfrage(basis()), { ok: false, error: '' })
+ assert.deepEqual(vorschauAnfrage(basis({ art: 'frei', projektId: '' })), { ok: false, error: '' })
+ const e = basis({ art: 'schluss', auftragId: 'a1', days: 'abc', memo: 'x'.repeat(1001) })
+ assert.equal(vorschauAnfrage(e).ok, true)
+ assert.equal(rechnungAnfrage(e).error, 'Das Zahlungsziel muss eine ganze Zahl von 0 bis 60 Tagen sein.')
+ assert.deepEqual(vorschauAnfrage(basis({ art: 'aufwand', kunde: null })), { ok: false, error: 'Bitte einen Kunden wählen.' })
+})
+
+test('vorschauSchluessel: gleiche Auswahl in anderer Reihenfolge ist derselbe Schlüssel, Texte zählen nicht', () => {
+ const key = (over) => vorschauSchluessel(vorschauAnfrage(basis({ art: 'aufwand', blaetter: B, ...over })).body)
+ const k1 = key({})
+ assert.equal(key({ blaetter: [B[2], B[0], B[1]] }), k1)
+ assert.notEqual(key({ abgewaehlt: ['b2'] }), k1)
+ assert.equal(key({ zeilentexte: { '2026-10-06': 'x' }, zeitraum: 'Oktober', zeitraumGeaendert: true }), k1)
+ assert.notEqual(key({ zusatz: [zeile(2, 'Domain', '1', '12,00')] }), k1)
+})
+
+test('vorschauStatus: nicht nötig, ungültig, lädt, veraltet, Fehler, bereit', () => {
+ const ok = { ok: true, body: {} }
+ const fertig = { key: 'k1', status: 'ok', daten: { summeCents: 30000, positionen: [] } }
+ const status = (args) => vorschauStatus({ art: 'aufwand', anfrage: ok, vorschau: fertig, schluessel: 'k1', ...args })
+ assert.deepEqual(
+ vorschauStatus({ art: 'festbetrag', anfrage: { ok: false, error: '' }, vorschau: null, schluessel: '' }),
+ { state: 'nicht-noetig', message: '' }
+ )
+ assert.deepEqual(
+ status({ anfrage: { ok: false, error: 'Bitte mindestens ein Arbeitsblatt auswählen.' }, schluessel: '' }),
+ { state: 'ungueltig', message: 'Bitte mindestens ein Arbeitsblatt auswählen.' }
+ )
+ assert.deepEqual(status({ vorschau: null }), { state: 'laedt', message: '' })
+ assert.equal(status({ vorschau: { key: 'k1', status: 'laedt' } }).state, 'laedt')
+ assert.equal(status({ schluessel: 'k2' }).state, 'laedt') // veraltet: die Eingabe hat sich geändert
+ assert.deepEqual(
+ status({ vorschau: { key: 'k1', status: 'fehler', message: 'Arbeitsblatt bereits abgerechnet' } }),
+ { state: 'fehler', message: 'Arbeitsblatt bereits abgerechnet' }
+ )
+ assert.deepEqual(
+ status({ vorschau: { key: 'k1', status: 'ok', daten: { summeCents: 0, positionen: [] } } }),
+ { state: 'fehler', message: 'Nichts mehr offen.' }
+ )
+ assert.deepEqual(status({}), { state: 'bereit', message: '' })
+ assert.equal(darfSpeichern({ state: 'bereit' }), true)
+ assert.equal(darfSpeichern({ state: 'nicht-noetig' }), true)
+ for (const state of ['laedt', 'ungueltig', 'fehler']) assert.equal(darfSpeichern({ state }), false, state)
+})
+
+test('Abwahl eines Arbeitsblatts macht die Vorschau veraltet und sperrt Entwurf und Senden', () => {
+ const e1 = basis({ art: 'aufwand', blaetter: B })
+ const a1 = vorschauAnfrage(e1)
+ const k1 = vorschauSchluessel(a1.body)
+ const vorschau = { key: k1, status: 'ok', daten: { summeCents: 32500, positionen: [] } }
+ assert.equal(darfSpeichern(vorschauStatus({ art: 'aufwand', anfrage: a1, vorschau, schluessel: k1 })), true)
+ const a2 = vorschauAnfrage({ ...e1, abgewaehlt: ['b2'] })
+ const k2 = vorschauSchluessel(a2.body)
+ assert.deepEqual(a2.body.arbeitsblattIds, ['b1', 'b3'])
+ assert.notEqual(k2, k1)
+ assert.equal(darfSpeichern(vorschauStatus({ art: 'aufwand', anfrage: a2, vorschau, schluessel: k2 })), false)
+})
+
+test('zeileTeilen, zeileBearbeitbar und maxVorn: nur Aufwandszeilen sind bearbeitbar, geteilt wird am letzten Gedankenstrich', () => {
+ const tag = '06.10.2026: Startseite, Kontaktformular – 2,25 Std. à 100,00 €'
+ assert.deepEqual(zeileTeilen(tag), { vorn: '06.10.2026: Startseite, Kontaktformular', hinten: ' – 2,25 Std. à 100,00 €' })
+ assert.deepEqual(zeileTeilen('KW 41/2026: A – B – 6,5 Std. à 100,00 €'), { vorn: 'KW 41/2026: A – B', hinten: ' – 6,5 Std. à 100,00 €' })
+ assert.deepEqual(zeileTeilen('Domain-Registrierung'), { vorn: 'Domain-Registrierung', hinten: '' })
+ const pos = (schluessel, beschreibung = tag) => ({ schluessel, beschreibung })
+ assert.equal(zeileBearbeitbar(pos('2026-10-06')), true)
+ assert.equal(zeileBearbeitbar(pos('KW-2026-41')), true)
+ for (const schluessel of ['ab-1', 'zusatz-1', 'abzug-in_123', 'anzahlung']) assert.equal(zeileBearbeitbar(pos(schluessel)), false, schluessel)
+ assert.equal(zeileBearbeitbar(pos('2026-10-06', 'ohne Strich')), false)
+ const hinten = ' – 2,25 Std. à 100,00 €'
+ assert.equal(maxVorn(hinten), 200 - hinten.length)
+ assert.equal(maxVorn('x'.repeat(250)), 1)
+})
+
+const okAntwort = { rechnung: { id: 'in_1', number: 'WEBKLAR-0009', status: 'offen', customerEmail: 'anelia@example.de' } }
+const anfrage = rechnungAnfrage(basis())
+
+test('createSubmitter: Entwurf fragt nicht nach und sendet senden=false', async () => {
+ const gesendet = []
+ const submit = createSubmitter({
+ create: async (body) => (gesendet.push(body), okAntwort),
+ confirm: () => assert.fail('beim Entwurf gibt es keine Rückfrage'),
+ })
+ const r = await submit({ anfrage, senden: false, kunde, totalCents: 50000 })
+ assert.deepEqual(r, { status: 'ok', rechnung: okAntwort.rechnung, senden: false })
+ assert.equal(gesendet[0].senden, false)
+ assert.equal(gesendet[0].art, 'festbetrag')
+})
+
+test('createSubmitter: Senden fragt vorher nach, bei „Abbrechen“ geht nichts raus', async () => {
+ let frage = ''
+ let create = 0
+ const submit = createSubmitter({ create: async () => { create += 1; return okAntwort }, confirm: (text) => { frage = text; return false } })
+ assert.deepEqual(await submit({ anfrage, senden: true, kunde, totalCents: 50000 }), { status: 'abgebrochen' })
+ assert.equal(create, 0)
+ assert.equal(frage, 'Rechnung für Anelia Wolf über 500,00 € jetzt festschreiben und an anelia@example.de senden? Danach lässt sie sich nur noch stornieren.')
+})
+
+test('createSubmitter: Adress-Sperre über 250 € ohne Anschrift gilt auch hier', async () => {
+ let create = 0
+ const submit = createSubmitter({ create: async () => { create += 1; return okAntwort }, confirm: () => true })
+ const ohneAdresse = { ...kunde, street: '' }
+ assert.deepEqual(await submit({ anfrage, senden: true, kunde: ohneAdresse, totalCents: 25001 }), { status: 'fehler', message: ADDRESS_INCOMPLETE_TEXT })
+ assert.equal(create, 0)
+ assert.equal((await submit({ anfrage, senden: true, kunde: ohneAdresse, totalCents: 25000 })).status, 'ok') // Kleinbetrag
+ assert.equal((await submit({ anfrage, senden: false, kunde: ohneAdresse, totalCents: 25001 })).status, 'ok') // Entwurf bleibt möglich
+})
+
+test('createSubmitter: Doppelklick auf Senden legt nur eine Rechnung an', async () => {
+ let create = 0
+ let fertig
+ const submit = createSubmitter({
+ create: () => { create += 1; return new Promise((resolve) => { fertig = () => resolve(okAntwort) }) },
+ confirm: () => true,
+ })
+ const erster = submit({ anfrage, senden: true, kunde, totalCents: 50000 })
+ const zweiter = await submit({ anfrage, senden: true, kunde, totalCents: 50000 })
+ assert.deepEqual(zweiter, { status: 'laeuft' })
+ fertig()
+ assert.equal((await erster).status, 'ok')
+ assert.equal(create, 1)
+ // danach geht wieder ein Aufruf (nach einem Fehler muss der Nutzer es erneut versuchen können)
+ const dritter = submit({ anfrage, senden: false, kunde, totalCents: 50000 })
+ fertig()
+ assert.equal((await dritter).status, 'ok')
+ assert.equal(create, 2)
+})
+
+test('createSubmitter: Fehler des Servers kommen als Meldung zurück, auch 409 und „festgeschrieben“', async () => {
+ const wirft = (err) => createSubmitter({ create: async () => { throw err }, confirm: () => true })
+ const konflikt = Object.assign(new Error('Arbeitsblatt bereits abgerechnet (Rechnung WEBKLAR-0009).'), { status: 409 })
+ assert.deepEqual(await wirft(konflikt)({ anfrage, senden: false, kunde, totalCents: 1 }), {
+ status: 'fehler', message: 'Arbeitsblatt bereits abgerechnet (Rechnung WEBKLAR-0009).', httpStatus: 409,
+ })
+ const ohneStatus = new Error('Irgendwas ist schiefgegangen.')
+ assert.deepEqual(await wirft(ohneStatus)({ anfrage, senden: false, kunde, totalCents: 1 }), { status: 'fehler', message: 'Irgendwas ist schiefgegangen.' })
+ const festgeschrieben = Object.assign(new Error('Rechnung WEBKLAR-0009 wurde festgeschrieben, aber nicht versendet. Bitte erneut senden.'), {
+ status: 502, invoiceId: 'in_9', invoiceNumber: 'WEBKLAR-0009',
+ })
+ assert.deepEqual(await wirft(festgeschrieben)({ anfrage, senden: true, kunde, totalCents: 50000 }), {
+ status: 'festgeschrieben',
+ notice: 'Rechnung WEBKLAR-0009 wurde festgeschrieben, aber nicht versendet. Bitte in der Liste erneut senden.',
+ finalizedFilter: 'offen',
+ })
+})
+
+test('createSubmitter: eine ungültige Anfrage geht nie an den Server', async () => {
+ let create = 0
+ const submit = createSubmitter({ create: async () => { create += 1 }, confirm: () => true })
+ const r = await submit({ anfrage: { ok: false, error: 'Bitte einen Kunden wählen.' }, senden: false, kunde, totalCents: 0 })
+ assert.deepEqual(r, { status: 'fehler', message: 'Bitte einen Kunden wählen.' })
+ assert.equal(create, 0)
+})
diff --git a/tests/unit/rechnungDialogState.test.mjs b/tests/unit/rechnungDialogState.test.mjs
new file mode 100644
index 0000000..0016188
--- /dev/null
+++ b/tests/unit/rechnungDialogState.test.mjs
@@ -0,0 +1,132 @@
+import test from 'node:test'
+import assert from 'node:assert/strict'
+import { loadModule } from './load.mjs'
+
+const { anfangszustand, reduce } = await loadModule('src/lib/rechnungDialogState.js')
+
+test('anfangszustand: nichts gewählt, eine leere Position, Zahlungsziel 14', () => {
+ assert.deepEqual(anfangszustand({ projektId: 'p1' }), {
+ projektId: 'p1', art: '', auftragId: '',
+ items: [{ uid: 1, description: '', quantity: '1', price: '' }], zusatz: [],
+ abgewaehlt: [], weitereTickets: [], anzahlung: {}, zeilentexte: {},
+ zeitraum: '', zeitraumGeaendert: false, days: '14', tageGeaendert: false, memo: '',
+ })
+ assert.equal(anfangszustand().projektId, null)
+ assert.equal(anfangszustand({ projektId: '' }).projektId, '')
+})
+
+test('projekt: setzt alles zurück, behält aber Zahlungsziel und Notiz', () => {
+ let s = anfangszustand({ projektId: 'p1' })
+ s = reduce(s, { type: 'tage', wert: '30' })
+ s = reduce(s, { type: 'notiz', wert: 'Danke' })
+ s = reduce(s, { type: 'art', art: 'aufwand' })
+ s = reduce(s, { type: 'blatt', id: 'b1' })
+ s = reduce(s, { type: 'zeilentext', schluessel: '2026-10-06', text: 'Neu', original: 'Alt' })
+ s = reduce(s, { type: 'projekt', projektId: 'p2' })
+ assert.equal(s.projektId, 'p2')
+ assert.equal(s.art, '')
+ assert.deepEqual(s.abgewaehlt, [])
+ assert.deepEqual(s.zeilentexte, {})
+ assert.equal(s.days, '30')
+ assert.equal(s.memo, 'Danke')
+ assert.equal(reduce(s, { type: 'projekt', projektId: null }).projektId, null)
+})
+
+test('art: Positionen für Festbetrag und Zusatzpositionen bleiben getrennt und erhalten', () => {
+ let s = anfangszustand({ projektId: 'p1' })
+ s = reduce(s, { type: 'position', liste: 'items', uid: 1, feld: 'description', wert: 'Pauschale' })
+ s = reduce(s, { type: 'position+', liste: 'zusatz' })
+ s = reduce(s, { type: 'position', liste: 'zusatz', uid: 2, feld: 'price', wert: '12,00' })
+ s = reduce(s, { type: 'art', art: 'aufwand' })
+ assert.equal(s.art, 'aufwand')
+ assert.equal(s.items[0].description, 'Pauschale')
+ assert.deepEqual(s.zusatz, [{ uid: 2, description: '', quantity: '1', price: '12,00' }])
+ // eine unbekannte Liste oder ein unbekanntes Feld ändert nichts
+ assert.equal(reduce(s, { type: 'position', liste: 'x', uid: 1, feld: 'description', wert: 'y' }), s)
+ assert.equal(reduce(s, { type: 'position', liste: 'items', uid: 1, feld: 'uid', wert: 9 }), s)
+})
+
+test('position+ und position-: Positionen mindestens 1, Zusatzpositionen mindestens 0, höchstens 20', () => {
+ let s = anfangszustand({ projektId: 'p1' })
+ assert.equal(reduce(s, { type: 'position-', liste: 'items', uid: 1 }), s)
+ s = reduce(s, { type: 'position+', liste: 'zusatz' })
+ assert.equal(s.zusatz[0].uid, 2)
+ s = reduce(s, { type: 'position-', liste: 'zusatz', uid: 2 })
+ assert.deepEqual(s.zusatz, [])
+ for (let i = 0; i < 25; i += 1) s = reduce(s, { type: 'position+', liste: 'zusatz' })
+ assert.equal(s.zusatz.length, 20)
+})
+
+test('blatt: Abwählen und Wiederanwählen', () => {
+ let s = anfangszustand({ projektId: 'p1' })
+ s = reduce(s, { type: 'blatt', id: 'b2' })
+ assert.deepEqual(s.abgewaehlt, ['b2'])
+ s = reduce(s, { type: 'blatt', id: 'b2' })
+ assert.deepEqual(s.abgewaehlt, [])
+})
+
+test('ticket+ und ticket-: ein Ticket nur einmal, beim Entfernen verschwindet die Abwahl seiner Blätter', () => {
+ const ticket = { ticketId: 't2', woid: '100140', titel: 'Kontaktformular' }
+ let s = anfangszustand({ projektId: 'p1' })
+ s = reduce(s, { type: 'ticket+', ticket })
+ s = reduce(s, { type: 'ticket+', ticket })
+ assert.deepEqual(s.weitereTickets, [ticket])
+ s = reduce(s, { type: 'blatt', id: 'w1' })
+ s = reduce(s, { type: 'blatt', id: 'b9' })
+ s = reduce(s, { type: 'ticket-', ticketId: 't2', blattIds: ['w1'] })
+ assert.deepEqual(s.weitereTickets, [])
+ assert.deepEqual(s.abgewaehlt, ['b9'])
+})
+
+test('zeitraum-vorschlag gilt nur, solange der Nutzer das Feld nicht selbst geändert hat', () => {
+ let s = anfangszustand({ projektId: 'p1' })
+ s = reduce(s, { type: 'zeitraum-vorschlag', text: '01.10.–20.10.2026' })
+ assert.deepEqual([s.zeitraum, s.zeitraumGeaendert], ['01.10.–20.10.2026', false])
+ assert.equal(reduce(s, { type: 'zeitraum-vorschlag', text: '01.10.–20.10.2026' }), s) // gleicher Text: kein neuer Zustand
+ s = reduce(s, { type: 'zeitraum', text: 'Oktober 2026' })
+ assert.deepEqual([s.zeitraum, s.zeitraumGeaendert], ['Oktober 2026', true])
+ assert.equal(reduce(s, { type: 'zeitraum-vorschlag', text: '02.10.–21.10.2026' }).zeitraum, 'Oktober 2026')
+ // ein Wechsel der Art verwirft einen Vorschlag, nicht aber die Eingabe des Nutzers
+ const vorschlag = reduce(anfangszustand({ projektId: 'p1' }), { type: 'zeitraum-vorschlag', text: '01.10.–20.10.2026' })
+ assert.equal(reduce(vorschlag, { type: 'art', art: 'festbetrag' }).zeitraum, '')
+ assert.equal(reduce(s, { type: 'art', art: 'festbetrag' }).zeitraum, 'Oktober 2026')
+})
+
+test('zeilentext: ein unveränderter oder leerer Text entfernt den Eintrag', () => {
+ let s = anfangszustand({ projektId: 'p1' })
+ const setze = (text) => reduce(s, { type: 'zeilentext', schluessel: '2026-10-06', text, original: 'Alt' })
+ s = setze('Neu')
+ assert.deepEqual(s.zeilentexte, { '2026-10-06': 'Neu' })
+ s = setze('Alt')
+ assert.deepEqual(s.zeilentexte, {})
+ s = setze('Neu')
+ s = setze(' ')
+ assert.deepEqual(s.zeilentexte, {})
+})
+
+test('anzahlung und auftrag: Eingaben ergänzen sich, eine andere AB setzt sie zurück', () => {
+ let s = anfangszustand({ projektId: 'p1' })
+ s = reduce(s, { type: 'anzahlung', patch: { modus: 'betrag' } })
+ s = reduce(s, { type: 'anzahlung', patch: { wert: '400,00' } })
+ assert.deepEqual(s.anzahlung, { modus: 'betrag', wert: '400,00' })
+ s = reduce(s, { type: 'auftrag', auftragId: 'a2' })
+ assert.deepEqual([s.auftragId, s.anzahlung], ['a2', {}])
+ assert.equal(reduce(s, { type: 'gibtsnicht' }), s)
+})
+
+test('tage-vorschlag: übernimmt das Zahlungsziel der AB, solange der Nutzer es nicht selbst geändert hat', () => {
+ let s = anfangszustand({ projektId: 'p1' })
+ s = reduce(s, { type: 'tage-vorschlag', wert: '30' })
+ assert.equal(s.days, '30')
+ assert.equal(s.tageGeaendert, false)
+ assert.equal(reduce(s, { type: 'tage-vorschlag', wert: '30' }), s) // derselbe Wert: nichts zu tun
+ s = reduce(s, { type: 'tage-vorschlag', wert: '14' }) // andere AB gewählt: der Vorschlag folgt
+ assert.equal(s.days, '14')
+ // eine Eingabe des Nutzers wird nie überschrieben, auch nicht nach einem Projektwechsel
+ s = reduce(s, { type: 'tage', wert: '7' })
+ assert.equal(s.tageGeaendert, true)
+ assert.equal(reduce(s, { type: 'tage-vorschlag', wert: '30' }), s)
+ s = reduce(s, { type: 'projekt', projektId: 'p2' })
+ assert.equal(s.days, '7')
+ assert.equal(reduce(s, { type: 'tage-vorschlag', wert: '30' }).days, '7')
+})
diff --git a/tests/unit/rechnungDialogView.test.mjs b/tests/unit/rechnungDialogView.test.mjs
new file mode 100644
index 0000000..1fce816
--- /dev/null
+++ b/tests/unit/rechnungDialogView.test.mjs
@@ -0,0 +1,300 @@
+import test from 'node:test'
+import assert from 'node:assert/strict'
+import { loadModule } from './load.mjs'
+
+const {
+ AB_HINWEIS, ENTWURF_HINWEIS,
+ startProjekt, projektOptionen, wirksamesProjekt,
+ gueltigeFestpreisAbs, aufwandAuftrag, abLabel, berechnetFuerAuftrag, hatEntwurfZurAuftrag,
+ artOptionen, standardArt, standardAuftrag, anzahlungStandard,
+ ausgewaehlteIds, blaetterSumme, blattAusAppwrite, ticketOptionen, zahlungszielVorschlag,
+ STEUERNUMMER_RECHNUNG_HINWEIS, steuernummerHinweis,
+ STRIPE_UNBEKANNT, abSummenText, ohneProjektAbDaten,
+} = await loadModule('src/lib/rechnungDialogView.js')
+
+const ab = (over = {}) => ({
+ id: 'a1', nummer: 'AB-2026-0002', status: 'bestaetigt', art: 'festpreis', summeCents: 80000,
+ gesendetAm: '2026-10-06T08:15:00.000Z', zahlungsplan: { art: 'anzahlung', prozent: 50, text: '' }, stundensatzCents: null, ...over,
+})
+const rk = (over = {}) => ({
+ id: 'in_1', number: 'WEBKLAR-0007', status: 'offen', createdDate: '2026-10-08', totalCents: 40000, paidCents: 0, art: 'anzahlung', auftragId: 'a1', ...over,
+})
+const bl = (id, minuten, over = {}) => ({
+ id, ticketId: 't1', woid: '100131', datum: '2026-10-06', minuten, text: 'Startseite', aboZeit: false, kommentar: false, ...over,
+})
+const KONTEN = [
+ { projektId: 'p1', name: 'Website Relaunch', tickets: [{ id: 't1', woid: '100131' }] },
+ { projektId: 'p2', name: 'Shop', tickets: [] },
+]
+const optionen = (args) => artOptionen({ projektId: 'p1', auftraege: [], rechnungen: [], auftraegeAktiv: true, ...args })
+const zustand = (o) => o.map((x) => [x.key, x.enabled])
+
+test('startProjekt: Vorauswahl der Aufrufer (undefined = keine, null = Ohne Projekt, Text = Projekt)', () => {
+ assert.equal(startProjekt(undefined), null)
+ assert.equal(startProjekt(null), '')
+ assert.equal(startProjekt('p1'), 'p1')
+})
+
+test('projektOptionen: Projekte mit Ticketnummer, am Ende „Ohne Projekt“', () => {
+ assert.deepEqual(projektOptionen(KONTEN), [
+ { value: 'p1', label: 'Website Relaunch · #100131' },
+ { value: 'p2', label: 'Shop' },
+ { value: '', label: 'Ohne Projekt' },
+ ])
+ assert.deepEqual(projektOptionen([]), [{ value: '', label: 'Ohne Projekt' }])
+ assert.deepEqual(projektOptionen(undefined), [{ value: '', label: 'Ohne Projekt' }])
+})
+
+test('wirksamesProjekt: null = noch zu wählen, „“ = Ohne Projekt, Kunde ohne Projekte = Ohne Projekt', () => {
+ assert.equal(wirksamesProjekt('', KONTEN), '')
+ assert.equal(wirksamesProjekt('p2', KONTEN), 'p2')
+ assert.equal(wirksamesProjekt('gibtsnicht', KONTEN), null)
+ assert.equal(wirksamesProjekt('p1', undefined), 'p1') // Liste noch nicht geladen: die Vorauswahl bleibt
+ assert.equal(wirksamesProjekt(null, KONTEN), null)
+ assert.equal(wirksamesProjekt(null, []), '') // Kunde ohne Projekte: gleich „Ohne Projekt“
+ assert.equal(wirksamesProjekt(null, undefined), null)
+})
+
+test('gueltigeFestpreisAbs: nur Festpreis mit Status versendet oder bestätigt, neueste zuerst', () => {
+ const list = [
+ ab({ id: 'a1' }),
+ ab({ id: 'a2', status: 'entwurf' }),
+ ab({ id: 'a3', art: 'aufwand' }),
+ ab({ id: 'a4', status: 'abgerechnet' }),
+ ab({ id: 'a5', status: 'versendet', gesendetAm: '2026-10-07T08:00:00.000Z' }),
+ ab({ id: 'a6', status: 'storniert' }),
+ ]
+ assert.deepEqual(gueltigeFestpreisAbs(list).map((a) => a.id), ['a5', 'a1'])
+ assert.deepEqual(gueltigeFestpreisAbs(undefined), [])
+})
+
+test('aufwandAuftrag: jüngste gültige Aufwands-AB', () => {
+ const list = [
+ ab({ id: 'x1', art: 'aufwand', status: 'bestaetigt', gesendetAm: '2026-10-01T08:00:00.000Z' }),
+ ab({ id: 'x2', art: 'aufwand', status: 'versendet', gesendetAm: '2026-10-05T08:00:00.000Z' }),
+ ab({ id: 'x3', art: 'aufwand', status: 'storniert', gesendetAm: '2026-10-09T08:00:00.000Z' }),
+ ab({ id: 'a1' }),
+ ]
+ assert.equal(aufwandAuftrag(list).id, 'x2')
+ assert.equal(aufwandAuftrag([ab()]), null)
+ assert.equal(aufwandAuftrag(undefined), null)
+})
+
+test('abLabel: Nummer, Datum des Sendens und Summe bzw. „nach Aufwand“', () => {
+ assert.equal(abLabel(ab()), 'AB-2026-0002 vom 06.10.2026 · 800,00 €')
+ assert.equal(abLabel(ab({ art: 'aufwand', summeCents: null })), 'AB-2026-0002 vom 06.10.2026 · nach Aufwand')
+})
+
+test('berechnetFuerAuftrag und hatEntwurfZurAuftrag: nur finalisierte zählen, ein Entwurf blockiert', () => {
+ const list = [
+ rk(),
+ rk({ id: 'in_2', status: 'bezahlt', totalCents: 10000 }),
+ rk({ id: 'in_3', status: 'entwurf', totalCents: 5000 }),
+ rk({ id: 'in_4', status: 'storniert', totalCents: 7000 }),
+ rk({ id: 'in_5', auftragId: 'a2', totalCents: 9000 }),
+ ]
+ assert.equal(berechnetFuerAuftrag(list, 'a1'), 50000)
+ assert.equal(berechnetFuerAuftrag(list, 'a2'), 9000)
+ assert.equal(berechnetFuerAuftrag(undefined, 'a1'), 0)
+ assert.equal(hatEntwurfZurAuftrag(list, 'a1'), true)
+ assert.equal(hatEntwurfZurAuftrag(list, 'a2'), false)
+})
+
+test('artOptionen: mit gültiger Festpreis-AB sind alle vier Arten wählbar', () => {
+ const o = optionen({ auftraege: [ab()] })
+ assert.deepEqual(zustand(o), [['anzahlung', true], ['schluss', true], ['aufwand', true], ['festbetrag', true]])
+ assert.deepEqual(o.map((x) => x.label), ['Anzahlung', 'Schlussrechnung', 'Nach Aufwand', 'Festbetrag'])
+})
+
+test('artOptionen: ohne AB oder bei ausgeschaltetem Schalter erklärt ein Hinweis die Sperre', () => {
+ for (const args of [{ auftraege: [] }, { auftraege: [ab()], auftraegeAktiv: false }, { auftraege: [ab({ status: 'entwurf' })] }]) {
+ const o = optionen(args)
+ assert.deepEqual(zustand(o), [['anzahlung', false], ['schluss', false], ['aufwand', true], ['festbetrag', true]])
+ assert.equal(o[0].hint, AB_HINWEIS)
+ assert.equal(o[1].hint, AB_HINWEIS)
+ }
+ assert.equal(AB_HINWEIS, 'Anzahlung und Schlussrechnung brauchen eine Auftragsbestätigung.')
+})
+
+test('artOptionen: ein offener Entwurf zur AB sperrt die Schlussrechnung, nicht die Anzahlung', () => {
+ const o = optionen({ auftraege: [ab()], rechnungen: [rk({ status: 'entwurf' })] })
+ assert.deepEqual(zustand(o), [['anzahlung', true], ['schluss', false], ['aufwand', true], ['festbetrag', true]])
+ assert.equal(o[1].hint, ENTWURF_HINWEIS)
+ assert.equal(ENTWURF_HINWEIS, 'Zu dieser Auftragsbestätigung gibt es noch einen Entwurf. Erst senden oder löschen.')
+ // zwei ABs: die Schlussrechnung bleibt wählbar, solange eine AB ohne Entwurf da ist
+ const zwei = optionen({ auftraege: [ab(), ab({ id: 'a2', nummer: 'AB-2026-0003' })], rechnungen: [rk({ status: 'entwurf' })] })
+ assert.equal(zwei[1].enabled, true)
+})
+
+test('artOptionen: ohne Wahl keine Arten, ohne Projekt nur die freie Rechnung', () => {
+ assert.deepEqual(artOptionen({ projektId: null, auftraege: [ab()], rechnungen: [], auftraegeAktiv: true }), [])
+ const frei = artOptionen({ projektId: '', auftraege: [ab({ projektId: 'p1' })], rechnungen: [], auftraegeAktiv: true })
+ assert.deepEqual(zustand(frei), [['frei', true]])
+})
+
+test('standardArt: Anzahlung, Schlussrechnung, Aufwand oder Festbetrag je nach Lage', () => {
+ const lage = (args, extra = {}) => {
+ const eingabe = { auftraege: [], rechnungen: [], offeneMinuten: 0, ...args, ...extra }
+ return standardArt(optionen({ auftraege: eingabe.auftraege, rechnungen: eingabe.rechnungen }), eingabe)
+ }
+ assert.equal(lage({ auftraege: [ab()] }), 'anzahlung') // Zahlungsplan Anzahlung, noch nichts berechnet
+ assert.equal(lage({ auftraege: [ab()], rechnungen: [rk()] }), 'schluss') // Anzahlung schon berechnet
+ const nachAbnahme = ab({ zahlungsplan: { art: 'nach_abnahme', prozent: null, text: '' } })
+ assert.equal(lage({ auftraege: [nachAbnahme] }), 'schluss')
+ assert.equal(lage({}, { offeneMinuten: 90 }), 'aufwand')
+ assert.equal(lage({}), 'festbetrag')
+ const ohneProjekt = artOptionen({ projektId: '', auftraege: [], rechnungen: [], auftraegeAktiv: true })
+ assert.equal(standardArt(ohneProjekt, { auftraege: [], rechnungen: [], offeneMinuten: 0 }), 'frei')
+ assert.equal(standardArt([], { auftraege: [], rechnungen: [], offeneMinuten: 0 }), '')
+})
+
+test('standardAuftrag: die jüngste passende AB, bei Schlussrechnung ohne offenen Entwurf', () => {
+ const neu = ab({ id: 'a5', gesendetAm: '2026-10-07T08:00:00.000Z' })
+ assert.equal(standardAuftrag('anzahlung', [ab(), neu]), 'a5')
+ assert.equal(standardAuftrag('schluss', [ab(), neu]), 'a5')
+ assert.equal(standardAuftrag('schluss', [ab(), neu], [rk({ auftragId: 'a5', status: 'entwurf' })]), 'a1')
+ assert.equal(standardAuftrag('anzahlung', []), '')
+ assert.equal(standardAuftrag('aufwand', [ab({ id: 'x2', art: 'aufwand' })]), '') // die Aufwands-AB ordnet der Server zu
+ assert.equal(standardAuftrag('festbetrag', [ab()]), '')
+})
+
+test('anzahlungStandard: Prozent aus dem Zahlungsplan, sonst 50', () => {
+ const vorgabe = { modus: 'prozent', wert: '50', bezeichnung: 'Anzahlung' }
+ assert.deepEqual(anzahlungStandard(ab()), vorgabe)
+ assert.deepEqual(anzahlungStandard(ab({ zahlungsplan: { art: 'anzahlung', prozent: 33.5, text: '' } })), { ...vorgabe, wert: '33,5' })
+ assert.deepEqual(anzahlungStandard(ab({ zahlungsplan: { art: 'nach_abnahme', prozent: null, text: '' } })), vorgabe)
+ assert.deepEqual(anzahlungStandard(null), vorgabe)
+})
+
+test('ausgewaehlteIds und blaetterSumme: alle sind vorausgewählt, abgewählte fallen heraus', () => {
+ const B = [bl('b1', 60), bl('b2', 90), bl('b3', 45)]
+ assert.deepEqual(ausgewaehlteIds(B, []), ['b1', 'b2', 'b3'])
+ assert.deepEqual(ausgewaehlteIds(B, ['b2']), ['b1', 'b3'])
+ assert.deepEqual(ausgewaehlteIds(undefined, []), [])
+ assert.deepEqual(blaetterSumme(B, ['b1', 'b3']), { anzahl: 2, minuten: 105 })
+ assert.deepEqual(blaetterSumme(B, []), { anzahl: 0, minuten: 0 })
+})
+
+const ws = (over = {}) => ({
+ $id: 'w1', woid: '100140', workorderId: 't2', totalTime: 60, supporters: '', isComment: false, countsToPlan: false,
+ startDate: '06.10.2026', endDate: '06.10.2026', details: 'Kontaktformular gebaut', $createdAt: '2026-10-06T07:00:00.000Z', ...over,
+})
+
+test('blattAusAppwrite: abrechenbares Blatt mit Minuten (inkl. Unterstützung), Datum und kurzem Text', () => {
+ assert.deepEqual(blattAusAppwrite(ws(), {}), {
+ id: 'w1', ticketId: 't2', woid: '100140', datum: '2026-10-06', minuten: 60, text: 'Kontaktformular gebaut', aboZeit: false, kommentar: false,
+ })
+ // Unterstützung zählt mit: 60 + 1 × 30
+ const mit = ws({ supporters: JSON.stringify([{ employeeId: 'e1', name: 'A', task: 'Review' }]) })
+ assert.equal(blattAusAppwrite(mit, {}).minuten, 90)
+ // ein Altwert außerhalb des 15-Minuten-Rasters wird aufgerundet
+ assert.equal(blattAusAppwrite(ws({ totalTime: 70 }), {}).minuten, 75)
+ // ohne gültiges Datum zählt der Erstellungstag, der Text ist die erste Zeile, höchstens 120 Zeichen
+ assert.equal(blattAusAppwrite(ws({ startDate: '', endDate: '' }), {}).datum, '2026-10-06')
+ assert.equal(blattAusAppwrite(ws({ details: 'Zeile 1\nZeile 2' }), {}).text, 'Zeile 1')
+ assert.equal(blattAusAppwrite(ws({ details: 'a'.repeat(300) }), {}).text.length, 120)
+})
+
+test('blattAusAppwrite: Kommentar, Abo-Zeit, 0 Minuten und schon Abgerechnetes sind null', () => {
+ assert.equal(blattAusAppwrite(ws({ isComment: true }), {}), null)
+ assert.equal(blattAusAppwrite(ws({ countsToPlan: true }), {}), null)
+ assert.equal(blattAusAppwrite(ws({ totalTime: 0 }), {}), null)
+ assert.equal(blattAusAppwrite(ws(), { w1: 'WEBKLAR-0009' }), null)
+ assert.equal(blattAusAppwrite(ws(), { w1: 'Entwurf' }), null)
+ assert.equal(blattAusAppwrite({}, {}), null)
+})
+
+test('ticketOptionen: Tickets des Kunden mit Nummer, ohne die schon gewählten', () => {
+ const T = [
+ { $id: 't1', woid: '100131', topic: 'Relaunch' },
+ { $id: 't2', woid: '100140', topic: 'Kontaktformular' },
+ { $id: 't3', woid: '', topic: '', title: 'Alt' },
+ { $id: 't4', woid: '100150', topic: '', title: '' },
+ ]
+ assert.deepEqual(ticketOptionen(T, { ausgeschlossen: ['t1'] }), [
+ { value: 't2', ticketId: 't2', woid: '100140', label: '#100140 · Kontaktformular' },
+ { value: 't4', ticketId: 't4', woid: '100150', label: '#100150 · Kein Betreff' },
+ ])
+ assert.deepEqual(ticketOptionen(undefined), [])
+})
+
+test('zahlungszielVorschlag: Anzahlung und Schluss übernehmen das Zahlungsziel der gewählten AB, sonst 14', () => {
+ const dreissig = ab({ zahlungszielTage: 30 })
+ assert.equal(zahlungszielVorschlag({ art: 'anzahlung', ab: dreissig }), '30')
+ assert.equal(zahlungszielVorschlag({ art: 'schluss', ab: dreissig }), '30')
+ assert.equal(zahlungszielVorschlag({ art: 'anzahlung', ab: ab({ zahlungszielTage: 0 }) }), '0') // sofort fällig ist erlaubt
+ // Aufwand: die jüngste gültige Aufwands-AB
+ const aufwand = ab({ id: 'x2', art: 'aufwand', zahlungszielTage: 21 })
+ assert.equal(zahlungszielVorschlag({ art: 'aufwand', auftraege: [ab(), aufwand] }), '21')
+ // ohne AB, ohne Wert oder mit unbrauchbarem Wert bleibt es bei 14; frei und Festbetrag kennen keine AB
+ assert.equal(zahlungszielVorschlag({ art: 'anzahlung', ab: undefined }), '14')
+ assert.equal(zahlungszielVorschlag({ art: 'anzahlung', ab: ab() }), '14')
+ for (const kaputt of [-1, 61, 1.5, '30', null, NaN]) assert.equal(zahlungszielVorschlag({ art: 'schluss', ab: ab({ zahlungszielTage: kaputt }) }), '14')
+ assert.equal(zahlungszielVorschlag({ art: 'festbetrag', ab: dreissig }), '14')
+ assert.equal(zahlungszielVorschlag({ art: 'frei', ab: dreissig }), '14')
+ assert.equal(zahlungszielVorschlag({ art: '', auftraege: [aufwand] }), '14')
+})
+
+test('steuernummerHinweis: nicht sperrender Hinweis, solange die Steuernummer in den Einstellungen leer ist', () => {
+ assert.equal(
+ STEUERNUMMER_RECHNUNG_HINWEIS,
+ 'Die Steuernummer fehlt noch – sie gehört auf jede Rechnung (§ 34a UStDV). Eintragen unter Finanzen → Einstellungen.'
+ )
+ for (const leer of [{}, { steuernummer: '' }, { steuernummer: ' ' }, { steuernummer: null }]) {
+ assert.equal(steuernummerHinweis(leer), STEUERNUMMER_RECHNUNG_HINWEIS)
+ }
+ assert.equal(steuernummerHinweis({ steuernummer: '12/345/67890' }), '')
+ // noch nicht geladen (undefined) und Ladefehler (null) behaupten nichts
+ assert.equal(steuernummerHinweis(undefined), '')
+ assert.equal(steuernummerHinweis(null), '')
+ assert.equal(steuernummerHinweis([]), '')
+})
+
+test('abSummenText: Auftragssumme, berechnet und noch offen; bei Stripe-Ausfall „Beträge unbekannt“ statt 0,00 €', () => {
+ assert.equal(
+ abSummenText({ ab: ab(), rechnungen: [rk()], stripeOk: true }),
+ 'Auftragssumme 800,00 € · bereits berechnet 400,00 € · noch nicht berechnet 400,00 €'
+ )
+ assert.equal(abSummenText({ ab: ab(), rechnungen: [], stripeOk: undefined }), 'Auftragssumme 800,00 € · bereits berechnet 0,00 € · noch nicht berechnet 800,00 €')
+ assert.equal(STRIPE_UNBEKANNT, 'Stripe nicht erreichbar – Beträge unbekannt')
+ const aus = abSummenText({ ab: ab(), rechnungen: [], stripeOk: false })
+ assert.equal(aus, 'Auftragssumme 800,00 € · Stripe nicht erreichbar – Beträge unbekannt')
+ assert.ok(!aus.includes('bereits berechnet'))
+})
+
+test('standardArt: bei Stripe-Ausfall wird keine Art vorgeschlagen (Beträge unbekannt)', () => {
+ const lage = { auftraege: [ab()], rechnungen: [], offeneMinuten: 90 }
+ const o = optionen({ auftraege: [ab()] })
+ assert.equal(standardArt(o, lage), 'anzahlung')
+ assert.equal(standardArt(o, { ...lage, stripeOk: true }), 'anzahlung')
+ assert.equal(standardArt(o, { ...lage, stripeOk: false }), '')
+ // ohne Projekt gibt es nichts zu raten: die freie Rechnung bleibt die Vorgabe
+ const frei = artOptionen({ projektId: '', auftraege: [], rechnungen: [], auftraegeAktiv: true })
+ assert.equal(standardArt(frei, { ...lage, stripeOk: false }), 'frei')
+})
+
+test('artOptionen ohne Projekt: eine gültige AB ohne Projekt macht Anzahlung und Schlussrechnung wählbar', () => {
+ const ohne = (args = {}) => artOptionen({ projektId: '', auftraege: [ab()], rechnungen: [], auftraegeAktiv: true, ...args })
+ assert.deepEqual(zustand(ohne()), [['frei', true], ['anzahlung', true], ['schluss', true]])
+ assert.deepEqual(ohne().map((x) => x.label), ['Freie Positionen', 'Anzahlung', 'Schlussrechnung'])
+ // eine AB mit Projekt, ein Entwurf, ein falscher Status oder der ausgeschaltete Schalter: wie bisher nur frei bzw. gesperrt
+ assert.deepEqual(zustand(ohne({ auftraege: [ab({ projektId: 'p1' })] })), [['frei', true]])
+ assert.deepEqual(zustand(ohne({ auftraege: [ab({ status: 'entwurf' })] })), [['frei', true]])
+ assert.deepEqual(zustand(ohne({ auftraegeAktiv: false })), [['frei', true]])
+ const entwurf = ohne({ rechnungen: [rk({ status: 'entwurf' })] })
+ assert.deepEqual(zustand(entwurf), [['frei', true], ['anzahlung', true], ['schluss', false]])
+ assert.equal(entwurf[2].hint, ENTWURF_HINWEIS)
+ // Vorgabe bleibt die freie Rechnung
+ assert.equal(standardArt(ohne(), { auftraege: [ab()], rechnungen: [], offeneMinuten: 0 }), 'frei')
+})
+
+test('ohneProjektAbDaten: nur ABs ohne Projekt und deren Rechnungen aus Auftrags- und Rechnungsliste', () => {
+ const daten = ohneProjektAbDaten(
+ { auftraege: [ab({ id: 'a1', projektId: '' }), ab({ id: 'a2', projektId: 'p1' }), ab({ id: 'a3' })] },
+ { rechnungen: [rk({ auftragId: 'a1' }), rk({ id: 'in_2', auftragId: 'a2' }), rk({ id: 'in_3', auftragId: '' }), rk({ id: 'in_4', auftragId: 'a3', status: 'entwurf' })] }
+ )
+ assert.deepEqual(daten.auftraege.map((a) => a.id), ['a1', 'a3'])
+ assert.deepEqual(daten.rechnungen.map((r) => r.id), ['in_1', 'in_4'])
+ assert.deepEqual(ohneProjektAbDaten(undefined, null), { auftraege: [], rechnungen: [] })
+ assert.deepEqual(ohneProjektAbDaten({ auftraege: 'x' }, { rechnungen: {} }), { auftraege: [], rechnungen: [] })
+})