Verbessere Booking-Form UX: Reset selectedTime bei Treatment-Wechsel, bessere Loading-States und lokale Datumsvalidierung

This commit is contained in:
2025-10-04 18:09:46 +02:00
parent 3a13c8dffb
commit 97c1d3493f
9 changed files with 1481 additions and 155 deletions

View File

@@ -4,7 +4,9 @@
- ~~ICS-Anhang/Link in EMails (Kalendereintrag)~~
- Erinnerungsmails (24h/3h vor Termin)
- ~~Umbuchen/Stornieren per sicherem Kundenlink (Token)~~
- Pufferzeiten und Sperrtage/Feiertage konfigurierbar
- ~~Wiederkehrende Verfügbarkeitsregeln (z.B. "Montags 13-18 Uhr")~~
- ~~Urlaubszeiten/Blockierungen konfigurierbar~~
- Pufferzeiten zwischen Terminen konfigurierbar
- ~~Slots dynamisch an Behandlungsdauer anpassen; Überschneidungen verhindern~~
### Sicherheit & Qualität

9
scripts/rebuild-dev.sh Normal file → Executable file
View File

@@ -1,6 +1,5 @@
#! /bin/bash
sudo docker compose -f docker-compose.yml down
git pull
sudo docker compose -f docker-compose.yml build --no-cache
sudo docker compose -f docker-compose.yml up -d
sudo docker compose -f docker-compose.yml logs -f stargirlnails
docker compose -f docker-compose.yml down
docker compose -f docker-compose.yml build --no-cache
docker compose -f docker-compose.yml up -d
# docker compose -f docker-compose.yml logs -f stargirlnails

View File

