Changes
Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com>
This commit is contained in:
co-authored by
renee-png
parent
e094b56e1b
commit
eeeae5181a
@@ -0,0 +1,284 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user