Files
info-share-spot/src/routes/_authenticated/students.$id.tsx
T
2026-06-30 20:44:37 +00:00

285 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { createFileRoute, Link } from "@tanstack/react-router";
import { useQuery, useMutation, useQueryClient } 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 { Textarea } from "@/components/ui/textarea";
import { Switch } from "@/components/ui/switch";
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
import { ArrowLeft, Upload, Trash2, Plus, FileText } from "lucide-react";
import { useState } from "react";
import { toast } from "sonner";
export const Route = createFileRoute("/_authenticated/students/$id")({
head: () => ({ meta: [{ title: "Student — School Portal" }] }),
component: StudentDetail,
});
function StudentDetail() {
const { id } = Route.useParams();
const { roles } = useAuth();
const isAdmin = roles.includes("admin");
const qc = useQueryClient();
const { data: student } = useQuery({
queryKey: ["student", id],
queryFn: async () => {
const { data, error } = await supabase.from("students").select("*, classes(name)").eq("id", id).single();
if (error) throw error;
return data;
},
});
return (
<div className="p-8 max-w-5xl">
<Link to="/students" className="text-sm text-muted-foreground inline-flex items-center gap-1 mb-3"><ArrowLeft className="h-4 w-4" /> All students</Link>
<h1 className="text-2xl font-semibold">{student?.first_name} {student?.last_name}</h1>
<p className="text-muted-foreground text-sm">{(student?.classes as { name: string } | null)?.name ?? "No class"}</p>
<Tabs defaultValue="info" className="mt-6">
<TabsList>
<TabsTrigger value="info">Info</TabsTrigger>
<TabsTrigger value="pickups">Authorized pickup</TabsTrigger>
<TabsTrigger value="attendance">Attendance</TabsTrigger>
<TabsTrigger value="ledger">Tuition ledger</TabsTrigger>
{isAdmin && <TabsTrigger value="contracts">Contracts</TabsTrigger>}
</TabsList>
<TabsContent value="info"><InfoTab studentId={id} canEdit={isAdmin} /></TabsContent>
<TabsContent value="pickups"><PickupsTab studentId={id} /></TabsContent>
<TabsContent value="attendance"><AttendanceTab studentId={id} /></TabsContent>
<TabsContent value="ledger"><LedgerTab studentId={id} canEdit={isAdmin} /></TabsContent>
{isAdmin && <TabsContent value="contracts"><ContractsTab studentId={id} /></TabsContent>}
</Tabs>
</div>
);
}
function InfoTab({ studentId, canEdit }: { studentId: string; canEdit: boolean }) {
const qc = useQueryClient();
const { data: s } = useQuery({
queryKey: ["student", studentId],
queryFn: async () => (await supabase.from("students").select("*").eq("id", studentId).single()).data,
});
const [form, setForm] = useState<Record<string, unknown> | null>(null);
const current = form ?? (s as Record<string, unknown> | null);
const save = useMutation({
mutationFn: async () => {
if (!current) return;
const { error } = await supabase.from("students").update({
first_name: current.first_name as string,
last_name: current.last_name as string,
dob: (current.dob as string) || null,
allergies: (current.allergies as string) || null,
photo_release: !!current.photo_release,
notes: (current.notes as string) || null,
}).eq("id", studentId);
if (error) throw error;
},
onSuccess: () => { toast.success("Saved"); qc.invalidateQueries({ queryKey: ["student", studentId] }); setForm(null); },
onError: (e: Error) => toast.error(e.message),
});
if (!current) return null;
const upd = (k: string, v: unknown) => setForm({ ...(current as Record<string, unknown>), [k]: v });
return (
<div className="bg-card border rounded-lg p-6 space-y-4 max-w-2xl">
<div className="grid grid-cols-2 gap-3">
<div><Label>First name</Label><Input disabled={!canEdit} value={current.first_name as string ?? ""} onChange={(e) => upd("first_name", e.target.value)} /></div>
<div><Label>Last name</Label><Input disabled={!canEdit} value={current.last_name as string ?? ""} onChange={(e) => upd("last_name", e.target.value)} /></div>
</div>
<div><Label>Date of birth</Label><Input disabled={!canEdit} type="date" value={(current.dob as string) ?? ""} onChange={(e) => upd("dob", e.target.value)} /></div>
<div><Label>Allergies</Label><Input disabled={!canEdit} value={(current.allergies as string) ?? ""} onChange={(e) => upd("allergies", e.target.value)} /></div>
<div className="flex items-center justify-between"><Label>Photo release granted</Label><Switch disabled={!canEdit} checked={!!current.photo_release} onCheckedChange={(v) => upd("photo_release", v)} /></div>
<div><Label>Notes</Label><Textarea disabled={!canEdit} value={(current.notes as string) ?? ""} onChange={(e) => upd("notes", e.target.value)} /></div>
{canEdit && <Button onClick={() => save.mutate()} disabled={save.isPending}>Save changes</Button>}
</div>
);
}
function PickupsTab({ studentId }: { studentId: string }) {
const qc = useQueryClient();
const { data } = useQuery({
queryKey: ["pickups", studentId],
queryFn: async () => (await supabase.from("authorized_pickups").select("*").eq("student_id", studentId).order("name")).data ?? [],
});
const [form, setForm] = useState({ name: "", phone: "", relationship: "" });
const add = useMutation({
mutationFn: async () => {
const { error } = await supabase.from("authorized_pickups").insert({ student_id: studentId, ...form });
if (error) throw error;
},
onSuccess: () => { setForm({ name: "", phone: "", relationship: "" }); qc.invalidateQueries({ queryKey: ["pickups", studentId] }); },
onError: (e: Error) => toast.error(e.message),
});
const del = useMutation({
mutationFn: async (id: string) => { const { error } = await supabase.from("authorized_pickups").delete().eq("id", id); if (error) throw error; },
onSuccess: () => qc.invalidateQueries({ queryKey: ["pickups", studentId] }),
});
return (
<div className="space-y-4 max-w-2xl">
<div className="bg-card border rounded-lg divide-y">
{(data ?? []).map((p) => (
<div key={p.id} className="flex justify-between items-center p-3">
<div><div className="font-medium">{p.name}</div><div className="text-xs text-muted-foreground">{p.relationship} · {p.phone}</div></div>
<Button size="icon" variant="ghost" onClick={() => del.mutate(p.id)}><Trash2 className="h-4 w-4" /></Button>
</div>
))}
{data?.length === 0 && <div className="p-4 text-sm text-muted-foreground">No one added yet.</div>}
</div>
<div className="bg-card border rounded-lg p-4 space-y-3">
<div className="font-medium text-sm">Add authorized pickup</div>
<div className="grid grid-cols-3 gap-2">
<Input placeholder="Name" value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} />
<Input placeholder="Relationship" value={form.relationship} onChange={(e) => setForm({ ...form, relationship: e.target.value })} />
<Input placeholder="Phone" value={form.phone} onChange={(e) => setForm({ ...form, phone: e.target.value })} />
</div>
<Button onClick={() => add.mutate()} disabled={!form.name || add.isPending}><Plus className="h-4 w-4 mr-1" /> Add</Button>
</div>
</div>
);
}
function AttendanceTab({ studentId }: { studentId: string }) {
const { data } = useQuery({
queryKey: ["attendance", studentId],
queryFn: async () => (await supabase.from("attendance").select("*").eq("student_id", studentId).order("date", { ascending: false }).limit(60)).data ?? [],
});
return (
<div className="bg-card border rounded-lg divide-y max-w-xl">
{(data ?? []).map((a) => (
<div key={a.id} className="flex justify-between p-3 text-sm">
<span>{new Date(a.date).toLocaleDateString()}</span>
<span className="capitalize">{a.status}</span>
</div>
))}
{data?.length === 0 && <div className="p-4 text-sm text-muted-foreground">No attendance recorded.</div>}
</div>
);
}
function LedgerTab({ studentId, canEdit }: { studentId: string; canEdit: boolean }) {
const qc = useQueryClient();
const { data } = useQuery({
queryKey: ["ledger", studentId],
queryFn: async () => (await supabase.from("ledger_entries").select("*").eq("student_id", studentId).order("date", { ascending: false })).data ?? [],
});
const [form, setForm] = useState({ kind: "charge", category: "tuition", amount: "", note: "", date: new Date().toISOString().slice(0,10) });
const balance = (data ?? []).reduce((sum, e) => sum + (e.kind === "charge" ? e.amount_cents : -e.amount_cents), 0);
const add = useMutation({
mutationFn: async () => {
const amt = Math.round(parseFloat(form.amount) * 100);
if (!amt) throw new Error("Enter a valid amount");
const { error } = await supabase.from("ledger_entries").insert({
student_id: studentId, kind: form.kind as "charge" | "payment", category: form.category as "tuition" | "late_pickup" | "activity" | "other",
amount_cents: amt, note: form.note || null, date: form.date,
});
if (error) throw error;
},
onSuccess: () => { setForm({ ...form, amount: "", note: "" }); qc.invalidateQueries({ queryKey: ["ledger", studentId] }); toast.success("Entry added"); },
onError: (e: Error) => toast.error(e.message),
});
return (
<div className="space-y-4 max-w-3xl">
<div className="bg-card border rounded-lg p-4 flex justify-between items-center">
<div className="text-sm text-muted-foreground">Current balance</div>
<div className={`text-2xl font-semibold ${balance > 0 ? "text-destructive" : ""}`}>${(balance / 100).toFixed(2)}</div>
</div>
<div className="bg-card border rounded-lg overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-muted text-xs"><tr><th className="text-left p-2">Date</th><th className="text-left p-2">Type</th><th className="text-left p-2">Category</th><th className="text-left p-2">Note</th><th className="text-right p-2">Amount</th></tr></thead>
<tbody className="divide-y">
{(data ?? []).map((e) => (
<tr key={e.id}>
<td className="p-2">{new Date(e.date).toLocaleDateString()}</td>
<td className="p-2 capitalize">{e.kind}</td>
<td className="p-2 capitalize">{e.category.replace("_", " ")}</td>
<td className="p-2 text-muted-foreground">{e.note}</td>
<td className={`p-2 text-right ${e.kind === "charge" ? "text-destructive" : "text-green-700"}`}>{e.kind === "payment" ? "−" : ""}${(e.amount_cents / 100).toFixed(2)}</td>
</tr>
))}
{data?.length === 0 && <tr><td colSpan={5} className="p-4 text-center text-muted-foreground">No entries.</td></tr>}
</tbody>
</table>
</div>
{canEdit && (
<div className="bg-card border rounded-lg p-4 space-y-3">
<div className="font-medium text-sm">Add entry</div>
<div className="grid grid-cols-5 gap-2">
<select className="border rounded-md px-2 text-sm" value={form.kind} onChange={(e) => setForm({ ...form, kind: e.target.value })}>
<option value="charge">Charge</option><option value="payment">Payment</option>
</select>
<select className="border rounded-md px-2 text-sm" value={form.category} onChange={(e) => setForm({ ...form, category: e.target.value })}>
<option value="tuition">Tuition</option><option value="late_pickup">Late pickup</option><option value="activity">Activity</option><option value="other">Other</option>
</select>
<Input type="date" value={form.date} onChange={(e) => setForm({ ...form, date: e.target.value })} />
<Input placeholder="Amount ($)" value={form.amount} onChange={(e) => setForm({ ...form, amount: e.target.value })} />
<Input placeholder="Note" value={form.note} onChange={(e) => setForm({ ...form, note: e.target.value })} />
</div>
<Button onClick={() => add.mutate()} disabled={add.isPending}>Add entry</Button>
</div>
)}
</div>
);
}
function ContractsTab({ studentId }: { studentId: string }) {
const qc = useQueryClient();
const { data } = useQuery({
queryKey: ["contracts", studentId],
queryFn: async () => (await supabase.from("contracts").select("*").eq("student_id", studentId).order("created_at", { ascending: false })).data ?? [],
});
const [title, setTitle] = useState("");
const [uploading, setUploading] = useState(false);
const onUpload = async (file: File) => {
if (!file) return;
setUploading(true);
try {
const path = `${studentId}/${Date.now()}-${file.name}`;
const { error: upErr } = await supabase.storage.from("contracts").upload(path, file);
if (upErr) throw upErr;
const { data: u } = await supabase.auth.getUser();
const { error } = await supabase.from("contracts").insert({ student_id: studentId, file_path: path, title: title || file.name, uploaded_by: u.user?.id });
if (error) throw error;
setTitle("");
qc.invalidateQueries({ queryKey: ["contracts", studentId] });
toast.success("Contract uploaded");
} catch (e) { toast.error((e as Error).message); } finally { setUploading(false); }
};
const download = async (path: string) => {
const { data, error } = await supabase.storage.from("contracts").createSignedUrl(path, 60);
if (error) return toast.error(error.message);
window.open(data.signedUrl, "_blank");
};
return (
<div className="space-y-4 max-w-2xl">
<div className="bg-card border rounded-lg p-4 space-y-3">
<div className="font-medium text-sm">Upload signed contract (admin only)</div>
<Input placeholder="Title (optional)" value={title} onChange={(e) => setTitle(e.target.value)} />
<Input type="file" disabled={uploading} onChange={(e) => e.target.files && onUpload(e.target.files[0])} />
</div>
<div className="bg-card border rounded-lg divide-y">
{(data ?? []).map((c) => (
<button key={c.id} onClick={() => download(c.file_path)} className="w-full flex justify-between items-center p-3 hover:bg-muted/50 text-left">
<span className="flex items-center gap-2 text-sm"><FileText className="h-4 w-4" /> {c.title}</span>
<span className="text-xs text-muted-foreground">{new Date(c.created_at).toLocaleDateString()}</span>
</button>
))}
{data?.length === 0 && <div className="p-4 text-sm text-muted-foreground">No contracts uploaded.</div>}
</div>
</div>
);
}