diff --git a/scripts/setup-legal.mjs b/scripts/setup-legal.mjs
index fdfbdcb..7acd994 100644
--- a/scripts/setup-legal.mjs
+++ b/scripts/setup-legal.mjs
@@ -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));
diff --git a/src/components/customer/CustomerFunktionen.jsx b/src/components/customer/CustomerFunktionen.jsx
index b8bb5e9..73b4a04 100644
--- a/src/components/customer/CustomerFunktionen.jsx
+++ b/src/components/customer/CustomerFunktionen.jsx
@@ -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
@@ -158,6 +159,75 @@ 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 (
+
+
+ Webshop: {shop.projectName}
+ {a && Anfrage: {label} }
+ {shop.geoeffnet ? 'Geöffnet' : 'Geschlossen'}
+ Stripe: {stripeText(stripe.status, !!stripe.accountId)}
+
+ {a && (
+
+ {anfrageZeilen(a.anfrage).map((z) => (
+ {z.label} {z.text}
+ ))}
+ Eingegangen {a.at ? fmtDateTime(a.at) : '–'}
+ {a.entschiedenAt && <>Entschieden {fmtDateTime(a.entschiedenAt)} >}
+ {a.begruendung && <>Begründung {a.begruendung} >}
+
+ )}
+
Artikel: {shop.zahlen?.artikel ?? 0} · Offene Bestellungen: {shop.zahlen?.offeneBestellungen ?? 0}
+ {a?.status === 'offen' && (
+ frage ? (
+
+ {frage === 'annehmen' ? 'Anfrage annehmen? Der Kunde bekommt eine Mail.' : 'Anfrage ablehnen? Der Kunde bekommt die Begründung per Mail.'}
+ {frage === 'ablehnen' && (
+
+ ) : (
+
+ { setBegruendung(''); setFrage('annehmen') }}>Anfrage annehmen
+ { setBegruendung(''); setFrage('ablehnen') }}>Ablehnen
+
+ )
+ )}
+ {status &&
{status.text}
}
+
+ )
+}
+
const LEERE_TEXTFORM = { confirmed: false, date: '', channel: '' }
export default function CustomerFunktionen({ customerId }) {
@@ -170,6 +240,8 @@ 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=)
+ 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(() => {
@@ -178,7 +250,14 @@ export default function CustomerFunktionen({ customerId }) {
.then(({ projects: list, termine: feld }) => { setProjects(list || []); setTermine(feld || 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: feld }) => { setProjects(list || []); setTermine(feld || null) }), loadShop()])
const save = async () => {
const { projectId, key, action } = confirm
@@ -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 }))
}
}
@@ -289,6 +369,14 @@ export default function CustomerFunktionen({ customerId }) {
}))}
{status && {status.text}
}
+ {shopFehler && {shopFehler}
}
+ {shop.filter(shopHatEintrag).length > 0 && (
+
+ {shop.filter(shopHatEintrag).map((s) => (
+
+ ))}
+
+ )}
)
}
diff --git a/src/lib/funktionenView.js b/src/lib/funktionenView.js
index 0d77daa..dbfa696 100644
--- a/src/lib/funktionenView.js
+++ b/src/lib/funktionenView.js
@@ -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']
@@ -229,3 +239,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.' }
diff --git a/src/lib/teamApi.js b/src/lib/teamApi.js
index a201c38..5be03a7 100644
--- a/src/lib/teamApi.js
+++ b/src/lib/teamApi.js
@@ -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,7 +94,7 @@ 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] }.
+ * Aktivieren (ohne Zahlung) oder Abschalten einer Funktion (key: news | termine | shop); Antwort { projects: [das eine Projekt] }.
* Terminplaner im Modus on: beim Aktivieren zusaetzlich textformDate (JJJJ-MM-TT) und textformChannel (chat | email).
*/
export const setFeature = (customerId, key, { 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 } */
diff --git a/tests/setup-legal.test.mjs b/tests/setup-legal.test.mjs
index 58e9856..db029e0 100644
--- a/tests/setup-legal.test.mjs
+++ b/tests/setup-legal.test.mjs
@@ -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')
})
diff --git a/tests/unit/funktionenView.test.mjs b/tests/unit/funktionenView.test.mjs
index 841080b..fb1d9dc 100644
--- a/tests/unit/funktionenView.test.mjs
+++ b/tests/unit/funktionenView.test.mjs
@@ -25,9 +25,20 @@ const {
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 +58,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?',
+ },
+ },
])
})
@@ -470,3 +491,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.' })
+})