Modified by www.SourceFiles.app

This commit is contained in:
2026-08-07 04:15:32 +00:00
parent 1ca9e038b8
commit 77f00e9e73
+284
View File
@@ -0,0 +1,284 @@
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, UserPlus, Copy } from "lucide-react";
import { useState } from "react";
import { toast } from "sonner";
import { createUserFn } from "@/lib/user-admin.functions";
import { genTempPassword } from "@/lib/temp-password";
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),
});
const [nu, setNu] = useState({ fullName: "", email: "", role: "teacher" as Role });
const [created, setCreated] = useState<{ email: string; password: string } | null>(null);
const addUser = useMutation({
mutationFn: async () => {
const password = genTempPassword();
const res = await createUserFn({ data: { fullName: nu.fullName, email: nu.email, password, role: nu.role } });
return { email: res.email, password };
},
onSuccess: (r) => {
setCreated(r);
setNu({ fullName: "", email: "", role: "teacher" });
qc.invalidateQueries({ queryKey: ["admin-users"] });
qc.invalidateQueries({ queryKey: ["teachers"] });
toast.success(`Created ${r.email}`);
},
onError: (e: Error) => toast.error(e.message),
});
return (
<div className="space-y-4 mt-4">
<div className="bg-card border rounded-lg p-4 space-y-3">
<div className="font-medium text-sm flex items-center gap-2"><UserPlus className="h-4 w-4" /> Add a user</div>
<div className="grid grid-cols-1 md:grid-cols-4 gap-2">
<Input placeholder="Full name" value={nu.fullName} onChange={(e) => setNu({ ...nu, fullName: e.target.value })} />
<Input type="email" placeholder="Email" value={nu.email} onChange={(e) => setNu({ ...nu, email: e.target.value })} />
<Select value={nu.role} onValueChange={(v) => setNu({ ...nu, role: v as Role })}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent><SelectItem value="teacher">Teacher</SelectItem><SelectItem value="parent">Parent</SelectItem><SelectItem value="admin">Admin</SelectItem></SelectContent>
</Select>
<Button onClick={() => addUser.mutate()} disabled={!nu.fullName || !nu.email || addUser.isPending}>{addUser.isPending ? "Creating…" : "Create account"}</Button>
</div>
<p className="text-xs text-muted-foreground">Creates a login with a temporary password (shown once). They can change it after signing in. No email is sent.</p>
{created && (
<div className="rounded-md border border-primary/30 bg-primary/5 p-3 text-sm">
<div className="font-medium">Account created — share these with {created.email}:</div>
<div className="mt-1 flex items-center gap-2 font-mono text-xs">
<span className="rounded bg-background border px-2 py-1">Email: {created.email}</span>
<span className="rounded bg-background border px-2 py-1">Password: {created.password}</span>
<Button size="icon" variant="ghost" onClick={() => { navigator.clipboard.writeText(`Email: ${created.email}\nPassword: ${created.password}`); toast.success("Copied"); }}><Copy className="h-4 w-4" /></Button>
</div>
</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">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>
</div>
);
}
function ClassesTab() {
const qc = useQueryClient();
const [name, setName] = useState("");
const [teacherId, setTeacherId] = useState("");
const { data: classes } = useQuery({
queryKey: ["admin-classes"],
queryFn: async () => {
const { data } = await supabase.from("classes").select("*").order("name");
const tids = Array.from(new Set((data ?? []).map((c) => c.teacher_id).filter(Boolean))) as string[];
const { data: profs } = tids.length ? await supabase.from("profiles").select("id, full_name").in("id", tids) : { data: [] };
const map = Object.fromEntries((profs ?? []).map((p) => [p.id, p.full_name]));
return (data ?? []).map((c) => ({ ...c, teacher_name: c.teacher_id ? map[c.teacher_id] : null }));
},
});
const { data: teachers } = useQuery({
queryKey: ["teachers"],
queryFn: async () => {
const { data: r } = await supabase.from("user_roles").select("user_id").eq("role", "teacher");
const ids = (r ?? []).map((x) => x.user_id);
if (ids.length === 0) return [];
const { data } = await supabase.from("profiles").select("id, full_name, email").in("id", ids);
return data ?? [];
},
});
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.teacher_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 () => {
const { data } = await supabase.from("parent_students").select("id, parent_id, students(first_name, last_name)");
const ids = (data ?? []).map((d) => d.parent_id);
const { data: profs } = ids.length ? await supabase.from("profiles").select("id, full_name, email").in("id", ids) : { data: [] };
const map = Object.fromEntries((profs ?? []).map((p) => [p.id, p]));
return (data ?? []).map((d) => ({ ...d, profile: map[d.parent_id] }));
},
});
const { data: parents } = useQuery({
queryKey: ["all-parents"],
queryFn: async () => {
const { data: r } = await supabase.from("user_roles").select("user_id").eq("role", "parent");
const ids = (r ?? []).map((x) => x.user_id);
if (ids.length === 0) return [];
const { data } = await supabase.from("profiles").select("id, full_name, email").in("id", ids);
return data ?? [];
},
});
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.profile as { full_name?: string; email?: string } | undefined;
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>
);
}