Initial commit: Kalender, Buchungen mit Slot-Reservierung, Resend-E-Mails, Admin-UI, Startscript
This commit is contained in:
@@ -6,10 +6,11 @@ import { BookingForm } from "@/client/components/booking-form";
|
||||
import { AdminTreatments } from "@/client/components/admin-treatments";
|
||||
import { AdminBookings } from "@/client/components/admin-bookings";
|
||||
import { InitialDataLoader } from "@/client/components/initial-data-loader";
|
||||
import { AdminAvailability } from "@/client/components/admin-availability";
|
||||
|
||||
function App() {
|
||||
const { user, isLoading, isOwner } = useAuth();
|
||||
const [activeTab, setActiveTab] = useState<"booking" | "admin-treatments" | "admin-bookings" | "profile">("booking");
|
||||
const [activeTab, setActiveTab] = useState<"booking" | "admin-treatments" | "admin-bookings" | "admin-availability" | "profile">("booking");
|
||||
|
||||
// Show loading spinner while checking authentication
|
||||
if (isLoading) {
|
||||
@@ -24,7 +25,7 @@ function App() {
|
||||
}
|
||||
|
||||
// Show login form if user is not authenticated and trying to access admin features
|
||||
const needsAuth = !user && (activeTab === "admin-treatments" || activeTab === "admin-bookings" || activeTab === "profile");
|
||||
const needsAuth = !user && (activeTab === "admin-treatments" || activeTab === "admin-bookings" || activeTab === "admin-availability" || activeTab === "profile");
|
||||
if (needsAuth) {
|
||||
return <LoginForm />;
|
||||
}
|
||||
@@ -33,6 +34,7 @@ function App() {
|
||||
{ id: "booking", label: "Termin buchen", icon: "📅", requiresAuth: false },
|
||||
{ id: "admin-treatments", label: "Behandlungen verwalten", icon: "💅", requiresAuth: true },
|
||||
{ id: "admin-bookings", label: "Buchungen verwalten", icon: "📋", requiresAuth: true },
|
||||
{ id: "admin-availability", label: "Verfügbarkeiten", icon: "⏰", requiresAuth: true },
|
||||
...(user ? [{ id: "profile", label: "Profil", icon: "👤", requiresAuth: true }] : []),
|
||||
] as const;
|
||||
|
||||
@@ -159,6 +161,20 @@ function App() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === "admin-availability" && isOwner && (
|
||||
<div>
|
||||
<div className="text-center mb-8">
|
||||
<h2 className="text-3xl font-bold text-gray-900 mb-4">
|
||||
Verfügbarkeiten verwalten
|
||||
</h2>
|
||||
<p className="text-lg text-gray-600">
|
||||
Lege freie Slots an und entferne sie bei Bedarf.
|
||||
</p>
|
||||
</div>
|
||||
<AdminAvailability />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === "profile" && user && (
|
||||
<div>
|
||||
<div className="text-center mb-8">
|
||||
|
94
src/client/components/admin-availability.tsx
Normal file
94
src/client/components/admin-availability.tsx
Normal file
@@ -0,0 +1,94 @@
|
||||
import { useState } from "react";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { queryClient } from "@/client/rpc-client";
|
||||
|
||||
export function AdminAvailability() {
|
||||
const today = new Date().toISOString().split("T")[0];
|
||||
const [selectedDate, setSelectedDate] = useState<string>(today);
|
||||
const [time, setTime] = useState<string>("09:00");
|
||||
const [duration, setDuration] = useState<number>(30);
|
||||
|
||||
const { data: slots } = useQuery(
|
||||
queryClient.availability.live.byDate.experimental_liveOptions(selectedDate)
|
||||
);
|
||||
|
||||
const { mutate: createSlot, isPending: isCreating } = useMutation(
|
||||
queryClient.availability.create.mutationOptions()
|
||||
);
|
||||
const { mutate: removeSlot } = useMutation(
|
||||
queryClient.availability.remove.mutationOptions()
|
||||
);
|
||||
|
||||
const addSlot = () => {
|
||||
if (!selectedDate || !time || !duration) return;
|
||||
createSlot({ sessionId: localStorage.getItem("sessionId") || "", date: selectedDate, time, durationMinutes: duration });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-3xl mx-auto space-y-6">
|
||||
<h2 className="text-xl font-semibold">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>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<h3 className="font-medium">Slots am {selectedDate}</h3>
|
||||
<div className="grid grid-cols-1 gap-2">
|
||||
{slots?.sort((a, b) => a.time.localeCompare(b.time)).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="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>
|
||||
</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>
|
||||
))}
|
||||
{slots?.length === 0 && (
|
||||
<div className="text-sm text-gray-600">Keine Slots vorhanden.</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
@@ -144,6 +144,9 @@ export function AdminBookings() {
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
||||
<div>{new Date(booking.appointmentDate).toLocaleDateString()}</div>
|
||||
<div>{booking.appointmentTime}</div>
|
||||
{booking.slotId && (
|
||||
<div className="text-xs text-gray-500">Slot-ID: {booking.slotId}</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<span className={`inline-flex px-2 py-1 text-xs font-semibold rounded-full ${getStatusColor(booking.status)}`}>
|
||||
@@ -155,13 +158,13 @@ export function AdminBookings() {
|
||||
{booking.status === "pending" && (
|
||||
<>
|
||||
<button
|
||||
onClick={() => updateBookingStatus({ id: booking.id, status: "confirmed" })}
|
||||
onClick={() => updateBookingStatus({ sessionId: localStorage.getItem("sessionId") || "", id: booking.id, status: "confirmed" })}
|
||||
className="text-green-600 hover:text-green-900"
|
||||
>
|
||||
Confirm
|
||||
</button>
|
||||
<button
|
||||
onClick={() => updateBookingStatus({ id: booking.id, status: "cancelled" })}
|
||||
onClick={() => updateBookingStatus({ sessionId: localStorage.getItem("sessionId") || "", id: booking.id, status: "cancelled" })}
|
||||
className="text-red-600 hover:text-red-900"
|
||||
>
|
||||
Cancel
|
||||
@@ -171,13 +174,13 @@ export function AdminBookings() {
|
||||
{booking.status === "confirmed" && (
|
||||
<>
|
||||
<button
|
||||
onClick={() => updateBookingStatus({ id: booking.id, status: "completed" })}
|
||||
onClick={() => updateBookingStatus({ sessionId: localStorage.getItem("sessionId") || "", id: booking.id, status: "completed" })}
|
||||
className="text-blue-600 hover:text-blue-900"
|
||||
>
|
||||
Complete
|
||||
</button>
|
||||
<button
|
||||
onClick={() => updateBookingStatus({ id: booking.id, status: "cancelled" })}
|
||||
onClick={() => updateBookingStatus({ sessionId: localStorage.getItem("sessionId") || "", id: booking.id, status: "cancelled" })}
|
||||
className="text-red-600 hover:text-red-900"
|
||||
>
|
||||
Cancel
|
||||
@@ -186,7 +189,7 @@ export function AdminBookings() {
|
||||
)}
|
||||
{(booking.status === "cancelled" || booking.status === "completed") && (
|
||||
<button
|
||||
onClick={() => updateBookingStatus({ id: booking.id, status: "confirmed" })}
|
||||
onClick={() => updateBookingStatus({ sessionId: localStorage.getItem("sessionId") || "", id: booking.id, status: "confirmed" })}
|
||||
className="text-green-600 hover:text-green-900"
|
||||
>
|
||||
Reactivate
|
||||
|
@@ -8,66 +8,33 @@ export function BookingForm() {
|
||||
const [customerEmail, setCustomerEmail] = useState("");
|
||||
const [customerPhone, setCustomerPhone] = useState("");
|
||||
const [appointmentDate, setAppointmentDate] = useState("");
|
||||
const [appointmentTime, setAppointmentTime] = useState("");
|
||||
const [selectedSlotId, setSelectedSlotId] = useState<string>("");
|
||||
const [notes, setNotes] = useState("");
|
||||
|
||||
const { data: treatments } = useQuery(
|
||||
queryClient.treatments.live.list.experimental_liveOptions()
|
||||
);
|
||||
|
||||
const { data: allBookings } = useQuery(
|
||||
queryClient.bookings.live.list.experimental_liveOptions()
|
||||
const { data: slotsByDate } = useQuery(
|
||||
appointmentDate
|
||||
? queryClient.availability.live.byDate.experimental_liveOptions(appointmentDate)
|
||||
: queryClient.availability.live.byDate.experimental_liveOptions("")
|
||||
);
|
||||
|
||||
const selectedDateBookings = allBookings?.filter(booking =>
|
||||
booking.appointmentDate === appointmentDate
|
||||
) || [];
|
||||
|
||||
const { mutate: createBooking, isPending } = useMutation(
|
||||
queryClient.bookings.create.mutationOptions()
|
||||
);
|
||||
|
||||
const selectedTreatmentData = treatments?.find(t => t.id === selectedTreatment);
|
||||
|
||||
// Generate available time slots (9 AM to 6 PM, 30-minute intervals)
|
||||
const generateTimeSlots = () => {
|
||||
const slots: string[] = [];
|
||||
for (let hour = 9; hour < 18; hour++) {
|
||||
for (let minute = 0; minute < 60; minute += 30) {
|
||||
const time = `${hour.toString().padStart(2, '0')}:${minute.toString().padStart(2, '0')}`;
|
||||
slots.push(time);
|
||||
}
|
||||
}
|
||||
return slots;
|
||||
};
|
||||
|
||||
const availableTimeSlots = generateTimeSlots().filter(time => {
|
||||
if (!selectedDateBookings || !selectedTreatmentData) return true;
|
||||
|
||||
// Check if this time slot conflicts with existing bookings
|
||||
const timeSlotStart = new Date(`${appointmentDate}T${time}:00`);
|
||||
const timeSlotEnd = new Date(timeSlotStart.getTime() + selectedTreatmentData.duration * 60000);
|
||||
|
||||
return !selectedDateBookings.some(booking => {
|
||||
if (booking.status === 'cancelled') return false;
|
||||
|
||||
const bookingStart = new Date(`${booking.appointmentDate}T${booking.appointmentTime}:00`);
|
||||
const treatment = treatments?.find(t => t.id === booking.treatmentId);
|
||||
if (!treatment) return false;
|
||||
|
||||
const bookingEnd = new Date(bookingStart.getTime() + treatment.duration * 60000);
|
||||
|
||||
return (timeSlotStart < bookingEnd && timeSlotEnd > bookingStart);
|
||||
});
|
||||
});
|
||||
const availableSlots = (slotsByDate || []).filter(s => s.status === "free");
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!selectedTreatment || !customerName || !customerEmail || !customerPhone || !appointmentDate || !appointmentTime) {
|
||||
if (!selectedTreatment || !customerName || !customerEmail || !customerPhone || !appointmentDate || !selectedSlotId) {
|
||||
alert("Bitte fülle alle erforderlichen Felder aus");
|
||||
return;
|
||||
}
|
||||
|
||||
const slot = availableSlots.find(s => s.id === selectedSlotId);
|
||||
const appointmentTime = slot?.time || "";
|
||||
createBooking({
|
||||
treatmentId: selectedTreatment,
|
||||
customerName,
|
||||
@@ -76,6 +43,7 @@ export function BookingForm() {
|
||||
appointmentDate,
|
||||
appointmentTime,
|
||||
notes,
|
||||
slotId: selectedSlotId,
|
||||
}, {
|
||||
onSuccess: () => {
|
||||
setSelectedTreatment("");
|
||||
@@ -83,7 +51,7 @@ export function BookingForm() {
|
||||
setCustomerEmail("");
|
||||
setCustomerPhone("");
|
||||
setAppointmentDate("");
|
||||
setAppointmentTime("");
|
||||
setSelectedSlotId("");
|
||||
setNotes("");
|
||||
alert("Buchung erfolgreich erstellt! Wir werden dich kontaktieren, um deinen Termin zu bestätigen.");
|
||||
}
|
||||
@@ -179,21 +147,23 @@ export function BookingForm() {
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Gewünschte Uhrzeit *
|
||||
Verfügbare Uhrzeit *
|
||||
</label>
|
||||
<select
|
||||
value={appointmentTime}
|
||||
onChange={(e) => setAppointmentTime(e.target.value)}
|
||||
value={selectedSlotId}
|
||||
onChange={(e) => setSelectedSlotId(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}
|
||||
required
|
||||
>
|
||||
<option value="">Zeit auswählen</option>
|
||||
{availableTimeSlots.map((time) => (
|
||||
<option key={time} value={time}>
|
||||
{time}
|
||||
</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>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
Reference in New Issue
Block a user