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,341 @@
import { queryOptions } from '@tanstack/react-query'
import { api } from '@/lib/api-client'
import type {
Instructor,
InstructorBlockedDate,
LessonType,
ScheduleSlot,
Enrollment,
LessonSession,
GradingScale,
LessonPlan,
LessonPlanItem,
LessonPlanItemGradeHistory,
LessonPlanTemplate,
StoreClosure,
SessionPlanItem,
} from '@/types/lesson'
import type { PaginatedResponse, PaginationInput } from '@lunarfront/shared/schemas'
// --- Instructors ---
export const instructorKeys = {
all: ['instructors'] as const,
list: (params: PaginationInput) => [...instructorKeys.all, 'list', params] as const,
detail: (id: string) => [...instructorKeys.all, 'detail', id] as const,
blockedDates: (id: string) => [...instructorKeys.all, id, 'blocked-dates'] as const,
}
export function instructorListOptions(params: PaginationInput) {
return queryOptions({
queryKey: instructorKeys.list(params),
queryFn: () => api.get<PaginatedResponse<Instructor>>('/v1/instructors', params),
})
}
export function instructorDetailOptions(id: string) {
return queryOptions({
queryKey: instructorKeys.detail(id),
queryFn: () => api.get<Instructor>(`/v1/instructors/${id}`),
enabled: !!id,
})
}
export function instructorBlockedDatesOptions(instructorId: string) {
return queryOptions({
queryKey: instructorKeys.blockedDates(instructorId),
queryFn: () => api.get<InstructorBlockedDate[]>(`/v1/instructors/${instructorId}/blocked-dates`),
enabled: !!instructorId,
})
}
export const instructorMutations = {
create: (data: Record<string, unknown>) =>
api.post<Instructor>('/v1/instructors', data),
update: (id: string, data: Record<string, unknown>) =>
api.patch<Instructor>(`/v1/instructors/${id}`, data),
delete: (id: string) =>
api.del<Instructor>(`/v1/instructors/${id}`),
addBlockedDate: (instructorId: string, data: Record<string, unknown>) =>
api.post<InstructorBlockedDate>(`/v1/instructors/${instructorId}/blocked-dates`, data),
deleteBlockedDate: (instructorId: string, id: string) =>
api.del<InstructorBlockedDate>(`/v1/instructors/${instructorId}/blocked-dates/${id}`),
}
// --- Lesson Types ---
export const lessonTypeKeys = {
all: ['lesson-types'] as const,
list: (params: PaginationInput) => [...lessonTypeKeys.all, 'list', params] as const,
detail: (id: string) => [...lessonTypeKeys.all, 'detail', id] as const,
}
export function lessonTypeListOptions(params: PaginationInput) {
return queryOptions({
queryKey: lessonTypeKeys.list(params),
queryFn: () => api.get<PaginatedResponse<LessonType>>('/v1/lesson-types', params),
})
}
export const lessonTypeMutations = {
create: (data: Record<string, unknown>) =>
api.post<LessonType>('/v1/lesson-types', data),
update: (id: string, data: Record<string, unknown>) =>
api.patch<LessonType>(`/v1/lesson-types/${id}`, data),
delete: (id: string) =>
api.del<LessonType>(`/v1/lesson-types/${id}`),
}
// --- Schedule Slots ---
export const scheduleSlotKeys = {
all: ['schedule-slots'] as const,
list: (params: PaginationInput) => [...scheduleSlotKeys.all, 'list', params] as const,
byInstructor: (instructorId: string, params: PaginationInput) =>
[...scheduleSlotKeys.all, 'instructor', instructorId, params] as const,
detail: (id: string) => [...scheduleSlotKeys.all, 'detail', id] as const,
}
export function scheduleSlotListOptions(params: PaginationInput, filters?: { instructorId?: string; dayOfWeek?: number }) {
const query = { ...params, ...filters }
return queryOptions({
queryKey: filters?.instructorId
? scheduleSlotKeys.byInstructor(filters.instructorId, params)
: scheduleSlotKeys.list(params),
queryFn: () => api.get<PaginatedResponse<ScheduleSlot>>('/v1/schedule-slots', query),
})
}
export const scheduleSlotMutations = {
create: (data: Record<string, unknown>) =>
api.post<ScheduleSlot>('/v1/schedule-slots', data),
update: (id: string, data: Record<string, unknown>) =>
api.patch<ScheduleSlot>(`/v1/schedule-slots/${id}`, data),
delete: (id: string) =>
api.del<ScheduleSlot>(`/v1/schedule-slots/${id}`),
}
// --- Enrollments ---
export const enrollmentKeys = {
all: ['enrollments'] as const,
list: (params: Record<string, unknown>) => [...enrollmentKeys.all, 'list', params] as const,
detail: (id: string) => [...enrollmentKeys.all, 'detail', id] as const,
}
export function enrollmentListOptions(params: Record<string, unknown>) {
return queryOptions({
queryKey: enrollmentKeys.list(params),
queryFn: () => api.get<PaginatedResponse<Enrollment>>('/v1/enrollments', params),
})
}
export function enrollmentDetailOptions(id: string) {
return queryOptions({
queryKey: enrollmentKeys.detail(id),
queryFn: () => api.get<Enrollment>(`/v1/enrollments/${id}`),
enabled: !!id,
})
}
export const enrollmentMutations = {
create: (data: Record<string, unknown>) =>
api.post<Enrollment>('/v1/enrollments', data),
update: (id: string, data: Record<string, unknown>) =>
api.patch<Enrollment>(`/v1/enrollments/${id}`, data),
updateStatus: (id: string, status: string) =>
api.post<Enrollment>(`/v1/enrollments/${id}/status`, { status }),
generateSessions: (id: string, weeks?: number) =>
api.post<{ generated: number; sessions: LessonSession[] }>(
`/v1/enrollments/${id}/generate-sessions${weeks ? `?weeks=${weeks}` : ''}`,
{},
),
}
// --- Lesson Sessions ---
export const sessionKeys = {
all: ['lesson-sessions'] as const,
list: (params: Record<string, unknown>) => [...sessionKeys.all, 'list', params] as const,
detail: (id: string) => [...sessionKeys.all, 'detail', id] as const,
planItems: (id: string) => [...sessionKeys.all, id, 'plan-items'] as const,
}
export function sessionListOptions(params: Record<string, unknown>) {
return queryOptions({
queryKey: sessionKeys.list(params),
queryFn: () => api.get<PaginatedResponse<LessonSession>>('/v1/lesson-sessions', params),
})
}
export function sessionDetailOptions(id: string) {
return queryOptions({
queryKey: sessionKeys.detail(id),
queryFn: () => api.get<LessonSession>(`/v1/lesson-sessions/${id}`),
enabled: !!id,
})
}
export function sessionPlanItemsOptions(sessionId: string) {
return queryOptions({
queryKey: sessionKeys.planItems(sessionId),
queryFn: () => api.get<SessionPlanItem[]>(`/v1/lesson-sessions/${sessionId}/plan-items`),
enabled: !!sessionId,
})
}
export const sessionMutations = {
update: (id: string, data: Record<string, unknown>) =>
api.patch<LessonSession>(`/v1/lesson-sessions/${id}`, data),
updateStatus: (id: string, status: string) =>
api.post<LessonSession>(`/v1/lesson-sessions/${id}/status`, { status }),
updateNotes: (id: string, data: Record<string, unknown>) =>
api.post<LessonSession>(`/v1/lesson-sessions/${id}/notes`, data),
linkPlanItems: (id: string, lessonPlanItemIds: string[]) =>
api.post<{ linked: number; items: SessionPlanItem[] }>(`/v1/lesson-sessions/${id}/plan-items`, { lessonPlanItemIds }),
}
// --- Grading Scales ---
export const gradingScaleKeys = {
all: ['grading-scales'] as const,
list: (params: PaginationInput) => [...gradingScaleKeys.all, 'list', params] as const,
allScales: [...['grading-scales'], 'all'] as const,
detail: (id: string) => [...gradingScaleKeys.all, 'detail', id] as const,
}
export function gradingScaleListOptions(params: PaginationInput) {
return queryOptions({
queryKey: gradingScaleKeys.list(params),
queryFn: () => api.get<PaginatedResponse<GradingScale>>('/v1/grading-scales', params),
})
}
export function gradingScaleAllOptions() {
return queryOptions({
queryKey: gradingScaleKeys.allScales,
queryFn: () => api.get<GradingScale[]>('/v1/grading-scales/all'),
})
}
export function gradingScaleDetailOptions(id: string) {
return queryOptions({
queryKey: gradingScaleKeys.detail(id),
queryFn: () => api.get<GradingScale>(`/v1/grading-scales/${id}`),
enabled: !!id,
})
}
export const gradingScaleMutations = {
create: (data: Record<string, unknown>) =>
api.post<GradingScale>('/v1/grading-scales', data),
update: (id: string, data: Record<string, unknown>) =>
api.patch<GradingScale>(`/v1/grading-scales/${id}`, data),
delete: (id: string) =>
api.del<GradingScale>(`/v1/grading-scales/${id}`),
}
// --- Lesson Plans ---
export const lessonPlanKeys = {
all: ['lesson-plans'] as const,
list: (params: Record<string, unknown>) => [...lessonPlanKeys.all, 'list', params] as const,
detail: (id: string) => [...lessonPlanKeys.all, 'detail', id] as const,
}
export function lessonPlanListOptions(params: Record<string, unknown>) {
return queryOptions({
queryKey: lessonPlanKeys.list(params),
queryFn: () => api.get<PaginatedResponse<LessonPlan>>('/v1/lesson-plans', params),
})
}
export function lessonPlanDetailOptions(id: string) {
return queryOptions({
queryKey: lessonPlanKeys.detail(id),
queryFn: () => api.get<LessonPlan>(`/v1/lesson-plans/${id}`),
enabled: !!id,
})
}
export const lessonPlanMutations = {
create: (data: Record<string, unknown>) =>
api.post<LessonPlan>('/v1/lesson-plans', data),
update: (id: string, data: Record<string, unknown>) =>
api.patch<LessonPlan>(`/v1/lesson-plans/${id}`, data),
}
// --- Lesson Plan Items ---
export const lessonPlanItemKeys = {
gradeHistory: (itemId: string) => ['lesson-plan-items', itemId, 'grade-history'] as const,
}
export function lessonPlanItemGradeHistoryOptions(itemId: string) {
return queryOptions({
queryKey: lessonPlanItemKeys.gradeHistory(itemId),
queryFn: () => api.get<LessonPlanItemGradeHistory[]>(`/v1/lesson-plan-items/${itemId}/grade-history`),
enabled: !!itemId,
})
}
export const lessonPlanItemMutations = {
update: (id: string, data: Record<string, unknown>) =>
api.patch<LessonPlanItem>(`/v1/lesson-plan-items/${id}`, data),
addGrade: (id: string, data: Record<string, unknown>) =>
api.post<{ record: LessonPlanItemGradeHistory; item: LessonPlanItem }>(`/v1/lesson-plan-items/${id}/grades`, data),
}
// --- Lesson Plan Templates ---
export const lessonPlanTemplateKeys = {
all: ['lesson-plan-templates'] as const,
list: (params: PaginationInput) => [...lessonPlanTemplateKeys.all, 'list', params] as const,
detail: (id: string) => [...lessonPlanTemplateKeys.all, 'detail', id] as const,
}
export function lessonPlanTemplateListOptions(params: PaginationInput) {
return queryOptions({
queryKey: lessonPlanTemplateKeys.list(params),
queryFn: () => api.get<PaginatedResponse<LessonPlanTemplate>>('/v1/lesson-plan-templates', params),
})
}
export function lessonPlanTemplateDetailOptions(id: string) {
return queryOptions({
queryKey: lessonPlanTemplateKeys.detail(id),
queryFn: () => api.get<LessonPlanTemplate>(`/v1/lesson-plan-templates/${id}`),
enabled: !!id,
})
}
export const lessonPlanTemplateMutations = {
create: (data: Record<string, unknown>) =>
api.post<LessonPlanTemplate>('/v1/lesson-plan-templates', data),
update: (id: string, data: Record<string, unknown>) =>
api.patch<LessonPlanTemplate>(`/v1/lesson-plan-templates/${id}`, data),
delete: (id: string) =>
api.del<LessonPlanTemplate>(`/v1/lesson-plan-templates/${id}`),
createPlan: (templateId: string, data: Record<string, unknown>) =>
api.post<LessonPlan>(`/v1/lesson-plan-templates/${templateId}/create-plan`, data),
}
// --- Store Closures ---
export const storeClosureKeys = {
all: ['store-closures'] as const,
}
export function storeClosureListOptions() {
return queryOptions({
queryKey: storeClosureKeys.all,
queryFn: () => api.get<StoreClosure[]>('/v1/store-closures'),
})
}
export const storeClosureMutations = {
create: (data: Record<string, unknown>) =>
api.post<StoreClosure>('/v1/store-closures', data),
delete: (id: string) =>
api.del<StoreClosure>(`/v1/store-closures/${id}`),
}