Files
Webklar-Kundenbereich/server/services/termine/vorlagen.js
T

223 lines
11 KiB
JavaScript

import { hatPreis, neueId, normalisiereEinstellung } from './einstellungen.js'
/**
* Branchen-Vorlagen des Terminplaners (Spezifikation Abschnitt 9, Erstes Oeffnen): Daten, kein Code je Nische.
* Eine Vorlage liefert Leistungen, Wochenplan, Regeln und Stornotext als Vorschlag; Betriebsangaben kommen aus dem
* Kundenkonto (ausVorlage). Alle Preise sind Platzhalter zum Anpassen. Die Fristen im Stornotext stammen aus den
* Regeln (absageBisStd), damit Text und Selbstbedienung der Gaeste uebereinstimmen. TP2 nutzt dieselben Daten fuer den Fragebogen.
*/
const ZAHLARTEN = ['bar', 'ec', 'kreditkarte']
const STORNO_PROZENT = 50
/** Stornobedingungen Variante 1 (Zahlung vor Ort), recherche-recht-zahlung.md 7.3 F, mit X = Frist und Y = Prozent. */
const stornoText = (stunden, prozent) =>
`Sie können Ihren Termin bis ${stunden} Stunden vorher kostenlos absagen oder verschieben (Link in der Bestätigungsmail). `
+ `Danach oder bei Nichterscheinen berechnen wir für den freigehaltenen Termin ${prozent} % des Preises. `
+ 'Dem Kunden bleibt der Nachweis vorbehalten, dass uns kein oder ein geringerer Aufwand entstanden ist. '
+ 'Ihr gesetzliches Widerrufsrecht bleibt unberührt.'
/** Wochenplan aus { '1-5': [[von, bis], …], '6': … } (ISO-Wochentage, Pausen = Luecken). */
function wochenplan(spezifikation) {
const plan = {}
for (const [bereich, fenster] of Object.entries(spezifikation)) {
const [von, bis = von] = bereich.split('-').map(Number)
for (let tag = von; tag <= bis; tag += 1) plan[tag] = fenster
}
return plan
}
const person = (name, plan) => ({ name, typ: 'person', plan })
const platz = (name, plan) => ({ name, typ: 'platz', plan })
const leistung = (name, dauer, pufferNach, preisArt, preisCent, beschreibung = '') => ({ name, beschreibung, dauer, pufferNach, preisArt, preisCent })
const MITTAG = [['09:00', '12:00'], ['13:00', '17:00']]
const BUERO = wochenplan({ '1-5': MITTAG })
/**
* Eine Vorlage. ressourcen: Wochenplan je Person/Platz; leistungen gelten fuer alle Ressourcen der Vorlage;
* buchungsart gilt fuer alle Leistungen; gastFelder/recht/regeln ueberschreiben nur die Abweichung von den Vorgaben.
*/
function vorlage(key, name, beschreibung, daten) {
const regeln = { raster: 15, vorlaufMin: 120, horizontTage: 60, absageBisStd: 24, ...daten.regeln }
return {
key,
name,
beschreibung,
ressourcen: daten.ressourcen,
leistungen: daten.leistungen,
buchungsart: daten.buchungsart || 'sofort',
regeln,
gastFelder: { telefon: 'optional', nachricht: 'aus', zusatz: [], ...daten.gastFelder },
recht: { gaesteSind: 'verbraucher', freizeit: false, ...daten.recht },
storno: stornoText(regeln.absageBisStd, STORNO_PROZENT),
}
}
function einfrieren(wert) {
if (wert && typeof wert === 'object' && !Object.isFrozen(wert)) {
Object.freeze(wert)
Object.values(wert).forEach(einfrieren)
}
return wert
}
export const VORLAGEN = einfrieren({
salon: vorlage('salon', 'Friseur und Salon', 'Haarschnitt, Färben und Bartpflege mit Terminen je Person; Preise als „ab“-Preise zum Anpassen.', {
ressourcen: [person('Stylist/in', wochenplan({ '2-5': [['09:00', '12:30'], ['13:30', '18:00']], '6': [['09:00', '13:00']] }))],
leistungen: [
leistung('Herrenschnitt', 30, 5, 'fest', 2800),
leistung('Damenschnitt', 45, 5, 'ab', 4500),
leistung('Kinderschnitt', 30, 5, 'fest', 1800),
leistung('Färben', 120, 10, 'ab', 7500),
],
}),
kosmetik: vorlage('kosmetik', 'Kosmetik und Nagelstudio', 'Gesichtsbehandlung, Maniküre und Pediküre mit Pause zum Aufräumen.', {
ressourcen: [person('Kosmetiker/in', wochenplan({ '1-5': MITTAG }))],
leistungen: [
leistung('Gesichtsbehandlung', 60, 10, 'ab', 6500),
leistung('Maniküre', 45, 10, 'fest', 3500),
leistung('Pediküre', 60, 10, 'fest', 4500),
leistung('Augenbrauen zupfen und färben', 20, 5, 'fest', 1800),
],
}),
beratung: vorlage('beratung', 'Beratung und Coaching', 'Kennenlerngespräch kostenlos, Beratungstermine mit Preis; für Berater, Coaches und Kanzleien.', {
ressourcen: [person('Berater/in', BUERO)],
leistungen: [
leistung('Kennenlerngespräch', 30, 5, 'keiner', 0, 'Kostenlos und unverbindlich.'),
leistung('Beratung', 60, 10, 'fest', 9000),
leistung('Folgetermin', 30, 5, 'fest', 4500),
],
gastFelder: { nachricht: 'optional' },
recht: { gaesteSind: 'gemischt' },
}),
werkstatt: vorlage('werkstatt', 'Werkstatt und Reifenservice', 'Zwei Arbeitsplätze, Termine nach Platz; Kennzeichen wird mit abgefragt.', {
ressourcen: [
platz('Platz 1', wochenplan({ '1-5': [['08:00', '12:00'], ['13:00', '17:00']], '6': [['08:00', '12:00']] })),
platz('Platz 2', wochenplan({ '1-5': [['08:00', '12:00'], ['13:00', '17:00']], '6': [['08:00', '12:00']] })),
],
leistungen: [
leistung('Reifenwechsel', 45, 15, 'ab', 4900),
leistung('Ölwechsel', 45, 15, 'ab', 7900),
leistung('Inspektion', 120, 15, 'ab', 15000),
leistung('Bremsencheck', 30, 5, 'fest', 2900),
],
regeln: { raster: 30, vorlaufMin: 240, horizontTage: 90 },
gastFelder: { telefon: 'pflicht', zusatz: [{ key: 'kennzeichen', label: 'Kennzeichen', typ: 'text', pflicht: true }] },
recht: { gaesteSind: 'gemischt' },
}),
handwerk: vorlage('handwerk', 'Handwerk (Besichtigung vor Ort)', 'Besichtigung und Aufmaß als Anfrage; du bestätigst den Termin und planst die Anfahrt.', {
ressourcen: [person('Team', wochenplan({ '1-5': [['08:00', '12:00'], ['13:00', '16:00']] }))],
leistungen: [
leistung('Besichtigung vor Ort', 60, 30, 'keiner', 0, 'Wir schauen uns die Aufgabe vor Ort an.'),
leistung('Aufmaß', 90, 30, 'keiner', 0),
leistung('Telefonische Beratung', 30, 0, 'keiner', 0),
],
buchungsart: 'anfrage',
regeln: { raster: 30, vorlaufMin: 1440, horizontTage: 90 },
gastFelder: { telefon: 'pflicht', nachricht: 'pflicht' },
recht: { gaesteSind: 'gemischt' },
}),
tattoo: vorlage('tattoo', 'Tattoo und Piercing', 'Beratung und Sessions als Anfrage; Motiv, Größe und Stelle schreibt der Gast dazu.', {
ressourcen: [person('Tätowierer/in', wochenplan({ '2-6': [['11:00', '18:00']] }))],
leistungen: [
leistung('Beratungsgespräch', 30, 10, 'keiner', 0),
leistung('Kleines Tattoo', 120, 30, 'ab', 8000),
leistung('Tattoo-Session', 240, 30, 'ab', 25000),
],
buchungsart: 'anfrage',
regeln: { raster: 30, vorlaufMin: 1440, horizontTage: 120, absageBisStd: 48 },
gastFelder: { telefon: 'pflicht', nachricht: 'pflicht' },
}),
fotograf: vorlage('fotograf', 'Fotograf', 'Kennenlerngespräch und Shootings als Anfrage, mit längerem Vorlauf.', {
ressourcen: [person('Fotograf/in', wochenplan({ '1-5': [['10:00', '18:00']], '6': [['10:00', '14:00']] }))],
leistungen: [
leistung('Kennenlerngespräch', 30, 10, 'keiner', 0),
leistung('Porträt-Shooting', 90, 30, 'ab', 15000),
leistung('Familien-Shooting', 120, 30, 'ab', 25000),
leistung('Business-Porträt', 60, 15, 'ab', 12000),
],
buchungsart: 'anfrage',
regeln: { raster: 30, vorlaufMin: 2880, horizontTage: 120, absageBisStd: 48 },
gastFelder: { nachricht: 'optional' },
}),
hundesalon: vorlage('hundesalon', 'Hundesalon', 'Pflege nach Größe des Hundes; Name und Rasse werden mit abgefragt.', {
ressourcen: [person('Hundefriseur/in', wochenplan({ '2-5': MITTAG, '6': [['09:00', '13:00']] }))],
leistungen: [
leistung('Kleiner Hund', 60, 10, 'ab', 3500),
leistung('Mittelgroßer Hund', 90, 10, 'ab', 5500),
leistung('Großer Hund', 120, 15, 'ab', 7500),
leistung('Krallen schneiden', 15, 5, 'fest', 1000),
],
regeln: { vorlaufMin: 240 },
gastFelder: { zusatz: [{ key: 'hund', label: 'Name und Rasse des Hundes', typ: 'text', pflicht: true }] },
}),
sonstiges: vorlage('sonstiges', 'Sonstiges', 'Neutrale Vorlage mit zwei Terminlängen, die du frei umbenennst.', {
ressourcen: [person('Team', BUERO)],
leistungen: [
leistung('Termin (30 Minuten)', 30, 0, 'keiner', 0),
leistung('Termin (60 Minuten)', 60, 0, 'keiner', 0),
],
}),
})
/** [{ key, name, beschreibung }] in fester Reihenfolge, fuer die Auswahl beim ersten Oeffnen. */
export function vorlagenListe() {
return Object.values(VORLAGEN).map(({ key, name, beschreibung }) => ({ key, name, beschreibung }))
}
/**
* Einstellung (Entwurf) aus einer Vorlage + den Betriebsangaben (name, email, telefon, adresse, kleinunternehmer,
* impressumUrl, datenschutzUrl, agbUrl, websiteUrl, optional bundesland). Neue IDs und Feed-Token bei jedem Aufruf.
* Telefon und Anschrift duerfen fehlen (normalisiereEinstellung mit entwurf); fehlendePflichtangaben(einstellung) nennt,
* was vor dem oeffentlichen Betrieb noch gebraucht wird. Unbekannte Vorlage oder unbrauchbare Betriebsangaben (kein
* Name/keine E-Mail) werfen; err.fehler enthaelt dann die Feldfehler.
*/
export function ausVorlage(key, betrieb, now) {
if (typeof key !== 'string' || !Object.hasOwn(VORLAGEN, key)) throw new Error(`Unbekannte Vorlage: ${String(key).slice(0, 40)}`)
const v = VORLAGEN[key]
const b = betrieb || {}
const ressourcen = v.ressourcen.map((r) => ({ id: neueId('r'), name: r.name, typ: r.typ, aktiv: true, plan: structuredClone(r.plan) }))
const eingabe = {
vorlage: key,
betrieb: {
name: b.name,
email: b.email,
telefon: b.telefon,
adresse: b.adresse,
kleinunternehmer: b.kleinunternehmer === true,
impressumUrl: b.impressumUrl,
datenschutzUrl: b.datenschutzUrl,
agbUrl: b.agbUrl,
websiteUrl: b.websiteUrl,
},
anrede: 'sie',
bundesland: b.bundesland,
ressourcen,
leistungen: v.leistungen.map((l) => ({ ...l, id: neueId('s'), buchungsart: v.buchungsart, aktiv: true, ressourcen: ressourcen.map((r) => r.id) })),
regeln: { ...v.regeln, maxOffeneJeEmail: 3, personWaehlbar: true },
gastFelder: structuredClone(v.gastFelder),
erinnerung: { anbieten: true, stundenVorher: 24 },
// Stornobedingungen gelten nur fuer Leistungen mit Preis: hat keine Leistung der Vorlage einen, gibt es keinen Vorschlag
storno: { text: v.leistungen.some(hatPreis) ? v.storno : '' },
recht: { ...v.recht },
zahlung: { modus: 'vorort', vorOrtArten: [...ZAHLARTEN] },
}
// Entwurf: ein Kunde ohne Telefon/Anschrift im Konto kann trotzdem starten; die Angaben holt das Portal nach (fehlendePflichtangaben)
const ergebnis = normalisiereEinstellung(eingabe, { now, entwurf: true })
if (ergebnis.fehler) {
const err = new Error('Die Vorlage ergibt mit diesen Betriebsangaben keine gültige Einstellung.')
err.fehler = ergebnis.fehler
throw err
}
return ergebnis.einstellung
}