diff --git a/src/components/reports.tsx b/src/components/reports.tsx new file mode 100644 index 0000000..b0e1a08 --- /dev/null +++ b/src/components/reports.tsx @@ -0,0 +1,460 @@ +import { useQuery } from "@tanstack/react-query"; +import { supabase } from "@/integrations/supabase/client"; +import { useEffectiveConfig } from "@/components/gradebook"; +import { studentPercent, letterFor, fmtPct, type Assn, type Grd } from "@/lib/grades"; +import { SCHOOL_NAME, summarizeAttendance } from "@/lib/reports"; +import { GOAL_STATUS_LABEL, PLAN_TYPE_LABEL, formatDate } from "@/lib/plans"; +import { Loader2 } from "lucide-react"; + +// Documents avoid filled backgrounds and rely on rules instead — grey fills eat +// toner and read muddy on paper. +export function DocHeader({ title, subtitle }: { title: string; subtitle?: string }) { + return ( +
+
+ +
+
{SCHOOL_NAME}
+
{subtitle}
+
+
+
+
{title}
+
+ Printed {new Date().toLocaleDateString()} +
+
+
+ ); +} + +function InfoGrid({ rows }: { rows: [string, string][] }) { + return ( +
+ {rows.map(([k, v]) => ( +
+ {k} + {v} +
+ ))} +
+ ); +} + +export function SignatureLines({ labels }: { labels: string[] }) { + return ( +
+ {labels.map((l) => ( +
+
+
{l}
+
+ ))} +
+ ); +} + +function Loading() { + return ( +
+ Loading… +
+ ); +} + +// ── Report card / academic progress report ────────────────────────────────── +export function ReportCardDoc({ + studentId, + variant, + from, + to, +}: { + studentId: string; + variant: "report_card" | "progress"; + from: string; + to: string; +}) { + const { data: student } = useQuery({ + queryKey: ["report-student", studentId], + queryFn: async () => + ( + await supabase + .from("students") + .select("id, first_name, last_name, grade_level, class_id, classes(name)") + .eq("id", studentId) + .single() + ).data, + }); + + const classId = (student?.class_id as string | null) ?? ""; + const cfg = useEffectiveConfig(classId); + + const { data: assignments } = useQuery({ + queryKey: ["report-assignments", classId], + enabled: !!classId, + queryFn: async () => + ( + await supabase + .from("assignments") + .select("*") + .eq("class_id", classId) + .order("date", { ascending: true, nullsFirst: true }) + ).data ?? [], + }); + + // Undated assignments are kept: they belong to the class's body of work and + // excluding them would silently distort the overall percentage. + const inRange = (assignments ?? []).filter((a) => !a.date || (a.date >= from && a.date <= to)); + const aIds = inRange.map((a) => a.id); + + const { data: grades } = useQuery({ + queryKey: ["report-grades", studentId, aIds.length, from, to], + enabled: aIds.length > 0, + queryFn: async () => + ( + await supabase + .from("grades") + .select("assignment_id, student_id, points") + .eq("student_id", studentId) + .in("assignment_id", aIds) + ).data ?? [], + }); + + const { data: attendance } = useQuery({ + queryKey: ["report-attendance", studentId, from, to], + queryFn: async () => + ( + await supabase + .from("attendance") + .select("status, date") + .eq("student_id", studentId) + .gte("date", from) + .lte("date", to) + ).data ?? [], + }); + + if (!student) return ; + + const gr = (grades ?? []) as Grd[]; + const overall = studentPercent(studentId, inRange as Assn[], gr, cfg.categories); + const att = summarizeAttendance(attendance ?? [], from, to); + const title = variant === "report_card" ? "Report Card" : "Progress Report"; + + return ( +
+ + + + + {!classId ? ( +

+ This student isn't assigned to a class, so no grades can be reported. +

+ ) : ( +
+

+ Academic performance +

+ + + + + + + + + + + {cfg.categories.map((cat) => { + const items = inRange.filter((a) => a.category_id === cat.id); + let earned = 0; + let possible = 0; + for (const a of items) { + const g = gr.find((x) => x.assignment_id === a.id && x.points != null); + if (!g) continue; + earned += Number(g.points); + possible += Number(a.max_points); + } + const pct = possible === 0 ? null : (earned / possible) * 100; + return ( + + + + + + + ); + })} + + + + + + +
CategoryPointsPercentWeight
{cat.name} + {possible === 0 ? "—" : `${earned} / ${possible}`} + {fmtPct(pct)}{Number(cat.weight)}%
Overall + {fmtPct(overall)}{letterFor(overall, cfg.scale)}
+ + {/* A mid-term progress report is only actionable with the underlying + work listed, so the detail is included for that variant only. */} + {variant === "progress" && inRange.length > 0 && ( +
+

+ Assignment detail +

+ + + {cfg.categories.flatMap((cat) => { + const items = inRange.filter((a) => a.category_id === cat.id); + if (!items.length) return []; + return [ + + + , + ...items.map((a) => { + const g = gr.find((x) => x.assignment_id === a.id); + return ( + + + + + + ); + }), + ]; + })} + +
+ {cat.name} +
{a.name} + {a.date ? formatDate(a.date) : "—"} + + {g?.points != null + ? `${Number(g.points)} / ${Number(a.max_points)}` + : "—"} +
+
+ )} +
+ )} + +
+

