Files
tickte-system/src/lib/funktionenView.js
T
ClaudeandClaude Opus 5.5 f67192db52 Funktionen: K16-Warnung nennt bei den Öffnungszeiten keinen AVV
Die Warnung "K16 ging nicht raus" sprach von Leistungsbeschreibung und AVV. Öffnungszeiten live hat keinen AVV und
schickt keine Anhänge mit, die Warnung behauptet es dort jetzt nicht mehr: speicherStatus(antwort, key) liefert für
zeiten den Text K16_WARNUNG_OHNE_UNTERLAGEN, für alle anderen Funktionen unverändert K16_WARNUNG. Test für beide Wege.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 06:19:55 +00:00

379 lines
21 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.
// Kunden-Reiter "Funktionen": Register der Funktionen je Projekt (News, Terminplaner, Webshop, Öffnungszeiten live), Aktionen je Zustand und
// Einarbeiten der Portal-Antwort. Rein, unter Node testbar.
import { fmtYmd, fmtBerlin } from './format'
import { canSubmitTextform } from './analyseView'
/** Funktionen in Anzeigereihenfolge; key = Schluessel im Portal (features[key], POST /funktionen/:customerId/:key). */
export const FUNKTIONEN = [
{
key: 'news',
label: 'News',
bestaetigen: {
activate: 'News ohne Zahlung aktivieren?',
disable: 'News abschalten? Die Website zeigt dann keine Beiträge mehr.',
},
},
{
key: 'termine',
label: 'Terminplaner',
bestaetigen: {
activate: 'Terminplaner ohne Zahlung aktivieren?',
disable: 'Terminplaner abschalten? Auf der Website sind dann keine Buchungen mehr möglich. Damit beginnt die Löschung: Nach 30 Tagen werden alle Termine und Einstellungen gelöscht. Wieder einschalten ist nach diesen 30 Tagen nur mit einer neuen Bestellung (149 €) möglich.',
abnahme: 'Terminplaner freischalten? Der Kunde bekommt die Mail „bereit“ und Gäste können buchen.',
ticket: 'Fehlendes Einrichtungs-Ticket neu anlegen?',
},
},
{
key: 'shop',
label: 'Webshop',
bestaetigen: {
activate: 'Webshop ohne Zahlung aktivieren?',
disable: 'Webshop abschalten? Auf der Website sind dann keine Bestellungen mehr möglich.',
abnahme: 'Webshop freischalten? Der Kunde bekommt die Mail „bereit“ und der Shop kann geöffnet werden.',
ticket: 'Fehlendes Einrichtungs-Ticket neu anlegen?',
},
},
{
key: 'zeiten',
label: 'Öffnungszeiten live',
bestaetigen: {
activate: 'Öffnungszeiten live ohne Zahlung aktivieren? Es startet der Einbau: Das Einbau-Ticket wird angelegt und die Seite, wenn möglich, neu gebaut. Freigeschaltet wird, sobald das Ticket geschlossen ist und der Kunde seine Zeiten eingetragen hat.',
disable: 'Öffnungszeiten live abschalten? Leisten und Live-Anzeige verschwinden von der Website, die feste Ersatz-Tabelle bleibt stehen. Die Angaben des Kunden bleiben gespeichert. Weicht der Wochenplan von der Ersatz-Tabelle ab, wird ein Ticket „Ersatz-Tabelle aktualisieren“ angelegt.',
abnahme: 'Öffnungszeiten live freischalten? Der Kunde bekommt die Mail „bereit“ und der Einbau gilt als abgenommen.',
ticket: 'Fehlendes Einbau-Ticket neu anlegen?',
},
},
]
/** Zustand einer Funktion -> [Ton des Abzeichens, Beschriftung]. */
export const ZUSTAND = {
off: ['muted', 'Aus'],
ordered: ['warn', 'Bestellt'],
setup: ['info', 'Einrichtung'],
active: ['ok', 'Aktiv'],
disabled: ['muted', 'Abgeschaltet'],
}
/** Abschaltgrund (Portal: features[key].disabledReason) -> Zusatz hinter "Abgeschaltet"; unbekannte Gruende gelten als "ohne Grund". */
const ABGESCHALTET_GRUND = {
abo_ende: ' (Abo beendet)',
abo_ende_vor_einrichtung: ' (Abo endete vor Einrichtung, Erstattung prüfen)',
team: ' (Team)',
kunde_geloescht: ' (Kunde gelöscht)',
}
/**
* Zeile unter dem Abzeichen "Abgeschaltet": Grund und, falls das Portal ein Loeschdatum nennt (feature.loeschungAm, Berliner
* Kalendertag; nur Terminplaner), "Löschung am TT.MM.JJJJ". Ausserhalb von "disabled" ''.
*/
export function abgeschaltetText(feature) {
if (feature?.state !== 'disabled') return ''
const grund = Object.prototype.hasOwnProperty.call(ABGESCHALTET_GRUND, feature.disabledReason) ? ABGESCHALTET_GRUND[feature.disabledReason] : ''
const loeschung = feature.loeschungAm ? `, Löschung am ${fmtYmd(feature.loeschungAm)}` : ''
return `Abgeschaltet${grund}${loeschung}`
}
/** Ansicht einer Funktion im Projekt (Portal: project.features[key]); ohne Eintrag gilt "off". */
export const funktionOf = (project, key) => project?.features?.[key] || { state: 'off' }
/**
* Site-Key (Schluessel fuer den Einbau auf der Kundenwebsite) einer Funktion: nur beim Terminplaner, beim Webshop und bei Oeffnungszeiten live
* und nur, wenn das Portal einen nennt (feature.siteKey); sonst ''.
*/
export function siteKeyOf(feature, key) {
const wert = feature?.siteKey
return (key === 'termine' || key === 'shop' || key === 'zeiten') && typeof wert === 'string' ? wert.trim() : ''
}
/** Beschriftung der Aktionsknoepfe (Aktion = action im POST /funktionen/:customerId/:key). */
export const AKTION_LABEL = {
activate: 'Aktivieren (ohne Zahlung)',
disable: 'Abschalten',
abnahme: 'Freischalten (Abnahme)',
ticket: 'Ticket nachlegen',
}
/**
* Moegliche Aktionen je Zustand. News (und ohne key): Aktivieren (ohne Zahlung) ausser bei "active", Abschalten solange bestellt, in
* Einrichtung oder aktiv. Terminplaner, Webshop und Oeffnungszeiten live (alle mit Ticket-Freischaltung): "ordered" nur Abschalten (Aktivieren
* ohne Zahlung wuerde eine spaetere Zahlung im Portal verschlucken), "setup" Freischalten nach der Abnahme (mit ticketMissing zuerst
* "Ticket nachlegen"), "active" nur Abschalten; sonst ("off", "disabled", ohne Zustand) Aktivieren. Fuer "disabled" gibt es kein
* Abschalten, auch wenn das Ersatz-Ticket beim Abschalten fehlschlug (das Portal merkt sich das nicht; die Antwort nennt es nur einmal).
* hindernis (Portal: feature.hindernis): beim Terminplaner bleibt die Abnahme sichtbar und das Portal sperrt sie mit Begruendung
* (hindernisText); bei Oeffnungszeiten live entfaellt sie, solange der Kunde noch keine Zeiten eingetragen hat ('zeiten_fehlen').
*/
export function aktionen(state, key, { ticketMissing = false, hindernis = null } = {}) {
if (key === 'termine' || key === 'shop' || key === 'zeiten') {
if (state === 'ordered' || state === 'active') return ['disable']
if (state === 'setup') {
const abnahme = key === 'zeiten' && hindernis ? [] : ['abnahme']
return [...(ticketMissing ? ['ticket'] : []), ...abnahme, 'disable']
}
return ['activate']
}
const list = []
if (state !== 'active') list.push('activate')
if (['ordered', 'setup', 'active'].includes(state)) list.push('disable')
return list
}
/** Warum der Terminplaner in "setup" noch nicht freigeschaltet werden kann (Portal: feature.hindernis) -> Hinweistext; sonst ''. */
const HINDERNIS_TEXT = {
kein_abo: 'Kein laufendes Wartungs-Abo: Freischalten ist gesperrt.',
keine_einstellung: 'Noch keine Einstellungen gespeichert.',
abo_unbekannt: 'Abo gerade nicht prüfbar.',
}
export const hindernisText = (code) => (Object.prototype.hasOwnProperty.call(HINDERNIS_TEXT, code) ? HINDERNIS_TEXT[code] : '')
/** Feste Beschriftungen der Teilkopien (Abschnitt-id -> Knopftext); jeder andere kopierbare Abschnitt heisst "<Titel> kopieren". */
const KOPIE_LABEL = {
snippet: 'Einbau-Code kopieren',
css: 'CSS-Variablen kopieren',
datenschutz_sie: 'Datenschutz-Absatz (Sie) kopieren',
datenschutz_du: 'Datenschutz-Absatz (Du) kopieren',
}
const istText = (wert) => typeof wert === 'string' && wert !== ''
/** Kopierknoepfe des Anleitungs-Panels: zuerst "alles" (antwort.text), dann jeder kopierbare Abschnitt mit Text. Ohne Antwort oder ohne Text []. */
export function anleitungKopien(antwort) {
if (!istText(antwort?.text)) return []
const kopien = [{ id: 'alles', label: 'Alles kopieren', text: antwort.text }]
for (const a of Array.isArray(antwort.abschnitte) ? antwort.abschnitte : []) {
if (!a?.kopierbar || !istText(a.text)) continue
const label = Object.prototype.hasOwnProperty.call(KOPIE_LABEL, a.id) ? KOPIE_LABEL[a.id] : `${a.titel || a.id} kopieren`
kopien.push({ id: a.id, label, text: a.text })
}
return kopien
}
/** Pfad der Team-Route mit der Einbauanleitung des Terminplaners (relativ zu /api/team). */
export const anleitungPfad = (customerId, projectId) =>
`/funktionen/${encodeURIComponent(customerId)}/termine/anleitung?projectId=${encodeURIComponent(projectId)}`
/** "Stand Version N, TT.MM.JJJJ" (Berliner Kalendertag) bzw. "Stand Version N" ohne brauchbares Datum; ohne Stand ''. */
function standKopf(stand) {
if (!Number.isInteger(stand?.version)) return ''
const datum = stand.updatedAt && !Number.isNaN(Date.parse(stand.updatedAt)) ? fmtBerlin(stand.updatedAt) : ''
return `Stand Version ${stand.version}${datum ? `, ${datum}` : ''}`
}
/**
* Alles, was das Anleitungs-Panel anzeigt, aus der Portal-Antwort (GET .../termine/anleitung) und der Funktionsansicht (feature.hindernis):
* { kopf, ticketPfad (/tickets?woid=… | null), warnungen, hinweis (Hindernis-Text), kopien, abschnitte [{ id, titel, text }] }. Ohne Antwort alles leer.
*/
export function panelModell(antwort, feature) {
if (!antwort) return { kopf: '', ticketPfad: null, warnungen: [], hinweis: '', kopien: [], abschnitte: [] }
return {
kopf: standKopf(antwort.stand),
ticketPfad: antwort.ticketWoid ? `/tickets?woid=${encodeURIComponent(antwort.ticketWoid)}` : null,
warnungen: Array.isArray(antwort.warnungen) ? antwort.warnungen : [],
hinweis: hindernisText(feature?.hindernis),
kopien: anleitungKopien(antwort),
abschnitte: (Array.isArray(antwort.abschnitte) ? antwort.abschnitte : []).map(({ id, titel, text }) => ({ id, titel, text })),
}
}
/** true, wenn die Adresse (location.search) ?anleitung=<projectId> traegt (kodiert oder nicht): das Panel dieses Projekts startet offen. */
export function anleitungOffen(search, projectId) {
if (typeof search !== 'string' || typeof projectId !== 'string' || projectId === '') return false
return new URLSearchParams(search).get('anleitung') === projectId
}
/**
* Antwort des Portals (das eine geaenderte Projekt) in die Projektliste einarbeiten: uebernommen wird nur
* aktualisiert.features[key]; Projektfelder und die anderen Funktionen bleiben. Gibt eine neue Liste zurueck.
*/
export function mergeFunktion(projects, projectId, key, aktualisiert) {
const view = aktualisiert?.features?.[key]
if (!view) return projects
return projects.map((p) => (p.projectId === projectId ? { ...p, features: { ...p.features, [key]: view } } : p))
}
// --- Aktivieren (ohne Zahlung) mit Zustimmung in Textform (Modus on des Terminplaners und des Webshops; Portal: GET /funktionen/:customerId => termine, shop) ---
/** Funktionen, deren Aktivierung durch das Team die Zustimmung des Kunden in Textform brauchen kann (Portal: TEXTFORM_KEYS). */
export const TEXTFORM_FUNKTIONEN = ['termine', 'shop']
/**
* true, wenn diese Aktion die Zustimmung in Textform braucht: nur "Aktivieren (ohne Zahlung)" des Terminplaners oder des Webshops und nur, wenn das
* Portal es verlangt (feld = Portal-Feld dieser Funktion, termine bzw. shop, feld.textformNoetig === true).
*/
export const textformNoetig = (key, action, feld) => TEXTFORM_FUNKTIONEN.includes(key) && action === 'activate' && feld?.textformNoetig === true
/** Kaestchentext (wie im Portal-Nachweis, ohne Datum und Weg); die beiden Links setzt die Komponente. */
export const termineTextformLabel = (version) => ({
before: 'Der Kunde hat der ',
lb: 'Leistungsbeschreibung Terminplaner',
between: ' und dem ',
avv: 'Auftragsverarbeitungsvertrag Terminplaner',
after: ` (Fassung ${version}) in Textform zugestimmt und bestätigt, dass sein Angebot nicht im Gesundheitsbereich liegt.`,
})
/** Kaestchentext des Webshops; die Erklaerungen entsprechen den Bestelltexten "unterlagen", "verantwortung" und "stripe", die das Portal im Nachweis speichert. */
export const shopTextformLabel = (version) => ({
before: 'Der Kunde hat der ',
lb: 'Leistungsbeschreibung Webshop',
between: ' und dem ',
avv: 'Auftragsverarbeitungsvertrag Webshop',
after: ` (Fassung ${version}) in Textform zugestimmt und bestätigt, dass er als Unternehmer in eigenem Namen verkauft, für seine Produkte, Preise, Produktangaben und Rechtstexte selbst verantwortlich ist, keine der in der Leistungsbeschreibung ausgeschlossenen Waren verkauft und die Zahlungen seiner Käufer über sein eigenes Stripe-Konto laufen.`,
})
/** Textvorlage fuer den Chat (Kopierknopf); links = die versionierten Adressen aus dem Portal (termine.links). */
export const termineChatTemplate = (version, links) =>
`Damit wir den Terminplaner für Dich einschalten können, brauchen wir Deine Zustimmung in Textform. Es gelten die Leistungsbeschreibung Terminplaner und der Auftragsverarbeitungsvertrag Terminplaner in der Fassung ${version}: ${links.lb} · ${links.avv}. Bitte antworte hier mit „Ich stimme zu“ und bestätige, dass Dein Angebot nicht im Gesundheitsbereich liegt.`
/** Textvorlage fuer den Chat beim Webshop; links = shop.links. Die Bestaetigung deckt dieselben Erklaerungen wie shopTextformLabel. */
export const shopChatTemplate = (version, links) =>
`Damit wir den Webshop für Dich einschalten können, brauchen wir Deine Zustimmung in Textform. Es gelten die Leistungsbeschreibung Webshop und der Auftragsverarbeitungsvertrag Webshop in der Fassung ${version}: ${links.lb} · ${links.avv}. Bitte antworte hier mit „Ich stimme zu“ und bestätige, dass Du als Unternehmer in eigenem Namen verkaufst, für Deine Produkte, Preise, Produktangaben und Rechtstexte selbst verantwortlich bist, keine der in der Leistungsbeschreibung ausgeschlossenen Waren verkaufst und Deine Zahlungen über Dein eigenes Stripe-Konto laufen.`
/** Nur geprüfte https-Adressen werden Links; alles andere ''. */
const httpsUrl = (wert) => {
if (typeof wert !== 'string') return ''
try {
return new URL(wert).protocol === 'https:' ? wert : ''
} catch {
return ''
}
}
/**
* Alles, was der Bestaetigungsdialog zeigt, aus dem Portal-Feld der Funktion (feld = termine bzw. shop, key = 'termine' | 'shop'):
* { version, links { lb, avv } (nur https, sonst ''), label, vorlage }.
* vorlage nur, wenn Fassung und beide Adressen da sind (eine Vorlage mit leeren Adressen geht nie an den Kunden), sonst ''.
*/
export function textformAnsicht(feld, key = 'termine') {
const shop = key === 'shop'
const version = typeof feld?.avvVersion === 'string' ? feld.avvVersion : ''
const links = { lb: httpsUrl(feld?.links?.lb), avv: httpsUrl(feld?.links?.avv) }
return {
version,
links,
label: (shop ? shopTextformLabel : termineTextformLabel)(version),
vorlage: version && links.lb && links.avv ? (shop ? shopChatTemplate : termineChatTemplate)(version, links) : '',
}
}
/** Alle drei Pflichtangaben (Haken, Datum zwischen Fassung und heute, Weg); ohne Fassung im Portal-Feld nie bereit. */
export function textformBereit({ confirmed, date, channel, today }, feld) {
const minDate = typeof feld?.avvVersion === 'string' ? feld.avvVersion : ''
if (!minDate) return false
return canSubmitTextform({ confirmed, date, channel, today, minDate })
}
/** Rumpf fuer POST /funktionen/:customerId/:key; Datum und Weg der Textform nur beim Aktivieren und nur, wenn beide da sind. */
export function featureBody({ projectId, action, note, textformDate, textformChannel }) {
const body = { projectId, action, note }
if (action === 'activate' && textformDate && textformChannel) {
body.textformDate = textformDate
body.textformChannel = textformChannel
}
return body
}
/** K16 (Unterlagen an den Kunden) ging beim Aktivieren nicht raus: Antwort mit warnung: true (Portal, funktionenAdmin). */
export const K16_WARNUNG = 'Eingeschaltet, aber die E-Mail mit den Unterlagen (K16) ging nicht raus. Bitte die Leistungsbeschreibung und den AVV von Hand an den Kunden senden.'
/** Dieselbe Warnung fuer Funktionen ohne AVV und ohne Anhaenge (Oeffnungszeiten live): sie behauptet weder Unterlagen noch einen AVV. */
export const K16_WARNUNG_OHNE_UNTERLAGEN = 'Eingeschaltet, aber die E-Mail „bereit“ (K16) ging nicht raus. Bitte den Kunden von Hand informieren.'
/** Funktionen, deren K16 keine Unterlagen und keinen AVV mitschickt (Portal: features[key].avv null, keine Anhaenge). */
const K16_OHNE_UNTERLAGEN = ['zeiten']
/**
* Statuszeile nach dem Speichern einer Aktion: Warnung statt "Gespeichert.", wenn die Antwort warnung: true traegt. Der Wortlaut der
* Warnung folgt der Funktion (key): Terminplaner und Webshop nennen Leistungsbeschreibung und AVV, Oeffnungszeiten live keinen AVV.
*/
export const speicherStatus = (antwort, key) =>
antwort?.warnung === true
? { ok: false, text: K16_OHNE_UNTERLAGEN.includes(key) ? K16_WARNUNG_OHNE_UNTERLAGEN : K16_WARNUNG }
: { ok: true, text: 'Gespeichert.' }
// --- Webshop: Anfrage des Kunden und Stand des Shops (Portal: GET /shop/:customerId) ---
const WARENART_LABEL = { waren: 'Waren', lebensmittel: 'Lebensmittel', kleidung: 'Kleidung', kunst: 'Kunst/Handgemachtes', sonstiges: 'Sonstiges' }
const ARTIKELZAHL_LABEL = { bis20: 'bis 20', bis100: 'bis 100', mehr: 'mehr als 100' }
const has = (map, key) => Object.prototype.hasOwnProperty.call(map, key)
/** Warenart-Code -> Beschriftung; unbekannt oder leer '–'. */
export const warenartLabel = (code) => (has(WARENART_LABEL, code) ? WARENART_LABEL[code] : code ? String(code) : '–')
/** Artikelzahl-Code -> Beschriftung; unbekannt oder leer '–'. */
export const artikelzahlLabel = (code) => (has(ARTIKELZAHL_LABEL, code) ? ARTIKELZAHL_LABEL[code] : code ? String(code) : '–')
/** Status der Anfrage -> [Ton des Abzeichens, Beschriftung]. */
export const ANFRAGE_STATUS = {
offen: ['warn', 'Offen'],
angenommen: ['ok', 'Angenommen'],
abgelehnt: ['danger', 'Abgelehnt'],
zurueckgezogen: ['muted', 'Zurückgezogen'],
}
export const anfrageStatus = (status) => (has(ANFRAGE_STATUS, status) ? ANFRAGE_STATUS[status] : ['muted', status ? String(status) : '–'])
const jaNein = (wert) => (wert === true ? 'Ja' : wert === false ? 'Nein' : '–')
const textOder = (wert) => (typeof wert === 'string' && wert.trim() ? wert.trim() : '–')
/** Zeilen der Anfrage fuer die Anzeige: [{ label, text }]. Ohne Anfrage []. */
export function anfrageZeilen(a) {
if (!a || typeof a !== 'object') return []
const wege = [a.versand === true ? 'Versand' : '', a.abholung === true ? 'Abholung' : ''].filter(Boolean)
return [
{ label: 'Warenart', text: warenartLabel(a.warenart) },
{ label: 'Beschreibung', text: textOder(a.beschreibung) },
{ label: 'Artikelzahl', text: artikelzahlLabel(a.artikelzahl) },
{ label: 'Übergabe', text: wege.length ? wege.join(' und ') : '–' },
{ label: 'Unternehmer', text: jaNein(a.unternehmer) },
{ label: 'Keine ausgeschlossenen Waren', text: jaNein(a.keineAusgeschlossenen) },
{ label: 'Nachricht', text: textOder(a.nachricht) },
]
}
/** Kurzfassung der Anfrage in einer Zeile ("Waren, bis 20 Artikel, Versand und Abholung"); ohne Anfrage ''. */
export function anfrageText(a) {
if (!a || typeof a !== 'object') return ''
const wege = [a.versand === true ? 'Versand' : '', a.abholung === true ? 'Abholung' : ''].filter(Boolean).join(' und ')
const zahl = a.artikelzahl ? `${artikelzahlLabel(a.artikelzahl)} Artikel` : ''
return [warenartLabel(a.warenart), zahl, wege].filter((t) => t && t !== '–').join(', ')
}
/**
* Zahlungskonto des Kunden bei Stripe in Worten. status = stripe.status aus dem Portal (null, solange nichts geprueft wurde),
* hatKonto = stripe.accountId. "nicht verbunden" | "Zahlungen aktiv" | "Angaben fehlen: …".
*/
export function stripeText(status, hatKonto = false) {
if (!status || typeof status !== 'object') return hatKonto ? 'Konto angelegt, Stand noch nicht geprüft' : 'nicht verbunden'
if (status.charges_enabled === true && status.payouts_enabled === true) return 'Zahlungen aktiv'
if (status.charges_enabled === true) return 'Zahlungen aktiv, Auszahlungen noch nicht freigegeben'
const offen = [...new Set([...(Array.isArray(status.past_due) ? status.past_due : []), ...(Array.isArray(status.currently_due) ? status.currently_due : [])])]
if (offen.length) {
const gezeigt = offen.slice(0, 4).join(', ')
return `Angaben fehlen: ${gezeigt}${offen.length > 4 ? ` und ${offen.length - 4} weitere` : ''}`
}
if (status.disabled_reason) return `Angaben fehlen: ${status.disabled_reason}`
return status.details_submitted === true ? 'Angaben fehlen: Prüfung durch Stripe läuft' : 'Angaben fehlen: Einrichtung nicht abgeschlossen'
}
/** Ton fuer das Stripe-Abzeichen: ok (aktiv) | muted (nicht verbunden) | warn (Angaben fehlen). */
export const stripeTon = (status, hatKonto = false) => {
if (status?.charges_enabled === true) return 'ok'
return status || hatKonto ? 'warn' : 'muted'
}
/** Gibt es zu diesem Projekt etwas anzuzeigen (Anfrage vorhanden oder Webshop nicht "off")? */
export const shopHatEintrag = (shop) => !!shop && (shop.anfrage != null || (shop.state && shop.state !== 'off'))
/** Begruendung der Ablehnung: Pflicht, hoechstens 1000 Zeichen (ohne Rand). */
export const BEGRUENDUNG_MAX = 1000
export const begruendungOk = (text) => typeof text === 'string' && text.trim().length > 0 && text.trim().length <= BEGRUENDUNG_MAX
/** Rumpf fuer POST /shop/:customerId/anfrage. Begruendung nur beim Ablehnen. */
export function entscheidungBody({ projectId, entscheidung, begruendung }) {
const body = { projectId, entscheidung }
if (entscheidung === 'ablehnen') body.begruendung = String(begruendung ?? '').trim()
return body
}
/** Statuszeile nach der Entscheidung (Antwort { ok, mailFehler? }). */
export const entscheidungStatus = (antwort) =>
antwort?.mailFehler ? { ok: false, text: 'Entscheidung gespeichert, Mail an den Kunden fehlgeschlagen.' } : { ok: true, text: 'Gespeichert.' }