| | |
| | 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 }) { |
| | |
| | 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: [], |
| | }); |
| |
|
| | |
| | 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(); |
| |
|
| | |
| | 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> |
| | ); |
| | } |
| |
|