| |
| 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> |
| ); |
| } |
|
|