Files
lunarfront-app/packages/admin/src/components/lessons/grade-entry-dialog.tsx
Ryan Moon 5ad27bc196 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
2026-03-30 18:52:57 -05:00

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