Add printable report cards, progress reports and invoices
Three documents, all print-styled rather than generated server-side: no PDF dependency is added, and "Save as PDF" in the browser produces the file. @media print in styles.css strips the app chrome (.no-print) and breaks each student onto its own sheet (.print-page). - Report card: weighted category breakdown, overall percent and letter from the existing gradebook config, plus an attendance summary. - Academic progress report: the same, plus assignment-level detail — a mid-term report is only actionable with the underlying work listed. - 504 / IEP progress report: each goal's baseline, target, status and progress entries within the period, for the quarterly report IDEA requires. RLS keeps it to the case manager, admins and parents. - Invoice, reachable per student from the tuition ledger: opening balance carried forward, itemised activity, and balance due. Grade maths is reused from lib/grades and the class grading config from useEffectiveConfig, so report cards cannot drift from the gradebook. The "Email to parents" button opens a prefilled mailto: draft — the same mechanism the intake-link share already uses. It gathers addresses from intake guardians and linked parent accounts. mailto cannot attach a file, so the itemisation goes in the body as text; sending a real attachment would need a transactional email provider and an API key. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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 (
|
||||||
|
<div className="flex items-start justify-between border-b-2 border-foreground pb-3">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<img src="/bayside-logo.png" alt="" className="h-10 w-auto" />
|
||||||
|
<div>
|
||||||
|
<div className="font-semibold leading-tight">{SCHOOL_NAME}</div>
|
||||||
|
<div className="text-xs text-muted-foreground">{subtitle}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<div className="text-lg font-semibold leading-tight">{title}</div>
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
Printed {new Date().toLocaleDateString()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function InfoGrid({ rows }: { rows: [string, string][] }) {
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-2 gap-x-8 gap-y-1 text-sm">
|
||||||
|
{rows.map(([k, v]) => (
|
||||||
|
<div key={k} className="flex justify-between border-b py-1">
|
||||||
|
<span className="text-muted-foreground">{k}</span>
|
||||||
|
<span className="font-medium text-right">{v}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SignatureLines({ labels }: { labels: string[] }) {
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-2 gap-8 pt-8">
|
||||||
|
{labels.map((l) => (
|
||||||
|
<div key={l}>
|
||||||
|
<div className="border-b border-foreground h-8" />
|
||||||
|
<div className="text-xs text-muted-foreground mt-1">{l}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Loading() {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2 text-sm text-muted-foreground py-6">
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" /> Loading…
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 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 <Loading />;
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div className="print-page print-doc bg-card border rounded-lg p-6 space-y-5">
|
||||||
|
<DocHeader title={title} subtitle={`${formatDate(from)} – ${formatDate(to)}`} />
|
||||||
|
|
||||||
|
<InfoGrid
|
||||||
|
rows={[
|
||||||
|
["Student", `${student.first_name} ${student.last_name}`],
|
||||||
|
["Grade level", student.grade_level || "—"],
|
||||||
|
["Class", (student.classes as { name: string } | null)?.name ?? "—"],
|
||||||
|
["Reporting period", `${formatDate(from)} – ${formatDate(to)}`],
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{!classId ? (
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
This student isn't assigned to a class, so no grades can be reported.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-semibold uppercase tracking-wide mb-2">
|
||||||
|
Academic performance
|
||||||
|
</h3>
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b text-left">
|
||||||
|
<th className="py-1 font-medium">Category</th>
|
||||||
|
<th className="py-1 font-medium text-right">Points</th>
|
||||||
|
<th className="py-1 font-medium text-right">Percent</th>
|
||||||
|
<th className="py-1 font-medium text-right">Weight</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{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 (
|
||||||
|
<tr key={cat.id} className="border-b">
|
||||||
|
<td className="py-1">{cat.name}</td>
|
||||||
|
<td className="py-1 text-right">
|
||||||
|
{possible === 0 ? "—" : `${earned} / ${possible}`}
|
||||||
|
</td>
|
||||||
|
<td className="py-1 text-right">{fmtPct(pct)}</td>
|
||||||
|
<td className="py-1 text-right text-muted-foreground">{Number(cat.weight)}%</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<tr className="font-semibold">
|
||||||
|
<td className="py-2">Overall</td>
|
||||||
|
<td />
|
||||||
|
<td className="py-2 text-right">{fmtPct(overall)}</td>
|
||||||
|
<td className="py-2 text-right">{letterFor(overall, cfg.scale)}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
{/* 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 && (
|
||||||
|
<div className="mt-4">
|
||||||
|
<h3 className="text-sm font-semibold uppercase tracking-wide mb-2">
|
||||||
|
Assignment detail
|
||||||
|
</h3>
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<tbody>
|
||||||
|
{cfg.categories.flatMap((cat) => {
|
||||||
|
const items = inRange.filter((a) => a.category_id === cat.id);
|
||||||
|
if (!items.length) return [];
|
||||||
|
return [
|
||||||
|
<tr key={cat.id}>
|
||||||
|
<td
|
||||||
|
colSpan={3}
|
||||||
|
className="pt-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground"
|
||||||
|
>
|
||||||
|
{cat.name}
|
||||||
|
</td>
|
||||||
|
</tr>,
|
||||||
|
...items.map((a) => {
|
||||||
|
const g = gr.find((x) => x.assignment_id === a.id);
|
||||||
|
return (
|
||||||
|
<tr key={a.id} className="border-b">
|
||||||
|
<td className="py-1">{a.name}</td>
|
||||||
|
<td className="py-1 text-right text-muted-foreground">
|
||||||
|
{a.date ? formatDate(a.date) : "—"}
|
||||||
|
</td>
|
||||||
|
<td className="py-1 text-right">
|
||||||
|
{g?.points != null
|
||||||
|
? `${Number(g.points)} / ${Number(a.max_points)}`
|
||||||
|
: "—"}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-semibold uppercase tracking-wide mb-2">Attendance</h3>
|
||||||
|
<div className="grid grid-cols-5 gap-2 text-sm text-center">
|
||||||
|
{[
|
||||||
|
["Present", att.present],
|
||||||
|
["Late", att.late],
|
||||||
|
["Absent", att.absent],
|
||||||
|
["Excused", att.excused],
|
||||||
|
["Days recorded", att.recorded],
|
||||||
|
].map(([label, n]) => (
|
||||||
|
<div key={label as string} className="border rounded-md py-2">
|
||||||
|
<div className="text-lg font-semibold">{n as number}</div>
|
||||||
|
<div className="text-xs text-muted-foreground">{label as string}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{att.attendedPct !== null && (
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">
|
||||||
|
Attended {att.attendedPct.toFixed(1)}% of recorded days.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-semibold uppercase tracking-wide mb-2">Comments</h3>
|
||||||
|
<div className="border rounded-md h-20" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<SignatureLines labels={["Teacher signature", "Parent / guardian signature"]} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 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 <Loading />;
|
||||||
|
|
||||||
|
if ((plans ?? []).length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="print-page print-doc bg-card border rounded-lg p-6 space-y-4">
|
||||||
|
<DocHeader
|
||||||
|
title="504 / IEP Progress Report"
|
||||||
|
subtitle={`${formatDate(from)} – ${formatDate(to)}`}
|
||||||
|
/>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
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.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="print-page print-doc bg-card border rounded-lg p-6 space-y-5">
|
||||||
|
<DocHeader
|
||||||
|
title="504 / IEP Progress Report"
|
||||||
|
subtitle={`${formatDate(from)} – ${formatDate(to)}`}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<InfoGrid
|
||||||
|
rows={[
|
||||||
|
["Student", `${student.first_name} ${student.last_name}`],
|
||||||
|
["Grade level", student.grade_level || "—"],
|
||||||
|
["Class", (student.classes as { name: string } | null)?.name ?? "—"],
|
||||||
|
["Reporting period", `${formatDate(from)} – ${formatDate(to)}`],
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{(plans ?? []).map((plan) => {
|
||||||
|
const planGoals = (goals ?? []).filter((g) => g.plan_id === plan.id);
|
||||||
|
return (
|
||||||
|
<div key={plan.id} className="space-y-3">
|
||||||
|
<div className="text-sm font-semibold uppercase tracking-wide border-b pb-1">
|
||||||
|
{PLAN_TYPE_LABEL[plan.plan_type]} · effective {formatDate(plan.effective_date)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{planGoals.length === 0 && (
|
||||||
|
<p className="text-sm text-muted-foreground">No goals recorded on this plan.</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{planGoals.map((goal, i) => {
|
||||||
|
const entries = (progress ?? []).filter((p) => p.goal_id === goal.id);
|
||||||
|
const latest = entries[entries.length - 1];
|
||||||
|
return (
|
||||||
|
<div key={goal.id} className="border rounded-md p-3 space-y-2">
|
||||||
|
<div className="flex justify-between gap-3">
|
||||||
|
<div className="font-medium text-sm">
|
||||||
|
Goal {i + 1}
|
||||||
|
{goal.area ? ` — ${goal.area}` : ""}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs">{GOAL_STATUS_LABEL[goal.status]}</div>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm">{goal.statement}</p>
|
||||||
|
<div className="grid grid-cols-3 gap-3 text-xs text-muted-foreground">
|
||||||
|
<div>Baseline: {goal.baseline || "—"}</div>
|
||||||
|
<div>
|
||||||
|
Target:{" "}
|
||||||
|
{goal.target_value != null ? `${goal.target_value}${goal.unit ?? ""}` : "—"}
|
||||||
|
{goal.target_date ? ` by ${formatDate(goal.target_date)}` : ""}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
Most recent:{" "}
|
||||||
|
{latest?.value != null
|
||||||
|
? `${latest.value}${goal.unit ?? ""}`
|
||||||
|
: latest
|
||||||
|
? "recorded"
|
||||||
|
: "—"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{goal.target_criteria && (
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
Mastery criteria: {goal.target_criteria}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div className="text-xs font-semibold uppercase tracking-wide mb-1">
|
||||||
|
Progress this period
|
||||||
|
</div>
|
||||||
|
{entries.length === 0 ? (
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
No progress data recorded between {formatDate(from)} and {formatDate(to)}.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<table className="w-full text-xs">
|
||||||
|
<tbody>
|
||||||
|
{entries.map((e) => (
|
||||||
|
<tr key={e.id} className="border-b">
|
||||||
|
<td className="py-1 w-24">{formatDate(e.date)}</td>
|
||||||
|
<td className="py-1 w-20">
|
||||||
|
{e.value != null ? `${e.value}${goal.unit ?? ""}` : "—"}
|
||||||
|
</td>
|
||||||
|
<td className="py-1">{e.narrative || ""}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
<SignatureLines labels={["Case manager signature", "Parent / guardian signature"]} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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";
|
||||||
@@ -14,6 +14,7 @@ import { Route as AuthenticatedRouteRouteImport } from './routes/_authenticated/
|
|||||||
import { Route as IndexRouteImport } from './routes/index'
|
import { Route as IndexRouteImport } from './routes/index'
|
||||||
import { Route as IntakeTokenRouteImport } from './routes/intake.$token'
|
import { Route as IntakeTokenRouteImport } from './routes/intake.$token'
|
||||||
import { Route as AuthenticatedStudentsRouteImport } from './routes/_authenticated/students'
|
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 AuthenticatedPlansRouteImport } from './routes/_authenticated/plans'
|
||||||
import { Route as AuthenticatedMessagesRouteImport } from './routes/_authenticated/messages'
|
import { Route as AuthenticatedMessagesRouteImport } from './routes/_authenticated/messages'
|
||||||
import { Route as AuthenticatedLedgerRouteImport } from './routes/_authenticated/ledger'
|
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 AuthenticatedClassesIndexRouteImport } from './routes/_authenticated/classes.index'
|
||||||
import { Route as AuthenticatedStudentsNewRouteImport } from './routes/_authenticated/students.new'
|
import { Route as AuthenticatedStudentsNewRouteImport } from './routes/_authenticated/students.new'
|
||||||
import { Route as AuthenticatedStudentsIdRouteImport } from './routes/_authenticated/students.$id'
|
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'
|
import { Route as AuthenticatedClassesIdRouteImport } from './routes/_authenticated/classes.$id'
|
||||||
|
|
||||||
const AuthRoute = AuthRouteImport.update({
|
const AuthRoute = AuthRouteImport.update({
|
||||||
@@ -53,6 +55,11 @@ const AuthenticatedStudentsRoute = AuthenticatedStudentsRouteImport.update({
|
|||||||
path: '/students',
|
path: '/students',
|
||||||
getParentRoute: () => AuthenticatedRouteRoute,
|
getParentRoute: () => AuthenticatedRouteRoute,
|
||||||
} as any)
|
} as any)
|
||||||
|
const AuthenticatedReportsRoute = AuthenticatedReportsRouteImport.update({
|
||||||
|
id: '/reports',
|
||||||
|
path: '/reports',
|
||||||
|
getParentRoute: () => AuthenticatedRouteRoute,
|
||||||
|
} as any)
|
||||||
const AuthenticatedPlansRoute = AuthenticatedPlansRouteImport.update({
|
const AuthenticatedPlansRoute = AuthenticatedPlansRouteImport.update({
|
||||||
id: '/plans',
|
id: '/plans',
|
||||||
path: '/plans',
|
path: '/plans',
|
||||||
@@ -121,6 +128,11 @@ const AuthenticatedStudentsIdRoute = AuthenticatedStudentsIdRouteImport.update({
|
|||||||
path: '/$id',
|
path: '/$id',
|
||||||
getParentRoute: () => AuthenticatedStudentsRoute,
|
getParentRoute: () => AuthenticatedStudentsRoute,
|
||||||
} as any)
|
} as any)
|
||||||
|
const AuthenticatedInvoiceIdRoute = AuthenticatedInvoiceIdRouteImport.update({
|
||||||
|
id: '/invoice/$id',
|
||||||
|
path: '/invoice/$id',
|
||||||
|
getParentRoute: () => AuthenticatedRouteRoute,
|
||||||
|
} as any)
|
||||||
const AuthenticatedClassesIdRoute = AuthenticatedClassesIdRouteImport.update({
|
const AuthenticatedClassesIdRoute = AuthenticatedClassesIdRouteImport.update({
|
||||||
id: '/$id',
|
id: '/$id',
|
||||||
path: '/$id',
|
path: '/$id',
|
||||||
@@ -139,9 +151,11 @@ export interface FileRoutesByFullPath {
|
|||||||
'/ledger': typeof AuthenticatedLedgerRoute
|
'/ledger': typeof AuthenticatedLedgerRoute
|
||||||
'/messages': typeof AuthenticatedMessagesRoute
|
'/messages': typeof AuthenticatedMessagesRoute
|
||||||
'/plans': typeof AuthenticatedPlansRoute
|
'/plans': typeof AuthenticatedPlansRoute
|
||||||
|
'/reports': typeof AuthenticatedReportsRoute
|
||||||
'/students': typeof AuthenticatedStudentsRouteWithChildren
|
'/students': typeof AuthenticatedStudentsRouteWithChildren
|
||||||
'/intake/$token': typeof IntakeTokenRoute
|
'/intake/$token': typeof IntakeTokenRoute
|
||||||
'/classes/$id': typeof AuthenticatedClassesIdRoute
|
'/classes/$id': typeof AuthenticatedClassesIdRoute
|
||||||
|
'/invoice/$id': typeof AuthenticatedInvoiceIdRoute
|
||||||
'/students/$id': typeof AuthenticatedStudentsIdRoute
|
'/students/$id': typeof AuthenticatedStudentsIdRoute
|
||||||
'/students/new': typeof AuthenticatedStudentsNewRoute
|
'/students/new': typeof AuthenticatedStudentsNewRoute
|
||||||
'/classes/': typeof AuthenticatedClassesIndexRoute
|
'/classes/': typeof AuthenticatedClassesIndexRoute
|
||||||
@@ -158,8 +172,10 @@ export interface FileRoutesByTo {
|
|||||||
'/ledger': typeof AuthenticatedLedgerRoute
|
'/ledger': typeof AuthenticatedLedgerRoute
|
||||||
'/messages': typeof AuthenticatedMessagesRoute
|
'/messages': typeof AuthenticatedMessagesRoute
|
||||||
'/plans': typeof AuthenticatedPlansRoute
|
'/plans': typeof AuthenticatedPlansRoute
|
||||||
|
'/reports': typeof AuthenticatedReportsRoute
|
||||||
'/intake/$token': typeof IntakeTokenRoute
|
'/intake/$token': typeof IntakeTokenRoute
|
||||||
'/classes/$id': typeof AuthenticatedClassesIdRoute
|
'/classes/$id': typeof AuthenticatedClassesIdRoute
|
||||||
|
'/invoice/$id': typeof AuthenticatedInvoiceIdRoute
|
||||||
'/students/$id': typeof AuthenticatedStudentsIdRoute
|
'/students/$id': typeof AuthenticatedStudentsIdRoute
|
||||||
'/students/new': typeof AuthenticatedStudentsNewRoute
|
'/students/new': typeof AuthenticatedStudentsNewRoute
|
||||||
'/classes': typeof AuthenticatedClassesIndexRoute
|
'/classes': typeof AuthenticatedClassesIndexRoute
|
||||||
@@ -179,9 +195,11 @@ export interface FileRoutesById {
|
|||||||
'/_authenticated/ledger': typeof AuthenticatedLedgerRoute
|
'/_authenticated/ledger': typeof AuthenticatedLedgerRoute
|
||||||
'/_authenticated/messages': typeof AuthenticatedMessagesRoute
|
'/_authenticated/messages': typeof AuthenticatedMessagesRoute
|
||||||
'/_authenticated/plans': typeof AuthenticatedPlansRoute
|
'/_authenticated/plans': typeof AuthenticatedPlansRoute
|
||||||
|
'/_authenticated/reports': typeof AuthenticatedReportsRoute
|
||||||
'/_authenticated/students': typeof AuthenticatedStudentsRouteWithChildren
|
'/_authenticated/students': typeof AuthenticatedStudentsRouteWithChildren
|
||||||
'/intake/$token': typeof IntakeTokenRoute
|
'/intake/$token': typeof IntakeTokenRoute
|
||||||
'/_authenticated/classes/$id': typeof AuthenticatedClassesIdRoute
|
'/_authenticated/classes/$id': typeof AuthenticatedClassesIdRoute
|
||||||
|
'/_authenticated/invoice/$id': typeof AuthenticatedInvoiceIdRoute
|
||||||
'/_authenticated/students/$id': typeof AuthenticatedStudentsIdRoute
|
'/_authenticated/students/$id': typeof AuthenticatedStudentsIdRoute
|
||||||
'/_authenticated/students/new': typeof AuthenticatedStudentsNewRoute
|
'/_authenticated/students/new': typeof AuthenticatedStudentsNewRoute
|
||||||
'/_authenticated/classes/': typeof AuthenticatedClassesIndexRoute
|
'/_authenticated/classes/': typeof AuthenticatedClassesIndexRoute
|
||||||
@@ -201,9 +219,11 @@ export interface FileRouteTypes {
|
|||||||
| '/ledger'
|
| '/ledger'
|
||||||
| '/messages'
|
| '/messages'
|
||||||
| '/plans'
|
| '/plans'
|
||||||
|
| '/reports'
|
||||||
| '/students'
|
| '/students'
|
||||||
| '/intake/$token'
|
| '/intake/$token'
|
||||||
| '/classes/$id'
|
| '/classes/$id'
|
||||||
|
| '/invoice/$id'
|
||||||
| '/students/$id'
|
| '/students/$id'
|
||||||
| '/students/new'
|
| '/students/new'
|
||||||
| '/classes/'
|
| '/classes/'
|
||||||
@@ -220,8 +240,10 @@ export interface FileRouteTypes {
|
|||||||
| '/ledger'
|
| '/ledger'
|
||||||
| '/messages'
|
| '/messages'
|
||||||
| '/plans'
|
| '/plans'
|
||||||
|
| '/reports'
|
||||||
| '/intake/$token'
|
| '/intake/$token'
|
||||||
| '/classes/$id'
|
| '/classes/$id'
|
||||||
|
| '/invoice/$id'
|
||||||
| '/students/$id'
|
| '/students/$id'
|
||||||
| '/students/new'
|
| '/students/new'
|
||||||
| '/classes'
|
| '/classes'
|
||||||
@@ -240,9 +262,11 @@ export interface FileRouteTypes {
|
|||||||
| '/_authenticated/ledger'
|
| '/_authenticated/ledger'
|
||||||
| '/_authenticated/messages'
|
| '/_authenticated/messages'
|
||||||
| '/_authenticated/plans'
|
| '/_authenticated/plans'
|
||||||
|
| '/_authenticated/reports'
|
||||||
| '/_authenticated/students'
|
| '/_authenticated/students'
|
||||||
| '/intake/$token'
|
| '/intake/$token'
|
||||||
| '/_authenticated/classes/$id'
|
| '/_authenticated/classes/$id'
|
||||||
|
| '/_authenticated/invoice/$id'
|
||||||
| '/_authenticated/students/$id'
|
| '/_authenticated/students/$id'
|
||||||
| '/_authenticated/students/new'
|
| '/_authenticated/students/new'
|
||||||
| '/_authenticated/classes/'
|
| '/_authenticated/classes/'
|
||||||
@@ -293,6 +317,13 @@ declare module '@tanstack/react-router' {
|
|||||||
preLoaderRoute: typeof AuthenticatedStudentsRouteImport
|
preLoaderRoute: typeof AuthenticatedStudentsRouteImport
|
||||||
parentRoute: typeof AuthenticatedRouteRoute
|
parentRoute: typeof AuthenticatedRouteRoute
|
||||||
}
|
}
|
||||||
|
'/_authenticated/reports': {
|
||||||
|
id: '/_authenticated/reports'
|
||||||
|
path: '/reports'
|
||||||
|
fullPath: '/reports'
|
||||||
|
preLoaderRoute: typeof AuthenticatedReportsRouteImport
|
||||||
|
parentRoute: typeof AuthenticatedRouteRoute
|
||||||
|
}
|
||||||
'/_authenticated/plans': {
|
'/_authenticated/plans': {
|
||||||
id: '/_authenticated/plans'
|
id: '/_authenticated/plans'
|
||||||
path: '/plans'
|
path: '/plans'
|
||||||
@@ -384,6 +415,13 @@ declare module '@tanstack/react-router' {
|
|||||||
preLoaderRoute: typeof AuthenticatedStudentsIdRouteImport
|
preLoaderRoute: typeof AuthenticatedStudentsIdRouteImport
|
||||||
parentRoute: typeof AuthenticatedStudentsRoute
|
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': {
|
'/_authenticated/classes/$id': {
|
||||||
id: '/_authenticated/classes/$id'
|
id: '/_authenticated/classes/$id'
|
||||||
path: '/$id'
|
path: '/$id'
|
||||||
@@ -434,7 +472,9 @@ interface AuthenticatedRouteRouteChildren {
|
|||||||
AuthenticatedLedgerRoute: typeof AuthenticatedLedgerRoute
|
AuthenticatedLedgerRoute: typeof AuthenticatedLedgerRoute
|
||||||
AuthenticatedMessagesRoute: typeof AuthenticatedMessagesRoute
|
AuthenticatedMessagesRoute: typeof AuthenticatedMessagesRoute
|
||||||
AuthenticatedPlansRoute: typeof AuthenticatedPlansRoute
|
AuthenticatedPlansRoute: typeof AuthenticatedPlansRoute
|
||||||
|
AuthenticatedReportsRoute: typeof AuthenticatedReportsRoute
|
||||||
AuthenticatedStudentsRoute: typeof AuthenticatedStudentsRouteWithChildren
|
AuthenticatedStudentsRoute: typeof AuthenticatedStudentsRouteWithChildren
|
||||||
|
AuthenticatedInvoiceIdRoute: typeof AuthenticatedInvoiceIdRoute
|
||||||
}
|
}
|
||||||
|
|
||||||
const AuthenticatedRouteRouteChildren: AuthenticatedRouteRouteChildren = {
|
const AuthenticatedRouteRouteChildren: AuthenticatedRouteRouteChildren = {
|
||||||
@@ -447,7 +487,9 @@ const AuthenticatedRouteRouteChildren: AuthenticatedRouteRouteChildren = {
|
|||||||
AuthenticatedLedgerRoute: AuthenticatedLedgerRoute,
|
AuthenticatedLedgerRoute: AuthenticatedLedgerRoute,
|
||||||
AuthenticatedMessagesRoute: AuthenticatedMessagesRoute,
|
AuthenticatedMessagesRoute: AuthenticatedMessagesRoute,
|
||||||
AuthenticatedPlansRoute: AuthenticatedPlansRoute,
|
AuthenticatedPlansRoute: AuthenticatedPlansRoute,
|
||||||
|
AuthenticatedReportsRoute: AuthenticatedReportsRoute,
|
||||||
AuthenticatedStudentsRoute: AuthenticatedStudentsRouteWithChildren,
|
AuthenticatedStudentsRoute: AuthenticatedStudentsRouteWithChildren,
|
||||||
|
AuthenticatedInvoiceIdRoute: AuthenticatedInvoiceIdRoute,
|
||||||
}
|
}
|
||||||
|
|
||||||
const AuthenticatedRouteRouteWithChildren =
|
const AuthenticatedRouteRouteWithChildren =
|
||||||
|
|||||||
@@ -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<string, string> = {
|
||||||
|
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<string, string>();
|
||||||
|
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 (
|
||||||
|
<div className="p-8 flex items-center gap-2 text-sm text-muted-foreground">
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" /> Loading invoice…
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div className="p-6 md:p-8 max-w-3xl">
|
||||||
|
<div className="no-print space-y-3">
|
||||||
|
<Link to="/ledger" className="text-sm text-muted-foreground inline-flex items-center gap-1">
|
||||||
|
<ArrowLeft className="h-4 w-4" /> Tuition ledger
|
||||||
|
</Link>
|
||||||
|
<div className="bg-card border rounded-lg p-4 flex flex-wrap gap-3 items-end">
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs">From</Label>
|
||||||
|
<Input
|
||||||
|
type="date"
|
||||||
|
key={`f${from}`}
|
||||||
|
defaultValue={from}
|
||||||
|
onChange={(e) => setFrom(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs">To</Label>
|
||||||
|
<Input
|
||||||
|
type="date"
|
||||||
|
key={`t${to}`}
|
||||||
|
defaultValue={to}
|
||||||
|
onChange={(e) => setTo(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => {
|
||||||
|
const r = schoolYearRange();
|
||||||
|
setFrom(r.from);
|
||||||
|
setTo(r.to);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
School year
|
||||||
|
</Button>
|
||||||
|
<div className="flex-1" />
|
||||||
|
<Button size="sm" onClick={() => window.print()}>
|
||||||
|
<Printer className="h-4 w-4 mr-1" /> Print
|
||||||
|
</Button>
|
||||||
|
{(recipients ?? []).length > 0 ? (
|
||||||
|
<a href={mailto}>
|
||||||
|
<Button size="sm" variant="outline">
|
||||||
|
<Mail className="h-4 w-4 mr-1" /> Email to parents
|
||||||
|
</Button>
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
disabled
|
||||||
|
title="No guardian or parent email on file"
|
||||||
|
>
|
||||||
|
<Mail className="h-4 w-4 mr-1" /> Email to parents
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{(recipients ?? []).length > 0 && (
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Will draft to: {(recipients ?? []).map((r) => r.email).join(", ")}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="print-doc bg-card border rounded-lg p-6 mt-6 space-y-5">
|
||||||
|
<DocHeader title="Invoice" subtitle={`${formatDate(from)} – ${formatDate(to)}`} />
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-x-8 gap-y-1 text-sm">
|
||||||
|
{[
|
||||||
|
["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]) => (
|
||||||
|
<div key={k} className="flex justify-between border-b py-1">
|
||||||
|
<span className="text-muted-foreground">{k}</span>
|
||||||
|
<span className="font-medium text-right">{v}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-semibold uppercase tracking-wide mb-2">Account activity</h3>
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b text-left">
|
||||||
|
<th className="py-1 font-medium">Date</th>
|
||||||
|
<th className="py-1 font-medium">Description</th>
|
||||||
|
<th className="py-1 font-medium text-right">Charge</th>
|
||||||
|
<th className="py-1 font-medium text-right">Payment</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr className="border-b">
|
||||||
|
<td className="py-1 text-muted-foreground">{formatDate(from)}</td>
|
||||||
|
<td className="py-1 text-muted-foreground">Balance carried forward</td>
|
||||||
|
<td className="py-1 text-right">{opening !== 0 ? money(opening) : "—"}</td>
|
||||||
|
<td className="py-1 text-right">—</td>
|
||||||
|
</tr>
|
||||||
|
{period.map((e) => (
|
||||||
|
<tr key={e.id} className="border-b">
|
||||||
|
<td className="py-1">{formatDate(e.date)}</td>
|
||||||
|
<td className="py-1">
|
||||||
|
{CATEGORY_LABEL[e.category] ?? e.category}
|
||||||
|
{e.note ? <span className="text-muted-foreground"> — {e.note}</span> : null}
|
||||||
|
</td>
|
||||||
|
<td className="py-1 text-right">
|
||||||
|
{e.kind === "charge" ? money(e.amount_cents) : "—"}
|
||||||
|
</td>
|
||||||
|
<td className="py-1 text-right">
|
||||||
|
{e.kind === "payment" ? money(e.amount_cents) : "—"}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
{period.length === 0 && (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={4} className="py-3 text-sm text-muted-foreground">
|
||||||
|
No activity in this period.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
<tfoot>
|
||||||
|
<tr>
|
||||||
|
<td colSpan={2} className="pt-2 text-right text-muted-foreground">
|
||||||
|
Charges this period
|
||||||
|
</td>
|
||||||
|
<td className="pt-2 text-right">{money(charges)}</td>
|
||||||
|
<td />
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td colSpan={2} className="text-right text-muted-foreground">
|
||||||
|
Payments this period
|
||||||
|
</td>
|
||||||
|
<td />
|
||||||
|
<td className="text-right">{money(payments)}</td>
|
||||||
|
</tr>
|
||||||
|
<tr className="font-semibold text-base">
|
||||||
|
<td colSpan={2} className="pt-2 text-right">
|
||||||
|
Balance due
|
||||||
|
</td>
|
||||||
|
<td colSpan={2} className="pt-2 text-right">
|
||||||
|
{money(balanceDue)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tfoot>
|
||||||
|
</table>
|
||||||
|
{balanceDue < 0 && (
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">
|
||||||
|
A negative balance is a credit on the account.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<SignatureLines labels={["Received by", "Date"]} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,7 +1,9 @@
|
|||||||
import { createFileRoute, Link } from "@tanstack/react-router";
|
import { createFileRoute, Link } from "@tanstack/react-router";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { supabase } from "@/integrations/supabase/client";
|
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")({
|
export const Route = createFileRoute("/_authenticated/ledger")({
|
||||||
head: () => ({ meta: [{ title: "Tuition — School Portal" }] }),
|
head: () => ({ meta: [{ title: "Tuition — School Portal" }] }),
|
||||||
@@ -12,11 +14,16 @@ function LedgerListPage() {
|
|||||||
const { data: students } = useQuery({
|
const { data: students } = useQuery({
|
||||||
queryKey: ["students-with-balance"],
|
queryKey: ["students-with-balance"],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const { data: studs } = await supabase.from("students").select("id, first_name, last_name");
|
const { data: studs } = await supabase
|
||||||
const { data: entries } = await supabase.from("ledger_entries").select("student_id, amount_cents, kind");
|
.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<string, number> = {};
|
const balances: Record<string, number> = {};
|
||||||
(entries ?? []).forEach((e) => {
|
(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 }));
|
return (studs ?? []).map((s) => ({ ...s, balance: balances[s.id] ?? 0 }));
|
||||||
},
|
},
|
||||||
@@ -25,18 +32,47 @@ function LedgerListPage() {
|
|||||||
return (
|
return (
|
||||||
<div className="p-8 max-w-4xl">
|
<div className="p-8 max-w-4xl">
|
||||||
<h1 className="text-2xl font-semibold mb-1">Tuition ledger</h1>
|
<h1 className="text-2xl font-semibold mb-1">Tuition ledger</h1>
|
||||||
<p className="text-muted-foreground text-sm mb-6">Click a student to view and manage their ledger.</p>
|
<p className="text-muted-foreground text-sm mb-6">
|
||||||
|
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.
|
||||||
|
</p>
|
||||||
<div className="bg-card border rounded-lg divide-y">
|
<div className="bg-card border rounded-lg divide-y">
|
||||||
{(students ?? []).map((s) => (
|
{(students ?? []).map((s) => (
|
||||||
<Link key={s.id} to="/students/$id" params={{ id: s.id }} className="flex justify-between items-center p-3 hover:bg-muted/50">
|
<div key={s.id} className="flex items-center gap-3 p-3 hover:bg-muted/50">
|
||||||
<span className="font-medium">{s.last_name}, {s.first_name}</span>
|
<Link
|
||||||
<span className="flex items-center gap-3">
|
to="/students/$id"
|
||||||
<span className={`text-sm font-medium ${s.balance > 0 ? "text-destructive" : "text-green-700"}`}>${(s.balance / 100).toFixed(2)}</span>
|
params={{ id: s.id }}
|
||||||
<ChevronRight className="h-4 w-4 text-muted-foreground" />
|
className="flex-1 min-w-0 flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<span className="font-medium truncate">
|
||||||
|
{s.last_name}, {s.first_name}
|
||||||
|
</span>
|
||||||
|
{s.daily_tuition_cents ? (
|
||||||
|
<span className="text-xs text-muted-foreground shrink-0">
|
||||||
|
{money(s.daily_tuition_cents)}/day
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-xs text-muted-foreground shrink-0">no rate</span>
|
||||||
|
)}
|
||||||
|
</Link>
|
||||||
|
<span
|
||||||
|
className={`text-sm font-medium shrink-0 ${s.balance > 0 ? "text-destructive" : "text-green-700"}`}
|
||||||
|
>
|
||||||
|
{money(s.balance)}
|
||||||
</span>
|
</span>
|
||||||
</Link>
|
<Link to="/invoice/$id" params={{ id: s.id }} className="shrink-0">
|
||||||
|
<Button size="sm" variant="outline">
|
||||||
|
<FileText className="h-4 w-4 mr-1" /> Invoice
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
<Link to="/students/$id" params={{ id: s.id }} className="shrink-0">
|
||||||
|
<ChevronRight className="h-4 w-4 text-muted-foreground" />
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
))}
|
))}
|
||||||
{students?.length === 0 && <div className="p-6 text-center text-sm text-muted-foreground">No students.</div>}
|
{students?.length === 0 && (
|
||||||
|
<div className="p-6 text-center text-sm text-muted-foreground">No students.</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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<ReportType>("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 (
|
||||||
|
<div className="p-6 md:p-8 max-w-4xl">
|
||||||
|
<div className="no-print">
|
||||||
|
<h1 className="text-2xl font-semibold flex items-center gap-2">
|
||||||
|
<FileText className="h-6 w-6 text-primary" /> Reports
|
||||||
|
</h1>
|
||||||
|
<p className="text-muted-foreground text-sm mt-1">
|
||||||
|
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.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="bg-card border rounded-lg p-4 mt-6 space-y-3">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs">Report</Label>
|
||||||
|
<Select value={type} onValueChange={(v) => setType(v as ReportType)}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{REPORT_TYPES.map((r) => (
|
||||||
|
<SelectItem key={r.value} value={r.value}>
|
||||||
|
{r.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs">Class</Label>
|
||||||
|
<Select
|
||||||
|
value={classId}
|
||||||
|
onValueChange={(v) => {
|
||||||
|
setClassId(v);
|
||||||
|
setStudentId(ALL);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Choose class" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{(classes ?? []).map((c) => (
|
||||||
|
<SelectItem key={c.id} value={c.id}>
|
||||||
|
{c.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs">Student</Label>
|
||||||
|
<Select value={studentId} onValueChange={setStudentId} disabled={!classId}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value={ALL}>All students in class</SelectItem>
|
||||||
|
{(students ?? []).map((s) => (
|
||||||
|
<SelectItem key={s.id} value={s.id}>
|
||||||
|
{s.last_name}, {s.first_name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-3 items-end">
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs">From</Label>
|
||||||
|
{/* Uncontrolled + keyed so Safari's native picker isn't reset by React. */}
|
||||||
|
<Input
|
||||||
|
type="date"
|
||||||
|
key={`f${from}`}
|
||||||
|
defaultValue={from}
|
||||||
|
onChange={(e) => setFrom(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs">To</Label>
|
||||||
|
<Input
|
||||||
|
type="date"
|
||||||
|
key={`t${to}`}
|
||||||
|
defaultValue={to}
|
||||||
|
onChange={(e) => setTo(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button size="sm" variant="outline" onClick={() => applyPreset("year")}>
|
||||||
|
School year
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" variant="outline" onClick={() => applyPreset("month")}>
|
||||||
|
Month to date
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between pt-1">
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{classId
|
||||||
|
? `${selected.length} document${selected.length === 1 ? "" : "s"} ready`
|
||||||
|
: "Select a class to begin."}
|
||||||
|
</span>
|
||||||
|
<Button size="sm" onClick={() => window.print()} disabled={selected.length === 0}>
|
||||||
|
<Printer className="h-4 w-4 mr-1" /> Print
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-6 mt-6">
|
||||||
|
{selected.map((s) =>
|
||||||
|
type === "iep" ? (
|
||||||
|
<IepProgressDoc key={s.id} studentId={s.id} from={from} to={to} />
|
||||||
|
) : (
|
||||||
|
<ReportCardDoc key={s.id} studentId={s.id} variant={type} from={from} to={to} />
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -4,7 +4,7 @@ import { supabase } from "@/integrations/supabase/client";
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
GraduationCap, LayoutDashboard, Users, ClipboardCheck, Receipt,
|
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";
|
} from "lucide-react";
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
|
|
||||||
@@ -35,6 +35,7 @@ function ProtectedLayout() {
|
|||||||
{ to: "/classes", label: "Classes", icon: BookOpen, show: isAdmin || roles.includes("teacher") },
|
{ to: "/classes", label: "Classes", icon: BookOpen, show: isAdmin || roles.includes("teacher") },
|
||||||
{ to: "/attendance", label: "Attendance", icon: ClipboardCheck, 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: "/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: "/ledger", label: "Tuition", icon: Receipt, show: true },
|
||||||
{ to: "/messages", label: "Messages", icon: MessageSquare, show: true },
|
{ to: "/messages", label: "Messages", icon: MessageSquare, show: true },
|
||||||
{ to: "/forms", label: "Forms", icon: FileText, show: true },
|
{ to: "/forms", label: "Forms", icon: FileText, show: true },
|
||||||
@@ -49,7 +50,7 @@ function ProtectedLayout() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-muted/20 flex">
|
<div className="min-h-screen bg-muted/20 flex">
|
||||||
<aside className="w-60 bg-card border-r flex flex-col">
|
<aside className="w-60 bg-card border-r flex flex-col no-print">
|
||||||
<div className="h-16 border-b flex items-center px-4">
|
<div className="h-16 border-b flex items-center px-4">
|
||||||
<img src="/bayside-logo.png" alt="Bayside Academy" className="h-9 w-auto" />
|
<img src="/bayside-logo.png" alt="Bayside Academy" className="h-9 w-auto" />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -142,3 +142,50 @@
|
|||||||
color: var(--color-foreground);
|
color: var(--color-foreground);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Print: report cards, progress reports, invoices ────────────────────────
|
||||||
|
Documents are ordinary pages on screen; @media print strips the app chrome
|
||||||
|
so what lands on paper (or in "Save as PDF") is just the document. Mark app
|
||||||
|
furniture with .no-print, and wrap each student's document in .print-page so
|
||||||
|
a batch of report cards breaks one-per-sheet. */
|
||||||
|
@page {
|
||||||
|
margin: 14mm;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media print {
|
||||||
|
.no-print {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
html,
|
||||||
|
body {
|
||||||
|
background: #fff !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The authenticated layout scrolls <main>; printing needs it to overflow. */
|
||||||
|
main {
|
||||||
|
overflow: visible !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.print-doc {
|
||||||
|
border: 0 !important;
|
||||||
|
box-shadow: none !important;
|
||||||
|
padding: 0 !important;
|
||||||
|
max-width: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.print-page + .print-page {
|
||||||
|
break-before: page;
|
||||||
|
}
|
||||||
|
|
||||||
|
tr,
|
||||||
|
td,
|
||||||
|
th {
|
||||||
|
break-inside: avoid;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: inherit;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user