Compare commits

..
2 Commits
Author SHA1 Message Date
ClaudeandClaude Opus 5.5 00fa229235 Shop Review B I-8: Webshop ohne Zahlung nur mit Zustimmung in Textform aktivieren
Das Textform-Formular (Haken, Datum, Weg, Chat-Vorlage) gilt wie beim Terminplaner auch
fuer den Webshop, wenn das Portal im Feld shop textformNoetig meldet. Kaestchentext und
Chat-Vorlage nennen die Unterlagen des Webshops, die Fassung und die Erklaerungen, die das
Portal im Nachweis speichert.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 09:45:31 +00:00
ClaudeandClaude Opus 5.5 0a2eab85d5 Webshop: Funktionen-Karte mit Anfrage annehmen/ablehnen, Stripe-Stand, Kennzahlen und Rechtliches-Karten
Webshop als Funktion im Reiter Funktionen (Aktionen wie beim Terminplaner), Panel je Projekt mit Anfrage,
Entscheidung mit Begruendung, Stripe-Status und Zahlen; Karten Leistungsbeschreibung und AVV Webshop.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 08:47:19 +00:00
6 changed files with 458 additions and 35 deletions

No files matched your search

+3
View File
@@ -99,6 +99,9 @@ export const DOCUMENTS = [
{ key: "leistungsbeschreibung-terminplaner", title: "Leistungsbeschreibung Terminplaner", liveUrl: "https://project.webklar.com/rechtliches/leistungsbeschreibung-terminplaner.html" },
{ key: "avv-terminplaner", title: "AVV Terminplaner (Auftragsverarbeitung)", liveUrl: "https://project.webklar.com/rechtliches/avv-terminplaner.html" },
{ key: "widerrufsbelehrung-funktionen", title: "Widerrufsbelehrung und Muster-Widerrufsformular (Funktionen)", liveUrl: "https://project.webklar.com/rechtliches/widerrufsbelehrung-funktionen.html" },
// Webshop: gleiches Muster wie beim Terminplaner (Alias-Seite ohne Fassungsdatum)
{ key: "leistungsbeschreibung-webshop", title: "Leistungsbeschreibung Webshop", liveUrl: "https://project.webklar.com/rechtliches/leistungsbeschreibung-webshop.html" },
{ key: "avv-webshop", title: "AVV Webshop", liveUrl: "https://project.webklar.com/rechtliches/avv-webshop.html" },
].map((d, i) => ({ ...d, sort: i + 1 }));
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
+102 -14
View File
@@ -3,16 +3,17 @@ import { Link } from 'react-router-dom'
import { FaCheck, FaCopy, FaRotateRight, FaSpinner } from 'react-icons/fa6'
import { Card, Badge, Button, EmptyState } from '../ui'
import { copyText } from '../CopyableCredential'
import { getFunktionen, setFeature, getTermineAnleitung } from '../../lib/teamApi'
import { getFunktionen, setFeature, getTermineAnleitung, getShop, entscheideShopAnfrage } from '../../lib/teamApi'
import {
FUNKTIONEN, ZUSTAND, AKTION_LABEL, funktionOf, aktionen, mergeFunktion, siteKeyOf, abgeschaltetText,
hindernisText, panelModell, anleitungOffen, textformNoetig, textformAnsicht, textformBereit, speicherStatus,
anfrageZeilen, anfrageStatus, stripeText, stripeTon, shopHatEintrag, begruendungOk, BEGRUENDUNG_MAX, entscheidungStatus,
} from '../../lib/funktionenView'
import { CHANNELS, channelLabel } from '../../lib/analyseView'
import { berlinYmd } from '../../lib/format'
import { berlinYmd, fmtDateTime } from '../../lib/format'
import '../../styles/admin-areas.css'
// Reiter "Funktionen": News-Pinnwand und Terminplaner je Projekt an/aus wie im frueheren Admin-Panel (Kundenliste, Zeile "Funktionen")
// Reiter "Funktionen": News-Pinnwand, Terminplaner und Webshop je Projekt an/aus wie im frueheren Admin-Panel (Kundenliste, Zeile "Funktionen")
const short = (text) => (text.length > 60 ? `${text.slice(0, 60)}…` : text)
// Site-Key des Terminplaners (steht im Einbau-Code der Kundenwebsite): kleine Schrift mit fester Breite und Kopierknopf
@@ -113,10 +114,10 @@ function AnleitungPanel({ customerId, projectId, feature, reloadKey }) {
)
}
// Zustimmung des Kunden in Textform (Terminplaner im Modus on): Haken, Datum, Weg und die Textvorlage fuer den Chat. Die Pflichtangaben
// Zustimmung des Kunden in Textform (Terminplaner und Webshop im Modus on): Haken, Datum, Weg und die Textvorlage fuer den Chat. Die Pflichtangaben
// prueft textformBereit (funktionenView); das Portal prueft sie noch einmal. Links nur als geprueftes https (textformAnsicht).
function TextformPanel({ termine, value, onChange, projectId, today, disabled }) {
const ansicht = textformAnsicht(termine)
function TextformPanel({ funktion, feld, value, onChange, projectId, today, disabled }) {
const ansicht = textformAnsicht(feld, funktion)
const { label, links, vorlage } = ansicht
return (
<div className="area-row funktionen-panel">
@@ -139,7 +140,7 @@ function TextformPanel({ termine, value, onChange, projectId, today, disabled })
<span>Weg:</span>
{CHANNELS.map((c) => (
<label key={c} className="area-inline">
<input type="radio" name={`termine-weg-${projectId}`} value={c} checked={value.channel === c} disabled={disabled} onChange={() => onChange({ ...value, channel: c })} />
<input type="radio" name={`textform-weg-${funktion}-${projectId}`} value={c} checked={value.channel === c} disabled={disabled} onChange={() => onChange({ ...value, channel: c })} />
{channelLabel(c)}
</label>
))}
@@ -158,11 +159,80 @@ function TextformPanel({ termine, value, onChange, projectId, today, disabled })
)
}
// Webshop je Projekt: Anfrage des Kunden (annehmen / ablehnen mit Begruendung), Stripe-Stand und Kennzahlen. Die Entscheidung geht ans Portal
// (POST /shop/:customerId/anfrage); danach laden Funktionen und Shop neu (onDecided). Texte sind ausschliesslich React-Textknoten.
function ShopPanel({ customerId, shop, onDecided }) {
const [frage, setFrage] = useState(null) // null | 'annehmen' | 'ablehnen'
const [begruendung, setBegruendung] = useState('')
const [busy, setBusy] = useState(false)
const [status, setStatus] = useState(null) // { ok, text }
const a = shop.anfrage
const [tone, label] = anfrageStatus(a?.status)
const stripe = shop.stripe || { accountId: false, status: null }
const entscheiden = async () => {
setBusy(true)
setStatus(null)
try {
const antwort = await entscheideShopAnfrage(customerId, { projectId: shop.projectId, entscheidung: frage, begruendung })
setFrage(null)
setBegruendung('')
setStatus(entscheidungStatus(antwort))
onDecided().catch(() => {}) // Neuladen scheitert hoechstens leise; die Entscheidung ist gespeichert
} catch (err) {
setFrage(null)
setStatus({ ok: false, text: err.message })
onDecided().catch(() => {})
} finally {
setBusy(false)
}
}
const ablehnenFehlt = frage === 'ablehnen' && !begruendungOk(begruendung)
return (
<div className="area-row funktionen-panel">
<div className="funktionen-panel-head">
<strong>Webshop: {shop.projectName}</strong>
{a && <Badge tone={tone}>Anfrage: {label}</Badge>}
<Badge tone={shop.geoeffnet ? 'ok' : 'muted'}>{shop.geoeffnet ? 'Geöffnet' : 'Geschlossen'}</Badge>
<Badge tone={stripeTon(stripe.status, !!stripe.accountId)}>Stripe: {stripeText(stripe.status, !!stripe.accountId)}</Badge>
</div>
{a && (
<dl className="area-kv">
{anfrageZeilen(a.anfrage).map((z) => (
<Fragment key={z.label}><dt>{z.label}</dt><dd style={{ whiteSpace: 'pre-wrap' }}>{z.text}</dd></Fragment>
))}
<dt>Eingegangen</dt><dd>{a.at ? fmtDateTime(a.at) : '–'}</dd>
{a.entschiedenAt && <><dt>Entschieden</dt><dd>{fmtDateTime(a.entschiedenAt)}</dd></>}
{a.begruendung && <><dt>Begründung</dt><dd style={{ whiteSpace: 'pre-wrap' }}>{a.begruendung}</dd></>}
</dl>
)}
<div className="area-muted">Artikel: {shop.zahlen?.artikel ?? 0} · Offene Bestellungen: {shop.zahlen?.offeneBestellungen ?? 0}</div>
{a?.status === 'offen' && (
frage ? (
<div className="area-inline">
<span>{frage === 'annehmen' ? 'Anfrage annehmen? Der Kunde bekommt eine Mail.' : 'Anfrage ablehnen? Der Kunde bekommt die Begründung per Mail.'}</span>
{frage === 'ablehnen' && (
<textarea className="form-control" required rows={3} maxLength={BEGRUENDUNG_MAX} placeholder="Begründung (Pflicht)" value={begruendung} onChange={(e) => setBegruendung(e.target.value)} style={{ width: '100%' }} />
)}
<Button size="sm" variant="primary" onClick={entscheiden} disabled={busy || ablehnenFehlt}>{busy ? <FaSpinner className="spinner" /> : 'Ja'}</Button>
<Button size="sm" variant="ghost" onClick={() => setFrage(null)} disabled={busy}>Abbrechen</Button>
</div>
) : (
<div className="area-inline">
<Button size="sm" variant="primary" onClick={() => { setBegruendung(''); setFrage('annehmen') }}>Anfrage annehmen</Button>
<Button size="sm" variant="ghost" onClick={() => { setBegruendung(''); setFrage('ablehnen') }}>Ablehnen</Button>
</div>
)
)}
{status && <div className={status.ok ? 'area-message' : 'area-error'}>{status.text}</div>}
</div>
)
}
const LEERE_TEXTFORM = { confirmed: false, date: '', channel: '' }
export default function CustomerFunktionen({ customerId }) {
const [projects, setProjects] = useState(null)
const [termine, setTermine] = useState(null) // Portal-Feld termine: { textformNoetig, avvVersion, links }
const [felder, setFelder] = useState({ termine: null, shop: null }) // Portal-Felder termine und shop: { textformNoetig, avvVersion, links }
const [textform, setTextform] = useState(LEERE_TEXTFORM)
const [error, setError] = useState(null)
const [confirm, setConfirm] = useState(null) // { projectId, key, action }
@@ -170,22 +240,31 @@ export default function CustomerFunktionen({ customerId }) {
const [busy, setBusy] = useState(false)
const [status, setStatus] = useState(null) // { ok, text }
const [panel, setPanel] = useState({}) // projectId -> true/false (Knopf "Anleitung"); ohne Eintrag entscheidet die Adresse (?anleitung=<projectId>)
const [shop, setShop] = useState([]) // GET /shop/:customerId => projects; Fehler nur im Webshop-Bereich, nicht in der Tabelle
const [shopFehler, setShopFehler] = useState(null)
const [panelReload, setPanelReload] = useState({}) // projectId -> Zaehler, nach jeder Aktion hochgezaehlt
const load = useCallback(() => {
setError(null)
getFunktionen(customerId)
.then(({ projects: list, termine: feld }) => { setProjects(list || []); setTermine(feld || null) })
.then(({ projects: list, termine, shop: shopFeld }) => { setProjects(list || []); setFelder({ termine: termine || null, shop: shopFeld || null }) })
.catch((err) => setError(err.message || 'Funktionen konnten nicht geladen werden.'))
}, [customerId])
useEffect(() => { load() }, [load])
const loadShop = useCallback(() => {
setShopFehler(null)
return getShop(customerId)
.then(({ projects: list }) => setShop(Array.isArray(list) ? list : []))
.catch((err) => setShopFehler(err.message || 'Webshop-Daten konnten nicht geladen werden.'))
}, [customerId])
useEffect(() => { load(); loadShop() }, [load, loadShop])
const nachEntscheidung = () => Promise.all([getFunktionen(customerId).then(({ projects: list, termine, shop: shopFeld }) => { setProjects(list || []); setFelder({ termine: termine || null, shop: shopFeld || null }) }), loadShop()])
const save = async () => {
const { projectId, key, action } = confirm
setBusy(true)
setStatus(null)
try {
const mitTextform = textformNoetig(key, action, termine)
const mitTextform = textformNoetig(key, action, felder[key])
const antwort = await setFeature(customerId, key, {
projectId, action, note: note.trim(),
...(mitTextform ? { textformDate: textform.date, textformChannel: textform.channel } : {}),
@@ -203,6 +282,7 @@ export default function CustomerFunktionen({ customerId }) {
} finally {
setBusy(false)
// Version und Hindernis der Anleitung haengen vom Zustand ab: Panel dieser Zeile neu laden
if (key === 'shop') loadShop()
if (key === 'termine') setPanelReload((r) => ({ ...r, [projectId]: (r[projectId] || 0) + 1 }))
}
}
@@ -226,8 +306,8 @@ export default function CustomerFunktionen({ customerId }) {
const isConfirming = confirm?.projectId === p.projectId && confirm?.key === f.key
const hatAnleitung = f.key === 'termine' && feature.state !== 'off'
const anleitungOffenHier = hatAnleitung && panelOffen(p.projectId)
const braucheTextform = isConfirming && textformNoetig(f.key, confirm.action, termine)
const textformFehlt = braucheTextform && !textformBereit({ ...textform, today: heute }, termine)
const braucheTextform = isConfirming && textformNoetig(f.key, confirm.action, felder[f.key])
const textformFehlt = braucheTextform && !textformBereit({ ...textform, today: heute }, felder[f.key])
return (
<Fragment key={`${p.projectId}:${f.key}`}>
<div className="area-row funktionen-row">
@@ -279,7 +359,7 @@ export default function CustomerFunktionen({ customerId }) {
</span>
</div>
{braucheTextform && (
<TextformPanel termine={termine} value={textform} onChange={setTextform} projectId={p.projectId} today={heute} disabled={busy} />
<TextformPanel funktion={f.key} feld={felder[f.key]} value={textform} onChange={setTextform} projectId={p.projectId} today={heute} disabled={busy} />
)}
{anleitungOffenHier && (
<AnleitungPanel customerId={customerId} projectId={p.projectId} feature={feature} reloadKey={panelReload[p.projectId] || 0} />
@@ -289,6 +369,14 @@ export default function CustomerFunktionen({ customerId }) {
}))}
</div>
{status && <div className={status.ok ? 'area-message' : 'area-error'} style={{ margin: 12 }}>{status.text}</div>}
{shopFehler && <div className="area-error" style={{ margin: 12 }}>{shopFehler}</div>}
{shop.filter(shopHatEintrag).length > 0 && (
<div className="area-rows">
{shop.filter(shopHatEintrag).map((s) => (
<ShopPanel key={s.projectId} customerId={customerId} shop={s} onDecided={nachEntscheidung} />
))}
</div>
)}
</Card>
)
}
+134 -16
View File
@@ -1,4 +1,4 @@
// Kunden-Reiter "Funktionen": Register der Funktionen je Projekt (News, Terminplaner), Aktionen je Zustand und
// Kunden-Reiter "Funktionen": Register der Funktionen je Projekt (News, Terminplaner, Webshop), Aktionen je Zustand und
// Einarbeiten der Portal-Antwort. Rein, unter Node testbar.
import { fmtYmd, fmtBerlin } from './format'
@@ -24,6 +24,16 @@ export const FUNKTIONEN = [
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?',
},
},
]
/** Zustand einer Funktion -> [Ton des Abzeichens, Beschriftung]. */
@@ -58,12 +68,12 @@ export function abgeschaltetText(feature) {
export const funktionOf = (project, key) => project?.features?.[key] || { state: 'off' }
/**
* Site-Key (Schluessel fuer den Einbau auf der Kundenwebsite) einer Funktion: nur beim Terminplaner und nur, wenn das Portal
* Site-Key (Schluessel fuer den Einbau auf der Kundenwebsite) einer Funktion: nur beim Terminplaner und beim Webshop und nur, wenn das Portal
* einen nennt (feature.siteKey); sonst ''.
*/
export function siteKeyOf(feature, key) {
const wert = feature?.siteKey
return key === 'termine' && typeof wert === 'string' ? wert.trim() : ''
return (key === 'termine' || key === 'shop') && typeof wert === 'string' ? wert.trim() : ''
}
/** Beschriftung der Aktionsknoepfe (Aktion = action im POST /funktionen/:customerId/:key). */
@@ -76,11 +86,11 @@ export const AKTION_LABEL = {
/**
* Moegliche Aktionen je Zustand. News (und ohne key): Aktivieren (ohne Zahlung) ausser bei "active", Abschalten solange bestellt, in
* Einrichtung oder aktiv. Terminplaner: "ordered" nur Abschalten (Aktivieren ohne Zahlung wuerde eine spaetere Zahlung im Portal
* Einrichtung oder aktiv. Terminplaner und Webshop (ticketbasiert): "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.
*/
export function aktionen(state, key, { ticketMissing = false } = {}) {
if (key === 'termine') {
if (key === 'termine' || key === 'shop') {
if (state === 'ordered' || state === 'active') return ['disable']
if (state === 'setup') return ticketMissing ? ['ticket', 'abnahme', 'disable'] : ['abnahme', 'disable']
return ['activate']
@@ -164,10 +174,16 @@ export function mergeFunktion(projects, projectId, key, aktualisiert) {
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; Portal: GET /funktionen/:customerId => termine) ---
// --- Aktivieren (ohne Zahlung) mit Zustimmung in Textform (Modus on des Terminplaners und des Webshops; Portal: GET /funktionen/:customerId => termine, shop) ---
/** true, wenn diese Aktion die Zustimmung in Textform braucht: nur "Aktivieren (ohne Zahlung)" des Terminplaners und nur, wenn das Portal es verlangt (termine.textformNoetig === true). */
export const textformNoetig = (key, action, termine) => key === 'termine' && action === 'activate' && termine?.textformNoetig === true
/** 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) => ({
@@ -178,10 +194,23 @@ export const termineTextformLabel = (version) => ({
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 ''
@@ -193,23 +222,25 @@ const httpsUrl = (wert) => {
}
/**
* Alles, was der Bestaetigungsdialog zeigt, aus dem Portal-Feld termine: { version, links { lb, avv } (nur https, sonst ''), label, vorlage }.
* 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(termine) {
const version = typeof termine?.avvVersion === 'string' ? termine.avvVersion : ''
const links = { lb: httpsUrl(termine?.links?.lb), avv: httpsUrl(termine?.links?.avv) }
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: termineTextformLabel(version),
vorlage: version && links.lb && links.avv ? termineChatTemplate(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 }, termine) {
const minDate = typeof termine?.avvVersion === 'string' ? termine.avvVersion : ''
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 })
}
@@ -229,3 +260,90 @@ export const K16_WARNUNG = 'Eingeschaltet, aber die E-Mail mit den Unterlagen (K
/** Statuszeile nach dem Speichern einer Aktion: Warnung statt "Gespeichert.", wenn die Antwort warnung: true traegt. */
export const speicherStatus = (antwort) => (antwort?.warnung === true ? { ok: false, text: 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.' }
+11 -3
View File
@@ -1,7 +1,7 @@
import { account } from './appwrite'
import { createFinanceRequest } from './financeRequest'
import { createJwtCache } from './serverpulsAuth'
import { anleitungPfad, featureBody } from './funktionenView'
import { anleitungPfad, featureBody, entscheidungBody } from './funktionenView'
/**
* Client fuer den Team-Eingang des Kundenportals (/api/team): alle Mitarbeiter, Appwrite-JWT (Bearer).
@@ -94,8 +94,8 @@ export const markStorageDone = (id) => request(`/storage-tasks/${enc(id)}/done`,
export const getFunktionen = (customerId) => request(`/funktionen/${enc(customerId)}`)
/**
* Aktivieren (ohne Zahlung) oder Abschalten einer Funktion (key: news | termine); Antwort { projects: [das eine Projekt] }.
* Terminplaner im Modus on: beim Aktivieren zusaetzlich textformDate (JJJJ-MM-TT) und textformChannel (chat | email).
* Aktivieren (ohne Zahlung) oder Abschalten einer Funktion (key: news | termine | shop); Antwort { projects: [das eine Projekt] }.
* Terminplaner und Webshop im Modus on: beim Aktivieren zusaetzlich textformDate (JJJJ-MM-TT) und textformChannel (chat | email).
*/
export const setFeature = (customerId, key, { projectId, action, note, textformDate, textformChannel }) =>
request(`/funktionen/${enc(customerId)}/${enc(key)}`, { method: 'POST', body: featureBody({ projectId, action, note, textformDate, textformChannel }) })
@@ -103,6 +103,14 @@ export const setNewsFeature = (customerId, body) => setFeature(customerId, 'news
/** Einbauanleitung des Terminplaners: { stand, warnungen, abschnitte, text, state, ticketWoid, ticketVersion, hindernis } (Aufbereitung: panelModell). */
export const getTermineAnleitung = (customerId, projectId) => request(anleitungPfad(customerId, projectId))
// --- Webshop (Anfrage des Kunden, Stripe-Stand, Kennzahlen) ---
/** { projects: [{ projectId, projectName, state, anfrage, stripe, geoeffnet, zahlen }] } (Aufbereitung: funktionenView) */
export const getShop = (customerId) => request(`/shop/${enc(customerId)}`)
/** Anfrage annehmen oder ablehnen: body { projectId, entscheidung: 'annehmen' | 'ablehnen', begruendung } => { ok, anfrage, mailFehler? } */
export const entscheideShopAnfrage = (customerId, { projectId, entscheidung, begruendung }) =>
request(`/shop/${enc(customerId)}/anfrage`, { method: 'POST', body: entscheidungBody({ projectId, entscheidung, begruendung }) })
// --- Abo-Status (alle Mitarbeiter) ---
/** { productKey, active, status, storageUnits, startedAt, serviceStart, currentPeriodEnd, cancelAt } */
+5 -1
View File
@@ -52,6 +52,8 @@ test('terminplaner_karten_vorhanden', () => {
{ key: 'leistungsbeschreibung-terminplaner', title: 'Leistungsbeschreibung Terminplaner', sort: 7 },
{ key: 'avv-terminplaner', title: 'AVV Terminplaner (Auftragsverarbeitung)', sort: 8 },
{ key: 'widerrufsbelehrung-funktionen', title: 'Widerrufsbelehrung und Muster-Widerrufsformular (Funktionen)', sort: 9 },
{ key: 'leistungsbeschreibung-webshop', title: 'Leistungsbeschreibung Webshop', sort: 10 },
{ key: 'avv-webshop', title: 'AVV Webshop', sort: 11 },
]
for (const e of erwartet) {
const d = DOCUMENTS.find((x) => x.key === e.key)
@@ -62,7 +64,7 @@ test('terminplaner_karten_vorhanden', () => {
assert.ok(d.liveUrl.startsWith(base))
assert.ok(!/\d{4}-\d{2}-\d{2}/.test(d.liveUrl), 'Alias-Seite ohne Fassungsdatum')
}
assert.equal(DOCUMENTS.length, 9, 'keine Analyse-Karten und keine weiteren')
assert.equal(DOCUMENTS.length, 11, 'keine Analyse-Karten und keine weiteren')
})
test('bestehende_karten_unveraendert', () => {
@@ -88,5 +90,7 @@ test('dry_run_listet_karten', () => {
assert.ok(zeilen.some((z) => z.startsWith('Rechtstext leistungsbeschreibung-terminplaner:')))
assert.ok(zeilen.some((z) => z.startsWith('Rechtstext avv-terminplaner:')))
assert.ok(zeilen.some((z) => z.startsWith('Rechtstext widerrufsbelehrung-funktionen:')))
assert.ok(zeilen.some((z) => z.startsWith('Rechtstext leistungsbeschreibung-webshop:')))
assert.ok(zeilen.some((z) => z.startsWith('Rechtstext avv-webshop:')))
assert.ok(!/angelegt|vorhanden|Fertig/.test(r.stdout), 'Trockenlauf legt nichts an')
})
+203 -1
View File
@@ -20,14 +20,28 @@ const {
textformNoetig,
termineTextformLabel,
termineChatTemplate,
shopTextformLabel,
shopChatTemplate,
TEXTFORM_FUNKTIONEN,
textformAnsicht,
textformBereit,
featureBody,
speicherStatus,
K16_WARNUNG,
warenartLabel,
artikelzahlLabel,
anfrageStatus,
anfrageZeilen,
anfrageText,
stripeText,
stripeTon,
shopHatEintrag,
begruendungOk,
entscheidungBody,
entscheidungStatus,
} = await loadModule('src/lib/funktionenView.js')
test('FUNKTIONEN: News und Terminplaner mit den Bestätigungstexten', () => {
test('FUNKTIONEN: News, Terminplaner und Webshop mit den Bestätigungstexten', () => {
assert.deepEqual(FUNKTIONEN, [
{
key: 'news',
@@ -47,6 +61,16 @@ test('FUNKTIONEN: News und Terminplaner mit den Bestätigungstexten', () => {
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?',
},
},
])
})
@@ -372,6 +396,71 @@ test('textformNoetig: nur "Aktivieren (ohne Zahlung)" des Terminplaners und nur,
assert.equal(textformNoetig(undefined, 'activate', termine()), false)
})
// Feld "shop" der Portal-Antwort GET /funktionen/:customerId (Fassung = config.shop.fassung)
const shopFeld = (extra = {}) => ({
textformNoetig: true,
avvVersion: '2026-10-05',
links: {
lb: 'https://project.webklar.com/rechtliches/leistungsbeschreibung-webshop-2026-10-05.html',
avv: 'https://project.webklar.com/rechtliches/avv-webshop-2026-10-05.html',
},
...extra,
})
test('textformNoetig: auch das Aktivieren (ohne Zahlung) des Webshops, mit dem Feld shop; die Funktion kommt aus dem Schlüssel', () => {
assert.deepEqual(TEXTFORM_FUNKTIONEN, ['termine', 'shop'])
assert.equal(textformNoetig('shop', 'activate', shopFeld()), true)
assert.equal(textformNoetig('shop', 'activate', shopFeld({ textformNoetig: false })), false)
assert.equal(textformNoetig('shop', 'activate', undefined), false, 'altes Portal ohne Feld shop: wie bisher')
assert.equal(textformNoetig('shop', 'activate', null), false)
assert.equal(textformNoetig('shop', 'activate', shopFeld({ textformNoetig: 'true' })), false, 'nur echtes true')
for (const action of ['disable', 'abnahme', 'ticket']) assert.equal(textformNoetig('shop', action, shopFeld()), false, action)
assert.equal(textformNoetig('news', 'activate', shopFeld()), false)
})
test('shopTextformLabel und shopChatTemplate: wörtlich, mit Fassung, den Erklärungen des Nachweises und den versionierten Adressen', () => {
const l = shopTextformLabel('2026-10-05')
assert.equal(l.lb, 'Leistungsbeschreibung Webshop')
assert.equal(l.avv, 'Auftragsverarbeitungsvertrag Webshop')
assert.equal(
l.before + l.lb + l.between + l.avv + l.after,
'Der Kunde hat der Leistungsbeschreibung Webshop und dem Auftragsverarbeitungsvertrag Webshop (Fassung 2026-10-05) ' +
'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.',
)
const { lb, avv } = shopFeld().links
const text = shopChatTemplate('2026-10-05', { lb, avv })
assert.ok(text.startsWith('Damit wir den Webshop für Dich einschalten können, brauchen wir Deine Zustimmung in Textform. '))
assert.ok(text.includes(`in der Fassung 2026-10-05: ${lb} · ${avv}. `))
assert.match(text, /leistungsbeschreibung-webshop-2026-10-05\.html/)
assert.match(text, /avv-webshop-2026-10-05\.html/)
assert.match(text, /Ich stimme zu/)
assert.match(text, /eigenes Stripe-Konto/)
assert.ok(!text.includes('undefined') && !text.includes('{') && !text.includes('Terminplaner'))
})
test('textformAnsicht(feld, "shop"): Label, Vorlage und Links des Webshops; ohne Schlüssel bleibt es der Terminplaner', () => {
const a = textformAnsicht(shopFeld(), 'shop')
assert.equal(a.version, '2026-10-05')
assert.deepEqual(a.links, shopFeld().links)
assert.deepEqual(a.label, shopTextformLabel('2026-10-05'))
assert.equal(a.vorlage, shopChatTemplate('2026-10-05', shopFeld().links))
assert.deepEqual(textformAnsicht(termine()).label, termineTextformLabel('2026-10-05'))
// Adressen ohne https werden nie Link, ohne beide Adressen keine Vorlage
const schlecht = textformAnsicht(shopFeld({ links: { lb: 'javascript:alert(1)', avv: 'http://x.example/a.html' } }), 'shop')
assert.deepEqual(schlecht.links, { lb: '', avv: '' })
assert.equal(schlecht.vorlage, '')
assert.equal(textformAnsicht(undefined, 'shop').version, '')
})
test('textformBereit mit dem Feld shop: Datum zwischen Fassung und heute, ohne Fassung nie bereit', () => {
const ok = { confirmed: true, date: '2026-10-06', channel: 'email', today: '2026-10-07' }
assert.equal(textformBereit(ok, shopFeld()), true)
assert.equal(textformBereit({ ...ok, date: '2026-10-04' }, shopFeld()), false, 'vor der Fassung')
assert.equal(textformBereit(ok, shopFeld({ avvVersion: '' })), false)
assert.equal(textformBereit(ok, undefined), false)
})
test('termineChatTemplate: wörtlich, mit Fassung und den beiden versionierten Adressen', () => {
const { lb, avv } = termine().links
assert.equal(
@@ -470,3 +559,116 @@ test('speicherStatus: warnung true (K16 nicht gesendet) wird zur Warnung, sonst
assert.deepEqual(speicherStatus({ projects: [] }), { ok: true, text: 'Gespeichert.' })
assert.deepEqual(speicherStatus(undefined), { ok: true, text: 'Gespeichert.' })
})
test('aktionen: Webshop verhält sich wie der Terminplaner (ticketbasiert)', () => {
assert.deepEqual(aktionen('off', 'shop'), ['activate'])
assert.deepEqual(aktionen('disabled', 'shop'), ['activate'])
assert.deepEqual(aktionen('ordered', 'shop'), ['disable'])
assert.deepEqual(aktionen('active', 'shop'), ['disable'])
assert.deepEqual(aktionen('setup', 'shop'), ['abnahme', 'disable'])
assert.deepEqual(aktionen('setup', 'shop', { ticketMissing: true }), ['ticket', 'abnahme', 'disable'])
for (const state of ['off', 'ordered', 'active', 'disabled']) assert.deepEqual(aktionen(state, 'shop', { ticketMissing: true }), aktionen(state, 'shop'))
for (const action of aktionen('setup', 'shop', { ticketMissing: true })) assert.ok(FUNKTIONEN.find((f) => f.key === 'shop').bestaetigen[action], `Text für ${action}`)
})
test('siteKeyOf: auch der Webshop zeigt seinen Site-Key', () => {
assert.equal(siteKeyOf({ state: 'active', siteKey: ' abcdefghijklmnop ' }, 'shop'), 'abcdefghijklmnop')
assert.equal(siteKeyOf({ state: 'active' }, 'shop'), '')
})
test('warenartLabel und artikelzahlLabel: Beschriftungen, Unbekanntes bleibt sichtbar', () => {
assert.equal(warenartLabel('waren'), 'Waren')
assert.equal(warenartLabel('lebensmittel'), 'Lebensmittel')
assert.equal(warenartLabel('kleidung'), 'Kleidung')
assert.equal(warenartLabel('kunst'), 'Kunst/Handgemachtes')
assert.equal(warenartLabel('sonstiges'), 'Sonstiges')
assert.equal(warenartLabel('xyz'), 'xyz')
assert.equal(warenartLabel(undefined), '–')
assert.equal(warenartLabel('toString'), 'toString', 'kein Treffer über die Prototypkette')
assert.equal(artikelzahlLabel('bis20'), 'bis 20')
assert.equal(artikelzahlLabel('bis100'), 'bis 100')
assert.equal(artikelzahlLabel('mehr'), 'mehr als 100')
assert.equal(artikelzahlLabel(''), '–')
})
test('anfrageStatus: Ton und Beschriftung', () => {
assert.deepEqual(anfrageStatus('offen'), ['warn', 'Offen'])
assert.deepEqual(anfrageStatus('angenommen'), ['ok', 'Angenommen'])
assert.deepEqual(anfrageStatus('abgelehnt'), ['danger', 'Abgelehnt'])
assert.deepEqual(anfrageStatus('zurueckgezogen'), ['muted', 'Zurückgezogen'])
assert.deepEqual(anfrageStatus(undefined), ['muted', '–'])
})
const ANFRAGE = { warenart: 'kunst', beschreibung: ' Handgemachte Kerzen ', artikelzahl: 'bis20', versand: true, abholung: false, unternehmer: true, keineAusgeschlossenen: true, nachricht: '' }
test('anfrageZeilen: alle Angaben der Anfrage als Zeilen', () => {
assert.deepEqual(anfrageZeilen(ANFRAGE), [
{ label: 'Warenart', text: 'Kunst/Handgemachtes' },
{ label: 'Beschreibung', text: 'Handgemachte Kerzen' },
{ label: 'Artikelzahl', text: 'bis 20' },
{ label: 'Übergabe', text: 'Versand' },
{ label: 'Unternehmer', text: 'Ja' },
{ label: 'Keine ausgeschlossenen Waren', text: 'Ja' },
{ label: 'Nachricht', text: '–' },
])
assert.equal(anfrageZeilen({ ...ANFRAGE, abholung: true }).find((z) => z.label === 'Übergabe').text, 'Versand und Abholung')
assert.equal(anfrageZeilen({ ...ANFRAGE, versand: false }).find((z) => z.label === 'Übergabe').text, '–')
assert.deepEqual(anfrageZeilen(null), [])
})
test('anfrageText: eine Zeile aus Warenart, Menge und Übergabe', () => {
assert.equal(anfrageText(ANFRAGE), 'Kunst/Handgemachtes, bis 20 Artikel, Versand')
assert.equal(anfrageText({ ...ANFRAGE, abholung: true }), 'Kunst/Handgemachtes, bis 20 Artikel, Versand und Abholung')
assert.equal(anfrageText({ warenart: 'waren' }), 'Waren')
assert.equal(anfrageText(null), '')
})
test('stripeText: nicht verbunden, aktiv, Angaben fehlen', () => {
assert.equal(stripeText(null, false), 'nicht verbunden')
assert.equal(stripeText(undefined), 'nicht verbunden')
assert.equal(stripeText(null, true), 'Konto angelegt, Stand noch nicht geprüft')
assert.equal(stripeText({ charges_enabled: true, payouts_enabled: true }, true), 'Zahlungen aktiv')
assert.equal(stripeText({ charges_enabled: true, payouts_enabled: false }, true), 'Zahlungen aktiv, Auszahlungen noch nicht freigegeben')
assert.equal(stripeText({ charges_enabled: false, currently_due: ['external_account', 'individual.dob.day'], past_due: [] }, true), 'Angaben fehlen: external_account, individual.dob.day')
assert.equal(stripeText({ charges_enabled: false, past_due: ['a'], currently_due: ['a', 'b'] }, true), 'Angaben fehlen: a, b', 'doppelte Einträge nur einmal')
assert.equal(stripeText({ charges_enabled: false, currently_due: ['a', 'b', 'c', 'd', 'e', 'f'] }, true), 'Angaben fehlen: a, b, c, d und 2 weitere')
assert.equal(stripeText({ charges_enabled: false, currently_due: [], disabled_reason: 'requirements.pending_verification' }, true), 'Angaben fehlen: requirements.pending_verification')
assert.equal(stripeText({ charges_enabled: false, details_submitted: true }, true), 'Angaben fehlen: Prüfung durch Stripe läuft')
assert.equal(stripeText({ charges_enabled: false, details_submitted: false }, true), 'Angaben fehlen: Einrichtung nicht abgeschlossen')
})
test('stripeTon: ok, warn, muted', () => {
assert.equal(stripeTon({ charges_enabled: true }, true), 'ok')
assert.equal(stripeTon({ charges_enabled: false }, true), 'warn')
assert.equal(stripeTon(null, true), 'warn')
assert.equal(stripeTon(null, false), 'muted')
})
test('shopHatEintrag: Anfrage oder Zustand außer "off"', () => {
assert.equal(shopHatEintrag({ state: 'off', anfrage: null }), false)
assert.equal(shopHatEintrag({ state: 'off', anfrage: { status: 'offen' } }), true)
assert.equal(shopHatEintrag({ state: 'active', anfrage: null }), true)
assert.equal(shopHatEintrag({ state: 'disabled' }), true)
assert.equal(shopHatEintrag(null), false)
})
test('begruendungOk: Pflicht, höchstens 1000 Zeichen', () => {
assert.equal(begruendungOk(''), false)
assert.equal(begruendungOk(' '), false)
assert.equal(begruendungOk(undefined), false)
assert.equal(begruendungOk('Passt nicht'), true)
assert.equal(begruendungOk('x'.repeat(1000)), true)
assert.equal(begruendungOk('x'.repeat(1001)), false)
assert.equal(begruendungOk(` ${'x'.repeat(1000)} `), true, 'ohne Rand gezählt')
})
test('entscheidungBody: Begründung nur beim Ablehnen, getrimmt', () => {
assert.deepEqual(entscheidungBody({ projectId: 'p1', entscheidung: 'annehmen', begruendung: 'egal' }), { projectId: 'p1', entscheidung: 'annehmen' })
assert.deepEqual(entscheidungBody({ projectId: 'p1', entscheidung: 'ablehnen', begruendung: ' Zu groß ' }), { projectId: 'p1', entscheidung: 'ablehnen', begruendung: 'Zu groß' })
})
test('entscheidungStatus: Warnung, wenn die Mail an den Kunden fehlschlug', () => {
assert.deepEqual(entscheidungStatus({ ok: true }), { ok: true, text: 'Gespeichert.' })
assert.deepEqual(entscheidungStatus({ ok: true, mailFehler: true }), { ok: false, text: 'Entscheidung gespeichert, Mail an den Kunden fehlgeschlagen.' })
assert.deepEqual(entscheidungStatus(null), { ok: true, text: 'Gespeichert.' })
})