Seth0330's picture
Update frontend/src/components/admin/ClassForm.jsx
cc0748d verified
// frontend/src/components/admin/ClassForm.jsx
import React, { useState, useEffect } from "react";
import { Plus, Trash2, Loader2, Save, X, MapPin } from "lucide-react";
import { useQuery } from "@tanstack/react-query";
import api from "../../api/client";
const DAYS = [
"Monday",
"Tuesday",
"Wednesday",
"Thursday",
"Friday",
"Saturday",
"Sunday",
];
export default function ClassForm({ classData, onSave, onCancel, isLoading }) {
// Fetch membership plans if not provided
const { data: plans = [], isLoading: plansLoading } = useQuery({
queryKey: ["membership-plans"],
queryFn: async () => {
const res = await api.get("/admin/plans");
return Array.isArray(res.data) ? res.data : [];
},
initialData: [],
});
// schedule: [{ day, start_time, end_time }]
const [formData, setFormData] = useState({
name: "",
description: "",
coach_email: "",
location: "",
membership_plan_ids: [],
schedule: [{ day: "Monday", start_time: "", end_time: "" }],
is_active: true,
});
useEffect(() => {
if (!classData) return;
setFormData({
name: classData.name || "",
description: classData.description || "",
coach_email: classData.coach_email || "",
location: classData.location || "",
membership_plan_ids: Array.isArray(classData.membership_plan_ids)
? classData.membership_plan_ids
: (classData.membership_plans?.map(p => p.id) || []),
schedule:
Array.isArray(classData.schedule) && classData.schedule.length > 0
? classData.schedule.map((slot) => ({
day: slot.day || "Monday",
start_time: slot.start_time || "",
end_time: slot.end_time || "",
}))
: [{ day: "Monday", start_time: "", end_time: "" }],
is_active:
classData.is_active !== undefined ? classData.is_active : true,
});
}, [classData]);
const addScheduleSlot = () => {
setFormData((prev) => ({
...prev,
schedule: [
...prev.schedule,
{ day: "Monday", start_time: "", end_time: "" },
],
}));
};
const removeScheduleSlot = (index) => {
setFormData((prev) => ({
...prev,
schedule: prev.schedule.filter((_, i) => i !== index),
}));
};
const updateScheduleSlot = (index, field, value) => {
setFormData((prev) => {
const schedule = [...prev.schedule];
schedule[index] = { ...schedule[index], [field]: value };
return { ...prev, schedule };
});
};
const handleSubmit = (e) => {
e.preventDefault();
// Transform schedule to a simple structure that backend can later map
const transformedSchedule = formData.schedule.map((slot) => ({
day: slot.day,
start_time: slot.start_time,
end_time: slot.end_time,
time:
slot.start_time && slot.end_time
? `${slot.start_time}-${slot.end_time}`
: "",
}));
const payload = {
name: formData.name,
description: formData.description,
coach_email: formData.coach_email || null,
location: formData.location || null,
membership_plan_ids: formData.membership_plan_ids || [],
schedule: transformedSchedule,
is_active: formData.is_active,
};
onSave(payload);
};
const handlePlanToggle = (planId) => {
setFormData((prev) => {
const currentIds = prev.membership_plan_ids || [];
const newIds = currentIds.includes(planId)
? currentIds.filter((id) => id !== planId)
: [...currentIds, planId];
return { ...prev, membership_plan_ids: newIds };
});
};
return (
<form onSubmit={handleSubmit} className="space-y-6">
{/* Class Name */}
<div className="space-y-1">
<label className="block text-sm font-medium text-stone-800">
Class Name
</label>
<input
type="text"
className="w-full rounded-md border border-stone-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-red-600"
value={formData.name}
onChange={(e) =>
setFormData((prev) => ({ ...prev, name: e.target.value }))
}
placeholder="e.g., Beginner Karate"
required
/>
</div>
{/* Description */}
<div className="space-y-1">
<label className="block text-sm font-medium text-stone-800">
Description
</label>
<textarea
className="w-full rounded-md border border-stone-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-red-600"
rows={2}
value={formData.description}
onChange={(e) =>
setFormData((prev) => ({
...prev,
description: e.target.value,
}))
}
placeholder="Class description..."
/>
</div>
{/* Coach Email and Location - Side by Side */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* Coach Email */}
<div className="space-y-1">
<label className="block text-sm font-medium text-stone-800">
Coach Email (Optional)
</label>
<input
type="email"
className="w-full rounded-md border border-stone-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-red-600"
value={formData.coach_email}
onChange={(e) =>
setFormData((prev) => ({ ...prev, coach_email: e.target.value }))
}
placeholder="coach@dojo.com"
/>
<p className="text-xs text-stone-500 mt-1">
Assign a coach to manage attendance.
</p>
</div>
{/* Location */}
<div className="space-y-1">
<label className="block text-sm font-medium text-stone-800">
Location
</label>
<div className="relative">
<MapPin className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-stone-400" />
<input
type="text"
className="w-full rounded-md border border-stone-300 pl-10 pr-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-red-600"
value={formData.location}
onChange={(e) =>
setFormData((prev) => ({ ...prev, location: e.target.value }))
}
placeholder="Address or Zoom meeting link"
/>
</div>
<p className="text-xs text-stone-500 mt-1">
Physical address or online meeting details.
</p>
</div>
</div>
{/* Membership Plans */}
<div className="space-y-2">
<label className="block text-sm font-medium text-stone-800">
Membership Plans
</label>
<p className="text-xs text-stone-500 mb-3">
Select which membership plans can access this class. Members under these plans will be able to see and enroll in this class.
</p>
{plansLoading ? (
<div className="text-sm text-stone-500">Loading plans...</div>
) : plans.length === 0 ? (
<div className="text-sm text-stone-500">No membership plans available.</div>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
{plans
.filter((plan) => plan.is_active)
.map((plan) => (
<label
key={plan.id}
className="flex items-start gap-3 p-3 border border-stone-200 rounded-lg hover:bg-stone-50 cursor-pointer transition-colors"
>
<input
type="checkbox"
checked={formData.membership_plan_ids?.includes(plan.id) || false}
onChange={() => handlePlanToggle(plan.id)}
className="mt-0.5 h-4 w-4 rounded border-stone-300 text-red-600 focus:ring-red-600"
/>
<div className="flex-1 min-w-0">
<div className="text-sm font-medium text-stone-900">
{plan.name}
</div>
<div className="text-xs text-stone-500 mt-0.5">
${plan.price} / {plan.billing_period}
</div>
</div>
</label>
))}
</div>
)}
</div>
{/* Schedule */}
<div>
<div className="flex items-center justify-between mb-3">
<span className="text-sm font-medium text-stone-800">
Class Schedule
</span>
<button
type="button"
onClick={addScheduleSlot}
className="inline-flex items-center gap-2 rounded-md border border-stone-300 px-2.5 py-1 text-xs font-medium text-stone-800 hover:bg-stone-50"
>
<Plus className="w-4 h-4" />
Add Time Slot
</button>
</div>
<div className="space-y-3">
{formData.schedule.map((slot, index) => (
<div
key={index}
className="rounded-lg border border-stone-200 bg-stone-50 px-3 py-3"
>
<div className="flex flex-col gap-3 md:flex-row md:items-center">
{/* Day */}
<div className="w-full md:w-40">
<label className="block text-xs font-medium text-stone-700 mb-1">
Day
</label>
<select
className="w-full rounded-md border border-stone-300 px-2 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-red-600"
value={slot.day}
onChange={(e) =>
updateScheduleSlot(index, "day", e.target.value)
}
>
{DAYS.map((day) => (
<option key={day} value={day}>
{day}
</option>
))}
</select>
</div>
{/* From / To */}
<div className="flex flex-1 flex-col gap-3 md:flex-row">
<div className="flex-1">
<label className="block text-xs font-medium text-stone-700 mb-1">
From
</label>
<input
type="time"
className="w-full rounded-md border border-stone-300 px-2 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-red-600"
value={slot.start_time}
onChange={(e) =>
updateScheduleSlot(index, "start_time", e.target.value)
}
/>
</div>
<div className="flex-1">
<label className="block text-xs font-medium text-stone-700 mb-1">
To
</label>
<input
type="time"
className="w-full rounded-md border border-stone-300 px-2 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-red-600"
value={slot.end_time}
onChange={(e) =>
updateScheduleSlot(index, "end_time", e.target.value)
}
/>
</div>
</div>
{/* Remove */}
{formData.schedule.length > 1 && (
<button
type="button"
onClick={() => removeScheduleSlot(index)}
className="self-start rounded-md p-2 text-red-600 hover:bg-red-50"
>
<Trash2 className="w-4 h-4" />
</button>
)}
</div>
</div>
))}
</div>
</div>
{/* Actions */}
<div className="flex gap-3 pt-2">
<button
type="submit"
disabled={isLoading}
className="inline-flex items-center gap-2 rounded-md bg-red-600 px-4 py-2 text-sm font-medium text-white hover:bg-red-700 disabled:opacity-60"
>
{isLoading ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<Save className="w-4 h-4" />
)}
{classData ? "Update Class" : "Create Class"}
</button>
<button
type="button"
onClick={onCancel}
disabled={isLoading}
className="inline-flex items-center gap-2 rounded-md border border-stone-300 px-4 py-2 text-sm font-medium text-stone-800 hover:bg-stone-50 disabled:opacity-60"
>
<X className="w-4 h-4" />
Cancel
</button>
</div>
</form>
);
}