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:
Ryan Moon
2026-03-30 18:52:57 -05:00
parent 7680a73d88
commit 5ad27bc196
47 changed files with 6303 additions and 139 deletions

View 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}`),
}

View File

@@ -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,

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

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

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

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

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

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

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

View File

@@ -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 }

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

View File

@@ -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(

View File

@@ -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') && (

View File

@@ -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' },

View File

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

View File

@@ -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

View File

@@ -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>
) : (

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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>
)

View 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
}

View File

@@ -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 35 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:004: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. 15, AF, 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[] {