feat: Add admin calendar and improve availability management

- Add admin calendar component with booking overview and status management
- Implement treatment-specific availability slots with automatic duration
- Enhance availability management with better UI and error handling
- Move admin credentials to .env configuration
- Add .env.example with all required environment variables
- Update README.md with comprehensive setup guide including PowerShell password hash generation
- Improve slot deletion with proper error handling and user feedback
- Add toast notifications for better UX
This commit is contained in:
2025-09-30 10:58:33 +02:00
parent 2e5bfdd879
commit 072c7985c7
8 changed files with 841 additions and 136 deletions

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";
@@ -7,14 +7,35 @@ export function AdminAvailability() {
const [selectedDate, setSelectedDate] = useState<string>(today);
const [time, setTime] = useState<string>("09:00");
const [duration, setDuration] = useState<number>(30);
const [selectedTreatmentId, setSelectedTreatmentId] = useState<string>("");
const [slotType, setSlotType] = useState<"treatment" | "manual">("treatment");
const { data: allSlots } = useQuery(
queryClient.availability.live.list.experimental_liveOptions()
);
const { data: treatments } = useQuery(
queryClient.treatments.live.list.experimental_liveOptions()
);
const [errorMsg, setErrorMsg] = useState<string>("");
const [successMsg, setSuccessMsg] = useState<string>("");
// Auto-clear messages after 5 seconds
useEffect(() => {
if (errorMsg) {
const timer = setTimeout(() => setErrorMsg(""), 5000);
return () => clearTimeout(timer);
}
}, [errorMsg]);
useEffect(() => {
if (successMsg) {
const timer = setTimeout(() => setSuccessMsg(""), 5000);
return () => clearTimeout(timer);
}
}, [successMsg]);
const { mutate: createSlot, isPending: isCreating } = useMutation(
queryClient.availability.create.mutationOptions()
);
@@ -22,30 +43,60 @@ export function AdminAvailability() {
queryClient.availability.remove.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";
};
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: () => {
setSuccessMsg("Slot angelegt.");
// advance time to next 30-minute step
const slotDescription = slotType === "treatment"
? `${getTreatmentName(selectedTreatmentId)} (${duration} Min)`
: `Manueller Slot (${duration} Min)`;
setSuccessMsg(`${slotDescription} angelegt.`);
// advance time by the duration of the slot
const [hStr, mStr] = time.split(":");
let h = parseInt(hStr, 10);
let m = parseInt(mStr, 10);
m += 30;
if (m >= 60) { h += 1; m -= 60; }
if (h >= 24) { h = 0; }
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);
},
@@ -58,76 +109,274 @@ export function AdminAvailability() {
};
return (
<div className="max-w-3xl mx-auto space-y-6">
<h2 className="text-xl font-semibold">Verfügbarkeiten verwalten</h2>
<div className="max-w-4xl mx-auto space-y-6">
<h2 className="text-2xl font-bold text-gray-900">Verfügbarkeiten verwalten</h2>
<div className="flex items-center gap-3">
<input
type="date"
value={selectedDate}
onChange={(e) => setSelectedDate(e.target.value)}
className="border rounded px-3 py-2"
/>
<input
type="time"
value={time}
onChange={(e) => setTime(e.target.value)}
className="border rounded px-3 py-2"
/>
<input
type="number"
min={5}
step={5}
value={duration}
onChange={(e) => setDuration(Number(e.target.value))}
className="border rounded px-3 py-2 w-28"
/>
<button
onClick={addSlot}
disabled={isCreating}
className="bg-black text-white px-4 py-2 rounded"
>
{isCreating ? "Anlegen..." : "Slot hinzufügen"}
</button>
{/* Slot Type Selection */}
<div className="bg-white rounded-lg shadow p-4">
<h3 className="text-lg font-semibold mb-4">Neuen Slot anlegen</h3>
<div className="flex flex-wrap gap-2 mb-4">
<button
onClick={() => setSlotType("treatment")}
className={`px-4 py-2 rounded-md font-medium transition-colors ${
slotType === "treatment"
? "bg-pink-600 text-white"
: "bg-gray-100 text-gray-700 hover:bg-gray-200"
}`}
>
💅 Behandlungs-Slot
</button>
<button
onClick={() => setSlotType("manual")}
className={`px-4 py-2 rounded-md font-medium transition-colors ${
slotType === "manual"
? "bg-pink-600 text-white"
: "bg-gray-100 text-gray-700 hover:bg-gray-200"
}`}
>
Manueller Slot
</button>
</div>
<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">
Datum
</label>
<input
type="date"
value={selectedDate}
onChange={(e) => 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"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Startzeit
</label>
<input
type="time"
value={time}
onChange={(e) => 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"
/>
</div>
{slotType === "treatment" ? (
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Behandlung
</label>
<select
value={selectedTreatmentId}
onChange={(e) => setSelectedTreatmentId(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="">Behandlung wählen...</option>
{treatments?.map((treatment) => (
<option key={treatment.id} value={treatment.id}>
{treatment.name} ({treatment.duration} Min)
</option>
))}
</select>
</div>
) : (
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Dauer (Minuten)
</label>
<input
type="number"
min={5}
step={5}
value={duration}
onChange={(e) => 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"
/>
</div>
)}
<div className="flex items-end">
<button
onClick={addSlot}
disabled={isCreating || (slotType === "treatment" && !selectedTreatmentId)}
className="w-full bg-pink-600 text-white px-4 py-2 rounded-md hover:bg-pink-700 disabled:bg-gray-400 disabled:cursor-not-allowed font-medium transition-colors"
>
{isCreating ? "Anlegen..." : "Slot hinzufügen"}
</button>
</div>
</div>
{/* Treatment Info Display */}
{slotType === "treatment" && selectedTreatment && (
<div className="mt-4 p-3 bg-pink-50 rounded-md border border-pink-200">
<div className="flex items-center justify-between">
<div>
<h4 className="font-medium text-pink-900">{selectedTreatment.name}</h4>
<p className="text-sm text-pink-700">{selectedTreatment.description}</p>
</div>
<div className="text-right">
<div className="text-lg font-semibold text-pink-900">
{(selectedTreatment.price / 100).toFixed(2)}
</div>
<div className="text-sm text-pink-700">
{selectedTreatment.duration} Minuten
</div>
</div>
</div>
</div>
)}
</div>
{(errorMsg || successMsg) && (
<div className="text-sm">
{errorMsg && <div className="text-red-600">{errorMsg}</div>}
{successMsg && <div className="text-green-700">{successMsg}</div>}
<div className="fixed top-4 right-4 z-50 max-w-md">
{errorMsg && (
<div className="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded-lg shadow-lg mb-2">
<div className="flex items-center">
<svg className="w-5 h-5 mr-2" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clipRule="evenodd" />
</svg>
<span className="font-medium">Fehler:</span>
<span className="ml-1">{errorMsg}</span>
</div>
</div>
)}
{successMsg && (
<div className="bg-green-100 border border-green-400 text-green-700 px-4 py-3 rounded-lg shadow-lg">
<div className="flex items-center">
<svg className="w-5 h-5 mr-2" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clipRule="evenodd" />
</svg>
<span className="font-medium">Erfolg:</span>
<span className="ml-1">{successMsg}</span>
</div>
</div>
)}
</div>
)}
<div className="space-y-2">
<h3 className="font-medium">Alle freien Slots</h3>
<div className="grid grid-cols-1 gap-2">
{/* Quick Stats */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="bg-white rounded-lg shadow p-4">
<div className="text-2xl font-bold text-green-600">
{allSlots?.filter(s => s.status === "free").length || 0}
</div>
<div className="text-sm text-gray-600">Freie Slots</div>
</div>
<div className="bg-white rounded-lg shadow p-4">
<div className="text-2xl font-bold text-yellow-600">
{allSlots?.filter(s => s.status === "reserved").length || 0}
</div>
<div className="text-sm text-gray-600">Reservierte Slots</div>
</div>
<div className="bg-white rounded-lg shadow p-4">
<div className="text-2xl font-bold text-blue-600">
{allSlots?.length || 0}
</div>
<div className="text-sm text-gray-600">Slots gesamt</div>
</div>
</div>
{/* All Slots List */}
<div className="bg-white rounded-lg shadow">
<div className="p-4 border-b">
<h3 className="text-lg font-semibold">Alle Slots</h3>
</div>
<div className="divide-y">
{allSlots
?.filter((s) => s.status === "free")
.sort((a, b) => (a.date === b.date ? a.time.localeCompare(b.time) : a.date.localeCompare(b.date)))
.map((slot) => (
<div key={slot.id} className="flex items-center justify-between border rounded px-3 py-2">
<div className="flex items-center gap-3">
<span className="text-sm text-gray-600">{slot.date}</span>
<span className="font-mono">{slot.time}</span>
<span className="text-sm text-gray-600">{slot.durationMinutes} Min</span>
<span className={`text-xs px-2 py-1 rounded ${slot.status === "free" ? "bg-green-100 text-green-800" : "bg-yellow-100 text-yellow-800"}`}>
{slot.status === "free" ? "frei" : "reserviert"}
</span>
?.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 (
<div key={slot.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="text-center">
<div className="text-sm text-gray-500">Datum</div>
<div className="font-medium">{new Date(slot.date).toLocaleDateString('de-DE')}</div>
</div>
<div className="text-center">
<div className="text-sm text-gray-500">Zeit</div>
<div className="font-mono text-lg">{slot.time}</div>
</div>
<div className="text-center">
<div className="text-sm text-gray-500">Dauer</div>
<div className="font-medium">{slot.durationMinutes} Min</div>
</div>
{matchingTreatments.length > 0 && (
<div className="text-center">
<div className="text-sm text-gray-500">Passende Behandlungen</div>
<div className="text-sm">
{matchingTreatments.length === 1
? matchingTreatments[0].name
: `${matchingTreatments.length} Behandlungen`
}
</div>
</div>
)}
<span className={`px-3 py-1 rounded-full text-sm font-medium ${
slot.status === "free"
? "bg-green-100 text-green-800"
: "bg-yellow-100 text-yellow-800"
}`}>
{slot.status === "free" ? "Frei" : "Reserviert"}
</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;
}
removeSlot(
{ sessionId, id: slot.id },
{
onSuccess: () => {
setSuccessMsg("Slot erfolgreich gelöscht.");
},
onError: (err: any) => {
const msg = (err && (err.message || (err as any).toString())) || "Fehler beim Löschen des Slots.";
setErrorMsg(msg);
}
}
);
}}
className="px-3 py-1 text-red-600 hover:bg-red-50 rounded-md transition-colors text-sm"
disabled={slot.status === "reserved"}
title={slot.status === "reserved" ? "Slot ist reserviert" : "Slot löschen"}
>
Löschen
</button>
</div>
</div>
{/* Show matching treatments if multiple */}
{matchingTreatments.length > 1 && (
<div className="mt-2 ml-20">
<div className="text-xs text-gray-500 mb-1">Passende Behandlungen:</div>
<div className="flex flex-wrap gap-1">
{matchingTreatments.map(treatment => (
<span key={treatment.id} className="px-2 py-1 bg-pink-100 text-pink-700 rounded text-xs">
{treatment.name}
</span>
))}
</div>
</div>
)}
</div>
<div className="flex items-center gap-2">
<button
onClick={() => removeSlot({ sessionId: localStorage.getItem("sessionId") || "", id: slot.id })}
className="text-red-600 hover:underline"
disabled={slot.status === "reserved"}
title={slot.status === "reserved" ? "Slot ist reserviert" : "Slot löschen"}
>
Löschen
</button>
</div>
</div>
))}
{allSlots?.filter((s) => s.status === "free").length === 0 && (
<div className="text-sm text-gray-600">Keine freien Slots vorhanden.</div>
);
})}
{allSlots?.length === 0 && (
<div className="p-8 text-center text-gray-500">
<div className="text-lg font-medium mb-2">Keine Slots vorhanden</div>
<div className="text-sm">Legen Sie den ersten Slot an, um zu beginnen.</div>
</div>
)}
</div>
</div>