Attendance

+
+ {[ + ["Present", att.present], + ["Late", att.late], + ["Absent", att.absent], + ["Excused", att.excused], + ["Days recorded", att.recorded], + ].map(([label, n]) => ( +
+
{n as number}
+
{label as string}
+
+ ))} +
+ {att.attendedPct !== null && ( +

+ Attended {att.attendedPct.toFixed(1)}% of recorded days. +

+ )} +
+ +
+

Comments

+
+
+ + +
+ ); +} + +// ── 504 / IEP goal progress report ────────────────────────────────────────── +export function IepProgressDoc({ + studentId, + from, + to, +}: { + studentId: string; + from: string; + to: string; +}) { + const { data: student } = useQuery({ + queryKey: ["report-student", studentId], + queryFn: async () => + ( + await supabase + .from("students") + .select("id, first_name, last_name, grade_level, class_id, classes(name)") + .eq("id", studentId) + .single() + ).data, + }); + + const { data: plans } = useQuery({ + queryKey: ["report-plans", studentId], + queryFn: async () => + ( + await supabase + .from("student_plans") + .select("*") + .eq("student_id", studentId) + .neq("status", "archived") + ).data ?? [], + }); + + const planIds = (plans ?? []).map((p) => p.id); + + const { data: goals } = useQuery({ + queryKey: ["report-goals", planIds.join(",")], + enabled: planIds.length > 0, + queryFn: async () => + (await supabase.from("plan_goals").select("*").in("plan_id", planIds).order("sort_order")) + .data ?? [], + }); + + const goalIds = (goals ?? []).map((g) => g.id); + + const { data: progress } = useQuery({ + queryKey: ["report-progress", goalIds.join(","), from, to], + enabled: goalIds.length > 0, + queryFn: async () => + ( + await supabase + .from("plan_goal_progress") + .select("*") + .in("goal_id", goalIds) + .gte("date", from) + .lte("date", to) + .order("date") + ).data ?? [], + }); + + if (!student) return ; + + if ((plans ?? []).length === 0) { + return ( +
+ +

+ No active 504 or IEP plan is on file for {student.first_name} {student.last_name} — or the + plan is restricted to its case manager, administrators, and the student's parents. +

