Modified by www.SourceFiles.app
This commit is contained in:
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user