223 lines
11 KiB
JavaScript
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
|
|
}
|