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:
1 parent
5060d150ea
commit
746244a4a6
3 files changed
+366
-8
No files matched your search
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
Reference in new issue
Block a user