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 }