diff --git a/src/components/auftraege/AuftragDialog.jsx b/src/components/auftraege/AuftragDialog.jsx index 564cbc6..2bded8c 100644 --- a/src/components/auftraege/AuftragDialog.jsx +++ b/src/components/auftraege/AuftragDialog.jsx @@ -1,12 +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' + /** - * Platzhalter von W0: Strang W2 ersetzt diese Datei vollständig (Dateiname, Default-Export und Props bleiben). - * - * Props (V8): open, onClose, onSaved(auftrag) (AuftragTeam mit mailStatus; nach Speichern oder Senden, danach onClose()), - * kunde (kommt mit aktueller kundenart: der Container gleicht sie über kundeMitKundenart ab), projektId (vorausgewähltes - * Projekt oder null = freier Titel), auftrag (Entwurf zum Bearbeiten oder null), konten (Projektkonten für die Projektwahl), - * onShowOverview (Weg „Zur Übersicht“). - * Die PDF-Vorschau öffnet openInNewTab(async () => URL.createObjectURL(await getAuftragPdf(id))). + * 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 }) { - return null + 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 && ( + + )} + + {schritt < 4 ? ( + <> + + + + ) : ( + <> + + + + )} +
+ ) + + return ( + { + e.preventDefault() + if (schritt < 4) weiter() + }} + footer={fuss} + > +
    + {SCHRITTE.map((s) => ( +
  1. + {s.id} + {s.label} +
  2. + ))} +
+ {schritt === 1 && } + {schritt === 2 && } + {schritt === 3 && } + {schritt === 4 && ( + { + schliessen() + onShowOverview() + }} + /> + )} +
+ ) } 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 && ( + <> + {' '} + + + )} +
+ )} + {meldung && !pdfUrl && !laedt && phase !== 'senden' && ( +
+ +
+ )} + + {pdfUrl &&

Entwurf gespeichert. Er bleibt in der Liste, bis er gesendet oder gelöscht wird.

} + + {gate && !gate.erlaubt && ( +
+ {gate.grund} + {gate.zurUebersicht && ( + <> + {' '} + + + )} +
+ )} + + {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 && ( + <> +