Add lessons module, rate cycles, EC2 deploy scripts, and help content
- Lessons module: lesson types, instructors, schedule slots, enrollments, sessions (list + week grid view), lesson plans, grading scales, templates - Rate cycles: replace monthly_rate with billing_interval + billing_unit on enrollments; add weekly/monthly/quarterly rate presets to lesson types and schedule slots with auto-fill on enrollment form - Member detail page: tabbed layout for details, identity documents, enrollments - Sessions week view: custom 7-column grid replacing react-big-calendar - Music store seed: instructors, lesson types, slots, enrollments, sessions, grading scale, lesson plan template - Scrollbar styling: themed to match sidebar/app palette - deploy/: EC2 setup and redeploy scripts, nginx config, systemd service - Help: add Lessons category (overview, types, instructors, slots, enrollments, sessions, plans/grading); collapsible sidebar with independent scroll; remove POS/accounting references from docs
This commit is contained in:
341
packages/admin/src/api/lessons.ts
Normal file
341
packages/admin/src/api/lessons.ts
Normal file
@@ -0,0 +1,341 @@
|
||||
import { queryOptions } from '@tanstack/react-query'
|
||||
import { api } from '@/lib/api-client'
|
||||
import type {
|
||||
Instructor,
|
||||
InstructorBlockedDate,
|
||||
LessonType,
|
||||
ScheduleSlot,
|
||||
Enrollment,
|
||||
LessonSession,
|
||||
GradingScale,
|
||||
LessonPlan,
|
||||
LessonPlanItem,
|
||||
LessonPlanItemGradeHistory,
|
||||
LessonPlanTemplate,
|
||||
StoreClosure,
|
||||
SessionPlanItem,
|
||||
} from '@/types/lesson'
|
||||
import type { PaginatedResponse, PaginationInput } from '@lunarfront/shared/schemas'
|
||||
|
||||
// --- Instructors ---
|
||||
|
||||
export const instructorKeys = {
|
||||
all: ['instructors'] as const,
|
||||
list: (params: PaginationInput) => [...instructorKeys.all, 'list', params] as const,
|
||||
detail: (id: string) => [...instructorKeys.all, 'detail', id] as const,
|
||||
blockedDates: (id: string) => [...instructorKeys.all, id, 'blocked-dates'] as const,
|
||||
}
|
||||
|
||||
export function instructorListOptions(params: PaginationInput) {
|
||||
return queryOptions({
|
||||
queryKey: instructorKeys.list(params),
|
||||
queryFn: () => api.get<PaginatedResponse<Instructor>>('/v1/instructors', params),
|
||||
})
|
||||
}
|
||||
|
||||
export function instructorDetailOptions(id: string) {
|
||||
return queryOptions({
|
||||
queryKey: instructorKeys.detail(id),
|
||||
queryFn: () => api.get<Instructor>(`/v1/instructors/${id}`),
|
||||
enabled: !!id,
|
||||
})
|
||||
}
|
||||
|
||||
export function instructorBlockedDatesOptions(instructorId: string) {
|
||||
return queryOptions({
|
||||
queryKey: instructorKeys.blockedDates(instructorId),
|
||||
queryFn: () => api.get<InstructorBlockedDate[]>(`/v1/instructors/${instructorId}/blocked-dates`),
|
||||
enabled: !!instructorId,
|
||||
})
|
||||
}
|
||||
|
||||
export const instructorMutations = {
|
||||
create: (data: Record<string, unknown>) =>
|
||||
api.post<Instructor>('/v1/instructors', data),
|
||||
update: (id: string, data: Record<string, unknown>) =>
|
||||
api.patch<Instructor>(`/v1/instructors/${id}`, data),
|
||||
delete: (id: string) =>
|
||||
api.del<Instructor>(`/v1/instructors/${id}`),
|
||||
addBlockedDate: (instructorId: string, data: Record<string, unknown>) =>
|
||||
api.post<InstructorBlockedDate>(`/v1/instructors/${instructorId}/blocked-dates`, data),
|
||||
deleteBlockedDate: (instructorId: string, id: string) =>
|
||||
api.del<InstructorBlockedDate>(`/v1/instructors/${instructorId}/blocked-dates/${id}`),
|
||||
}
|
||||
|
||||
// --- Lesson Types ---
|
||||
|
||||
export const lessonTypeKeys = {
|
||||
all: ['lesson-types'] as const,
|
||||
list: (params: PaginationInput) => [...lessonTypeKeys.all, 'list', params] as const,
|
||||
detail: (id: string) => [...lessonTypeKeys.all, 'detail', id] as const,
|
||||
}
|
||||
|
||||
export function lessonTypeListOptions(params: PaginationInput) {
|
||||
return queryOptions({
|
||||
queryKey: lessonTypeKeys.list(params),
|
||||
queryFn: () => api.get<PaginatedResponse<LessonType>>('/v1/lesson-types', params),
|
||||
})
|
||||
}
|
||||
|
||||
export const lessonTypeMutations = {
|
||||
create: (data: Record<string, unknown>) =>
|
||||
api.post<LessonType>('/v1/lesson-types', data),
|
||||
update: (id: string, data: Record<string, unknown>) =>
|
||||
api.patch<LessonType>(`/v1/lesson-types/${id}`, data),
|
||||
delete: (id: string) =>
|
||||
api.del<LessonType>(`/v1/lesson-types/${id}`),
|
||||
}
|
||||
|
||||
// --- Schedule Slots ---
|
||||
|
||||
export const scheduleSlotKeys = {
|
||||
all: ['schedule-slots'] as const,
|
||||
list: (params: PaginationInput) => [...scheduleSlotKeys.all, 'list', params] as const,
|
||||
byInstructor: (instructorId: string, params: PaginationInput) =>
|
||||
[...scheduleSlotKeys.all, 'instructor', instructorId, params] as const,
|
||||
detail: (id: string) => [...scheduleSlotKeys.all, 'detail', id] as const,
|
||||
}
|
||||
|
||||
export function scheduleSlotListOptions(params: PaginationInput, filters?: { instructorId?: string; dayOfWeek?: number }) {
|
||||
const query = { ...params, ...filters }
|
||||
return queryOptions({
|
||||
queryKey: filters?.instructorId
|
||||
? scheduleSlotKeys.byInstructor(filters.instructorId, params)
|
||||
: scheduleSlotKeys.list(params),
|
||||
queryFn: () => api.get<PaginatedResponse<ScheduleSlot>>('/v1/schedule-slots', query),
|
||||
})
|
||||
}
|
||||
|
||||
export const scheduleSlotMutations = {
|
||||
create: (data: Record<string, unknown>) =>
|
||||
api.post<ScheduleSlot>('/v1/schedule-slots', data),
|
||||
update: (id: string, data: Record<string, unknown>) =>
|
||||
api.patch<ScheduleSlot>(`/v1/schedule-slots/${id}`, data),
|
||||
delete: (id: string) =>
|
||||
api.del<ScheduleSlot>(`/v1/schedule-slots/${id}`),
|
||||
}
|
||||
|
||||
// --- Enrollments ---
|
||||
|
||||
export const enrollmentKeys = {
|
||||
all: ['enrollments'] as const,
|
||||
list: (params: Record<string, unknown>) => [...enrollmentKeys.all, 'list', params] as const,
|
||||
detail: (id: string) => [...enrollmentKeys.all, 'detail', id] as const,
|
||||
}
|
||||
|
||||
export function enrollmentListOptions(params: Record<string, unknown>) {
|
||||
return queryOptions({
|
||||
queryKey: enrollmentKeys.list(params),
|
||||
queryFn: () => api.get<PaginatedResponse<Enrollment>>('/v1/enrollments', params),
|
||||
})
|
||||
}
|
||||
|
||||
export function enrollmentDetailOptions(id: string) {
|
||||
return queryOptions({
|
||||
queryKey: enrollmentKeys.detail(id),
|
||||
queryFn: () => api.get<Enrollment>(`/v1/enrollments/${id}`),
|
||||
enabled: !!id,
|
||||
})
|
||||
}
|
||||
|
||||
export const enrollmentMutations = {
|
||||
create: (data: Record<string, unknown>) =>
|
||||
api.post<Enrollment>('/v1/enrollments', data),
|
||||
update: (id: string, data: Record<string, unknown>) =>
|
||||
api.patch<Enrollment>(`/v1/enrollments/${id}`, data),
|
||||
updateStatus: (id: string, status: string) =>
|
||||
api.post<Enrollment>(`/v1/enrollments/${id}/status`, { status }),
|
||||
generateSessions: (id: string, weeks?: number) =>
|
||||
api.post<{ generated: number; sessions: LessonSession[] }>(
|
||||
`/v1/enrollments/${id}/generate-sessions${weeks ? `?weeks=${weeks}` : ''}`,
|
||||
{},
|
||||
),
|
||||
}
|
||||
|
||||
// --- Lesson Sessions ---
|
||||
|
||||
export const sessionKeys = {
|
||||
all: ['lesson-sessions'] as const,
|
||||
list: (params: Record<string, unknown>) => [...sessionKeys.all, 'list', params] as const,
|
||||
detail: (id: string) => [...sessionKeys.all, 'detail', id] as const,
|
||||
planItems: (id: string) => [...sessionKeys.all, id, 'plan-items'] as const,
|
||||
}
|
||||
|
||||
export function sessionListOptions(params: Record<string, unknown>) {
|
||||
return queryOptions({
|
||||
queryKey: sessionKeys.list(params),
|
||||
queryFn: () => api.get<PaginatedResponse<LessonSession>>('/v1/lesson-sessions', params),
|
||||
})
|
||||
}
|
||||
|
||||
export function sessionDetailOptions(id: string) {
|
||||
return queryOptions({
|
||||
queryKey: sessionKeys.detail(id),
|
||||
queryFn: () => api.get<LessonSession>(`/v1/lesson-sessions/${id}`),
|
||||
enabled: !!id,
|
||||
})
|
||||
}
|
||||
|
||||
export function sessionPlanItemsOptions(sessionId: string) {
|
||||
return queryOptions({
|
||||
queryKey: sessionKeys.planItems(sessionId),
|
||||
queryFn: () => api.get<SessionPlanItem[]>(`/v1/lesson-sessions/${sessionId}/plan-items`),
|
||||
enabled: !!sessionId,
|
||||
})
|
||||
}
|
||||
|
||||
export const sessionMutations = {
|
||||
update: (id: string, data: Record<string, unknown>) =>
|
||||
api.patch<LessonSession>(`/v1/lesson-sessions/${id}`, data),
|
||||
updateStatus: (id: string, status: string) =>
|
||||
api.post<LessonSession>(`/v1/lesson-sessions/${id}/status`, { status }),
|
||||
updateNotes: (id: string, data: Record<string, unknown>) =>
|
||||
api.post<LessonSession>(`/v1/lesson-sessions/${id}/notes`, data),
|
||||
linkPlanItems: (id: string, lessonPlanItemIds: string[]) =>
|
||||
api.post<{ linked: number; items: SessionPlanItem[] }>(`/v1/lesson-sessions/${id}/plan-items`, { lessonPlanItemIds }),
|
||||
}
|
||||
|
||||
// --- Grading Scales ---
|
||||
|
||||
export const gradingScaleKeys = {
|
||||
all: ['grading-scales'] as const,
|
||||
list: (params: PaginationInput) => [...gradingScaleKeys.all, 'list', params] as const,
|
||||
allScales: [...['grading-scales'], 'all'] as const,
|
||||
detail: (id: string) => [...gradingScaleKeys.all, 'detail', id] as const,
|
||||
}
|
||||
|
||||
export function gradingScaleListOptions(params: PaginationInput) {
|
||||
return queryOptions({
|
||||
queryKey: gradingScaleKeys.list(params),
|
||||
queryFn: () => api.get<PaginatedResponse<GradingScale>>('/v1/grading-scales', params),
|
||||
})
|
||||
}
|
||||
|
||||
export function gradingScaleAllOptions() {
|
||||
return queryOptions({
|
||||
queryKey: gradingScaleKeys.allScales,
|
||||
queryFn: () => api.get<GradingScale[]>('/v1/grading-scales/all'),
|
||||
})
|
||||
}
|
||||
|
||||
export function gradingScaleDetailOptions(id: string) {
|
||||
return queryOptions({
|
||||
queryKey: gradingScaleKeys.detail(id),
|
||||
queryFn: () => api.get<GradingScale>(`/v1/grading-scales/${id}`),
|
||||
enabled: !!id,
|
||||
})
|
||||
}
|
||||
|
||||
export const gradingScaleMutations = {
|
||||
create: (data: Record<string, unknown>) =>
|
||||
api.post<GradingScale>('/v1/grading-scales', data),
|
||||
update: (id: string, data: Record<string, unknown>) =>
|
||||
api.patch<GradingScale>(`/v1/grading-scales/${id}`, data),
|
||||
delete: (id: string) =>
|
||||
api.del<GradingScale>(`/v1/grading-scales/${id}`),
|
||||
}
|
||||
|
||||
// --- Lesson Plans ---
|
||||
|
||||
export const lessonPlanKeys = {
|
||||
all: ['lesson-plans'] as const,
|
||||
list: (params: Record<string, unknown>) => [...lessonPlanKeys.all, 'list', params] as const,
|
||||
detail: (id: string) => [...lessonPlanKeys.all, 'detail', id] as const,
|
||||
}
|
||||
|
||||
export function lessonPlanListOptions(params: Record<string, unknown>) {
|
||||
return queryOptions({
|
||||
queryKey: lessonPlanKeys.list(params),
|
||||
queryFn: () => api.get<PaginatedResponse<LessonPlan>>('/v1/lesson-plans', params),
|
||||
})
|
||||
}
|
||||
|
||||
export function lessonPlanDetailOptions(id: string) {
|
||||
return queryOptions({
|
||||
queryKey: lessonPlanKeys.detail(id),
|
||||
queryFn: () => api.get<LessonPlan>(`/v1/lesson-plans/${id}`),
|
||||
enabled: !!id,
|
||||
})
|
||||
}
|
||||
|
||||
export const lessonPlanMutations = {
|
||||
create: (data: Record<string, unknown>) =>
|
||||
api.post<LessonPlan>('/v1/lesson-plans', data),
|
||||
update: (id: string, data: Record<string, unknown>) =>
|
||||
api.patch<LessonPlan>(`/v1/lesson-plans/${id}`, data),
|
||||
}
|
||||
|
||||
// --- Lesson Plan Items ---
|
||||
|
||||
export const lessonPlanItemKeys = {
|
||||
gradeHistory: (itemId: string) => ['lesson-plan-items', itemId, 'grade-history'] as const,
|
||||
}
|
||||
|
||||
export function lessonPlanItemGradeHistoryOptions(itemId: string) {
|
||||
return queryOptions({
|
||||
queryKey: lessonPlanItemKeys.gradeHistory(itemId),
|
||||
queryFn: () => api.get<LessonPlanItemGradeHistory[]>(`/v1/lesson-plan-items/${itemId}/grade-history`),
|
||||
enabled: !!itemId,
|
||||
})
|
||||
}
|
||||
|
||||
export const lessonPlanItemMutations = {
|
||||
update: (id: string, data: Record<string, unknown>) =>
|
||||
api.patch<LessonPlanItem>(`/v1/lesson-plan-items/${id}`, data),
|
||||
addGrade: (id: string, data: Record<string, unknown>) =>
|
||||
api.post<{ record: LessonPlanItemGradeHistory; item: LessonPlanItem }>(`/v1/lesson-plan-items/${id}/grades`, data),
|
||||
}
|
||||
|
||||
// --- Lesson Plan Templates ---
|
||||
|
||||
export const lessonPlanTemplateKeys = {
|
||||
all: ['lesson-plan-templates'] as const,
|
||||
list: (params: PaginationInput) => [...lessonPlanTemplateKeys.all, 'list', params] as const,
|
||||
detail: (id: string) => [...lessonPlanTemplateKeys.all, 'detail', id] as const,
|
||||
}
|
||||
|
||||
export function lessonPlanTemplateListOptions(params: PaginationInput) {
|
||||
return queryOptions({
|
||||
queryKey: lessonPlanTemplateKeys.list(params),
|
||||
queryFn: () => api.get<PaginatedResponse<LessonPlanTemplate>>('/v1/lesson-plan-templates', params),
|
||||
})
|
||||
}
|
||||
|
||||
export function lessonPlanTemplateDetailOptions(id: string) {
|
||||
return queryOptions({
|
||||
queryKey: lessonPlanTemplateKeys.detail(id),
|
||||
queryFn: () => api.get<LessonPlanTemplate>(`/v1/lesson-plan-templates/${id}`),
|
||||
enabled: !!id,
|
||||
})
|
||||
}
|
||||
|
||||
export const lessonPlanTemplateMutations = {
|
||||
create: (data: Record<string, unknown>) =>
|
||||
api.post<LessonPlanTemplate>('/v1/lesson-plan-templates', data),
|
||||
update: (id: string, data: Record<string, unknown>) =>
|
||||
api.patch<LessonPlanTemplate>(`/v1/lesson-plan-templates/${id}`, data),
|
||||
delete: (id: string) =>
|
||||
api.del<LessonPlanTemplate>(`/v1/lesson-plan-templates/${id}`),
|
||||
createPlan: (templateId: string, data: Record<string, unknown>) =>
|
||||
api.post<LessonPlan>(`/v1/lesson-plan-templates/${templateId}/create-plan`, data),
|
||||
}
|
||||
|
||||
// --- Store Closures ---
|
||||
|
||||
export const storeClosureKeys = {
|
||||
all: ['store-closures'] as const,
|
||||
}
|
||||
|
||||
export function storeClosureListOptions() {
|
||||
return queryOptions({
|
||||
queryKey: storeClosureKeys.all,
|
||||
queryFn: () => api.get<StoreClosure[]>('/v1/store-closures'),
|
||||
})
|
||||
}
|
||||
|
||||
export const storeClosureMutations = {
|
||||
create: (data: Record<string, unknown>) =>
|
||||
api.post<StoreClosure>('/v1/store-closures', data),
|
||||
delete: (id: string) =>
|
||||
api.del<StoreClosure>(`/v1/store-closures/${id}`),
|
||||
}
|
||||
@@ -89,6 +89,30 @@ body {
|
||||
transition: background-color 5000s ease-in-out 0s;
|
||||
}
|
||||
|
||||
/* Scrollbars — themed to match sidebar/app palette */
|
||||
* {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--border) transparent;
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar-thumb {
|
||||
background-color: var(--border);
|
||||
border-radius: 9999px;
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar-thumb:hover {
|
||||
background-color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
/* Prevent browser autofill from overriding dark theme input colors */
|
||||
input:-webkit-autofill,
|
||||
input:-webkit-autofill:hover,
|
||||
|
||||
45
packages/admin/src/components/lessons/blocked-date-form.tsx
Normal file
45
packages/admin/src/components/lessons/blocked-date-form.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
|
||||
interface Props {
|
||||
onSubmit: (data: Record<string, unknown>) => void
|
||||
loading?: boolean
|
||||
}
|
||||
|
||||
export function BlockedDateForm({ onSubmit, loading }: Props) {
|
||||
const { register, handleSubmit } = useForm({
|
||||
defaultValues: { startDate: '', endDate: '', reason: '' },
|
||||
})
|
||||
|
||||
function handleFormSubmit(data: { startDate: string; endDate: string; reason: string }) {
|
||||
onSubmit({
|
||||
startDate: data.startDate,
|
||||
endDate: data.endDate,
|
||||
reason: data.reason || undefined,
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(handleFormSubmit)} className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="bd-start">Start Date *</Label>
|
||||
<Input id="bd-start" type="date" {...register('startDate')} required />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="bd-end">End Date *</Label>
|
||||
<Input id="bd-end" type="date" {...register('endDate')} required />
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="bd-reason">Reason</Label>
|
||||
<Input id="bd-reason" {...register('reason')} placeholder="e.g. Vacation, Conference" />
|
||||
</div>
|
||||
<Button type="submit" disabled={loading} className="w-full">
|
||||
{loading ? 'Saving...' : 'Add Blocked Date'}
|
||||
</Button>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
127
packages/admin/src/components/lessons/grade-entry-dialog.tsx
Normal file
127
packages/admin/src/components/lessons/grade-entry-dialog.tsx
Normal file
@@ -0,0 +1,127 @@
|
||||
import { useState } from 'react'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { gradingScaleAllOptions, lessonPlanItemMutations, lessonPlanItemGradeHistoryOptions, lessonPlanItemKeys } from '@/api/lessons'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||
import { toast } from 'sonner'
|
||||
import type { LessonPlanItem } from '@/types/lesson'
|
||||
|
||||
interface Props {
|
||||
item: LessonPlanItem
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export function GradeEntryDialog({ item, open, onClose }: Props) {
|
||||
const queryClient = useQueryClient()
|
||||
const [selectedScaleId, setSelectedScaleId] = useState(item.gradingScaleId ?? '')
|
||||
const [selectedValue, setSelectedValue] = useState('')
|
||||
const [gradeNotes, setGradeNotes] = useState('')
|
||||
|
||||
const { data: scales } = useQuery(gradingScaleAllOptions())
|
||||
const { data: history } = useQuery(lessonPlanItemGradeHistoryOptions(item.id))
|
||||
|
||||
const selectedScale = scales?.find((s) => s.id === selectedScaleId)
|
||||
|
||||
const gradeMutation = useMutation({
|
||||
mutationFn: () =>
|
||||
lessonPlanItemMutations.addGrade(item.id, {
|
||||
gradingScaleId: selectedScaleId || undefined,
|
||||
gradeValue: selectedValue,
|
||||
notes: gradeNotes || undefined,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: lessonPlanItemKeys.gradeHistory(item.id) })
|
||||
toast.success('Grade recorded')
|
||||
setSelectedValue('')
|
||||
setGradeNotes('')
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
})
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(o) => { if (!o) onClose() }}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Grade: {item.title}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Grading Scale</Label>
|
||||
<Select value={selectedScaleId || 'none'} onValueChange={(v) => { setSelectedScaleId(v === 'none' ? '' : v); setSelectedValue('') }}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="No scale (freeform)" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">No scale (freeform)</SelectItem>
|
||||
{(scales ?? []).map((s) => (
|
||||
<SelectItem key={s.id} value={s.id}>{s.name}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Grade Value *</Label>
|
||||
{selectedScale ? (
|
||||
<Select value={selectedValue} onValueChange={setSelectedValue}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select grade..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{[...selectedScale.levels].sort((a, b) => a.sortOrder - b.sortOrder).map((level) => (
|
||||
<SelectItem key={level.id} value={level.value}>
|
||||
{level.value} — {level.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
<input
|
||||
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-xs transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
value={selectedValue}
|
||||
onChange={(e) => setSelectedValue(e.target.value)}
|
||||
placeholder="Enter grade (e.g. A, Pass, 85)"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Notes</Label>
|
||||
<Textarea value={gradeNotes} onChange={(e) => setGradeNotes(e.target.value)} rows={2} />
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={() => gradeMutation.mutate()}
|
||||
disabled={!selectedValue || gradeMutation.isPending}
|
||||
className="w-full"
|
||||
>
|
||||
{gradeMutation.isPending ? 'Recording...' : 'Record Grade'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Grade History */}
|
||||
{(history ?? []).length > 0 && (
|
||||
<div className="border-t pt-4 space-y-2">
|
||||
<p className="text-sm font-medium text-muted-foreground">Grade History</p>
|
||||
<div className="space-y-2 max-h-40 overflow-y-auto">
|
||||
{[...history!].reverse().map((h) => (
|
||||
<div key={h.id} className="flex items-start justify-between text-sm">
|
||||
<div>
|
||||
<span className="font-medium">{h.gradeValue}</span>
|
||||
{h.notes && <p className="text-xs text-muted-foreground">{h.notes}</p>}
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground">{new Date(h.createdAt).toLocaleDateString()}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
127
packages/admin/src/components/lessons/grading-scale-form.tsx
Normal file
127
packages/admin/src/components/lessons/grading-scale-form.tsx
Normal file
@@ -0,0 +1,127 @@
|
||||
import { useState } from 'react'
|
||||
import { useForm } from 'react-hook-form'
|
||||
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 { Trash2, Plus } from 'lucide-react'
|
||||
|
||||
interface LevelRow {
|
||||
value: string
|
||||
label: string
|
||||
numericValue: string
|
||||
colorHex: string
|
||||
}
|
||||
|
||||
interface Props {
|
||||
onSubmit: (data: Record<string, unknown>) => void
|
||||
loading?: boolean
|
||||
}
|
||||
|
||||
const DEFAULT_LEVELS: LevelRow[] = [
|
||||
{ value: 'A', label: 'Excellent', numericValue: '4', colorHex: '#22c55e' },
|
||||
{ value: 'B', label: 'Good', numericValue: '3', colorHex: '#84cc16' },
|
||||
{ value: 'C', label: 'Developing', numericValue: '2', colorHex: '#eab308' },
|
||||
{ value: 'D', label: 'Beginning', numericValue: '1', colorHex: '#f97316' },
|
||||
]
|
||||
|
||||
export function GradingScaleForm({ onSubmit, loading }: Props) {
|
||||
const { register, handleSubmit } = useForm({
|
||||
defaultValues: { name: '', description: '', isDefault: false },
|
||||
})
|
||||
const [levels, setLevels] = useState<LevelRow[]>(DEFAULT_LEVELS)
|
||||
|
||||
function addLevel() {
|
||||
setLevels((prev) => [...prev, { value: '', label: '', numericValue: String(prev.length + 1), colorHex: '' }])
|
||||
}
|
||||
|
||||
function removeLevel(idx: number) {
|
||||
setLevels((prev) => prev.filter((_, i) => i !== idx))
|
||||
}
|
||||
|
||||
function updateLevel(idx: number, field: keyof LevelRow, value: string) {
|
||||
setLevels((prev) => prev.map((l, i) => (i === idx ? { ...l, [field]: value } : l)))
|
||||
}
|
||||
|
||||
function handleFormSubmit(data: { name: string; description: string; isDefault: boolean }) {
|
||||
onSubmit({
|
||||
name: data.name,
|
||||
description: data.description || undefined,
|
||||
isDefault: data.isDefault,
|
||||
levels: levels.map((l, i) => ({
|
||||
value: l.value,
|
||||
label: l.label,
|
||||
numericValue: Number(l.numericValue) || i + 1,
|
||||
colorHex: l.colorHex || undefined,
|
||||
sortOrder: i,
|
||||
})),
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(handleFormSubmit)} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="gs-name">Name *</Label>
|
||||
<Input id="gs-name" {...register('name')} placeholder="e.g. RCM Performance Scale" required />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="gs-desc">Description</Label>
|
||||
<Textarea id="gs-desc" {...register('description')} rows={2} />
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<input type="checkbox" id="gs-default" {...register('isDefault')} className="h-4 w-4" />
|
||||
<Label htmlFor="gs-default">Set as default scale</Label>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label>Grade Levels</Label>
|
||||
<Button type="button" variant="outline" size="sm" onClick={addLevel}>
|
||||
<Plus className="h-3 w-3 mr-1" />Add Level
|
||||
</Button>
|
||||
</div>
|
||||
<div className="space-y-2 max-h-64 overflow-y-auto pr-1">
|
||||
{levels.map((level, idx) => (
|
||||
<div key={idx} className="grid grid-cols-[1fr_2fr_1fr_auto_auto] gap-2 items-center">
|
||||
<Input
|
||||
placeholder="Value"
|
||||
value={level.value}
|
||||
onChange={(e) => updateLevel(idx, 'value', e.target.value)}
|
||||
required
|
||||
/>
|
||||
<Input
|
||||
placeholder="Label"
|
||||
value={level.label}
|
||||
onChange={(e) => updateLevel(idx, 'label', e.target.value)}
|
||||
required
|
||||
/>
|
||||
<Input
|
||||
type="number"
|
||||
placeholder="Score"
|
||||
value={level.numericValue}
|
||||
onChange={(e) => updateLevel(idx, 'numericValue', e.target.value)}
|
||||
/>
|
||||
<input
|
||||
type="color"
|
||||
value={level.colorHex || '#888888'}
|
||||
onChange={(e) => updateLevel(idx, 'colorHex', e.target.value)}
|
||||
className="h-9 w-9 rounded border border-input cursor-pointer"
|
||||
title="Color"
|
||||
/>
|
||||
<Button type="button" variant="ghost" size="icon" onClick={() => removeLevel(idx)} className="h-9 w-9">
|
||||
<Trash2 className="h-4 w-4 text-destructive" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{levels.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground text-center py-2">No levels — add at least one.</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Button type="submit" disabled={loading || levels.length === 0} className="w-full">
|
||||
{loading ? 'Saving...' : 'Create Grading Scale'}
|
||||
</Button>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
52
packages/admin/src/components/lessons/instructor-form.tsx
Normal file
52
packages/admin/src/components/lessons/instructor-form.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
import { useForm } from 'react-hook-form'
|
||||
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 type { Instructor } from '@/types/lesson'
|
||||
|
||||
interface Props {
|
||||
defaultValues?: Partial<Instructor>
|
||||
onSubmit: (data: Record<string, unknown>) => void
|
||||
loading?: boolean
|
||||
}
|
||||
|
||||
export function InstructorForm({ defaultValues, onSubmit, loading }: Props) {
|
||||
const { register, handleSubmit } = useForm({
|
||||
defaultValues: {
|
||||
displayName: defaultValues?.displayName ?? '',
|
||||
bio: defaultValues?.bio ?? '',
|
||||
instruments: defaultValues?.instruments?.join(', ') ?? '',
|
||||
},
|
||||
})
|
||||
|
||||
function handleFormSubmit(data: { displayName: string; bio: string; instruments: string }) {
|
||||
onSubmit({
|
||||
displayName: data.displayName,
|
||||
bio: data.bio || undefined,
|
||||
instruments: data.instruments
|
||||
? data.instruments.split(',').map((s) => s.trim()).filter(Boolean)
|
||||
: undefined,
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(handleFormSubmit)} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="displayName">Display Name *</Label>
|
||||
<Input id="displayName" {...register('displayName')} required />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="bio">Bio</Label>
|
||||
<Textarea id="bio" {...register('bio')} rows={3} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="instruments">Instruments</Label>
|
||||
<Input id="instruments" {...register('instruments')} placeholder="Piano, Guitar, Voice (comma-separated)" />
|
||||
</div>
|
||||
<Button type="submit" disabled={loading} className="w-full">
|
||||
{loading ? 'Saving...' : defaultValues ? 'Save Changes' : 'Create Instructor'}
|
||||
</Button>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
99
packages/admin/src/components/lessons/lesson-type-form.tsx
Normal file
99
packages/admin/src/components/lessons/lesson-type-form.tsx
Normal file
@@ -0,0 +1,99 @@
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import type { LessonType } from '@/types/lesson'
|
||||
|
||||
interface Props {
|
||||
defaultValues?: Partial<LessonType>
|
||||
onSubmit: (data: Record<string, unknown>) => void
|
||||
loading?: boolean
|
||||
}
|
||||
|
||||
export function LessonTypeForm({ defaultValues, onSubmit, loading }: Props) {
|
||||
const { register, handleSubmit, setValue, watch } = useForm({
|
||||
defaultValues: {
|
||||
name: defaultValues?.name ?? '',
|
||||
instrument: defaultValues?.instrument ?? '',
|
||||
durationMinutes: defaultValues?.durationMinutes ?? 30,
|
||||
lessonFormat: (defaultValues?.lessonFormat ?? 'private') as 'private' | 'group',
|
||||
rateWeekly: defaultValues?.rateWeekly ?? '',
|
||||
rateMonthly: defaultValues?.rateMonthly ?? '',
|
||||
rateQuarterly: defaultValues?.rateQuarterly ?? '',
|
||||
},
|
||||
})
|
||||
|
||||
const lessonFormat = watch('lessonFormat')
|
||||
|
||||
function handleFormSubmit(data: {
|
||||
name: string
|
||||
instrument: string
|
||||
durationMinutes: number
|
||||
lessonFormat: string
|
||||
rateWeekly: string
|
||||
rateMonthly: string
|
||||
rateQuarterly: string
|
||||
}) {
|
||||
onSubmit({
|
||||
name: data.name,
|
||||
instrument: data.instrument || undefined,
|
||||
durationMinutes: Number(data.durationMinutes),
|
||||
lessonFormat: data.lessonFormat,
|
||||
rateWeekly: data.rateWeekly || undefined,
|
||||
rateMonthly: data.rateMonthly || undefined,
|
||||
rateQuarterly: data.rateQuarterly || undefined,
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(handleFormSubmit)} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="lt-name">Name *</Label>
|
||||
<Input id="lt-name" {...register('name')} placeholder="e.g. Piano — 30 min Private" required />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="lt-instrument">Instrument</Label>
|
||||
<Input id="lt-instrument" {...register('instrument')} placeholder="e.g. Piano, Guitar" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="lt-duration">Duration (minutes) *</Label>
|
||||
<Input id="lt-duration" type="number" min={5} step={5} {...register('durationMinutes')} required />
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Format *</Label>
|
||||
<Select value={lessonFormat} onValueChange={(v) => setValue('lessonFormat', v as 'private' | 'group')}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="private">Private</SelectItem>
|
||||
<SelectItem value="group">Group</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="block mb-2">Default Rates (optional)</Label>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="lt-rate-weekly" className="text-xs text-muted-foreground">Weekly</Label>
|
||||
<Input id="lt-rate-weekly" type="number" step="0.01" min="0" {...register('rateWeekly')} placeholder="—" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="lt-rate-monthly" className="text-xs text-muted-foreground">Monthly</Label>
|
||||
<Input id="lt-rate-monthly" type="number" step="0.01" min="0" {...register('rateMonthly')} placeholder="—" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="lt-rate-quarterly" className="text-xs text-muted-foreground">Quarterly</Label>
|
||||
<Input id="lt-rate-quarterly" type="number" step="0.01" min="0" {...register('rateQuarterly')} placeholder="—" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Button type="submit" disabled={loading} className="w-full">
|
||||
{loading ? 'Saving...' : defaultValues?.id ? 'Save Changes' : 'Create Lesson Type'}
|
||||
</Button>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
124
packages/admin/src/components/lessons/schedule-slot-form.tsx
Normal file
124
packages/admin/src/components/lessons/schedule-slot-form.tsx
Normal file
@@ -0,0 +1,124 @@
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import type { LessonType, ScheduleSlot } from '@/types/lesson'
|
||||
|
||||
const DAYS = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday']
|
||||
|
||||
interface Props {
|
||||
lessonTypes: LessonType[]
|
||||
defaultValues?: Partial<ScheduleSlot>
|
||||
onSubmit: (data: Record<string, unknown>) => void
|
||||
loading?: boolean
|
||||
}
|
||||
|
||||
export function ScheduleSlotForm({ lessonTypes, defaultValues, onSubmit, loading }: Props) {
|
||||
const { register, handleSubmit, setValue, watch } = useForm({
|
||||
defaultValues: {
|
||||
dayOfWeek: String(defaultValues?.dayOfWeek ?? 1),
|
||||
startTime: defaultValues?.startTime ?? '',
|
||||
lessonTypeId: defaultValues?.lessonTypeId ?? '',
|
||||
room: defaultValues?.room ?? '',
|
||||
maxStudents: String(defaultValues?.maxStudents ?? 1),
|
||||
rateWeekly: defaultValues?.rateWeekly ?? '',
|
||||
rateMonthly: defaultValues?.rateMonthly ?? '',
|
||||
rateQuarterly: defaultValues?.rateQuarterly ?? '',
|
||||
},
|
||||
})
|
||||
|
||||
const dayOfWeek = watch('dayOfWeek')
|
||||
const lessonTypeId = watch('lessonTypeId')
|
||||
|
||||
function handleFormSubmit(data: {
|
||||
dayOfWeek: string
|
||||
startTime: string
|
||||
lessonTypeId: string
|
||||
room: string
|
||||
maxStudents: string
|
||||
rateWeekly: string
|
||||
rateMonthly: string
|
||||
rateQuarterly: string
|
||||
}) {
|
||||
onSubmit({
|
||||
dayOfWeek: Number(data.dayOfWeek),
|
||||
startTime: data.startTime,
|
||||
lessonTypeId: data.lessonTypeId,
|
||||
room: data.room || undefined,
|
||||
maxStudents: Number(data.maxStudents) || 1,
|
||||
rateWeekly: data.rateWeekly || undefined,
|
||||
rateMonthly: data.rateMonthly || undefined,
|
||||
rateQuarterly: data.rateQuarterly || undefined,
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(handleFormSubmit)} className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Day *</Label>
|
||||
<Select value={dayOfWeek} onValueChange={(v) => setValue('dayOfWeek', v)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{DAYS.map((day, i) => (
|
||||
<SelectItem key={i} value={String(i)}>{day}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="slot-time">Start Time *</Label>
|
||||
<Input id="slot-time" type="time" {...register('startTime')} required />
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Lesson Type *</Label>
|
||||
<Select value={lessonTypeId} onValueChange={(v) => setValue('lessonTypeId', v)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select lesson type..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{lessonTypes.map((lt) => (
|
||||
<SelectItem key={lt.id} value={lt.id}>
|
||||
{lt.name} ({lt.durationMinutes} min)
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="slot-room">Room</Label>
|
||||
<Input id="slot-room" {...register('room')} placeholder="e.g. Studio A" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="slot-max">Max Students</Label>
|
||||
<Input id="slot-max" type="number" min={1} {...register('maxStudents')} />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="block mb-2">Instructor Rates (override lesson type defaults)</Label>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="slot-rate-weekly" className="text-xs text-muted-foreground">Weekly</Label>
|
||||
<Input id="slot-rate-weekly" type="number" step="0.01" min="0" {...register('rateWeekly')} placeholder="—" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="slot-rate-monthly" className="text-xs text-muted-foreground">Monthly</Label>
|
||||
<Input id="slot-rate-monthly" type="number" step="0.01" min="0" {...register('rateMonthly')} placeholder="—" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="slot-rate-quarterly" className="text-xs text-muted-foreground">Quarterly</Label>
|
||||
<Input id="slot-rate-quarterly" type="number" step="0.01" min="0" {...register('rateQuarterly')} placeholder="—" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Button type="submit" disabled={loading || !lessonTypeId} className="w-full">
|
||||
{loading ? 'Saving...' : defaultValues?.id ? 'Save Changes' : 'Add Slot'}
|
||||
</Button>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
41
packages/admin/src/components/lessons/store-closure-form.tsx
Normal file
41
packages/admin/src/components/lessons/store-closure-form.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
|
||||
interface Props {
|
||||
onSubmit: (data: Record<string, unknown>) => void
|
||||
loading?: boolean
|
||||
}
|
||||
|
||||
export function StoreClosureForm({ onSubmit, loading }: Props) {
|
||||
const { register, handleSubmit } = useForm({
|
||||
defaultValues: { name: '', startDate: '', endDate: '' },
|
||||
})
|
||||
|
||||
function handleFormSubmit(data: { name: string; startDate: string; endDate: string }) {
|
||||
onSubmit({ name: data.name, startDate: data.startDate, endDate: data.endDate })
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(handleFormSubmit)} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="closure-name">Name *</Label>
|
||||
<Input id="closure-name" {...register('name')} placeholder="e.g. Thanksgiving Break" required />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="closure-start">Start Date *</Label>
|
||||
<Input id="closure-start" type="date" {...register('startDate')} required />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="closure-end">End Date *</Label>
|
||||
<Input id="closure-end" type="date" {...register('endDate')} required />
|
||||
</div>
|
||||
</div>
|
||||
<Button type="submit" disabled={loading} className="w-full">
|
||||
{loading ? 'Saving...' : 'Add Closure'}
|
||||
</Button>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
import { useState } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Trash2, Plus, ChevronUp, ChevronDown } from 'lucide-react'
|
||||
|
||||
interface TemplateItemRow {
|
||||
id: string
|
||||
title: string
|
||||
description: string
|
||||
}
|
||||
|
||||
interface TemplateSectionRow {
|
||||
id: string
|
||||
title: string
|
||||
description: string
|
||||
items: TemplateItemRow[]
|
||||
}
|
||||
|
||||
interface Props {
|
||||
sections: TemplateSectionRow[]
|
||||
onChange: (sections: TemplateSectionRow[]) => void
|
||||
}
|
||||
|
||||
function uid() {
|
||||
return Math.random().toString(36).slice(2)
|
||||
}
|
||||
|
||||
export function TemplateSectionBuilder({ sections, onChange }: Props) {
|
||||
function addSection() {
|
||||
onChange([...sections, { id: uid(), title: '', description: '', items: [] }])
|
||||
}
|
||||
|
||||
function removeSection(idx: number) {
|
||||
onChange(sections.filter((_, i) => i !== idx))
|
||||
}
|
||||
|
||||
function moveSection(idx: number, dir: -1 | 1) {
|
||||
const next = [...sections]
|
||||
const [removed] = next.splice(idx, 1)
|
||||
next.splice(idx + dir, 0, removed)
|
||||
onChange(next)
|
||||
}
|
||||
|
||||
function updateSection(idx: number, field: 'title' | 'description', value: string) {
|
||||
onChange(sections.map((s, i) => (i === idx ? { ...s, [field]: value } : s)))
|
||||
}
|
||||
|
||||
function addItem(sIdx: number) {
|
||||
onChange(sections.map((s, i) =>
|
||||
i === sIdx ? { ...s, items: [...s.items, { id: uid(), title: '', description: '' }] } : s,
|
||||
))
|
||||
}
|
||||
|
||||
function removeItem(sIdx: number, iIdx: number) {
|
||||
onChange(sections.map((s, i) =>
|
||||
i === sIdx ? { ...s, items: s.items.filter((_, j) => j !== iIdx) } : s,
|
||||
))
|
||||
}
|
||||
|
||||
function moveItem(sIdx: number, iIdx: number, dir: -1 | 1) {
|
||||
onChange(sections.map((s, i) => {
|
||||
if (i !== sIdx) return s
|
||||
const next = [...s.items]
|
||||
const [removed] = next.splice(iIdx, 1)
|
||||
next.splice(iIdx + dir, 0, removed)
|
||||
return { ...s, items: next }
|
||||
}))
|
||||
}
|
||||
|
||||
function updateItem(sIdx: number, iIdx: number, field: 'title' | 'description', value: string) {
|
||||
onChange(sections.map((s, i) =>
|
||||
i === sIdx
|
||||
? { ...s, items: s.items.map((item, j) => (j === iIdx ? { ...item, [field]: value } : item)) }
|
||||
: s,
|
||||
))
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{sections.map((section, sIdx) => (
|
||||
<div key={section.id} className="border rounded-lg overflow-hidden">
|
||||
<div className="bg-muted/40 px-3 py-2 flex items-center gap-2">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<Button type="button" variant="ghost" size="icon" className="h-4 w-4" disabled={sIdx === 0} onClick={() => moveSection(sIdx, -1)}>
|
||||
<ChevronUp className="h-3 w-3" />
|
||||
</Button>
|
||||
<Button type="button" variant="ghost" size="icon" className="h-4 w-4" disabled={sIdx === sections.length - 1} onClick={() => moveSection(sIdx, 1)}>
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
<Input
|
||||
className="h-7 text-sm font-medium flex-1"
|
||||
placeholder="Section title *"
|
||||
value={section.title}
|
||||
onChange={(e) => updateSection(sIdx, 'title', e.target.value)}
|
||||
required
|
||||
/>
|
||||
<Button type="button" variant="ghost" size="icon" className="h-7 w-7" onClick={() => removeSection(sIdx)}>
|
||||
<Trash2 className="h-4 w-4 text-destructive" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="p-3 space-y-2">
|
||||
<Textarea
|
||||
className="text-xs resize-none"
|
||||
placeholder="Section description (optional)"
|
||||
rows={1}
|
||||
value={section.description}
|
||||
onChange={(e) => updateSection(sIdx, 'description', e.target.value)}
|
||||
/>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
{section.items.map((item, iIdx) => (
|
||||
<div key={item.id} className="flex items-center gap-2">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<Button type="button" variant="ghost" size="icon" className="h-4 w-4" disabled={iIdx === 0} onClick={() => moveItem(sIdx, iIdx, -1)}>
|
||||
<ChevronUp className="h-3 w-3" />
|
||||
</Button>
|
||||
<Button type="button" variant="ghost" size="icon" className="h-4 w-4" disabled={iIdx === section.items.length - 1} onClick={() => moveItem(sIdx, iIdx, 1)}>
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
<Input
|
||||
className="h-7 text-xs flex-1"
|
||||
placeholder="Item title *"
|
||||
value={item.title}
|
||||
onChange={(e) => updateItem(sIdx, iIdx, 'title', e.target.value)}
|
||||
required
|
||||
/>
|
||||
<Input
|
||||
className="h-7 text-xs flex-1"
|
||||
placeholder="Description (optional)"
|
||||
value={item.description}
|
||||
onChange={(e) => updateItem(sIdx, iIdx, 'description', e.target.value)}
|
||||
/>
|
||||
<Button type="button" variant="ghost" size="icon" className="h-7 w-7 shrink-0" onClick={() => removeItem(sIdx, iIdx)}>
|
||||
<Trash2 className="h-3 w-3 text-destructive" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Button type="button" variant="outline" size="sm" className="text-xs h-7" onClick={() => addItem(sIdx)}>
|
||||
<Plus className="h-3 w-3 mr-1" />Add Item
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<Button type="button" variant="outline" size="sm" onClick={addSection}>
|
||||
<Plus className="h-4 w-4 mr-1" />Add Section
|
||||
</Button>
|
||||
|
||||
{sections.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground text-center py-2">No sections yet — add one above.</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export type { TemplateSectionRow, TemplateItemRow }
|
||||
87
packages/admin/src/components/lessons/weekly-slot-grid.tsx
Normal file
87
packages/admin/src/components/lessons/weekly-slot-grid.tsx
Normal file
@@ -0,0 +1,87 @@
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Pencil, Trash2 } from 'lucide-react'
|
||||
import type { ScheduleSlot, LessonType } from '@/types/lesson'
|
||||
|
||||
const DAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
|
||||
|
||||
interface Props {
|
||||
slots: ScheduleSlot[]
|
||||
lessonTypes: LessonType[]
|
||||
onEdit: (slot: ScheduleSlot) => void
|
||||
onDelete: (slot: ScheduleSlot) => void
|
||||
}
|
||||
|
||||
function formatTime(t: string) {
|
||||
const [h, m] = t.split(':').map(Number)
|
||||
const ampm = h >= 12 ? 'PM' : 'AM'
|
||||
const hour = h % 12 || 12
|
||||
return `${hour}:${String(m).padStart(2, '0')} ${ampm}`
|
||||
}
|
||||
|
||||
export function WeeklySlotGrid({ slots, lessonTypes, onEdit, onDelete }: Props) {
|
||||
const ltMap = new Map(lessonTypes.map((lt) => [lt.id, lt]))
|
||||
|
||||
const slotsByDay = DAYS.map((_, day) =>
|
||||
slots.filter((s) => s.dayOfWeek === day).sort((a, b) => a.startTime.localeCompare(b.startTime)),
|
||||
)
|
||||
|
||||
const hasAny = slots.length > 0
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-7 gap-2">
|
||||
{DAYS.map((day, idx) => (
|
||||
<div key={day} className="min-h-[120px]">
|
||||
<div className="text-xs font-semibold text-muted-foreground uppercase tracking-wide text-center mb-2 py-1 border-b">
|
||||
{day}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{slotsByDay[idx].map((slot) => {
|
||||
const lt = ltMap.get(slot.lessonTypeId)
|
||||
return (
|
||||
<div
|
||||
key={slot.id}
|
||||
className="bg-sidebar-accent rounded-md p-2 text-xs group relative"
|
||||
>
|
||||
<div className="font-medium">{formatTime(slot.startTime)}</div>
|
||||
<div className="text-muted-foreground truncate">{lt?.name ?? 'Unknown'}</div>
|
||||
{slot.room && <div className="text-muted-foreground">{slot.room}</div>}
|
||||
{lt && (
|
||||
<Badge variant="outline" className="mt-1 text-[10px] py-0">
|
||||
{lt.lessonFormat}
|
||||
</Badge>
|
||||
)}
|
||||
<div className="absolute top-1 right-1 hidden group-hover:flex gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-5 w-5"
|
||||
onClick={() => onEdit(slot)}
|
||||
title="Edit"
|
||||
>
|
||||
<Pencil className="h-3 w-3" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-5 w-5"
|
||||
onClick={() => onDelete(slot)}
|
||||
title="Delete"
|
||||
>
|
||||
<Trash2 className="h-3 w-3 text-destructive" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{!hasAny && (
|
||||
<div className="col-span-7 text-center text-sm text-muted-foreground py-8">
|
||||
No schedule slots yet — add one to get started.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -33,11 +33,24 @@ import { Route as AuthenticatedRepairBatchesBatchIdRouteImport } from './routes/
|
||||
import { Route as AuthenticatedMembersMemberIdRouteImport } from './routes/_authenticated/members/$memberId'
|
||||
import { Route as AuthenticatedAccountsNewRouteImport } from './routes/_authenticated/accounts/new'
|
||||
import { Route as AuthenticatedAccountsAccountIdRouteImport } from './routes/_authenticated/accounts/$accountId'
|
||||
import { Route as AuthenticatedLessonsTemplatesIndexRouteImport } from './routes/_authenticated/lessons/templates/index'
|
||||
import { Route as AuthenticatedLessonsSessionsIndexRouteImport } from './routes/_authenticated/lessons/sessions/index'
|
||||
import { Route as AuthenticatedLessonsScheduleIndexRouteImport } from './routes/_authenticated/lessons/schedule/index'
|
||||
import { Route as AuthenticatedLessonsPlansIndexRouteImport } from './routes/_authenticated/lessons/plans/index'
|
||||
import { Route as AuthenticatedLessonsEnrollmentsIndexRouteImport } from './routes/_authenticated/lessons/enrollments/index'
|
||||
import { Route as AuthenticatedAccountsAccountIdIndexRouteImport } from './routes/_authenticated/accounts/$accountId/index'
|
||||
import { Route as AuthenticatedLessonsTemplatesNewRouteImport } from './routes/_authenticated/lessons/templates/new'
|
||||
import { Route as AuthenticatedLessonsTemplatesTemplateIdRouteImport } from './routes/_authenticated/lessons/templates/$templateId'
|
||||
import { Route as AuthenticatedLessonsSessionsSessionIdRouteImport } from './routes/_authenticated/lessons/sessions/$sessionId'
|
||||
import { Route as AuthenticatedLessonsPlansPlanIdRouteImport } from './routes/_authenticated/lessons/plans/$planId'
|
||||
import { Route as AuthenticatedLessonsEnrollmentsNewRouteImport } from './routes/_authenticated/lessons/enrollments/new'
|
||||
import { Route as AuthenticatedLessonsEnrollmentsEnrollmentIdRouteImport } from './routes/_authenticated/lessons/enrollments/$enrollmentId'
|
||||
import { Route as AuthenticatedAccountsAccountIdTaxExemptionsRouteImport } from './routes/_authenticated/accounts/$accountId/tax-exemptions'
|
||||
import { Route as AuthenticatedAccountsAccountIdProcessorLinksRouteImport } from './routes/_authenticated/accounts/$accountId/processor-links'
|
||||
import { Route as AuthenticatedAccountsAccountIdPaymentMethodsRouteImport } from './routes/_authenticated/accounts/$accountId/payment-methods'
|
||||
import { Route as AuthenticatedAccountsAccountIdMembersRouteImport } from './routes/_authenticated/accounts/$accountId/members'
|
||||
import { Route as AuthenticatedAccountsAccountIdEnrollmentsRouteImport } from './routes/_authenticated/accounts/$accountId/enrollments'
|
||||
import { Route as AuthenticatedLessonsScheduleInstructorsInstructorIdRouteImport } from './routes/_authenticated/lessons/schedule/instructors/$instructorId'
|
||||
|
||||
const LoginRoute = LoginRouteImport.update({
|
||||
id: '/login',
|
||||
@@ -170,12 +183,78 @@ const AuthenticatedAccountsAccountIdRoute =
|
||||
path: '/accounts/$accountId',
|
||||
getParentRoute: () => AuthenticatedRoute,
|
||||
} as any)
|
||||
const AuthenticatedLessonsTemplatesIndexRoute =
|
||||
AuthenticatedLessonsTemplatesIndexRouteImport.update({
|
||||
id: '/lessons/templates/',
|
||||
path: '/lessons/templates/',
|
||||
getParentRoute: () => AuthenticatedRoute,
|
||||
} as any)
|
||||
const AuthenticatedLessonsSessionsIndexRoute =
|
||||
AuthenticatedLessonsSessionsIndexRouteImport.update({
|
||||
id: '/lessons/sessions/',
|
||||
path: '/lessons/sessions/',
|
||||
getParentRoute: () => AuthenticatedRoute,
|
||||
} as any)
|
||||
const AuthenticatedLessonsScheduleIndexRoute =
|
||||
AuthenticatedLessonsScheduleIndexRouteImport.update({
|
||||
id: '/lessons/schedule/',
|
||||
path: '/lessons/schedule/',
|
||||
getParentRoute: () => AuthenticatedRoute,
|
||||
} as any)
|
||||
const AuthenticatedLessonsPlansIndexRoute =
|
||||
AuthenticatedLessonsPlansIndexRouteImport.update({
|
||||
id: '/lessons/plans/',
|
||||
path: '/lessons/plans/',
|
||||
getParentRoute: () => AuthenticatedRoute,
|
||||
} as any)
|
||||
const AuthenticatedLessonsEnrollmentsIndexRoute =
|
||||
AuthenticatedLessonsEnrollmentsIndexRouteImport.update({
|
||||
id: '/lessons/enrollments/',
|
||||
path: '/lessons/enrollments/',
|
||||
getParentRoute: () => AuthenticatedRoute,
|
||||
} as any)
|
||||
const AuthenticatedAccountsAccountIdIndexRoute =
|
||||
AuthenticatedAccountsAccountIdIndexRouteImport.update({
|
||||
id: '/',
|
||||
path: '/',
|
||||
getParentRoute: () => AuthenticatedAccountsAccountIdRoute,
|
||||
} as any)
|
||||
const AuthenticatedLessonsTemplatesNewRoute =
|
||||
AuthenticatedLessonsTemplatesNewRouteImport.update({
|
||||
id: '/lessons/templates/new',
|
||||
path: '/lessons/templates/new',
|
||||
getParentRoute: () => AuthenticatedRoute,
|
||||
} as any)
|
||||
const AuthenticatedLessonsTemplatesTemplateIdRoute =
|
||||
AuthenticatedLessonsTemplatesTemplateIdRouteImport.update({
|
||||
id: '/lessons/templates/$templateId',
|
||||
path: '/lessons/templates/$templateId',
|
||||
getParentRoute: () => AuthenticatedRoute,
|
||||
} as any)
|
||||
const AuthenticatedLessonsSessionsSessionIdRoute =
|
||||
AuthenticatedLessonsSessionsSessionIdRouteImport.update({
|
||||
id: '/lessons/sessions/$sessionId',
|
||||
path: '/lessons/sessions/$sessionId',
|
||||
getParentRoute: () => AuthenticatedRoute,
|
||||
} as any)
|
||||
const AuthenticatedLessonsPlansPlanIdRoute =
|
||||
AuthenticatedLessonsPlansPlanIdRouteImport.update({
|
||||
id: '/lessons/plans/$planId',
|
||||
path: '/lessons/plans/$planId',
|
||||
getParentRoute: () => AuthenticatedRoute,
|
||||
} as any)
|
||||
const AuthenticatedLessonsEnrollmentsNewRoute =
|
||||
AuthenticatedLessonsEnrollmentsNewRouteImport.update({
|
||||
id: '/lessons/enrollments/new',
|
||||
path: '/lessons/enrollments/new',
|
||||
getParentRoute: () => AuthenticatedRoute,
|
||||
} as any)
|
||||
const AuthenticatedLessonsEnrollmentsEnrollmentIdRoute =
|
||||
AuthenticatedLessonsEnrollmentsEnrollmentIdRouteImport.update({
|
||||
id: '/lessons/enrollments/$enrollmentId',
|
||||
path: '/lessons/enrollments/$enrollmentId',
|
||||
getParentRoute: () => AuthenticatedRoute,
|
||||
} as any)
|
||||
const AuthenticatedAccountsAccountIdTaxExemptionsRoute =
|
||||
AuthenticatedAccountsAccountIdTaxExemptionsRouteImport.update({
|
||||
id: '/tax-exemptions',
|
||||
@@ -200,6 +279,18 @@ const AuthenticatedAccountsAccountIdMembersRoute =
|
||||
path: '/members',
|
||||
getParentRoute: () => AuthenticatedAccountsAccountIdRoute,
|
||||
} as any)
|
||||
const AuthenticatedAccountsAccountIdEnrollmentsRoute =
|
||||
AuthenticatedAccountsAccountIdEnrollmentsRouteImport.update({
|
||||
id: '/enrollments',
|
||||
path: '/enrollments',
|
||||
getParentRoute: () => AuthenticatedAccountsAccountIdRoute,
|
||||
} as any)
|
||||
const AuthenticatedLessonsScheduleInstructorsInstructorIdRoute =
|
||||
AuthenticatedLessonsScheduleInstructorsInstructorIdRouteImport.update({
|
||||
id: '/lessons/schedule/instructors/$instructorId',
|
||||
path: '/lessons/schedule/instructors/$instructorId',
|
||||
getParentRoute: () => AuthenticatedRoute,
|
||||
} as any)
|
||||
|
||||
export interface FileRoutesByFullPath {
|
||||
'/': typeof AuthenticatedIndexRoute
|
||||
@@ -225,11 +316,24 @@ export interface FileRoutesByFullPath {
|
||||
'/repairs/': typeof AuthenticatedRepairsIndexRoute
|
||||
'/roles/': typeof AuthenticatedRolesIndexRoute
|
||||
'/vault/': typeof AuthenticatedVaultIndexRoute
|
||||
'/accounts/$accountId/enrollments': typeof AuthenticatedAccountsAccountIdEnrollmentsRoute
|
||||
'/accounts/$accountId/members': typeof AuthenticatedAccountsAccountIdMembersRoute
|
||||
'/accounts/$accountId/payment-methods': typeof AuthenticatedAccountsAccountIdPaymentMethodsRoute
|
||||
'/accounts/$accountId/processor-links': typeof AuthenticatedAccountsAccountIdProcessorLinksRoute
|
||||
'/accounts/$accountId/tax-exemptions': typeof AuthenticatedAccountsAccountIdTaxExemptionsRoute
|
||||
'/lessons/enrollments/$enrollmentId': typeof AuthenticatedLessonsEnrollmentsEnrollmentIdRoute
|
||||
'/lessons/enrollments/new': typeof AuthenticatedLessonsEnrollmentsNewRoute
|
||||
'/lessons/plans/$planId': typeof AuthenticatedLessonsPlansPlanIdRoute
|
||||
'/lessons/sessions/$sessionId': typeof AuthenticatedLessonsSessionsSessionIdRoute
|
||||
'/lessons/templates/$templateId': typeof AuthenticatedLessonsTemplatesTemplateIdRoute
|
||||
'/lessons/templates/new': typeof AuthenticatedLessonsTemplatesNewRoute
|
||||
'/accounts/$accountId/': typeof AuthenticatedAccountsAccountIdIndexRoute
|
||||
'/lessons/enrollments/': typeof AuthenticatedLessonsEnrollmentsIndexRoute
|
||||
'/lessons/plans/': typeof AuthenticatedLessonsPlansIndexRoute
|
||||
'/lessons/schedule/': typeof AuthenticatedLessonsScheduleIndexRoute
|
||||
'/lessons/sessions/': typeof AuthenticatedLessonsSessionsIndexRoute
|
||||
'/lessons/templates/': typeof AuthenticatedLessonsTemplatesIndexRoute
|
||||
'/lessons/schedule/instructors/$instructorId': typeof AuthenticatedLessonsScheduleInstructorsInstructorIdRoute
|
||||
}
|
||||
export interface FileRoutesByTo {
|
||||
'/login': typeof LoginRoute
|
||||
@@ -254,11 +358,24 @@ export interface FileRoutesByTo {
|
||||
'/repairs': typeof AuthenticatedRepairsIndexRoute
|
||||
'/roles': typeof AuthenticatedRolesIndexRoute
|
||||
'/vault': typeof AuthenticatedVaultIndexRoute
|
||||
'/accounts/$accountId/enrollments': typeof AuthenticatedAccountsAccountIdEnrollmentsRoute
|
||||
'/accounts/$accountId/members': typeof AuthenticatedAccountsAccountIdMembersRoute
|
||||
'/accounts/$accountId/payment-methods': typeof AuthenticatedAccountsAccountIdPaymentMethodsRoute
|
||||
'/accounts/$accountId/processor-links': typeof AuthenticatedAccountsAccountIdProcessorLinksRoute
|
||||
'/accounts/$accountId/tax-exemptions': typeof AuthenticatedAccountsAccountIdTaxExemptionsRoute
|
||||
'/lessons/enrollments/$enrollmentId': typeof AuthenticatedLessonsEnrollmentsEnrollmentIdRoute
|
||||
'/lessons/enrollments/new': typeof AuthenticatedLessonsEnrollmentsNewRoute
|
||||
'/lessons/plans/$planId': typeof AuthenticatedLessonsPlansPlanIdRoute
|
||||
'/lessons/sessions/$sessionId': typeof AuthenticatedLessonsSessionsSessionIdRoute
|
||||
'/lessons/templates/$templateId': typeof AuthenticatedLessonsTemplatesTemplateIdRoute
|
||||
'/lessons/templates/new': typeof AuthenticatedLessonsTemplatesNewRoute
|
||||
'/accounts/$accountId': typeof AuthenticatedAccountsAccountIdIndexRoute
|
||||
'/lessons/enrollments': typeof AuthenticatedLessonsEnrollmentsIndexRoute
|
||||
'/lessons/plans': typeof AuthenticatedLessonsPlansIndexRoute
|
||||
'/lessons/schedule': typeof AuthenticatedLessonsScheduleIndexRoute
|
||||
'/lessons/sessions': typeof AuthenticatedLessonsSessionsIndexRoute
|
||||
'/lessons/templates': typeof AuthenticatedLessonsTemplatesIndexRoute
|
||||
'/lessons/schedule/instructors/$instructorId': typeof AuthenticatedLessonsScheduleInstructorsInstructorIdRoute
|
||||
}
|
||||
export interface FileRoutesById {
|
||||
__root__: typeof rootRouteImport
|
||||
@@ -286,11 +403,24 @@ export interface FileRoutesById {
|
||||
'/_authenticated/repairs/': typeof AuthenticatedRepairsIndexRoute
|
||||
'/_authenticated/roles/': typeof AuthenticatedRolesIndexRoute
|
||||
'/_authenticated/vault/': typeof AuthenticatedVaultIndexRoute
|
||||
'/_authenticated/accounts/$accountId/enrollments': typeof AuthenticatedAccountsAccountIdEnrollmentsRoute
|
||||
'/_authenticated/accounts/$accountId/members': typeof AuthenticatedAccountsAccountIdMembersRoute
|
||||
'/_authenticated/accounts/$accountId/payment-methods': typeof AuthenticatedAccountsAccountIdPaymentMethodsRoute
|
||||
'/_authenticated/accounts/$accountId/processor-links': typeof AuthenticatedAccountsAccountIdProcessorLinksRoute
|
||||
'/_authenticated/accounts/$accountId/tax-exemptions': typeof AuthenticatedAccountsAccountIdTaxExemptionsRoute
|
||||
'/_authenticated/lessons/enrollments/$enrollmentId': typeof AuthenticatedLessonsEnrollmentsEnrollmentIdRoute
|
||||
'/_authenticated/lessons/enrollments/new': typeof AuthenticatedLessonsEnrollmentsNewRoute
|
||||
'/_authenticated/lessons/plans/$planId': typeof AuthenticatedLessonsPlansPlanIdRoute
|
||||
'/_authenticated/lessons/sessions/$sessionId': typeof AuthenticatedLessonsSessionsSessionIdRoute
|
||||
'/_authenticated/lessons/templates/$templateId': typeof AuthenticatedLessonsTemplatesTemplateIdRoute
|
||||
'/_authenticated/lessons/templates/new': typeof AuthenticatedLessonsTemplatesNewRoute
|
||||
'/_authenticated/accounts/$accountId/': typeof AuthenticatedAccountsAccountIdIndexRoute
|
||||
'/_authenticated/lessons/enrollments/': typeof AuthenticatedLessonsEnrollmentsIndexRoute
|
||||
'/_authenticated/lessons/plans/': typeof AuthenticatedLessonsPlansIndexRoute
|
||||
'/_authenticated/lessons/schedule/': typeof AuthenticatedLessonsScheduleIndexRoute
|
||||
'/_authenticated/lessons/sessions/': typeof AuthenticatedLessonsSessionsIndexRoute
|
||||
'/_authenticated/lessons/templates/': typeof AuthenticatedLessonsTemplatesIndexRoute
|
||||
'/_authenticated/lessons/schedule/instructors/$instructorId': typeof AuthenticatedLessonsScheduleInstructorsInstructorIdRoute
|
||||
}
|
||||
export interface FileRouteTypes {
|
||||
fileRoutesByFullPath: FileRoutesByFullPath
|
||||
@@ -318,11 +448,24 @@ export interface FileRouteTypes {
|
||||
| '/repairs/'
|
||||
| '/roles/'
|
||||
| '/vault/'
|
||||
| '/accounts/$accountId/enrollments'
|
||||
| '/accounts/$accountId/members'
|
||||
| '/accounts/$accountId/payment-methods'
|
||||
| '/accounts/$accountId/processor-links'
|
||||
| '/accounts/$accountId/tax-exemptions'
|
||||
| '/lessons/enrollments/$enrollmentId'
|
||||
| '/lessons/enrollments/new'
|
||||
| '/lessons/plans/$planId'
|
||||
| '/lessons/sessions/$sessionId'
|
||||
| '/lessons/templates/$templateId'
|
||||
| '/lessons/templates/new'
|
||||
| '/accounts/$accountId/'
|
||||
| '/lessons/enrollments/'
|
||||
| '/lessons/plans/'
|
||||
| '/lessons/schedule/'
|
||||
| '/lessons/sessions/'
|
||||
| '/lessons/templates/'
|
||||
| '/lessons/schedule/instructors/$instructorId'
|
||||
fileRoutesByTo: FileRoutesByTo
|
||||
to:
|
||||
| '/login'
|
||||
@@ -347,11 +490,24 @@ export interface FileRouteTypes {
|
||||
| '/repairs'
|
||||
| '/roles'
|
||||
| '/vault'
|
||||
| '/accounts/$accountId/enrollments'
|
||||
| '/accounts/$accountId/members'
|
||||
| '/accounts/$accountId/payment-methods'
|
||||
| '/accounts/$accountId/processor-links'
|
||||
| '/accounts/$accountId/tax-exemptions'
|
||||
| '/lessons/enrollments/$enrollmentId'
|
||||
| '/lessons/enrollments/new'
|
||||
| '/lessons/plans/$planId'
|
||||
| '/lessons/sessions/$sessionId'
|
||||
| '/lessons/templates/$templateId'
|
||||
| '/lessons/templates/new'
|
||||
| '/accounts/$accountId'
|
||||
| '/lessons/enrollments'
|
||||
| '/lessons/plans'
|
||||
| '/lessons/schedule'
|
||||
| '/lessons/sessions'
|
||||
| '/lessons/templates'
|
||||
| '/lessons/schedule/instructors/$instructorId'
|
||||
id:
|
||||
| '__root__'
|
||||
| '/_authenticated'
|
||||
@@ -378,11 +534,24 @@ export interface FileRouteTypes {
|
||||
| '/_authenticated/repairs/'
|
||||
| '/_authenticated/roles/'
|
||||
| '/_authenticated/vault/'
|
||||
| '/_authenticated/accounts/$accountId/enrollments'
|
||||
| '/_authenticated/accounts/$accountId/members'
|
||||
| '/_authenticated/accounts/$accountId/payment-methods'
|
||||
| '/_authenticated/accounts/$accountId/processor-links'
|
||||
| '/_authenticated/accounts/$accountId/tax-exemptions'
|
||||
| '/_authenticated/lessons/enrollments/$enrollmentId'
|
||||
| '/_authenticated/lessons/enrollments/new'
|
||||
| '/_authenticated/lessons/plans/$planId'
|
||||
| '/_authenticated/lessons/sessions/$sessionId'
|
||||
| '/_authenticated/lessons/templates/$templateId'
|
||||
| '/_authenticated/lessons/templates/new'
|
||||
| '/_authenticated/accounts/$accountId/'
|
||||
| '/_authenticated/lessons/enrollments/'
|
||||
| '/_authenticated/lessons/plans/'
|
||||
| '/_authenticated/lessons/schedule/'
|
||||
| '/_authenticated/lessons/sessions/'
|
||||
| '/_authenticated/lessons/templates/'
|
||||
| '/_authenticated/lessons/schedule/instructors/$instructorId'
|
||||
fileRoutesById: FileRoutesById
|
||||
}
|
||||
export interface RootRouteChildren {
|
||||
@@ -560,6 +729,41 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof AuthenticatedAccountsAccountIdRouteImport
|
||||
parentRoute: typeof AuthenticatedRoute
|
||||
}
|
||||
'/_authenticated/lessons/templates/': {
|
||||
id: '/_authenticated/lessons/templates/'
|
||||
path: '/lessons/templates'
|
||||
fullPath: '/lessons/templates/'
|
||||
preLoaderRoute: typeof AuthenticatedLessonsTemplatesIndexRouteImport
|
||||
parentRoute: typeof AuthenticatedRoute
|
||||
}
|
||||
'/_authenticated/lessons/sessions/': {
|
||||
id: '/_authenticated/lessons/sessions/'
|
||||
path: '/lessons/sessions'
|
||||
fullPath: '/lessons/sessions/'
|
||||
preLoaderRoute: typeof AuthenticatedLessonsSessionsIndexRouteImport
|
||||
parentRoute: typeof AuthenticatedRoute
|
||||
}
|
||||
'/_authenticated/lessons/schedule/': {
|
||||
id: '/_authenticated/lessons/schedule/'
|
||||
path: '/lessons/schedule'
|
||||
fullPath: '/lessons/schedule/'
|
||||
preLoaderRoute: typeof AuthenticatedLessonsScheduleIndexRouteImport
|
||||
parentRoute: typeof AuthenticatedRoute
|
||||
}
|
||||
'/_authenticated/lessons/plans/': {
|
||||
id: '/_authenticated/lessons/plans/'
|
||||
path: '/lessons/plans'
|
||||
fullPath: '/lessons/plans/'
|
||||
preLoaderRoute: typeof AuthenticatedLessonsPlansIndexRouteImport
|
||||
parentRoute: typeof AuthenticatedRoute
|
||||
}
|
||||
'/_authenticated/lessons/enrollments/': {
|
||||
id: '/_authenticated/lessons/enrollments/'
|
||||
path: '/lessons/enrollments'
|
||||
fullPath: '/lessons/enrollments/'
|
||||
preLoaderRoute: typeof AuthenticatedLessonsEnrollmentsIndexRouteImport
|
||||
parentRoute: typeof AuthenticatedRoute
|
||||
}
|
||||
'/_authenticated/accounts/$accountId/': {
|
||||
id: '/_authenticated/accounts/$accountId/'
|
||||
path: '/'
|
||||
@@ -567,6 +771,48 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof AuthenticatedAccountsAccountIdIndexRouteImport
|
||||
parentRoute: typeof AuthenticatedAccountsAccountIdRoute
|
||||
}
|
||||
'/_authenticated/lessons/templates/new': {
|
||||
id: '/_authenticated/lessons/templates/new'
|
||||
path: '/lessons/templates/new'
|
||||
fullPath: '/lessons/templates/new'
|
||||
preLoaderRoute: typeof AuthenticatedLessonsTemplatesNewRouteImport
|
||||
parentRoute: typeof AuthenticatedRoute
|
||||
}
|
||||
'/_authenticated/lessons/templates/$templateId': {
|
||||
id: '/_authenticated/lessons/templates/$templateId'
|
||||
path: '/lessons/templates/$templateId'
|
||||
fullPath: '/lessons/templates/$templateId'
|
||||
preLoaderRoute: typeof AuthenticatedLessonsTemplatesTemplateIdRouteImport
|
||||
parentRoute: typeof AuthenticatedRoute
|
||||
}
|
||||
'/_authenticated/lessons/sessions/$sessionId': {
|
||||
id: '/_authenticated/lessons/sessions/$sessionId'
|
||||
path: '/lessons/sessions/$sessionId'
|
||||
fullPath: '/lessons/sessions/$sessionId'
|
||||
preLoaderRoute: typeof AuthenticatedLessonsSessionsSessionIdRouteImport
|
||||
parentRoute: typeof AuthenticatedRoute
|
||||
}
|
||||
'/_authenticated/lessons/plans/$planId': {
|
||||
id: '/_authenticated/lessons/plans/$planId'
|
||||
path: '/lessons/plans/$planId'
|
||||
fullPath: '/lessons/plans/$planId'
|
||||
preLoaderRoute: typeof AuthenticatedLessonsPlansPlanIdRouteImport
|
||||
parentRoute: typeof AuthenticatedRoute
|
||||
}
|
||||
'/_authenticated/lessons/enrollments/new': {
|
||||
id: '/_authenticated/lessons/enrollments/new'
|
||||
path: '/lessons/enrollments/new'
|
||||
fullPath: '/lessons/enrollments/new'
|
||||
preLoaderRoute: typeof AuthenticatedLessonsEnrollmentsNewRouteImport
|
||||
parentRoute: typeof AuthenticatedRoute
|
||||
}
|
||||
'/_authenticated/lessons/enrollments/$enrollmentId': {
|
||||
id: '/_authenticated/lessons/enrollments/$enrollmentId'
|
||||
path: '/lessons/enrollments/$enrollmentId'
|
||||
fullPath: '/lessons/enrollments/$enrollmentId'
|
||||
preLoaderRoute: typeof AuthenticatedLessonsEnrollmentsEnrollmentIdRouteImport
|
||||
parentRoute: typeof AuthenticatedRoute
|
||||
}
|
||||
'/_authenticated/accounts/$accountId/tax-exemptions': {
|
||||
id: '/_authenticated/accounts/$accountId/tax-exemptions'
|
||||
path: '/tax-exemptions'
|
||||
@@ -595,10 +841,25 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof AuthenticatedAccountsAccountIdMembersRouteImport
|
||||
parentRoute: typeof AuthenticatedAccountsAccountIdRoute
|
||||
}
|
||||
'/_authenticated/accounts/$accountId/enrollments': {
|
||||
id: '/_authenticated/accounts/$accountId/enrollments'
|
||||
path: '/enrollments'
|
||||
fullPath: '/accounts/$accountId/enrollments'
|
||||
preLoaderRoute: typeof AuthenticatedAccountsAccountIdEnrollmentsRouteImport
|
||||
parentRoute: typeof AuthenticatedAccountsAccountIdRoute
|
||||
}
|
||||
'/_authenticated/lessons/schedule/instructors/$instructorId': {
|
||||
id: '/_authenticated/lessons/schedule/instructors/$instructorId'
|
||||
path: '/lessons/schedule/instructors/$instructorId'
|
||||
fullPath: '/lessons/schedule/instructors/$instructorId'
|
||||
preLoaderRoute: typeof AuthenticatedLessonsScheduleInstructorsInstructorIdRouteImport
|
||||
parentRoute: typeof AuthenticatedRoute
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
interface AuthenticatedAccountsAccountIdRouteChildren {
|
||||
AuthenticatedAccountsAccountIdEnrollmentsRoute: typeof AuthenticatedAccountsAccountIdEnrollmentsRoute
|
||||
AuthenticatedAccountsAccountIdMembersRoute: typeof AuthenticatedAccountsAccountIdMembersRoute
|
||||
AuthenticatedAccountsAccountIdPaymentMethodsRoute: typeof AuthenticatedAccountsAccountIdPaymentMethodsRoute
|
||||
AuthenticatedAccountsAccountIdProcessorLinksRoute: typeof AuthenticatedAccountsAccountIdProcessorLinksRoute
|
||||
@@ -608,6 +869,8 @@ interface AuthenticatedAccountsAccountIdRouteChildren {
|
||||
|
||||
const AuthenticatedAccountsAccountIdRouteChildren: AuthenticatedAccountsAccountIdRouteChildren =
|
||||
{
|
||||
AuthenticatedAccountsAccountIdEnrollmentsRoute:
|
||||
AuthenticatedAccountsAccountIdEnrollmentsRoute,
|
||||
AuthenticatedAccountsAccountIdMembersRoute:
|
||||
AuthenticatedAccountsAccountIdMembersRoute,
|
||||
AuthenticatedAccountsAccountIdPaymentMethodsRoute:
|
||||
@@ -648,6 +911,18 @@ interface AuthenticatedRouteChildren {
|
||||
AuthenticatedRepairsIndexRoute: typeof AuthenticatedRepairsIndexRoute
|
||||
AuthenticatedRolesIndexRoute: typeof AuthenticatedRolesIndexRoute
|
||||
AuthenticatedVaultIndexRoute: typeof AuthenticatedVaultIndexRoute
|
||||
AuthenticatedLessonsEnrollmentsEnrollmentIdRoute: typeof AuthenticatedLessonsEnrollmentsEnrollmentIdRoute
|
||||
AuthenticatedLessonsEnrollmentsNewRoute: typeof AuthenticatedLessonsEnrollmentsNewRoute
|
||||
AuthenticatedLessonsPlansPlanIdRoute: typeof AuthenticatedLessonsPlansPlanIdRoute
|
||||
AuthenticatedLessonsSessionsSessionIdRoute: typeof AuthenticatedLessonsSessionsSessionIdRoute
|
||||
AuthenticatedLessonsTemplatesTemplateIdRoute: typeof AuthenticatedLessonsTemplatesTemplateIdRoute
|
||||
AuthenticatedLessonsTemplatesNewRoute: typeof AuthenticatedLessonsTemplatesNewRoute
|
||||
AuthenticatedLessonsEnrollmentsIndexRoute: typeof AuthenticatedLessonsEnrollmentsIndexRoute
|
||||
AuthenticatedLessonsPlansIndexRoute: typeof AuthenticatedLessonsPlansIndexRoute
|
||||
AuthenticatedLessonsScheduleIndexRoute: typeof AuthenticatedLessonsScheduleIndexRoute
|
||||
AuthenticatedLessonsSessionsIndexRoute: typeof AuthenticatedLessonsSessionsIndexRoute
|
||||
AuthenticatedLessonsTemplatesIndexRoute: typeof AuthenticatedLessonsTemplatesIndexRoute
|
||||
AuthenticatedLessonsScheduleInstructorsInstructorIdRoute: typeof AuthenticatedLessonsScheduleInstructorsInstructorIdRoute
|
||||
}
|
||||
|
||||
const AuthenticatedRouteChildren: AuthenticatedRouteChildren = {
|
||||
@@ -675,6 +950,27 @@ const AuthenticatedRouteChildren: AuthenticatedRouteChildren = {
|
||||
AuthenticatedRepairsIndexRoute: AuthenticatedRepairsIndexRoute,
|
||||
AuthenticatedRolesIndexRoute: AuthenticatedRolesIndexRoute,
|
||||
AuthenticatedVaultIndexRoute: AuthenticatedVaultIndexRoute,
|
||||
AuthenticatedLessonsEnrollmentsEnrollmentIdRoute:
|
||||
AuthenticatedLessonsEnrollmentsEnrollmentIdRoute,
|
||||
AuthenticatedLessonsEnrollmentsNewRoute:
|
||||
AuthenticatedLessonsEnrollmentsNewRoute,
|
||||
AuthenticatedLessonsPlansPlanIdRoute: AuthenticatedLessonsPlansPlanIdRoute,
|
||||
AuthenticatedLessonsSessionsSessionIdRoute:
|
||||
AuthenticatedLessonsSessionsSessionIdRoute,
|
||||
AuthenticatedLessonsTemplatesTemplateIdRoute:
|
||||
AuthenticatedLessonsTemplatesTemplateIdRoute,
|
||||
AuthenticatedLessonsTemplatesNewRoute: AuthenticatedLessonsTemplatesNewRoute,
|
||||
AuthenticatedLessonsEnrollmentsIndexRoute:
|
||||
AuthenticatedLessonsEnrollmentsIndexRoute,
|
||||
AuthenticatedLessonsPlansIndexRoute: AuthenticatedLessonsPlansIndexRoute,
|
||||
AuthenticatedLessonsScheduleIndexRoute:
|
||||
AuthenticatedLessonsScheduleIndexRoute,
|
||||
AuthenticatedLessonsSessionsIndexRoute:
|
||||
AuthenticatedLessonsSessionsIndexRoute,
|
||||
AuthenticatedLessonsTemplatesIndexRoute:
|
||||
AuthenticatedLessonsTemplatesIndexRoute,
|
||||
AuthenticatedLessonsScheduleInstructorsInstructorIdRoute:
|
||||
AuthenticatedLessonsScheduleInstructorsInstructorIdRoute,
|
||||
}
|
||||
|
||||
const AuthenticatedRouteWithChildren = AuthenticatedRoute._addFileChildren(
|
||||
|
||||
@@ -8,7 +8,7 @@ import { myPermissionsOptions } from '@/api/rbac'
|
||||
import { moduleListOptions } from '@/api/modules'
|
||||
import { Avatar } from '@/components/shared/avatar-upload'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Users, UserRound, HelpCircle, Shield, UserCog, LogOut, User, Wrench, Package, ClipboardList, FolderOpen, KeyRound, Settings, PanelLeftClose, PanelLeft } from 'lucide-react'
|
||||
import { Users, UserRound, HelpCircle, Shield, UserCog, LogOut, User, Wrench, Package, ClipboardList, FolderOpen, KeyRound, Settings, PanelLeftClose, PanelLeft, CalendarDays, GraduationCap, CalendarRange, BookOpen, BookMarked } from 'lucide-react'
|
||||
|
||||
export const Route = createFileRoute('/_authenticated')({
|
||||
beforeLoad: () => {
|
||||
@@ -142,6 +142,7 @@ function AuthenticatedLayout() {
|
||||
|
||||
const canViewAccounts = !permissionsLoaded || hasPermission('accounts.view')
|
||||
const canViewRepairs = !permissionsLoaded || hasPermission('repairs.view')
|
||||
const canViewLessons = !permissionsLoaded || hasPermission('lessons.view')
|
||||
const canViewUsers = !permissionsLoaded || hasPermission('users.view')
|
||||
|
||||
const [collapsed, setCollapsed] = useState(false)
|
||||
@@ -186,6 +187,17 @@ function AuthenticatedLayout() {
|
||||
)}
|
||||
</NavGroup>
|
||||
)}
|
||||
{isModuleEnabled('lessons') && canViewLessons && (
|
||||
<NavGroup label="Lessons" collapsed={collapsed}>
|
||||
<NavLink to="/lessons/schedule" icon={<CalendarDays className="h-4 w-4" />} label="Schedule" collapsed={collapsed} />
|
||||
<NavLink to="/lessons/enrollments" icon={<GraduationCap className="h-4 w-4" />} label="Enrollments" collapsed={collapsed} />
|
||||
<NavLink to="/lessons/sessions" icon={<CalendarRange className="h-4 w-4" />} label="Sessions" collapsed={collapsed} />
|
||||
<NavLink to="/lessons/plans" icon={<BookOpen className="h-4 w-4" />} label="Lesson Plans" collapsed={collapsed} />
|
||||
{hasPermission('lessons.admin') && (
|
||||
<NavLink to="/lessons/templates" icon={<BookMarked className="h-4 w-4" />} label="Templates" collapsed={collapsed} />
|
||||
)}
|
||||
</NavGroup>
|
||||
)}
|
||||
{(isModuleEnabled('files') || isModuleEnabled('vault')) && (
|
||||
<NavGroup label="Storage" collapsed={collapsed}>
|
||||
{isModuleEnabled('files') && (
|
||||
|
||||
@@ -12,6 +12,7 @@ export const Route = createFileRoute('/_authenticated/accounts/$accountId')({
|
||||
const tabs = [
|
||||
{ label: 'Overview', to: '/accounts/$accountId' },
|
||||
{ label: 'Members', to: '/accounts/$accountId/members' },
|
||||
{ label: 'Enrollments', to: '/accounts/$accountId/enrollments' },
|
||||
{ label: 'Payment Methods', to: '/accounts/$accountId/payment-methods' },
|
||||
{ label: 'Tax Exemptions', to: '/accounts/$accountId/tax-exemptions' },
|
||||
{ label: 'Processor Links', to: '/accounts/$accountId/processor-links' },
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import { createFileRoute, useNavigate } from '@tanstack/react-router'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { enrollmentListOptions } from '@/api/lessons'
|
||||
import { memberListOptions } from '@/api/members'
|
||||
import { DataTable, type Column } from '@/components/shared/data-table'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Plus } from 'lucide-react'
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
import type { Enrollment } from '@/types/lesson'
|
||||
|
||||
export const Route = createFileRoute('/_authenticated/accounts/$accountId/enrollments')({
|
||||
component: AccountEnrollmentsTab,
|
||||
})
|
||||
|
||||
function statusBadge(status: string) {
|
||||
const variants: Record<string, 'default' | 'secondary' | 'destructive' | 'outline'> = {
|
||||
active: 'default', paused: 'secondary', cancelled: 'destructive', completed: 'outline',
|
||||
}
|
||||
return <Badge variant={variants[status] ?? 'outline'}>{status}</Badge>
|
||||
}
|
||||
|
||||
const columns: Column<Enrollment & { memberName?: string }>[] = [
|
||||
{ key: 'member_name', header: 'Member', sortable: true, render: (e) => <span className="font-medium">{(e as any).memberName ?? e.memberId}</span> },
|
||||
{ key: 'status', header: 'Status', sortable: true, render: (e) => statusBadge(e.status) },
|
||||
{ key: 'start_date', header: 'Start', sortable: true, render: (e) => <>{new Date(e.startDate + 'T00:00:00').toLocaleDateString()}</> },
|
||||
{ key: 'rate', header: 'Rate', render: (e) => <>{e.rate ? `$${e.rate}${e.billingInterval ? ` / ${e.billingInterval} ${e.billingUnit}` : ''}` : <span className="text-muted-foreground">—</span>}</> },
|
||||
]
|
||||
|
||||
function AccountEnrollmentsTab() {
|
||||
const { accountId } = Route.useParams()
|
||||
const navigate = useNavigate()
|
||||
const hasPermission = useAuthStore((s) => s.hasPermission)
|
||||
|
||||
// Get member IDs for this account so we can filter enrollments
|
||||
const { data: membersData } = useQuery(memberListOptions(accountId, { page: 1, limit: 100, order: 'asc' }))
|
||||
const memberIds = (membersData?.data ?? []).map((m) => m.id)
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
...enrollmentListOptions({ accountId, page: 1, limit: 100 }),
|
||||
enabled: !!accountId,
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm text-muted-foreground">{data?.pagination.total ?? 0} enrollment(s)</p>
|
||||
{hasPermission('lessons.edit') && (
|
||||
<Button size="sm" onClick={() => navigate({ to: '/lessons/enrollments/new', search: {} as any })}>
|
||||
<Plus className="h-4 w-4 mr-1" />Enroll a Member
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={data?.data ?? []}
|
||||
loading={isLoading}
|
||||
page={1}
|
||||
totalPages={1}
|
||||
total={data?.data?.length ?? 0}
|
||||
onPageChange={() => {}}
|
||||
onSort={() => {}}
|
||||
onRowClick={(e) => navigate({ to: '/lessons/enrollments/$enrollmentId', params: { enrollmentId: e.id }, search: {} as any })}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -281,6 +281,7 @@ function MembersTab() {
|
||||
<DropdownMenuItem onClick={() => navigate({
|
||||
to: '/members/$memberId',
|
||||
params: { memberId: m.id },
|
||||
search: {} as any,
|
||||
})}>
|
||||
<Pencil className="mr-2 h-4 w-4" />
|
||||
Edit
|
||||
|
||||
@@ -2,7 +2,7 @@ import { createFileRoute } from '@tanstack/react-router'
|
||||
import { useState } from 'react'
|
||||
import { getWikiCategories, getWikiPage, type WikiPage } from '@/wiki'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Search } from 'lucide-react'
|
||||
import { Search, ChevronDown, ChevronRight } from 'lucide-react'
|
||||
|
||||
export const Route = createFileRoute('/_authenticated/help')({
|
||||
validateSearch: (search: Record<string, unknown>) => ({
|
||||
@@ -64,6 +64,7 @@ function HelpPage() {
|
||||
const navigate = Route.useNavigate()
|
||||
const currentPage = getWikiPage(search.page)
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [collapsed, setCollapsed] = useState<Record<string, boolean>>({})
|
||||
|
||||
const allPages = categories.flatMap((c) => c.pages)
|
||||
const filteredPages = searchQuery
|
||||
@@ -79,10 +80,14 @@ function HelpPage() {
|
||||
setSearchQuery('')
|
||||
}
|
||||
|
||||
function toggleCategory(name: string) {
|
||||
setCollapsed((prev) => ({ ...prev, [name]: !prev[name] }))
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex gap-6 max-w-5xl">
|
||||
<div className="flex gap-6 max-w-5xl h-[calc(100vh-8rem)]">
|
||||
{/* Sidebar */}
|
||||
<div className="w-56 shrink-0 space-y-4">
|
||||
<div className="w-56 shrink-0 flex flex-col gap-3">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
@@ -93,9 +98,9 @@ function HelpPage() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{filteredPages ? (
|
||||
<div className="space-y-1">
|
||||
{filteredPages.length === 0 ? (
|
||||
<div className="overflow-y-auto flex-1 space-y-1 pr-1">
|
||||
{filteredPages ? (
|
||||
filteredPages.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground px-2">No results</p>
|
||||
) : (
|
||||
filteredPages.map((p) => (
|
||||
@@ -107,36 +112,47 @@ function HelpPage() {
|
||||
{p.title}
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
categories.map((cat) => (
|
||||
<div key={cat.name}>
|
||||
<h3 className="text-xs font-semibold text-muted-foreground uppercase tracking-wide px-2 mb-1">
|
||||
{cat.name}
|
||||
</h3>
|
||||
<div className="space-y-0.5">
|
||||
{cat.pages.map((p) => (
|
||||
)
|
||||
) : (
|
||||
categories.map((cat) => {
|
||||
const isCollapsed = collapsed[cat.name] ?? false
|
||||
return (
|
||||
<div key={cat.name}>
|
||||
<button
|
||||
key={p.slug}
|
||||
onClick={() => goToPage(p.slug)}
|
||||
className={`block w-full text-left px-2 py-1.5 text-sm rounded-md transition-colors ${
|
||||
search.page === p.slug
|
||||
? 'bg-accent text-accent-foreground font-medium'
|
||||
: 'text-muted-foreground hover:bg-accent hover:text-accent-foreground'
|
||||
}`}
|
||||
onClick={() => toggleCategory(cat.name)}
|
||||
className="flex items-center justify-between w-full px-2 py-1 text-xs font-semibold text-muted-foreground uppercase tracking-wide hover:text-foreground transition-colors"
|
||||
>
|
||||
{p.title}
|
||||
{cat.name}
|
||||
{isCollapsed
|
||||
? <ChevronRight className="h-3 w-3" />
|
||||
: <ChevronDown className="h-3 w-3" />}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
{!isCollapsed && (
|
||||
<div className="space-y-0.5 mt-0.5">
|
||||
{cat.pages.map((p) => (
|
||||
<button
|
||||
key={p.slug}
|
||||
onClick={() => goToPage(p.slug)}
|
||||
className={`block w-full text-left px-2 py-1.5 text-sm rounded-md transition-colors ${
|
||||
search.page === p.slug
|
||||
? 'bg-accent text-accent-foreground font-medium'
|
||||
: 'text-muted-foreground hover:bg-accent hover:text-accent-foreground'
|
||||
}`}
|
||||
>
|
||||
{p.title}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex-1 min-w-0 overflow-y-auto">
|
||||
{currentPage ? (
|
||||
<div className="prose-sm">{renderMarkdown(currentPage.content)}</div>
|
||||
) : (
|
||||
|
||||
@@ -0,0 +1,450 @@
|
||||
import { useState } from 'react'
|
||||
import { createFileRoute, useNavigate, Link } from '@tanstack/react-router'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import {
|
||||
enrollmentDetailOptions, enrollmentMutations, enrollmentKeys,
|
||||
sessionListOptions,
|
||||
lessonPlanListOptions, lessonPlanMutations, lessonPlanKeys,
|
||||
lessonPlanTemplateListOptions, lessonPlanTemplateMutations,
|
||||
instructorDetailOptions,
|
||||
scheduleSlotListOptions,
|
||||
lessonTypeListOptions,
|
||||
} from '@/api/lessons'
|
||||
import { DataTable, type Column } from '@/components/shared/data-table'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
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 { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||
import { ArrowLeft, RefreshCw } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
import type { LessonSession, LessonPlan, LessonPlanTemplate } from '@/types/lesson'
|
||||
|
||||
export const Route = createFileRoute('/_authenticated/lessons/enrollments/$enrollmentId')({
|
||||
validateSearch: (search: Record<string, unknown>) => ({
|
||||
tab: (search.tab as string) || 'details',
|
||||
}),
|
||||
component: EnrollmentDetailPage,
|
||||
})
|
||||
|
||||
const DAYS = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday']
|
||||
|
||||
function formatTime(t: string) {
|
||||
const [h, m] = t.split(':').map(Number)
|
||||
const ampm = h >= 12 ? 'PM' : 'AM'
|
||||
const hour = h % 12 || 12
|
||||
return `${hour}:${String(m).padStart(2, '0')} ${ampm}`
|
||||
}
|
||||
|
||||
function statusBadge(status: string) {
|
||||
const variants: Record<string, 'default' | 'secondary' | 'destructive' | 'outline'> = {
|
||||
active: 'default', paused: 'secondary', cancelled: 'destructive', completed: 'outline',
|
||||
}
|
||||
return <Badge variant={variants[status] ?? 'outline'}>{status}</Badge>
|
||||
}
|
||||
|
||||
function sessionStatusBadge(status: string) {
|
||||
const variants: Record<string, 'default' | 'secondary' | 'destructive' | 'outline'> = {
|
||||
scheduled: 'outline', attended: 'default', missed: 'destructive', makeup: 'secondary', cancelled: 'secondary',
|
||||
}
|
||||
return <Badge variant={variants[status] ?? 'outline'}>{status}</Badge>
|
||||
}
|
||||
|
||||
const sessionColumns: Column<LessonSession>[] = [
|
||||
{ key: 'scheduled_date', header: 'Date', sortable: true, render: (s) => <>{new Date(s.scheduledDate + 'T00:00:00').toLocaleDateString()}</> },
|
||||
{ key: 'scheduled_time', header: 'Time', render: (s) => <>{formatTime(s.scheduledTime)}</> },
|
||||
{ key: 'status', header: 'Status', sortable: true, render: (s) => sessionStatusBadge(s.status) },
|
||||
{
|
||||
key: 'substitute', header: 'Sub', render: (s) => s.substituteInstructorId
|
||||
? <Badge variant="outline" className="text-xs">Sub</Badge>
|
||||
: null,
|
||||
},
|
||||
{ key: 'notes', header: 'Notes', render: (s) => s.notesCompletedAt ? <Badge variant="outline" className="text-xs">Notes</Badge> : null },
|
||||
]
|
||||
|
||||
const TABS = [
|
||||
{ key: 'details', label: 'Details' },
|
||||
{ key: 'sessions', label: 'Sessions' },
|
||||
{ key: 'plan', label: 'Lesson Plan' },
|
||||
]
|
||||
|
||||
function EnrollmentDetailPage() {
|
||||
const { enrollmentId } = Route.useParams()
|
||||
const search = Route.useSearch()
|
||||
const navigate = useNavigate()
|
||||
const queryClient = useQueryClient()
|
||||
const hasPermission = useAuthStore((s) => s.hasPermission)
|
||||
const canEdit = hasPermission('lessons.edit')
|
||||
const tab = search.tab
|
||||
|
||||
function setTab(t: string) {
|
||||
navigate({ to: '/lessons/enrollments/$enrollmentId', params: { enrollmentId }, search: { tab: t } as any })
|
||||
}
|
||||
|
||||
const { data: enrollment, isLoading } = useQuery(enrollmentDetailOptions(enrollmentId))
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: (data: Record<string, unknown>) => enrollmentMutations.update(enrollmentId, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: enrollmentKeys.detail(enrollmentId) })
|
||||
toast.success('Enrollment updated')
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
})
|
||||
|
||||
const statusMutation = useMutation({
|
||||
mutationFn: (status: string) => enrollmentMutations.updateStatus(enrollmentId, status),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: enrollmentKeys.detail(enrollmentId) })
|
||||
toast.success('Status updated')
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
})
|
||||
|
||||
const generateMutation = useMutation({
|
||||
mutationFn: () => enrollmentMutations.generateSessions(enrollmentId, 4),
|
||||
onSuccess: (res) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['lesson-sessions'] })
|
||||
toast.success(`Generated ${res.generated} sessions`)
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
})
|
||||
|
||||
const { data: instructorData } = useQuery({
|
||||
...instructorDetailOptions(enrollment?.instructorId ?? ''),
|
||||
enabled: !!enrollment?.instructorId,
|
||||
})
|
||||
|
||||
const { data: slotsData } = useQuery(scheduleSlotListOptions({ page: 1, limit: 100, order: 'asc' }))
|
||||
const { data: lessonTypesData } = useQuery(lessonTypeListOptions({ page: 1, limit: 100, order: 'asc' }))
|
||||
|
||||
if (isLoading) return <div className="text-sm text-muted-foreground">Loading...</div>
|
||||
if (!enrollment) return <div className="text-sm text-destructive">Enrollment not found.</div>
|
||||
|
||||
const slot = slotsData?.data?.find((s) => s.id === enrollment.scheduleSlotId)
|
||||
const lessonType = lessonTypesData?.data?.find((lt) => lt.id === slot?.lessonTypeId)
|
||||
const slotLabel = slot ? `${DAYS[slot.dayOfWeek]} ${formatTime(slot.startTime)}${slot.room ? ` — ${slot.room}` : ''}` : '—'
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<Button variant="ghost" size="sm" onClick={() => navigate({ to: '/lessons/enrollments', search: {} as any })}>
|
||||
<ArrowLeft className="h-4 w-4 mr-1" />Back
|
||||
</Button>
|
||||
<div className="flex-1">
|
||||
<h1 className="text-2xl font-bold">Enrollment</h1>
|
||||
<p className="text-sm text-muted-foreground">{instructorData?.displayName ?? enrollment.instructorId} · {slotLabel}</p>
|
||||
</div>
|
||||
{statusBadge(enrollment.status)}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-1 border-b">
|
||||
{TABS.map((t) => (
|
||||
<button
|
||||
key={t.key}
|
||||
onClick={() => setTab(t.key)}
|
||||
className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
|
||||
tab === t.key ? 'border-primary text-primary' : 'border-transparent text-muted-foreground hover:text-foreground'
|
||||
}`}
|
||||
>
|
||||
{t.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{tab === 'details' && (
|
||||
<DetailsTab
|
||||
enrollment={enrollment}
|
||||
slotLabel={slotLabel}
|
||||
lessonTypeName={lessonType?.name}
|
||||
instructorName={instructorData?.displayName}
|
||||
canEdit={canEdit}
|
||||
onSave={updateMutation.mutate}
|
||||
saving={updateMutation.isPending}
|
||||
onStatusChange={statusMutation.mutate}
|
||||
statusChanging={statusMutation.isPending}
|
||||
/>
|
||||
)}
|
||||
{tab === 'sessions' && (
|
||||
<SessionsTab
|
||||
enrollmentId={enrollmentId}
|
||||
onGenerate={generateMutation.mutate}
|
||||
generating={generateMutation.isPending}
|
||||
/>
|
||||
)}
|
||||
{tab === 'plan' && <LessonPlanTab enrollmentId={enrollmentId} memberId={enrollment.memberId} canEdit={canEdit} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Details Tab ──────────────────────────────────────────────────────────────
|
||||
|
||||
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)' },
|
||||
]
|
||||
|
||||
function DetailsTab({
|
||||
enrollment, slotLabel, lessonTypeName, instructorName,
|
||||
canEdit, onSave, saving, onStatusChange, statusChanging,
|
||||
}: any) {
|
||||
const [rate, setRate] = useState(enrollment.rate ?? '')
|
||||
const [billingInterval, setBillingInterval] = useState(String(enrollment.billingInterval ?? 1))
|
||||
const [billingUnit, setBillingUnit] = useState(enrollment.billingUnit ?? 'month')
|
||||
const [notes, setNotes] = useState(enrollment.notes ?? '')
|
||||
const [endDate, setEndDate] = useState(enrollment.endDate ?? '')
|
||||
|
||||
const NEXT_STATUSES: Record<string, string[]> = {
|
||||
active: ['paused', 'cancelled', 'completed'],
|
||||
paused: ['active', 'cancelled'],
|
||||
cancelled: [],
|
||||
completed: [],
|
||||
}
|
||||
|
||||
const nextStatuses = NEXT_STATUSES[enrollment.status] ?? []
|
||||
|
||||
return (
|
||||
<div className="space-y-6 max-w-lg">
|
||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<p className="text-muted-foreground">Instructor</p>
|
||||
<p className="font-medium">{instructorName ?? enrollment.instructorId}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground">Slot</p>
|
||||
<p className="font-medium">{slotLabel}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground">Lesson Type</p>
|
||||
<p className="font-medium">{lessonTypeName ?? '—'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground">Start Date</p>
|
||||
<p className="font-medium">{new Date(enrollment.startDate + 'T00:00:00').toLocaleDateString()}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground">Billing Cycle</p>
|
||||
<p className="font-medium">{enrollment.billingInterval ? `${enrollment.billingInterval} ${enrollment.billingUnit}(s)` : '—'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground">Rate</p>
|
||||
<p className="font-medium">{enrollment.rate ? `$${enrollment.rate}` : '—'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground">Makeup Credits</p>
|
||||
<p className="font-medium">{enrollment.makeupCredits}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{canEdit && (
|
||||
<>
|
||||
<div className="space-y-4">
|
||||
<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)}
|
||||
className="w-20"
|
||||
/>
|
||||
<Select value={billingUnit} onValueChange={setBillingUnit}>
|
||||
<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="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Rate</Label>
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-muted-foreground">$</span>
|
||||
<Input type="number" step="0.01" value={rate} onChange={(e) => setRate(e.target.value)} placeholder="Optional" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>End Date</Label>
|
||||
<Input type="date" value={endDate} onChange={(e) => setEndDate(e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Notes</Label>
|
||||
<Textarea value={notes} onChange={(e) => setNotes(e.target.value)} rows={2} />
|
||||
</div>
|
||||
<Button onClick={() => onSave({
|
||||
rate: rate || undefined,
|
||||
billingInterval: billingInterval ? Number(billingInterval) : undefined,
|
||||
billingUnit: billingUnit || undefined,
|
||||
notes: notes || undefined,
|
||||
endDate: endDate || undefined,
|
||||
})} disabled={saving}>
|
||||
{saving ? 'Saving...' : 'Save Changes'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{nextStatuses.length > 0 && (
|
||||
<div className="border-t pt-4 space-y-2">
|
||||
<p className="text-sm font-medium">Change Status</p>
|
||||
<div className="flex gap-2">
|
||||
{nextStatuses.map((s) => (
|
||||
<Button key={s} variant={s === 'cancelled' ? 'destructive' : 'outline'} size="sm" onClick={() => onStatusChange(s)} disabled={statusChanging}>
|
||||
{s.charAt(0).toUpperCase() + s.slice(1)}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Sessions Tab ─────────────────────────────────────────────────────────────
|
||||
|
||||
function SessionsTab({ enrollmentId, onGenerate, generating }: { enrollmentId: string; onGenerate: () => void; generating: boolean }) {
|
||||
const navigate = useNavigate()
|
||||
const { data, isLoading } = useQuery(sessionListOptions({ enrollmentId, page: 1, limit: 100, sort: 'scheduled_date', order: 'asc' }))
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex justify-end">
|
||||
<Button variant="outline" size="sm" onClick={onGenerate} disabled={generating}>
|
||||
<RefreshCw className={`h-4 w-4 mr-2 ${generating ? 'animate-spin' : ''}`} />
|
||||
Generate Sessions
|
||||
</Button>
|
||||
</div>
|
||||
<DataTable
|
||||
columns={sessionColumns}
|
||||
data={data?.data ?? []}
|
||||
loading={isLoading}
|
||||
page={1}
|
||||
totalPages={1}
|
||||
total={data?.data?.length ?? 0}
|
||||
onPageChange={() => {}}
|
||||
onSort={() => {}}
|
||||
onRowClick={(s) => navigate({ to: '/lessons/sessions/$sessionId', params: { sessionId: s.id }, search: {} as any })}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Lesson Plan Tab ──────────────────────────────────────────────────────────
|
||||
|
||||
function LessonPlanTab({ enrollmentId, memberId, canEdit }: { enrollmentId: string; memberId: string; canEdit: boolean }) {
|
||||
const navigate = useNavigate()
|
||||
const queryClient = useQueryClient()
|
||||
const [templatePickerOpen, setTemplatePickerOpen] = useState(false)
|
||||
const [selectedTemplateId, setSelectedTemplateId] = useState('')
|
||||
const [customTitle, setCustomTitle] = useState('')
|
||||
|
||||
const { data: plansData } = useQuery(lessonPlanListOptions({ enrollmentId, isActive: true }))
|
||||
const activePlan: LessonPlan | undefined = plansData?.data?.[0]
|
||||
|
||||
const { data: templatesData } = useQuery(lessonPlanTemplateListOptions({ page: 1, limit: 100, order: 'asc' }))
|
||||
|
||||
const createPlanMutation = useMutation({
|
||||
mutationFn: () => lessonPlanMutations.create({ memberId, enrollmentId, title: `Lesson Plan — ${new Date().toLocaleDateString()}` }),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: lessonPlanKeys.all })
|
||||
toast.success('Lesson plan created')
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
})
|
||||
|
||||
const instantiateMutation = useMutation({
|
||||
mutationFn: () => lessonPlanTemplateMutations.createPlan(selectedTemplateId, {
|
||||
memberId,
|
||||
enrollmentId,
|
||||
title: customTitle || undefined,
|
||||
}),
|
||||
onSuccess: (plan) => {
|
||||
queryClient.invalidateQueries({ queryKey: lessonPlanKeys.all })
|
||||
toast.success('Plan created from template')
|
||||
setTemplatePickerOpen(false)
|
||||
navigate({ to: '/lessons/plans/$planId', params: { planId: plan.id }, search: {} as any })
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
})
|
||||
|
||||
const templates: LessonPlanTemplate[] = templatesData?.data ?? []
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{activePlan ? (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="font-medium">{activePlan.title}</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{Math.round(activePlan.progress)}% complete
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={() => navigate({ to: '/lessons/plans/$planId', params: { planId: activePlan.id }, search: {} as any })}>
|
||||
View Plan
|
||||
</Button>
|
||||
</div>
|
||||
<div className="w-full bg-muted rounded-full h-2">
|
||||
<div className="bg-primary h-2 rounded-full transition-all" style={{ width: `${activePlan.progress}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-sm text-muted-foreground py-4">No active lesson plan.</div>
|
||||
)}
|
||||
|
||||
{canEdit && (
|
||||
<div className="flex gap-2 pt-2 border-t">
|
||||
<Button variant="outline" size="sm" onClick={() => createPlanMutation.mutate()} disabled={createPlanMutation.isPending}>
|
||||
{createPlanMutation.isPending ? 'Creating...' : 'New Blank Plan'}
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={() => setTemplatePickerOpen(true)}>
|
||||
Use Template
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Dialog open={templatePickerOpen} onOpenChange={setTemplatePickerOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader><DialogTitle>Create Plan from Template</DialogTitle></DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Template *</Label>
|
||||
<Select value={selectedTemplateId} onValueChange={setSelectedTemplateId}>
|
||||
<SelectTrigger><SelectValue placeholder="Choose a template..." /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{templates.map((t) => (
|
||||
<SelectItem key={t.id} value={t.id}>
|
||||
{t.name}{t.instrument ? ` — ${t.instrument}` : ''}{t.skillLevel !== 'all_levels' ? ` (${t.skillLevel})` : ''}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Custom Title</Label>
|
||||
<Input value={customTitle} onChange={(e) => setCustomTitle(e.target.value)} placeholder="Leave blank to use template name" />
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => instantiateMutation.mutate()}
|
||||
disabled={!selectedTemplateId || instantiateMutation.isPending}
|
||||
className="w-full"
|
||||
>
|
||||
{instantiateMutation.isPending ? 'Creating...' : 'Create Plan'}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import { useState } from 'react'
|
||||
import { createFileRoute, useNavigate } from '@tanstack/react-router'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { enrollmentListOptions } from '@/api/lessons'
|
||||
import { usePagination } from '@/hooks/use-pagination'
|
||||
import { DataTable, type Column } from '@/components/shared/data-table'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { Plus, Search } from 'lucide-react'
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
import type { Enrollment } from '@/types/lesson'
|
||||
|
||||
export const Route = createFileRoute('/_authenticated/lessons/enrollments/')({
|
||||
validateSearch: (search: Record<string, unknown>) => ({
|
||||
page: Number(search.page) || 1,
|
||||
limit: Number(search.limit) || 25,
|
||||
q: (search.q as string) || undefined,
|
||||
sort: (search.sort as string) || undefined,
|
||||
order: (search.order as 'asc' | 'desc') || 'desc',
|
||||
status: (search.status as string) || undefined,
|
||||
instructorId: (search.instructorId as string) || undefined,
|
||||
}),
|
||||
component: EnrollmentsListPage,
|
||||
})
|
||||
|
||||
const STATUS_LABELS: Record<string, string> = {
|
||||
active: 'Active',
|
||||
paused: 'Paused',
|
||||
cancelled: 'Cancelled',
|
||||
completed: 'Completed',
|
||||
}
|
||||
|
||||
function statusBadge(status: string) {
|
||||
const variants: Record<string, 'default' | 'secondary' | 'destructive' | 'outline'> = {
|
||||
active: 'default',
|
||||
paused: 'secondary',
|
||||
cancelled: 'destructive',
|
||||
completed: 'outline',
|
||||
}
|
||||
return <Badge variant={variants[status] ?? 'outline'}>{STATUS_LABELS[status] ?? status}</Badge>
|
||||
}
|
||||
|
||||
const columns: Column<Enrollment & { memberName?: string; instructorName?: string; slotInfo?: string; lessonTypeName?: string }>[] = [
|
||||
{ key: 'member_name', header: 'Member', sortable: true, render: (e) => <span className="font-medium">{(e as any).memberName ?? e.memberId}</span> },
|
||||
{ key: 'instructor_name', header: 'Instructor', render: (e) => <>{(e as any).instructorName ?? e.instructorId}</> },
|
||||
{ key: 'slot_info', header: 'Day / Time', render: (e) => <>{(e as any).slotInfo ?? '—'}</> },
|
||||
{ key: 'status', header: 'Status', sortable: true, render: (e) => statusBadge(e.status) },
|
||||
{ key: 'start_date', header: 'Start', sortable: true, render: (e) => <>{new Date(e.startDate + 'T00:00:00').toLocaleDateString()}</> },
|
||||
{ key: 'rate', header: 'Rate', render: (e) => <>{e.rate ? `$${e.rate}${e.billingInterval ? ` / ${e.billingInterval} ${e.billingUnit}` : ''}` : <span className="text-muted-foreground">—</span>}</> },
|
||||
]
|
||||
|
||||
function EnrollmentsListPage() {
|
||||
const navigate = useNavigate()
|
||||
const hasPermission = useAuthStore((s) => s.hasPermission)
|
||||
const search = Route.useSearch()
|
||||
const { params, setPage, setSearch, setSort } = usePagination()
|
||||
const [searchInput, setSearchInput] = useState(params.q ?? '')
|
||||
const [statusFilter, setStatusFilter] = useState(search.status ?? '')
|
||||
|
||||
const queryParams: Record<string, unknown> = { ...params }
|
||||
if (statusFilter) queryParams.status = statusFilter
|
||||
|
||||
const { data, isLoading } = useQuery(enrollmentListOptions(queryParams))
|
||||
|
||||
function handleSearchSubmit(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
setSearch(searchInput)
|
||||
}
|
||||
|
||||
function handleStatusChange(v: string) {
|
||||
const s = v === 'all' ? '' : v
|
||||
setStatusFilter(s)
|
||||
navigate({ to: '/lessons/enrollments', search: { ...search, status: s || undefined, page: 1 } as any })
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold">Enrollments</h1>
|
||||
{hasPermission('lessons.edit') && (
|
||||
<Button onClick={() => navigate({ to: '/lessons/enrollments/new', search: {} as any })}>
|
||||
<Plus className="mr-2 h-4 w-4" />New Enrollment
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 flex-wrap">
|
||||
<form onSubmit={handleSearchSubmit} className="flex gap-2">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search enrollments..."
|
||||
value={searchInput}
|
||||
onChange={(e) => setSearchInput(e.target.value)}
|
||||
className="pl-9 w-64"
|
||||
/>
|
||||
</div>
|
||||
<Button type="submit" variant="secondary">Search</Button>
|
||||
</form>
|
||||
|
||||
<Select value={statusFilter || 'all'} onValueChange={handleStatusChange}>
|
||||
<SelectTrigger className="w-40">
|
||||
<SelectValue placeholder="All Statuses" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Statuses</SelectItem>
|
||||
<SelectItem value="active">Active</SelectItem>
|
||||
<SelectItem value="paused">Paused</SelectItem>
|
||||
<SelectItem value="cancelled">Cancelled</SelectItem>
|
||||
<SelectItem value="completed">Completed</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={data?.data ?? []}
|
||||
loading={isLoading}
|
||||
page={params.page}
|
||||
totalPages={data?.pagination.totalPages ?? 1}
|
||||
total={data?.pagination.total ?? 0}
|
||||
sort={params.sort}
|
||||
order={params.order}
|
||||
onPageChange={setPage}
|
||||
onSort={setSort}
|
||||
onRowClick={(e) => navigate({ to: '/lessons/enrollments/$enrollmentId', params: { enrollmentId: e.id }, search: {} as any })}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,292 @@
|
||||
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: {} as any })
|
||||
},
|
||||
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: {} as any })}>
|
||||
<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: {} as any })}>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
import { useState } from 'react'
|
||||
import { createFileRoute, useNavigate } from '@tanstack/react-router'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { lessonPlanDetailOptions, lessonPlanMutations, lessonPlanKeys, lessonPlanItemMutations } from '@/api/lessons'
|
||||
import { GradeEntryDialog } from '@/components/lessons/grade-entry-dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { ArrowLeft, Star } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
import type { LessonPlanItem } from '@/types/lesson'
|
||||
|
||||
export const Route = createFileRoute('/_authenticated/lessons/plans/$planId')({
|
||||
component: LessonPlanDetailPage,
|
||||
})
|
||||
|
||||
const STATUSES = ['not_started', 'in_progress', 'mastered', 'skipped'] as const
|
||||
type ItemStatus = typeof STATUSES[number]
|
||||
|
||||
const STATUS_LABELS: Record<ItemStatus, string> = {
|
||||
not_started: 'Not Started',
|
||||
in_progress: 'In Progress',
|
||||
mastered: 'Mastered',
|
||||
skipped: 'Skipped',
|
||||
}
|
||||
|
||||
const STATUS_VARIANTS: Record<ItemStatus, 'default' | 'secondary' | 'outline'> = {
|
||||
not_started: 'outline',
|
||||
in_progress: 'secondary',
|
||||
mastered: 'default',
|
||||
skipped: 'outline',
|
||||
}
|
||||
|
||||
function nextStatus(current: ItemStatus): ItemStatus {
|
||||
const idx = STATUSES.indexOf(current)
|
||||
return STATUSES[(idx + 1) % STATUSES.length]
|
||||
}
|
||||
|
||||
function LessonPlanDetailPage() {
|
||||
const { planId } = Route.useParams()
|
||||
const navigate = useNavigate()
|
||||
const queryClient = useQueryClient()
|
||||
const hasPermission = useAuthStore((s) => s.hasPermission)
|
||||
const canEdit = hasPermission('lessons.edit')
|
||||
|
||||
const { data: plan, isLoading } = useQuery(lessonPlanDetailOptions(planId))
|
||||
const [gradeItem, setGradeItem] = useState<LessonPlanItem | null>(null)
|
||||
const [editingTitle, setEditingTitle] = useState(false)
|
||||
const [titleInput, setTitleInput] = useState('')
|
||||
|
||||
const updatePlanMutation = useMutation({
|
||||
mutationFn: (data: Record<string, unknown>) => lessonPlanMutations.update(planId, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: lessonPlanKeys.detail(planId) })
|
||||
setEditingTitle(false)
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
})
|
||||
|
||||
const updateItemMutation = useMutation({
|
||||
mutationFn: ({ id, data }: { id: string; data: Record<string, unknown> }) =>
|
||||
lessonPlanItemMutations.update(id, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: lessonPlanKeys.detail(planId) })
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
})
|
||||
|
||||
if (isLoading) return <div className="text-sm text-muted-foreground">Loading...</div>
|
||||
if (!plan) return <div className="text-sm text-destructive">Plan not found.</div>
|
||||
|
||||
const totalItems = plan.sections.flatMap((s) => s.items).filter((i) => i.status !== 'skipped').length
|
||||
const masteredItems = plan.sections.flatMap((s) => s.items).filter((i) => i.status === 'mastered').length
|
||||
|
||||
function startEditTitle() {
|
||||
setTitleInput(plan!.title)
|
||||
setEditingTitle(true)
|
||||
}
|
||||
|
||||
function saveTitle() {
|
||||
if (titleInput.trim() && titleInput !== plan!.title) {
|
||||
updatePlanMutation.mutate({ title: titleInput.trim() })
|
||||
} else {
|
||||
setEditingTitle(false)
|
||||
}
|
||||
}
|
||||
|
||||
function cycleStatus(item: LessonPlanItem) {
|
||||
updateItemMutation.mutate({ id: item.id, data: { status: nextStatus(item.status as ItemStatus) } })
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6 max-w-3xl">
|
||||
<div className="flex items-center gap-3">
|
||||
<Button variant="ghost" size="sm" onClick={() => navigate({ to: '/lessons/plans', search: {} as any })}>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<div className="flex-1">
|
||||
{editingTitle ? (
|
||||
<div className="flex gap-2 items-center">
|
||||
<Input
|
||||
value={titleInput}
|
||||
onChange={(e) => setTitleInput(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') saveTitle(); if (e.key === 'Escape') setEditingTitle(false) }}
|
||||
className="text-xl font-bold h-9"
|
||||
autoFocus
|
||||
/>
|
||||
<Button size="sm" onClick={saveTitle} disabled={updatePlanMutation.isPending}>Save</Button>
|
||||
<Button size="sm" variant="ghost" onClick={() => setEditingTitle(false)}>Cancel</Button>
|
||||
</div>
|
||||
) : (
|
||||
<h1
|
||||
className={`text-2xl font-bold ${canEdit ? 'cursor-pointer hover:underline decoration-dashed' : ''}`}
|
||||
onClick={canEdit ? startEditTitle : undefined}
|
||||
title={canEdit ? 'Click to edit' : undefined}
|
||||
>
|
||||
{plan.title}
|
||||
</h1>
|
||||
)}
|
||||
</div>
|
||||
<Badge variant={plan.isActive ? 'default' : 'secondary'}>{plan.isActive ? 'Active' : 'Inactive'}</Badge>
|
||||
</div>
|
||||
|
||||
{/* Progress */}
|
||||
<div className="space-y-1">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-muted-foreground">{masteredItems} / {totalItems} mastered</span>
|
||||
<span className="font-medium">{Math.round(plan.progress)}%</span>
|
||||
</div>
|
||||
<div className="w-full bg-muted rounded-full h-2.5">
|
||||
<div className="bg-primary h-2.5 rounded-full transition-all" style={{ width: `${plan.progress}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sections */}
|
||||
<div className="space-y-4">
|
||||
{plan.sections.map((section) => (
|
||||
<details key={section.id} open className="border rounded-lg">
|
||||
<summary className="px-4 py-3 cursor-pointer font-semibold text-sm select-none hover:bg-muted/30 rounded-t-lg">
|
||||
{section.title}
|
||||
<span className="ml-2 text-xs font-normal text-muted-foreground">
|
||||
({section.items.filter((i) => i.status === 'mastered').length}/{section.items.length})
|
||||
</span>
|
||||
</summary>
|
||||
<div className="divide-y">
|
||||
{section.items.map((item) => (
|
||||
<div key={item.id} className="flex items-center gap-3 px-4 py-2.5">
|
||||
{canEdit ? (
|
||||
<button
|
||||
onClick={() => cycleStatus(item)}
|
||||
className="shrink-0"
|
||||
title={`Click to change: ${STATUS_LABELS[item.status as ItemStatus]}`}
|
||||
>
|
||||
<Badge
|
||||
variant={STATUS_VARIANTS[item.status as ItemStatus]}
|
||||
className={`text-xs cursor-pointer ${item.status === 'mastered' ? 'bg-green-600 text-white border-green-600' : ''}`}
|
||||
>
|
||||
{STATUS_LABELS[item.status as ItemStatus]}
|
||||
</Badge>
|
||||
</button>
|
||||
) : (
|
||||
<Badge variant={STATUS_VARIANTS[item.status as ItemStatus]} className="text-xs shrink-0">
|
||||
{STATUS_LABELS[item.status as ItemStatus]}
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm">{item.title}</p>
|
||||
{item.description && <p className="text-xs text-muted-foreground">{item.description}</p>}
|
||||
</div>
|
||||
|
||||
{item.currentGradeValue && (
|
||||
<Badge variant="outline" className="text-xs shrink-0">{item.currentGradeValue}</Badge>
|
||||
)}
|
||||
|
||||
{canEdit && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 shrink-0"
|
||||
onClick={() => setGradeItem(item)}
|
||||
title="Record grade"
|
||||
>
|
||||
<Star className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</details>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{gradeItem && (
|
||||
<GradeEntryDialog
|
||||
item={gradeItem}
|
||||
open={!!gradeItem}
|
||||
onClose={() => setGradeItem(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import { useState } from 'react'
|
||||
import { createFileRoute, useNavigate } from '@tanstack/react-router'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { lessonPlanListOptions } from '@/api/lessons'
|
||||
import { usePagination } from '@/hooks/use-pagination'
|
||||
import { DataTable, type Column } from '@/components/shared/data-table'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Search } from 'lucide-react'
|
||||
import type { LessonPlan } from '@/types/lesson'
|
||||
|
||||
export const Route = createFileRoute('/_authenticated/lessons/plans/')({
|
||||
validateSearch: (search: Record<string, unknown>) => ({
|
||||
page: Number(search.page) || 1,
|
||||
limit: Number(search.limit) || 25,
|
||||
q: (search.q as string) || undefined,
|
||||
sort: (search.sort as string) || undefined,
|
||||
order: (search.order as 'asc' | 'desc') || 'desc',
|
||||
}),
|
||||
component: LessonPlansPage,
|
||||
})
|
||||
|
||||
const columns: Column<LessonPlan>[] = [
|
||||
{ key: 'title', header: 'Title', sortable: true, render: (p) => <span className="font-medium">{p.title}</span> },
|
||||
{
|
||||
key: 'progress', header: 'Progress', sortable: true,
|
||||
render: (p) => (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-24 bg-muted rounded-full h-2">
|
||||
<div className="bg-primary h-2 rounded-full" style={{ width: `${p.progress}%` }} />
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground">{Math.round(p.progress)}%</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'is_active', header: 'Status',
|
||||
render: (p) => <Badge variant={p.isActive ? 'default' : 'secondary'}>{p.isActive ? 'Active' : 'Inactive'}</Badge>,
|
||||
},
|
||||
{
|
||||
key: 'created_at', header: 'Created', sortable: true,
|
||||
render: (p) => <>{new Date(p.createdAt).toLocaleDateString()}</>,
|
||||
},
|
||||
]
|
||||
|
||||
function LessonPlansPage() {
|
||||
const navigate = useNavigate()
|
||||
const { params, setPage, setSearch, setSort } = usePagination()
|
||||
const [searchInput, setSearchInput] = useState(params.q ?? '')
|
||||
|
||||
const { data, isLoading } = useQuery(lessonPlanListOptions(params))
|
||||
|
||||
function handleSearchSubmit(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
setSearch(searchInput)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<h1 className="text-2xl font-bold">Lesson Plans</h1>
|
||||
|
||||
<form onSubmit={handleSearchSubmit} className="flex gap-2 max-w-sm">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search lesson plans..."
|
||||
value={searchInput}
|
||||
onChange={(e) => setSearchInput(e.target.value)}
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
<Button type="submit" variant="secondary">Search</Button>
|
||||
</form>
|
||||
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={data?.data ?? []}
|
||||
loading={isLoading}
|
||||
page={params.page}
|
||||
totalPages={data?.pagination.totalPages ?? 1}
|
||||
total={data?.pagination.total ?? 0}
|
||||
sort={params.sort}
|
||||
order={params.order}
|
||||
onPageChange={setPage}
|
||||
onSort={setSort}
|
||||
onRowClick={(p) => navigate({ to: '/lessons/plans/$planId', params: { planId: p.id }, search: {} as any })}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,444 @@
|
||||
import { useState } from 'react'
|
||||
import { createFileRoute, useNavigate } from '@tanstack/react-router'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import {
|
||||
instructorListOptions, instructorMutations, instructorKeys,
|
||||
lessonTypeListOptions, lessonTypeMutations, lessonTypeKeys,
|
||||
gradingScaleListOptions, gradingScaleMutations, gradingScaleKeys,
|
||||
storeClosureListOptions, storeClosureMutations, storeClosureKeys,
|
||||
} from '@/api/lessons'
|
||||
import { usePagination } from '@/hooks/use-pagination'
|
||||
import { DataTable, type Column } from '@/components/shared/data-table'
|
||||
import { InstructorForm } from '@/components/lessons/instructor-form'
|
||||
import { LessonTypeForm } from '@/components/lessons/lesson-type-form'
|
||||
import { GradingScaleForm } from '@/components/lessons/grading-scale-form'
|
||||
import { StoreClosureForm } from '@/components/lessons/store-closure-form'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog'
|
||||
import { Plus, Search, Trash2 } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
import type { Instructor, LessonType, GradingScale, StoreClosure } from '@/types/lesson'
|
||||
|
||||
export const Route = createFileRoute('/_authenticated/lessons/schedule/')({
|
||||
validateSearch: (search: Record<string, unknown>) => ({
|
||||
tab: (search.tab as string) || 'instructors',
|
||||
page: Number(search.page) || 1,
|
||||
limit: Number(search.limit) || 25,
|
||||
q: (search.q as string) || undefined,
|
||||
sort: (search.sort as string) || undefined,
|
||||
order: (search.order as 'asc' | 'desc') || 'asc',
|
||||
}),
|
||||
component: ScheduleHubPage,
|
||||
})
|
||||
|
||||
const TABS = [
|
||||
{ key: 'instructors', label: 'Instructors' },
|
||||
{ key: 'lesson-types', label: 'Lesson Types' },
|
||||
{ key: 'grading-scales', label: 'Grading Scales' },
|
||||
{ key: 'closures', label: 'Store Closures' },
|
||||
]
|
||||
|
||||
function ScheduleHubPage() {
|
||||
const navigate = useNavigate()
|
||||
const search = Route.useSearch()
|
||||
const tab = search.tab
|
||||
const hasPermission = useAuthStore((s) => s.hasPermission)
|
||||
const canAdmin = hasPermission('lessons.admin')
|
||||
|
||||
function setTab(t: string) {
|
||||
navigate({ to: '/lessons/schedule', search: { ...search, tab: t, page: 1 } as any })
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<h1 className="text-2xl font-bold">Lessons Setup</h1>
|
||||
<div className="flex gap-1 border-b">
|
||||
{TABS.map((t) => (
|
||||
<button
|
||||
key={t.key}
|
||||
onClick={() => setTab(t.key)}
|
||||
className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
|
||||
tab === t.key
|
||||
? 'border-primary text-primary'
|
||||
: 'border-transparent text-muted-foreground hover:text-foreground'
|
||||
}`}
|
||||
>
|
||||
{t.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{tab === 'instructors' && <InstructorsTab canAdmin={canAdmin} search={search} />}
|
||||
{tab === 'lesson-types' && <LessonTypesTab canAdmin={canAdmin} search={search} />}
|
||||
{tab === 'grading-scales' && <GradingScalesTab canAdmin={canAdmin} search={search} />}
|
||||
{tab === 'closures' && <StoreClosuresTab canAdmin={canAdmin} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Instructors Tab ──────────────────────────────────────────────────────────
|
||||
|
||||
const instructorColumns: Column<Instructor>[] = [
|
||||
{ key: 'display_name', header: 'Name', sortable: true, render: (i) => <span className="font-medium">{i.displayName}</span> },
|
||||
{ key: 'instruments', header: 'Instruments', render: (i) => <>{i.instruments?.join(', ') || <span className="text-muted-foreground">—</span>}</> },
|
||||
{
|
||||
key: 'is_active', header: 'Status', sortable: true,
|
||||
render: (i) => <Badge variant={i.isActive ? 'default' : 'secondary'}>{i.isActive ? 'Active' : 'Inactive'}</Badge>,
|
||||
},
|
||||
]
|
||||
|
||||
function InstructorsTab({ canAdmin, search }: { canAdmin: boolean; search: any }) {
|
||||
const navigate = useNavigate()
|
||||
const queryClient = useQueryClient()
|
||||
const { params, setPage, setSearch, setSort } = usePagination()
|
||||
const [searchInput, setSearchInput] = useState(params.q ?? '')
|
||||
const [createOpen, setCreateOpen] = useState(false)
|
||||
|
||||
const { data, isLoading } = useQuery(instructorListOptions(params))
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: instructorMutations.create,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: instructorKeys.all })
|
||||
toast.success('Instructor created')
|
||||
setCreateOpen(false)
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
})
|
||||
|
||||
function handleSearchSubmit(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
setSearch(searchInput)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<form onSubmit={handleSearchSubmit} className="flex gap-2 max-w-sm">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search instructors..."
|
||||
value={searchInput}
|
||||
onChange={(e) => setSearchInput(e.target.value)}
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
<Button type="submit" variant="secondary">Search</Button>
|
||||
</form>
|
||||
{canAdmin && (
|
||||
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button><Plus className="mr-2 h-4 w-4" />New Instructor</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader><DialogTitle>Create Instructor</DialogTitle></DialogHeader>
|
||||
<InstructorForm onSubmit={createMutation.mutate} loading={createMutation.isPending} />
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)}
|
||||
</div>
|
||||
<DataTable
|
||||
columns={instructorColumns}
|
||||
data={data?.data ?? []}
|
||||
loading={isLoading}
|
||||
page={params.page}
|
||||
totalPages={data?.pagination.totalPages ?? 1}
|
||||
total={data?.pagination.total ?? 0}
|
||||
sort={params.sort}
|
||||
order={params.order}
|
||||
onPageChange={setPage}
|
||||
onSort={setSort}
|
||||
onRowClick={(i) => navigate({ to: '/lessons/schedule/instructors/$instructorId', params: { instructorId: i.id }, search: {} as any })}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Lesson Types Tab ─────────────────────────────────────────────────────────
|
||||
|
||||
const lessonTypeColumns: Column<LessonType>[] = [
|
||||
{ key: 'name', header: 'Name', sortable: true, render: (lt) => <span className="font-medium">{lt.name}</span> },
|
||||
{ key: 'instrument', header: 'Instrument', render: (lt) => <>{lt.instrument ?? <span className="text-muted-foreground">—</span>}</> },
|
||||
{ key: 'duration_minutes', header: 'Duration', sortable: true, render: (lt) => <>{lt.durationMinutes} min</> },
|
||||
{ key: 'lesson_format', header: 'Format', render: (lt) => <Badge variant="outline">{lt.lessonFormat}</Badge> },
|
||||
{ key: 'rate_monthly', header: 'Monthly Rate', render: (lt) => <>{lt.rateMonthly ? `$${lt.rateMonthly}` : <span className="text-muted-foreground">—</span>}</> },
|
||||
{ key: 'is_active', header: 'Status', render: (lt) => <Badge variant={lt.isActive ? 'default' : 'secondary'}>{lt.isActive ? 'Active' : 'Inactive'}</Badge> },
|
||||
]
|
||||
|
||||
function LessonTypesTab({ canAdmin, search }: { canAdmin: boolean; search: any }) {
|
||||
const queryClient = useQueryClient()
|
||||
const { params, setPage, setSearch, setSort } = usePagination()
|
||||
const [searchInput, setSearchInput] = useState(params.q ?? '')
|
||||
const [createOpen, setCreateOpen] = useState(false)
|
||||
const [editTarget, setEditTarget] = useState<LessonType | null>(null)
|
||||
|
||||
const { data, isLoading } = useQuery(lessonTypeListOptions(params))
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: lessonTypeMutations.create,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: lessonTypeKeys.all })
|
||||
toast.success('Lesson type created')
|
||||
setCreateOpen(false)
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
})
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: ({ id, data }: { id: string; data: Record<string, unknown> }) => lessonTypeMutations.update(id, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: lessonTypeKeys.all })
|
||||
toast.success('Lesson type updated')
|
||||
setEditTarget(null)
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
})
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: lessonTypeMutations.delete,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: lessonTypeKeys.all })
|
||||
toast.success('Lesson type removed')
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
})
|
||||
|
||||
function handleSearchSubmit(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
setSearch(searchInput)
|
||||
}
|
||||
|
||||
const columnsWithActions: Column<LessonType>[] = [
|
||||
...lessonTypeColumns,
|
||||
...(canAdmin ? [{
|
||||
key: 'actions' as any,
|
||||
header: '' as any,
|
||||
render: (lt: LessonType) => (
|
||||
<div className="flex gap-1 justify-end">
|
||||
<Button variant="ghost" size="sm" onClick={(e) => { e.stopPropagation(); setEditTarget(lt) }}>Edit</Button>
|
||||
<Button variant="ghost" size="sm" onClick={(e) => { e.stopPropagation(); deleteMutation.mutate(lt.id) }}>
|
||||
<Trash2 className="h-4 w-4 text-destructive" />
|
||||
</Button>
|
||||
</div>
|
||||
),
|
||||
}] : []),
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<form onSubmit={handleSearchSubmit} className="flex gap-2 max-w-sm">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search lesson types..."
|
||||
value={searchInput}
|
||||
onChange={(e) => setSearchInput(e.target.value)}
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
<Button type="submit" variant="secondary">Search</Button>
|
||||
</form>
|
||||
{canAdmin && (
|
||||
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button><Plus className="mr-2 h-4 w-4" />New Lesson Type</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader><DialogTitle>Create Lesson Type</DialogTitle></DialogHeader>
|
||||
<LessonTypeForm onSubmit={createMutation.mutate} loading={createMutation.isPending} />
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{editTarget && (
|
||||
<Dialog open={!!editTarget} onOpenChange={(o) => { if (!o) setEditTarget(null) }}>
|
||||
<DialogContent>
|
||||
<DialogHeader><DialogTitle>Edit Lesson Type</DialogTitle></DialogHeader>
|
||||
<LessonTypeForm
|
||||
defaultValues={editTarget}
|
||||
onSubmit={(data) => updateMutation.mutate({ id: editTarget.id, data })}
|
||||
loading={updateMutation.isPending}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)}
|
||||
|
||||
<DataTable
|
||||
columns={columnsWithActions}
|
||||
data={data?.data ?? []}
|
||||
loading={isLoading}
|
||||
page={params.page}
|
||||
totalPages={data?.pagination.totalPages ?? 1}
|
||||
total={data?.pagination.total ?? 0}
|
||||
sort={params.sort}
|
||||
order={params.order}
|
||||
onPageChange={setPage}
|
||||
onSort={setSort}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Grading Scales Tab ───────────────────────────────────────────────────────
|
||||
|
||||
const gradingScaleColumns: Column<GradingScale>[] = [
|
||||
{ key: 'name', header: 'Name', sortable: true, render: (gs) => <span className="font-medium">{gs.name}</span> },
|
||||
{
|
||||
key: 'is_default', header: '', render: (gs) => gs.isDefault
|
||||
? <Badge variant="default">Default</Badge>
|
||||
: null,
|
||||
},
|
||||
{ key: 'levels', header: 'Levels', render: (gs) => <>{gs.levels?.length ?? 0}</> },
|
||||
{ key: 'is_active', header: 'Status', render: (gs) => <Badge variant={gs.isActive ? 'default' : 'secondary'}>{gs.isActive ? 'Active' : 'Inactive'}</Badge> },
|
||||
]
|
||||
|
||||
function GradingScalesTab({ canAdmin, search }: { canAdmin: boolean; search: any }) {
|
||||
const queryClient = useQueryClient()
|
||||
const { params, setPage, setSort } = usePagination()
|
||||
const [createOpen, setCreateOpen] = useState(false)
|
||||
|
||||
const { data, isLoading } = useQuery(gradingScaleListOptions(params))
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: gradingScaleMutations.create,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: gradingScaleKeys.all })
|
||||
toast.success('Grading scale created')
|
||||
setCreateOpen(false)
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
})
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: gradingScaleMutations.delete,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: gradingScaleKeys.all })
|
||||
toast.success('Grading scale removed')
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
})
|
||||
|
||||
const columnsWithActions: Column<GradingScale>[] = [
|
||||
...gradingScaleColumns,
|
||||
...(canAdmin ? [{
|
||||
key: 'actions' as any,
|
||||
header: '' as any,
|
||||
render: (gs: GradingScale) => (
|
||||
<Button variant="ghost" size="sm" onClick={(e) => { e.stopPropagation(); deleteMutation.mutate(gs.id) }}>
|
||||
<Trash2 className="h-4 w-4 text-destructive" />
|
||||
</Button>
|
||||
),
|
||||
}] : []),
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex justify-end">
|
||||
{canAdmin && (
|
||||
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button><Plus className="mr-2 h-4 w-4" />New Grading Scale</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogHeader><DialogTitle>Create Grading Scale</DialogTitle></DialogHeader>
|
||||
<GradingScaleForm onSubmit={createMutation.mutate} loading={createMutation.isPending} />
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)}
|
||||
</div>
|
||||
<DataTable
|
||||
columns={columnsWithActions}
|
||||
data={data?.data ?? []}
|
||||
loading={isLoading}
|
||||
page={params.page}
|
||||
totalPages={data?.pagination.totalPages ?? 1}
|
||||
total={data?.pagination.total ?? 0}
|
||||
sort={params.sort}
|
||||
order={params.order}
|
||||
onPageChange={setPage}
|
||||
onSort={setSort}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Store Closures Tab ───────────────────────────────────────────────────────
|
||||
|
||||
function StoreClosuresTab({ canAdmin }: { canAdmin: boolean }) {
|
||||
const queryClient = useQueryClient()
|
||||
const [createOpen, setCreateOpen] = useState(false)
|
||||
|
||||
const { data, isLoading } = useQuery(storeClosureListOptions())
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: storeClosureMutations.create,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: storeClosureKeys.all })
|
||||
toast.success('Store closure added')
|
||||
setCreateOpen(false)
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
})
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: storeClosureMutations.delete,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: storeClosureKeys.all })
|
||||
toast.success('Closure removed')
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
})
|
||||
|
||||
const closures: StoreClosure[] = data ?? []
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex justify-end">
|
||||
{canAdmin && (
|
||||
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button><Plus className="mr-2 h-4 w-4" />Add Closure</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader><DialogTitle>Add Store Closure</DialogTitle></DialogHeader>
|
||||
<StoreClosureForm onSubmit={createMutation.mutate} loading={createMutation.isPending} />
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="text-sm text-muted-foreground">Loading...</div>
|
||||
) : closures.length === 0 ? (
|
||||
<div className="text-sm text-muted-foreground text-center py-8 border rounded-md">
|
||||
No store closures configured.
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y border rounded-md">
|
||||
{closures.map((c) => (
|
||||
<div key={c.id} className="flex items-center justify-between px-4 py-3">
|
||||
<div>
|
||||
<div className="font-medium text-sm">{c.name}</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{new Date(c.startDate + 'T00:00:00').toLocaleDateString()} –{' '}
|
||||
{new Date(c.endDate + 'T00:00:00').toLocaleDateString()}
|
||||
</div>
|
||||
</div>
|
||||
{canAdmin && (
|
||||
<Button variant="ghost" size="sm" onClick={() => deleteMutation.mutate(c.id)}>
|
||||
<Trash2 className="h-4 w-4 text-destructive" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
import { useState } from 'react'
|
||||
import { createFileRoute, useNavigate } from '@tanstack/react-router'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import {
|
||||
instructorDetailOptions, instructorMutations, instructorKeys,
|
||||
instructorBlockedDatesOptions,
|
||||
scheduleSlotListOptions, scheduleSlotMutations, scheduleSlotKeys,
|
||||
lessonTypeListOptions,
|
||||
} from '@/api/lessons'
|
||||
import { InstructorForm } from '@/components/lessons/instructor-form'
|
||||
import { ScheduleSlotForm } from '@/components/lessons/schedule-slot-form'
|
||||
import { BlockedDateForm } from '@/components/lessons/blocked-date-form'
|
||||
import { WeeklySlotGrid } from '@/components/lessons/weekly-slot-grid'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog'
|
||||
import { ArrowLeft, Plus, Trash2 } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
import type { ScheduleSlot } from '@/types/lesson'
|
||||
|
||||
export const Route = createFileRoute('/_authenticated/lessons/schedule/instructors/$instructorId')({
|
||||
validateSearch: (search: Record<string, unknown>) => ({
|
||||
tab: (search.tab as string) || 'overview',
|
||||
}),
|
||||
component: InstructorDetailPage,
|
||||
})
|
||||
|
||||
const TABS = [
|
||||
{ key: 'overview', label: 'Overview' },
|
||||
{ key: 'slots', label: 'Schedule Slots' },
|
||||
{ key: 'blocked', label: 'Blocked Dates' },
|
||||
]
|
||||
|
||||
function InstructorDetailPage() {
|
||||
const { instructorId } = Route.useParams()
|
||||
const search = Route.useSearch()
|
||||
const navigate = useNavigate()
|
||||
const queryClient = useQueryClient()
|
||||
const hasPermission = useAuthStore((s) => s.hasPermission)
|
||||
const canAdmin = hasPermission('lessons.admin')
|
||||
const tab = search.tab
|
||||
|
||||
function setTab(t: string) {
|
||||
navigate({ to: '/lessons/schedule/instructors/$instructorId', params: { instructorId }, search: { tab: t } as any })
|
||||
}
|
||||
|
||||
const { data: instructor, isLoading } = useQuery(instructorDetailOptions(instructorId))
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: (data: Record<string, unknown>) => instructorMutations.update(instructorId, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: instructorKeys.detail(instructorId) })
|
||||
toast.success('Instructor updated')
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
})
|
||||
|
||||
if (isLoading) return <div className="text-sm text-muted-foreground">Loading...</div>
|
||||
if (!instructor) return <div className="text-sm text-destructive">Instructor not found.</div>
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<Button variant="ghost" size="sm" onClick={() => navigate({ to: '/lessons/schedule', search: { tab: 'instructors' } as any })}>
|
||||
<ArrowLeft className="h-4 w-4 mr-1" />Back
|
||||
</Button>
|
||||
<div className="flex-1">
|
||||
<h1 className="text-2xl font-bold">{instructor.displayName}</h1>
|
||||
{instructor.instruments && instructor.instruments.length > 0 && (
|
||||
<p className="text-sm text-muted-foreground">{instructor.instruments.join(', ')}</p>
|
||||
)}
|
||||
</div>
|
||||
<Badge variant={instructor.isActive ? 'default' : 'secondary'}>
|
||||
{instructor.isActive ? 'Active' : 'Inactive'}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-1 border-b">
|
||||
{TABS.map((t) => (
|
||||
<button
|
||||
key={t.key}
|
||||
onClick={() => setTab(t.key)}
|
||||
className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
|
||||
tab === t.key
|
||||
? 'border-primary text-primary'
|
||||
: 'border-transparent text-muted-foreground hover:text-foreground'
|
||||
}`}
|
||||
>
|
||||
{t.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{tab === 'overview' && (
|
||||
<div className="max-w-lg">
|
||||
<InstructorForm
|
||||
defaultValues={instructor}
|
||||
onSubmit={updateMutation.mutate}
|
||||
loading={updateMutation.isPending}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{tab === 'slots' && <ScheduleSlotsTab instructorId={instructorId} canAdmin={canAdmin} />}
|
||||
{tab === 'blocked' && <BlockedDatesTab instructorId={instructorId} canAdmin={canAdmin} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Schedule Slots Tab ───────────────────────────────────────────────────────
|
||||
|
||||
function ScheduleSlotsTab({ instructorId, canAdmin }: { instructorId: string; canAdmin: boolean }) {
|
||||
const queryClient = useQueryClient()
|
||||
const [addOpen, setAddOpen] = useState(false)
|
||||
const [editSlot, setEditSlot] = useState<ScheduleSlot | null>(null)
|
||||
|
||||
const { data: slotsData } = useQuery(scheduleSlotListOptions({ page: 1, limit: 100, order: 'asc' }, { instructorId }))
|
||||
const { data: lessonTypesData } = useQuery(lessonTypeListOptions({ page: 1, limit: 100, order: 'asc' }))
|
||||
|
||||
const slots = slotsData?.data ?? []
|
||||
const lessonTypes = lessonTypesData?.data ?? []
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (data: Record<string, unknown>) => scheduleSlotMutations.create({ ...data, instructorId }),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: scheduleSlotKeys.all })
|
||||
toast.success('Schedule slot added')
|
||||
setAddOpen(false)
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
})
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: ({ id, data }: { id: string; data: Record<string, unknown> }) => scheduleSlotMutations.update(id, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: scheduleSlotKeys.all })
|
||||
toast.success('Slot updated')
|
||||
setEditSlot(null)
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
})
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: scheduleSlotMutations.delete,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: scheduleSlotKeys.all })
|
||||
toast.success('Slot removed')
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{canAdmin && (
|
||||
<div className="flex justify-end">
|
||||
<Dialog open={addOpen} onOpenChange={setAddOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button><Plus className="mr-2 h-4 w-4" />Add Slot</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader><DialogTitle>Add Schedule Slot</DialogTitle></DialogHeader>
|
||||
<ScheduleSlotForm
|
||||
lessonTypes={lessonTypes}
|
||||
onSubmit={createMutation.mutate}
|
||||
loading={createMutation.isPending}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{editSlot && (
|
||||
<Dialog open={!!editSlot} onOpenChange={(o) => { if (!o) setEditSlot(null) }}>
|
||||
<DialogContent>
|
||||
<DialogHeader><DialogTitle>Edit Schedule Slot</DialogTitle></DialogHeader>
|
||||
<ScheduleSlotForm
|
||||
lessonTypes={lessonTypes}
|
||||
defaultValues={editSlot}
|
||||
onSubmit={(data) => updateMutation.mutate({ id: editSlot.id, data })}
|
||||
loading={updateMutation.isPending}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)}
|
||||
|
||||
<WeeklySlotGrid
|
||||
slots={slots}
|
||||
lessonTypes={lessonTypes}
|
||||
onEdit={setEditSlot}
|
||||
onDelete={(slot) => deleteMutation.mutate(slot.id)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Blocked Dates Tab ────────────────────────────────────────────────────────
|
||||
|
||||
function BlockedDatesTab({ instructorId, canAdmin }: { instructorId: string; canAdmin: boolean }) {
|
||||
const queryClient = useQueryClient()
|
||||
const [addOpen, setAddOpen] = useState(false)
|
||||
|
||||
const { data: blockedDates, isLoading } = useQuery(instructorBlockedDatesOptions(instructorId))
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (data: Record<string, unknown>) =>
|
||||
instructorMutations.addBlockedDate(instructorId, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: instructorKeys.blockedDates(instructorId) })
|
||||
toast.success('Blocked date added')
|
||||
setAddOpen(false)
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
})
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id: string) => instructorMutations.deleteBlockedDate(instructorId, id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: instructorKeys.blockedDates(instructorId) })
|
||||
toast.success('Blocked date removed')
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
})
|
||||
|
||||
const dates = blockedDates ?? []
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{canAdmin && (
|
||||
<div className="flex justify-end">
|
||||
<Dialog open={addOpen} onOpenChange={setAddOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button><Plus className="mr-2 h-4 w-4" />Add Blocked Date</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader><DialogTitle>Add Blocked Date</DialogTitle></DialogHeader>
|
||||
<BlockedDateForm onSubmit={createMutation.mutate} loading={createMutation.isPending} />
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isLoading ? (
|
||||
<div className="text-sm text-muted-foreground">Loading...</div>
|
||||
) : dates.length === 0 ? (
|
||||
<div className="text-sm text-muted-foreground text-center py-8 border rounded-md">
|
||||
No blocked dates configured.
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y border rounded-md">
|
||||
{dates.map((d) => (
|
||||
<div key={d.id} className="flex items-center justify-between px-4 py-3">
|
||||
<div>
|
||||
<div className="font-medium text-sm">
|
||||
{new Date(d.startDate + 'T00:00:00').toLocaleDateString()} –{' '}
|
||||
{new Date(d.endDate + 'T00:00:00').toLocaleDateString()}
|
||||
</div>
|
||||
{d.reason && <div className="text-xs text-muted-foreground">{d.reason}</div>}
|
||||
</div>
|
||||
{canAdmin && (
|
||||
<Button variant="ghost" size="sm" onClick={() => deleteMutation.mutate(d.id)}>
|
||||
<Trash2 className="h-4 w-4 text-destructive" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,341 @@
|
||||
import { useState } from 'react'
|
||||
import { createFileRoute, useNavigate, Link } from '@tanstack/react-router'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import {
|
||||
sessionDetailOptions, sessionMutations, sessionKeys,
|
||||
sessionPlanItemsOptions,
|
||||
enrollmentDetailOptions,
|
||||
instructorDetailOptions, instructorListOptions,
|
||||
lessonPlanListOptions,
|
||||
} from '@/api/lessons'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
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, CheckSquare, Square } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
import type { LessonPlan, LessonPlanSection } from '@/types/lesson'
|
||||
|
||||
export const Route = createFileRoute('/_authenticated/lessons/sessions/$sessionId')({
|
||||
component: SessionDetailPage,
|
||||
})
|
||||
|
||||
const STATUS_ACTIONS: Record<string, { label: string; next: string; variant: 'default' | 'destructive' | 'secondary' | 'outline' }[]> = {
|
||||
scheduled: [
|
||||
{ label: 'Mark Attended', next: 'attended', variant: 'default' },
|
||||
{ label: 'Mark Missed', next: 'missed', variant: 'destructive' },
|
||||
{ label: 'Cancel', next: 'cancelled', variant: 'secondary' },
|
||||
],
|
||||
attended: [],
|
||||
missed: [],
|
||||
makeup: [
|
||||
{ label: 'Mark Attended', next: 'attended', variant: 'default' },
|
||||
{ label: 'Cancel', next: 'cancelled', variant: 'secondary' },
|
||||
],
|
||||
cancelled: [],
|
||||
}
|
||||
|
||||
function sessionStatusBadge(status: string) {
|
||||
const variants: Record<string, 'default' | 'secondary' | 'destructive' | 'outline'> = {
|
||||
scheduled: 'outline', attended: 'default', missed: 'destructive', makeup: 'secondary', cancelled: 'secondary',
|
||||
}
|
||||
return <Badge variant={variants[status] ?? 'outline'}>{status}</Badge>
|
||||
}
|
||||
|
||||
function formatTime(t: string) {
|
||||
const [h, m] = t.split(':').map(Number)
|
||||
const ampm = h >= 12 ? 'PM' : 'AM'
|
||||
const hour = h % 12 || 12
|
||||
return `${hour}:${String(m).padStart(2, '0')} ${ampm}`
|
||||
}
|
||||
|
||||
function SessionDetailPage() {
|
||||
const { sessionId } = Route.useParams()
|
||||
const navigate = useNavigate()
|
||||
const queryClient = useQueryClient()
|
||||
const hasPermission = useAuthStore((s) => s.hasPermission)
|
||||
const canEdit = hasPermission('lessons.edit')
|
||||
|
||||
const { data: session, isLoading } = useQuery(sessionDetailOptions(sessionId))
|
||||
|
||||
const { data: enrollment } = useQuery({
|
||||
...enrollmentDetailOptions(session?.enrollmentId ?? ''),
|
||||
enabled: !!session?.enrollmentId,
|
||||
})
|
||||
|
||||
const { data: instructorData } = useQuery({
|
||||
...instructorDetailOptions(session?.substituteInstructorId ?? enrollment?.instructorId ?? ''),
|
||||
enabled: !!(session?.substituteInstructorId ?? enrollment?.instructorId),
|
||||
})
|
||||
|
||||
const { data: instructorsList } = useQuery(instructorListOptions({ page: 1, limit: 100, order: 'asc' }))
|
||||
|
||||
const { data: planItems } = useQuery(sessionPlanItemsOptions(sessionId))
|
||||
|
||||
const { data: plansData } = useQuery({
|
||||
...lessonPlanListOptions({ enrollmentId: session?.enrollmentId ?? '', isActive: true }),
|
||||
enabled: !!session?.enrollmentId,
|
||||
})
|
||||
const activePlan: LessonPlan | undefined = plansData?.data?.[0]
|
||||
|
||||
const statusMutation = useMutation({
|
||||
mutationFn: (status: string) => sessionMutations.updateStatus(sessionId, status),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: sessionKeys.detail(sessionId) })
|
||||
toast.success('Status updated')
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
})
|
||||
|
||||
const notesMutation = useMutation({
|
||||
mutationFn: (data: Record<string, unknown>) => sessionMutations.updateNotes(sessionId, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: sessionKeys.detail(sessionId) })
|
||||
toast.success('Notes saved')
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
})
|
||||
|
||||
const subMutation = useMutation({
|
||||
mutationFn: (subId: string | null) => sessionMutations.update(sessionId, { substituteInstructorId: subId }),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: sessionKeys.detail(sessionId) })
|
||||
toast.success('Substitute updated')
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
})
|
||||
|
||||
const linkPlanItemsMutation = useMutation({
|
||||
mutationFn: (ids: string[]) => sessionMutations.linkPlanItems(sessionId, ids),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: sessionKeys.planItems(sessionId) })
|
||||
toast.success('Plan items linked')
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
})
|
||||
|
||||
if (isLoading) return <div className="text-sm text-muted-foreground">Loading...</div>
|
||||
if (!session) return <div className="text-sm text-destructive">Session not found.</div>
|
||||
|
||||
const linkedItemIds = new Set(planItems?.map((pi) => pi.lessonPlanItemId) ?? [])
|
||||
|
||||
return (
|
||||
<div className="space-y-6 max-w-3xl">
|
||||
<div className="flex items-center gap-3">
|
||||
<Button variant="ghost" size="sm" onClick={() => navigate({ to: '/lessons/sessions', search: {} as any })}>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<div className="flex-1">
|
||||
<h1 className="text-xl font-bold">
|
||||
{new Date(session.scheduledDate + 'T00:00:00').toLocaleDateString()} · {formatTime(session.scheduledTime)}
|
||||
</h1>
|
||||
{enrollment && (
|
||||
<Link
|
||||
to="/lessons/enrollments/$enrollmentId"
|
||||
params={{ enrollmentId: enrollment.id }}
|
||||
search={{} as any}
|
||||
className="text-sm text-primary hover:underline"
|
||||
>
|
||||
View Enrollment
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
{sessionStatusBadge(session.status)}
|
||||
</div>
|
||||
|
||||
{/* Status Actions */}
|
||||
{canEdit && (STATUS_ACTIONS[session.status]?.length ?? 0) > 0 && (
|
||||
<Card>
|
||||
<CardContent className="pt-4">
|
||||
<div className="flex gap-2">
|
||||
{STATUS_ACTIONS[session.status].map((action) => (
|
||||
<Button
|
||||
key={action.next}
|
||||
variant={action.variant}
|
||||
size="sm"
|
||||
onClick={() => statusMutation.mutate(action.next)}
|
||||
disabled={statusMutation.isPending}
|
||||
>
|
||||
{action.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Substitute Instructor */}
|
||||
{canEdit && (
|
||||
<Card>
|
||||
<CardHeader><CardTitle className="text-base">Substitute Instructor</CardTitle></CardHeader>
|
||||
<CardContent className="flex gap-3 items-center">
|
||||
<Select
|
||||
value={session.substituteInstructorId ?? 'none'}
|
||||
onValueChange={(v) => subMutation.mutate(v === 'none' ? null : v)}
|
||||
>
|
||||
<SelectTrigger className="flex-1">
|
||||
<SelectValue placeholder="No substitute" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">No substitute</SelectItem>
|
||||
{(instructorsList?.data ?? []).map((i) => (
|
||||
<SelectItem key={i.id} value={i.id}>{i.displayName}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Post-lesson Notes */}
|
||||
<NotesCard session={session} canEdit={canEdit} onSave={notesMutation.mutate} saving={notesMutation.isPending} />
|
||||
|
||||
{/* Plan Items */}
|
||||
{activePlan && (
|
||||
<PlanItemsCard
|
||||
plan={activePlan}
|
||||
linkedItemIds={linkedItemIds}
|
||||
onLink={(ids) => linkPlanItemsMutation.mutate(ids)}
|
||||
linking={linkPlanItemsMutation.isPending}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Notes Card ───────────────────────────────────────────────────────────────
|
||||
|
||||
function NotesCard({ session, canEdit, onSave, saving }: any) {
|
||||
const [instructorNotes, setInstructorNotes] = useState(session.instructorNotes ?? '')
|
||||
const [memberNotes, setMemberNotes] = useState(session.memberNotes ?? '')
|
||||
const [homeworkAssigned, setHomeworkAssigned] = useState(session.homeworkAssigned ?? '')
|
||||
const [nextLessonGoals, setNextLessonGoals] = useState(session.nextLessonGoals ?? '')
|
||||
const [topicsCovered, setTopicsCovered] = useState((session.topicsCovered ?? []).join(', '))
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-base">Post-lesson Notes</CardTitle>
|
||||
{session.notesCompletedAt && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Saved {new Date(session.notesCompletedAt).toLocaleString()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Instructor Notes</Label>
|
||||
<Textarea value={instructorNotes} onChange={(e) => setInstructorNotes(e.target.value)} rows={3} disabled={!canEdit} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Member Notes (shared with student)</Label>
|
||||
<Textarea value={memberNotes} onChange={(e) => setMemberNotes(e.target.value)} rows={2} disabled={!canEdit} />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Homework Assigned</Label>
|
||||
<Input value={homeworkAssigned} onChange={(e) => setHomeworkAssigned(e.target.value)} disabled={!canEdit} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Next Lesson Goals</Label>
|
||||
<Input value={nextLessonGoals} onChange={(e) => setNextLessonGoals(e.target.value)} disabled={!canEdit} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Topics Covered</Label>
|
||||
<Input
|
||||
value={topicsCovered}
|
||||
onChange={(e) => setTopicsCovered(e.target.value)}
|
||||
placeholder="Comma-separated topics"
|
||||
disabled={!canEdit}
|
||||
/>
|
||||
</div>
|
||||
{canEdit && (
|
||||
<Button
|
||||
onClick={() => onSave({
|
||||
instructorNotes: instructorNotes || undefined,
|
||||
memberNotes: memberNotes || undefined,
|
||||
homeworkAssigned: homeworkAssigned || undefined,
|
||||
nextLessonGoals: nextLessonGoals || undefined,
|
||||
topicsCovered: topicsCovered ? topicsCovered.split(',').map((s: string) => s.trim()).filter(Boolean) : undefined,
|
||||
})}
|
||||
disabled={saving}
|
||||
>
|
||||
{saving ? 'Saving...' : 'Save Notes'}
|
||||
</Button>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Plan Items Card ──────────────────────────────────────────────────────────
|
||||
|
||||
function PlanItemsCard({ plan, linkedItemIds, onLink, linking }: {
|
||||
plan: LessonPlan
|
||||
linkedItemIds: Set<string>
|
||||
onLink: (ids: string[]) => void
|
||||
linking: boolean
|
||||
}) {
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set(linkedItemIds))
|
||||
|
||||
function toggle(id: string) {
|
||||
if (linkedItemIds.has(id)) return // already committed
|
||||
setSelected((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(id)) next.delete(id)
|
||||
else next.add(id)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const newSelections = [...selected].filter((id) => !linkedItemIds.has(id))
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader><CardTitle className="text-base">Plan Items Worked On</CardTitle></CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{(plan.sections ?? []).map((section: LessonPlanSection) => (
|
||||
<div key={section.id}>
|
||||
<p className="text-sm font-semibold text-muted-foreground mb-2">{section.title}</p>
|
||||
<div className="space-y-1">
|
||||
{(section.items ?? []).map((item) => {
|
||||
const isLinked = linkedItemIds.has(item.id)
|
||||
const isSelected = selected.has(item.id)
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
className={`flex items-center gap-2 w-full text-left px-2 py-1.5 rounded text-sm transition-colors ${
|
||||
isLinked ? 'opacity-60 cursor-default' : 'hover:bg-accent cursor-pointer'
|
||||
}`}
|
||||
onClick={() => toggle(item.id)}
|
||||
disabled={isLinked}
|
||||
>
|
||||
{isLinked || isSelected
|
||||
? <CheckSquare className="h-4 w-4 text-primary shrink-0" />
|
||||
: <Square className="h-4 w-4 text-muted-foreground shrink-0" />}
|
||||
<span>{item.title}</span>
|
||||
{isLinked && <span className="text-xs text-muted-foreground ml-auto">linked</span>}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{newSelections.length > 0 && (
|
||||
<Button onClick={() => onLink(newSelections)} disabled={linking} size="sm">
|
||||
{linking ? 'Linking...' : `Link ${newSelections.length} item${newSelections.length !== 1 ? 's' : ''}`}
|
||||
</Button>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,278 @@
|
||||
import { useState } from 'react'
|
||||
import { createFileRoute, useNavigate } from '@tanstack/react-router'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { format, startOfWeek, endOfWeek, addWeeks, subWeeks, addDays, isSameDay } from 'date-fns'
|
||||
import { sessionListOptions } from '@/api/lessons'
|
||||
import { instructorListOptions } from '@/api/lessons'
|
||||
import { usePagination } from '@/hooks/use-pagination'
|
||||
import { DataTable, type Column } from '@/components/shared/data-table'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { Search, LayoutList, CalendarDays, ChevronLeft, ChevronRight } from 'lucide-react'
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
import type { LessonSession } from '@/types/lesson'
|
||||
|
||||
export const Route = createFileRoute('/_authenticated/lessons/sessions/')({
|
||||
validateSearch: (search: Record<string, unknown>) => ({
|
||||
view: (search.view as 'list' | 'week') || 'list',
|
||||
page: Number(search.page) || 1,
|
||||
limit: Number(search.limit) || 25,
|
||||
q: (search.q as string) || undefined,
|
||||
sort: (search.sort as string) || undefined,
|
||||
order: (search.order as 'asc' | 'desc') || 'desc',
|
||||
status: (search.status as string) || undefined,
|
||||
instructorId: (search.instructorId as string) || undefined,
|
||||
}),
|
||||
component: SessionsPage,
|
||||
})
|
||||
|
||||
const STATUS_COLORS: Record<string, string> = {
|
||||
attended: 'bg-green-100 border-green-400 text-green-800',
|
||||
missed: 'bg-red-100 border-red-400 text-red-800',
|
||||
cancelled: 'bg-gray-100 border-gray-300 text-gray-500',
|
||||
makeup: 'bg-purple-100 border-purple-400 text-purple-800',
|
||||
scheduled: 'bg-blue-100 border-blue-400 text-blue-800',
|
||||
}
|
||||
|
||||
function sessionStatusBadge(status: string) {
|
||||
const variants: Record<string, 'default' | 'secondary' | 'destructive' | 'outline'> = {
|
||||
scheduled: 'outline', attended: 'default', missed: 'destructive', makeup: 'secondary', cancelled: 'secondary',
|
||||
}
|
||||
return <Badge variant={variants[status] ?? 'outline'}>{status}</Badge>
|
||||
}
|
||||
|
||||
function formatTime(t: string) {
|
||||
const [h, m] = t.split(':').map(Number)
|
||||
const ampm = h >= 12 ? 'PM' : 'AM'
|
||||
const hour = h % 12 || 12
|
||||
return `${hour}:${String(m).padStart(2, '0')} ${ampm}`
|
||||
}
|
||||
|
||||
const listColumns: Column<LessonSession>[] = [
|
||||
{
|
||||
key: 'scheduled_date', header: 'Date', sortable: true,
|
||||
render: (s) => <>{new Date(s.scheduledDate + 'T00:00:00').toLocaleDateString()}</>,
|
||||
},
|
||||
{
|
||||
key: 'scheduled_time', header: 'Time',
|
||||
render: (s) => <>{formatTime(s.scheduledTime)}</>,
|
||||
},
|
||||
{
|
||||
key: 'member_name', header: 'Member',
|
||||
render: (s) => <span className="font-medium">{s.memberName ?? '—'}</span>,
|
||||
},
|
||||
{
|
||||
key: 'instructor_name', header: 'Instructor',
|
||||
render: (s) => <>{s.instructorName ?? '—'}</>,
|
||||
},
|
||||
{
|
||||
key: 'lesson_type', header: 'Lesson',
|
||||
render: (s) => <>{s.lessonTypeName ?? '—'}</>,
|
||||
},
|
||||
{ key: 'status', header: 'Status', sortable: true, render: (s) => sessionStatusBadge(s.status) },
|
||||
{
|
||||
key: 'notes', header: 'Notes',
|
||||
render: (s) => s.notesCompletedAt ? <Badge variant="outline" className="text-xs">Notes</Badge> : null,
|
||||
},
|
||||
]
|
||||
|
||||
const DAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
|
||||
|
||||
function SessionsPage() {
|
||||
const navigate = useNavigate()
|
||||
const search = Route.useSearch()
|
||||
const view = search.view ?? 'list'
|
||||
const { params, setPage, setSearch, setSort } = usePagination()
|
||||
const [searchInput, setSearchInput] = useState(params.q ?? '')
|
||||
const [statusFilter, setStatusFilter] = useState(search.status ?? '')
|
||||
const [weekStart, setWeekStart] = useState(() => startOfWeek(new Date(), { weekStartsOn: 0 }))
|
||||
const [weekInstructorId, setWeekInstructorId] = useState(search.instructorId ?? '')
|
||||
|
||||
const weekEnd = endOfWeek(weekStart, { weekStartsOn: 0 })
|
||||
|
||||
function setView(v: 'list' | 'week') {
|
||||
navigate({ to: '/lessons/sessions', search: { ...search, view: v, page: 1 } as any })
|
||||
}
|
||||
|
||||
function handleStatusChange(v: string) {
|
||||
const s = v === 'all' ? '' : v
|
||||
setStatusFilter(s)
|
||||
navigate({ to: '/lessons/sessions', search: { ...search, status: s || undefined, page: 1 } as any })
|
||||
}
|
||||
|
||||
// List query
|
||||
const listQueryParams: Record<string, unknown> = { ...params }
|
||||
if (statusFilter) listQueryParams.status = statusFilter
|
||||
const { data: listData, isLoading: listLoading } = useQuery({
|
||||
...sessionListOptions(listQueryParams),
|
||||
enabled: view === 'list',
|
||||
})
|
||||
|
||||
// Week query
|
||||
const weekQueryParams: Record<string, unknown> = {
|
||||
page: 1, limit: 100,
|
||||
sort: 'scheduled_date', order: 'asc',
|
||||
dateFrom: format(weekStart, 'yyyy-MM-dd'),
|
||||
dateTo: format(weekEnd, 'yyyy-MM-dd'),
|
||||
}
|
||||
if (weekInstructorId) weekQueryParams.instructorId = weekInstructorId
|
||||
const { data: weekData } = useQuery({
|
||||
...sessionListOptions(weekQueryParams),
|
||||
enabled: view === 'week',
|
||||
})
|
||||
|
||||
const { data: instructorsData } = useQuery({
|
||||
...instructorListOptions({ page: 1, limit: 100, order: 'asc' }),
|
||||
enabled: view === 'week',
|
||||
})
|
||||
|
||||
function handleSearchSubmit(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
setSearch(searchInput)
|
||||
}
|
||||
|
||||
const weekSessions = weekData?.data ?? []
|
||||
const weekDays = Array.from({ length: 7 }, (_, i) => addDays(weekStart, i))
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold">Sessions</h1>
|
||||
<div className="flex gap-1 border rounded-md p-1">
|
||||
<Button variant={view === 'list' ? 'default' : 'ghost'} size="sm" onClick={() => setView('list')}>
|
||||
<LayoutList className="h-4 w-4 mr-1" />List
|
||||
</Button>
|
||||
<Button variant={view === 'week' ? 'default' : 'ghost'} size="sm" onClick={() => setView('week')}>
|
||||
<CalendarDays className="h-4 w-4 mr-1" />Week
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{view === 'list' && (
|
||||
<>
|
||||
<div className="flex gap-3 flex-wrap">
|
||||
<form onSubmit={handleSearchSubmit} className="flex gap-2">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search sessions..."
|
||||
value={searchInput}
|
||||
onChange={(e) => setSearchInput(e.target.value)}
|
||||
className="pl-9 w-64"
|
||||
/>
|
||||
</div>
|
||||
<Button type="submit" variant="secondary">Search</Button>
|
||||
</form>
|
||||
<Select value={statusFilter || 'all'} onValueChange={handleStatusChange}>
|
||||
<SelectTrigger className="w-40">
|
||||
<SelectValue placeholder="All Statuses" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Statuses</SelectItem>
|
||||
<SelectItem value="scheduled">Scheduled</SelectItem>
|
||||
<SelectItem value="attended">Attended</SelectItem>
|
||||
<SelectItem value="missed">Missed</SelectItem>
|
||||
<SelectItem value="makeup">Makeup</SelectItem>
|
||||
<SelectItem value="cancelled">Cancelled</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<DataTable
|
||||
columns={listColumns}
|
||||
data={listData?.data ?? []}
|
||||
loading={listLoading}
|
||||
page={params.page}
|
||||
totalPages={listData?.pagination.totalPages ?? 1}
|
||||
total={listData?.pagination.total ?? 0}
|
||||
sort={params.sort}
|
||||
order={params.order}
|
||||
onPageChange={setPage}
|
||||
onSort={setSort}
|
||||
onRowClick={(s) => navigate({ to: '/lessons/sessions/$sessionId', params: { sessionId: s.id }, search: {} as any })}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{view === 'week' && (
|
||||
<div className="space-y-4">
|
||||
{/* Week nav + instructor filter */}
|
||||
<div className="flex items-center gap-4 flex-wrap">
|
||||
<div className="flex items-center gap-1">
|
||||
<Button variant="outline" size="icon" onClick={() => setWeekStart(subWeeks(weekStart, 1))}>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={() => setWeekStart(startOfWeek(new Date(), { weekStartsOn: 0 }))}>
|
||||
This Week
|
||||
</Button>
|
||||
<Button variant="outline" size="icon" onClick={() => setWeekStart(addWeeks(weekStart, 1))}>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<span className="text-sm font-medium text-muted-foreground">
|
||||
{format(weekStart, 'MMM d')} – {format(weekEnd, 'MMM d, yyyy')}
|
||||
</span>
|
||||
<Select value={weekInstructorId || 'all'} onValueChange={(v) => setWeekInstructorId(v === 'all' ? '' : v)}>
|
||||
<SelectTrigger className="w-48">
|
||||
<SelectValue placeholder="All Instructors" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Instructors</SelectItem>
|
||||
{(instructorsData?.data ?? []).map((i) => (
|
||||
<SelectItem key={i.id} value={i.id}>{i.displayName}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Week grid */}
|
||||
<div className="grid grid-cols-7 gap-px bg-border rounded-lg overflow-hidden border">
|
||||
{/* Day headers */}
|
||||
{weekDays.map((day) => {
|
||||
const isToday = isSameDay(day, new Date())
|
||||
return (
|
||||
<div key={day.toISOString()} className={`bg-muted/50 px-2 py-1.5 text-center ${isToday ? 'bg-primary/10' : ''}`}>
|
||||
<p className="text-xs font-medium text-muted-foreground">{DAYS[day.getDay()]}</p>
|
||||
<p className={`text-sm font-semibold ${isToday ? 'text-primary' : ''}`}>{format(day, 'd')}</p>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
{/* Session cells */}
|
||||
{weekDays.map((day) => {
|
||||
const daySessions = weekSessions.filter((s) => s.scheduledDate === format(day, 'yyyy-MM-dd'))
|
||||
const isToday = isSameDay(day, new Date())
|
||||
return (
|
||||
<div key={day.toISOString()} className={`bg-background min-h-32 p-1.5 space-y-1 ${isToday ? 'bg-primary/5' : ''}`}>
|
||||
{daySessions.length === 0 && (
|
||||
<p className="text-xs text-muted-foreground/40 text-center pt-4">—</p>
|
||||
)}
|
||||
{daySessions.map((s) => (
|
||||
<button
|
||||
key={s.id}
|
||||
onClick={() => navigate({ to: '/lessons/sessions/$sessionId', params: { sessionId: s.id }, search: {} as any })}
|
||||
className={`w-full text-left rounded border px-1.5 py-1 text-xs hover:opacity-80 transition-opacity ${STATUS_COLORS[s.status] ?? STATUS_COLORS.scheduled}`}
|
||||
>
|
||||
<p className="font-semibold">{formatTime(s.scheduledTime)}</p>
|
||||
<p className="truncate">{s.memberName ?? '—'}</p>
|
||||
{s.lessonTypeName && <p className="truncate text-[10px] opacity-70">{s.lessonTypeName}</p>}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Legend */}
|
||||
<div className="flex gap-3 flex-wrap text-xs text-muted-foreground">
|
||||
{Object.entries(STATUS_COLORS).map(([status, cls]) => (
|
||||
<span key={status} className={`inline-flex items-center gap-1 px-2 py-0.5 rounded border ${cls}`}>
|
||||
{status}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,320 @@
|
||||
import { useState } from 'react'
|
||||
import { createFileRoute, useNavigate } from '@tanstack/react-router'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import {
|
||||
lessonPlanTemplateDetailOptions, lessonPlanTemplateMutations, lessonPlanTemplateKeys,
|
||||
enrollmentListOptions,
|
||||
} from '@/api/lessons'
|
||||
import { globalMemberListOptions } from '@/api/members'
|
||||
import { TemplateSectionBuilder, type TemplateSectionRow } from '@/components/lessons/template-section-builder'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
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 { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||
import { ArrowLeft, Search, X, Zap } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
import type { LessonPlanTemplate } from '@/types/lesson'
|
||||
import type { MemberWithAccount } from '@/api/members'
|
||||
|
||||
export const Route = createFileRoute('/_authenticated/lessons/templates/$templateId')({
|
||||
component: TemplateDetailPage,
|
||||
})
|
||||
|
||||
function TemplateDetailPage() {
|
||||
const { templateId } = Route.useParams()
|
||||
const navigate = useNavigate()
|
||||
const queryClient = useQueryClient()
|
||||
const hasPermission = useAuthStore((s) => s.hasPermission)
|
||||
const canAdmin = hasPermission('lessons.admin')
|
||||
|
||||
const { data: template, isLoading } = useQuery(lessonPlanTemplateDetailOptions(templateId))
|
||||
|
||||
const [instantiateOpen, setInstantiateOpen] = useState(false)
|
||||
|
||||
if (isLoading) return <div className="text-sm text-muted-foreground">Loading...</div>
|
||||
if (!template) return <div className="text-sm text-destructive">Template not found.</div>
|
||||
|
||||
return (
|
||||
<div className="space-y-6 max-w-3xl">
|
||||
<div className="flex items-center gap-3">
|
||||
<Button variant="ghost" size="sm" onClick={() => navigate({ to: '/lessons/templates', search: {} as any })}>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<div className="flex-1">
|
||||
<h1 className="text-2xl font-bold">{template.name}</h1>
|
||||
{template.instrument && <p className="text-sm text-muted-foreground">{template.instrument}</p>}
|
||||
</div>
|
||||
<Badge variant={template.isActive ? 'default' : 'secondary'}>{template.isActive ? 'Active' : 'Inactive'}</Badge>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={() => setInstantiateOpen(true)}>
|
||||
<Zap className="h-4 w-4 mr-2" />Instantiate for Student
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{canAdmin && (
|
||||
<EditTemplateForm template={template} templateId={templateId} queryClient={queryClient} />
|
||||
)}
|
||||
|
||||
{/* Read-only curriculum preview */}
|
||||
{!canAdmin && (
|
||||
<Card>
|
||||
<CardHeader><CardTitle className="text-lg">Curriculum</CardTitle></CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{template.sections.map((section) => (
|
||||
<div key={section.id}>
|
||||
<p className="font-semibold text-sm">{section.title}</p>
|
||||
<ul className="mt-1 space-y-0.5 pl-4">
|
||||
{section.items.map((item) => (
|
||||
<li key={item.id} className="text-sm text-muted-foreground list-disc">{item.title}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<InstantiateDialog
|
||||
template={template}
|
||||
templateId={templateId}
|
||||
open={instantiateOpen}
|
||||
onClose={() => setInstantiateOpen(false)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Edit Form ────────────────────────────────────────────────────────────────
|
||||
|
||||
function EditTemplateForm({ template, templateId, queryClient }: { template: LessonPlanTemplate; templateId: string; queryClient: any }) {
|
||||
const [name, setName] = useState(template.name)
|
||||
const [description, setDescription] = useState(template.description ?? '')
|
||||
const [instrument, setInstrument] = useState(template.instrument ?? '')
|
||||
const [skillLevel, setSkillLevel] = useState<'beginner' | 'intermediate' | 'advanced' | 'all_levels'>(template.skillLevel)
|
||||
const [sections, setSections] = useState<TemplateSectionRow[]>(
|
||||
template.sections.map((s) => ({
|
||||
id: s.id,
|
||||
title: s.title,
|
||||
description: s.description ?? '',
|
||||
items: s.items.map((i) => ({ id: i.id, title: i.title, description: i.description ?? '' })),
|
||||
})),
|
||||
)
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: () =>
|
||||
lessonPlanTemplateMutations.update(templateId, {
|
||||
name,
|
||||
description: description || undefined,
|
||||
instrument: instrument || undefined,
|
||||
skillLevel,
|
||||
sections: sections.map((s, sIdx) => ({
|
||||
title: s.title,
|
||||
description: s.description || undefined,
|
||||
sortOrder: sIdx,
|
||||
items: s.items.map((item, iIdx) => ({
|
||||
title: item.title,
|
||||
description: item.description || undefined,
|
||||
sortOrder: iIdx,
|
||||
})),
|
||||
})),
|
||||
}),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: lessonPlanTemplateKeys.detail(templateId) })
|
||||
toast.success('Template updated')
|
||||
},
|
||||
onError: (err: Error) => toast.error(err.message),
|
||||
})
|
||||
|
||||
const allValid = name.trim() && sections.every((s) => s.title.trim() && s.items.every((i) => i.title.trim()))
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={(e) => { e.preventDefault(); updateMutation.mutate() }}
|
||||
className="space-y-6"
|
||||
>
|
||||
<Card>
|
||||
<CardHeader><CardTitle className="text-lg">Details</CardTitle></CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Name *</Label>
|
||||
<Input value={name} onChange={(e) => setName(e.target.value)} required />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Description</Label>
|
||||
<Textarea value={description} onChange={(e) => setDescription(e.target.value)} rows={2} />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Instrument</Label>
|
||||
<Input value={instrument} onChange={(e) => setInstrument(e.target.value)} placeholder="e.g. Piano" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Skill Level</Label>
|
||||
<Select value={skillLevel} onValueChange={(v) => setSkillLevel(v as 'beginner' | 'intermediate' | 'advanced' | 'all_levels')}>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="beginner">Beginner</SelectItem>
|
||||
<SelectItem value="intermediate">Intermediate</SelectItem>
|
||||
<SelectItem value="advanced">Advanced</SelectItem>
|
||||
<SelectItem value="all_levels">All Levels</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader><CardTitle className="text-lg">Curriculum</CardTitle></CardHeader>
|
||||
<CardContent>
|
||||
<TemplateSectionBuilder sections={sections} onChange={setSections} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Button type="submit" disabled={updateMutation.isPending || !allValid}>
|
||||
{updateMutation.isPending ? 'Saving...' : 'Save Changes'}
|
||||
</Button>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Instantiate Dialog ───────────────────────────────────────────────────────
|
||||
|
||||
function InstantiateDialog({ template, templateId, open, onClose }: {
|
||||
template: LessonPlanTemplate
|
||||
templateId: string
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
}) {
|
||||
const navigate = useNavigate()
|
||||
const [memberSearch, setMemberSearch] = useState('')
|
||||
const [showDropdown, setShowDropdown] = useState(false)
|
||||
const [selectedMember, setSelectedMember] = useState<MemberWithAccount | null>(null)
|
||||
const [selectedEnrollmentId, setSelectedEnrollmentId] = useState('')
|
||||
const [customTitle, setCustomTitle] = useState('')
|
||||
|
||||
const { data: membersData } = useQuery(
|
||||
globalMemberListOptions({ page: 1, limit: 20, q: memberSearch || undefined, order: 'asc', sort: 'first_name' }),
|
||||
)
|
||||
|
||||
const { data: enrollmentsData } = useQuery({
|
||||
...enrollmentListOptions({ memberId: selectedMember?.id ?? '', status: 'active', page: 1, limit: 50 }),
|
||||
enabled: !!selectedMember?.id,
|
||||
})
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: () =>
|
||||
lessonPlanTemplateMutations.createPlan(templateId, {
|
||||
memberId: selectedMember!.id,
|
||||
enrollmentId: selectedEnrollmentId || undefined,
|
||||
title: customTitle || undefined,
|
||||
}),
|
||||
onSuccess: (plan) => {
|
||||
toast.success('Plan created from template')
|
||||
navigate({ to: '/lessons/plans/$planId', params: { planId: plan.id }, search: {} as any })
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
})
|
||||
|
||||
const members = membersData?.data ?? []
|
||||
const enrollments = enrollmentsData?.data ?? []
|
||||
|
||||
function reset() {
|
||||
setMemberSearch('')
|
||||
setSelectedMember(null)
|
||||
setSelectedEnrollmentId('')
|
||||
setCustomTitle('')
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(o) => { if (!o) { reset(); onClose() } }}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create Plan from "{template.name}"</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
{/* Member select */}
|
||||
{!selectedMember ? (
|
||||
<div className="relative">
|
||||
<Label>Student *</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="Search member..."
|
||||
value={memberSearch}
|
||||
onChange={(e) => { setMemberSearch(e.target.value); setShowDropdown(true) }}
|
||||
onFocus={() => setShowDropdown(true)}
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
{showDropdown && memberSearch && (
|
||||
<div className="absolute z-50 mt-1 w-full rounded-md border bg-popover shadow-lg max-h-48 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={() => { setSelectedMember(m); setShowDropdown(false); setMemberSearch('') }}
|
||||
>
|
||||
<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>
|
||||
<Label>Student</Label>
|
||||
<div className="flex items-center justify-between mt-1 p-2 rounded-md border bg-muted/30">
|
||||
<p className="text-sm font-medium">{selectedMember.firstName} {selectedMember.lastName}</p>
|
||||
<Button type="button" variant="ghost" size="sm" onClick={() => { setSelectedMember(null); setSelectedEnrollmentId('') }}>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedMember && enrollments.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<Label>Enrollment (optional)</Label>
|
||||
<Select value={selectedEnrollmentId || 'none'} onValueChange={(v) => setSelectedEnrollmentId(v === 'none' ? '' : v)}>
|
||||
<SelectTrigger><SelectValue placeholder="Not linked to enrollment" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">Not linked to enrollment</SelectItem>
|
||||
{enrollments.map((e: any) => (
|
||||
<SelectItem key={e.id} value={e.id}>Enrollment {e.id.slice(-6)}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Custom Title</Label>
|
||||
<Input value={customTitle} onChange={(e) => setCustomTitle(e.target.value)} placeholder={`Leave blank to use "${template.name}"`} />
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={() => mutation.mutate()}
|
||||
disabled={!selectedMember || mutation.isPending}
|
||||
className="w-full"
|
||||
>
|
||||
{mutation.isPending ? 'Creating...' : 'Create Plan'}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
import { useState } from 'react'
|
||||
import { createFileRoute, useNavigate } from '@tanstack/react-router'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { lessonPlanTemplateListOptions, lessonPlanTemplateMutations, lessonPlanTemplateKeys } from '@/api/lessons'
|
||||
import { usePagination } from '@/hooks/use-pagination'
|
||||
import { DataTable, type Column } from '@/components/shared/data-table'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Plus, Search, Trash2 } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
import type { LessonPlanTemplate } from '@/types/lesson'
|
||||
|
||||
export const Route = createFileRoute('/_authenticated/lessons/templates/')({
|
||||
validateSearch: (search: Record<string, unknown>) => ({
|
||||
page: Number(search.page) || 1,
|
||||
limit: Number(search.limit) || 25,
|
||||
q: (search.q as string) || undefined,
|
||||
sort: (search.sort as string) || undefined,
|
||||
order: (search.order as 'asc' | 'desc') || 'asc',
|
||||
}),
|
||||
component: TemplatesListPage,
|
||||
})
|
||||
|
||||
const SKILL_LABELS: Record<string, string> = {
|
||||
beginner: 'Beginner',
|
||||
intermediate: 'Intermediate',
|
||||
advanced: 'Advanced',
|
||||
all_levels: 'All Levels',
|
||||
}
|
||||
|
||||
const SKILL_VARIANTS: Record<string, 'default' | 'secondary' | 'outline'> = {
|
||||
beginner: 'outline',
|
||||
intermediate: 'secondary',
|
||||
advanced: 'default',
|
||||
all_levels: 'outline',
|
||||
}
|
||||
|
||||
const columns: Column<LessonPlanTemplate>[] = [
|
||||
{ key: 'name', header: 'Name', sortable: true, render: (t) => <span className="font-medium">{t.name}</span> },
|
||||
{ key: 'instrument', header: 'Instrument', render: (t) => <>{t.instrument ?? <span className="text-muted-foreground">—</span>}</> },
|
||||
{
|
||||
key: 'skill_level', header: 'Level', sortable: true,
|
||||
render: (t) => <Badge variant={SKILL_VARIANTS[t.skillLevel] ?? 'outline'}>{SKILL_LABELS[t.skillLevel] ?? t.skillLevel}</Badge>,
|
||||
},
|
||||
{
|
||||
key: 'sections', header: 'Sections',
|
||||
render: (t) => <>{t.sections?.length ?? 0} sections</>,
|
||||
},
|
||||
{
|
||||
key: 'is_active', header: 'Status',
|
||||
render: (t) => <Badge variant={t.isActive ? 'default' : 'secondary'}>{t.isActive ? 'Active' : 'Inactive'}</Badge>,
|
||||
},
|
||||
]
|
||||
|
||||
function TemplatesListPage() {
|
||||
const navigate = useNavigate()
|
||||
const queryClient = useQueryClient()
|
||||
const hasPermission = useAuthStore((s) => s.hasPermission)
|
||||
const canAdmin = hasPermission('lessons.admin')
|
||||
const { params, setPage, setSearch, setSort } = usePagination()
|
||||
const [searchInput, setSearchInput] = useState(params.q ?? '')
|
||||
|
||||
const { data, isLoading } = useQuery(lessonPlanTemplateListOptions(params))
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: lessonPlanTemplateMutations.delete,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: lessonPlanTemplateKeys.all })
|
||||
toast.success('Template deleted')
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
})
|
||||
|
||||
function handleSearchSubmit(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
setSearch(searchInput)
|
||||
}
|
||||
|
||||
const columnsWithActions: Column<LessonPlanTemplate>[] = [
|
||||
...columns,
|
||||
...(canAdmin ? [{
|
||||
key: 'actions' as any,
|
||||
header: '' as any,
|
||||
render: (t: LessonPlanTemplate) => (
|
||||
<Button variant="ghost" size="sm" onClick={(e) => { e.stopPropagation(); deleteMutation.mutate(t.id) }}>
|
||||
<Trash2 className="h-4 w-4 text-destructive" />
|
||||
</Button>
|
||||
),
|
||||
}] : []),
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold">Lesson Plan Templates</h1>
|
||||
{canAdmin && (
|
||||
<Button onClick={() => navigate({ to: '/lessons/templates/new', search: {} as any })}>
|
||||
<Plus className="mr-2 h-4 w-4" />New Template
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSearchSubmit} className="flex gap-2 max-w-sm">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search templates..."
|
||||
value={searchInput}
|
||||
onChange={(e) => setSearchInput(e.target.value)}
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
<Button type="submit" variant="secondary">Search</Button>
|
||||
</form>
|
||||
|
||||
<DataTable
|
||||
columns={columnsWithActions}
|
||||
data={data?.data ?? []}
|
||||
loading={isLoading}
|
||||
page={params.page}
|
||||
totalPages={data?.pagination.totalPages ?? 1}
|
||||
total={data?.pagination.total ?? 0}
|
||||
sort={params.sort}
|
||||
order={params.order}
|
||||
onPageChange={setPage}
|
||||
onSort={setSort}
|
||||
onRowClick={(t) => navigate({ to: '/lessons/templates/$templateId', params: { templateId: t.id }, search: {} as any })}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
import { useState } from 'react'
|
||||
import { createFileRoute, useNavigate } from '@tanstack/react-router'
|
||||
import { useMutation } from '@tanstack/react-query'
|
||||
import { lessonPlanTemplateMutations } from '@/api/lessons'
|
||||
import { TemplateSectionBuilder, type TemplateSectionRow } from '@/components/lessons/template-section-builder'
|
||||
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 } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
export const Route = createFileRoute('/_authenticated/lessons/templates/new')({
|
||||
component: NewTemplatePage,
|
||||
})
|
||||
|
||||
function NewTemplatePage() {
|
||||
const navigate = useNavigate()
|
||||
|
||||
const [name, setName] = useState('')
|
||||
const [description, setDescription] = useState('')
|
||||
const [instrument, setInstrument] = useState('')
|
||||
const [skillLevel, setSkillLevel] = useState('all_levels')
|
||||
const [sections, setSections] = useState<TemplateSectionRow[]>([])
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: () =>
|
||||
lessonPlanTemplateMutations.create({
|
||||
name,
|
||||
description: description || undefined,
|
||||
instrument: instrument || undefined,
|
||||
skillLevel,
|
||||
sections: sections.map((s, sIdx) => ({
|
||||
title: s.title,
|
||||
description: s.description || undefined,
|
||||
sortOrder: sIdx,
|
||||
items: s.items.map((item, iIdx) => ({
|
||||
title: item.title,
|
||||
description: item.description || undefined,
|
||||
sortOrder: iIdx,
|
||||
})),
|
||||
})),
|
||||
}),
|
||||
onSuccess: (template) => {
|
||||
toast.success('Template created')
|
||||
navigate({ to: '/lessons/templates/$templateId', params: { templateId: template.id }, search: {} as any })
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
})
|
||||
|
||||
function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
if (!name.trim()) return
|
||||
mutation.mutate()
|
||||
}
|
||||
|
||||
const allSectionsValid = sections.every(
|
||||
(s) => s.title.trim() && s.items.every((i) => i.title.trim()),
|
||||
)
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-6 max-w-3xl">
|
||||
<div className="flex items-center gap-3">
|
||||
<Button type="button" variant="ghost" size="sm" onClick={() => navigate({ to: '/lessons/templates', search: {} as any })}>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<h1 className="text-2xl font-bold">New Template</h1>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader><CardTitle className="text-lg">Details</CardTitle></CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Name *</Label>
|
||||
<Input value={name} onChange={(e) => setName(e.target.value)} placeholder="e.g. Piano Foundations — Beginner" required />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Description</Label>
|
||||
<Textarea value={description} onChange={(e) => setDescription(e.target.value)} rows={2} placeholder="What this curriculum covers..." />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Instrument</Label>
|
||||
<Input value={instrument} onChange={(e) => setInstrument(e.target.value)} placeholder="e.g. Piano, Guitar" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Skill Level</Label>
|
||||
<Select value={skillLevel} onValueChange={setSkillLevel}>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="beginner">Beginner</SelectItem>
|
||||
<SelectItem value="intermediate">Intermediate</SelectItem>
|
||||
<SelectItem value="advanced">Advanced</SelectItem>
|
||||
<SelectItem value="all_levels">All Levels</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader><CardTitle className="text-lg">Curriculum</CardTitle></CardHeader>
|
||||
<CardContent>
|
||||
<TemplateSectionBuilder sections={sections} onChange={setSections} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button type="submit" disabled={mutation.isPending || !name.trim() || !allSectionsValid} size="lg">
|
||||
{mutation.isPending ? 'Creating...' : 'Create Template'}
|
||||
</Button>
|
||||
<Button type="button" variant="secondary" size="lg" onClick={() => navigate({ to: '/lessons/templates', search: {} as any })}>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
@@ -1,22 +1,26 @@
|
||||
import { useState } from 'react'
|
||||
import { createFileRoute, useParams, useNavigate, Link } from '@tanstack/react-router'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { api } from '@/lib/api-client'
|
||||
import { queryOptions } from '@tanstack/react-query'
|
||||
import { identifierListOptions, identifierMutations, identifierKeys } from '@/api/identifiers'
|
||||
import { enrollmentListOptions } from '@/api/lessons'
|
||||
import { moduleListOptions } from '@/api/modules'
|
||||
import { MemberForm } from '@/components/accounts/member-form'
|
||||
import { IdentifierForm, type IdentifierFiles } from '@/components/accounts/identifier-form'
|
||||
import { DataTable, type Column } from '@/components/shared/data-table'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { ArrowLeft, Plus, Trash2, CreditCard } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { AvatarUpload } from '@/components/shared/avatar-upload'
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { Member, MemberIdentifier } from '@/types/account'
|
||||
import { useState } from 'react'
|
||||
import { queryOptions } from '@tanstack/react-query'
|
||||
import type { Enrollment } from '@/types/lesson'
|
||||
|
||||
function memberDetailOptions(id: string) {
|
||||
return queryOptions({
|
||||
@@ -26,9 +30,14 @@ function memberDetailOptions(id: string) {
|
||||
}
|
||||
|
||||
export const Route = createFileRoute('/_authenticated/members/$memberId')({
|
||||
validateSearch: (search: Record<string, unknown>) => ({
|
||||
tab: (search.tab as string) || 'details',
|
||||
}),
|
||||
component: MemberDetailPage,
|
||||
})
|
||||
|
||||
// ─── Identifier images ────────────────────────────────────────────────────────
|
||||
|
||||
function IdentifierImages({ identifierId }: { identifierId: string }) {
|
||||
const { data } = useQuery({
|
||||
queryKey: ['files', 'member_identifier', identifierId],
|
||||
@@ -37,13 +46,10 @@ function IdentifierImages({ identifierId }: { identifierId: string }) {
|
||||
entityId: identifierId,
|
||||
}),
|
||||
})
|
||||
|
||||
const files = data?.data ?? []
|
||||
const frontFile = files.find((f) => f.category === 'front')
|
||||
const backFile = files.find((f) => f.category === 'back')
|
||||
|
||||
if (!frontFile && !backFile) return null
|
||||
|
||||
return (
|
||||
<div className="flex gap-2 mt-2">
|
||||
{frontFile && <img src={`/v1/files/serve/${frontFile.path}`} alt="Front" className="h-20 rounded border object-cover" />}
|
||||
@@ -58,16 +64,45 @@ const ID_TYPE_LABELS: Record<string, string> = {
|
||||
school_id: 'School ID',
|
||||
}
|
||||
|
||||
function statusBadge(status: string) {
|
||||
const variants: Record<string, 'default' | 'secondary' | 'destructive' | 'outline'> = {
|
||||
active: 'default', paused: 'secondary', cancelled: 'destructive', completed: 'outline',
|
||||
}
|
||||
return <Badge variant={variants[status] ?? 'outline'}>{status}</Badge>
|
||||
}
|
||||
|
||||
const enrollmentColumns: Column<Enrollment>[] = [
|
||||
{ key: 'status', header: 'Status', sortable: true, render: (e) => statusBadge(e.status) },
|
||||
{ key: 'instructor_name', header: 'Instructor', render: (e) => <>{(e as any).instructorName ?? e.instructorId}</> },
|
||||
{ key: 'slot_info', header: 'Day / Time', render: (e) => <>{(e as any).slotInfo ?? '—'}</> },
|
||||
{ key: 'lesson_type', header: 'Lesson', render: (e) => <>{(e as any).lessonTypeName ?? '—'}</> },
|
||||
{ key: 'start_date', header: 'Start', sortable: true, render: (e) => <>{new Date(e.startDate + 'T00:00:00').toLocaleDateString()}</> },
|
||||
{ key: 'rate', header: 'Rate', render: (e) => <>{e.rate ? `$${e.rate} / ${e.billingInterval} ${e.billingUnit}` : <span className="text-muted-foreground">—</span>}</> },
|
||||
]
|
||||
|
||||
// ─── Page ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
function MemberDetailPage() {
|
||||
const { memberId } = useParams({ from: '/_authenticated/members/$memberId' })
|
||||
const search = Route.useSearch()
|
||||
const navigate = useNavigate()
|
||||
const queryClient = useQueryClient()
|
||||
const [addIdOpen, setAddIdOpen] = useState(false)
|
||||
const [createLoading, setCreateLoading] = useState(false)
|
||||
const tab = search.tab ?? 'details'
|
||||
|
||||
const token = useAuthStore((s) => s.token)
|
||||
const hasPermission = useAuthStore((s) => s.hasPermission)
|
||||
|
||||
const { data: member, isLoading } = useQuery(memberDetailOptions(memberId))
|
||||
const { data: idsData } = useQuery(identifierListOptions(memberId))
|
||||
const [createLoading, setCreateLoading] = useState(false)
|
||||
const { data: idsData } = useQuery({ ...identifierListOptions(memberId), enabled: tab === 'identity' })
|
||||
const { data: modulesData } = useQuery(moduleListOptions())
|
||||
const lessonsEnabled = (modulesData?.data ?? []).some((m) => m.slug === 'lessons' && m.enabled && m.licensed)
|
||||
|
||||
const { data: enrollmentsData } = useQuery({
|
||||
...enrollmentListOptions({ memberId, page: 1, limit: 100, order: 'asc' }),
|
||||
enabled: tab === 'enrollments' && lessonsEnabled,
|
||||
})
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: (data: Record<string, unknown>) => api.patch<Member>(`/v1/members/${memberId}`, data),
|
||||
@@ -84,23 +119,19 @@ function MemberDetailPage() {
|
||||
formData.append('entityType', 'member_identifier')
|
||||
formData.append('entityId', identifierId)
|
||||
formData.append('category', category)
|
||||
|
||||
const res = await fetch('/v1/files', {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
body: formData,
|
||||
})
|
||||
if (!res.ok) return null
|
||||
const data = await res.json()
|
||||
return data.id
|
||||
return (await res.json()).id
|
||||
}
|
||||
|
||||
async function handleCreateIdentifier(data: Record<string, unknown>, files: IdentifierFiles) {
|
||||
setCreateLoading(true)
|
||||
try {
|
||||
const identifier = await identifierMutations.create(memberId, data)
|
||||
|
||||
// Upload images and update identifier with file IDs
|
||||
const updates: Record<string, unknown> = {}
|
||||
if (files.front) {
|
||||
const fileId = await uploadIdFile(identifier.id, files.front, 'front')
|
||||
@@ -110,11 +141,7 @@ function MemberDetailPage() {
|
||||
const fileId = await uploadIdFile(identifier.id, files.back, 'back')
|
||||
if (fileId) updates.imageBackFileId = fileId
|
||||
}
|
||||
|
||||
if (Object.keys(updates).length > 0) {
|
||||
await identifierMutations.update(identifier.id, updates)
|
||||
}
|
||||
|
||||
if (Object.keys(updates).length > 0) await identifierMutations.update(identifier.id, updates)
|
||||
queryClient.invalidateQueries({ queryKey: identifierKeys.all(memberId) })
|
||||
toast.success('ID added')
|
||||
setAddIdOpen(false)
|
||||
@@ -134,23 +161,33 @@ function MemberDetailPage() {
|
||||
onError: (err) => toast.error(err.message),
|
||||
})
|
||||
|
||||
function setTab(t: string) {
|
||||
navigate({ to: '/members/$memberId', params: { memberId }, search: { tab: t } as any })
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Skeleton className="h-8 w-48" />
|
||||
<Skeleton className="h-10 w-full" />
|
||||
<Skeleton className="h-64 w-full max-w-lg" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!member) {
|
||||
return <p className="text-muted-foreground">Member not found</p>
|
||||
}
|
||||
if (!member) return <p className="text-muted-foreground">Member not found</p>
|
||||
|
||||
const identifiers = idsData?.data ?? []
|
||||
|
||||
const tabs = [
|
||||
{ key: 'details', label: 'Details' },
|
||||
{ key: 'identity', label: 'Identity Documents' },
|
||||
...(lessonsEnabled ? [{ key: 'enrollments', label: 'Enrollments' }] : []),
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="space-y-6 max-w-2xl">
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-3">
|
||||
<Button variant="ghost" size="sm" onClick={() => navigate({ to: '/accounts/$accountId/members', params: { accountId: member.accountId }, search: {} as any })}>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
@@ -160,22 +197,34 @@ function MemberDetailPage() {
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<span>#{member.memberNumber}</span>
|
||||
{member.isMinor && <Badge variant="secondary">Minor</Badge>}
|
||||
<Link
|
||||
to="/accounts/$accountId"
|
||||
params={{ accountId: member.accountId }}
|
||||
className="hover:underline"
|
||||
>
|
||||
<Link to="/accounts/$accountId" params={{ accountId: member.accountId }} className="hover:underline">
|
||||
View Account
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">Details</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{/* Tabs */}
|
||||
<nav className="flex gap-1 border-b">
|
||||
{tabs.map((t) => (
|
||||
<button
|
||||
key={t.key}
|
||||
onClick={() => setTab(t.key)}
|
||||
className={cn(
|
||||
'px-4 py-2 text-sm font-medium border-b-2 -mb-px transition-colors',
|
||||
tab === t.key
|
||||
? 'border-primary text-foreground'
|
||||
: 'border-transparent text-muted-foreground hover:text-foreground hover:border-border',
|
||||
)}
|
||||
>
|
||||
{t.label}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
{/* Details tab */}
|
||||
{tab === 'details' && (
|
||||
<div className="max-w-lg space-y-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<AvatarUpload entityType="member" entityId={memberId} size="lg" />
|
||||
<div>
|
||||
@@ -189,25 +238,26 @@ function MemberDetailPage() {
|
||||
onSubmit={(data) => updateMutation.mutate(data)}
|
||||
loading={updateMutation.isPending}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<CardTitle className="text-lg">Identity Documents</CardTitle>
|
||||
<Dialog open={addIdOpen} onOpenChange={setAddIdOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button size="sm"><Plus className="mr-2 h-4 w-4" />Add ID</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader><DialogTitle>Add Identity Document</DialogTitle></DialogHeader>
|
||||
<IdentifierForm memberId={memberId} onSubmit={handleCreateIdentifier} loading={createLoading} />
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{/* Identity Documents tab */}
|
||||
{tab === 'identity' && (
|
||||
<div className="space-y-4 max-w-2xl">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm text-muted-foreground">{identifiers.length} document(s) on file</p>
|
||||
<Dialog open={addIdOpen} onOpenChange={setAddIdOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button size="sm"><Plus className="mr-2 h-4 w-4" />Add ID</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader><DialogTitle>Add Identity Document</DialogTitle></DialogHeader>
|
||||
<IdentifierForm memberId={memberId} onSubmit={handleCreateIdentifier} loading={createLoading} />
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
{identifiers.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground py-4 text-center">No IDs on file</p>
|
||||
<p className="text-sm text-muted-foreground py-8 text-center">No IDs on file</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{identifiers.map((id) => (
|
||||
@@ -225,9 +275,7 @@ function MemberDetailPage() {
|
||||
{id.issuedDate && <span>Issued: {id.issuedDate}</span>}
|
||||
{id.expiresAt && <span>Expires: {id.expiresAt}</span>}
|
||||
</div>
|
||||
{(id.imageFrontFileId || id.imageBackFileId) && (
|
||||
<IdentifierImages identifierId={id.id} />
|
||||
)}
|
||||
{(id.imageFrontFileId || id.imageBackFileId) && <IdentifierImages identifierId={id.id} />}
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" onClick={() => deleteIdMutation.mutate(id.id)}>
|
||||
@@ -237,8 +285,33 @@ function MemberDetailPage() {
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Enrollments tab */}
|
||||
{tab === 'enrollments' && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm text-muted-foreground">{enrollmentsData?.pagination.total ?? 0} enrollment(s)</p>
|
||||
{hasPermission('lessons.edit') && (
|
||||
<Button size="sm" onClick={() => navigate({ to: '/lessons/enrollments/new', search: { memberId } as any })}>
|
||||
<Plus className="h-4 w-4 mr-1" />Enroll
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<DataTable
|
||||
columns={enrollmentColumns}
|
||||
data={enrollmentsData?.data ?? []}
|
||||
loading={!enrollmentsData && tab === 'enrollments'}
|
||||
page={1}
|
||||
totalPages={1}
|
||||
total={enrollmentsData?.data?.length ?? 0}
|
||||
onPageChange={() => {}}
|
||||
onSort={() => {}}
|
||||
onRowClick={(e) => navigate({ to: '/lessons/enrollments/$enrollmentId', params: { enrollmentId: e.id }, search: {} as any })}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -84,7 +84,7 @@ function MembersListPage() {
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => navigate({ to: '/members/$memberId', params: { memberId: row.id } })}>
|
||||
<DropdownMenuItem onClick={() => navigate({ to: '/members/$memberId', params: { memberId: row.id }, search: {} as any })}>
|
||||
<Pencil className="mr-2 h-4 w-4" />
|
||||
Edit
|
||||
</DropdownMenuItem>
|
||||
@@ -134,7 +134,7 @@ function MembersListPage() {
|
||||
order={params.order}
|
||||
onPageChange={setPage}
|
||||
onSort={setSort}
|
||||
onRowClick={(member) => navigate({ to: '/members/$memberId', params: { memberId: member.id } })}
|
||||
onRowClick={(member) => navigate({ to: '/members/$memberId', params: { memberId: member.id }, search: {} as any })}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
216
packages/admin/src/types/lesson.ts
Normal file
216
packages/admin/src/types/lesson.ts
Normal file
@@ -0,0 +1,216 @@
|
||||
export interface Instructor {
|
||||
id: string
|
||||
userId: string | null
|
||||
displayName: string
|
||||
bio: string | null
|
||||
instruments: string[] | null
|
||||
isActive: boolean
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export interface InstructorBlockedDate {
|
||||
id: string
|
||||
instructorId: string
|
||||
startDate: string
|
||||
endDate: string
|
||||
reason: string | null
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export interface LessonType {
|
||||
id: string
|
||||
name: string
|
||||
instrument: string | null
|
||||
durationMinutes: number
|
||||
lessonFormat: 'private' | 'group'
|
||||
rateWeekly: string | null
|
||||
rateMonthly: string | null
|
||||
rateQuarterly: string | null
|
||||
isActive: boolean
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export interface ScheduleSlot {
|
||||
id: string
|
||||
instructorId: string
|
||||
lessonTypeId: string
|
||||
dayOfWeek: number
|
||||
startTime: string
|
||||
room: string | null
|
||||
maxStudents: number
|
||||
rateWeekly: string | null
|
||||
rateMonthly: string | null
|
||||
rateQuarterly: string | null
|
||||
isActive: boolean
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export interface Enrollment {
|
||||
id: string
|
||||
memberId: string
|
||||
accountId: string
|
||||
scheduleSlotId: string
|
||||
instructorId: string
|
||||
status: 'active' | 'paused' | 'cancelled' | 'completed'
|
||||
startDate: string
|
||||
endDate: string | null
|
||||
rate: string | null
|
||||
billingInterval: number | null
|
||||
billingUnit: 'day' | 'week' | 'month' | 'quarter' | 'year' | null
|
||||
makeupCredits: number
|
||||
notes: string | null
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export interface LessonSession {
|
||||
id: string
|
||||
enrollmentId: string
|
||||
scheduledDate: string
|
||||
scheduledTime: string
|
||||
actualStartTime: string | null
|
||||
actualEndTime: string | null
|
||||
status: 'scheduled' | 'attended' | 'missed' | 'makeup' | 'cancelled'
|
||||
instructorNotes: string | null
|
||||
memberNotes: string | null
|
||||
homeworkAssigned: string | null
|
||||
nextLessonGoals: string | null
|
||||
topicsCovered: string[] | null
|
||||
makeupForSessionId: string | null
|
||||
substituteInstructorId: string | null
|
||||
notesCompletedAt: string | null
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
// enriched fields from list endpoint
|
||||
memberName?: string
|
||||
instructorName?: string
|
||||
lessonTypeName?: string
|
||||
}
|
||||
|
||||
export interface GradingScaleLevel {
|
||||
id: string
|
||||
gradingScaleId: string
|
||||
value: string
|
||||
label: string
|
||||
numericValue: number
|
||||
colorHex: string | null
|
||||
sortOrder: number
|
||||
}
|
||||
|
||||
export interface GradingScale {
|
||||
id: string
|
||||
name: string
|
||||
description: string | null
|
||||
isDefault: boolean
|
||||
createdBy: string | null
|
||||
isActive: boolean
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
levels: GradingScaleLevel[]
|
||||
}
|
||||
|
||||
export interface LessonPlanItem {
|
||||
id: string
|
||||
sectionId: string
|
||||
title: string
|
||||
description: string | null
|
||||
status: 'not_started' | 'in_progress' | 'mastered' | 'skipped'
|
||||
gradingScaleId: string | null
|
||||
currentGradeValue: string | null
|
||||
targetGradeValue: string | null
|
||||
startedDate: string | null
|
||||
masteredDate: string | null
|
||||
notes: string | null
|
||||
sortOrder: number
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export interface LessonPlanSection {
|
||||
id: string
|
||||
lessonPlanId: string
|
||||
title: string
|
||||
description: string | null
|
||||
sortOrder: number
|
||||
createdAt: string
|
||||
items: LessonPlanItem[]
|
||||
}
|
||||
|
||||
export interface LessonPlan {
|
||||
id: string
|
||||
memberId: string
|
||||
enrollmentId: string
|
||||
createdBy: string | null
|
||||
title: string
|
||||
description: string | null
|
||||
isActive: boolean
|
||||
startedDate: string | null
|
||||
completedDate: string | null
|
||||
progress: number
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
sections: LessonPlanSection[]
|
||||
}
|
||||
|
||||
export interface LessonPlanItemGradeHistory {
|
||||
id: string
|
||||
lessonPlanItemId: string
|
||||
gradingScaleId: string | null
|
||||
gradeValue: string
|
||||
gradedBy: string | null
|
||||
sessionId: string | null
|
||||
notes: string | null
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export interface LessonPlanTemplateItem {
|
||||
id: string
|
||||
sectionId: string
|
||||
title: string
|
||||
description: string | null
|
||||
gradingScaleId: string | null
|
||||
targetGradeValue: string | null
|
||||
sortOrder: number
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export interface LessonPlanTemplateSection {
|
||||
id: string
|
||||
templateId: string
|
||||
title: string
|
||||
description: string | null
|
||||
sortOrder: number
|
||||
createdAt: string
|
||||
items: LessonPlanTemplateItem[]
|
||||
}
|
||||
|
||||
export interface LessonPlanTemplate {
|
||||
id: string
|
||||
name: string
|
||||
description: string | null
|
||||
instrument: string | null
|
||||
skillLevel: 'beginner' | 'intermediate' | 'advanced' | 'all_levels'
|
||||
createdBy: string | null
|
||||
isActive: boolean
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
sections: LessonPlanTemplateSection[]
|
||||
}
|
||||
|
||||
export interface StoreClosure {
|
||||
id: string
|
||||
name: string
|
||||
startDate: string
|
||||
endDate: string
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export interface SessionPlanItem {
|
||||
id: string
|
||||
sessionId: string
|
||||
lessonPlanItemId: string
|
||||
createdAt: string
|
||||
}
|
||||
@@ -50,7 +50,7 @@ If you can't find what you're looking for, contact your admin or system administ
|
||||
content: `
|
||||
# Accounts
|
||||
|
||||
An **account** is a billing entity — it could be a family, a business, a school, or an individual person. All billing, invoices, and payments are tied to an account.
|
||||
An **account** is an organizational entity — it could be a family, a business, a school, or an individual person. Members, repairs, and lessons are all linked to an account.
|
||||
|
||||
## Creating an Account
|
||||
|
||||
@@ -251,8 +251,7 @@ Permissions are organized by area:
|
||||
|
||||
- **Accounts** — view, edit, admin
|
||||
- **Inventory** — view, edit, admin
|
||||
- **POS** — view, edit, admin
|
||||
- **Rentals, Lessons, Repairs** — each has view, edit, admin
|
||||
- **Lessons, Repairs** — each has view, edit, admin
|
||||
|
||||
**Permission inheritance:** If a role has **admin** permission for an area, it automatically includes **edit** and **view** too. If it has **edit**, it includes **view**.
|
||||
|
||||
@@ -486,6 +485,249 @@ For pending approval tickets, you can:
|
||||
5. Then move the ticket to **Approved** status
|
||||
`.trim(),
|
||||
},
|
||||
{
|
||||
slug: 'lessons-overview',
|
||||
title: 'Lessons Overview',
|
||||
category: 'Lessons',
|
||||
content: `
|
||||
# Lessons
|
||||
|
||||
The Lessons module manages music instruction — instructors, schedules, student enrollments, individual sessions, and lesson plans with grading.
|
||||
|
||||
## Module Setup
|
||||
|
||||
Before using Lessons, an admin must enable the module in **Admin → Modules**.
|
||||
|
||||
## Core Concepts
|
||||
|
||||
- **Lesson Types** — the kinds of lessons you offer (e.g. "30-min Piano", "1-hr Guitar"). Each has default rates.
|
||||
- **Instructors** — staff members who teach. Each instructor has their own schedule slots.
|
||||
- **Schedule Slots** — recurring time blocks when an instructor is available (e.g. Mondays 3–5 PM). Slots can override the lesson type's default rates.
|
||||
- **Enrollments** — a student (member) assigned to a specific slot with agreed billing terms.
|
||||
- **Sessions** — individual lesson occurrences generated from enrollments. Each session is marked attended, missed, cancelled, or makeup.
|
||||
- **Lesson Plans** — per-student curriculum tracking with goals, notes, and graded progress.
|
||||
|
||||
## Typical Workflow
|
||||
|
||||
1. Create **Lesson Types** with default rates
|
||||
2. Add **Instructors**
|
||||
3. Create **Schedule Slots** for each instructor
|
||||
4. **Enroll** students into slots — set billing cycle and rate
|
||||
5. Sessions are created automatically on a weekly basis
|
||||
6. After each lesson, mark the session **Attended** (or Missed/Cancelled) and add notes
|
||||
7. Track progress via the student's **Lesson Plan**
|
||||
`.trim(),
|
||||
},
|
||||
{
|
||||
slug: 'lesson-types',
|
||||
title: 'Lesson Types',
|
||||
category: 'Lessons',
|
||||
content: `
|
||||
# Lesson Types
|
||||
|
||||
Lesson types define what you teach and your default rates. Examples: "30-min Piano", "1-hr Guitar", "Group Drum — 45 min".
|
||||
|
||||
## Creating a Lesson Type
|
||||
|
||||
1. Go to **Lessons → Lesson Types**
|
||||
2. Click **New Lesson Type**
|
||||
3. Enter a name and optional description
|
||||
4. Set default rates for each billing cycle:
|
||||
- **Weekly** — billed every week
|
||||
- **Monthly** — billed once a month
|
||||
- **Quarterly** — billed every 3 months
|
||||
5. Click **Create**
|
||||
|
||||
Rates here are defaults — individual schedule slots (and enrollments) can override them.
|
||||
|
||||
## Editing Rates
|
||||
|
||||
Click any lesson type to edit it. Rate changes only affect new enrollments — existing enrollments keep their agreed rate unless you manually update them.
|
||||
`.trim(),
|
||||
},
|
||||
{
|
||||
slug: 'instructors',
|
||||
title: 'Instructors',
|
||||
category: 'Lessons',
|
||||
content: `
|
||||
# Instructors
|
||||
|
||||
Instructors are the teachers in your system. Each instructor has a display name, contact info, and their own set of schedule slots.
|
||||
|
||||
## Adding an Instructor
|
||||
|
||||
1. Go to **Lessons → Instructors**
|
||||
2. Click **New Instructor**
|
||||
3. Enter the display name (shown everywhere in the UI), email, and optional phone
|
||||
4. Click **Create**
|
||||
|
||||
## Instructor Detail
|
||||
|
||||
Click an instructor to see their profile and all their schedule slots.
|
||||
|
||||
## Deactivating an Instructor
|
||||
|
||||
If an instructor leaves, you can mark them inactive. Their existing enrollments and session history are preserved — they just won't appear in dropdowns for new enrollments.
|
||||
`.trim(),
|
||||
},
|
||||
{
|
||||
slug: 'schedule-slots',
|
||||
title: 'Schedule Slots',
|
||||
category: 'Lessons',
|
||||
content: `
|
||||
# Schedule Slots
|
||||
|
||||
A schedule slot is a recurring time block when an instructor teaches — for example, "Mondays 4:00–4:30 PM with Sarah Chen, Piano".
|
||||
|
||||
## Creating a Slot
|
||||
|
||||
1. Go to **Lessons → Instructors** → click an instructor
|
||||
2. Click **Add Slot**, or go to **Lessons → Schedule** → **New Slot**
|
||||
3. Select:
|
||||
- **Lesson Type**
|
||||
- **Day of week**
|
||||
- **Start time** and **End time**
|
||||
4. Optionally set **instructor rate overrides** for this specific slot:
|
||||
- These override the lesson type's default rates for students in this slot
|
||||
- Leave blank to use the lesson type defaults
|
||||
5. Click **Create**
|
||||
|
||||
## Rate Override Logic
|
||||
|
||||
When enrolling a student, the rate auto-fills based on this priority:
|
||||
|
||||
1. Slot's rate for the chosen billing cycle (if set)
|
||||
2. Lesson type's rate for that cycle
|
||||
3. Manual entry (if neither is set)
|
||||
|
||||
## Blocked Dates
|
||||
|
||||
Use **Lessons → Blocked Dates** to mark days when lessons don't occur (holidays, school breaks). Sessions on blocked dates are automatically marked cancelled.
|
||||
`.trim(),
|
||||
},
|
||||
{
|
||||
slug: 'enrollments',
|
||||
title: 'Enrollments',
|
||||
category: 'Lessons',
|
||||
content: `
|
||||
# Enrollments
|
||||
|
||||
An enrollment connects a student (member) to a schedule slot with agreed billing terms.
|
||||
|
||||
## Creating an Enrollment
|
||||
|
||||
1. Go to **Lessons → Enrollments → New Enrollment**
|
||||
2. Select the **member** (student)
|
||||
3. Select the **instructor** and **schedule slot**
|
||||
4. Set the **billing cycle**:
|
||||
- Choose the interval (e.g. 1) and unit (week / month / quarter / year)
|
||||
- The rate field auto-fills from the slot or lesson type — you can override it manually
|
||||
5. Set the **start date**
|
||||
6. Click **Create Enrollment**
|
||||
|
||||
## Enrollment Statuses
|
||||
|
||||
- **Active** — student is actively taking lessons; sessions are being generated
|
||||
- **Paused** — temporarily on hold (e.g. summer break); no new sessions generated
|
||||
- **Cancelled** — permanently ended
|
||||
- **Completed** — finished the curriculum
|
||||
|
||||
## Changing Status
|
||||
|
||||
Open the enrollment and use the status dropdown. Adding a note when pausing or cancelling is recommended.
|
||||
|
||||
## Billing Terms
|
||||
|
||||
The **rate** and **billing cycle** represent what the customer agreed to pay. These don't connect to payment processing automatically — they're for reference when generating invoices.
|
||||
`.trim(),
|
||||
},
|
||||
{
|
||||
slug: 'sessions',
|
||||
title: 'Sessions',
|
||||
category: 'Lessons',
|
||||
content: `
|
||||
# Sessions
|
||||
|
||||
A session is one instance of a lesson. Sessions are generated automatically from active enrollments based on their schedule slot's day and time.
|
||||
|
||||
## Viewing Sessions
|
||||
|
||||
- **Lessons → Sessions** — list view with search and filters, or week view showing all sessions on a calendar grid
|
||||
- Use the **instructor filter** in the week view to focus on one teacher's schedule
|
||||
- Use the **status filter** in list view to find missed or upcoming sessions
|
||||
|
||||
## Session Statuses
|
||||
|
||||
- **Scheduled** — upcoming, not yet occurred
|
||||
- **Attended** — lesson took place
|
||||
- **Missed** — student didn't show
|
||||
- **Cancelled** — lesson was cancelled (instructor unavailable, holiday, etc.)
|
||||
- **Makeup** — a makeup session for a previously missed lesson
|
||||
|
||||
## Recording a Session
|
||||
|
||||
1. Click on a session (from the list or week view)
|
||||
2. Update the status (Attended, Missed, etc.)
|
||||
3. Add session notes — these feed into the student's lesson plan
|
||||
4. Click **Save**
|
||||
|
||||
## Makeup Sessions
|
||||
|
||||
If a student misses a session, you can schedule a makeup:
|
||||
|
||||
1. Open the missed session
|
||||
2. Click **Schedule Makeup**
|
||||
3. Choose a date and time
|
||||
4. A new session is created with status **Makeup** linked to the original
|
||||
|
||||
## Notes
|
||||
|
||||
Session notes are visible on the session detail page and also appear in the student's lesson plan history. Use them to record what was covered, what to practice, and any concerns.
|
||||
`.trim(),
|
||||
},
|
||||
{
|
||||
slug: 'lesson-plans',
|
||||
title: 'Lesson Plans & Grading',
|
||||
category: 'Lessons',
|
||||
content: `
|
||||
# Lesson Plans & Grading
|
||||
|
||||
Lesson plans track a student's curriculum, goals, and progress over time.
|
||||
|
||||
## Grading Scales
|
||||
|
||||
Before using grading, set up a grading scale in **Lessons → Grading Scales**. A scale defines the grades available (e.g. 1–5, A–F, or custom labels like "Needs Work / Developing / Proficient / Mastered").
|
||||
|
||||
## Lesson Plan Templates
|
||||
|
||||
Templates define a standard curriculum that can be applied to students. Go to **Lessons → Plan Templates** to create reusable outlines with goals organized by category.
|
||||
|
||||
## Student Lesson Plans
|
||||
|
||||
Each active enrollment can have a lesson plan. To view or edit a student's plan:
|
||||
|
||||
1. Go to the member's detail page → **Enrollments** tab
|
||||
2. Click the enrollment → **Lesson Plan** tab
|
||||
|
||||
Or go to **Lessons → Lesson Plans** and search by student name.
|
||||
|
||||
## Recording Progress
|
||||
|
||||
On the lesson plan detail page:
|
||||
|
||||
1. Click **Add Grade Entry**
|
||||
2. Select the goal or skill being assessed
|
||||
3. Choose the grade from your scale
|
||||
4. Add optional notes
|
||||
5. Click **Save**
|
||||
|
||||
Grade history is shown as a timeline so you can see improvement over time.
|
||||
|
||||
## Goals
|
||||
|
||||
Goals are the specific skills or pieces being tracked (e.g. "C Major Scale", "Correct bow hold", "Sight reading level 2"). Goals come from the plan template but can be customized per student.
|
||||
`.trim(),
|
||||
},
|
||||
]
|
||||
|
||||
export function getWikiPages(): WikiPage[] {
|
||||
|
||||
Reference in New Issue
Block a user