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>
This commit is contained in:
1 parent
4c3f67eadc
commit
0a2eab85d5
6 files changed
+347
-13
No files matched your search
@@ -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 (
|
||||
<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 }) {
|
||||
@@ -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=<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(() => {
|
||||
@@ -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 }) {
|
||||
}))}
|
||||
</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>
|
||||
)
|
||||
}
|
||||
+102
-5
@@ -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.' }
|
||||
+10
-2
@@ -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 } */
|
||||
|
||||
Reference in new issue
Block a user