Auftragsbestaetigung: Listenlogik (Status, Aktionen, Meldungen, Fehlerklassen, Sperre) und PDF-Hilfe mit Tests
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
45dd73d8cc
commit
8b055a5d42
4 files changed
+585
No files matched your search
@@ -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<Blob>. 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 }
|
||||
)
|
||||
}
|
||||
@@ -417,3 +417,234 @@ export function auftragHinweise({ kunde, form, einstellungen } = {}) {
|
||||
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
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user