diff --git a/src/routes/_authenticated/admin.tsx b/src/routes/_authenticated/admin.tsx new file mode 100644 index 0000000..693bbe1 --- /dev/null +++ b/src/routes/_authenticated/admin.tsx @@ -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

Admins only

; + } + return ( +
+

Admin

+

Manage users, roles, classes, and parent-student links.

+ + + Users & roles + Classes + Parent ↔ student + + + + + +
+ ); +} + +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 = {}; + (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 ( +
+ + + + {(data ?? []).map((u) => ( + + + + + + ))} + +
NameEmailRoles
{u.full_name || "—"}{u.email} +
+ {(["admin", "teacher", "parent"] as Role[]).map((r) => { + const on = u.roles.includes(r); + return ; + })} +
+
+
+ ); +} + +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 ( +
+
+ {(classes ?? []).map((c) => ( +
+
+
{c.name}
+
Teacher: {(c.profiles as { full_name?: string } | null)?.full_name ?? "unassigned"}
+
+ +
+ ))} + {classes?.length === 0 &&
No classes yet.
} +
+
+
New class
+
+ setName(e.target.value)} /> + + +
+
+
+ ); +} + +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 ( +
+
+ {(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 ( +
+ {p?.full_name || p?.email} → {s?.first_name} {s?.last_name} + +
+ ); + })} + {links?.length === 0 &&
No links yet.
} +
+
+
Link parent to student
+
+ + + +
+
+
+ ); +} diff --git a/src/routes/_authenticated/attendance.tsx b/src/routes/_authenticated/attendance.tsx new file mode 100644 index 0000000..528c8cc --- /dev/null +++ b/src/routes/_authenticated/attendance.tsx @@ -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(""); + + 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 ( +
+

Attendance

+

Mark today's attendance for your class.

+ +
+
+ +
+ setDate(e.target.value)} /> +
+ + {classId && ( +
+ {(students ?? []).map((s) => { + const status = getStatus(s as { attendance: { date: string; status: string }[] }); + return ( +
+
{s.last_name}, {s.first_name}
+
+ {["present", "absent", "late", "excused"].map((opt) => ( + + ))} +
+
+ ); + })} + {students?.length === 0 &&
No students in this class.
} +
+ )} + {!classId &&

Select a class to begin.

} +
+ ); +} diff --git a/src/routes/_authenticated/calendar.tsx b/src/routes/_authenticated/calendar.tsx new file mode 100644 index 0000000..1809c27 --- /dev/null +++ b/src/routes/_authenticated/calendar.tsx @@ -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 ( +
+
+
+

School calendar

+

Year at a glance.

+
+ {isAdmin && ( + + + + New calendar event +
+
setForm({ ...form, title: e.target.value })} />
+
+
setForm({ ...form, date: e.target.value })} />
+
setForm({ ...form, end_date: e.target.value })} />
+
+