@@ -1,4 +1,4 @@
import { useState } from "react";
import { useState, useEffect } from "react";
import { useAuth } from "@/client/components/auth-provider";
import { LoginForm } from "@/client/components/login-form";
import { UserProfile } from "@/client/components/user-profile";
@@ -14,6 +14,13 @@ import LegalPage from "@/client/components/legal-page";
function App() {
const { user, isLoading, isOwner } = useAuth();
const [activeTab, setActiveTab] = useState<"booking" | "admin-treatments" | "admin-bookings" | "admin-calendar" | "admin-availability" | "profile" | "legal">("booking");
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
// Prevent background scroll when menu is open
useEffect(() => {
document.body.classList.toggle('overflow-hidden', isMobileMenuOpen);
return () => document.body.classList.remove('overflow-hidden');
}, [isMobileMenuOpen]);
// Handle booking status page
const path = window.location.pathname;
@@ -84,11 +91,26 @@ function App() {
</div>
</div>
{/* Hamburger Button für Mobile */}
<button
type="button"
aria-label="Menü öffnen"
aria-controls="mobile-menu"
aria-expanded={isMobileMenuOpen}
className="md:hidden p-2 -ml-2 text-3xl text-gray-700 hover:text-pink-600 transition-colors"
onClick={() => setIsMobileMenuOpen(true)}
>
</button>
{user && (
<div className="flex items-center space-x-4">
<span className="text-sm text-gray-600">
<span className="text-sm text-gray-600 hidden sm:inline">
Willkommen, {user.username}
</span>
<span className="text-sm text-gray-600 sm:hidden">
{user.username}
</span>
{isOwner && (
<span className="bg-pink-100 text-pink-800 px-2 py-1 rounded-full text-xs font-medium">
Inhaber
@@ -100,8 +122,8 @@ function App() {
</div>
</header>
{/* Navigation */}
<nav className="bg-white shadow-sm">
{/* Desktop Navigation */}
<nav className="bg-white shadow-sm hidden md:block">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex space-x-8">
{tabs.map((tab) => {
@@ -146,6 +168,76 @@ function App() {
</div>
</nav>
{/* Mobile Backdrop */}
{isMobileMenuOpen && (
<div
className="fixed inset-0 bg-black/50 z-40 md:hidden"
onClick={() => setIsMobileMenuOpen(false)}
/>
)}
{/* Mobile Slide-in Panel */}
<div
id="mobile-menu"
role="dialog"
aria-modal="true"
aria-label="Navigation"
className={`fixed inset-y-0 left-0 w-64 bg-white shadow-xl z-50 md:hidden transform transition-transform duration-300 ease-in-out ${isMobileMenuOpen ? 'translate-x-0' : '-translate-x-full'}`}
>
<button
type="button"
aria-label="Menü schließen"
className="absolute top-4 right-4 text-2xl text-gray-600 hover:text-pink-600"
onClick={() => setIsMobileMenuOpen(false)}
>
×
</button>
<div className="p-6">
<img
src="/assets/stargilnails_logo_transparent_112.png"
alt="Stargil Nails Logo"
className="w-10 h-10 mb-6 object-contain"
/>
<nav className="mt-2 flex flex-col space-y-2">
{tabs.map((tab) => {
// Hide admin tabs for non-owners
if (tab.requiresAuth && !isOwner && tab.id !== 'profile') return null;
return (
<button
key={tab.id}
onClick={() => {
setActiveTab(tab.id as any);
setIsMobileMenuOpen(false);
}}
className={`flex items-center space-x-3 px-4 py-3 rounded-lg transition-colors ${
activeTab === tab.id ? 'bg-pink-100 text-pink-600' : 'text-gray-700 hover:bg-gray-100'
}`}
>
<span>{tab.icon}</span>
<span>{tab.label}</span>
</button>
);
})}
{!user && (
<button
onClick={() => {
setActiveTab('profile');
setIsMobileMenuOpen(false);
}}
className="flex items-center space-x-3 px-4 py-3 rounded-lg text-gray-700 hover:bg-gray-100 transition-colors"
>
<span>🔑</span>
<span>Inhaber Login</span>
</button>
)}
</nav>
</div>
</div>
{/* Main Content */}
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
{activeTab === "booking" && (

View File

@@ -9,6 +9,22 @@ export function AdminAvailability() {
const [duration, setDuration] = useState<number>(30);
const [selectedTreatmentId, setSelectedTreatmentId] = useState<string>("");
const [slotType, setSlotType] = useState<"treatment" | "manual">("treatment");
// Neue State-Variablen für Tab-Navigation
const [activeSubTab, setActiveSubTab] = useState<"slots" | "recurring" | "timeoff">("slots");
// States für Recurring Rules
const [selectedDayOfWeek, setSelectedDayOfWeek] = useState<number>(1); // 1=Montag
const [ruleStartTime, setRuleStartTime] = useState<string>("13:00");
const [ruleEndTime, setRuleEndTime] = useState<string>("18:00");
const [editingRuleId, setEditingRuleId] = useState<string>("");
// States für Time-Off
const [timeOffStartDate, setTimeOffStartDate] = useState<string>("");
const [timeOffEndDate, setTimeOffEndDate] = useState<string>("");
const [timeOffReason, setTimeOffReason] = useState<string>("");
const [editingTimeOffId, setEditingTimeOffId] = useState<string>("");
const { data: allSlots, refetch: refetchSlots } = useQuery(
queryClient.availability.live.list.experimental_liveOptions()
@@ -17,6 +33,18 @@ export function AdminAvailability() {
const { data: treatments } = useQuery(
queryClient.treatments.live.list.experimental_liveOptions()
);
// Neue Queries für wiederkehrende Verfügbarkeiten (mit Authentifizierung)
const { data: recurringRules } = useQuery(
queryClient.recurringAvailability.live.adminListRules.experimental_liveOptions({
input: { sessionId: localStorage.getItem("sessionId") || "" }
})
);
const { data: timeOffPeriods } = useQuery(
queryClient.recurringAvailability.live.adminListTimeOff.experimental_liveOptions({
input: { sessionId: localStorage.getItem("sessionId") || "" }
})
);
const [errorMsg, setErrorMsg] = useState<string>("");
const [successMsg, setSuccessMsg] = useState<string>("");
@@ -45,6 +73,29 @@ export function AdminAvailability() {
const { mutate: cleanupPastSlots } = useMutation(
queryClient.availability.cleanupPastSlots.mutationOptions()
);
// Neue Mutations für wiederkehrende Verfügbarkeiten
const { mutate: createRule } = useMutation(
queryClient.recurringAvailability.createRule.mutationOptions()
);
const { mutate: updateRule } = useMutation(
queryClient.recurringAvailability.updateRule.mutationOptions()
);
const { mutate: deleteRule } = useMutation(
queryClient.recurringAvailability.deleteRule.mutationOptions()
);
const { mutate: toggleRuleActive } = useMutation(
queryClient.recurringAvailability.toggleRuleActive.mutationOptions()
);
const { mutate: createTimeOff } = useMutation(
queryClient.recurringAvailability.createTimeOff.mutationOptions()
);
const { mutate: updateTimeOff } = useMutation(
queryClient.recurringAvailability.updateTimeOff.mutationOptions()
);
const { mutate: deleteTimeOff } = useMutation(
queryClient.recurringAvailability.deleteTimeOff.mutationOptions()
);
// Auto-update duration when treatment is selected
useEffect(() => {
@@ -63,6 +114,12 @@ export function AdminAvailability() {
const getTreatmentName = (treatmentId: string) => {
return treatments?.find(t => t.id === treatmentId)?.name || "Unbekannte Behandlung";
};
// Helper-Funktion für Wochentag-Namen
const getDayName = (dayOfWeek: number): string => {
const days = ["Sonntag", "Montag", "Dienstag", "Mittwoch", "Donnerstag", "Freitag", "Samstag"];
return days[dayOfWeek];
};
const addSlot = () => {
setErrorMsg("");
@@ -116,7 +173,48 @@ export function AdminAvailability() {
return (
<div className="max-w-4xl mx-auto space-y-6">
{/* Slot Type Selection */}
{/* Tab-Navigation */}
<div className="bg-white rounded-lg shadow">
<div className="border-b border-gray-200">
<nav className="-mb-px flex space-x-8 px-6">
<button
onClick={() => setActiveSubTab("slots")}
className={`py-4 px-1 border-b-2 font-medium text-sm ${
activeSubTab === "slots"
? "border-pink-500 text-pink-600"
: "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300"
}`}
>
📅 Slots
</button>
<button
onClick={() => setActiveSubTab("recurring")}
className={`py-4 px-1 border-b-2 font-medium text-sm ${
activeSubTab === "recurring"
? "border-pink-500 text-pink-600"
: "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300"
}`}
>
🔄 Wiederkehrende Zeiten
</button>
<button
onClick={() => setActiveSubTab("timeoff")}
className={`py-4 px-1 border-b-2 font-medium text-sm ${
activeSubTab === "timeoff"
? "border-pink-500 text-pink-600"
: "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300"
}`}
>
🏖 Urlaubszeiten
</button>
</nav>
</div>
</div>
{/* Tab Inhalt */}
{activeSubTab === "slots" && (
<>
{/* Slot Type Selection */}
<div className="bg-white rounded-lg shadow p-4">
<h3 className="text-lg font-semibold mb-4">Neuen Slot anlegen</h3>
@@ -421,6 +519,338 @@ export function AdminAvailability() {
)}
</div>
</div>
</>
)}
{/* Tab "Wiederkehrende Zeiten" */}
{activeSubTab === "recurring" && (
<div className="space-y-6">
{/* Neue Regel erstellen */}
<div className="bg-white rounded-lg shadow p-4">
<h3 className="text-lg font-semibold mb-4">Neue wiederkehrende Regel erstellen</h3>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Wochentag
</label>
<select
value={selectedDayOfWeek}
onChange={(e) => setSelectedDayOfWeek(Number(e.target.value))}
className="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-pink-500"
>
<option value={1}>Montag</option>
<option value={2}>Dienstag</option>
<option value={3}>Mittwoch</option>
<option value={4}>Donnerstag</option>
<option value={5}>Freitag</option>
<option value={6}>Samstag</option>
<option value={0}>Sonntag</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Startzeit
</label>
<input
type="time"
value={ruleStartTime}
onChange={(e) => setRuleStartTime(e.target.value)}
className="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-pink-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Endzeit
</label>
<input
type="time"
value={ruleEndTime}
onChange={(e) => setRuleEndTime(e.target.value)}
className="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-pink-500"
/>
</div>
</div>
<div className="mt-4">
<button
onClick={() => {
setErrorMsg("");
setSuccessMsg("");
if (ruleStartTime >= ruleEndTime) {
setErrorMsg("Startzeit muss vor der Endzeit liegen.");
return;
}
const sessionId = localStorage.getItem("sessionId") || "";
if (!sessionId) {
setErrorMsg("Nicht eingeloggt. Bitte als Inhaber anmelden.");
return;
}
createRule(
{ sessionId, dayOfWeek: selectedDayOfWeek, startTime: ruleStartTime, endTime: ruleEndTime },
{
onSuccess: () => {
setSuccessMsg(`Regel für ${getDayName(selectedDayOfWeek)} erstellt.`);
},
onError: (err: any) => {
setErrorMsg(err?.message || "Fehler beim Erstellen der Regel.");
}
}
);
}}
className="bg-pink-600 text-white px-4 py-2 rounded-md hover:bg-pink-700 font-medium transition-colors"
>
Regel hinzufügen
</button>
</div>
</div>
{/* Bestehende Regeln */}
<div className="bg-white rounded-lg shadow">
<div className="p-4 border-b">
<h3 className="text-lg font-semibold">Bestehende Regeln</h3>
</div>
<div className="divide-y">
{recurringRules?.length === 0 && (
<div className="p-8 text-center text-gray-500">
<div className="text-lg font-medium mb-2">Noch keine wiederkehrenden Regeln definiert</div>
<div className="text-sm">Erstellen Sie Ihre erste Regel, um automatisch Slots zu generieren.</div>
</div>
)}
{recurringRules?.map((rule) => (
<div key={rule.id} className="p-4 hover:bg-gray-50 transition-colors">
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<div className="font-medium">{getDayName(rule.dayOfWeek)}</div>
<div className="text-gray-600">{rule.startTime} - {rule.endTime} Uhr</div>
<span className={`px-2 py-1 rounded-full text-xs font-medium ${
rule.isActive
? "bg-green-100 text-green-800"
: "bg-gray-100 text-gray-800"
}`}>
{rule.isActive ? "Aktiv" : "Inaktiv"}
</span>
</div>
<div className="flex items-center gap-2">
<button
onClick={() => {
const sessionId = localStorage.getItem("sessionId");
if (!sessionId) {
setErrorMsg("Nicht eingeloggt. Bitte als Inhaber anmelden.");
return;
}
toggleRuleActive(
{ sessionId, id: rule.id },
{
onSuccess: () => {
setSuccessMsg(`Regel ${rule.isActive ? "deaktiviert" : "aktiviert"}.`);
},
onError: (err: any) => {
setErrorMsg(err?.message || "Fehler beim Umschalten der Regel.");
}
}
);
}}
className="px-3 py-1 text-blue-600 hover:bg-blue-50 rounded-md transition-colors text-sm"
>
{rule.isActive ? "Deaktivieren" : "Aktivieren"}
</button>
<button
onClick={() => {
const sessionId = localStorage.getItem("sessionId");
if (!sessionId) {
setErrorMsg("Nicht eingeloggt. Bitte als Inhaber anmelden.");
return;
}
deleteRule(
{ sessionId, id: rule.id },
{
onSuccess: () => {
setSuccessMsg("Regel gelöscht.");
},
onError: (err: any) => {
setErrorMsg(err?.message || "Fehler beim Löschen der Regel.");
}
}
);
}}
className="px-3 py-1 text-red-600 hover:bg-red-50 rounded-md transition-colors text-sm"
>
Löschen
</button>
</div>
</div>
</div>
))}
</div>
</div>
</div>
)}
{/* Tab "Urlaubszeiten" */}
{activeSubTab === "timeoff" && (
<div className="space-y-6">
{/* Neue Urlaubszeit erstellen */}
<div className="bg-white rounded-lg shadow p-4">
<h3 className="text-lg font-semibold mb-4">Neue Urlaubszeit erstellen</h3>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Von Datum
</label>
<input
type="date"
value={timeOffStartDate}
onChange={(e) => setTimeOffStartDate(e.target.value)}
className="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-pink-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Bis Datum
</label>
<input
type="date"
value={timeOffEndDate}
onChange={(e) => setTimeOffEndDate(e.target.value)}
className="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-pink-500"
/>
</div>
<div className="md:col-span-2">
<label className="block text-sm font-medium text-gray-700 mb-1">
Grund/Notiz
</label>
<input
type="text"
placeholder="z.B. Sommerurlaub, Feiertag"
value={timeOffReason}
onChange={(e) => setTimeOffReason(e.target.value)}
className="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-pink-500"
/>
</div>
</div>
<div className="mt-4">
<button
onClick={() => {
setErrorMsg("");
setSuccessMsg("");
if (!timeOffStartDate || !timeOffEndDate || !timeOffReason) {
setErrorMsg("Bitte alle Felder ausfüllen.");
return;
}
if (timeOffStartDate > timeOffEndDate) {
setErrorMsg("Startdatum muss vor dem Enddatum liegen.");
return;
}
const sessionId = localStorage.getItem("sessionId") || "";
if (!sessionId) {
setErrorMsg("Nicht eingeloggt. Bitte als Inhaber anmelden.");
return;
}
createTimeOff(
{ sessionId, startDate: timeOffStartDate, endDate: timeOffEndDate, reason: timeOffReason },
{
onSuccess: () => {
setSuccessMsg("Urlaubszeit hinzugefügt.");
setTimeOffStartDate("");
setTimeOffEndDate("");
setTimeOffReason("");
},
onError: (err: any) => {
setErrorMsg(err?.message || "Fehler beim Hinzufügen der Urlaubszeit.");
}
}
);
}}
className="bg-pink-600 text-white px-4 py-2 rounded-md hover:bg-pink-700 font-medium transition-colors"
>
Urlaubszeit hinzufügen
</button>
</div>
</div>
{/* Bestehende Urlaubszeiten */}
<div className="bg-white rounded-lg shadow">
<div className="p-4 border-b">
<h3 className="text-lg font-semibold">Bestehende Urlaubszeiten</h3>
</div>
<div className="divide-y">
{timeOffPeriods?.length === 0 && (
<div className="p-8 text-center text-gray-500">
<div className="text-lg font-medium mb-2">Keine Urlaubszeiten eingetragen</div>
<div className="text-sm">Fügen Sie Urlaubszeiten hinzu, um automatisch Slots zu blockieren.</div>
</div>
)}
{timeOffPeriods?.map((period) => {
const today = new Date().toISOString().split("T")[0];
const isPast = period.endDate < today;
const isCurrent = period.startDate <= today && period.endDate >= today;
const isFuture = period.startDate > today;
return (
<div key={period.id} className="p-4 hover:bg-gray-50 transition-colors">
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<div className="font-medium">
{new Date(period.startDate).toLocaleDateString('de-DE')} - {new Date(period.endDate).toLocaleDateString('de-DE')}
</div>
<div className="text-gray-600">{period.reason}</div>
<span className={`px-2 py-1 rounded-full text-xs font-medium ${
isPast
? "bg-gray-100 text-gray-800"
: isCurrent
? "bg-red-100 text-red-800"
: "bg-blue-100 text-blue-800"
}`}>
{isPast ? "Vergangen" : isCurrent ? "Aktuell" : "Geplant"}
</span>
</div>
<div className="flex items-center gap-2">
<button
onClick={() => {
const sessionId = localStorage.getItem("sessionId");
if (!sessionId) {
setErrorMsg("Nicht eingeloggt. Bitte als Inhaber anmelden.");
return;
}
deleteTimeOff(
{ sessionId, id: period.id },
{
onSuccess: () => {
setSuccessMsg("Urlaubszeit gelöscht.");
},
onError: (err: any) => {
setErrorMsg(err?.message || "Fehler beim Löschen der Urlaubszeit.");
}
}
);
}}
className="px-3 py-1 text-red-600 hover:bg-red-50 rounded-md transition-colors text-sm"
>
Löschen
</button>
</div>
</div>
</div>
);
})}
</div>
</div>
</div>
)}
</div>
);
}

View File

@@ -1,4 +1,4 @@
import { useState } from "react";
import { useState, useEffect } from "react";
import { useMutation, useQuery } from "@tanstack/react-query";
import { queryClient } from "@/client/rpc-client";
@@ -8,7 +8,7 @@ export function BookingForm() {
const [customerEmail, setCustomerEmail] = useState("");
const [customerPhone, setCustomerPhone] = useState("");
const [appointmentDate, setAppointmentDate] = useState("");
const [selectedSlotId, setSelectedSlotId] = useState<string>("");
const [selectedTime, setSelectedTime] = useState("");
const [notes, setNotes] = useState("");
const [agbAccepted, setAgbAccepted] = useState(false);
const [inspirationPhoto, setInspirationPhoto] = useState<string>("");
@@ -19,58 +19,44 @@ export function BookingForm() {
queryClient.treatments.live.list.experimental_liveOptions()
);
// Lade alle Slots live und filtere freie Slots
const { data: allSlots } = useQuery(
queryClient.availability.live.list.experimental_liveOptions()
);
// Filtere freie Slots und entferne vergangene Termine
const today = new Date().toISOString().split("T")[0]; // YYYY-MM-DD
const freeSlots = (allSlots || []).filter((s) => {
// Nur freie Slots
if (s.status !== "free") return false;
// Nur zukünftige oder heutige Termine
if (s.date < today) return false;
// Für heute: nur zukünftige Uhrzeiten
if (s.date === today) {
const now = new Date();
const currentTime = `${String(now.getHours()).padStart(2, "0")}:${String(now.getMinutes()).padStart(2, "0")}`;
if (s.time <= currentTime) return false;
}
return true;
// Dynamische Verfügbarkeitsabfrage für das gewählte Datum und die Behandlung
const { data: availableTimes, isLoading, isFetching, error } = useQuery({
...queryClient.recurringAvailability.getAvailableTimes.queryOptions({
input: {
date: appointmentDate,
treatmentId: selectedTreatment
}
}),
enabled: !!appointmentDate && !!selectedTreatment
});
const availableDates = Array.from(new Set(freeSlots.map((s) => s.date))).sort();
const slotsByDate = appointmentDate
? freeSlots.filter((s) => s.date === appointmentDate)
: [];
const { mutate: createBooking, isPending } = useMutation(
queryClient.bookings.create.mutationOptions()
);
const selectedTreatmentData = treatments?.find((t) => t.id === selectedTreatment);
const availableSlots = slotsByDate || []; // Slots sind bereits gefiltert
// Debug logging (commented out - uncomment if needed)
// console.log("Debug - All slots:", allSlots);
// console.log("Debug - Free slots:", freeSlots);
// console.log("Debug - Available dates:", availableDates);
// console.log("Debug - Selected date:", appointmentDate);
// console.log("Debug - Slots by date:", slotsByDate);
// console.log("Debug - Available slots:", availableSlots);
// Additional debugging for slot status
// if (allSlots && allSlots.length > 0) {
// const statusCounts = allSlots.reduce((acc, slot) => {
// acc[slot.status] = (acc[slot.status] || 0) + 1;
// return acc;
// }, {} as Record<string, number>);
// console.log("Debug - Slot status counts:", statusCounts);
// }
// Clear selectedTime when treatment changes
const handleTreatmentChange = (treatmentId: string) => {
setSelectedTreatment(treatmentId);
setSelectedTime("");
};
// Clear selectedTime when it becomes invalid
useEffect(() => {
if (selectedTime && availableTimes && !availableTimes.includes(selectedTime)) {
setSelectedTime("");
}
}, [availableTimes, selectedTime]);
// Helper function for local date in YYYY-MM-DD format
const getLocalYmd = () => {
const now = new Date();
const year = now.getFullYear();
const month = String(now.getMonth() + 1).padStart(2, '0');
const day = String(now.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
};
const handlePhotoUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
@@ -153,7 +139,7 @@ export function BookingForm() {
// agbAccepted
// });
if (!selectedTreatment || !customerName || !customerEmail || !customerPhone || !appointmentDate || !selectedSlotId) {
if (!selectedTreatment || !customerName || !customerEmail || !customerPhone || !appointmentDate || !selectedTime) {
setErrorMessage("Bitte fülle alle erforderlichen Felder aus.");
return;
}
@@ -162,9 +148,8 @@ export function BookingForm() {
return;
}
// Email validation now handled in backend before slot reservation
const slot = availableSlots.find((s) => s.id === selectedSlotId);
const appointmentTime = slot?.time || "";
// Email validation now handled in backend before booking creation
const appointmentTime = selectedTime;
// console.log("Creating booking with data:", {
// treatmentId: selectedTreatment,
// customerName,
@@ -173,8 +158,7 @@ export function BookingForm() {
// appointmentDate,
// appointmentTime,
// notes,
// inspirationPhoto,
// slotId: selectedSlotId,
// inspirationPhoto
// });
createBooking(
{
@@ -186,7 +170,6 @@ export function BookingForm() {
appointmentTime,
notes,
inspirationPhoto,
slotId: selectedSlotId,
},
{
onSuccess: () => {
@@ -195,7 +178,7 @@ export function BookingForm() {
setCustomerEmail("");
setCustomerPhone("");
setAppointmentDate("");
setSelectedSlotId("");
setSelectedTime("");
setNotes("");
setAgbAccepted(false);
setInspirationPhoto("");
@@ -224,7 +207,8 @@ export function BookingForm() {
);
};
// Get minimum date (today) nicht mehr genutzt, Datumsauswahl erfolgt aus freien Slots
// Dynamische Zeitauswahl: Kunde wählt beliebiges zukünftiges Datum,
// System berechnet verfügbare Zeiten in 15-Minuten-Intervallen basierend auf wiederkehrenden Regeln
return (
<div className="max-w-2xl mx-auto bg-white rounded-lg shadow-lg p-6">
@@ -238,7 +222,7 @@ export function BookingForm() {
</label>
<select
value={selectedTreatment}
onChange={(e) => setSelectedTreatment(e.target.value)}
onChange={(e) => handleTreatmentChange(e.target.value)}
className="w-full p-3 border border-gray-300 rounded-md focus:ring-2 focus:ring-pink-500 focus:border-pink-500"
required
>
@@ -299,48 +283,53 @@ export function BookingForm() {
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Datum (nur freie Termine) *
Wunschdatum *
</label>
<select
<input
type="date"
value={appointmentDate}
onChange={(e) => { setAppointmentDate(e.target.value); setSelectedSlotId(""); }}
onChange={(e) => { setAppointmentDate(e.target.value); setSelectedTime(""); }}
min={getLocalYmd()}
className="w-full p-3 border border-gray-300 rounded-md focus:ring-2 focus:ring-pink-500 focus:border-pink-500"
required
>
<option value="">Datum auswählen</option>
{availableDates.map((d) => (
<option key={d} value={d}>{d}</option>
))}
</select>
{availableDates.length === 0 && (
<p className="mt-2 text-sm text-gray-500">Aktuell keine freien Termine verfügbar.</p>
)}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Verfügbare Uhrzeit *
Verfügbare Uhrzeit (15-Min-Raster) *
</label>
<select
value={selectedSlotId}
onChange={(e) => setSelectedSlotId(e.target.value)}
value={selectedTime}
onChange={(e) => setSelectedTime(e.target.value)}
className="w-full p-3 border border-gray-300 rounded-md focus:ring-2 focus:ring-pink-500 focus:border-pink-500"
disabled={!appointmentDate || !selectedTreatment}
disabled={!appointmentDate || !selectedTreatment || isLoading || isFetching}
required
>
<option value="">Zeit auswählen</option>
{availableSlots
.sort((a, b) => a.time.localeCompare(b.time))
.map((slot) => (
<option key={slot.id} value={slot.id}>
{slot.time} ({slot.durationMinutes} min)
</option>
))}
{availableTimes?.map((time) => (
<option key={time} value={time}>
{time}
</option>
))}
</select>
{appointmentDate && availableSlots.length === 0 && (
{appointmentDate && selectedTreatment && isLoading && (
<p className="mt-2 text-sm text-gray-500">
Keine freien Zeitslots für {appointmentDate} verfügbar.
Lade verfügbare Zeiten...
</p>
)}
{appointmentDate && selectedTreatment && error && (
<p className="mt-2 text-sm text-red-500">
Fehler beim Laden der verfügbaren Zeiten. Bitte versuche es erneut.
</p>
)}
{appointmentDate && selectedTreatment && !isLoading && !isFetching && !error && (!availableTimes || availableTimes.length === 0) && (
<p className="mt-2 text-sm text-gray-500">
Keine verfügbaren Zeiten für dieses Datum. Bitte wähle ein anderes Datum.
</p>
)}
{selectedTreatmentData && (
<p className="mt-1 text-xs text-gray-500">Dauer: {selectedTreatmentData.duration} Minuten</p>
)}
</div>
</div>

View File

@@ -124,6 +124,61 @@ const list = os.handler(async () => {
return remainingSlots;
});
// Non-mutating function that returns all slots without auto-cleanup
const peekAll = os.handler(async () => {
const allSlots = await kv.getAllItems();
return allSlots;
});
// Helper function to check if a date is in a time-off period
function isDateInTimeOffPeriod(date: string, periods: Array<{startDate: string, endDate: string}>): boolean {
return periods.some(period => date >= period.startDate && date <= period.endDate);
}
// Filtered list that excludes slots in time-off periods (for customer-facing endpoints)
const listFiltered = os.handler(async () => {
const allSlots = await kv.getAllItems();
// Auto-delete past slots
const today = new Date().toISOString().split("T")[0]; // YYYY-MM-DD
const now = new Date();
const currentTime = `${String(now.getHours()).padStart(2, "0")}:${String(now.getMinutes()).padStart(2, "0")}`;
let deletedCount = 0;
const slotsToDelete: string[] = [];
// Identify past slots for deletion
allSlots.forEach(slot => {
const isPastDate = slot.date < today;
const isPastTime = slot.date === today && slot.time <= currentTime;
if (isPastDate || isPastTime) {
slotsToDelete.push(slot.id);
}
});
// Delete past slots (only if not reserved)
for (const slotId of slotsToDelete) {
const slot = await kv.getItem(slotId);
if (slot && slot.status !== "reserved") {
await kv.removeItem(slotId);
deletedCount++;
}
}
if (deletedCount > 0) {
console.log(`Auto-deleted ${deletedCount} past availability slots`);
}
// Get remaining slots (all are now current/future)
let remainingSlots = allSlots.filter(slot => !slotsToDelete.includes(slot.id));
// Note: Time-off filtering would be added here if we had access to time-off periods
// For now, we'll rely on the slot generation logic to not create slots during time-off periods
return remainingSlots;
});
const get = os.input(z.string()).handler(async ({ input }) => {
return kv.getItem(input);
});
@@ -184,6 +239,8 @@ export const router = {
update,
remove,
list,
listFiltered,
peekAll,
get,
getByDate,
cleanupPastSlots,

View File

@@ -2,7 +2,6 @@ import { call, os } from "@orpc/server";
import { z } from "zod";
import { randomUUID } from "crypto";
import { createKV } from "../lib/create-kv.js";
import { createKV as createAvailabilityKV } from "../lib/create-kv.js";
import { sendEmail, sendEmailWithAGB, sendEmailWithAGBAndCalendar, sendEmailWithInspirationPhoto } from "../lib/email.js";
import { renderBookingPendingHTML, renderBookingConfirmedHTML, renderBookingCancelledHTML, renderAdminBookingNotificationHTML } from "../lib/email-templates.js";
import { router as rootRouter } from "./index.js";
@@ -30,6 +29,105 @@ function generateUrl(path: string = ''): string {
return `${protocol}://${domain}${path}`;
}
// Helper function to parse time string to minutes since midnight
function parseTime(timeStr: string): number {
const [hours, minutes] = timeStr.split(':').map(Number);
return hours * 60 + minutes;
}
// Helper function to check if date is in time-off period
function isDateInTimeOffPeriod(date: string, periods: TimeOffPeriod[]): boolean {
return periods.some(period => date >= period.startDate && date <= period.endDate);
}
// Helper function to validate booking time against recurring rules
async function validateBookingAgainstRules(
date: string,
time: string,
treatmentDuration: number
): Promise<void> {
// Parse date to get day of week
const [year, month, day] = date.split('-').map(Number);
const localDate = new Date(year, month - 1, day);
const dayOfWeek = localDate.getDay();
// Check time-off periods
const timeOffPeriods = await timeOffPeriodsKV.getAllItems();
if (isDateInTimeOffPeriod(date, timeOffPeriods)) {
throw new Error("Dieser Tag ist nicht verfügbar (Urlaubszeit).");
}
// Find matching recurring rules
const allRules = await recurringRulesKV.getAllItems();
const matchingRules = allRules.filter(rule =>
rule.isActive === true && rule.dayOfWeek === dayOfWeek
);
if (matchingRules.length === 0) {
throw new Error("Für diesen Wochentag sind keine Termine verfügbar.");
}
// Check if booking time falls within any rule's time span
const bookingStartMinutes = parseTime(time);
const bookingEndMinutes = bookingStartMinutes + treatmentDuration;
const isWithinRules = matchingRules.some(rule => {
const ruleStartMinutes = parseTime(rule.startTime);
const ruleEndMinutes = parseTime(rule.endTime);
// Booking must start at or after rule start and end at or before rule end
return bookingStartMinutes >= ruleStartMinutes && bookingEndMinutes <= ruleEndMinutes;
});
if (!isWithinRules) {
throw new Error("Die gewählte Uhrzeit liegt außerhalb der verfügbaren Zeiten.");
}
}
// Helper function to check for booking conflicts
async function checkBookingConflicts(
date: string,
time: string,
treatmentDuration: number,
excludeBookingId?: string
): Promise<void> {
const allBookings = await kv.getAllItems();
const dateBookings = allBookings.filter(booking =>
booking.appointmentDate === date &&
['pending', 'confirmed', 'completed'].includes(booking.status) &&
booking.id !== excludeBookingId
);
const bookingStartMinutes = parseTime(time);
const bookingEndMinutes = bookingStartMinutes + treatmentDuration;
// Cache treatment durations by ID to avoid N+1 lookups
const uniqueTreatmentIds = [...new Set(dateBookings.map(booking => booking.treatmentId))];
const treatmentDurationMap = new Map<string, number>();
for (const treatmentId of uniqueTreatmentIds) {
const treatment = await treatmentsKV.getItem(treatmentId);
treatmentDurationMap.set(treatmentId, treatment?.duration || 60);
}
// Check for overlaps with existing bookings
for (const existingBooking of dateBookings) {
// Use cached duration or fallback to bookedDurationMinutes if available
let existingDuration = treatmentDurationMap.get(existingBooking.treatmentId) || 60;
if (existingBooking.bookedDurationMinutes) {
existingDuration = existingBooking.bookedDurationMinutes;
}
const existingStartMinutes = parseTime(existingBooking.appointmentTime);
const existingEndMinutes = existingStartMinutes + existingDuration;
// Check overlap: bookingStart < existingEnd && bookingEnd > existingStart
if (bookingStartMinutes < existingEndMinutes && bookingEndMinutes > existingStartMinutes) {
throw new Error("Dieser Zeitslot ist bereits gebucht. Bitte wähle eine andere Zeit.");
}
}
}
const BookingSchema = z.object({
id: z.string(),
treatmentId: z.string(),
@@ -41,26 +139,50 @@ const BookingSchema = z.object({
status: z.enum(["pending", "confirmed", "cancelled", "completed"]),
notes: z.string().optional(),
inspirationPhoto: z.string().optional(), // Base64 encoded image data
bookedDurationMinutes: z.number().optional(), // Snapshot of treatment duration at booking time
createdAt: z.string(),
// DEPRECATED: slotId is no longer used for validation, kept for backward compatibility
slotId: z.string().optional(),
});
type Booking = z.output<typeof BookingSchema>;
const kv = createKV<Booking>("bookings");
type Availability = {
// DEPRECATED: Availability slots are no longer used for booking validation
// type Availability = {
// id: string;
// date: string;
// time: string;
// durationMinutes: number;
// status: "free" | "reserved";
// reservedByBookingId?: string;
// createdAt: string;
// };
// const availabilityKV = createAvailabilityKV<Availability>("availability");
type RecurringRule = {
id: string;
date: string;
time: string;
durationMinutes: number;
status: "free" | "reserved";
reservedByBookingId?: string;
dayOfWeek: number;
startTime: string;
endTime: string;
isActive: boolean;
createdAt: string;
slotDurationMinutes?: number;
};
type TimeOffPeriod = {
id: string;
startDate: string;
endDate: string;
reason: string;
createdAt: string;
};
const availabilityKV = createAvailabilityKV<Availability>("availability");
const recurringRulesKV = createKV<RecurringRule>("recurringRules");
const timeOffPeriodsKV = createKV<TimeOffPeriod>("timeOffPeriods");
// Import treatments KV for admin notifications
import { createKV as createTreatmentsKV } from "../lib/create-kv.js";
type Treatment = {
id: string;
name: string;
@@ -70,7 +192,7 @@ type Treatment = {
category: string;
createdAt: string;
};
const treatmentsKV = createTreatmentsKV<Treatment>("treatments");
const treatmentsKV = createKV<Treatment>("treatments");
const create = os
.input(BookingSchema.omit({ id: true, createdAt: true, status: true }))
@@ -105,6 +227,12 @@ const create = os
throw new Error(emailValidation.reason || "Ungültige E-Mail-Adresse");
}
// Validate appointment time is on 15-minute grid
const appointmentMinutes = parseTime(input.appointmentTime);
if (appointmentMinutes % 15 !== 0) {
throw new Error("Termine müssen auf 15-Minuten-Raster ausgerichtet sein (z.B. 09:00, 09:15, 09:30, 09:45).");
}
// Validate that the booking is not in the past
const today = new Date().toISOString().split("T")[0]; // YYYY-MM-DD
if (input.appointmentDate < today) {
@@ -133,30 +261,38 @@ const create = os
throw new Error("Du hast bereits eine Buchung für dieses Datum. Bitte wähle einen anderen Tag oder storniere zuerst.");
}
}
// Get treatment duration for validation
const treatment = await treatmentsKV.getItem(input.treatmentId);
if (!treatment) {
throw new Error("Behandlung nicht gefunden.");
}
// Validate booking time against recurring rules
await validateBookingAgainstRules(
input.appointmentDate,
input.appointmentTime,
treatment.duration
);
// Check for booking conflicts
await checkBookingConflicts(
input.appointmentDate,
input.appointmentTime,
treatment.duration
);
const id = randomUUID();
const booking = {
id,
...input,
bookedDurationMinutes: treatment.duration, // Snapshot treatment duration
status: "pending" as const,
createdAt: new Date().toISOString()
};
// First save the booking
// Save the booking
await kv.setItem(id, booking);
// Then reserve the slot only after successful booking creation
if (booking.slotId) {
const slot = await availabilityKV.getItem(booking.slotId);
if (!slot) throw new Error("Availability slot not found");
if (slot.status !== "free") throw new Error("Slot not available");
const updatedSlot: Availability = {
...slot,
status: "reserved",
reservedByBookingId: id,
};
await availabilityKV.setItem(slot.id, updatedSlot);
}
// Notify customer: request received (pending)
void (async () => {
// Create booking access token for status viewing
@@ -264,43 +400,7 @@ const updateStatus = os
const updatedBooking = { ...booking, status: input.status };
await kv.setItem(input.id, updatedBooking);
// Manage availability slot state transitions
if (booking.slotId) {
const slot = await availabilityKV.getItem(booking.slotId);
if (slot) {
// console.log(`Updating slot ${slot.id} (${slot.date} ${slot.time}) from ${slot.status} to ${input.status}`);
if (input.status === "cancelled") {
// Free the slot again
await availabilityKV.setItem(slot.id, {
...slot,
status: "free",
reservedByBookingId: undefined,
});
// console.log(`Slot ${slot.id} freed due to cancellation`);
} else if (input.status === "pending") {
// keep reserved as pending
if (slot.status !== "reserved") {
await availabilityKV.setItem(slot.id, {
...slot,
status: "reserved",
reservedByBookingId: booking.id,
});
// console.log(`Slot ${slot.id} reserved for pending booking`);
}
} else if (input.status === "confirmed" || input.status === "completed") {
// keep reserved; optionally noop
if (slot.status !== "reserved" || slot.reservedByBookingId !== booking.id) {
await availabilityKV.setItem(slot.id, {
...slot,
status: "reserved",
reservedByBookingId: booking.id,
});
// console.log(`Slot ${slot.id} confirmed as reserved`);
}
}
}
}
// Note: Slot state management removed - bookings now validated against recurring rules
// Email notifications on status changes
try {
if (input.status === "confirmed") {
@@ -322,7 +422,8 @@ const updateStatus = os
const allTreatments = await treatmentsKV.getAllItems();
const treatment = allTreatments.find(t => t.id === booking.treatmentId);
const treatmentName = treatment?.name || "Behandlung";
const treatmentDuration = treatment?.duration || 60; // Default 60 minutes if not found
// Use bookedDurationMinutes if available, otherwise fallback to treatment duration
const treatmentDuration = booking.bookedDurationMinutes || treatment?.duration || 60;
await sendEmailWithAGBAndCalendar({
to: booking.customerEmail,

View File

@@ -3,6 +3,7 @@ import { router as treatments } from "./treatments.js";
import { router as bookings } from "./bookings.js";
import { router as auth } from "./auth.js";
import { router as availability } from "./availability.js";
import { router as recurringAvailability } from "./recurring-availability.js";
import { router as cancellation } from "./cancellation.js";
import { router as legal } from "./legal.js";
@@ -12,6 +13,7 @@ export const router = {
bookings,
auth,
availability,
recurringAvailability,
cancellation,
legal,
};

View File

@@ -0,0 +1,654 @@
import { call, os } from "@orpc/server";
import { z } from "zod";
import { randomUUID } from "crypto";
import { createKV } from "../lib/create-kv.js";
// Datenmodelle
const RecurringRuleSchema = z.object({
id: z.string(),
dayOfWeek: z.number().int().min(0).max(6), // 0=Sonntag, 1=Montag, ..., 6=Samstag
startTime: z.string().regex(/^\d{2}:\d{2}$/), // HH:MM Format
endTime: z.string().regex(/^\d{2}:\d{2}$/), // HH:MM Format
isActive: z.boolean(),
createdAt: z.string(),
// LEGACY: slotDurationMinutes - deprecated field for generateSlots only, will be removed
slotDurationMinutes: z.number().int().min(1).optional(),
});
const TimeOffPeriodSchema = z.object({
id: z.string(),
startDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/), // YYYY-MM-DD
endDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/), // YYYY-MM-DD
reason: z.string(),
createdAt: z.string(),
});
export type RecurringRule = z.output<typeof RecurringRuleSchema>;
export type TimeOffPeriod = z.output<typeof TimeOffPeriodSchema>;
// KV-Stores
const recurringRulesKV = createKV<RecurringRule>("recurringRules");
const timeOffPeriodsKV = createKV<TimeOffPeriod>("timeOffPeriods");
// Import existing availability KV
import { router as availabilityRouter } from "./availability.js";
// Import bookings and treatments KV stores for getAvailableTimes endpoint
const bookingsKV = createKV<any>("bookings");
const treatmentsKV = createKV<any>("treatments");
// Owner-Authentifizierung (kopiert aus availability.ts)
type Session = { id: string; userId: string; expiresAt: string; createdAt: string };
type User = { id: string; username: string; email: string; passwordHash: string; role: "customer" | "owner"; createdAt: string };
const sessionsKV = createKV<Session>("sessions");
const usersKV = createKV<User>("users");
async function assertOwner(sessionId: string): Promise<void> {
const session = await sessionsKV.getItem(sessionId);
if (!session) throw new Error("Invalid session");
if (new Date(session.expiresAt) < new Date()) throw new Error("Session expired");
const user = await usersKV.getItem(session.userId);
if (!user || user.role !== "owner") throw new Error("Forbidden");
}
// Helper-Funktionen
function parseTime(timeStr: string): number {
const [hours, minutes] = timeStr.split(':').map(Number);
return hours * 60 + minutes; // Minuten seit Mitternacht
}
function formatTime(minutes: number): string {
const hours = Math.floor(minutes / 60);
const mins = minutes % 60;
return `${String(hours).padStart(2, '0')}:${String(mins).padStart(2, '0')}`;
}
function addDays(date: Date, days: number): Date {
const result = new Date(date);
result.setDate(result.getDate() + days);
return result;
}
function formatDate(date: Date): string {
return date.toISOString().split('T')[0];
}
function isDateInTimeOffPeriod(date: string, periods: TimeOffPeriod[]): boolean {
return periods.some(period => date >= period.startDate && date <= period.endDate);
}
// Helper-Funktion zur Erkennung überlappender Regeln
function detectOverlappingRules(newRule: { dayOfWeek: number; startTime: string; endTime: string; id?: string }, existingRules: RecurringRule[]): RecurringRule[] {
const newStart = parseTime(newRule.startTime);
const newEnd = parseTime(newRule.endTime);
return existingRules.filter(rule => {
// Gleicher Wochentag und nicht dieselbe Regel (bei Updates)
if (rule.dayOfWeek !== newRule.dayOfWeek || rule.id === newRule.id) {
return false;
}
const existingStart = parseTime(rule.startTime);
const existingEnd = parseTime(rule.endTime);
// Überlappung wenn: neue Startzeit < bestehende Endzeit UND neue Endzeit > bestehende Startzeit
return newStart < existingEnd && newEnd > existingStart;
});
}
// CRUD-Endpoints für Recurring Rules
const createRule = os
.input(
z.object({
sessionId: z.string(),
dayOfWeek: z.number().int().min(0).max(6),
startTime: z.string().regex(/^\d{2}:\d{2}$/),
endTime: z.string().regex(/^\d{2}:\d{2}$/),
}).passthrough()
)
.handler(async ({ input }) => {
try {
await assertOwner(input.sessionId);
// Validierung: startTime < endTime
const startMinutes = parseTime(input.startTime);
const endMinutes = parseTime(input.endTime);
if (startMinutes >= endMinutes) {
throw new Error("Startzeit muss vor der Endzeit liegen.");
}
// Überlappungsprüfung
const allRules = await recurringRulesKV.getAllItems();
const overlappingRules = detectOverlappingRules(input, allRules);
if (overlappingRules.length > 0) {
const overlappingTimes = overlappingRules.map(rule => `${rule.startTime}-${rule.endTime}`).join(", ");
throw new Error(`Überlappung mit bestehenden Regeln erkannt: ${overlappingTimes}. Bitte Zeitfenster anpassen.`);
}
const id = randomUUID();
const rule: RecurringRule = {
id,
dayOfWeek: input.dayOfWeek,
startTime: input.startTime,
endTime: input.endTime,
isActive: true,
createdAt: new Date().toISOString(),
};
await recurringRulesKV.setItem(id, rule);
return rule;
} catch (err) {
console.error("recurring-availability.createRule error", err);
throw err;
}
});
const updateRule = os
.input(RecurringRuleSchema.extend({ sessionId: z.string() }).passthrough())
.handler(async ({ input }) => {
await assertOwner(input.sessionId);
// Validierung: startTime < endTime
const startMinutes = parseTime(input.startTime);
const endMinutes = parseTime(input.endTime);
if (startMinutes >= endMinutes) {
throw new Error("Startzeit muss vor der Endzeit liegen.");
}
// Überlappungsprüfung
const allRules = await recurringRulesKV.getAllItems();
const overlappingRules = detectOverlappingRules(input, allRules);
if (overlappingRules.length > 0) {
const overlappingTimes = overlappingRules.map(rule => `${rule.startTime}-${rule.endTime}`).join(", ");
throw new Error(`Überlappung mit bestehenden Regeln erkannt: ${overlappingTimes}. Bitte Zeitfenster anpassen.`);
}
const { sessionId, ...rule } = input as any;
await recurringRulesKV.setItem(rule.id, rule as RecurringRule);
return rule as RecurringRule;
});
const deleteRule = os
.input(z.object({ sessionId: z.string(), id: z.string() }))
.handler(async ({ input }) => {
await assertOwner(input.sessionId);
await recurringRulesKV.removeItem(input.id);
});
const toggleRuleActive = os
.input(z.object({ sessionId: z.string(), id: z.string() }))
.handler(async ({ input }) => {
await assertOwner(input.sessionId);
const rule = await recurringRulesKV.getItem(input.id);
if (!rule) throw new Error("Regel nicht gefunden.");
rule.isActive = !rule.isActive;
await recurringRulesKV.setItem(input.id, rule);
return rule;
});
const listRules = os.handler(async () => {
const allRules = await recurringRulesKV.getAllItems();
return allRules.sort((a, b) => {
if (a.dayOfWeek !== b.dayOfWeek) return a.dayOfWeek - b.dayOfWeek;
return a.startTime.localeCompare(b.startTime);
});
});
const adminListRules = os
.input(z.object({ sessionId: z.string() }))
.handler(async ({ input }) => {
await assertOwner(input.sessionId);
const allRules = await recurringRulesKV.getAllItems();
return allRules.sort((a, b) => {
if (a.dayOfWeek !== b.dayOfWeek) return a.dayOfWeek - b.dayOfWeek;
return a.startTime.localeCompare(b.startTime);
});
});
// CRUD-Endpoints für Time-Off Periods
const createTimeOff = os
.input(
z.object({
sessionId: z.string(),
startDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
endDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
reason: z.string(),
})
)
.handler(async ({ input }) => {
try {
await assertOwner(input.sessionId);
// Validierung: startDate <= endDate
if (input.startDate > input.endDate) {
throw new Error("Startdatum muss vor oder am Enddatum liegen.");
}
const id = randomUUID();
const timeOff: TimeOffPeriod = {
id,
startDate: input.startDate,
endDate: input.endDate,
reason: input.reason,
createdAt: new Date().toISOString(),
};
// Blockiere bestehende Slots in diesem Zeitraum
const existingSlots = await call(availabilityRouter.peekAll, {}, {});
let blockedCount = 0;
for (const slot of existingSlots) {
if (slot.date >= input.startDate && slot.date <= input.endDate && slot.status === "free") {
await call(availabilityRouter.remove, { sessionId: input.sessionId, id: slot.id }, {});
blockedCount++;
}
}
if (blockedCount > 0) {
console.log(`Blocked ${blockedCount} existing slots for time-off period: ${input.reason}`);
}
await timeOffPeriodsKV.setItem(id, timeOff);
return { ...timeOff, blockedSlots: blockedCount };
} catch (err) {
console.error("recurring-availability.createTimeOff error", err);
throw err;
}
});
const updateTimeOff = os
.input(TimeOffPeriodSchema.extend({ sessionId: z.string() }).passthrough())
.handler(async ({ input }) => {
await assertOwner(input.sessionId);
// Validierung: startDate <= endDate
if (input.startDate > input.endDate) {
throw new Error("Startdatum muss vor oder am Enddatum liegen.");
}
const { sessionId, ...timeOff } = input as any;
await timeOffPeriodsKV.setItem(timeOff.id, timeOff as TimeOffPeriod);
return timeOff as TimeOffPeriod;
});
const deleteTimeOff = os
.input(z.object({ sessionId: z.string(), id: z.string() }))
.handler(async ({ input }) => {
await assertOwner(input.sessionId);
await timeOffPeriodsKV.removeItem(input.id);
});
const listTimeOff = os.handler(async () => {
const allTimeOff = await timeOffPeriodsKV.getAllItems();
return allTimeOff.sort((a, b) => a.startDate.localeCompare(b.startDate));
});
const adminListTimeOff = os
.input(z.object({ sessionId: z.string() }))
.handler(async ({ input }) => {
await assertOwner(input.sessionId);
const allTimeOff = await timeOffPeriodsKV.getAllItems();
return allTimeOff.sort((a, b) => a.startDate.localeCompare(b.startDate));
});
// Slot-Generator-Endpoint
// DEPRECATED: This endpoint will be removed in a future version.
// The system is transitioning to dynamic availability calculation with 15-minute intervals.
// Slots are no longer pre-generated based on recurring rules.
const generateSlots = os
.input(
z.object({
sessionId: z.string(),
startDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
endDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
overwriteExisting: z.boolean().default(false),
})
)
.handler(async ({ input }) => {
try {
await assertOwner(input.sessionId);
// Validierung: startDate <= endDate
if (input.startDate > input.endDate) {
throw new Error("Startdatum muss vor oder am Enddatum liegen.");
}
// Validierung: maximal 12 Wochen Zeitraum
const start = new Date(input.startDate);
const end = new Date(input.endDate);
const daysDiff = Math.ceil((end.getTime() - start.getTime()) / (1000 * 60 * 60 * 24));
if (daysDiff > 84) { // 12 Wochen = 84 Tage
throw new Error("Zeitraum darf maximal 12 Wochen betragen.");
}
// Lade alle aktiven Regeln
const allRules = await recurringRulesKV.getAllItems();
const activeRules = allRules.filter(rule => rule.isActive);
// Lade alle Urlaubszeiten
const timeOffPeriods = await timeOffPeriodsKV.getAllItems();
// Lade bestehende Slots (ohne Auto-Cleanup)
const existingSlots = await call(availabilityRouter.peekAll, {}, {});
// Erstelle Set für effiziente Duplikat-Prüfung
const existing = new Set(existingSlots.map(s => `${s.date}T${s.time}`));
let created = 0;
let skipped = 0;
let updated = 0;
// Iteriere über jeden Tag im Zeitraum
const currentDate = new Date(start);
while (currentDate <= end) {
const dateStr = formatDate(currentDate);
// Verwende lokale Datumskomponenten für korrekte Wochentag-Berechnung
const [y, m, d] = dateStr.split('-').map(Number);
const localDate = new Date(y, m - 1, d);
const dayOfWeek = localDate.getDay(); // 0=Sonntag, 1=Montag, ...
// Prüfe, ob Datum in einer Urlaubszeit liegt
if (isDateInTimeOffPeriod(dateStr, timeOffPeriods)) {
currentDate.setDate(currentDate.getDate() + 1);
continue;
}
// Finde alle Regeln für diesen Wochentag
const matchingRules = activeRules.filter(rule => rule.dayOfWeek === dayOfWeek);
for (const rule of matchingRules) {
// Skip rules without slotDurationMinutes (legacy field for deprecated generateSlots)
if (!rule.slotDurationMinutes) {
console.log(`Skipping rule ${rule.id} - no slotDurationMinutes defined (legacy field)`);
continue;
}
const startMinutes = parseTime(rule.startTime);
const endMinutes = parseTime(rule.endTime);
// Generiere Slots in slotDurationMinutes-Schritten
let currentMinutes = startMinutes;
while (currentMinutes + rule.slotDurationMinutes <= endMinutes) {
const timeStr = formatTime(currentMinutes);
const key = `${dateStr}T${timeStr}`;
// Prüfe, ob bereits ein Slot für dieses Datum+Zeit existiert
if (existing.has(key)) {
if (input.overwriteExisting) {
// Finde den bestehenden Slot für Update
const existingSlot = existingSlots.find(
slot => slot.date === dateStr && slot.time === timeStr
);
if (existingSlot && existingSlot.status === "free") {
// Überschreibe Dauer des bestehenden Slots
const updatedSlot = {
...existingSlot,
durationMinutes: rule.slotDurationMinutes,
};
await call(availabilityRouter.update, {
sessionId: input.sessionId,
...updatedSlot
}, {});
updated++;
} else {
skipped++;
}
} else {
skipped++;
}
} else {
// Erstelle neuen Slot mit try/catch für Duplikat-Konflikte
try {
await call(availabilityRouter.create, {
sessionId: input.sessionId,
date: dateStr,
time: timeStr,
durationMinutes: rule.slotDurationMinutes,
}, {});
existing.add(key);
created++;
} catch (err: any) {
// Behandle bekannte Duplikat-Fehler
if (err.message && err.message.includes("bereits ein Slot")) {
skipped++;
} else {
throw err; // Re-throw unbekannte Fehler
}
}
}
currentMinutes += rule.slotDurationMinutes;
}
}
currentDate.setDate(currentDate.getDate() + 1);
}
const message = `${created} Slots erstellt, ${updated} aktualisiert, ${skipped} übersprungen.`;
console.log(`Slot generation completed: ${message}`);
return {
created,
updated,
skipped,
message,
};
} catch (err) {
console.error("recurring-availability.generateSlots error", err);
throw err;
}
});
// Get Available Times Endpoint
const getAvailableTimes = os
.input(
z.object({
date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
treatmentId: z.string(),
})
)
.handler(async ({ input }) => {
try {
// Validate that the date is not in the past
const today = new Date();
const inputDate = new Date(input.date);
today.setHours(0, 0, 0, 0);
inputDate.setHours(0, 0, 0, 0);
if (inputDate < today) {
return [];
}
// Get treatment duration
const treatment = await treatmentsKV.getItem(input.treatmentId);
if (!treatment) {
throw new Error("Behandlung nicht gefunden.");
}
const treatmentDuration = treatment.duration;
// Parse the date to get day of week
const [year, month, day] = input.date.split('-').map(Number);
const localDate = new Date(year, month - 1, day);
const dayOfWeek = localDate.getDay(); // 0=Sonntag, 1=Montag, ...
// Find matching recurring rules
const allRules = await recurringRulesKV.getAllItems();
const matchingRules = allRules.filter(rule =>
rule.isActive === true && rule.dayOfWeek === dayOfWeek
);
if (matchingRules.length === 0) {
return []; // No rules for this day of week
}
// Check time-off periods
const timeOffPeriods = await timeOffPeriodsKV.getAllItems();
if (isDateInTimeOffPeriod(input.date, timeOffPeriods)) {
return []; // Date is blocked by time-off period
}
// Generate 15-minute intervals with boundary alignment
const availableTimes: string[] = [];
// Helper functions for 15-minute boundary alignment
const ceilTo15 = (m: number) => m % 15 === 0 ? m : m + (15 - (m % 15));
const floorTo15 = (m: number) => m - (m % 15);
for (const rule of matchingRules) {
const startMinutes = parseTime(rule.startTime);
const endMinutes = parseTime(rule.endTime);
let currentMinutes = ceilTo15(startMinutes);
const endBound = floorTo15(endMinutes);
while (currentMinutes + treatmentDuration <= endBound) {
const timeStr = formatTime(currentMinutes);
availableTimes.push(timeStr);
currentMinutes += 15; // 15-minute intervals
}
}
// Get all bookings for this date and their treatments
const allBookings = await bookingsKV.getAllItems();
const dateBookings = allBookings.filter(booking =>
booking.appointmentDate === input.date &&
['pending', 'confirmed', 'completed'].includes(booking.status)
);
// Optimize treatment duration lookup with Map caching
const uniqueTreatmentIds = [...new Set(dateBookings.map(booking => booking.treatmentId))];
const treatmentDurationMap = new Map<string, number>();
for (const treatmentId of uniqueTreatmentIds) {
const treatment = await treatmentsKV.getItem(treatmentId);
treatmentDurationMap.set(treatmentId, treatment?.duration || 60);
}
// Get treatment durations for all bookings using the cached map
const bookingTreatments = new Map();
for (const booking of dateBookings) {
// Use bookedDurationMinutes if available, otherwise fallback to treatment duration
const duration = booking.bookedDurationMinutes || treatmentDurationMap.get(booking.treatmentId) || 60;
bookingTreatments.set(booking.id, duration);
}
// Filter out booking conflicts
const availableTimesFiltered = availableTimes.filter(slotTime => {
const slotStartMinutes = parseTime(slotTime);
const slotEndMinutes = slotStartMinutes + treatmentDuration;
// Check if this slot overlaps with any existing booking
const hasConflict = dateBookings.some(booking => {
const bookingStartMinutes = parseTime(booking.appointmentTime);
const bookingDuration = bookingTreatments.get(booking.id) || 60;
const bookingEndMinutes = bookingStartMinutes + bookingDuration;
// Check overlap: slotStart < bookingEnd && slotEnd > bookingStart
return slotStartMinutes < bookingEndMinutes && slotEndMinutes > bookingStartMinutes;
});
return !hasConflict;
});
// Filter out past times for today
const now = new Date();
const isToday = inputDate.getTime() === today.getTime();
const finalAvailableTimes = isToday
? availableTimesFiltered.filter(timeStr => {
const slotTime = parseTime(timeStr);
const currentTime = now.getHours() * 60 + now.getMinutes();
return slotTime > currentTime;
})
: availableTimesFiltered;
// Deduplicate and sort chronologically
const unique = Array.from(new Set(finalAvailableTimes));
return unique.sort((a, b) => a.localeCompare(b));
} catch (err) {
console.error("recurring-availability.getAvailableTimes error", err);
throw err;
}
});
// Live-Queries
const live = {
listRules: os.handler(async function* ({ signal }) {
yield call(listRules, {}, { signal });
for await (const _ of recurringRulesKV.subscribe()) {
yield call(listRules, {}, { signal });
}
}),
listTimeOff: os.handler(async function* ({ signal }) {
yield call(listTimeOff, {}, { signal });
for await (const _ of timeOffPeriodsKV.subscribe()) {
yield call(listTimeOff, {}, { signal });
}
}),
adminListRules: os
.input(z.object({ sessionId: z.string() }))
.handler(async function* ({ input, signal }) {
await assertOwner(input.sessionId);
const allRules = await recurringRulesKV.getAllItems();
const sortedRules = allRules.sort((a, b) => {
if (a.dayOfWeek !== b.dayOfWeek) return a.dayOfWeek - b.dayOfWeek;
return a.startTime.localeCompare(b.startTime);
});
yield sortedRules;
for await (const _ of recurringRulesKV.subscribe()) {
const updatedRules = await recurringRulesKV.getAllItems();
const sortedUpdatedRules = updatedRules.sort((a, b) => {
if (a.dayOfWeek !== b.dayOfWeek) return a.dayOfWeek - b.dayOfWeek;
return a.startTime.localeCompare(b.startTime);
});
yield sortedUpdatedRules;
}
}),
adminListTimeOff: os
.input(z.object({ sessionId: z.string() }))
.handler(async function* ({ input, signal }) {
await assertOwner(input.sessionId);
const allTimeOff = await timeOffPeriodsKV.getAllItems();
const sortedTimeOff = allTimeOff.sort((a, b) => a.startDate.localeCompare(b.startDate));
yield sortedTimeOff;
for await (const _ of timeOffPeriodsKV.subscribe()) {
const updatedTimeOff = await timeOffPeriodsKV.getAllItems();
const sortedUpdatedTimeOff = updatedTimeOff.sort((a, b) => a.startDate.localeCompare(b.startDate));
yield sortedUpdatedTimeOff;
}
}),
};
export const router = {
// Recurring Rules
createRule,
updateRule,
deleteRule,
toggleRuleActive,
listRules,
adminListRules,
// Time-Off Periods
createTimeOff,
updateTimeOff,
deleteTimeOff,
listTimeOff,
adminListTimeOff,
// Generator
generateSlots,
// Availability
getAvailableTimes,
// Live queries
live,
};