Files
tickte-system/src/lib/auftragView.js
T

651 lines
31 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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 <Ende der Kennung>“).
*/
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
}
},
}
}