I´d like to create a booking platform for a beauty shop (nail design). the customer shall be able to book a treatment. an admin backend is needed to manage articles and their durations.

This commit is contained in:
Quests Agent
2025-09-29 18:01:00 +02:00
parent a4ecf845bf
commit 63a402b3ad
9 changed files with 1068 additions and 27 deletions

View File

@@ -0,0 +1,213 @@
import { useState } from "react";
import { useMutation, useQuery } from "@tanstack/react-query";
import { queryClient } from "@/client/rpc-client";
export function AdminBookings() {
const [selectedDate, setSelectedDate] = useState(new Date().toISOString().split('T')[0]);
const { data: bookings } = useQuery(
queryClient.bookings.live.list.experimental_liveOptions()
);
const { data: treatments } = useQuery(
queryClient.treatments.live.list.experimental_liveOptions()
);
const { mutate: updateBookingStatus } = useMutation(
queryClient.bookings.updateStatus.mutationOptions()
);
const getTreatmentName = (treatmentId: string) => {
return treatments?.find(t => t.id === treatmentId)?.name || "Unknown Treatment";
};
const getStatusColor = (status: string) => {
switch (status) {
case "pending": return "bg-yellow-100 text-yellow-800";
case "confirmed": return "bg-green-100 text-green-800";
case "cancelled": return "bg-red-100 text-red-800";
case "completed": return "bg-blue-100 text-blue-800";
default: return "bg-gray-100 text-gray-800";
}
};
const filteredBookings = bookings?.filter(booking =>
selectedDate ? booking.appointmentDate === selectedDate : true
).sort((a, b) => {
if (a.appointmentDate === b.appointmentDate) {
return a.appointmentTime.localeCompare(b.appointmentTime);
}
return a.appointmentDate.localeCompare(b.appointmentDate);
});
const upcomingBookings = bookings?.filter(booking => {
const bookingDate = new Date(booking.appointmentDate);
const today = new Date();
today.setHours(0, 0, 0, 0);
return bookingDate >= today && booking.status !== "cancelled";
}).sort((a, b) => {
if (a.appointmentDate === b.appointmentDate) {
return a.appointmentTime.localeCompare(b.appointmentTime);
}
return a.appointmentDate.localeCompare(b.appointmentDate);
});
return (
<div className="max-w-6xl mx-auto">
<h2 className="text-2xl font-bold text-gray-900 mb-6">Manage Bookings</h2>
{/* Quick Stats */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
<div className="bg-white rounded-lg shadow p-4">
<div className="text-2xl font-bold text-pink-600">
{bookings?.filter(b => b.status === "pending").length || 0}
</div>
<div className="text-sm text-gray-600">Pending Approval</div>
</div>
<div className="bg-white rounded-lg shadow p-4">
<div className="text-2xl font-bold text-green-600">
{bookings?.filter(b => b.status === "confirmed").length || 0}
</div>
<div className="text-sm text-gray-600">Confirmed</div>
</div>
<div className="bg-white rounded-lg shadow p-4">
<div className="text-2xl font-bold text-blue-600">
{upcomingBookings?.length || 0}
</div>
<div className="text-sm text-gray-600">Upcoming</div>
</div>
<div className="bg-white rounded-lg shadow p-4">
<div className="text-2xl font-bold text-gray-600">
{bookings?.length || 0}
</div>
<div className="text-sm text-gray-600">Total Bookings</div>
</div>
</div>
{/* Date Filter */}
<div className="bg-white rounded-lg shadow p-4 mb-6">
<div className="flex items-center space-x-4">
<label className="text-sm font-medium text-gray-700">Filter by date:</label>
<input
type="date"
value={selectedDate}
onChange={(e) => setSelectedDate(e.target.value)}
className="p-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-pink-500 focus:border-pink-500"
/>
<button
onClick={() => setSelectedDate("")}
className="text-sm text-pink-600 hover:text-pink-800"
>
Show All
</button>
</div>
</div>
{/* Bookings Table */}
<div className="bg-white rounded-lg shadow-lg overflow-hidden">
<table className="w-full">
<thead className="bg-gray-50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Customer
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Treatment
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Date & Time
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Status
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Actions
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{filteredBookings?.map((booking) => (
<tr key={booking.id} className="hover:bg-gray-50">
<td className="px-6 py-4 whitespace-nowrap">
<div>
<div className="text-sm font-medium text-gray-900">{booking.customerName}</div>
<div className="text-sm text-gray-500">{booking.customerEmail}</div>
<div className="text-sm text-gray-500">{booking.customerPhone}</div>
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm text-gray-900">{getTreatmentName(booking.treatmentId)}</div>
{booking.notes && (
<div className="text-sm text-gray-500">Notes: {booking.notes}</div>
)}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
<div>{new Date(booking.appointmentDate).toLocaleDateString()}</div>
<div>{booking.appointmentTime}</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)}`}>
{booking.status}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
<div className="flex space-x-2">
{booking.status === "pending" && (
<>
<button
onClick={() => updateBookingStatus({ id: booking.id, status: "confirmed" })}
className="text-green-600 hover:text-green-900"
>
Confirm
</button>
<button
onClick={() => updateBookingStatus({ id: booking.id, status: "cancelled" })}
className="text-red-600 hover:text-red-900"
>
Cancel
</button>
</>
)}
{booking.status === "confirmed" && (
<>
<button
onClick={() => updateBookingStatus({ id: booking.id, status: "completed" })}
className="text-blue-600 hover:text-blue-900"
>
Complete
</button>
<button
onClick={() => updateBookingStatus({ id: booking.id, status: "cancelled" })}
className="text-red-600 hover:text-red-900"
>
Cancel
</button>
</>
)}
{(booking.status === "cancelled" || booking.status === "completed") && (
<button
onClick={() => updateBookingStatus({ id: booking.id, status: "confirmed" })}
className="text-green-600 hover:text-green-900"
>
Reactivate
</button>
)}
</div>
</td>
</tr>
))}
</tbody>
</table>
{!filteredBookings?.length && (
<div className="text-center py-8 text-gray-500">
{selectedDate
? `No bookings found for ${new Date(selectedDate).toLocaleDateString()}`
: "No bookings available."
}
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,270 @@
import { useState } from "react";
import { useMutation, useQuery } from "@tanstack/react-query";
import { queryClient } from "@/client/rpc-client";
export function AdminTreatments() {
const [showForm, setShowForm] = useState(false);
const [editingTreatment, setEditingTreatment] = useState<any>(null);
const [formData, setFormData] = useState({
name: "",
description: "",
duration: 60,
price: 5000, // $50.00 in cents
category: "Manicure",
});
const { data: treatments } = useQuery(
queryClient.treatments.live.list.experimental_liveOptions()
);
const { mutate: createTreatment, isPending: isCreating } = useMutation(
queryClient.treatments.create.mutationOptions()
);
const { mutate: updateTreatment, isPending: isUpdating } = useMutation(
queryClient.treatments.update.mutationOptions()
);
const { mutate: deleteTreatment } = useMutation(
queryClient.treatments.remove.mutationOptions()
);
const categories = ["Manicure", "Pedicure", "Nail Art", "Extensions", "Other"];
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (editingTreatment) {
updateTreatment({
id: editingTreatment.id,
...formData,
}, {
onSuccess: () => {
setEditingTreatment(null);
setShowForm(false);
resetForm();
}
});
} else {
createTreatment(formData, {
onSuccess: () => {
setShowForm(false);
resetForm();
}
});
}
};
const resetForm = () => {
setFormData({
name: "",
description: "",
duration: 60,
price: 5000,
category: "Manicure",
});
};
const handleEdit = (treatment: any) => {
setEditingTreatment(treatment);
setFormData({
name: treatment.name,
description: treatment.description,
duration: treatment.duration,
price: treatment.price,
category: treatment.category,
});
setShowForm(true);
};
const handleCancel = () => {
setShowForm(false);
setEditingTreatment(null);
resetForm();
};
return (
<div className="max-w-6xl mx-auto">
<div className="flex justify-between items-center mb-6">
<h2 className="text-2xl font-bold text-gray-900">Manage Treatments</h2>
<button
onClick={() => setShowForm(true)}
className="bg-pink-600 text-white px-4 py-2 rounded-md hover:bg-pink-700 font-medium"
>
Add Treatment
</button>
</div>
{showForm && (
<div className="bg-white rounded-lg shadow-lg p-6 mb-6">
<h3 className="text-lg font-semibold mb-4">
{editingTreatment ? "Edit Treatment" : "Add New Treatment"}
</h3>
<form onSubmit={handleSubmit} className="space-y-4">
<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">
Treatment Name *
</label>
<input
type="text"
value={formData.name}
onChange={(e) => setFormData({...formData, name: 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
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Category *
</label>
<select
value={formData.category}
onChange={(e) => setFormData({...formData, category: 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
>
{categories.map(cat => (
<option key={cat} value={cat}>{cat}</option>
))}
</select>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Description *
</label>
<textarea
value={formData.description}
onChange={(e) => setFormData({...formData, description: e.target.value})}
rows={3}
className="w-full p-3 border border-gray-300 rounded-md focus:ring-2 focus:ring-pink-500 focus:border-pink-500"
required
/>
</div>
<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">
Duration (minutes) *
</label>
<input
type="number"
value={formData.duration}
onChange={(e) => setFormData({...formData, duration: parseInt(e.target.value)})}
min="15"
max="480"
step="15"
className="w-full p-3 border border-gray-300 rounded-md focus:ring-2 focus:ring-pink-500 focus:border-pink-500"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Price ($) *
</label>
<input
type="number"
value={formData.price / 100}
onChange={(e) => setFormData({...formData, price: Math.round(parseFloat(e.target.value) * 100)})}
min="0"
step="0.01"
className="w-full p-3 border border-gray-300 rounded-md focus:ring-2 focus:ring-pink-500 focus:border-pink-500"
required
/>
</div>
</div>
<div className="flex space-x-4">
<button
type="submit"
disabled={isCreating || isUpdating}
className="bg-pink-600 text-white px-4 py-2 rounded-md hover:bg-pink-700 disabled:opacity-50 font-medium"
>
{isCreating || isUpdating ? "Saving..." : (editingTreatment ? "Update" : "Create")}
</button>
<button
type="button"
onClick={handleCancel}
className="bg-gray-300 text-gray-700 px-4 py-2 rounded-md hover:bg-gray-400 font-medium"
>
Cancel
</button>
</div>
</form>
</div>
)}
<div className="bg-white rounded-lg shadow-lg overflow-hidden">
<table className="w-full">
<thead className="bg-gray-50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Treatment
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Category
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Duration
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Price
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Actions
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{treatments?.map((treatment) => (
<tr key={treatment.id}>
<td className="px-6 py-4 whitespace-nowrap">
<div>
<div className="text-sm font-medium text-gray-900">{treatment.name}</div>
<div className="text-sm text-gray-500">{treatment.description}</div>
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
{treatment.category}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
{treatment.duration} min
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
${(treatment.price / 100).toFixed(2)}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium space-x-2">
<button
onClick={() => handleEdit(treatment)}
className="text-pink-600 hover:text-pink-900"
>
Edit
</button>
<button
onClick={() => {
if (confirm("Are you sure you want to delete this treatment?")) {
deleteTreatment(treatment.id);
}
}}
className="text-red-600 hover:text-red-900"
>
Delete
</button>
</td>
</tr>
))}
</tbody>
</table>
{!treatments?.length && (
<div className="text-center py-8 text-gray-500">
No treatments available. Add your first treatment to get started.
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,225 @@
import { useState } from "react";
import { useMutation, useQuery } from "@tanstack/react-query";
import { queryClient } from "@/client/rpc-client";
export function BookingForm() {
const [selectedTreatment, setSelectedTreatment] = useState("");
const [customerName, setCustomerName] = useState("");
const [customerEmail, setCustomerEmail] = useState("");
const [customerPhone, setCustomerPhone] = useState("");
const [appointmentDate, setAppointmentDate] = useState("");
const [appointmentTime, setAppointmentTime] = useState("");
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 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 handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!selectedTreatment || !customerName || !customerEmail || !customerPhone || !appointmentDate || !appointmentTime) {
alert("Please fill in all required fields");
return;
}
createBooking({
treatmentId: selectedTreatment,
customerName,
customerEmail,
customerPhone,
appointmentDate,
appointmentTime,
notes,
}, {
onSuccess: () => {
setSelectedTreatment("");
setCustomerName("");
setCustomerEmail("");
setCustomerPhone("");
setAppointmentDate("");
setAppointmentTime("");
setNotes("");
alert("Booking created successfully! We'll contact you to confirm your appointment.");
}
});
};
// Get minimum date (today)
const today = new Date().toISOString().split('T')[0];
return (
<div className="max-w-2xl mx-auto bg-white rounded-lg shadow-lg p-6">
<h2 className="text-2xl font-bold text-gray-900 mb-6">Book Your Nail Treatment</h2>
<form onSubmit={handleSubmit} className="space-y-6">
{/* Treatment Selection */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Select Treatment *
</label>
<select
value={selectedTreatment}
onChange={(e) => setSelectedTreatment(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
>
<option value="">Choose a treatment</option>
{treatments?.map((treatment) => (
<option key={treatment.id} value={treatment.id}>
{treatment.name} - ${(treatment.price / 100).toFixed(2)} ({treatment.duration} min)
</option>
))}
</select>
{selectedTreatmentData && (
<p className="mt-2 text-sm text-gray-600">{selectedTreatmentData.description}</p>
)}
</div>
{/* Customer Information */}
<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">
Full Name *
</label>
<input
type="text"
value={customerName}
onChange={(e) => setCustomerName(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
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Email *
</label>
<input
type="email"
value={customerEmail}
onChange={(e) => setCustomerEmail(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
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Phone Number *
</label>
<input
type="tel"
value={customerPhone}
onChange={(e) => setCustomerPhone(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
/>
</div>
{/* Date and Time Selection */}
<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">
Preferred Date *
</label>
<input
type="date"
value={appointmentDate}
onChange={(e) => setAppointmentDate(e.target.value)}
min={today}
className="w-full p-3 border border-gray-300 rounded-md focus:ring-2 focus:ring-pink-500 focus:border-pink-500"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Preferred Time *
</label>
<select
value={appointmentTime}
onChange={(e) => setAppointmentTime(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="">Select time</option>
{availableTimeSlots.map((time) => (
<option key={time} value={time}>
{time}
</option>
))}
</select>
</div>
</div>
{/* Notes */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Additional Notes
</label>
<textarea
value={notes}
onChange={(e) => setNotes(e.target.value)}
rows={3}
className="w-full p-3 border border-gray-300 rounded-md focus:ring-2 focus:ring-pink-500 focus:border-pink-500"
placeholder="Any special requests or information..."
/>
</div>
<button
type="submit"
disabled={isPending}
className="w-full bg-pink-600 text-white py-3 px-4 rounded-md hover:bg-pink-700 focus:ring-2 focus:ring-pink-500 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed font-medium"
>
{isPending ? "Booking..." : "Book Appointment"}
</button>
</form>
</div>
);
}

View File

@@ -0,0 +1,69 @@
import { useEffect } from "react";
import { useMutation, useQuery } from "@tanstack/react-query";
import { queryClient } from "@/client/rpc-client";
export function InitialDataLoader() {
const { data: treatments } = useQuery(
queryClient.treatments.list.queryOptions()
);
const { mutate: createTreatment } = useMutation(
queryClient.treatments.create.mutationOptions()
);
useEffect(() => {
// Only initialize if no treatments exist
if (treatments && treatments.length === 0) {
const sampleTreatments = [
{
name: "Classic Manicure",
description: "Traditional nail care with cuticle treatment, shaping, and polish application",
duration: 45,
price: 3500, // $35.00
category: "Manicure"
},
{
name: "Gel Manicure",
description: "Long-lasting gel polish that cures under UV light for chip-free wear",
duration: 60,
price: 4500, // $45.00
category: "Manicure"
},
{
name: "Classic Pedicure",
description: "Foot soak, exfoliation, nail care, and polish application",
duration: 60,
price: 4000, // $40.00
category: "Pedicure"
},
{
name: "French Manicure",
description: "Elegant white tips with natural base for a timeless look",
duration: 50,
price: 4000, // $40.00
category: "Manicure"
},
{
name: "Nail Art Design",
description: "Custom nail art with intricate designs and decorations",
duration: 90,
price: 6500, // $65.00
category: "Nail Art"
},
{
name: "Acrylic Extensions",
description: "Full set of acrylic nail extensions with shape and length customization",
duration: 120,
price: 8000, // $80.00
category: "Extensions"
}
];
sampleTreatments.forEach(treatment => {
createTreatment(treatment);
});
}
}, [treatments, createTreatment]);
return null; // This component doesn't render anything
}