// Reine Ansichtslogik der Auftragsbestaetigungen (AB) in WOMS: Eingaben pruefen, Cent und Minuten, Formular <-> Anfrage, // Zahlungsplan-Vorschau, Hinweise, Status und Aktionen der Liste, Meldungen und Fehlerklassen. Kein React, kein Appwrite, // unter Node testbar. Betraege sind ganze Cent, in Formularen Euro-Texte (de-DE); Texte deutsch und sachlich (keine Anrede). // Die Grenzen entsprechen dem Vertrag V2 (LIMITS) der Uebersicht, der Server entscheidet. import { canInvoice, dateDe, euro, ymdBerlin } from './financeFormat' import { ADDRESS_INCOMPLETE_TEXT, addressComplete, invoiceTotalCents, parseQuantity, parseUnitPrice } from './financeView' import { euroField } from './financeSettings' import { ticketNumber } from './ticketView' import { KUNDENARTEN, STEUERNUMMER_FEHLT_HINWEIS, steuernummerFehlt } from './kundenFelder' // --- Konstanten -------------------------------------------------------------------------------------------------------- export const AUFTRAG_LIMITS = { maxPositionen: 20, maxBeschreibung: 200, maxMenge: 999, maxEinzelpreisCents: 10_000_000, maxSummeCents: 99_999_999, maxTitel: 200, maxZahlungsplanText: 500, maxZahlungszielTage: 60, standardZahlungszielTage: 14, standardAnzahlungProzent: 50, maxZeitquellen: 10, maxSchaetzungMinuten: 99_960, maxLeistungszeitraum: 140, } export const STUNDENSATZ_STANDARD_CENTS = 10000 export const AUFTRAG_WEGE = [ { value: 'muendlich', label: 'Mündlich' }, { value: 'telefon', label: 'Telefonisch' }, { value: 'email', label: 'Per E-Mail' }, { value: 'vor_ort', label: 'Vor Ort' }, { value: 'chat', label: 'Im Chat' }, ] export const ZAHLUNGSPLAENE = { nach_abnahme: 'Alles nach Abnahme', anzahlung: 'Anzahlung bei Auftrag, Rest nach Abnahme', monatlich: 'Monatlich nach Aufwand', frei: 'Frei (eigener Text)', } export const SCHRITTE = [ { id: 1, label: 'Projekt' }, { id: 2, label: 'Auftrag' }, { id: 3, label: 'Zahlung und Termine' }, { id: 4, label: 'Vorschau' }, ] const ARTEN = ['festpreis', 'aufwand'] /** Auswahl des Zahlungsplans je Art: „Anzahlung“ nur beim Festpreis, „Monatlich“ nur nach Aufwand. */ export function zahlungsplanOptionen(art) { const werte = art === 'aufwand' ? ['nach_abnahme', 'monatlich', 'frei'] : ['nach_abnahme', 'anzahlung', 'frei'] return werte.map((value) => ({ value, label: ZAHLUNGSPLAENE[value] })) } // --- Hilfen ------------------------------------------------------------------------------------------------------------ const text = (v) => (typeof v === 'string' ? v : '') const trimmed = (v) => text(v).trim() const istObjekt = (v) => Boolean(v) && typeof v === 'object' && !Array.isArray(v) const objekte = (v) => (Array.isArray(v) ? v.filter(istObjekt) : []) /** 'YYYY-MM-DD' und ein wirklich existierender Tag. */ function istTag(wert) { return /^\d{4}-\d{2}-\d{2}$/.test(text(wert)) && dateDe(wert) !== '–' } // --- Eingaben ---------------------------------------------------------------------------------------------------------- /** Stunden mit Komma in 15-Minuten-Schritten („10“, „12,5“, „2 Std.“) -> Minuten; ungueltig, leer oder 0 -> null. */ export function parseStunden(eingabe) { const s = trimmed(eingabe).replace(/\s*(std\.?|h)$/i, '') const m = /^(\d{1,4})(?:,(\d{1,2}))?$/.exec(s) if (!m) return null const hundertstel = Number(m[1]) * 60 * 100 + Number((m[2] || '').padEnd(2, '0') || 0) * 60 if (hundertstel % 100 !== 0) return null const minuten = hundertstel / 100 if (!(minuten > 0) || minuten % 15 !== 0 || minuten > AUFTRAG_LIMITS.maxSchaetzungMinuten) return null return minuten } /** Minuten -> Stunden mit Komma ohne ueberfluessige Nullen (150 -> „2,5“); keine Zahl -> „–“. */ export function stundenText(minuten) { if (typeof minuten !== 'number' || !Number.isFinite(minuten)) return '–' return String(Number((minuten / 60).toFixed(2))).replace('.', ',') } /** „50“ oder „30 %“ -> 1..99, sonst null. */ export function parseProzent(eingabe) { const s = trimmed(eingabe).replace(/\s*%$/, '') if (!/^\d{1,2}$/.test(s)) return null const n = Number(s) return n >= 1 && n <= 99 ? n : null } /** Zahlungsziel in Tagen 0..60, sonst null. */ export function parseTage(eingabe) { const s = trimmed(eingabe) if (!/^\d{1,3}$/.test(s)) return null const n = Number(s) return n <= AUFTRAG_LIMITS.maxZahlungszielTage ? n : null } /** Berliner Kalendertag („YYYY-MM-DD“), nicht der UTC-Tag. */ export function heuteBerlin(now = Date.now()) { return ymdBerlin(now) } // --- Formular ---------------------------------------------------------------------------------------------------------- let naechsteUid = 1 /** Leere Position des Formulars; `uid` ist nur der React-Schluessel. */ export function neuePosition() { const uid = naechsteUid naechsteUid += 1 return { uid, description: '', quantity: '1', price: '' } } const kontoMit = (konten, projektId) => projektId && Array.isArray(konten) ? objekte(konten).find((k) => k.projektId === projektId) || null : null const ticketIds = (konto) => (konto && Array.isArray(konto.tickets) ? konto.tickets.map((t) => t?.id).filter((id) => typeof id === 'string' && id) : []) /** Neues Formular; ein vorgewaehltes Projekt fuellt Titel und Zeitquelle. */ export function neuesFormular({ projektId = '', konten = [], heute = heuteBerlin() } = {}) { const konto = kontoMit(konten, projektId) return { projektId: konto ? konto.projektId : '', titel: konto ? text(konto.name) : '', titelGeaendert: false, kundenart: '', art: 'festpreis', auftragVom: heute, auftragWeg: '', positionen: [neuePosition()], stundensatz: euroField(STUNDENSATZ_STANDARD_CENTS), schaetzung: '', zahlungsart: 'nach_abnahme', prozent: String(AUFTRAG_LIMITS.standardAnzahlungProzent), zahlungstext: '', zahlungsziel: String(AUFTRAG_LIMITS.standardZahlungszielTage), startAm: '', fertigBis: '', zeitquelle: ticketIds(konto), zeitquelleGeaendert: false, } } /** Entwurf vom Server -> Formular (Euro- und Stundentexte). Titel und Zeitquelle gelten als von Hand gesetzt. */ export function auftragZuFormular(auftrag) { const a = istObjekt(auftrag) ? auftrag : {} const positionen = objekte(a.positionen).map((p) => ({ uid: neuePosition().uid, description: text(p.beschreibung), quantity: String(p.menge ?? ''), price: euroField(p.einzelpreisCents), })) const plan = istObjekt(a.zahlungsplan) ? a.zahlungsplan : {} return { projektId: text(a.projektId), titel: text(a.titel), titelGeaendert: true, kundenart: '', art: ARTEN.includes(a.art) ? a.art : 'festpreis', auftragVom: text(a.auftragVom), auftragWeg: text(a.auftragWeg), positionen: positionen.length ? positionen : [neuePosition()], stundensatz: euroField(typeof a.stundensatzCents === 'number' ? a.stundensatzCents : STUNDENSATZ_STANDARD_CENTS), schaetzung: typeof a.schaetzungMinuten === 'number' ? stundenText(a.schaetzungMinuten) : '', zahlungsart: text(plan.art) || 'nach_abnahme', prozent: typeof plan.prozent === 'number' ? String(plan.prozent) : String(AUFTRAG_LIMITS.standardAnzahlungProzent), zahlungstext: text(plan.text), zahlungsziel: String(typeof a.zahlungszielTage === 'number' ? a.zahlungszielTage : AUFTRAG_LIMITS.standardZahlungszielTage), startAm: text(a.startAm), fertigBis: text(a.fertigBis), zeitquelle: Array.isArray(a.zeitquelle) ? a.zeitquelle.filter((id) => typeof id === 'string') : [], zeitquelleGeaendert: true, } } /** Projekt waehlen: Titel und Zeitquelle folgen dem Projekt, solange sie nicht von Hand geaendert wurden. */ export function waehleProjekt(form, projektId, konten) { const konto = kontoMit(konten, projektId) const neu = { ...form, projektId: konto ? konto.projektId : '' } if (!form.titelGeaendert) neu.titel = konto ? text(konto.name) : '' if (!form.zeitquelleGeaendert) neu.zeitquelle = ticketIds(konto) return neu } /** Art wechseln; ein unpassender Zahlungsplan wird zu „Alles nach Abnahme“. */ export function wechselArt(form, art) { if (!ARTEN.includes(art)) return form const passt = zahlungsplanOptionen(art).some((o) => o.value === form.zahlungsart) return { ...form, art, zahlungsart: passt ? form.zahlungsart : 'nach_abnahme' } } /** Summe der gueltigen Positionen in Cent (Festpreis), bei Aufwand null. */ export function formularSummeCents(form) { return form?.art === 'festpreis' ? invoiceTotalCents(form.positionen) : null } // --- Pruefen ----------------------------------------------------------------------------------------------------------- const MELDUNG = { titel: `Bitte einen Titel eingeben (höchstens ${AUFTRAG_LIMITS.maxTitel} Zeichen).`, kundenart: 'Bitte die Kundenart wählen.', auftragVom: 'Bitte das Datum der Absprache angeben.', auftragWeg: 'Bitte den Weg der Absprache wählen.', art: 'Bitte die Art des Auftrags wählen.', anzahl: `Eine Auftragsbestätigung braucht 1 bis ${AUFTRAG_LIMITS.maxPositionen} Positionen.`, menge: `Die Menge muss eine ganze Zahl von 1 bis ${AUFTRAG_LIMITS.maxMenge} sein.`, preis: 'Der Einzelpreis muss ein Betrag zwischen 0,01 € und 100.000,00 € sein (z. B. 120,00).', summe: `Die Summe ist zu hoch (höchstens ${euro(AUFTRAG_LIMITS.maxSummeCents)}).`, stundensatz: 'Der Stundensatz muss ein Betrag zwischen 0,01 € und 100.000,00 € sein (z. B. 100,00).', schaetzung: `Die Schätzung geht in Schritten von 15 Minuten (z. B. 10 oder 12,5 Stunden, höchstens ${AUFTRAG_LIMITS.maxSchaetzungMinuten / 60}).`, zahlungsart: 'Dieser Zahlungsplan passt nicht zur Art des Auftrags.', prozent: 'Die Anzahlung muss eine ganze Zahl von 1 bis 99 Prozent sein.', zahlungstext: `Bitte den Zahlungsplan als Text eingeben (höchstens ${AUFTRAG_LIMITS.maxZahlungsplanText} Zeichen).`, zahlungsziel: `Das Zahlungsziel muss eine ganze Zahl von 0 bis ${AUFTRAG_LIMITS.maxZahlungszielTage} Tagen sein.`, datum: 'Bitte ein gültiges Datum wählen.', fertigVorStart: 'Die Fertigstellung liegt vor dem Start.', zeitquelle: `Höchstens ${AUFTRAG_LIMITS.maxZeitquellen} Tickets als Zeitquelle.`, } function positionenFehler(positionen) { const liste = objekte(positionen) if (liste.length < 1 || liste.length > AUFTRAG_LIMITS.maxPositionen) return MELDUNG.anzahl for (let i = 0; i < liste.length; i += 1) { const p = liste[i] const vor = `Position ${i + 1}: ` const beschreibung = trimmed(p.description) if (!beschreibung) return `${vor}Bitte eine Beschreibung eingeben.` if (beschreibung.length > AUFTRAG_LIMITS.maxBeschreibung) return `${vor}Die Beschreibung darf höchstens ${AUFTRAG_LIMITS.maxBeschreibung} Zeichen lang sein.` if (parseQuantity(p.quantity) === null) return `${vor}${MELDUNG.menge}` if (parseUnitPrice(p.price) === null) return `${vor}${MELDUNG.preis}` } if (invoiceTotalCents(liste) > AUFTRAG_LIMITS.maxSummeCents) return MELDUNG.summe return '' } /** Prueft einen Schritt (1 bis 3). Ergebnis { ok, fehler: { feld: Meldung }, erstes } (erstes = erstes fehlerhaftes Feld). */ export function pruefeSchritt(schritt, form, { kunde } = {}) { const f = istObjekt(form) ? form : {} const fehler = {} if (schritt === 1) { const titel = trimmed(f.titel) if (titel.length < 1 || titel.length > AUFTRAG_LIMITS.maxTitel) fehler.titel = MELDUNG.titel if (!trimmed(kunde?.kundenart) && !KUNDENARTEN.includes(f.kundenart)) fehler.kundenart = MELDUNG.kundenart } else if (schritt === 2) { if (!istTag(f.auftragVom)) fehler.auftragVom = MELDUNG.auftragVom if (!AUFTRAG_WEGE.some((w) => w.value === f.auftragWeg)) fehler.auftragWeg = MELDUNG.auftragWeg if (!ARTEN.includes(f.art)) { fehler.art = MELDUNG.art } else if (f.art === 'festpreis') { const meldung = positionenFehler(f.positionen) if (meldung) fehler.positionen = meldung } else { if (parseUnitPrice(f.stundensatz) === null) fehler.stundensatz = MELDUNG.stundensatz if (trimmed(f.schaetzung) !== '' && parseStunden(f.schaetzung) === null) fehler.schaetzung = MELDUNG.schaetzung } } else if (schritt === 3) { if (!zahlungsplanOptionen(f.art).some((o) => o.value === f.zahlungsart)) fehler.zahlungsart = MELDUNG.zahlungsart if (f.zahlungsart === 'anzahlung' && parseProzent(f.prozent) === null) fehler.prozent = MELDUNG.prozent if (f.zahlungsart === 'frei') { const t = trimmed(f.zahlungstext) if (t.length < 1 || t.length > AUFTRAG_LIMITS.maxZahlungsplanText) fehler.zahlungstext = MELDUNG.zahlungstext } if (parseTage(f.zahlungsziel) === null) fehler.zahlungsziel = MELDUNG.zahlungsziel const start = trimmed(f.startAm) const fertig = trimmed(f.fertigBis) if (start !== '' && !istTag(start)) fehler.startAm = MELDUNG.datum if (fertig !== '' && !istTag(fertig)) fehler.fertigBis = MELDUNG.datum if (start !== '' && fertig !== '' && istTag(start) && istTag(fertig) && fertig < start) fehler.fertigBis = MELDUNG.fertigVorStart if (Array.isArray(f.zeitquelle) && f.zeitquelle.length > AUFTRAG_LIMITS.maxZeitquellen) fehler.zeitquelle = MELDUNG.zeitquelle } const erstes = Object.keys(fehler)[0] || '' return { ok: erstes === '', fehler, erstes } } /** * Formular -> Anfrage fuer POST/PATCH …/auftraege. Prueft die Schritte 1 bis 3 der Reihe nach. * { ok: true, payload, summeCents } oder { ok: false, schritt, erstes, fehler }. `kundenId` setzt der Aufrufer. */ export function formularZuNutzlast(form, { kunde } = {}) { for (const schritt of [1, 2, 3]) { const r = pruefeSchritt(schritt, form, { kunde }) if (!r.ok) return { ok: false, schritt, erstes: r.erstes, fehler: r.fehler } } const festpreis = form.art === 'festpreis' const plan = form.zahlungsart const schaetzung = trimmed(form.schaetzung) const payload = { projektId: text(form.projektId), titel: trimmed(form.titel), art: form.art, positionen: festpreis ? form.positionen.map((p) => ({ beschreibung: trimmed(p.description), menge: parseQuantity(p.quantity), einzelpreisCents: parseUnitPrice(p.price) })) : [], stundensatzCents: festpreis ? null : parseUnitPrice(form.stundensatz), schaetzungMinuten: festpreis || schaetzung === '' ? null : parseStunden(schaetzung), zahlungsplan: { art: plan, prozent: plan === 'anzahlung' ? parseProzent(form.prozent) : null, text: plan === 'frei' ? trimmed(form.zahlungstext) : '', }, zahlungszielTage: parseTage(form.zahlungsziel), auftragVom: trimmed(form.auftragVom), auftragWeg: form.auftragWeg, startAm: trimmed(form.startAm), fertigBis: trimmed(form.fertigBis), zeitquelle: [...form.zeitquelle], } if (!trimmed(kunde?.kundenart)) payload.kundenart = form.kundenart return { ok: true, payload, summeCents: festpreis ? invoiceTotalCents(form.positionen) : null } } // --- Texte ------------------------------------------------------------------------------------------------------------- /** Sinngemaesser Zahlungsplan wie in der AB; massgeblich bleibt der Wortlaut im PDF. Unbekannt oder unvollstaendig: ''. */ export function zahlungsplanVorschau({ art, prozent, text: freitext, summeCents, zielTage } = {}) { const tage = Number.isInteger(zielTage) ? zielTage : AUFTRAG_LIMITS.standardZahlungszielTage const ziel = tage === 0 ? 'sofort nach Rechnung' : tage === 1 ? 'innerhalb von 1 Tag nach Rechnung' : `innerhalb von ${tage} Tagen nach Rechnung` const summe = Number.isInteger(summeCents) ? summeCents : null if (art === 'nach_abnahme') { return `${summe === null ? 'Alles' : `Gesamtbetrag (${euro(summe)})`} nach Abnahme; zahlbar ${ziel}.` } if (art === 'anzahlung') { if (!Number.isInteger(prozent)) return '' if (summe === null) return `Anzahlung ${prozent} % nach Auftragsbestätigung, Rest nach Abnahme; zahlbar jeweils ${ziel}.` const anzahlung = Math.round((summe * prozent) / 100) return `Anzahlung ${prozent} % (${euro(anzahlung)}) nach Auftragsbestätigung, Rest (${euro(summe - anzahlung)}) nach Abnahme; zahlbar jeweils ${ziel}.` } if (art === 'monatlich') return `Monatlich nach Aufwand; zahlbar ${ziel}.` if (art === 'frei') { const t = trimmed(freitext) return t ? `${t} (zahlbar ${ziel}).` : '' } return '' } /** * Darf gesendet werden? { erlaubt, grund, zurUebersicht }. Der Entwurf laesst sich immer speichern; `zurUebersicht` * heisst: der Grund liegt in den Kundendaten (Reiter Uebersicht). */ export function sendeGate(kunde, kundenart = '') { if (!kunde) return { erlaubt: false, grund: 'Kein Kunde ausgewählt', zurUebersicht: false } const check = canInvoice(kunde) if (!check.ok) return { erlaubt: false, grund: check.reason, zurUebersicht: true } if (!addressComplete(kunde)) return { erlaubt: false, grund: ADDRESS_INCOMPLETE_TEXT, zurUebersicht: true } if (!trimmed(kunde.kundenart) && !trimmed(kundenart)) { return { erlaubt: false, grund: 'Die Kundenart fehlt. Bitte im ersten Schritt wählen.', zurUebersicht: false } } return { erlaubt: true, grund: '', zurUebersicht: false } } // --- Zeitquelle -------------------------------------------------------------------------------------------------------- /** * Tickets der Zeitquelle: erst die Tickets des Projekts, dann die uebrigen gewaehlten, dann alle weiteren. Eine gewaehlte * Kennung, die nicht (mehr) in `tickets` steht, bleibt sichtbar („Ticket “). */ export function zeitquelleOptionen(tickets, auswahl, projektTicketIds) { const liste = objekte(tickets) const gewaehlt = Array.isArray(auswahl) ? auswahl : [] const projekt = Array.isArray(projektTicketIds) ? projektTicketIds : [] const nachId = new Map(liste.map((t) => [t.$id, t])) const reihenfolge = [] const gesehen = new Set() for (const id of [...projekt, ...gewaehlt, ...liste.map((t) => t.$id)]) { if (typeof id !== 'string' || !id || gesehen.has(id)) continue gesehen.add(id) reihenfolge.push(id) } return reihenfolge.map((id) => { const ticket = nachId.get(id) const titel = ticket ? trimmed(ticket.topic) || trimmed(ticket.title) : '' const label = ticket ? `#${ticketNumber(ticket)}${titel ? ` ${titel}` : ''}` : `Ticket ${id.slice(-5)}` return { id, label, gewaehlt: gewaehlt.includes(id), projekt: projekt.includes(id) } }) } /** Ticket zur Auswahl hinzufuegen (hoechstens 10, keine Doppelten) oder entfernen; liefert eine neue Liste. */ export function schalteZeitquelle(auswahl, id, an) { const liste = Array.isArray(auswahl) ? [...auswahl] : [] if (!an) return liste.filter((x) => x !== id) if (liste.includes(id) || liste.length >= AUFTRAG_LIMITS.maxZeitquellen) return liste return [...liste, id] } // --- Hinweise (nie blockierend) ---------------------------------------------------------------------------------------- /** Wirksame Kundenart: die am Kunden gespeicherte hat Vorrang (der Server schreibt sie nur, wenn sie dort leer ist). */ export function kundenartWirksam(kunde, kundenart) { if (KUNDENARTEN.includes(kunde?.kundenart)) return kunde.kundenart if (KUNDENARTEN.includes(kundenart)) return kundenart return '' } /** § 312f Abs. 1 BGB: bei ausserhalb von Geschaeftsraeumen geschlossenen Vertraegen mit Verbrauchern eine Papierfassung geben. */ export const VOR_ORT_HINWEIS = 'Bei Aufträgen vor Ort mit Privatpersonen die Auftragsbestätigung zusätzlich ausdrucken und übergeben.' export function vorOrtHinweis(weg, kundenart) { return weg === 'vor_ort' && kundenart === 'privat' ? VOR_ORT_HINWEIS : '' } /** Hinweise fuer Schritt 4 (Steuernummer fehlt; „Vor Ort“ bei Privatperson). Reine Texte, sie sperren nichts. */ export function auftragHinweise({ kunde, form, einstellungen } = {}) { const liste = [] if (steuernummerFehlt(einstellungen)) liste.push({ key: 'steuernummer', text: STEUERNUMMER_FEHLT_HINWEIS }) const vorOrt = vorOrtHinweis(form?.auftragWeg, kundenartWirksam(kunde, form?.kundenart)) 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 } }, } }