Modified by www.SourceFiles.app
This commit is contained in:
@@ -1,288 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user