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,220 @@
|
|||||||
|
import { createFileRoute } 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 { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
|
import { Plus, Trash2, Lock } from "lucide-react";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
type Role = "admin" | "teacher" | "parent";
|
||||||
|
|
||||||
|
export const Route = createFileRoute("/_authenticated/admin")({
|
||||||
|
head: () => ({ meta: [{ title: "Admin — School Portal" }] }),
|
||||||
|
component: AdminPage,
|
||||||
|
});
|
||||||
|
|
||||||
|
function AdminPage() {
|
||||||
|
const { roles } = useAuth();
|
||||||
|
if (!roles.includes("admin")) {
|
||||||
|
return <div className="p-8"><Lock className="h-6 w-6 text-muted-foreground" /><h1 className="mt-2 text-xl font-semibold">Admins only</h1></div>;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className="p-8 max-w-5xl">
|
||||||
|
<h1 className="text-2xl font-semibold mb-1">Admin</h1>
|
||||||
|
<p className="text-muted-foreground text-sm mb-6">Manage users, roles, classes, and parent-student links.</p>
|
||||||
|
<Tabs defaultValue="users">
|
||||||
|
<TabsList>
|
||||||
|
<TabsTrigger value="users">Users & roles</TabsTrigger>
|
||||||
|
<TabsTrigger value="classes">Classes</TabsTrigger>
|
||||||
|
<TabsTrigger value="links">Parent ↔ student</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
<TabsContent value="users"><UsersTab /></TabsContent>
|
||||||
|
<TabsContent value="classes"><ClassesTab /></TabsContent>
|
||||||
|
<TabsContent value="links"><LinksTab /></TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function UsersTab() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
const { data } = useQuery({
|
||||||
|
queryKey: ["admin-users"],
|
||||||
|
queryFn: async () => {
|
||||||
|
const { data: profiles } = await supabase.from("profiles").select("id, full_name, email");
|
||||||
|
const { data: roles } = await supabase.from("user_roles").select("user_id, role");
|
||||||
|
const rolesByUser: Record<string, Role[]> = {};
|
||||||
|
(roles ?? []).forEach((r) => { rolesByUser[r.user_id] = [...(rolesByUser[r.user_id] ?? []), r.role as Role]; });
|
||||||
|
return (profiles ?? []).map((p) => ({ ...p, roles: rolesByUser[p.id] ?? [] }));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const setRole = useMutation({
|
||||||
|
mutationFn: async ({ userId, role, on }: { userId: string; role: Role; on: boolean }) => {
|
||||||
|
if (on) {
|
||||||
|
const { error } = await supabase.from("user_roles").insert({ user_id: userId, role });
|
||||||
|
if (error && !error.message.includes("duplicate")) throw error;
|
||||||
|
} else {
|
||||||
|
const { error } = await supabase.from("user_roles").delete().eq("user_id", userId).eq("role", role);
|
||||||
|
if (error) throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: ["admin-users"] }),
|
||||||
|
onError: (e: Error) => toast.error(e.message),
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-card border rounded-lg overflow-hidden mt-4">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead className="bg-muted text-xs"><tr><th className="text-left p-2">Name</th><th className="text-left p-2">Email</th><th className="text-left p-2">Roles</th></tr></thead>
|
||||||
|
<tbody className="divide-y">
|
||||||
|
{(data ?? []).map((u) => (
|
||||||
|
<tr key={u.id}>
|
||||||
|
<td className="p-2">{u.full_name || "—"}</td>
|
||||||
|
<td className="p-2 text-muted-foreground">{u.email}</td>
|
||||||
|
<td className="p-2">
|
||||||
|
<div className="flex gap-1">
|
||||||
|
{(["admin", "teacher", "parent"] as Role[]).map((r) => {
|
||||||
|
const on = u.roles.includes(r);
|
||||||
|
return <Button key={r} size="sm" variant={on ? "default" : "outline"} onClick={() => setRole.mutate({ userId: u.id, role: r, on: !on })}>{r}</Button>;
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ClassesTab() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
const [name, setName] = useState("");
|
||||||
|
const [teacherId, setTeacherId] = useState("");
|
||||||
|
|
||||||
|
const { data: classes } = useQuery({
|
||||||
|
queryKey: ["admin-classes"],
|
||||||
|
queryFn: async () => (await supabase.from("classes").select("*, profiles!classes_teacher_id_fkey(full_name)").order("name")).data ?? [],
|
||||||
|
});
|
||||||
|
const { data: teachers } = useQuery({
|
||||||
|
queryKey: ["teachers"],
|
||||||
|
queryFn: async () => {
|
||||||
|
const { data } = await supabase.from("user_roles").select("user_id, profiles(id, full_name, email)").eq("role", "teacher");
|
||||||
|
return (data ?? []).map((d) => d.profiles as { id: string; full_name: string; email: string }).filter(Boolean);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const add = useMutation({
|
||||||
|
mutationFn: async () => {
|
||||||
|
const { error } = await supabase.from("classes").insert({ name, teacher_id: teacherId || null });
|
||||||
|
if (error) throw error;
|
||||||
|
},
|
||||||
|
onSuccess: () => { setName(""); setTeacherId(""); qc.invalidateQueries({ queryKey: ["admin-classes"] }); toast.success("Class added"); },
|
||||||
|
onError: (e: Error) => toast.error(e.message),
|
||||||
|
});
|
||||||
|
const del = useMutation({
|
||||||
|
mutationFn: async (id: string) => { const { error } = await supabase.from("classes").delete().eq("id", id); if (error) throw error; },
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: ["admin-classes"] }),
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4 mt-4">
|
||||||
|
<div className="bg-card border rounded-lg divide-y">
|
||||||
|
{(classes ?? []).map((c) => (
|
||||||
|
<div key={c.id} className="p-3 flex justify-between items-center">
|
||||||
|
<div>
|
||||||
|
<div className="font-medium">{c.name}</div>
|
||||||
|
<div className="text-xs text-muted-foreground">Teacher: {(c.profiles as { full_name?: string } | null)?.full_name ?? "unassigned"}</div>
|
||||||
|
</div>
|
||||||
|
<Button size="icon" variant="ghost" onClick={() => del.mutate(c.id)}><Trash2 className="h-4 w-4" /></Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{classes?.length === 0 && <div className="p-4 text-sm text-muted-foreground">No classes yet.</div>}
|
||||||
|
</div>
|
||||||
|
<div className="bg-card border rounded-lg p-4 space-y-3">
|
||||||
|
<div className="font-medium text-sm">New class</div>
|
||||||
|
<div className="grid grid-cols-3 gap-2">
|
||||||
|
<Input placeholder="Class name" value={name} onChange={(e) => setName(e.target.value)} />
|
||||||
|
<Select value={teacherId} onValueChange={setTeacherId}>
|
||||||
|
<SelectTrigger><SelectValue placeholder="Assign teacher" /></SelectTrigger>
|
||||||
|
<SelectContent>{(teachers ?? []).map((t) => <SelectItem key={t.id} value={t.id}>{t.full_name || t.email}</SelectItem>)}</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<Button onClick={() => add.mutate()} disabled={!name || add.isPending}><Plus className="h-4 w-4 mr-1" /> Add</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function LinksTab() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
const [parentId, setParentId] = useState("");
|
||||||
|
const [studentId, setStudentId] = useState("");
|
||||||
|
|
||||||
|
const { data: links } = useQuery({
|
||||||
|
queryKey: ["admin-links"],
|
||||||
|
queryFn: async () => (await supabase.from("parent_students").select("id, profiles!parent_students_parent_id_fkey(full_name, email), students(first_name, last_name)")).data ?? [],
|
||||||
|
});
|
||||||
|
const { data: parents } = useQuery({
|
||||||
|
queryKey: ["all-parents"],
|
||||||
|
queryFn: async () => {
|
||||||
|
const { data } = await supabase.from("user_roles").select("profiles(id, full_name, email)").eq("role", "parent");
|
||||||
|
return (data ?? []).map((d) => d.profiles as { id: string; full_name: string; email: string }).filter(Boolean);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const { data: students } = useQuery({
|
||||||
|
queryKey: ["all-students-link"],
|
||||||
|
queryFn: async () => (await supabase.from("students").select("id, first_name, last_name").order("last_name")).data ?? [],
|
||||||
|
});
|
||||||
|
|
||||||
|
const add = useMutation({
|
||||||
|
mutationFn: async () => {
|
||||||
|
const { error } = await supabase.from("parent_students").insert({ parent_id: parentId, student_id: studentId });
|
||||||
|
if (error) throw error;
|
||||||
|
},
|
||||||
|
onSuccess: () => { setParentId(""); setStudentId(""); qc.invalidateQueries({ queryKey: ["admin-links"] }); toast.success("Linked"); },
|
||||||
|
onError: (e: Error) => toast.error(e.message),
|
||||||
|
});
|
||||||
|
const del = useMutation({
|
||||||
|
mutationFn: async (id: string) => { const { error } = await supabase.from("parent_students").delete().eq("id", id); if (error) throw error; },
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: ["admin-links"] }),
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4 mt-4">
|
||||||
|
<div className="bg-card border rounded-lg divide-y">
|
||||||
|
{(links ?? []).map((l) => {
|
||||||
|
const p = l.profiles as { full_name?: string; email?: string } | null;
|
||||||
|
const s = l.students as { first_name: string; last_name: string } | null;
|
||||||
|
return (
|
||||||
|
<div key={l.id} className="p-3 flex justify-between items-center text-sm">
|
||||||
|
<span>{p?.full_name || p?.email} → {s?.first_name} {s?.last_name}</span>
|
||||||
|
<Button size="icon" variant="ghost" onClick={() => del.mutate(l.id)}><Trash2 className="h-4 w-4" /></Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{links?.length === 0 && <div className="p-4 text-sm text-muted-foreground">No links yet.</div>}
|
||||||
|
</div>
|
||||||
|
<div className="bg-card border rounded-lg p-4 space-y-3">
|
||||||
|
<div className="font-medium text-sm">Link parent to student</div>
|
||||||
|
<div className="grid grid-cols-3 gap-2">
|
||||||
|
<Select value={parentId} onValueChange={setParentId}>
|
||||||
|
<SelectTrigger><SelectValue placeholder="Parent" /></SelectTrigger>
|
||||||
|
<SelectContent>{(parents ?? []).map((p) => <SelectItem key={p.id} value={p.id}>{p.full_name || p.email}</SelectItem>)}</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<Select value={studentId} onValueChange={setStudentId}>
|
||||||
|
<SelectTrigger><SelectValue placeholder="Student" /></SelectTrigger>
|
||||||
|
<SelectContent>{(students ?? []).map((s) => <SelectItem key={s.id} value={s.id}>{s.last_name}, {s.first_name}</SelectItem>)}</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<Button onClick={() => add.mutate()} disabled={!parentId || !studentId || add.isPending}><Plus className="h-4 w-4 mr-1" /> Link</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
import { createFileRoute } 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 { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
export const Route = createFileRoute("/_authenticated/attendance")({
|
||||||
|
head: () => ({ meta: [{ title: "Attendance — School Portal" }] }),
|
||||||
|
component: AttendancePage,
|
||||||
|
});
|
||||||
|
|
||||||
|
function AttendancePage() {
|
||||||
|
const { user, roles } = useAuth();
|
||||||
|
const isAdmin = roles.includes("admin");
|
||||||
|
const qc = useQueryClient();
|
||||||
|
const [date, setDate] = useState(new Date().toISOString().slice(0, 10));
|
||||||
|
const [classId, setClassId] = useState<string>("");
|
||||||
|
|
||||||
|
const { data: classes } = useQuery({
|
||||||
|
queryKey: ["my-classes", user?.id],
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: students } = useQuery({
|
||||||
|
queryKey: ["class-students", classId, date],
|
||||||
|
queryFn: async () => {
|
||||||
|
if (!classId) return [];
|
||||||
|
const { data } = await supabase.from("students").select("id, first_name, last_name, attendance(status, date, id)").eq("class_id", classId);
|
||||||
|
return data ?? [];
|
||||||
|
},
|
||||||
|
enabled: !!classId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const mark = useMutation({
|
||||||
|
mutationFn: async ({ studentId, status }: { studentId: string; status: string }) => {
|
||||||
|
const { error } = await supabase.from("attendance").upsert(
|
||||||
|
{ student_id: studentId, date, status: status as "present" | "absent" | "late" | "excused", recorded_by: user!.id },
|
||||||
|
{ onConflict: "student_id,date" }
|
||||||
|
);
|
||||||
|
if (error) throw error;
|
||||||
|
},
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: ["class-students", classId, date] }),
|
||||||
|
onError: (e: Error) => toast.error(e.message),
|
||||||
|
});
|
||||||
|
|
||||||
|
const getStatus = (s: { attendance: { date: string; status: string }[] }) =>
|
||||||
|
s.attendance.find((a) => a.date === date)?.status ?? "";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-8 max-w-4xl">
|
||||||
|
<h1 className="text-2xl font-semibold mb-1">Attendance</h1>
|
||||||
|
<p className="text-muted-foreground text-sm mb-6">Mark today's attendance for your class.</p>
|
||||||
|
|
||||||
|
<div className="flex gap-3 mb-6">
|
||||||
|
<div>
|
||||||
|
<Select value={classId} onValueChange={setClassId}>
|
||||||
|
<SelectTrigger className="w-60"><SelectValue placeholder="Choose class" /></SelectTrigger>
|
||||||
|
<SelectContent>{(classes ?? []).map((c) => <SelectItem key={c.id} value={c.id}>{c.name}</SelectItem>)}</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<Input type="date" className="w-44" value={date} onChange={(e) => setDate(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{classId && (
|
||||||
|
<div className="bg-card border rounded-lg divide-y">
|
||||||
|
{(students ?? []).map((s) => {
|
||||||
|
const status = getStatus(s as { attendance: { date: string; status: string }[] });
|
||||||
|
return (
|
||||||
|
<div key={s.id} className="flex items-center justify-between p-3">
|
||||||
|
<div className="font-medium">{s.last_name}, {s.first_name}</div>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
{["present", "absent", "late", "excused"].map((opt) => (
|
||||||
|
<Button key={opt} size="sm" variant={status === opt ? "default" : "outline"} onClick={() => mark.mutate({ studentId: s.id, status: opt })}>
|
||||||
|
{opt}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{students?.length === 0 && <div className="p-4 text-sm text-muted-foreground">No students in this class.</div>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!classId && <p className="text-sm text-muted-foreground">Select a class to begin.</p>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
import { createFileRoute } 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 { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
|
||||||
|
import { Plus, Trash2, CalendarDays } from "lucide-react";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
export const Route = createFileRoute("/_authenticated/calendar")({
|
||||||
|
head: () => ({ meta: [{ title: "Calendar — School Portal" }] }),
|
||||||
|
component: CalendarPage,
|
||||||
|
});
|
||||||
|
|
||||||
|
function CalendarPage() {
|
||||||
|
const { user, roles } = useAuth();
|
||||||
|
const isAdmin = roles.includes("admin");
|
||||||
|
const qc = useQueryClient();
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [form, setForm] = useState({ title: "", date: "", end_date: "", description: "" });
|
||||||
|
|
||||||
|
const { data: events } = useQuery({
|
||||||
|
queryKey: ["calendar"],
|
||||||
|
queryFn: async () => (await supabase.from("calendar_events").select("*").order("date")).data ?? [],
|
||||||
|
});
|
||||||
|
|
||||||
|
const add = useMutation({
|
||||||
|
mutationFn: async () => {
|
||||||
|
const { error } = await supabase.from("calendar_events").insert({
|
||||||
|
title: form.title, date: form.date, end_date: form.end_date || null, description: form.description || null, created_by: user!.id,
|
||||||
|
});
|
||||||
|
if (error) throw error;
|
||||||
|
},
|
||||||
|
onSuccess: () => { setOpen(false); setForm({ title: "", date: "", end_date: "", description: "" }); qc.invalidateQueries({ queryKey: ["calendar"] }); toast.success("Event added"); },
|
||||||
|
onError: (e: Error) => toast.error(e.message),
|
||||||
|
});
|
||||||
|
|
||||||
|
const del = useMutation({
|
||||||
|
mutationFn: async (id: string) => { const { error } = await supabase.from("calendar_events").delete().eq("id", id); if (error) throw error; },
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: ["calendar"] }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const today = new Date().toISOString().slice(0, 10);
|
||||||
|
const upcoming = (events ?? []).filter((e) => e.date >= today);
|
||||||
|
const past = (events ?? []).filter((e) => e.date < today);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-8 max-w-3xl">
|
||||||
|
<div className="flex justify-between items-center mb-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-semibold">School calendar</h1>
|
||||||
|
<p className="text-muted-foreground text-sm">Year at a glance.</p>
|
||||||
|
</div>
|
||||||
|
{isAdmin && (
|
||||||
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
|
<DialogTrigger asChild><Button><Plus className="h-4 w-4 mr-1" /> Add event</Button></DialogTrigger>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader><DialogTitle>New calendar event</DialogTitle></DialogHeader>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div><Label>Title</Label><Input value={form.title} onChange={(e) => setForm({ ...form, title: e.target.value })} /></div>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div><Label>Date</Label><Input type="date" value={form.date} onChange={(e) => setForm({ ...form, date: e.target.value })} /></div>
|
||||||
|
<div><Label>End date (optional)</Label><Input type="date" value={form.end_date} onChange={(e) => setForm({ ...form, end_date: e.target.value })} /></div>
|
||||||
|
</div>
|
||||||
|
<div><Label>Description</Label><Textarea value={form.description} onChange={(e) => setForm({ ...form, description: e.target.value })} /></div>
|
||||||
|
<Button className="w-full" disabled={!form.title || !form.date || add.isPending} onClick={() => add.mutate()}>Add</Button>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2 className="font-medium text-sm text-muted-foreground mb-2">Upcoming</h2>
|
||||||
|
<div className="bg-card border rounded-lg divide-y mb-6">
|
||||||
|
{upcoming.map((e) => (
|
||||||
|
<div key={e.id} className="p-3 flex justify-between items-start">
|
||||||
|
<div>
|
||||||
|
<div className="font-medium">{e.title}</div>
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
{new Date(e.date).toLocaleDateString(undefined, { weekday: "short", month: "short", day: "numeric" })}
|
||||||
|
{e.end_date && ` – ${new Date(e.end_date).toLocaleDateString()}`}
|
||||||
|
</div>
|
||||||
|
{e.description && <div className="text-sm mt-1">{e.description}</div>}
|
||||||
|
</div>
|
||||||
|
{isAdmin && <Button size="icon" variant="ghost" onClick={() => del.mutate(e.id)}><Trash2 className="h-4 w-4" /></Button>}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{upcoming.length === 0 && <div className="p-6 text-sm text-muted-foreground text-center"><CalendarDays className="h-5 w-5 mx-auto mb-2" /> No upcoming events</div>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{past.length > 0 && (
|
||||||
|
<>
|
||||||
|
<h2 className="font-medium text-sm text-muted-foreground mb-2">Past</h2>
|
||||||
|
<div className="bg-card border rounded-lg divide-y">
|
||||||
|
{past.map((e) => (
|
||||||
|
<div key={e.id} className="p-3 text-sm text-muted-foreground flex justify-between">
|
||||||
|
<span>{e.title}</span><span>{new Date(e.date).toLocaleDateString()}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,195 @@
|
|||||||
|
import { createFileRoute } 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 { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
|
||||||
|
import { Plus, Trash2, FileText } from "lucide-react";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
type Field = { label: string; type: "text" | "textarea" | "yesno" | "date" };
|
||||||
|
|
||||||
|
export const Route = createFileRoute("/_authenticated/forms")({
|
||||||
|
head: () => ({ meta: [{ title: "Forms — School Portal" }] }),
|
||||||
|
component: FormsPage,
|
||||||
|
});
|
||||||
|
|
||||||
|
function FormsPage() {
|
||||||
|
const { user, roles } = useAuth();
|
||||||
|
const isAdmin = roles.includes("admin");
|
||||||
|
const qc = useQueryClient();
|
||||||
|
const [activeForm, setActiveForm] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const { data: forms } = useQuery({
|
||||||
|
queryKey: ["forms"],
|
||||||
|
queryFn: async () => (await supabase.from("forms").select("*").eq("active", true).order("created_at", { ascending: false })).data ?? [],
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-8 max-w-5xl">
|
||||||
|
<div className="flex justify-between items-center mb-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-semibold">Forms</h1>
|
||||||
|
<p className="text-muted-foreground text-sm">Push a form to families; they complete it in the portal.</p>
|
||||||
|
</div>
|
||||||
|
{isAdmin && <FormBuilder onCreated={() => qc.invalidateQueries({ queryKey: ["forms"] })} />}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
{(forms ?? []).map((f) => (
|
||||||
|
<button key={f.id} onClick={() => setActiveForm(f.id)} className="text-left bg-card border rounded-lg p-4 hover:bg-muted/30">
|
||||||
|
<FileText className="h-5 w-5 text-primary mb-2" />
|
||||||
|
<div className="font-medium">{f.title}</div>
|
||||||
|
{f.description && <div className="text-sm text-muted-foreground mt-1">{f.description}</div>}
|
||||||
|
<div className="text-xs text-muted-foreground mt-2">{(f.schema_json as Field[])?.length ?? 0} field(s)</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
{forms?.length === 0 && <div className="text-sm text-muted-foreground">No active forms.</div>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{activeForm && <FormFillDialog formId={activeForm} onClose={() => setActiveForm(null)} isAdmin={isAdmin} />}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function FormBuilder({ onCreated }: { onCreated: () => void }) {
|
||||||
|
const { user } = useAuth();
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [title, setTitle] = useState("");
|
||||||
|
const [description, setDescription] = useState("");
|
||||||
|
const [fields, setFields] = useState<Field[]>([{ label: "Full name", type: "text" }]);
|
||||||
|
|
||||||
|
const create = useMutation({
|
||||||
|
mutationFn: async () => {
|
||||||
|
const { error } = await supabase.from("forms").insert({ title, description, schema_json: fields, created_by: user!.id });
|
||||||
|
if (error) throw error;
|
||||||
|
},
|
||||||
|
onSuccess: () => { setOpen(false); setTitle(""); setDescription(""); setFields([{ label: "Full name", type: "text" }]); onCreated(); toast.success("Form created"); },
|
||||||
|
onError: (e: Error) => toast.error(e.message),
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
|
<DialogTrigger asChild><Button><Plus className="h-4 w-4 mr-1" /> New form</Button></DialogTrigger>
|
||||||
|
<DialogContent className="max-w-xl">
|
||||||
|
<DialogHeader><DialogTitle>Create form</DialogTitle></DialogHeader>
|
||||||
|
<div className="space-y-3 max-h-[70vh] overflow-auto">
|
||||||
|
<div><Label>Title</Label><Input value={title} onChange={(e) => setTitle(e.target.value)} /></div>
|
||||||
|
<div><Label>Description</Label><Textarea value={description} onChange={(e) => setDescription(e.target.value)} /></div>
|
||||||
|
<div>
|
||||||
|
<Label>Fields</Label>
|
||||||
|
<div className="space-y-2 mt-1">
|
||||||
|
{fields.map((f, i) => (
|
||||||
|
<div key={i} className="flex gap-2">
|
||||||
|
<Input className="flex-1" value={f.label} onChange={(e) => { const c = [...fields]; c[i] = { ...c[i], label: e.target.value }; setFields(c); }} placeholder="Field label" />
|
||||||
|
<select className="border rounded-md px-2 text-sm" value={f.type} onChange={(e) => { const c = [...fields]; c[i] = { ...c[i], type: e.target.value as Field["type"] }; setFields(c); }}>
|
||||||
|
<option value="text">Short text</option><option value="textarea">Long text</option><option value="yesno">Yes / No</option><option value="date">Date</option>
|
||||||
|
</select>
|
||||||
|
<Button size="icon" variant="ghost" onClick={() => setFields(fields.filter((_, j) => j !== i))}><Trash2 className="h-4 w-4" /></Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<Button variant="outline" size="sm" onClick={() => setFields([...fields, { label: "", type: "text" }])}><Plus className="h-4 w-4 mr-1" /> Add field</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button className="w-full" disabled={!title || create.isPending} onClick={() => create.mutate()}>Create form</Button>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function FormFillDialog({ formId, onClose, isAdmin }: { formId: string; onClose: () => void; isAdmin: boolean }) {
|
||||||
|
const { user, roles } = useAuth();
|
||||||
|
const isParent = roles.includes("parent");
|
||||||
|
const qc = useQueryClient();
|
||||||
|
const [values, setValues] = useState<Record<string, string>>({});
|
||||||
|
const [studentId, setStudentId] = useState<string>("");
|
||||||
|
|
||||||
|
const { data: form } = useQuery({
|
||||||
|
queryKey: ["form", formId],
|
||||||
|
queryFn: async () => (await supabase.from("forms").select("*").eq("id", formId).single()).data,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: myStudents } = useQuery({
|
||||||
|
queryKey: ["my-students", user?.id],
|
||||||
|
queryFn: async () => {
|
||||||
|
const { data } = await supabase.from("parent_students").select("student_id, students(id, first_name, last_name)").eq("parent_id", user!.id);
|
||||||
|
return (data ?? []).map((d) => d.students as { id: string; first_name: string; last_name: string });
|
||||||
|
},
|
||||||
|
enabled: !!user,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: responses } = useQuery({
|
||||||
|
queryKey: ["form-responses", formId],
|
||||||
|
queryFn: async () => (await supabase.from("form_responses").select("*, students(first_name, last_name), profiles!form_responses_submitted_by_fkey(full_name)").eq("form_id", formId).order("submitted_at", { ascending: false })).data ?? [],
|
||||||
|
enabled: isAdmin,
|
||||||
|
});
|
||||||
|
|
||||||
|
const submit = useMutation({
|
||||||
|
mutationFn: async () => {
|
||||||
|
const { error } = await supabase.from("form_responses").insert({ form_id: formId, student_id: studentId || null, submitted_by: user!.id, data_json: values });
|
||||||
|
if (error) throw error;
|
||||||
|
},
|
||||||
|
onSuccess: () => { toast.success("Submitted"); qc.invalidateQueries({ queryKey: ["form-responses", formId] }); onClose(); },
|
||||||
|
onError: (e: Error) => toast.error(e.message),
|
||||||
|
});
|
||||||
|
|
||||||
|
const fields = (form?.schema_json ?? []) as Field[];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open onOpenChange={(o) => !o && onClose()}>
|
||||||
|
<DialogContent className="max-w-2xl max-h-[85vh] overflow-auto">
|
||||||
|
<DialogHeader><DialogTitle>{form?.title}</DialogTitle></DialogHeader>
|
||||||
|
{form?.description && <p className="text-sm text-muted-foreground">{form.description}</p>}
|
||||||
|
|
||||||
|
<div className="space-y-3 mt-2">
|
||||||
|
{isParent && myStudents && myStudents.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<Label>For student</Label>
|
||||||
|
<select className="w-full border rounded-md p-2 text-sm" value={studentId} onChange={(e) => setStudentId(e.target.value)}>
|
||||||
|
<option value="">Select…</option>
|
||||||
|
{myStudents.map((s) => <option key={s.id} value={s.id}>{s.first_name} {s.last_name}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{fields.map((f, i) => (
|
||||||
|
<div key={i}>
|
||||||
|
<Label>{f.label}</Label>
|
||||||
|
{f.type === "textarea" ? <Textarea value={values[f.label] ?? ""} onChange={(e) => setValues({ ...values, [f.label]: e.target.value })} /> :
|
||||||
|
f.type === "yesno" ? (
|
||||||
|
<select className="w-full border rounded-md p-2 text-sm" value={values[f.label] ?? ""} onChange={(e) => setValues({ ...values, [f.label]: e.target.value })}>
|
||||||
|
<option value="">—</option><option value="yes">Yes</option><option value="no">No</option>
|
||||||
|
</select>
|
||||||
|
) :
|
||||||
|
<Input type={f.type === "date" ? "date" : "text"} value={values[f.label] ?? ""} onChange={(e) => setValues({ ...values, [f.label]: e.target.value })} />}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<Button className="w-full" onClick={() => submit.mutate()} disabled={submit.isPending}>Submit</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isAdmin && (
|
||||||
|
<div className="mt-6 border-t pt-4">
|
||||||
|
<h3 className="font-medium text-sm mb-2">Responses ({responses?.length ?? 0})</h3>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{(responses ?? []).map((r) => (
|
||||||
|
<div key={r.id} className="border rounded-md p-3 text-sm">
|
||||||
|
<div className="font-medium">
|
||||||
|
{(r.students as { first_name: string; last_name: string } | null)?.first_name ?? "—"} {(r.students as { last_name?: string } | null)?.last_name ?? ""}
|
||||||
|
<span className="text-muted-foreground font-normal ml-2">by {(r.profiles as { full_name?: string } | null)?.full_name} · {new Date(r.submitted_at).toLocaleDateString()}</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 text-xs space-y-0.5">
|
||||||
|
{Object.entries(r.data_json as Record<string, string>).map(([k, v]) => <div key={k}><span className="text-muted-foreground">{k}:</span> {v}</div>)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { createFileRoute, Link } from "@tanstack/react-router";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { supabase } from "@/integrations/supabase/client";
|
||||||
|
import { ChevronRight } from "lucide-react";
|
||||||
|
|
||||||
|
export const Route = createFileRoute("/_authenticated/ledger")({
|
||||||
|
head: () => ({ meta: [{ title: "Tuition — School Portal" }] }),
|
||||||
|
component: LedgerListPage,
|
||||||
|
});
|
||||||
|
|
||||||
|
function LedgerListPage() {
|
||||||
|
const { data: students } = useQuery({
|
||||||
|
queryKey: ["students-with-balance"],
|
||||||
|
queryFn: async () => {
|
||||||
|
const { data: studs } = await supabase.from("students").select("id, first_name, last_name");
|
||||||
|
const { data: entries } = await supabase.from("ledger_entries").select("student_id, amount_cents, kind");
|
||||||
|
const balances: Record<string, number> = {};
|
||||||
|
(entries ?? []).forEach((e) => {
|
||||||
|
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 (
|
||||||
|
<div className="p-8 max-w-4xl">
|
||||||
|
<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>
|
||||||
|
<div className="bg-card border rounded-lg divide-y">
|
||||||
|
{(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">
|
||||||
|
<span className="font-medium">{s.last_name}, {s.first_name}</span>
|
||||||
|
<span className="flex items-center gap-3">
|
||||||
|
<span className={`text-sm font-medium ${s.balance > 0 ? "text-destructive" : "text-green-700"}`}>${(s.balance / 100).toFixed(2)}</span>
|
||||||
|
<ChevronRight className="h-4 w-4 text-muted-foreground" />
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
{students?.length === 0 && <div className="p-6 text-center text-sm text-muted-foreground">No students.</div>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
import { createFileRoute } 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 { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
|
||||||
|
import { Plus, MessageSquare } from "lucide-react";
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
export const Route = createFileRoute("/_authenticated/messages")({
|
||||||
|
head: () => ({ meta: [{ title: "Messages — School Portal" }] }),
|
||||||
|
component: MessagesPage,
|
||||||
|
});
|
||||||
|
|
||||||
|
function MessagesPage() {
|
||||||
|
const { user, roles } = useAuth();
|
||||||
|
const isStaff = roles.includes("admin") || roles.includes("teacher");
|
||||||
|
const qc = useQueryClient();
|
||||||
|
const [activeThread, setActiveThread] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const { data: threads } = useQuery({
|
||||||
|
queryKey: ["threads", user?.id],
|
||||||
|
queryFn: async () => {
|
||||||
|
const { data: parts } = await supabase.from("thread_participants").select("thread_id").eq("user_id", user!.id);
|
||||||
|
const ids = (parts ?? []).map((p) => p.thread_id);
|
||||||
|
if (ids.length === 0) return [];
|
||||||
|
const { data } = await supabase.from("message_threads").select("*").in("id", ids).order("updated_at", { ascending: false });
|
||||||
|
return data ?? [];
|
||||||
|
},
|
||||||
|
enabled: !!user,
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!user) return;
|
||||||
|
const ch = supabase.channel("messages-realtime").on("postgres_changes", { event: "*", schema: "public", table: "messages" }, () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ["thread-messages"] });
|
||||||
|
}).subscribe();
|
||||||
|
return () => { supabase.removeChannel(ch); };
|
||||||
|
}, [user, qc]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-8 max-w-6xl">
|
||||||
|
<div className="flex justify-between items-center mb-6">
|
||||||
|
<h1 className="text-2xl font-semibold">Messages</h1>
|
||||||
|
<NewThreadDialog onCreated={(id) => setActiveThread(id)} isStaff={isStaff} />
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-3 gap-4 h-[70vh]">
|
||||||
|
<div className="bg-card border rounded-lg overflow-auto divide-y">
|
||||||
|
{(threads ?? []).map((t) => (
|
||||||
|
<button key={t.id} onClick={() => setActiveThread(t.id)} className={`w-full text-left p-3 hover:bg-muted/50 ${activeThread === t.id ? "bg-muted" : ""}`}>
|
||||||
|
<div className="font-medium text-sm">{t.subject ?? "(no subject)"}</div>
|
||||||
|
<div className="text-xs text-muted-foreground">{new Date(t.updated_at).toLocaleDateString()}</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
{threads?.length === 0 && <div className="p-6 text-sm text-muted-foreground text-center"><MessageSquare className="h-6 w-6 mx-auto mb-2" /> No threads yet</div>}
|
||||||
|
</div>
|
||||||
|
<div className="col-span-2 bg-card border rounded-lg flex flex-col">
|
||||||
|
{activeThread ? <ThreadView threadId={activeThread} /> : <div className="flex-1 flex items-center justify-center text-sm text-muted-foreground">Select a thread</div>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ThreadView({ threadId }: { threadId: string }) {
|
||||||
|
const { user } = useAuth();
|
||||||
|
const qc = useQueryClient();
|
||||||
|
const [text, setText] = useState("");
|
||||||
|
const endRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const { data: messages } = useQuery({
|
||||||
|
queryKey: ["thread-messages", threadId],
|
||||||
|
queryFn: async () => (await supabase.from("messages").select("*").eq("thread_id", threadId).order("created_at")).data ?? [],
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => { endRef.current?.scrollIntoView({ behavior: "smooth" }); }, [messages]);
|
||||||
|
|
||||||
|
const send = useMutation({
|
||||||
|
mutationFn: async () => {
|
||||||
|
if (!text.trim()) return;
|
||||||
|
const { error } = await supabase.from("messages").insert({ thread_id: threadId, sender_id: user!.id, body: text.trim() });
|
||||||
|
if (error) throw error;
|
||||||
|
await supabase.from("message_threads").update({ updated_at: new Date().toISOString() }).eq("id", threadId);
|
||||||
|
},
|
||||||
|
onSuccess: () => { setText(""); qc.invalidateQueries({ queryKey: ["thread-messages", threadId] }); },
|
||||||
|
onError: (e: Error) => toast.error(e.message),
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="flex-1 overflow-auto p-4 space-y-2">
|
||||||
|
{(messages ?? []).map((m) => (
|
||||||
|
<div key={m.id} className={`flex ${m.sender_id === user?.id ? "justify-end" : ""}`}>
|
||||||
|
<div className={`px-3 py-2 rounded-lg max-w-md text-sm ${m.sender_id === user?.id ? "bg-primary text-primary-foreground" : "bg-muted"}`}>
|
||||||
|
{m.body}
|
||||||
|
<div className="text-[10px] opacity-70 mt-1">{new Date(m.created_at).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<div ref={endRef} />
|
||||||
|
</div>
|
||||||
|
<form className="border-t p-3 flex gap-2" onSubmit={(e) => { e.preventDefault(); send.mutate(); }}>
|
||||||
|
<Input value={text} onChange={(e) => setText(e.target.value)} placeholder="Type a message…" />
|
||||||
|
<Button type="submit" disabled={!text.trim() || send.isPending}>Send</Button>
|
||||||
|
</form>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function NewThreadDialog({ onCreated, isStaff }: { onCreated: (id: string) => void; isStaff: boolean }) {
|
||||||
|
const { user } = useAuth();
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [subject, setSubject] = useState("");
|
||||||
|
const [body, setBody] = useState("");
|
||||||
|
const [recipientId, setRecipientId] = useState("");
|
||||||
|
|
||||||
|
const { data: recipients } = useQuery({
|
||||||
|
queryKey: ["thread-recipients", isStaff],
|
||||||
|
queryFn: async () => (await supabase.from("profiles").select("id, full_name, email").neq("id", user!.id).order("full_name")).data ?? [],
|
||||||
|
enabled: open && !!user,
|
||||||
|
});
|
||||||
|
|
||||||
|
const create = useMutation({
|
||||||
|
mutationFn: async () => {
|
||||||
|
if (!recipientId) throw new Error("Pick a recipient");
|
||||||
|
const { data: thread, error } = await supabase.from("message_threads").insert({ subject, created_by: user!.id }).select().single();
|
||||||
|
if (error) throw error;
|
||||||
|
const { error: pErr } = await supabase.from("thread_participants").insert([
|
||||||
|
{ thread_id: thread.id, user_id: user!.id },
|
||||||
|
{ thread_id: thread.id, user_id: recipientId },
|
||||||
|
]);
|
||||||
|
if (pErr) throw pErr;
|
||||||
|
if (body.trim()) {
|
||||||
|
await supabase.from("messages").insert({ thread_id: thread.id, sender_id: user!.id, body: body.trim() });
|
||||||
|
}
|
||||||
|
return thread.id;
|
||||||
|
},
|
||||||
|
onSuccess: (id) => { setOpen(false); setSubject(""); setBody(""); setRecipientId(""); onCreated(id); toast.success("Thread started"); },
|
||||||
|
onError: (e: Error) => toast.error(e.message),
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
|
<DialogTrigger asChild><Button><Plus className="h-4 w-4 mr-1" /> New message</Button></DialogTrigger>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader><DialogTitle>New conversation</DialogTitle></DialogHeader>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<Label>Recipient</Label>
|
||||||
|
<select className="w-full border rounded-md p-2 text-sm" value={recipientId} onChange={(e) => setRecipientId(e.target.value)}>
|
||||||
|
<option value="">Choose…</option>
|
||||||
|
{(recipients ?? []).map((r) => <option key={r.id} value={r.id}>{r.full_name || r.email}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div><Label>Subject</Label><Input value={subject} onChange={(e) => setSubject(e.target.value)} /></div>
|
||||||
|
<div><Label>Message</Label><Textarea value={body} onChange={(e) => setBody(e.target.value)} rows={4} /></div>
|
||||||
|
<Button className="w-full" onClick={() => create.mutate()} disabled={create.isPending}>Start conversation</Button>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
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 { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
|
||||||
|
import { Plus, ChevronRight } from "lucide-react";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
export const Route = createFileRoute("/_authenticated/students")({
|
||||||
|
head: () => ({ meta: [{ title: "Students — School Portal" }] }),
|
||||||
|
component: StudentsPage,
|
||||||
|
});
|
||||||
|
|
||||||
|
function StudentsPage() {
|
||||||
|
const { roles } = useAuth();
|
||||||
|
const isAdmin = roles.includes("admin");
|
||||||
|
const qc = useQueryClient();
|
||||||
|
|
||||||
|
const { data: students } = useQuery({
|
||||||
|
queryKey: ["students"],
|
||||||
|
queryFn: async () => {
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from("students")
|
||||||
|
.select("id, first_name, last_name, dob, allergies, photo_release, class_id, classes(name)")
|
||||||
|
.order("last_name");
|
||||||
|
if (error) throw error;
|
||||||
|
return data ?? [];
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: classes } = useQuery({
|
||||||
|
queryKey: ["classes"],
|
||||||
|
queryFn: async () => (await supabase.from("classes").select("*").order("name")).data ?? [],
|
||||||
|
});
|
||||||
|
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [form, setForm] = useState({ first_name: "", last_name: "", dob: "", class_id: "", allergies: "" });
|
||||||
|
|
||||||
|
const createStudent = useMutation({
|
||||||
|
mutationFn: async () => {
|
||||||
|
const { error } = await supabase.from("students").insert({
|
||||||
|
first_name: form.first_name,
|
||||||
|
last_name: form.last_name,
|
||||||
|
dob: form.dob || null,
|
||||||
|
class_id: form.class_id || null,
|
||||||
|
allergies: form.allergies || null,
|
||||||
|
});
|
||||||
|
if (error) throw error;
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success("Student added");
|
||||||
|
setOpen(false);
|
||||||
|
setForm({ first_name: "", last_name: "", dob: "", class_id: "", allergies: "" });
|
||||||
|
qc.invalidateQueries({ queryKey: ["students"] });
|
||||||
|
},
|
||||||
|
onError: (e: Error) => toast.error(e.message),
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-8 max-w-6xl">
|
||||||
|
<div className="flex justify-between items-center mb-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-semibold">Students</h1>
|
||||||
|
<p className="text-muted-foreground text-sm">{students?.length ?? 0} students visible to you</p>
|
||||||
|
</div>
|
||||||
|
{isAdmin && (
|
||||||
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
|
<DialogTrigger asChild><Button><Plus className="h-4 w-4 mr-1" /> Add student</Button></DialogTrigger>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader><DialogTitle>New student</DialogTitle></DialogHeader>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div><Label>First name</Label><Input value={form.first_name} onChange={(e) => setForm({ ...form, first_name: e.target.value })} /></div>
|
||||||
|
<div><Label>Last name</Label><Input value={form.last_name} onChange={(e) => setForm({ ...form, last_name: e.target.value })} /></div>
|
||||||
|
</div>
|
||||||
|
<div><Label>Date of birth</Label><Input type="date" value={form.dob} onChange={(e) => setForm({ ...form, dob: e.target.value })} /></div>
|
||||||
|
<div>
|
||||||
|
<Label>Class</Label>
|
||||||
|
<Select value={form.class_id} onValueChange={(v) => setForm({ ...form, class_id: v })}>
|
||||||
|
<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>Allergies</Label><Input value={form.allergies} onChange={(e) => setForm({ ...form, allergies: e.target.value })} placeholder="None" /></div>
|
||||||
|
<Button className="w-full" disabled={!form.first_name || !form.last_name || createStudent.isPending} onClick={() => createStudent.mutate()}>Create student</Button>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-card border rounded-lg divide-y">
|
||||||
|
{(students ?? []).map((s) => (
|
||||||
|
<Link to="/students/$id" params={{ id: s.id }} key={s.id} className="flex items-center justify-between px-4 py-3 hover:bg-muted/50">
|
||||||
|
<div>
|
||||||
|
<div className="font-medium">{s.last_name}, {s.first_name}</div>
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
{(s.classes as { name: string } | null)?.name ?? "No class"} · {s.allergies ? `Allergies: ${s.allergies}` : "No allergies"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ChevronRight className="h-4 w-4 text-muted-foreground" />
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
{students?.length === 0 && <div className="p-6 text-sm text-muted-foreground text-center">No students yet.</div>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user