+
+ ); + } + + return ( +
+ + + + + {(plans ?? []).map((plan) => { + const planGoals = (goals ?? []).filter((g) => g.plan_id === plan.id); + return ( +
+
+ {PLAN_TYPE_LABEL[plan.plan_type]} · effective {formatDate(plan.effective_date)} +
+ + {planGoals.length === 0 && ( +

No goals recorded on this plan.

+ )} + + {planGoals.map((goal, i) => { + const entries = (progress ?? []).filter((p) => p.goal_id === goal.id); + const latest = entries[entries.length - 1]; + return ( +
+
+
+ Goal {i + 1} + {goal.area ? ` — ${goal.area}` : ""} +
+
{GOAL_STATUS_LABEL[goal.status]}
+
+

{goal.statement}

+
+
Baseline: {goal.baseline || "—"}
+
+ Target:{" "} + {goal.target_value != null ? `${goal.target_value}${goal.unit ?? ""}` : "—"} + {goal.target_date ? ` by ${formatDate(goal.target_date)}` : ""} +
+
+ Most recent:{" "} + {latest?.value != null + ? `${latest.value}${goal.unit ?? ""}` + : latest + ? "recorded" + : "—"} +
+
+ {goal.target_criteria && ( +
+ Mastery criteria: {goal.target_criteria} +
+ )} + +
+
+ Progress this period +
+ {entries.length === 0 ? ( +

+ No progress data recorded between {formatDate(from)} and {formatDate(to)}. +

+ ) : ( + + + {entries.map((e) => ( + + + + + + ))} + +
{formatDate(e.date)} + {e.value != null ? `${e.value}${goal.unit ?? ""}` : "—"} + {e.narrative || ""}
+ )} +
+
+ ); + })} +
+ ); + })} + + +
+ ); +} diff --git a/src/lib/reports.ts b/src/lib/reports.ts new file mode 100644 index 0000000..d55cffe --- /dev/null +++ b/src/lib/reports.ts @@ -0,0 +1,66 @@ +// Shared helpers for the printable documents: report cards, progress reports, +// and tuition invoices. +import { todayISO } from "@/lib/plans"; + +export const SCHOOL_NAME = "Bayside Academy"; + +export const money = (cents: number) => + `${cents < 0 ? "-" : ""}$${(Math.abs(cents) / 100).toFixed(2)}`; + +export type AttendanceRow = { status: string; date: string }; + +export type AttendanceCounts = { + present: number; + late: number; + absent: number; + excused: number; + recorded: number; + /** Share of recorded days the student actually attended (present or late). */ + attendedPct: number | null; +}; + +export function summarizeAttendance( + rows: AttendanceRow[], + from: string, + to: string, +): AttendanceCounts { + const inRange = rows.filter((r) => r.date >= from && r.date <= to); + const count = (s: string) => inRange.filter((r) => r.status === s).length; + const present = count("present"); + const late = count("late"); + const absent = count("absent"); + const excused = count("excused"); + const recorded = inRange.length; + return { + present, + late, + absent, + excused, + recorded, + attendedPct: recorded === 0 ? null : ((present + late) / recorded) * 100, + }; +} + +// School year runs Aug 1 → Jul 31, so a report pulled in e.g. February still +// covers the year that began the previous August. +export function schoolYearRange(today = new Date()): { from: string; to: string } { + const startYear = today.getMonth() >= 7 ? today.getFullYear() : today.getFullYear() - 1; + return { from: `${startYear}-08-01`, to: `${startYear + 1}-07-31` }; +} + +export function monthToDateRange(today = new Date()): { from: string; to: string } { + const y = today.getFullYear(); + const m = String(today.getMonth() + 1).padStart(2, "0"); + return { from: `${y}-${m}-01`, to: todayISO() }; +} + +export const REPORT_TYPES = [ + { value: "report_card", label: "Report card" }, + { value: "progress", label: "Academic progress report" }, + { value: "iep", label: "504 / IEP progress report" }, +] as const; + +export type ReportType = (typeof REPORT_TYPES)[number]["value"]; + +export const reportTitle = (t: ReportType) => + REPORT_TYPES.find((r) => r.value === t)?.label ?? "Report"; diff --git a/src/routeTree.gen.ts b/src/routeTree.gen.ts index 37062e6..9efd0e9 100644 --- a/src/routeTree.gen.ts +++ b/src/routeTree.gen.ts @@ -14,6 +14,7 @@ import { Route as AuthenticatedRouteRouteImport } from './routes/_authenticated/ import { Route as IndexRouteImport } from './routes/index' import { Route as IntakeTokenRouteImport } from './routes/intake.$token' import { Route as AuthenticatedStudentsRouteImport } from './routes/_authenticated/students' +import { Route as AuthenticatedReportsRouteImport } from './routes/_authenticated/reports' import { Route as AuthenticatedPlansRouteImport } from './routes/_authenticated/plans' import { Route as AuthenticatedMessagesRouteImport } from './routes/_authenticated/messages' import { Route as AuthenticatedLedgerRouteImport } from './routes/_authenticated/ledger' @@ -27,6 +28,7 @@ import { Route as AuthenticatedStudentsIndexRouteImport } from './routes/_authen import { Route as AuthenticatedClassesIndexRouteImport } from './routes/_authenticated/classes.index' import { Route as AuthenticatedStudentsNewRouteImport } from './routes/_authenticated/students.new' import { Route as AuthenticatedStudentsIdRouteImport } from './routes/_authenticated/students.$id' +import { Route as AuthenticatedInvoiceIdRouteImport } from './routes/_authenticated/invoice.$id' import { Route as AuthenticatedClassesIdRouteImport } from './routes/_authenticated/classes.$id' const AuthRoute = AuthRouteImport.update({ @@ -53,6 +55,11 @@ const AuthenticatedStudentsRoute = AuthenticatedStudentsRouteImport.update({ path: '/students', getParentRoute: () => AuthenticatedRouteRoute, } as any) +const AuthenticatedReportsRoute = AuthenticatedReportsRouteImport.update({ + id: '/reports', + path: '/reports', + getParentRoute: () => AuthenticatedRouteRoute, +} as any) const AuthenticatedPlansRoute = AuthenticatedPlansRouteImport.update({ id: '/plans', path: '/plans', @@ -121,6 +128,11 @@ const AuthenticatedStudentsIdRoute = AuthenticatedStudentsIdRouteImport.update({ path: '/$id', getParentRoute: () => AuthenticatedStudentsRoute, } as any) +const AuthenticatedInvoiceIdRoute = AuthenticatedInvoiceIdRouteImport.update({ + id: '/invoice/$id', + path: '/invoice/$id', + getParentRoute: () => AuthenticatedRouteRoute, +} as any) const AuthenticatedClassesIdRoute = AuthenticatedClassesIdRouteImport.update({ id: '/$id', path: '/$id', @@ -139,9 +151,11 @@ export interface FileRoutesByFullPath { '/ledger': typeof AuthenticatedLedgerRoute '/messages': typeof AuthenticatedMessagesRoute '/plans': typeof AuthenticatedPlansRoute + '/reports': typeof AuthenticatedReportsRoute '/students': typeof AuthenticatedStudentsRouteWithChildren '/intake/$token': typeof IntakeTokenRoute '/classes/$id': typeof AuthenticatedClassesIdRoute + '/invoice/$id': typeof AuthenticatedInvoiceIdRoute '/students/$id': typeof AuthenticatedStudentsIdRoute '/students/new': typeof AuthenticatedStudentsNewRoute '/classes/': typeof AuthenticatedClassesIndexRoute @@ -158,8 +172,10 @@ export interface FileRoutesByTo { '/ledger': typeof AuthenticatedLedgerRoute '/messages': typeof AuthenticatedMessagesRoute '/plans': typeof AuthenticatedPlansRoute + '/reports': typeof AuthenticatedReportsRoute '/intake/$token': typeof IntakeTokenRoute '/classes/$id': typeof AuthenticatedClassesIdRoute + '/invoice/$id': typeof AuthenticatedInvoiceIdRoute '/students/$id': typeof AuthenticatedStudentsIdRoute '/students/new': typeof AuthenticatedStudentsNewRoute '/classes': typeof AuthenticatedClassesIndexRoute @@ -179,9 +195,11 @@ export interface FileRoutesById { '/_authenticated/ledger': typeof AuthenticatedLedgerRoute '/_authenticated/messages': typeof AuthenticatedMessagesRoute '/_authenticated/plans': typeof AuthenticatedPlansRoute + '/_authenticated/reports': typeof AuthenticatedReportsRoute '/_authenticated/students': typeof AuthenticatedStudentsRouteWithChildren '/intake/$token': typeof IntakeTokenRoute '/_authenticated/classes/$id': typeof AuthenticatedClassesIdRoute + '/_authenticated/invoice/$id': typeof AuthenticatedInvoiceIdRoute '/_authenticated/students/$id': typeof AuthenticatedStudentsIdRoute '/_authenticated/students/new': typeof AuthenticatedStudentsNewRoute '/_authenticated/classes/': typeof AuthenticatedClassesIndexRoute @@ -201,9 +219,11 @@ export interface FileRouteTypes { | '/ledger' | '/messages' | '/plans' + | '/reports' | '/students' | '/intake/$token' | '/classes/$id' + | '/invoice/$id' | '/students/$id' | '/students/new' | '/classes/' @@ -220,8 +240,10 @@ export interface FileRouteTypes { | '/ledger' | '/messages' | '/plans' + | '/reports' | '/intake/$token' | '/classes/$id' + | '/invoice/$id' | '/students/$id' | '/students/new' | '/classes' @@ -240,9 +262,11 @@ export interface FileRouteTypes { | '/_authenticated/ledger' | '/_authenticated/messages' | '/_authenticated/plans' + | '/_authenticated/reports' | '/_authenticated/students' | '/intake/$token' | '/_authenticated/classes/$id' + | '/_authenticated/invoice/$id' | '/_authenticated/students/$id' | '/_authenticated/students/new' | '/_authenticated/classes/' @@ -293,6 +317,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AuthenticatedStudentsRouteImport parentRoute: typeof AuthenticatedRouteRoute } + '/_authenticated/reports': { + id: '/_authenticated/reports' + path: '/reports' + fullPath: '/reports' + preLoaderRoute: typeof AuthenticatedReportsRouteImport + parentRoute: typeof AuthenticatedRouteRoute + } '/_authenticated/plans': { id: '/_authenticated/plans' path: '/plans' @@ -384,6 +415,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AuthenticatedStudentsIdRouteImport parentRoute: typeof AuthenticatedStudentsRoute } + '/_authenticated/invoice/$id': { + id: '/_authenticated/invoice/$id' + path: '/invoice/$id' + fullPath: '/invoice/$id' + preLoaderRoute: typeof AuthenticatedInvoiceIdRouteImport + parentRoute: typeof AuthenticatedRouteRoute + } '/_authenticated/classes/$id': { id: '/_authenticated/classes/$id' path: '/$id' @@ -434,7 +472,9 @@ interface AuthenticatedRouteRouteChildren { AuthenticatedLedgerRoute: typeof AuthenticatedLedgerRoute AuthenticatedMessagesRoute: typeof AuthenticatedMessagesRoute AuthenticatedPlansRoute: typeof AuthenticatedPlansRoute + AuthenticatedReportsRoute: typeof AuthenticatedReportsRoute AuthenticatedStudentsRoute: typeof AuthenticatedStudentsRouteWithChildren + AuthenticatedInvoiceIdRoute: typeof AuthenticatedInvoiceIdRoute } const AuthenticatedRouteRouteChildren: AuthenticatedRouteRouteChildren = { @@ -447,7 +487,9 @@ const AuthenticatedRouteRouteChildren: AuthenticatedRouteRouteChildren = { AuthenticatedLedgerRoute: AuthenticatedLedgerRoute, AuthenticatedMessagesRoute: AuthenticatedMessagesRoute, AuthenticatedPlansRoute: AuthenticatedPlansRoute, + AuthenticatedReportsRoute: AuthenticatedReportsRoute, AuthenticatedStudentsRoute: AuthenticatedStudentsRouteWithChildren, + AuthenticatedInvoiceIdRoute: AuthenticatedInvoiceIdRoute, } const AuthenticatedRouteRouteWithChildren = diff --git a/src/routes/_authenticated/invoice.$id.tsx b/src/routes/_authenticated/invoice.$id.tsx new file mode 100644 index 0000000..ca6054e --- /dev/null +++ b/src/routes/_authenticated/invoice.$id.tsx @@ -0,0 +1,288 @@ +import { createFileRoute, Link } from "@tanstack/react-router"; +import { useQuery } from "@tanstack/react-query"; +import { supabase } from "@/integrations/supabase/client"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { ArrowLeft, Loader2, Mail, Printer } from "lucide-react"; +import { useState } from "react"; +import { DocHeader, SignatureLines } from "@/components/reports"; +import { SCHOOL_NAME, money, monthToDateRange, schoolYearRange } from "@/lib/reports"; +import { formatDate } from "@/lib/plans"; + +export const Route = createFileRoute("/_authenticated/invoice/$id")({ + head: () => ({ meta: [{ title: "Invoice — School Portal" }] }), + component: InvoicePage, +}); + +const CATEGORY_LABEL: Record = { + tuition: "Tuition", + late_pickup: "Late pickup", + activity: "Activity", + other: "Other", +}; + +function InvoicePage() { + const { id } = Route.useParams(); + const initial = monthToDateRange(); + const [from, setFrom] = useState(initial.from); + const [to, setTo] = useState(initial.to); + + const { data: student } = useQuery({ + queryKey: ["invoice-student", id], + queryFn: async () => + ( + await supabase + .from("students") + .select("id, first_name, last_name, grade_level, daily_tuition_cents, classes(name)") + .eq("id", id) + .single() + ).data, + }); + + // Every entry, so the opening balance can be derived from activity before `from`. + const { data: entries } = useQuery({ + queryKey: ["invoice-entries", id], + queryFn: async () => + ( + await supabase + .from("ledger_entries") + .select("id, date, kind, category, amount_cents, note, attendance_id") + .eq("student_id", id) + .order("date") + ).data ?? [], + }); + + // Guardians from the intake form, plus any linked parent login accounts. + const { data: recipients } = useQuery({ + queryKey: ["invoice-recipients", id], + queryFn: async () => { + const [guardians, links] = await Promise.all([ + supabase.from("student_guardians").select("full_name, email").eq("student_id", id), + supabase.from("parent_students").select("parent_id").eq("student_id", id), + ]); + const emails = new Map(); + for (const g of guardians.data ?? []) + if (g.email) emails.set(g.email.toLowerCase(), g.full_name ?? g.email); + const ids = (links.data ?? []).map((l) => l.parent_id); + if (ids.length) { + // profiles is admin-readable only; non-admins simply get fewer recipients. + const { data: profs } = await supabase + .from("profiles") + .select("full_name, email") + .in("id", ids); + for (const p of profs ?? []) + if (p.email) emails.set(p.email.toLowerCase(), p.full_name ?? p.email); + } + return [...emails.entries()].map(([email, name]) => ({ email, name })); + }, + }); + + if (!student) { + return ( +
+ Loading invoice… +
+ ); + } + + const all = entries ?? []; + const signed = (e: { kind: string; amount_cents: number }) => + e.kind === "charge" ? e.amount_cents : -e.amount_cents; + + const opening = all.filter((e) => e.date < from).reduce((n, e) => n + signed(e), 0); + const period = all.filter((e) => e.date >= from && e.date <= to); + const charges = period.filter((e) => e.kind === "charge").reduce((n, e) => n + e.amount_cents, 0); + const payments = period + .filter((e) => e.kind === "payment") + .reduce((n, e) => n + e.amount_cents, 0); + const balanceDue = opening + charges - payments; + + const name = `${student.first_name} ${student.last_name}`; + const lines = period + .map( + (e) => + `${formatDate(e.date)} ${CATEGORY_LABEL[e.category] ?? e.category}${e.note ? ` — ${e.note}` : ""} ${ + e.kind === "charge" ? money(e.amount_cents) : `(${money(e.amount_cents)})` + }`, + ) + .join("\n"); + + // mailto: is the same mechanism the intake-link share uses — it opens the + // staff member's own mail client with a prefilled draft. It cannot attach a + // file, so the itemisation goes in the body as text. + const mailto = + `mailto:${(recipients ?? []).map((r) => r.email).join(",")}` + + `?subject=${encodeURIComponent(`${SCHOOL_NAME} — invoice for ${name}`)}` + + `&body=${encodeURIComponent( + `Hello,\n\nHere is the tuition statement for ${name} covering ${formatDate(from)} to ${formatDate(to)}.\n\n` + + `Balance carried forward: ${money(opening)}\n` + + `${lines ? `${lines}\n\n` : "\n"}` + + `Charges this period: ${money(charges)}\n` + + `Payments this period: ${money(payments)}\n` + + `Balance due: ${money(balanceDue)}\n\n` + + `Please reply to this email with any questions.\n\nThank you,\n${SCHOOL_NAME}`, + )}`; + + return ( +
+
+ + Tuition ledger + +
+
+ + setFrom(e.target.value)} + /> +
+
+ + setTo(e.target.value)} + /> +
+ +
+ + {(recipients ?? []).length > 0 ? ( + + + + ) : ( + + )} +
+ {(recipients ?? []).length > 0 && ( +

+ Will draft to: {(recipients ?? []).map((r) => r.email).join(", ")} +

+ )} +
+ +
+ + +
+ {[ + ["Student", name], + ["Grade level", student.grade_level || "—"], + ["Class", (student.classes as { name: string } | null)?.name ?? "—"], + [ + "Daily rate", + student.daily_tuition_cents ? money(student.daily_tuition_cents) : "Not set", + ], + ].map(([k, v]) => ( +
+ {k} + {v} +
+ ))} +
+ +
+

