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 index 6944401..834d516 100644 --- a/src/lib/auftragView.js +++ b/src/lib/auftragView.js @@ -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 + } + }, + } +} 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 index 71df20a..1845a09 100644 --- a/tests/unit/auftragView.test.mjs +++ b/tests/unit/auftragView.test.mjs @@ -8,6 +8,8 @@ const { 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 = [ @@ -398,3 +400,281 @@ test('auftragHinweise: fehlende Steuernummer, dann „vor Ort“ bei Privatperso 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) +})