Some checks failed
Build & Release / build (push) Failing after 33s
Each destination route's search must match its validateSearch shape exactly:
- Detail pages (tab-based): { tab: '...' }
- List pages with extra filters: include status, instructorId, view, categoryId etc.
- Form pages (enrollments/new, repairs/new): include only their specific fields
- use-pagination.ts: fix search reducer to use (prev: any) instead of invalid cast
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
293 lines
13 KiB
TypeScript
293 lines
13 KiB
TypeScript
import { useState, useEffect } from 'react'
|
|
import { createFileRoute, useNavigate } from '@tanstack/react-router'
|
|
import { useQuery, useMutation } from '@tanstack/react-query'
|
|
import { globalMemberListOptions } from '@/api/members'
|
|
import { scheduleSlotListOptions, enrollmentMutations, instructorListOptions, lessonTypeListOptions } from '@/api/lessons'
|
|
import { Button } from '@/components/ui/button'
|
|
import { Input } from '@/components/ui/input'
|
|
import { Label } from '@/components/ui/label'
|
|
import { Textarea } from '@/components/ui/textarea'
|
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
|
import { ArrowLeft, Search, X } from 'lucide-react'
|
|
import { toast } from 'sonner'
|
|
import type { MemberWithAccount } from '@/api/members'
|
|
import type { ScheduleSlot, LessonType, Instructor } from '@/types/lesson'
|
|
|
|
export const Route = createFileRoute('/_authenticated/lessons/enrollments/new')({
|
|
validateSearch: (search: Record<string, unknown>) => ({
|
|
memberId: (search.memberId as string) || undefined,
|
|
accountId: (search.accountId as string) || undefined,
|
|
}),
|
|
component: NewEnrollmentPage,
|
|
})
|
|
|
|
const DAYS = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday']
|
|
|
|
const BILLING_UNITS = [
|
|
{ value: 'day', label: 'Day(s)' },
|
|
{ value: 'week', label: 'Week(s)' },
|
|
{ value: 'month', label: 'Month(s)' },
|
|
{ value: 'quarter', label: 'Quarter(s)' },
|
|
{ value: 'year', label: 'Year(s)' },
|
|
] as const
|
|
|
|
function formatSlotLabel(slot: ScheduleSlot, instructors: Instructor[], lessonTypes: LessonType[]) {
|
|
const instructor = instructors.find((i) => i.id === slot.instructorId)
|
|
const lessonType = lessonTypes.find((lt) => lt.id === slot.lessonTypeId)
|
|
const [h, m] = slot.startTime.split(':').map(Number)
|
|
const ampm = h >= 12 ? 'PM' : 'AM'
|
|
const hour = h % 12 || 12
|
|
const time = `${hour}:${String(m).padStart(2, '0')} ${ampm}`
|
|
const day = DAYS[slot.dayOfWeek]
|
|
return `${day} ${time} — ${lessonType?.name ?? 'Unknown'} (${instructor?.displayName ?? 'Unknown'})`
|
|
}
|
|
|
|
/** Returns the preset rate for a given cycle from slot (falling back to lesson type) */
|
|
function getPresetRate(
|
|
billingInterval: string,
|
|
billingUnit: string,
|
|
slot: ScheduleSlot | undefined,
|
|
lessonType: LessonType | undefined,
|
|
): string {
|
|
if (!slot) return ''
|
|
const isPreset = billingInterval === '1'
|
|
if (!isPreset) return ''
|
|
if (billingUnit === 'week') return slot.rateWeekly ?? lessonType?.rateWeekly ?? ''
|
|
if (billingUnit === 'month') return slot.rateMonthly ?? lessonType?.rateMonthly ?? ''
|
|
if (billingUnit === 'quarter') return slot.rateQuarterly ?? lessonType?.rateQuarterly ?? ''
|
|
return ''
|
|
}
|
|
|
|
function NewEnrollmentPage() {
|
|
const navigate = useNavigate()
|
|
|
|
const [memberSearch, setMemberSearch] = useState('')
|
|
const [showMemberDropdown, setShowMemberDropdown] = useState(false)
|
|
const [selectedMember, setSelectedMember] = useState<MemberWithAccount | null>(null)
|
|
|
|
const [selectedSlotId, setSelectedSlotId] = useState('')
|
|
const [startDate, setStartDate] = useState('')
|
|
const [billingInterval, setBillingInterval] = useState('1')
|
|
const [billingUnit, setBillingUnit] = useState('month')
|
|
const [rate, setRate] = useState('')
|
|
const [rateManual, setRateManual] = useState(false)
|
|
const [notes, setNotes] = useState('')
|
|
|
|
const { data: membersData } = useQuery(
|
|
globalMemberListOptions({ page: 1, limit: 20, q: memberSearch || undefined, order: 'asc', sort: 'first_name' }),
|
|
)
|
|
|
|
const { data: slotsData } = useQuery(scheduleSlotListOptions({ page: 1, limit: 100, order: 'asc' }))
|
|
const { data: instructorsData } = useQuery(instructorListOptions({ page: 1, limit: 100, order: 'asc' }))
|
|
const { data: lessonTypesData } = useQuery(lessonTypeListOptions({ page: 1, limit: 100, order: 'asc' }))
|
|
|
|
const slots = slotsData?.data?.filter((s) => s.isActive) ?? []
|
|
const instructors = instructorsData?.data ?? []
|
|
const lessonTypes = lessonTypesData?.data ?? []
|
|
|
|
const selectedSlot = slots.find((s) => s.id === selectedSlotId)
|
|
const selectedLessonType = lessonTypes.find((lt) => lt.id === selectedSlot?.lessonTypeId)
|
|
|
|
// Auto-fill rate from slot/lesson-type presets when slot or cycle changes, unless user has manually edited
|
|
useEffect(() => {
|
|
if (rateManual) return
|
|
const preset = getPresetRate(billingInterval, billingUnit, selectedSlot, selectedLessonType)
|
|
setRate(preset ? String(preset) : '')
|
|
}, [selectedSlotId, billingInterval, billingUnit, selectedSlot, selectedLessonType, rateManual])
|
|
|
|
const mutation = useMutation({
|
|
mutationFn: async (data: Record<string, unknown>) => {
|
|
const enrollment = await enrollmentMutations.create(data)
|
|
try {
|
|
await enrollmentMutations.generateSessions(enrollment.id, 4)
|
|
} catch {
|
|
// non-fatal — sessions can be generated later
|
|
}
|
|
return enrollment
|
|
},
|
|
onSuccess: (enrollment) => {
|
|
toast.success('Enrollment created')
|
|
navigate({ to: '/lessons/enrollments/$enrollmentId', params: { enrollmentId: enrollment.id }, search: { tab: 'details' } })
|
|
},
|
|
onError: (err) => toast.error(err.message),
|
|
})
|
|
|
|
function selectMember(member: MemberWithAccount) {
|
|
setSelectedMember(member)
|
|
setShowMemberDropdown(false)
|
|
setMemberSearch('')
|
|
}
|
|
|
|
function handleSubmit(e: React.FormEvent) {
|
|
e.preventDefault()
|
|
if (!selectedMember || !selectedSlotId || !startDate) return
|
|
|
|
mutation.mutate({
|
|
memberId: selectedMember.id,
|
|
accountId: selectedMember.accountId,
|
|
scheduleSlotId: selectedSlotId,
|
|
instructorId: selectedSlot?.instructorId,
|
|
startDate,
|
|
rate: rate || undefined,
|
|
billingInterval: billingInterval ? Number(billingInterval) : undefined,
|
|
billingUnit: billingUnit || undefined,
|
|
notes: notes || undefined,
|
|
})
|
|
}
|
|
|
|
const members = membersData?.data ?? []
|
|
|
|
return (
|
|
<div className="space-y-6 max-w-2xl">
|
|
<div className="flex items-center gap-3">
|
|
<Button variant="ghost" size="sm" onClick={() => navigate({ to: '/lessons/enrollments', search: { page: 1, limit: 25, q: undefined, sort: undefined, order: 'desc' as const, status: undefined, instructorId: undefined } })}>
|
|
<ArrowLeft className="h-4 w-4" />
|
|
</Button>
|
|
<h1 className="text-2xl font-bold">New Enrollment</h1>
|
|
</div>
|
|
|
|
<form onSubmit={handleSubmit} className="space-y-6">
|
|
{/* Student */}
|
|
<Card>
|
|
<CardHeader><CardTitle className="text-lg">Student</CardTitle></CardHeader>
|
|
<CardContent>
|
|
{!selectedMember ? (
|
|
<div className="relative">
|
|
<Label>Search Member</Label>
|
|
<div className="relative mt-1">
|
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
|
<Input
|
|
placeholder="Type name to search..."
|
|
value={memberSearch}
|
|
onChange={(e) => { setMemberSearch(e.target.value); setShowMemberDropdown(true) }}
|
|
onFocus={() => setShowMemberDropdown(true)}
|
|
className="pl-9"
|
|
/>
|
|
</div>
|
|
{showMemberDropdown && memberSearch.length > 0 && (
|
|
<div className="absolute z-50 mt-1 w-full rounded-md border bg-popover shadow-lg max-h-60 overflow-auto">
|
|
{members.length === 0 ? (
|
|
<div className="p-3 text-sm text-muted-foreground">No members found</div>
|
|
) : (
|
|
members.map((m) => (
|
|
<button
|
|
key={m.id}
|
|
type="button"
|
|
className="w-full text-left px-3 py-2 text-sm hover:bg-accent"
|
|
onClick={() => selectMember(m)}
|
|
>
|
|
<span className="font-medium">{m.firstName} {m.lastName}</span>
|
|
{m.accountName && <span className="text-muted-foreground ml-2">— {m.accountName}</span>}
|
|
</button>
|
|
))
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
) : (
|
|
<div className="flex items-center justify-between p-3 rounded-md border bg-muted/30">
|
|
<div>
|
|
<p className="font-medium">{selectedMember.firstName} {selectedMember.lastName}</p>
|
|
{selectedMember.accountName && (
|
|
<p className="text-sm text-muted-foreground">{selectedMember.accountName}</p>
|
|
)}
|
|
</div>
|
|
<Button type="button" variant="ghost" size="sm" onClick={() => setSelectedMember(null)}>
|
|
<X className="h-4 w-4" />
|
|
</Button>
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* Schedule Slot */}
|
|
<Card>
|
|
<CardHeader><CardTitle className="text-lg">Schedule Slot</CardTitle></CardHeader>
|
|
<CardContent>
|
|
<div className="space-y-2">
|
|
<Label>Select Slot *</Label>
|
|
<Select value={selectedSlotId} onValueChange={(v) => { setSelectedSlotId(v); setRateManual(false) }}>
|
|
<SelectTrigger>
|
|
<SelectValue placeholder="Choose a time slot..." />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{slots.map((slot) => (
|
|
<SelectItem key={slot.id} value={slot.id}>
|
|
{formatSlotLabel(slot, instructors, lessonTypes)}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* Terms */}
|
|
<Card>
|
|
<CardHeader><CardTitle className="text-lg">Terms</CardTitle></CardHeader>
|
|
<CardContent className="space-y-4">
|
|
<div className="space-y-2">
|
|
<Label htmlFor="startDate">Start Date *</Label>
|
|
<Input id="startDate" type="date" value={startDate} onChange={(e) => setStartDate(e.target.value)} required className="max-w-xs" />
|
|
</div>
|
|
<div>
|
|
<Label className="block mb-2">Billing Cycle</Label>
|
|
<div className="flex items-center gap-2">
|
|
<Input
|
|
type="number"
|
|
min={1}
|
|
value={billingInterval}
|
|
onChange={(e) => { setBillingInterval(e.target.value); setRateManual(false) }}
|
|
className="w-20"
|
|
/>
|
|
<Select value={billingUnit} onValueChange={(v) => { setBillingUnit(v); setRateManual(false) }}>
|
|
<SelectTrigger className="w-36">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{BILLING_UNITS.map((u) => (
|
|
<SelectItem key={u.value} value={u.value}>{u.label}</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
</div>
|
|
<div className="space-y-1">
|
|
<Label htmlFor="rate">Rate</Label>
|
|
<div className="flex items-center gap-2 max-w-xs">
|
|
<span className="text-muted-foreground">$</span>
|
|
<Input
|
|
id="rate"
|
|
type="number"
|
|
step="0.01"
|
|
min="0"
|
|
value={rate}
|
|
onChange={(e) => { setRate(e.target.value); setRateManual(true) }}
|
|
placeholder="Auto-filled from slot"
|
|
/>
|
|
</div>
|
|
{!rateManual && rate && (
|
|
<p className="text-xs text-muted-foreground">Auto-filled from slot rates</p>
|
|
)}
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label htmlFor="notes">Notes</Label>
|
|
<Textarea id="notes" value={notes} onChange={(e) => setNotes(e.target.value)} rows={2} placeholder="Internal notes..." />
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<div className="flex gap-2">
|
|
<Button type="submit" disabled={mutation.isPending || !selectedMember || !selectedSlotId || !startDate} size="lg">
|
|
{mutation.isPending ? 'Creating...' : 'Create Enrollment'}
|
|
</Button>
|
|
<Button variant="secondary" type="button" size="lg" onClick={() => navigate({ to: '/lessons/enrollments', search: { page: 1, limit: 25, q: undefined, sort: undefined, order: 'desc' as const, status: undefined, instructorId: undefined } })}>
|
|
Cancel
|
|
</Button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
)
|
|
}
|