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