Buchungskalender: Funktionsanfragen und Rechtliches-Karten
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
d2883018fc
commit
b2a0ff5d53
6 files changed
+192
-43
No files matched your search
@@ -2,7 +2,7 @@ import { Fragment, useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { Card, Badge, Button, EmptyState } from '../ui'
|
||||
import { copyText } from '../CopyableCredential'
|
||||
import { getFunktionen, setFeature, getTermineAnleitung, getShop, entscheideShopAnfrage } from '../../lib/teamApi'
|
||||
import { getFunktionen, setFeature, getTermineAnleitung, getShop, entscheideShopAnfrage, getUnterkunft, entscheideUnterkunftAnfrage } from '../../lib/teamApi'
|
||||
import {
|
||||
FUNKTIONEN, ZUSTAND, AKTION_LABEL, funktionOf, aktionen, mergeFunktion, siteKeyOf, abgeschaltetText,
|
||||
hindernisText, panelModell, anleitungOffen, textformNoetig, textformAnsicht, textformBereit, speicherStatus,
|
||||
@@ -160,21 +160,18 @@ function TextformPanel({ funktion, feld, value, onChange, projectId, today, disa
|
||||
)
|
||||
}
|
||||
|
||||
// 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 }) {
|
||||
// Entscheidung ueber eine Anfrage (Webshop und Buchungskalender): Annehmen / Ablehnen mit Begruendung. Die Entscheidung geht ueber entscheide
|
||||
// ans Portal; danach laden Funktionen und Bereich neu (onDecided). Texte sind ausschliesslich React-Textknoten.
|
||||
function EntscheidungBlock({ projectId, anfrage, entscheide, 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 () => {
|
||||
const run = async () => {
|
||||
setBusy(true)
|
||||
setStatus(null)
|
||||
try {
|
||||
const antwort = await entscheideShopAnfrage(customerId, { projectId: shop.projectId, entscheidung: frage, begruendung })
|
||||
const antwort = await entscheide({ projectId, entscheidung: frage, begruendung })
|
||||
setFrage(null)
|
||||
setBegruendung('')
|
||||
setStatus(entscheidungStatus(antwort))
|
||||
@@ -189,32 +186,15 @@ function ShopPanel({ customerId, shop, onDecided }) {
|
||||
}
|
||||
const ablehnenFehlt = frage === 'ablehnen' && !begruendungOk(begruendung)
|
||||
return (
|
||||
<div className="funktionen-shop">
|
||||
<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="kv">
|
||||
{anfrageZeilen(a.anfrage).map((z) => (
|
||||
<Fragment key={z.label}><dt>{z.label}</dt><dd>{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>{a.begruendung}</dd></>}
|
||||
</dl>
|
||||
)}
|
||||
<div className="muted small">Artikel: {shop.zahlen?.artikel ?? 0} · Offene Bestellungen: {shop.zahlen?.offeneBestellungen ?? 0}</div>
|
||||
{a?.status === 'offen' && (
|
||||
<>
|
||||
{anfrage?.status === 'offen' && (
|
||||
frage ? (
|
||||
<div className="flex items-center gap-2 wrap">
|
||||
<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="textarea funktionen-begruendung" required rows={3} maxLength={BEGRUENDUNG_MAX} placeholder="Begründung (Pflicht)" aria-label="Begründung (Pflicht)" value={begruendung} onChange={(e) => setBegruendung(e.target.value)} />
|
||||
)}
|
||||
<Button type="button" variant="primary" onClick={entscheiden} disabled={busy || ablehnenFehlt}>{busy ? 'Speichert …' : 'Ja'}</Button>
|
||||
<Button type="button" variant="primary" onClick={run} disabled={busy || ablehnenFehlt}>{busy ? 'Speichert …' : 'Ja'}</Button>
|
||||
<Button type="button" onClick={() => setFrage(null)} disabled={busy}>Abbrechen</Button>
|
||||
</div>
|
||||
) : (
|
||||
@@ -225,6 +205,61 @@ function ShopPanel({ customerId, shop, onDecided }) {
|
||||
)
|
||||
)}
|
||||
{status && <div className={`msg ${status.ok ? 'ok' : 'err'}`} role={status.ok ? undefined : 'alert'}>{status.text}</div>}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// Zeilen der Anfrage samt Eingang, Entscheidung und Begruendung
|
||||
function AnfrageListe({ a, art }) {
|
||||
return (
|
||||
<dl className="kv">
|
||||
{anfrageZeilen(a.anfrage, art).map((z) => (
|
||||
<Fragment key={z.label}><dt>{z.label}</dt><dd>{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>{a.begruendung}</dd></>}
|
||||
</dl>
|
||||
)
|
||||
}
|
||||
|
||||
// Webshop je Projekt: Anfrage des Kunden (annehmen / ablehnen mit Begruendung), Stripe-Stand und Kennzahlen. Die Entscheidung geht ans Portal
|
||||
// (POST /shop/:customerId/anfrage).
|
||||
function ShopPanel({ customerId, shop, onDecided }) {
|
||||
const a = shop.anfrage
|
||||
const [tone, label] = anfrageStatus(a?.status)
|
||||
const stripe = shop.stripe || { accountId: false, status: null }
|
||||
return (
|
||||
<div className="funktionen-shop">
|
||||
<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 && <AnfrageListe a={a} art="shop" />}
|
||||
<div className="muted small">Artikel: {shop.zahlen?.artikel ?? 0} · Offene Bestellungen: {shop.zahlen?.offeneBestellungen ?? 0}</div>
|
||||
<EntscheidungBlock projectId={shop.projectId} anfrage={a} entscheide={(body) => entscheideShopAnfrage(customerId, body)} onDecided={onDecided} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Buchungskalender je Projekt: Anfrage (annehmen / ablehnen mit Begruendung), Stripe-Stand, Zahl der Unterkuenfte und offene Buchungsanfragen
|
||||
// (Portal: GET /unterkunft/:customerId, POST /unterkunft/:customerId/anfrage).
|
||||
function UnterkunftPanel({ customerId, eintrag, onDecided }) {
|
||||
const a = eintrag.anfrage
|
||||
const [tone, label] = anfrageStatus(a?.status)
|
||||
const stripe = eintrag.stripe || { accountId: false, status: null }
|
||||
return (
|
||||
<div className="funktionen-shop">
|
||||
<div className="funktionen-panel-head">
|
||||
<strong>Buchungskalender: {eintrag.name}</strong>
|
||||
{a && <Badge tone={tone}>Anfrage: {label}</Badge>}
|
||||
<Badge tone={stripeTon(stripe.status, !!stripe.accountId)}>Stripe: {stripeText(stripe.status, !!stripe.accountId)}</Badge>
|
||||
</div>
|
||||
{a && <AnfrageListe a={a} art="unterkunft" />}
|
||||
<div className="muted small">Unterkünfte: {eintrag.unterkuenfte ?? 0} · Offene Buchungsanfragen: {eintrag.offeneAnfragen ?? 0}</div>
|
||||
<EntscheidungBlock projectId={eintrag.projectId} anfrage={a} entscheide={(body) => entscheideUnterkunftAnfrage(customerId, body)} onDecided={onDecided} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -243,6 +278,8 @@ export default function CustomerFunktionen({ customerId }) {
|
||||
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 [unterkunft, setUnterkunft] = useState([]) // GET /unterkunft/:customerId => projects
|
||||
const [unterkunftFehler, setUnterkunftFehler] = useState(null)
|
||||
const [panelReload, setPanelReload] = useState({}) // projectId -> Zaehler, nach jeder Aktion hochgezaehlt
|
||||
|
||||
const load = useCallback(() => {
|
||||
@@ -257,8 +294,14 @@ export default function CustomerFunktionen({ 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 loadUnterkunft = useCallback(() => {
|
||||
setUnterkunftFehler(null)
|
||||
return getUnterkunft(customerId)
|
||||
.then(({ projects: list }) => setUnterkunft(Array.isArray(list) ? list : []))
|
||||
.catch((err) => setUnterkunftFehler(err.message || 'Daten des Buchungskalenders konnten nicht geladen werden.'))
|
||||
}, [customerId])
|
||||
useEffect(() => { load(); loadShop(); loadUnterkunft() }, [load, loadShop, loadUnterkunft])
|
||||
const nachEntscheidung = () => Promise.all([getFunktionen(customerId).then(({ projects: list, termine, shop: shopFeld }) => { setProjects(list || []); setFelder({ termine: termine || null, shop: shopFeld || null }) }), loadShop(), loadUnterkunft()])
|
||||
|
||||
const save = async () => {
|
||||
const { projectId, key, action } = confirm
|
||||
@@ -285,6 +328,7 @@ export default function CustomerFunktionen({ customerId }) {
|
||||
setBusy(false)
|
||||
// Version und Hindernis der Anleitung haengen vom Zustand ab: Panel dieser Zeile neu laden
|
||||
if (key === 'shop') loadShop()
|
||||
if (key === 'unterkunft') loadUnterkunft()
|
||||
if (key === 'termine') setPanelReload((r) => ({ ...r, [projectId]: (r[projectId] || 0) + 1 }))
|
||||
}
|
||||
}
|
||||
@@ -300,6 +344,7 @@ export default function CustomerFunktionen({ customerId }) {
|
||||
if (!projects.length) return <EmptyState title="Keine Projekte für diesen Kunden." />
|
||||
|
||||
const shopEintraege = shop.filter(shopHatEintrag)
|
||||
const unterkunftEintraege = unterkunft.filter(shopHatEintrag)
|
||||
return (
|
||||
<Card title="Funktionen">
|
||||
<table className="table funktionen-table">
|
||||
@@ -411,6 +456,10 @@ export default function CustomerFunktionen({ customerId }) {
|
||||
{shopEintraege.map((s) => (
|
||||
<ShopPanel key={s.projectId} customerId={customerId} shop={s} onDecided={nachEntscheidung} />
|
||||
))}
|
||||
{unterkunftFehler && <div className="msg err funktionen-msg" role="alert">{unterkunftFehler}</div>}
|
||||
{unterkunftEintraege.map((u) => (
|
||||
<UnterkunftPanel key={u.projectId} customerId={customerId} eintrag={u} onDecided={nachEntscheidung} />
|
||||
))}
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -44,6 +44,16 @@ export const FUNKTIONEN = [
|
||||
ticket: 'Fehlendes Einbau-Ticket neu anlegen?',
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'unterkunft',
|
||||
label: 'Buchungskalender',
|
||||
bestaetigen: {
|
||||
activate: 'Buchungskalender ohne Zahlung aktivieren?',
|
||||
disable: 'Buchungskalender abschalten? Auf der Website sind dann keine Buchungsanfragen mehr möglich.',
|
||||
abnahme: 'Buchungskalender freischalten? Der Kunde bekommt die Mail „bereit“ und Gäste können anfragen.',
|
||||
ticket: 'Fehlendes Einrichtungs-Ticket neu anlegen?',
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
/** Zustand einer Funktion -> [Ton des Abzeichens, Beschriftung]. */
|
||||
@@ -83,7 +93,7 @@ export const funktionOf = (project, key) => project?.features?.[key] || { state:
|
||||
*/
|
||||
export function siteKeyOf(feature, key) {
|
||||
const wert = feature?.siteKey
|
||||
return (key === 'termine' || key === 'shop' || key === 'zeiten') && typeof wert === 'string' ? wert.trim() : ''
|
||||
return (key === 'termine' || key === 'shop' || key === 'zeiten' || key === 'unterkunft') && typeof wert === 'string' ? wert.trim() : ''
|
||||
}
|
||||
|
||||
/** Beschriftung der Aktionsknoepfe (Aktion = action im POST /funktionen/:customerId/:key). */
|
||||
@@ -104,7 +114,7 @@ export const AKTION_LABEL = {
|
||||
* (hindernisText); bei Oeffnungszeiten live entfaellt sie, solange der Kunde noch keine Zeiten eingetragen hat ('zeiten_fehlen').
|
||||
*/
|
||||
export function aktionen(state, key, { ticketMissing = false, hindernis = null } = {}) {
|
||||
if (key === 'termine' || key === 'shop' || key === 'zeiten') {
|
||||
if (key === 'termine' || key === 'shop' || key === 'zeiten' || key === 'unterkunft') {
|
||||
if (state === 'ordered' || state === 'active') return ['disable']
|
||||
if (state === 'setup') {
|
||||
const abnahme = key === 'zeiten' && hindernis ? [] : ['abnahme']
|
||||
@@ -313,9 +323,25 @@ export const anfrageStatus = (status) => (has(ANFRAGE_STATUS, status) ? ANFRAGE_
|
||||
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) {
|
||||
const UNTERKUNFT_ART_LABEL = { ferienwohnung: 'Ferienwohnung', monteurzimmer: 'Monteurzimmer', pension: 'Pension', sonstiges: 'Sonstiges' }
|
||||
/** Art der Unterkunft -> Beschriftung; unbekannt oder leer '–'. */
|
||||
export const unterkunftArtLabel = (code) => (has(UNTERKUNFT_ART_LABEL, code) ? UNTERKUNFT_ART_LABEL[code] : code ? String(code) : '–')
|
||||
const einheitenText = (n) => (Number.isFinite(n) && n > 0 ? String(n) : '–')
|
||||
const portaleText = (wert) => (wert === true ? 'nutzt Booking.com/Airbnb' : wert === false ? 'nutzt keine Portale' : '–')
|
||||
|
||||
/**
|
||||
* Zeilen der Anfrage fuer die Anzeige: [{ label, text }]. Ohne Anfrage []. art = 'shop' (Standard) oder 'unterkunft' (Buchungskalender).
|
||||
*/
|
||||
export function anfrageZeilen(a, art = 'shop') {
|
||||
if (!a || typeof a !== 'object') return []
|
||||
if (art === 'unterkunft') {
|
||||
return [
|
||||
{ label: 'Art', text: unterkunftArtLabel(a.art) },
|
||||
{ label: 'Einheiten', text: einheitenText(a.einheiten) },
|
||||
{ label: 'Portale', text: portaleText(a.portale) },
|
||||
{ label: 'Nachricht', text: textOder(a.nachricht) },
|
||||
]
|
||||
}
|
||||
const wege = [a.versand === true ? 'Versand' : '', a.abholung === true ? 'Abholung' : ''].filter(Boolean)
|
||||
return [
|
||||
{ label: 'Warenart', text: warenartLabel(a.warenart) },
|
||||
@@ -328,9 +354,13 @@ export function anfrageZeilen(a) {
|
||||
]
|
||||
}
|
||||
|
||||
/** Kurzfassung der Anfrage in einer Zeile ("Waren, bis 20 Artikel, Versand und Abholung"); ohne Anfrage ''. */
|
||||
export function anfrageText(a) {
|
||||
/** Kurzfassung der Anfrage in einer Zeile ("Waren, bis 20 Artikel, Versand und Abholung"); ohne Anfrage ''. art wie bei anfrageZeilen. */
|
||||
export function anfrageText(a, art = 'shop') {
|
||||
if (!a || typeof a !== 'object') return ''
|
||||
if (art === 'unterkunft') {
|
||||
const n = Number.isFinite(a.einheiten) && a.einheiten > 0 ? `${a.einheiten} ${a.einheiten === 1 ? 'Einheit' : 'Einheiten'}` : ''
|
||||
return [unterkunftArtLabel(a.art), n, a.portale === true ? 'nutzt Booking.com/Airbnb' : ''].filter((t) => t && t !== '–').join(', ')
|
||||
}
|
||||
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(', ')
|
||||
@@ -359,14 +389,14 @@ export const stripeTon = (status, hatKonto = false) => {
|
||||
return status || hatKonto ? 'warn' : 'muted'
|
||||
}
|
||||
|
||||
/** Gibt es zu diesem Projekt etwas anzuzeigen (Anfrage vorhanden oder Webshop nicht "off")? */
|
||||
/** Gibt es zu diesem Projekt etwas anzuzeigen (Anfrage vorhanden oder Funktion nicht "off")? Gilt fuer Webshop und Buchungskalender. */
|
||||
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. */
|
||||
/** Rumpf fuer POST /shop/:customerId/anfrage und /unterkunft/:customerId/anfrage. Begruendung nur beim Ablehnen. */
|
||||
export function entscheidungBody({ projectId, entscheidung, begruendung }) {
|
||||
const body = { projectId, entscheidung }
|
||||
if (entscheidung === 'ablehnen') body.begruendung = String(begruendung ?? '').trim()
|
||||
|
||||
+9
-1
@@ -95,7 +95,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 | shop); Antwort { projects: [das eine Projekt] }.
|
||||
* Aktivieren (ohne Zahlung) oder Abschalten einer Funktion (key: news | termine | shop | zeiten | unterkunft); 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 }) =>
|
||||
@@ -112,6 +112,14 @@ export const getShop = (customerId) => request(`/shop/${enc(customerId)}`)
|
||||
export const entscheideShopAnfrage = (customerId, { projectId, entscheidung, begruendung }) =>
|
||||
request(`/shop/${enc(customerId)}/anfrage`, { method: 'POST', body: entscheidungBody({ projectId, entscheidung, begruendung }) })
|
||||
|
||||
// --- Buchungskalender (Anfrage des Kunden, Stripe-Stand, Unterkuenfte) ---
|
||||
|
||||
/** { projects: [{ projectId, name, state, anfrage, stripe, unterkuenfte, offeneAnfragen }] } (Aufbereitung: funktionenView) */
|
||||
export const getUnterkunft = (customerId) => request(`/unterkunft/${enc(customerId)}`)
|
||||
/** Anfrage annehmen oder ablehnen: body { projectId, entscheidung: 'annehmen' | 'ablehnen', begruendung } => { ok, anfrage, mailFehler? } */
|
||||
export const entscheideUnterkunftAnfrage = (customerId, { projectId, entscheidung, begruendung }) =>
|
||||
request(`/unterkunft/${enc(customerId)}/anfrage`, { method: 'POST', body: entscheidungBody({ projectId, entscheidung, begruendung }) })
|
||||
|
||||
// --- Oeffnungszeiten live (Funktion zeiten): Ersatz-HTML und Team-Einstellungen ---
|
||||
|
||||
/** Ersatz-HTML (Wochentabelle) eines Plans fuer den Einbau: { html }; 404 unbekannter Plan, 409 "Der Kunde hat noch keine Zeiten eingetragen." */
|
||||
|
||||
Reference in new issue
Block a user