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:
45
packages/admin/src/components/lessons/blocked-date-form.tsx
Normal file
45
packages/admin/src/components/lessons/blocked-date-form.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
|
||||
interface Props {
|
||||
onSubmit: (data: Record<string, unknown>) => void
|
||||
loading?: boolean
|
||||
}
|
||||
|
||||
export function BlockedDateForm({ onSubmit, loading }: Props) {
|
||||
const { register, handleSubmit } = useForm({
|
||||
defaultValues: { startDate: '', endDate: '', reason: '' },
|
||||
})
|
||||
|
||||
function handleFormSubmit(data: { startDate: string; endDate: string; reason: string }) {
|
||||
onSubmit({
|
||||
startDate: data.startDate,
|
||||
endDate: data.endDate,
|
||||
reason: data.reason || undefined,
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(handleFormSubmit)} className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="bd-start">Start Date *</Label>
|
||||
<Input id="bd-start" type="date" {...register('startDate')} required />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="bd-end">End Date *</Label>
|
||||
<Input id="bd-end" type="date" {...register('endDate')} required />
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="bd-reason">Reason</Label>
|
||||
<Input id="bd-reason" {...register('reason')} placeholder="e.g. Vacation, Conference" />
|
||||
</div>
|
||||
<Button type="submit" disabled={loading} className="w-full">
|
||||
{loading ? 'Saving...' : 'Add Blocked Date'}
|
||||
</Button>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
127
packages/admin/src/components/lessons/grade-entry-dialog.tsx
Normal file
127
packages/admin/src/components/lessons/grade-entry-dialog.tsx
Normal file
@@ -0,0 +1,127 @@
|
||||
import { useState } from 'react'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { gradingScaleAllOptions, lessonPlanItemMutations, lessonPlanItemGradeHistoryOptions, lessonPlanItemKeys } from '@/api/lessons'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||
import { toast } from 'sonner'
|
||||
import type { LessonPlanItem } from '@/types/lesson'
|
||||
|
||||
interface Props {
|
||||
item: LessonPlanItem
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export function GradeEntryDialog({ item, open, onClose }: Props) {
|
||||
const queryClient = useQueryClient()
|
||||
const [selectedScaleId, setSelectedScaleId] = useState(item.gradingScaleId ?? '')
|
||||
const [selectedValue, setSelectedValue] = useState('')
|
||||
const [gradeNotes, setGradeNotes] = useState('')
|
||||
|
||||
const { data: scales } = useQuery(gradingScaleAllOptions())
|
||||
const { data: history } = useQuery(lessonPlanItemGradeHistoryOptions(item.id))
|
||||
|
||||
const selectedScale = scales?.find((s) => s.id === selectedScaleId)
|
||||
|
||||
const gradeMutation = useMutation({
|
||||
mutationFn: () =>
|
||||
lessonPlanItemMutations.addGrade(item.id, {
|
||||
gradingScaleId: selectedScaleId || undefined,
|
||||
gradeValue: selectedValue,
|
||||
notes: gradeNotes || undefined,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: lessonPlanItemKeys.gradeHistory(item.id) })
|
||||
toast.success('Grade recorded')
|
||||
setSelectedValue('')
|
||||
setGradeNotes('')
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
})
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(o) => { if (!o) onClose() }}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Grade: {item.title}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Grading Scale</Label>
|
||||
<Select value={selectedScaleId || 'none'} onValueChange={(v) => { setSelectedScaleId(v === 'none' ? '' : v); setSelectedValue('') }}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="No scale (freeform)" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">No scale (freeform)</SelectItem>
|
||||
{(scales ?? []).map((s) => (
|
||||
<SelectItem key={s.id} value={s.id}>{s.name}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Grade Value *</Label>
|
||||
{selectedScale ? (
|
||||
<Select value={selectedValue} onValueChange={setSelectedValue}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select grade..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{[...selectedScale.levels].sort((a, b) => a.sortOrder - b.sortOrder).map((level) => (
|
||||
<SelectItem key={level.id} value={level.value}>
|
||||
{level.value} — {level.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
<input
|
||||
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-xs transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
value={selectedValue}
|
||||
onChange={(e) => setSelectedValue(e.target.value)}
|
||||
placeholder="Enter grade (e.g. A, Pass, 85)"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Notes</Label>
|
||||
<Textarea value={gradeNotes} onChange={(e) => setGradeNotes(e.target.value)} rows={2} />
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={() => gradeMutation.mutate()}
|
||||
disabled={!selectedValue || gradeMutation.isPending}
|
||||
className="w-full"
|
||||
>
|
||||
{gradeMutation.isPending ? 'Recording...' : 'Record Grade'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Grade History */}
|
||||
{(history ?? []).length > 0 && (
|
||||
<div className="border-t pt-4 space-y-2">
|
||||
<p className="text-sm font-medium text-muted-foreground">Grade History</p>
|
||||
<div className="space-y-2 max-h-40 overflow-y-auto">
|
||||
{[...history!].reverse().map((h) => (
|
||||
<div key={h.id} className="flex items-start justify-between text-sm">
|
||||
<div>
|
||||
<span className="font-medium">{h.gradeValue}</span>
|
||||
{h.notes && <p className="text-xs text-muted-foreground">{h.notes}</p>}
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground">{new Date(h.createdAt).toLocaleDateString()}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
127
packages/admin/src/components/lessons/grading-scale-form.tsx
Normal file
127
packages/admin/src/components/lessons/grading-scale-form.tsx
Normal file
@@ -0,0 +1,127 @@
|
||||
import { useState } from 'react'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { Trash2, Plus } from 'lucide-react'
|
||||
|
||||
interface LevelRow {
|
||||
value: string
|
||||
label: string
|
||||
numericValue: string
|
||||
colorHex: string
|
||||
}
|
||||
|
||||
interface Props {
|
||||
onSubmit: (data: Record<string, unknown>) => void
|
||||
loading?: boolean
|
||||
}
|
||||
|
||||
const DEFAULT_LEVELS: LevelRow[] = [
|
||||
{ value: 'A', label: 'Excellent', numericValue: '4', colorHex: '#22c55e' },
|
||||
{ value: 'B', label: 'Good', numericValue: '3', colorHex: '#84cc16' },
|
||||
{ value: 'C', label: 'Developing', numericValue: '2', colorHex: '#eab308' },
|
||||
{ value: 'D', label: 'Beginning', numericValue: '1', colorHex: '#f97316' },
|
||||
]
|
||||
|
||||
export function GradingScaleForm({ onSubmit, loading }: Props) {
|
||||
const { register, handleSubmit } = useForm({
|
||||
defaultValues: { name: '', description: '', isDefault: false },
|
||||
})
|
||||
const [levels, setLevels] = useState<LevelRow[]>(DEFAULT_LEVELS)
|
||||
|
||||
function addLevel() {
|
||||
setLevels((prev) => [...prev, { value: '', label: '', numericValue: String(prev.length + 1), colorHex: '' }])
|
||||
}
|
||||
|
||||
function removeLevel(idx: number) {
|
||||
setLevels((prev) => prev.filter((_, i) => i !== idx))
|
||||
}
|
||||
|
||||
function updateLevel(idx: number, field: keyof LevelRow, value: string) {
|
||||
setLevels((prev) => prev.map((l, i) => (i === idx ? { ...l, [field]: value } : l)))
|
||||
}
|
||||
|
||||
function handleFormSubmit(data: { name: string; description: string; isDefault: boolean }) {
|
||||
onSubmit({
|
||||
name: data.name,
|
||||
description: data.description || undefined,
|
||||
isDefault: data.isDefault,
|
||||
levels: levels.map((l, i) => ({
|
||||
value: l.value,
|
||||
label: l.label,
|
||||
numericValue: Number(l.numericValue) || i + 1,
|
||||
colorHex: l.colorHex || undefined,
|
||||
sortOrder: i,
|
||||
})),
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(handleFormSubmit)} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="gs-name">Name *</Label>
|
||||
<Input id="gs-name" {...register('name')} placeholder="e.g. RCM Performance Scale" required />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="gs-desc">Description</Label>
|
||||
<Textarea id="gs-desc" {...register('description')} rows={2} />
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<input type="checkbox" id="gs-default" {...register('isDefault')} className="h-4 w-4" />
|
||||
<Label htmlFor="gs-default">Set as default scale</Label>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label>Grade Levels</Label>
|
||||
<Button type="button" variant="outline" size="sm" onClick={addLevel}>
|
||||
<Plus className="h-3 w-3 mr-1" />Add Level
|
||||
</Button>
|
||||
</div>
|
||||
<div className="space-y-2 max-h-64 overflow-y-auto pr-1">
|
||||
{levels.map((level, idx) => (
|
||||
<div key={idx} className="grid grid-cols-[1fr_2fr_1fr_auto_auto] gap-2 items-center">
|
||||
<Input
|
||||
placeholder="Value"
|
||||
value={level.value}
|
||||
onChange={(e) => updateLevel(idx, 'value', e.target.value)}
|
||||
required
|
||||
/>
|
||||
<Input
|
||||
placeholder="Label"
|
||||
value={level.label}
|
||||
onChange={(e) => updateLevel(idx, 'label', e.target.value)}
|
||||
required
|
||||
/>
|
||||
<Input
|
||||
type="number"
|
||||
placeholder="Score"
|
||||
value={level.numericValue}
|
||||
onChange={(e) => updateLevel(idx, 'numericValue', e.target.value)}
|
||||
/>
|
||||
<input
|
||||
type="color"
|
||||
value={level.colorHex || '#888888'}
|
||||
onChange={(e) => updateLevel(idx, 'colorHex', e.target.value)}
|
||||
className="h-9 w-9 rounded border border-input cursor-pointer"
|
||||
title="Color"
|
||||
/>
|
||||
<Button type="button" variant="ghost" size="icon" onClick={() => removeLevel(idx)} className="h-9 w-9">
|
||||
<Trash2 className="h-4 w-4 text-destructive" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{levels.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground text-center py-2">No levels — add at least one.</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Button type="submit" disabled={loading || levels.length === 0} className="w-full">
|
||||
{loading ? 'Saving...' : 'Create Grading Scale'}
|
||||
</Button>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
52
packages/admin/src/components/lessons/instructor-form.tsx
Normal file
52
packages/admin/src/components/lessons/instructor-form.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import type { Instructor } from '@/types/lesson'
|
||||
|
||||
interface Props {
|
||||
defaultValues?: Partial<Instructor>
|
||||
onSubmit: (data: Record<string, unknown>) => void
|
||||
loading?: boolean
|
||||
}
|
||||
|
||||
export function InstructorForm({ defaultValues, onSubmit, loading }: Props) {
|
||||
const { register, handleSubmit } = useForm({
|
||||
defaultValues: {
|
||||
displayName: defaultValues?.displayName ?? '',
|
||||
bio: defaultValues?.bio ?? '',
|
||||
instruments: defaultValues?.instruments?.join(', ') ?? '',
|
||||
},
|
||||
})
|
||||
|
||||
function handleFormSubmit(data: { displayName: string; bio: string; instruments: string }) {
|
||||
onSubmit({
|
||||
displayName: data.displayName,
|
||||
bio: data.bio || undefined,
|
||||
instruments: data.instruments
|
||||
? data.instruments.split(',').map((s) => s.trim()).filter(Boolean)
|
||||
: undefined,
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(handleFormSubmit)} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="displayName">Display Name *</Label>
|
||||
<Input id="displayName" {...register('displayName')} required />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="bio">Bio</Label>
|
||||
<Textarea id="bio" {...register('bio')} rows={3} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="instruments">Instruments</Label>
|
||||
<Input id="instruments" {...register('instruments')} placeholder="Piano, Guitar, Voice (comma-separated)" />
|
||||
</div>
|
||||
<Button type="submit" disabled={loading} className="w-full">
|
||||
{loading ? 'Saving...' : defaultValues ? 'Save Changes' : 'Create Instructor'}
|
||||
</Button>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
99
packages/admin/src/components/lessons/lesson-type-form.tsx
Normal file
99
packages/admin/src/components/lessons/lesson-type-form.tsx
Normal file
@@ -0,0 +1,99 @@
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import type { LessonType } from '@/types/lesson'
|
||||
|
||||
interface Props {
|
||||
defaultValues?: Partial<LessonType>
|
||||
onSubmit: (data: Record<string, unknown>) => void
|
||||
loading?: boolean
|
||||
}
|
||||
|
||||
export function LessonTypeForm({ defaultValues, onSubmit, loading }: Props) {
|
||||
const { register, handleSubmit, setValue, watch } = useForm({
|
||||
defaultValues: {
|
||||
name: defaultValues?.name ?? '',
|
||||
instrument: defaultValues?.instrument ?? '',
|
||||
durationMinutes: defaultValues?.durationMinutes ?? 30,
|
||||
lessonFormat: (defaultValues?.lessonFormat ?? 'private') as 'private' | 'group',
|
||||
rateWeekly: defaultValues?.rateWeekly ?? '',
|
||||
rateMonthly: defaultValues?.rateMonthly ?? '',
|
||||
rateQuarterly: defaultValues?.rateQuarterly ?? '',
|
||||
},
|
||||
})
|
||||
|
||||
const lessonFormat = watch('lessonFormat')
|
||||
|
||||
function handleFormSubmit(data: {
|
||||
name: string
|
||||
instrument: string
|
||||
durationMinutes: number
|
||||
lessonFormat: string
|
||||
rateWeekly: string
|
||||
rateMonthly: string
|
||||
rateQuarterly: string
|
||||
}) {
|
||||
onSubmit({
|
||||
name: data.name,
|
||||
instrument: data.instrument || undefined,
|
||||
durationMinutes: Number(data.durationMinutes),
|
||||
lessonFormat: data.lessonFormat,
|
||||
rateWeekly: data.rateWeekly || undefined,
|
||||
rateMonthly: data.rateMonthly || undefined,
|
||||
rateQuarterly: data.rateQuarterly || undefined,
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(handleFormSubmit)} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="lt-name">Name *</Label>
|
||||
<Input id="lt-name" {...register('name')} placeholder="e.g. Piano — 30 min Private" required />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="lt-instrument">Instrument</Label>
|
||||
<Input id="lt-instrument" {...register('instrument')} placeholder="e.g. Piano, Guitar" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="lt-duration">Duration (minutes) *</Label>
|
||||
<Input id="lt-duration" type="number" min={5} step={5} {...register('durationMinutes')} required />
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Format *</Label>
|
||||
<Select value={lessonFormat} onValueChange={(v) => setValue('lessonFormat', v as 'private' | 'group')}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="private">Private</SelectItem>
|
||||
<SelectItem value="group">Group</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="block mb-2">Default Rates (optional)</Label>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="lt-rate-weekly" className="text-xs text-muted-foreground">Weekly</Label>
|
||||
<Input id="lt-rate-weekly" type="number" step="0.01" min="0" {...register('rateWeekly')} placeholder="—" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="lt-rate-monthly" className="text-xs text-muted-foreground">Monthly</Label>
|
||||
<Input id="lt-rate-monthly" type="number" step="0.01" min="0" {...register('rateMonthly')} placeholder="—" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="lt-rate-quarterly" className="text-xs text-muted-foreground">Quarterly</Label>
|
||||
<Input id="lt-rate-quarterly" type="number" step="0.01" min="0" {...register('rateQuarterly')} placeholder="—" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Button type="submit" disabled={loading} className="w-full">
|
||||
{loading ? 'Saving...' : defaultValues?.id ? 'Save Changes' : 'Create Lesson Type'}
|
||||
</Button>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
124
packages/admin/src/components/lessons/schedule-slot-form.tsx
Normal file
124
packages/admin/src/components/lessons/schedule-slot-form.tsx
Normal file
@@ -0,0 +1,124 @@
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import type { LessonType, ScheduleSlot } from '@/types/lesson'
|
||||
|
||||
const DAYS = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday']
|
||||
|
||||
interface Props {
|
||||
lessonTypes: LessonType[]
|
||||
defaultValues?: Partial<ScheduleSlot>
|
||||
onSubmit: (data: Record<string, unknown>) => void
|
||||
loading?: boolean
|
||||
}
|
||||
|
||||
export function ScheduleSlotForm({ lessonTypes, defaultValues, onSubmit, loading }: Props) {
|
||||
const { register, handleSubmit, setValue, watch } = useForm({
|
||||
defaultValues: {
|
||||
dayOfWeek: String(defaultValues?.dayOfWeek ?? 1),
|
||||
startTime: defaultValues?.startTime ?? '',
|
||||
lessonTypeId: defaultValues?.lessonTypeId ?? '',
|
||||
room: defaultValues?.room ?? '',
|
||||
maxStudents: String(defaultValues?.maxStudents ?? 1),
|
||||
rateWeekly: defaultValues?.rateWeekly ?? '',
|
||||
rateMonthly: defaultValues?.rateMonthly ?? '',
|
||||
rateQuarterly: defaultValues?.rateQuarterly ?? '',
|
||||
},
|
||||
})
|
||||
|
||||
const dayOfWeek = watch('dayOfWeek')
|
||||
const lessonTypeId = watch('lessonTypeId')
|
||||
|
||||
function handleFormSubmit(data: {
|
||||
dayOfWeek: string
|
||||
startTime: string
|
||||
lessonTypeId: string
|
||||
room: string
|
||||
maxStudents: string
|
||||
rateWeekly: string
|
||||
rateMonthly: string
|
||||
rateQuarterly: string
|
||||
}) {
|
||||
onSubmit({
|
||||
dayOfWeek: Number(data.dayOfWeek),
|
||||
startTime: data.startTime,
|
||||
lessonTypeId: data.lessonTypeId,
|
||||
room: data.room || undefined,
|
||||
maxStudents: Number(data.maxStudents) || 1,
|
||||
rateWeekly: data.rateWeekly || undefined,
|
||||
rateMonthly: data.rateMonthly || undefined,
|
||||
rateQuarterly: data.rateQuarterly || undefined,
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(handleFormSubmit)} className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Day *</Label>
|
||||
<Select value={dayOfWeek} onValueChange={(v) => setValue('dayOfWeek', v)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{DAYS.map((day, i) => (
|
||||
<SelectItem key={i} value={String(i)}>{day}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="slot-time">Start Time *</Label>
|
||||
<Input id="slot-time" type="time" {...register('startTime')} required />
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Lesson Type *</Label>
|
||||
<Select value={lessonTypeId} onValueChange={(v) => setValue('lessonTypeId', v)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select lesson type..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{lessonTypes.map((lt) => (
|
||||
<SelectItem key={lt.id} value={lt.id}>
|
||||
{lt.name} ({lt.durationMinutes} min)
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="slot-room">Room</Label>
|
||||
<Input id="slot-room" {...register('room')} placeholder="e.g. Studio A" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="slot-max">Max Students</Label>
|
||||
<Input id="slot-max" type="number" min={1} {...register('maxStudents')} />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="block mb-2">Instructor Rates (override lesson type defaults)</Label>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="slot-rate-weekly" className="text-xs text-muted-foreground">Weekly</Label>
|
||||
<Input id="slot-rate-weekly" type="number" step="0.01" min="0" {...register('rateWeekly')} placeholder="—" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="slot-rate-monthly" className="text-xs text-muted-foreground">Monthly</Label>
|
||||
<Input id="slot-rate-monthly" type="number" step="0.01" min="0" {...register('rateMonthly')} placeholder="—" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="slot-rate-quarterly" className="text-xs text-muted-foreground">Quarterly</Label>
|
||||
<Input id="slot-rate-quarterly" type="number" step="0.01" min="0" {...register('rateQuarterly')} placeholder="—" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Button type="submit" disabled={loading || !lessonTypeId} className="w-full">
|
||||
{loading ? 'Saving...' : defaultValues?.id ? 'Save Changes' : 'Add Slot'}
|
||||
</Button>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
41
packages/admin/src/components/lessons/store-closure-form.tsx
Normal file
41
packages/admin/src/components/lessons/store-closure-form.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
|
||||
interface Props {
|
||||
onSubmit: (data: Record<string, unknown>) => void
|
||||
loading?: boolean
|
||||
}
|
||||
|
||||
export function StoreClosureForm({ onSubmit, loading }: Props) {
|
||||
const { register, handleSubmit } = useForm({
|
||||
defaultValues: { name: '', startDate: '', endDate: '' },
|
||||
})
|
||||
|
||||
function handleFormSubmit(data: { name: string; startDate: string; endDate: string }) {
|
||||
onSubmit({ name: data.name, startDate: data.startDate, endDate: data.endDate })
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(handleFormSubmit)} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="closure-name">Name *</Label>
|
||||
<Input id="closure-name" {...register('name')} placeholder="e.g. Thanksgiving Break" required />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="closure-start">Start Date *</Label>
|
||||
<Input id="closure-start" type="date" {...register('startDate')} required />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="closure-end">End Date *</Label>
|
||||
<Input id="closure-end" type="date" {...register('endDate')} required />
|
||||
</div>
|
||||
</div>
|
||||
<Button type="submit" disabled={loading} className="w-full">
|
||||
{loading ? 'Saving...' : 'Add Closure'}
|
||||
</Button>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
import { useState } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Trash2, Plus, ChevronUp, ChevronDown } from 'lucide-react'
|
||||
|
||||
interface TemplateItemRow {
|
||||
id: string
|
||||
title: string
|
||||
description: string
|
||||
}
|
||||
|
||||
interface TemplateSectionRow {
|
||||
id: string
|
||||
title: string
|
||||
description: string
|
||||
items: TemplateItemRow[]
|
||||
}
|
||||
|
||||
interface Props {
|
||||
sections: TemplateSectionRow[]
|
||||
onChange: (sections: TemplateSectionRow[]) => void
|
||||
}
|
||||
|
||||
function uid() {
|
||||
return Math.random().toString(36).slice(2)
|
||||
}
|
||||
|
||||
export function TemplateSectionBuilder({ sections, onChange }: Props) {
|
||||
function addSection() {
|
||||
onChange([...sections, { id: uid(), title: '', description: '', items: [] }])
|
||||
}
|
||||
|
||||
function removeSection(idx: number) {
|
||||
onChange(sections.filter((_, i) => i !== idx))
|
||||
}
|
||||
|
||||
function moveSection(idx: number, dir: -1 | 1) {
|
||||
const next = [...sections]
|
||||
const [removed] = next.splice(idx, 1)
|
||||
next.splice(idx + dir, 0, removed)
|
||||
onChange(next)
|
||||
}
|
||||
|
||||
function updateSection(idx: number, field: 'title' | 'description', value: string) {
|
||||
onChange(sections.map((s, i) => (i === idx ? { ...s, [field]: value } : s)))
|
||||
}
|
||||
|
||||
function addItem(sIdx: number) {
|
||||
onChange(sections.map((s, i) =>
|
||||
i === sIdx ? { ...s, items: [...s.items, { id: uid(), title: '', description: '' }] } : s,
|
||||
))
|
||||
}
|
||||
|
||||
function removeItem(sIdx: number, iIdx: number) {
|
||||
onChange(sections.map((s, i) =>
|
||||
i === sIdx ? { ...s, items: s.items.filter((_, j) => j !== iIdx) } : s,
|
||||
))
|
||||
}
|
||||
|
||||
function moveItem(sIdx: number, iIdx: number, dir: -1 | 1) {
|
||||
onChange(sections.map((s, i) => {
|
||||
if (i !== sIdx) return s
|
||||
const next = [...s.items]
|
||||
const [removed] = next.splice(iIdx, 1)
|
||||
next.splice(iIdx + dir, 0, removed)
|
||||
return { ...s, items: next }
|
||||
}))
|
||||
}
|
||||
|
||||
function updateItem(sIdx: number, iIdx: number, field: 'title' | 'description', value: string) {
|
||||
onChange(sections.map((s, i) =>
|
||||
i === sIdx
|
||||
? { ...s, items: s.items.map((item, j) => (j === iIdx ? { ...item, [field]: value } : item)) }
|
||||
: s,
|
||||
))
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{sections.map((section, sIdx) => (
|
||||
<div key={section.id} className="border rounded-lg overflow-hidden">
|
||||
<div className="bg-muted/40 px-3 py-2 flex items-center gap-2">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<Button type="button" variant="ghost" size="icon" className="h-4 w-4" disabled={sIdx === 0} onClick={() => moveSection(sIdx, -1)}>
|
||||
<ChevronUp className="h-3 w-3" />
|
||||
</Button>
|
||||
<Button type="button" variant="ghost" size="icon" className="h-4 w-4" disabled={sIdx === sections.length - 1} onClick={() => moveSection(sIdx, 1)}>
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
<Input
|
||||
className="h-7 text-sm font-medium flex-1"
|
||||
placeholder="Section title *"
|
||||
value={section.title}
|
||||
onChange={(e) => updateSection(sIdx, 'title', e.target.value)}
|
||||
required
|
||||
/>
|
||||
<Button type="button" variant="ghost" size="icon" className="h-7 w-7" onClick={() => removeSection(sIdx)}>
|
||||
<Trash2 className="h-4 w-4 text-destructive" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="p-3 space-y-2">
|
||||
<Textarea
|
||||
className="text-xs resize-none"
|
||||
placeholder="Section description (optional)"
|
||||
rows={1}
|
||||
value={section.description}
|
||||
onChange={(e) => updateSection(sIdx, 'description', e.target.value)}
|
||||
/>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
{section.items.map((item, iIdx) => (
|
||||
<div key={item.id} className="flex items-center gap-2">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<Button type="button" variant="ghost" size="icon" className="h-4 w-4" disabled={iIdx === 0} onClick={() => moveItem(sIdx, iIdx, -1)}>
|
||||
<ChevronUp className="h-3 w-3" />
|
||||
</Button>
|
||||
<Button type="button" variant="ghost" size="icon" className="h-4 w-4" disabled={iIdx === section.items.length - 1} onClick={() => moveItem(sIdx, iIdx, 1)}>
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
<Input
|
||||
className="h-7 text-xs flex-1"
|
||||
placeholder="Item title *"
|
||||
value={item.title}
|
||||
onChange={(e) => updateItem(sIdx, iIdx, 'title', e.target.value)}
|
||||
required
|
||||
/>
|
||||
<Input
|
||||
className="h-7 text-xs flex-1"
|
||||
placeholder="Description (optional)"
|
||||
value={item.description}
|
||||
onChange={(e) => updateItem(sIdx, iIdx, 'description', e.target.value)}
|
||||
/>
|
||||
<Button type="button" variant="ghost" size="icon" className="h-7 w-7 shrink-0" onClick={() => removeItem(sIdx, iIdx)}>
|
||||
<Trash2 className="h-3 w-3 text-destructive" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Button type="button" variant="outline" size="sm" className="text-xs h-7" onClick={() => addItem(sIdx)}>
|
||||
<Plus className="h-3 w-3 mr-1" />Add Item
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<Button type="button" variant="outline" size="sm" onClick={addSection}>
|
||||
<Plus className="h-4 w-4 mr-1" />Add Section
|
||||
</Button>
|
||||
|
||||
{sections.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground text-center py-2">No sections yet — add one above.</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export type { TemplateSectionRow, TemplateItemRow }
|
||||
87
packages/admin/src/components/lessons/weekly-slot-grid.tsx
Normal file
87
packages/admin/src/components/lessons/weekly-slot-grid.tsx
Normal file
@@ -0,0 +1,87 @@
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Pencil, Trash2 } from 'lucide-react'
|
||||
import type { ScheduleSlot, LessonType } from '@/types/lesson'
|
||||
|
||||
const DAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
|
||||
|
||||
interface Props {
|
||||
slots: ScheduleSlot[]
|
||||
lessonTypes: LessonType[]
|
||||
onEdit: (slot: ScheduleSlot) => void
|
||||
onDelete: (slot: ScheduleSlot) => void
|
||||
}
|
||||
|
||||
function formatTime(t: string) {
|
||||
const [h, m] = t.split(':').map(Number)
|
||||
const ampm = h >= 12 ? 'PM' : 'AM'
|
||||
const hour = h % 12 || 12
|
||||
return `${hour}:${String(m).padStart(2, '0')} ${ampm}`
|
||||
}
|
||||
|
||||
export function WeeklySlotGrid({ slots, lessonTypes, onEdit, onDelete }: Props) {
|
||||
const ltMap = new Map(lessonTypes.map((lt) => [lt.id, lt]))
|
||||
|
||||
const slotsByDay = DAYS.map((_, day) =>
|
||||
slots.filter((s) => s.dayOfWeek === day).sort((a, b) => a.startTime.localeCompare(b.startTime)),
|
||||
)
|
||||
|
||||
const hasAny = slots.length > 0
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-7 gap-2">
|
||||
{DAYS.map((day, idx) => (
|
||||
<div key={day} className="min-h-[120px]">
|
||||
<div className="text-xs font-semibold text-muted-foreground uppercase tracking-wide text-center mb-2 py-1 border-b">
|
||||
{day}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{slotsByDay[idx].map((slot) => {
|
||||
const lt = ltMap.get(slot.lessonTypeId)
|
||||
return (
|
||||
<div
|
||||
key={slot.id}
|
||||
className="bg-sidebar-accent rounded-md p-2 text-xs group relative"
|
||||
>
|
||||
<div className="font-medium">{formatTime(slot.startTime)}</div>
|
||||
<div className="text-muted-foreground truncate">{lt?.name ?? 'Unknown'}</div>
|
||||
{slot.room && <div className="text-muted-foreground">{slot.room}</div>}
|
||||
{lt && (
|
||||
<Badge variant="outline" className="mt-1 text-[10px] py-0">
|
||||
{lt.lessonFormat}
|
||||
</Badge>
|
||||
)}
|
||||
<div className="absolute top-1 right-1 hidden group-hover:flex gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-5 w-5"
|
||||
onClick={() => onEdit(slot)}
|
||||
title="Edit"
|
||||
>
|
||||
<Pencil className="h-3 w-3" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-5 w-5"
|
||||
onClick={() => onDelete(slot)}
|
||||
title="Delete"
|
||||
>
|
||||
<Trash2 className="h-3 w-3 text-destructive" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{!hasAny && (
|
||||
<div className="col-span-7 text-center text-sm text-muted-foreground py-8">
|
||||
No schedule slots yet — add one to get started.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user