Account activity

+ + + + + + + + + + + + + + + + + {period.map((e) => ( + + + + + + + ))} + {period.length === 0 && ( + + + + )} + + + + + + + + + + + + + + + +
DateDescriptionChargePayment
{formatDate(from)}Balance carried forward{opening !== 0 ? money(opening) : "—"}—
{formatDate(e.date)} + {CATEGORY_LABEL[e.category] ?? e.category} + {e.note ? — {e.note} : null} + + {e.kind === "charge" ? money(e.amount_cents) : "—"} + + {e.kind === "payment" ? money(e.amount_cents) : "—"} +
+ No activity in this period. +
+ Charges this period + {money(charges)} +
+ Payments this period + + {money(payments)}
+ Balance due + + {money(balanceDue)} +
+ {balanceDue < 0 && ( +

+ A negative balance is a credit on the account. +

+ )} +
+ + +
+
+ ); +} diff --git a/src/routes/_authenticated/ledger.tsx b/src/routes/_authenticated/ledger.tsx index 3b17c42..5e74b6a 100644 --- a/src/routes/_authenticated/ledger.tsx +++ b/src/routes/_authenticated/ledger.tsx @@ -1,7 +1,9 @@ import { createFileRoute, Link } from "@tanstack/react-router"; import { useQuery } from "@tanstack/react-query"; import { supabase } from "@/integrations/supabase/client"; -import { ChevronRight } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { ChevronRight, FileText } from "lucide-react"; +import { money } from "@/lib/reports"; export const Route = createFileRoute("/_authenticated/ledger")({ head: () => ({ meta: [{ title: "Tuition — School Portal" }] }), @@ -12,11 +14,16 @@ function LedgerListPage() { const { data: students } = useQuery({ queryKey: ["students-with-balance"], queryFn: async () => { - const { data: studs } = await supabase.from("students").select("id, first_name, last_name"); - const { data: entries } = await supabase.from("ledger_entries").select("student_id, amount_cents, kind"); + const { data: studs } = await supabase + .from("students") + .select("id, first_name, last_name, daily_tuition_cents"); + const { data: entries } = await supabase + .from("ledger_entries") + .select("student_id, amount_cents, kind"); const balances: Record = {}; (entries ?? []).forEach((e) => { - balances[e.student_id] = (balances[e.student_id] ?? 0) + (e.kind === "charge" ? e.amount_cents : -e.amount_cents); + balances[e.student_id] = + (balances[e.student_id] ?? 0) + (e.kind === "charge" ? e.amount_cents : -e.amount_cents); }); return (studs ?? []).map((s) => ({ ...s, balance: balances[s.id] ?? 0 })); }, @@ -25,18 +32,47 @@ function LedgerListPage() { return (

Tuition ledger

-

Click a student to view and manage their ledger.

+

+ Click a student to view and manage their ledger, or build an invoice. Students with a daily + rate are charged automatically when marked present or late. +

{(students ?? []).map((s) => ( - - {s.last_name}, {s.first_name} - - 0 ? "text-destructive" : "text-green-700"}`}>${(s.balance / 100).toFixed(2)} - +
+ + + {s.last_name}, {s.first_name} + + {s.daily_tuition_cents ? ( + + {money(s.daily_tuition_cents)}/day + + ) : ( + no rate + )} + + 0 ? "text-destructive" : "text-green-700"}`} + > + {money(s.balance)} - + + + + + + +
))} - {students?.length === 0 &&
No students.
} + {students?.length === 0 && ( +
No students.
+ )}
); diff --git a/src/routes/_authenticated/reports.tsx b/src/routes/_authenticated/reports.tsx new file mode 100644 index 0000000..e1abfa3 --- /dev/null +++ b/src/routes/_authenticated/reports.tsx @@ -0,0 +1,191 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { useQuery } from "@tanstack/react-query"; +import { supabase } from "@/integrations/supabase/client"; +import { useAuth } from "@/hooks/use-auth"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { FileText, Printer } from "lucide-react"; +import { useState } from "react"; +import { IepProgressDoc, ReportCardDoc } from "@/components/reports"; +import { REPORT_TYPES, monthToDateRange, schoolYearRange, type ReportType } from "@/lib/reports"; + +export const Route = createFileRoute("/_authenticated/reports")({ + head: () => ({ meta: [{ title: "Reports — School Portal" }] }), + component: ReportsPage, +}); + +const ALL = "__all__"; + +function ReportsPage() { + const { user, roles, loading } = useAuth(); + const isAdmin = roles.includes("admin"); + + const [type, setType] = useState("report_card"); + const [classId, setClassId] = useState(""); + const [studentId, setStudentId] = useState(ALL); + const year = schoolYearRange(); + const [from, setFrom] = useState(year.from); + const [to, setTo] = useState(year.to); + + const { data: classes } = useQuery({ + queryKey: ["my-classes", user?.id, isAdmin], + queryFn: async () => { + let q = supabase.from("classes").select("id, name, teacher_id"); + if (!isAdmin) q = q.eq("teacher_id", user!.id); + return (await q.order("name")).data ?? []; + }, + enabled: !!user && !loading, + }); + + const { data: students } = useQuery({ + queryKey: ["report-class-students", classId], + enabled: !!classId, + queryFn: async () => + ( + await supabase + .from("students") + .select("id, first_name, last_name") + .eq("class_id", classId) + .order("last_name") + ).data ?? [], + }); + + const selected = + studentId === ALL ? (students ?? []) : (students ?? []).filter((s) => s.id === studentId); + + const applyPreset = (preset: "year" | "month") => { + const r = preset === "year" ? schoolYearRange() : monthToDateRange(); + setFrom(r.from); + setTo(r.to); + }; + + return ( +
+
+

+ Reports +

+

+ Build report cards, academic progress reports, and 504 / IEP goal progress reports. Print + or save as PDF from your browser — each student starts on a new page. +

+ +
+
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+ + {/* Uncontrolled + keyed so Safari's native picker isn't reset by React. */} + setFrom(e.target.value)} + /> +
+
+ + setTo(e.target.value)} + /> +
+
+ + +
+
+ +
+ + {classId + ? `${selected.length} document${selected.length === 1 ? "" : "s"} ready` + : "Select a class to begin."} + + +
+
+
+ +
+ {selected.map((s) => + type === "iep" ? ( + + ) : ( + + ), + )} +
+
+ ); +} diff --git a/src/routes/_authenticated/route.tsx b/src/routes/_authenticated/route.tsx index 34f9e5a..1bdfe3b 100644 --- a/src/routes/_authenticated/route.tsx +++ b/src/routes/_authenticated/route.tsx @@ -4,7 +4,7 @@ import { supabase } from "@/integrations/supabase/client"; import { Button } from "@/components/ui/button"; import { GraduationCap, LayoutDashboard, Users, ClipboardCheck, Receipt, - MessageSquare, FileText, CalendarDays, Settings, LogOut, Loader2, BookOpen, ClipboardList + MessageSquare, FileText, CalendarDays, Settings, LogOut, Loader2, BookOpen, ClipboardList, Printer } from "lucide-react"; import { useEffect } from "react"; @@ -35,6 +35,7 @@ function ProtectedLayout() { { to: "/classes", label: "Classes", icon: BookOpen, show: isAdmin || roles.includes("teacher") }, { to: "/attendance", label: "Attendance", icon: ClipboardCheck, show: isAdmin || roles.includes("teacher") }, { to: "/plans", label: "504 / IEP", icon: ClipboardList, show: isAdmin || roles.includes("teacher") }, + { to: "/reports", label: "Reports", icon: Printer, show: isAdmin || roles.includes("teacher") }, { to: "/ledger", label: "Tuition", icon: Receipt, show: true }, { to: "/messages", label: "Messages", icon: MessageSquare, show: true }, { to: "/forms", label: "Forms", icon: FileText, show: true }, @@ -49,7 +50,7 @@ function ProtectedLayout() { return (
-