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

@@ -112,14 +112,14 @@ suite('Lessons', { tags: ['lessons'] }, (t) => {
instrument: 'Piano',
durationMinutes: 30,
lessonFormat: 'private',
baseRateMonthly: 120,
rateMonthly: 120,
})
t.assert.status(res, 201)
t.assert.equal(res.data.name, '30-min Private Piano')
t.assert.equal(res.data.instrument, 'Piano')
t.assert.equal(res.data.durationMinutes, 30)
t.assert.equal(res.data.lessonFormat, 'private')
t.assert.equal(res.data.baseRateMonthly, '120')
t.assert.equal(res.data.rateMonthly, '120.00')
t.assert.ok(res.data.id)
})
@@ -129,12 +129,26 @@ suite('Lessons', { tags: ['lessons'] }, (t) => {
instrument: 'Guitar',
durationMinutes: 60,
lessonFormat: 'group',
baseRateMonthly: 80,
rateMonthly: 80,
})
t.assert.status(res, 201)
t.assert.equal(res.data.lessonFormat, 'group')
})
t.test('creates a lesson type with all three rate presets', { tags: ['lesson-types', 'create'] }, async () => {
const res = await t.api.post('/v1/lesson-types', {
name: 'Multi-Rate Type',
durationMinutes: 30,
rateWeekly: 35,
rateMonthly: 120,
rateQuarterly: 330,
})
t.assert.status(res, 201)
t.assert.equal(res.data.rateWeekly, '35.00')
t.assert.equal(res.data.rateMonthly, '120.00')
t.assert.equal(res.data.rateQuarterly, '330.00')
})
t.test('rejects lesson type without required fields', { tags: ['lesson-types', 'create', 'validation'] }, async () => {
const res = await t.api.post('/v1/lesson-types', {})
t.assert.status(res, 400)
@@ -162,12 +176,12 @@ suite('Lessons', { tags: ['lessons'] }, (t) => {
const res = await t.api.patch(`/v1/lesson-types/${created.data.id}`, {
name: 'After Update Type',
durationMinutes: 45,
baseRateMonthly: 150,
rateMonthly: 150,
})
t.assert.status(res, 200)
t.assert.equal(res.data.name, 'After Update Type')
t.assert.equal(res.data.durationMinutes, 45)
t.assert.equal(res.data.baseRateMonthly, '150')
t.assert.equal(res.data.rateMonthly, '150.00')
})
t.test('soft-deletes a lesson type', { tags: ['lesson-types', 'delete'] }, async () => {
@@ -370,6 +384,45 @@ suite('Lessons', { tags: ['lessons'] }, (t) => {
t.assert.equal(res.data.maxStudents, 3)
})
t.test('creates a schedule slot with instructor rate overrides', { tags: ['schedule-slots', 'create'] }, async () => {
const instructor = await t.api.post('/v1/instructors', { displayName: 'Rates Instructor' })
const lessonType = await t.api.post('/v1/lesson-types', { name: 'Rates Slot Type', durationMinutes: 30, rateMonthly: 100 })
const res = await t.api.post('/v1/schedule-slots', {
instructorId: instructor.data.id,
lessonTypeId: lessonType.data.id,
dayOfWeek: 1,
startTime: '13:00',
rateWeekly: 40,
rateMonthly: 150,
rateQuarterly: 400,
})
t.assert.status(res, 201)
t.assert.equal(res.data.rateWeekly, '40.00')
t.assert.equal(res.data.rateMonthly, '150.00')
t.assert.equal(res.data.rateQuarterly, '400.00')
})
t.test('updates schedule slot rates', { tags: ['schedule-slots', 'update'] }, async () => {
const instructor = await t.api.post('/v1/instructors', { displayName: 'Update Rates Instructor' })
const lessonType = await t.api.post('/v1/lesson-types', { name: 'Update Rates Type', durationMinutes: 30 })
const created = await t.api.post('/v1/schedule-slots', {
instructorId: instructor.data.id,
lessonTypeId: lessonType.data.id,
dayOfWeek: 3,
startTime: '09:00',
})
const res = await t.api.patch(`/v1/schedule-slots/${created.data.id}`, {
rateWeekly: 35,
rateMonthly: 120,
})
t.assert.status(res, 200)
t.assert.equal(res.data.rateWeekly, '35.00')
t.assert.equal(res.data.rateMonthly, '120.00')
t.assert.equal(res.data.rateQuarterly, null)
})
t.test('update detects conflict when changing time', { tags: ['schedule-slots', 'update', 'conflict'] }, async () => {
const instructor = await t.api.post('/v1/instructors', { displayName: 'Update Conflict Instructor' })
const lessonType = await t.api.post('/v1/lesson-types', { name: 'Update Conflict Type', durationMinutes: 30 })
@@ -505,13 +558,17 @@ suite('Lessons', { tags: ['lessons'] }, (t) => {
scheduleSlotId: slot.data.id,
instructorId: instructor.data.id,
startDate: '2026-01-15',
monthlyRate: 120,
rate: 120,
billingInterval: 1,
billingUnit: 'month',
notes: 'Beginner piano student',
})
t.assert.status(res, 201)
t.assert.equal(res.data.status, 'active')
t.assert.equal(res.data.memberId, member.data.id)
t.assert.equal(res.data.monthlyRate, '120.00')
t.assert.equal(res.data.rate, '120.00')
t.assert.equal(res.data.billingInterval, 1)
t.assert.equal(res.data.billingUnit, 'month')
t.assert.equal(res.data.startDate, '2026-01-15')
t.assert.equal(res.data.makeupCredits, 0)
})
@@ -618,12 +675,16 @@ suite('Lessons', { tags: ['lessons'] }, (t) => {
})
const res = await t.api.patch(`/v1/enrollments/${created.data.id}`, {
monthlyRate: 150,
rate: 150,
billingInterval: 2,
billingUnit: 'week',
notes: 'Updated rate',
endDate: '2026-06-30',
})
t.assert.status(res, 200)
t.assert.equal(res.data.monthlyRate, '150.00')
t.assert.equal(res.data.rate, '150.00')
t.assert.equal(res.data.billingInterval, 2)
t.assert.equal(res.data.billingUnit, 'week')
t.assert.equal(res.data.notes, 'Updated rate')
t.assert.equal(res.data.endDate, '2026-06-30')
})