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

@@ -1,6 +1,7 @@
import { eq, and, ne, count, gte, lte, inArray, type Column, type SQL } from 'drizzle-orm'
import type { PostgresJsDatabase } from 'drizzle-orm/postgres-js'
import { instructors, lessonTypes, scheduleSlots, enrollments, lessonSessions, instructorBlockedDates, storeClosures, gradingScales, gradingScaleLevels, memberLessonPlans, lessonPlanSections, lessonPlanItems, lessonPlanTemplates, lessonPlanTemplateSections, lessonPlanTemplateItems, lessonPlanItemGradeHistory, lessonSessionPlanItems } from '../db/schema/lessons.js'
import { members } from '../db/schema/accounts.js'
import type {
InstructorCreateInput,
InstructorUpdateInput,
@@ -108,7 +109,9 @@ export const LessonTypeService = {
instrument: input.instrument,
durationMinutes: input.durationMinutes,
lessonFormat: input.lessonFormat,
baseRateMonthly: input.baseRateMonthly?.toString(),
rateWeekly: input.rateWeekly?.toString(),
rateMonthly: input.rateMonthly?.toString(),
rateQuarterly: input.rateQuarterly?.toString(),
})
.returning()
return lessonType
@@ -151,7 +154,9 @@ export const LessonTypeService = {
async update(db: PostgresJsDatabase<any>, id: string, input: LessonTypeUpdateInput) {
const values: Record<string, unknown> = { ...input, updatedAt: new Date() }
if (input.baseRateMonthly !== undefined) values.baseRateMonthly = input.baseRateMonthly.toString()
if (input.rateWeekly !== undefined) values.rateWeekly = input.rateWeekly?.toString() ?? null
if (input.rateMonthly !== undefined) values.rateMonthly = input.rateMonthly?.toString() ?? null
if (input.rateQuarterly !== undefined) values.rateQuarterly = input.rateQuarterly?.toString() ?? null
const [lessonType] = await db
.update(lessonTypes)
@@ -199,6 +204,9 @@ export const ScheduleSlotService = {
startTime: input.startTime,
room: input.room,
maxStudents: input.maxStudents,
rateWeekly: input.rateWeekly?.toString(),
rateMonthly: input.rateMonthly?.toString(),
rateQuarterly: input.rateQuarterly?.toString(),
})
.returning()
return slot
@@ -346,7 +354,9 @@ export const EnrollmentService = {
status: 'active',
startDate: input.startDate,
endDate: input.endDate,
monthlyRate: input.monthlyRate?.toString(),
rate: input.rate?.toString(),
billingInterval: input.billingInterval,
billingUnit: input.billingUnit,
notes: input.notes,
})
.returning()
@@ -374,7 +384,6 @@ export const EnrollmentService = {
if (filters?.accountId) conditions.push(eq(enrollments.accountId, filters.accountId))
if (filters?.instructorId) conditions.push(eq(enrollments.instructorId, filters.instructorId))
if (filters?.status?.length) {
const { inArray } = await import('drizzle-orm')
conditions.push(inArray(enrollments.status, filters.status as any))
}
@@ -384,23 +393,65 @@ export const EnrollmentService = {
start_date: enrollments.startDate,
status: enrollments.status,
created_at: enrollments.createdAt,
member_name: members.firstName,
}
let query = db.select().from(enrollments).where(where).$dynamic()
let query = db
.select({
id: enrollments.id,
memberId: enrollments.memberId,
accountId: enrollments.accountId,
scheduleSlotId: enrollments.scheduleSlotId,
instructorId: enrollments.instructorId,
status: enrollments.status,
startDate: enrollments.startDate,
endDate: enrollments.endDate,
rate: enrollments.rate,
billingInterval: enrollments.billingInterval,
billingUnit: enrollments.billingUnit,
makeupCredits: enrollments.makeupCredits,
notes: enrollments.notes,
createdAt: enrollments.createdAt,
updatedAt: enrollments.updatedAt,
memberName: members.firstName,
memberLastName: members.lastName,
instructorName: instructors.displayName,
slotDayOfWeek: scheduleSlots.dayOfWeek,
slotStartTime: scheduleSlots.startTime,
lessonTypeName: lessonTypes.name,
})
.from(enrollments)
.leftJoin(members, eq(enrollments.memberId, members.id))
.leftJoin(instructors, eq(enrollments.instructorId, instructors.id))
.leftJoin(scheduleSlots, eq(enrollments.scheduleSlotId, scheduleSlots.id))
.leftJoin(lessonTypes, eq(scheduleSlots.lessonTypeId, lessonTypes.id))
.where(where)
.$dynamic()
query = withSort(query, params.sort, params.order, sortableColumns, enrollments.createdAt)
query = withPagination(query, params.page, params.limit)
const [data, [{ total }]] = await Promise.all([
const [rows, [{ total }]] = await Promise.all([
query,
db.select({ total: count() }).from(enrollments).where(where),
])
const DAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
const data = rows.map((r) => ({
...r,
memberName: r.memberName && r.memberLastName ? `${r.memberName} ${r.memberLastName}` : undefined,
slotInfo: r.slotDayOfWeek != null && r.slotStartTime
? `${DAYS[r.slotDayOfWeek]} ${r.slotStartTime.slice(0, 5)}`
: undefined,
}))
return paginatedResponse(data, total, params.page, params.limit)
},
async update(db: PostgresJsDatabase<any>, id: string, input: EnrollmentUpdateInput) {
const values: Record<string, unknown> = { ...input, updatedAt: new Date() }
if (input.monthlyRate !== undefined) values.monthlyRate = input.monthlyRate.toString()
if (input.rate !== undefined) values.rate = input.rate?.toString() ?? null
if (input.billingInterval !== undefined) values.billingInterval = input.billingInterval
if (input.billingUnit !== undefined) values.billingUnit = input.billingUnit
const [enrollment] = await db
.update(enrollments)
@@ -542,29 +593,11 @@ export const LessonSessionService = {
}) {
const conditions: SQL[] = []
if (filters?.enrollmentId) {
conditions.push(eq(lessonSessions.enrollmentId, filters.enrollmentId))
}
if (filters?.instructorId) {
// Join through enrollment to filter by instructor
const enrollmentIds = await db
.select({ id: enrollments.id })
.from(enrollments)
.where(eq(enrollments.instructorId, filters.instructorId))
if (enrollmentIds.length === 0) {
return paginatedResponse([], 0, params.page, params.limit)
}
conditions.push(inArray(lessonSessions.enrollmentId, enrollmentIds.map((e) => e.id)))
}
if (filters?.status?.length) {
conditions.push(inArray(lessonSessions.status, filters.status as any))
}
if (filters?.dateFrom) {
conditions.push(gte(lessonSessions.scheduledDate, filters.dateFrom))
}
if (filters?.dateTo) {
conditions.push(lte(lessonSessions.scheduledDate, filters.dateTo))
}
if (filters?.enrollmentId) conditions.push(eq(lessonSessions.enrollmentId, filters.enrollmentId))
if (filters?.instructorId) conditions.push(eq(enrollments.instructorId, filters.instructorId))
if (filters?.status?.length) conditions.push(inArray(lessonSessions.status, filters.status as any))
if (filters?.dateFrom) conditions.push(gte(lessonSessions.scheduledDate, filters.dateFrom))
if (filters?.dateTo) conditions.push(lte(lessonSessions.scheduledDate, filters.dateTo))
const where = conditions.length > 0 ? and(...conditions) : undefined
@@ -575,15 +608,54 @@ export const LessonSessionService = {
created_at: lessonSessions.createdAt,
}
let query = db.select().from(lessonSessions).where(where).$dynamic()
let query = db
.select({
id: lessonSessions.id,
enrollmentId: lessonSessions.enrollmentId,
scheduledDate: lessonSessions.scheduledDate,
scheduledTime: lessonSessions.scheduledTime,
actualStartTime: lessonSessions.actualStartTime,
actualEndTime: lessonSessions.actualEndTime,
status: lessonSessions.status,
instructorNotes: lessonSessions.instructorNotes,
memberNotes: lessonSessions.memberNotes,
homeworkAssigned: lessonSessions.homeworkAssigned,
nextLessonGoals: lessonSessions.nextLessonGoals,
topicsCovered: lessonSessions.topicsCovered,
makeupForSessionId: lessonSessions.makeupForSessionId,
substituteInstructorId: lessonSessions.substituteInstructorId,
notesCompletedAt: lessonSessions.notesCompletedAt,
createdAt: lessonSessions.createdAt,
updatedAt: lessonSessions.updatedAt,
memberName: members.firstName,
memberLastName: members.lastName,
instructorName: instructors.displayName,
lessonTypeName: lessonTypes.name,
})
.from(lessonSessions)
.leftJoin(enrollments, eq(lessonSessions.enrollmentId, enrollments.id))
.leftJoin(members, eq(enrollments.memberId, members.id))
.leftJoin(instructors, eq(enrollments.instructorId, instructors.id))
.leftJoin(scheduleSlots, eq(enrollments.scheduleSlotId, scheduleSlots.id))
.leftJoin(lessonTypes, eq(scheduleSlots.lessonTypeId, lessonTypes.id))
.where(where)
.$dynamic()
query = withSort(query, params.sort, params.order, sortableColumns, lessonSessions.scheduledDate)
query = withPagination(query, params.page, params.limit)
const [data, [{ total }]] = await Promise.all([
const [rows, [{ total }]] = await Promise.all([
query,
db.select({ total: count() }).from(lessonSessions).where(where),
db.select({ total: count() })
.from(lessonSessions)
.leftJoin(enrollments, eq(lessonSessions.enrollmentId, enrollments.id))
.where(where),
])
const data = rows.map((r) => ({
...r,
memberName: r.memberName && r.memberLastName ? `${r.memberName} ${r.memberLastName}` : undefined,
}))
return paginatedResponse(data, total, params.page, params.limit)
},