- 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
128 lines
5.1 KiB
TypeScript
128 lines
5.1 KiB
TypeScript
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>
|
|
)
|
|
}
|