diff --git a/src/client/app.tsx b/src/client/app.tsx index 3fab87d..a19d4de 100644 --- a/src/client/app.tsx +++ b/src/client/app.tsx @@ -304,7 +304,7 @@ function App() { Verfügbarkeiten verwalten

- Lege freie Slots an und entferne sie bei Bedarf. + Verwalte wiederkehrende Zeiten und Urlaubszeiten.

diff --git a/src/client/components/admin-availability.tsx b/src/client/components/admin-availability.tsx index 3866121..4dbbb4d 100644 --- a/src/client/components/admin-availability.tsx +++ b/src/client/components/admin-availability.tsx @@ -4,14 +4,9 @@ import { queryClient } from "@/client/rpc-client"; export function AdminAvailability() { const today = new Date().toISOString().split("T")[0]; - const [selectedDate, setSelectedDate] = useState(today); - const [time, setTime] = useState("09:00"); - const [duration, setDuration] = useState(30); - const [selectedTreatmentId, setSelectedTreatmentId] = useState(""); - const [slotType, setSlotType] = useState<"treatment" | "manual">("treatment"); - // Neue State-Variablen für Tab-Navigation - const [activeSubTab, setActiveSubTab] = useState<"slots" | "recurring" | "timeoff">("slots"); + // Tab-Navigation (Slots entfernt) + const [activeSubTab, setActiveSubTab] = useState<"recurring" | "timeoff">("recurring"); // States für Recurring Rules const [selectedDayOfWeek, setSelectedDayOfWeek] = useState(1); // 1=Montag @@ -25,14 +20,6 @@ export function AdminAvailability() { const [timeOffReason, setTimeOffReason] = useState(""); const [editingTimeOffId, setEditingTimeOffId] = useState(""); - - const { data: allSlots, refetch: refetchSlots } = useQuery( - queryClient.availability.live.list.experimental_liveOptions() - ); - - const { data: treatments } = useQuery( - queryClient.treatments.live.list.experimental_liveOptions() - ); // Neue Queries für wiederkehrende Verfügbarkeiten (mit Authentifizierung) const { data: recurringRules } = useQuery( @@ -64,16 +51,6 @@ export function AdminAvailability() { } }, [successMsg]); - const { mutate: createSlot, isPending: isCreating } = useMutation( - queryClient.availability.create.mutationOptions() - ); - const { mutate: removeSlot } = useMutation( - queryClient.availability.remove.mutationOptions() - ); - const { mutate: cleanupPastSlots } = useMutation( - queryClient.availability.cleanupPastSlots.mutationOptions() - ); - // Neue Mutations für wiederkehrende Verfügbarkeiten const { mutate: createRule } = useMutation( queryClient.recurringAvailability.createRule.mutationOptions() @@ -97,96 +74,20 @@ export function AdminAvailability() { queryClient.recurringAvailability.deleteTimeOff.mutationOptions() ); - // Auto-update duration when treatment is selected - useEffect(() => { - if (selectedTreatmentId && treatments) { - const treatment = treatments.find(t => t.id === selectedTreatmentId); - if (treatment) { - setDuration(treatment.duration); - } - } - }, [selectedTreatmentId, treatments]); - - // Get selected treatment details - const selectedTreatment = treatments?.find(t => t.id === selectedTreatmentId); - - // Get treatment name for display - 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(""); - setSuccessMsg(""); - - // Validation based on slot type - if (slotType === "treatment" && !selectedTreatmentId) { - setErrorMsg("Bitte eine Behandlung auswählen."); - return; - } - if (!selectedDate || !time || !duration) { - setErrorMsg("Bitte Datum, Uhrzeit und Dauer angeben."); - return; - } - - const sessionId = localStorage.getItem("sessionId") || ""; - if (!sessionId) { - setErrorMsg("Nicht eingeloggt. Bitte als Inhaber anmelden."); - return; - } - - createSlot( - { sessionId, date: selectedDate, time, durationMinutes: duration }, - { - onSuccess: () => { - const slotDescription = slotType === "treatment" - ? `${getTreatmentName(selectedTreatmentId)} (${duration} Min)` - : `Manueller Slot (${duration} Min)`; - setSuccessMsg(`${slotDescription} angelegt.`); - - // Manually refetch slots to ensure live updates - refetchSlots(); - - // advance time by the duration of the slot - const [hStr, mStr] = time.split(":"); - let h = parseInt(hStr, 10); - let m = parseInt(mStr, 10); - m += duration; - if (m >= 60) { h += Math.floor(m / 60); m = m % 60; } - if (h >= 24) { h = h % 24; } - const next = `${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}`; - setTime(next); - }, - onError: (err: any) => { - const msg = (err && (err.message || (err as any).toString())) || "Fehler beim Anlegen."; - setErrorMsg(msg); - }, - } - ); - }; + return (
- {/* Tab-Navigation */} + {/* Tab-Navigation (Slots entfernt) */}
- {/* Tab Inhalt */} - {activeSubTab === "slots" && ( - <> - {/* Slot Type Selection */} -
-

Neuen Slot anlegen

- -
- - -
- -
-
- - setSelectedDate(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" - /> -
- -
- - setTime(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" - /> -
- - {slotType === "treatment" ? ( -
- - -
- ) : ( -
- - setDuration(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" - /> -
- )} - -
- -
-
- - {/* Treatment Info Display */} - {slotType === "treatment" && selectedTreatment && ( -
-
-
-

{selectedTreatment.name}

-

{selectedTreatment.description}

-
-
-
- {(selectedTreatment.price / 100).toFixed(2)} € -
-
- {selectedTreatment.duration} Minuten -
-
-
-
- )} -
- - {(errorMsg || successMsg) && ( -
- {errorMsg && ( -
-
- - - - Fehler: - {errorMsg} -
-
- )} - {successMsg && ( -
-
- - - - Erfolg: - {successMsg} -
-
- )} -
- )} - - {/* Quick Stats */} -
-
-
- {allSlots?.filter(s => s.status === "free").length || 0} -
-
Freie Slots
-
-
-
- {allSlots?.filter(s => s.status === "reserved").length || 0} -
-
Reservierte Slots
-
-
-
- {allSlots?.length || 0} -
-
Slots gesamt
-
-
- - {/* Cleanup Button */} -
-
-
-

Bereinigung

-

Vergangene Slots automatisch löschen

-
- -
-
- - {/* All Slots List */} -
-
-

Alle Slots

-
-
- {allSlots - ?.sort((a, b) => (a.date === b.date ? a.time.localeCompare(b.time) : a.date.localeCompare(b.date))) - .map((slot) => { - // Try to find matching treatment based on duration - const matchingTreatments = treatments?.filter(t => t.duration === slot.durationMinutes) || []; - - return ( -
-
-
-
-
Datum
-
{new Date(slot.date).toLocaleDateString('de-DE')}
-
-
-
Zeit
-
{slot.time}
-
-
-
Dauer
-
{slot.durationMinutes} Min
-
- {matchingTreatments.length > 0 && ( -
-
Passende Behandlungen
-
- {matchingTreatments.length === 1 - ? matchingTreatments[0].name - : `${matchingTreatments.length} Behandlungen` - } -
-
- )} - - {slot.status === "free" ? "Frei" : "Reserviert"} - -
-
- -
-
- - {/* Show matching treatments if multiple */} - {matchingTreatments.length > 1 && ( -
-
Passende Behandlungen:
-
- {matchingTreatments.map(treatment => ( - - {treatment.name} - - ))} -
-
- )} -
- ); - })} - {allSlots?.length === 0 && ( -
-
Keine Slots vorhanden
-
Legen Sie den ersten Slot an, um zu beginnen.
-
- )} -
-
- - )} + {/* Slot-Tab und Slot-UI entfernt */} {/* Tab "Wiederkehrende Zeiten" */} {activeSubTab === "recurring" && ( diff --git a/src/server/rpc/availability.ts b/src/server/rpc/availability.ts deleted file mode 100644 index 387fbfb..0000000 --- a/src/server/rpc/availability.ts +++ /dev/null @@ -1,250 +0,0 @@ -import { call, os } from "@orpc/server"; -import { z } from "zod"; -import { randomUUID } from "crypto"; -import { createKV } from "../lib/create-kv.js"; - -const AvailabilitySchema = z.object({ - id: z.string(), - date: z.string(), // YYYY-MM-DD - time: z.string(), // HH:MM - durationMinutes: z.number().int().positive(), - status: z.enum(["free", "reserved"]), - reservedByBookingId: z.string().optional(), - createdAt: z.string(), -}); - -export type Availability = z.output; - -const kv = createKV("availability"); - -// Minimal Owner-Prüfung über Sessions/Users KV -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("sessions"); -const usersKV = createKV("users"); - -async function assertOwner(sessionId: string): Promise { - 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"); -} - -const create = os - .input( - z.object({ - sessionId: z.string(), - date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/), - time: z.string().regex(/^\d{2}:\d{2}$/), - durationMinutes: z.number().int().positive(), - }) - ) - .handler(async ({ input }) => { - try { - await assertOwner(input.sessionId); - // Prevent duplicate slot on same date+time - const existing = await kv.getAllItems(); - const conflict = existing.some((s) => s.date === input.date && s.time === input.time); - if (conflict) { - throw new Error("Es existiert bereits ein Slot zu diesem Datum und dieser Uhrzeit."); - } - const id = randomUUID(); - const slot: Availability = { - id, - date: input.date, - time: input.time, - durationMinutes: input.durationMinutes, - status: "free", - createdAt: new Date().toISOString(), - }; - await kv.setItem(id, slot); - return slot; - } catch (err) { - console.error("availability.create error", err); - throw err; - } - }); - -const update = os - .input(AvailabilitySchema.extend({ sessionId: z.string() })) - .handler(async ({ input }) => { - await assertOwner(input.sessionId); - const { sessionId, ...rest } = input as any; - await kv.setItem(rest.id, rest as Availability); - return rest as Availability; - }); - -const remove = os - .input(z.object({ sessionId: z.string(), id: z.string() })) - .handler(async ({ input }) => { - await assertOwner(input.sessionId); - const slot = await kv.getItem(input.id); - if (slot && slot.status === "reserved") throw new Error("Cannot delete reserved slot"); - await kv.removeItem(input.id); - }); - -const list = 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`); - } - - // Return remaining slots (all are now current/future) - const remainingSlots = allSlots.filter(slot => !slotsToDelete.includes(slot.id)); - - 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); -}); - -const getByDate = os - .input(z.string()) // YYYY-MM-DD - .handler(async ({ input }) => { - const all = await kv.getAllItems(); - return all.filter((s) => s.date === input); - }); - -// Cleanup function to manually delete past slots -const cleanupPastSlots = os - .input(z.object({ sessionId: z.string() })) - .handler(async ({ input }) => { - await assertOwner(input.sessionId); - - const allSlots = await kv.getAllItems(); - const today = new Date().toISOString().split("T")[0]; - const now = new Date(); - const currentTime = `${String(now.getHours()).padStart(2, "0")}:${String(now.getMinutes()).padStart(2, "0")}`; - - let deletedCount = 0; - - for (const slot of allSlots) { - const isPastDate = slot.date < today; - const isPastTime = slot.date === today && slot.time <= currentTime; - - if ((isPastDate || isPastTime) && slot.status !== "reserved") { - await kv.removeItem(slot.id); - deletedCount++; - } - } - - console.log(`Manual cleanup: deleted ${deletedCount} past availability slots`); - return { deletedCount, message: `${deletedCount} vergangene Slots wurden gelöscht.` }; - }); - -const live = { - list: os.handler(async function* ({ signal }) { - yield call(list, {}, { signal }); - for await (const _ of kv.subscribe()) { - yield call(list, {}, { signal }); - } - }), - byDate: os - .input(z.string()) - .handler(async function* ({ input, signal }) { - yield call(getByDate, input, { signal }); - for await (const _ of kv.subscribe()) { - yield call(getByDate, input, { signal }); - } - }), -}; - -export const router = { - create, - update, - remove, - list, - listFiltered, - peekAll, - get, - getByDate, - cleanupPastSlots, - live, -}; - - diff --git a/src/server/rpc/index.ts b/src/server/rpc/index.ts index 3d72a88..5feea1e 100644 --- a/src/server/rpc/index.ts +++ b/src/server/rpc/index.ts @@ -2,7 +2,6 @@ import { demo } from "./demo/index.js"; 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,7 +11,6 @@ export const router = { treatments, bookings, auth, - availability, recurringAvailability, cancellation, legal, diff --git a/src/server/rpc/recurring-availability.ts b/src/server/rpc/recurring-availability.ts index b85037b..f3b344c 100644 --- a/src/server/rpc/recurring-availability.ts +++ b/src/server/rpc/recurring-availability.ts @@ -30,14 +30,12 @@ export type TimeOffPeriod = z.output; const recurringRulesKV = createKV("recurringRules"); const timeOffPeriodsKV = createKV("timeOffPeriods"); -// Import existing availability KV -import { router as availabilityRouter } from "./availability.js"; // Import bookings and treatments KV stores for getAvailableTimes endpoint const bookingsKV = createKV("bookings"); const treatmentsKV = createKV("treatments"); -// Owner-Authentifizierung (kopiert aus availability.ts) +// Owner-Authentifizierung 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("sessions"); @@ -236,23 +234,8 @@ const createTimeOff = os 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 }; + return timeOff; } catch (err) { console.error("recurring-availability.createTimeOff error", err); throw err; @@ -294,153 +277,6 @@ const adminListTimeOff = os 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 @@ -643,9 +479,6 @@ export const router = { listTimeOff, adminListTimeOff, - // Generator - generateSlots, - // Availability getAvailableTimes,