Auftragsbestaetigung: Dialog Schritt 4 (Vorschau, Entwurf speichern, Senden mit Rueckfrage und Sperre)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Sonnet 5.5 committed 2026-10-07 14:36:59 +00:00
1 parent 5060d150ea
commit 746244a4a6
3 files changed
+366 -8

No files matched your search

+271 -8
View File
@@ -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 (
<Dialog title="Auftragsbestätigung" onClose={onClose} footer={<Button type="button" onClick={onClose}>Schließen</Button>}>
<p className="msg warn" role="alert">
Nur Entwürfe lassen sich bearbeiten. Eine gesendete Auftragsbestätigung lässt sich nur über „Neue Fassung“ in der Liste ändern.
</p>
</Dialog>
)
}
return (
<AuftragForm
key={auftrag?.id || projektId || 'neu'}
onClose={onClose}
onSaved={onSaved}
kunde={kunde}
projektId={projektId}
auftrag={auftrag}
konten={konten}
onShowOverview={onShowOverview}
/>
)
}
// 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 = (
<div className="auftrag-foot">
{schritt > 1 && (
<Button type="button" onClick={zurueck} disabled={beschaeftigt}>Zurück</Button>
)}
<span className="grow" />
{schritt < 4 ? (
<>
<Button type="button" onClick={schliessen} disabled={beschaeftigt}>Abbrechen</Button>
<Button type="submit" variant="primary" disabled={beschaeftigt}>{schritt === 3 ? 'Zur Vorschau' : 'Weiter'}</Button>
</>
) : (
<>
<Button type="button" onClick={speichernUndSchliessen} disabled={beschaeftigt || !entwurf}>Als Entwurf speichern</Button>
<Button
type="button"
variant="primary"
onClick={senden}
disabled={beschaeftigt || !entwurf || !pdfUrl || !gate.erlaubt}
title={!gate.erlaubt ? gate.grund : undefined}
>
{phase === 'senden' ? 'Sende …' : 'Senden'}
</Button>
</>
)}
</div>
)
return (
<Dialog
as="form"
wide
title={auftrag ? 'Auftragsbestätigung bearbeiten' : 'Auftragsbestätigung'}
busy={beschaeftigt}
onClose={schliessen}
onSubmit={(e) => {
e.preventDefault()
if (schritt < 4) weiter()
}}
footer={fuss}
>
<ol className="auftrag-schritte" aria-label="Schritte">
{SCHRITTE.map((s) => (
<li key={s.id} aria-current={s.id === schritt ? 'step' : undefined}>
<span className="auftrag-schritt-nr">{s.id}</span>
<span className="auftrag-schritt-text">{s.label}</span>
</li>
))}
</ol>
{schritt === 1 && <SchrittProjekt form={form} onChange={setForm} fehler={fehler} kunde={kunde} konten={konten} neueFassung={Boolean(auftrag?.ersetztId)} />}
{schritt === 2 && <SchrittAuftrag form={form} onChange={setForm} fehler={fehler} kunde={kunde} konten={konten} />}
{schritt === 3 && <SchrittZahlung form={form} onChange={setForm} fehler={fehler} kunde={kunde} konten={konten} />}
{schritt === 4 && (
<AuftragVorschau
phase={phase}
pdfUrl={pdfUrl}
meldung={meldung}
gate={gate}
privat={privat}
hinweise={hinweise}
onRetry={zurVorschau}
onShowOverview={() => {
schliessen()
onShowOverview()
}}
/>
)}
</Dialog>
)
}
@@ -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 (
<div className="auftrag-schritt">
{phase === 'speichern' && <div className="loading" role="status">Entwurf wird gespeichert …</div>}
{phase === 'pdf' && <div className="loading" role="status">Vorschau wird erstellt …</div>}
{meldung && (
<div className="msg err" role="alert" ref={errorRef}>
{meldung.text}
{meldung.zurUebersicht && (
<>
{' '}
<button type="button" className="link" onClick={onShowOverview}>
Zur Übersicht
</button>
</>
)}
</div>
)}
{meldung && !pdfUrl && !laedt && phase !== 'senden' && (
<div>
<Button type="button" onClick={onRetry}>Erneut versuchen</Button>
</div>
)}
{pdfUrl && <p className="msg ok">Entwurf gespeichert. Er bleibt in der Liste, bis er gesendet oder gelöscht wird.</p>}
{gate && !gate.erlaubt && (
<div className="msg warn" role="alert">
<span>{gate.grund}</span>
{gate.zurUebersicht && (
<>
{' '}
<button type="button" className="link" onClick={onShowOverview}>
Zur Übersicht
</button>
</>
)}
</div>
)}
{hinweise.map((h) => (
<p key={h.key} className="msg warn auftrag-hinweis">{h.text}</p>
))}
{privat && (
<p className="hint">
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).
</p>
)}
{pdfUrl && (
<>
<iframe className="auftrag-pdf" title="Vorschau der Auftragsbestätigung" src={pdfUrl} />
<a className="link" href={pdfUrl} target="_blank" rel="noopener noreferrer">
PDF in neuem Tab öffnen
</a>
</>
)}
</div>
)
}
+15
View File
@@ -76,3 +76,18 @@
.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; }
}