651 lines
31 KiB
JavaScript
651 lines
31 KiB
JavaScript
// 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
|
||
}
|
||
},
|
||
}
|
||
}
|