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:
270
src/client/components/admin-treatments.tsx
Normal file
270
src/client/components/admin-treatments.tsx
Normal 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>
|
||||
);
|
||||
}
|
Reference in New Issue
Block a user