Merge remote-tracking branch 'origin/test' into feature/oeffnungszeiten
Zusammenführung von TP2 und Webshop im Ticketsystem (origin/test00fa229) mit Öffnungszeiten live. Die Funktionen stehen in der Reihenfolge news, termine, shop, zeiten. Konfliktdateien (6, erwartet war nur CustomerFunktionen.jsx; origin/test enthält seit der Vorbereitung auch die beiden Webshop-Commits0a2eab8und00fa229): scripts/setup-legal.mjs, src/components/customer/CustomerFunktionen.jsx, src/lib/funktionenView.js, src/lib/teamApi.js, tests/setup-legal.test.mjs, tests/unit/funktionenView.test.mjs. Entscheidungen: ersatzTicket-Behandlung der Zeiten und speicherStatus/K16_WARNUNG von TP2 bleiben beide in save() (Abschalten der Zeiten zeigt abschaltMeldung, jede andere Aktion speicherStatus); Rechtliches-Karten Webshop (sort 10, 11) vor der Karte der Öffnungszeiten (sort 12, DOCUMENTS hat 12 Einträge); siteKeyOf kennt termine, shop und zeiten; aktionen behandelt shop und zeiten wie den Terminplaner (ticketbasiert, Abnahme der Zeiten mit hindernis); teamApi trägt Webshop- und Zeiten-Aufrufe nebeneinander. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
8319084930
19 files changed
+906
-165
No files matched your search
+140
-16
@@ -1,4 +1,4 @@
|
||||
// Kunden-Reiter "Funktionen": Register der Funktionen je Projekt (News, Terminplaner, Öffnungszeiten live), Aktionen je Zustand und
|
||||
// Kunden-Reiter "Funktionen": Register der Funktionen je Projekt (News, Terminplaner, Webshop, Öffnungszeiten live), Aktionen je Zustand und
|
||||
// Einarbeiten der Portal-Antwort. Rein, unter Node testbar.
|
||||
|
||||
import { fmtYmd, fmtBerlin } from './format'
|
||||
@@ -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?',
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'zeiten',
|
||||
label: 'Öffnungszeiten live',
|
||||
@@ -68,12 +78,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 bei Oeffnungszeiten live
|
||||
* Site-Key (Schluessel fuer den Einbau auf der Kundenwebsite) einer Funktion: nur beim Terminplaner, beim Webshop und bei Oeffnungszeiten live
|
||||
* und nur, wenn das Portal einen nennt (feature.siteKey); sonst ''.
|
||||
*/
|
||||
export function siteKeyOf(feature, key) {
|
||||
const wert = feature?.siteKey
|
||||
return (key === 'termine' || key === 'zeiten') && typeof wert === 'string' ? wert.trim() : ''
|
||||
return (key === 'termine' || key === 'shop' || key === 'zeiten') && typeof wert === 'string' ? wert.trim() : ''
|
||||
}
|
||||
|
||||
/** Beschriftung der Aktionsknoepfe (Aktion = action im POST /funktionen/:customerId/:key). */
|
||||
@@ -86,7 +96,7 @@ 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 und Oeffnungszeiten live (beide mit Ticket-Freischaltung): "ordered" nur Abschalten (Aktivieren
|
||||
* Einrichtung oder aktiv. Terminplaner, Webshop und Oeffnungszeiten live (alle mit Ticket-Freischaltung): "ordered" nur Abschalten (Aktivieren
|
||||
* ohne Zahlung wuerde eine spaetere Zahlung im Portal verschlucken), "setup" Freischalten nach der Abnahme (mit ticketMissing zuerst
|
||||
* "Ticket nachlegen"), "active" nur Abschalten; sonst ("off", "disabled", ohne Zustand) Aktivieren. Fuer "disabled" gibt es kein
|
||||
* Abschalten, auch wenn das Ersatz-Ticket beim Abschalten fehlschlug (das Portal merkt sich das nicht; die Antwort nennt es nur einmal).
|
||||
@@ -94,7 +104,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 === 'zeiten') {
|
||||
if (key === 'termine' || key === 'shop' || key === 'zeiten') {
|
||||
if (state === 'ordered' || state === 'active') return ['disable']
|
||||
if (state === 'setup') {
|
||||
const abnahme = key === 'zeiten' && hindernis ? [] : ['abnahme']
|
||||
@@ -181,10 +191,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) => ({
|
||||
@@ -195,10 +211,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 ''
|
||||
@@ -210,23 +239,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 })
|
||||
}
|
||||
@@ -240,3 +271,96 @@ export function featureBody({ projectId, action, note, textformDate, textformCha
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
/** K16 (Unterlagen an den Kunden) ging beim Aktivieren nicht raus: Antwort mit warnung: true (Portal, funktionenAdmin). */
|
||||
export const K16_WARNUNG = 'Eingeschaltet, aber die E-Mail mit den Unterlagen (K16) ging nicht raus. Bitte die Leistungsbeschreibung und den AVV von Hand an den Kunden senden.'
|
||||
|
||||
/** 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.' }
|
||||
Reference in new issue
Block a user