Changes
Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com>
This commit is contained in:
co-authored by
renee-png
parent
d7e7de54d3
commit
a2a4e6c811
@@ -0,0 +1,56 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import type { User } from "@supabase/supabase-js";
|
||||||
|
import { supabase } from "@/integrations/supabase/client";
|
||||||
|
|
||||||
|
export type Role = "admin" | "teacher" | "parent";
|
||||||
|
|
||||||
|
export interface AuthState {
|
||||||
|
user: User | null;
|
||||||
|
roles: Role[];
|
||||||
|
loading: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAuth(): AuthState {
|
||||||
|
const [user, setUser] = useState<User | null>(null);
|
||||||
|
const [roles, setRoles] = useState<Role[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let mounted = true;
|
||||||
|
|
||||||
|
const loadRoles = async (uid: string) => {
|
||||||
|
const { data } = await supabase.from("user_roles").select("role").eq("user_id", uid);
|
||||||
|
if (mounted) setRoles((data ?? []).map((r) => r.role as Role));
|
||||||
|
};
|
||||||
|
|
||||||
|
supabase.auth.getSession().then(({ data }) => {
|
||||||
|
if (!mounted) return;
|
||||||
|
setUser(data.session?.user ?? null);
|
||||||
|
if (data.session?.user) loadRoles(data.session.user.id).finally(() => mounted && setLoading(false));
|
||||||
|
else setLoading(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: sub } = supabase.auth.onAuthStateChange((_e, session) => {
|
||||||
|
setUser(session?.user ?? null);
|
||||||
|
if (session?.user) {
|
||||||
|
setTimeout(() => loadRoles(session.user.id), 0);
|
||||||
|
} else {
|
||||||
|
setRoles([]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
mounted = false;
|
||||||
|
sub.subscription.unsubscribe();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return { user, roles, loading };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function highestRole(roles: Role[]): Role | null {
|
||||||
|
if (roles.includes("admin")) return "admin";
|
||||||
|
if (roles.includes("teacher")) return "teacher";
|
||||||
|
if (roles.includes("parent")) return "parent";
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
|
import { useAuth, highestRole } from "@/hooks/use-auth";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { supabase } from "@/integrations/supabase/client";
|
||||||
|
import { Users, ClipboardCheck, Receipt, CalendarDays } from "lucide-react";
|
||||||
|
|
||||||
|
export const Route = createFileRoute("/_authenticated/dashboard")({
|
||||||
|
head: () => ({ meta: [{ title: "Dashboard — School Portal" }] }),
|
||||||
|
component: Dashboard,
|
||||||
|
});
|
||||||
|
|
||||||
|
function Dashboard() {
|
||||||
|
const { user, roles } = useAuth();
|
||||||
|
const role = highestRole(roles);
|
||||||
|
|
||||||
|
const { data: studentCount } = useQuery({
|
||||||
|
queryKey: ["students-count"],
|
||||||
|
queryFn: async () => {
|
||||||
|
const { count } = await supabase.from("students").select("*", { count: "exact", head: true });
|
||||||
|
return count ?? 0;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: todayAttendance } = useQuery({
|
||||||
|
queryKey: ["attendance-today"],
|
||||||
|
queryFn: async () => {
|
||||||
|
const today = new Date().toISOString().slice(0, 10);
|
||||||
|
const { count } = await supabase.from("attendance").select("*", { count: "exact", head: true }).eq("date", today);
|
||||||
|
return count ?? 0;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: upcoming } = useQuery({
|
||||||
|
queryKey: ["upcoming-events"],
|
||||||
|
queryFn: async () => {
|
||||||
|
const today = new Date().toISOString().slice(0, 10);
|
||||||
|
const { data } = await supabase.from("calendar_events").select("*").gte("date", today).order("date").limit(5);
|
||||||
|
return data ?? [];
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-8 max-w-6xl">
|
||||||
|
<h1 className="text-2xl font-semibold">Welcome back</h1>
|
||||||
|
<p className="text-muted-foreground">Signed in as <span className="capitalize">{role}</span></p>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mt-6">
|
||||||
|
<Stat icon={Users} label="Students" value={studentCount ?? "—"} />
|
||||||
|
<Stat icon={ClipboardCheck} label="Attendance recorded today" value={todayAttendance ?? "—"} />
|
||||||
|
<Stat icon={CalendarDays} label="Upcoming events" value={upcoming?.length ?? "—"} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-8 bg-card border rounded-lg p-6">
|
||||||
|
<h2 className="font-semibold mb-3 flex items-center gap-2"><CalendarDays className="h-4 w-4" /> Upcoming on the calendar</h2>
|
||||||
|
{upcoming && upcoming.length > 0 ? (
|
||||||
|
<ul className="space-y-2 text-sm">
|
||||||
|
{upcoming.map((e) => (
|
||||||
|
<li key={e.id} className="flex justify-between border-b last:border-0 py-2">
|
||||||
|
<span>{e.title}</span>
|
||||||
|
<span className="text-muted-foreground">{new Date(e.date).toLocaleDateString()}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-muted-foreground">No upcoming events.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Stat({ icon: Icon, label, value }: { icon: typeof Users; label: string; value: number | string }) {
|
||||||
|
return (
|
||||||
|
<div className="bg-card border rounded-lg p-6">
|
||||||
|
<Icon className="h-5 w-5 text-primary mb-2" />
|
||||||
|
<div className="text-3xl font-semibold">{value}</div>
|
||||||
|
<div className="text-sm text-muted-foreground">{label}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import { createFileRoute, Link, Outlet, useNavigate, useRouterState } from "@tanstack/react-router";
|
||||||
|
import { useAuth, highestRole } from "@/hooks/use-auth";
|
||||||
|
import { supabase } from "@/integrations/supabase/client";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
GraduationCap, LayoutDashboard, Users, ClipboardCheck, Receipt,
|
||||||
|
MessageSquare, FileText, CalendarDays, Settings, LogOut, Loader2
|
||||||
|
} from "lucide-react";
|
||||||
|
import { useEffect } from "react";
|
||||||
|
|
||||||
|
export const Route = createFileRoute("/_authenticated")({
|
||||||
|
ssr: false,
|
||||||
|
component: ProtectedLayout,
|
||||||
|
});
|
||||||
|
|
||||||
|
function ProtectedLayout() {
|
||||||
|
const { user, roles, loading } = useAuth();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const path = useRouterState({ select: (s) => s.location.pathname });
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!loading && !user) navigate({ to: "/auth" });
|
||||||
|
}, [loading, user, navigate]);
|
||||||
|
|
||||||
|
if (loading || !user) {
|
||||||
|
return <div className="min-h-screen flex items-center justify-center"><Loader2 className="h-6 w-6 animate-spin text-muted-foreground" /></div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const role = highestRole(roles);
|
||||||
|
const isAdmin = roles.includes("admin");
|
||||||
|
|
||||||
|
const nav = [
|
||||||
|
{ to: "/dashboard", label: "Dashboard", icon: LayoutDashboard, show: true },
|
||||||
|
{ to: "/students", label: "Students", icon: Users, show: true },
|
||||||
|
{ to: "/attendance", label: "Attendance", icon: ClipboardCheck, show: role !== "parent" },
|
||||||
|
{ to: "/ledger", label: "Tuition", icon: Receipt, show: true },
|
||||||
|
{ to: "/messages", label: "Messages", icon: MessageSquare, show: true },
|
||||||
|
{ to: "/forms", label: "Forms", icon: FileText, show: true },
|
||||||
|
{ to: "/calendar", label: "Calendar", icon: CalendarDays, show: true },
|
||||||
|
{ to: "/admin", label: "Admin", icon: Settings, show: isAdmin },
|
||||||
|
];
|
||||||
|
|
||||||
|
const signOut = async () => {
|
||||||
|
await supabase.auth.signOut();
|
||||||
|
navigate({ to: "/auth" });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-muted/20 flex">
|
||||||
|
<aside className="w-60 bg-card border-r flex flex-col">
|
||||||
|
<div className="h-16 border-b flex items-center gap-2 px-4 font-semibold">
|
||||||
|
<GraduationCap className="h-5 w-5 text-primary" /> School Portal
|
||||||
|
</div>
|
||||||
|
<nav className="p-3 flex-1 space-y-1">
|
||||||
|
{nav.filter((n) => n.show).map((n) => {
|
||||||
|
const active = path.startsWith(n.to);
|
||||||
|
return (
|
||||||
|
<Link key={n.to} to={n.to} className={`flex items-center gap-2 px-3 py-2 rounded-md text-sm ${active ? "bg-primary text-primary-foreground" : "hover:bg-muted"}`}>
|
||||||
|
<n.icon className="h-4 w-4" /> {n.label}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
<div className="p-3 border-t">
|
||||||
|
<div className="text-xs text-muted-foreground mb-2 px-1 truncate">{user.email}<br/><span className="capitalize">{role ?? "no role"}</span></div>
|
||||||
|
<Button variant="ghost" size="sm" className="w-full justify-start" onClick={signOut}>
|
||||||
|
<LogOut className="h-4 w-4 mr-2" /> Sign out
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
<main className="flex-1 overflow-auto"><Outlet /></main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
import { createFileRoute, useNavigate } from "@tanstack/react-router";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { supabase } from "@/integrations/supabase/client";
|
||||||
|
import { lovable } from "@/integrations/lovable";
|
||||||
|
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 { toast } from "sonner";
|
||||||
|
import { GraduationCap } from "lucide-react";
|
||||||
|
|
||||||
|
const searchSchema = z.object({ mode: z.enum(["signin", "signup"]).optional() });
|
||||||
|
|
||||||
|
export const Route = createFileRoute("/auth")({
|
||||||
|
validateSearch: searchSchema,
|
||||||
|
head: () => ({ meta: [{ title: "Sign in — School Portal" }] }),
|
||||||
|
component: AuthPage,
|
||||||
|
});
|
||||||
|
|
||||||
|
function AuthPage() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { mode } = Route.useSearch();
|
||||||
|
const [tab, setTab] = useState<"signin" | "signup">(mode ?? "signin");
|
||||||
|
const [email, setEmail] = useState("");
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
|
const [fullName, setFullName] = useState("");
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const onSignIn = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setLoading(true);
|
||||||
|
const { error } = await supabase.auth.signInWithPassword({ email, password });
|
||||||
|
setLoading(false);
|
||||||
|
if (error) return toast.error(error.message);
|
||||||
|
navigate({ to: "/dashboard" });
|
||||||
|
};
|
||||||
|
|
||||||
|
const onSignUp = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setLoading(true);
|
||||||
|
const { error } = await supabase.auth.signUp({
|
||||||
|
email,
|
||||||
|
password,
|
||||||
|
options: { emailRedirectTo: window.location.origin, data: { full_name: fullName } },
|
||||||
|
});
|
||||||
|
setLoading(false);
|
||||||
|
if (error) return toast.error(error.message);
|
||||||
|
toast.success("Account created. You can sign in now.");
|
||||||
|
setTab("signin");
|
||||||
|
};
|
||||||
|
|
||||||
|
const onGoogle = async () => {
|
||||||
|
const res = await lovable.auth.signInWithOAuth("google", { redirect_uri: window.location.origin });
|
||||||
|
if (res.error) toast.error(res.error.message ?? "Google sign-in failed");
|
||||||
|
else if (!res.redirected) navigate({ to: "/dashboard" });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-muted/30 p-4">
|
||||||
|
<div className="w-full max-w-md bg-card border rounded-lg p-8 shadow-sm">
|
||||||
|
<div className="flex items-center gap-2 mb-6">
|
||||||
|
<GraduationCap className="h-6 w-6 text-primary" />
|
||||||
|
<h1 className="font-semibold text-lg">School Portal</h1>
|
||||||
|
</div>
|
||||||
|
<Tabs value={tab} onValueChange={(v) => setTab(v as "signin" | "signup")}>
|
||||||
|
<TabsList className="grid grid-cols-2 w-full">
|
||||||
|
<TabsTrigger value="signin">Sign in</TabsTrigger>
|
||||||
|
<TabsTrigger value="signup">Create account</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
<TabsContent value="signin">
|
||||||
|
<form onSubmit={onSignIn} className="space-y-4 mt-4">
|
||||||
|
<div><Label>Email</Label><Input type="email" required value={email} onChange={(e) => setEmail(e.target.value)} /></div>
|
||||||
|
<div><Label>Password</Label><Input type="password" required value={password} onChange={(e) => setPassword(e.target.value)} /></div>
|
||||||
|
<Button type="submit" className="w-full" disabled={loading}>Sign in</Button>
|
||||||
|
</form>
|
||||||
|
</TabsContent>
|
||||||
|
<TabsContent value="signup">
|
||||||
|
<form onSubmit={onSignUp} className="space-y-4 mt-4">
|
||||||
|
<div><Label>Full name</Label><Input required value={fullName} onChange={(e) => setFullName(e.target.value)} /></div>
|
||||||
|
<div><Label>Email</Label><Input type="email" required value={email} onChange={(e) => setEmail(e.target.value)} /></div>
|
||||||
|
<div><Label>Password</Label><Input type="password" required minLength={6} value={password} onChange={(e) => setPassword(e.target.value)} /></div>
|
||||||
|
<Button type="submit" className="w-full" disabled={loading}>Create account</Button>
|
||||||
|
<p className="text-xs text-muted-foreground">The first account becomes admin. Other accounts default to parent — an admin can change your role.</p>
|
||||||
|
</form>
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
|
||||||
|
<div className="my-4 flex items-center gap-3 text-xs text-muted-foreground">
|
||||||
|
<div className="flex-1 border-t" /> OR <div className="flex-1 border-t" />
|
||||||
|
</div>
|
||||||
|
<Button variant="outline" className="w-full" onClick={onGoogle}>Continue with Google</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
+53
-17
@@ -1,29 +1,65 @@
|
|||||||
import { createFileRoute } from "@tanstack/react-router";
|
import { createFileRoute, Link } from "@tanstack/react-router";
|
||||||
|
import { GraduationCap, Users, CalendarDays, MessageSquare, FileText, Receipt } from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
||||||
export const Route = createFileRoute("/")({
|
export const Route = createFileRoute("/")({
|
||||||
head: () => ({
|
head: () => ({
|
||||||
meta: [
|
meta: [
|
||||||
{ title: "Your App" },
|
{ title: "School Portal" },
|
||||||
{ name: "description", content: "Replace this with a one-sentence description of your app." },
|
{ name: "description", content: "Secure portal for staff and parents — attendance, tuition, messaging, and more." },
|
||||||
{ property: "og:title", content: "Your App" },
|
|
||||||
{ property: "og:description", content: "Replace this with a one-sentence description of your app." },
|
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
component: Index,
|
component: Landing,
|
||||||
});
|
});
|
||||||
|
|
||||||
// IMPORTANT: Replace this placeholder. See ./README.md for routing conventions.
|
function Landing() {
|
||||||
function Index() {
|
const features = [
|
||||||
|
{ icon: Users, title: "Student records", desc: "Contacts, pickup list, allergies, photo release." },
|
||||||
|
{ icon: CalendarDays, title: "Attendance", desc: "Teachers mark their class. Parents see their child." },
|
||||||
|
{ icon: Receipt, title: "Tuition ledger", desc: "Monthly plan, late fees, activities — all in one place." },
|
||||||
|
{ icon: MessageSquare, title: "Messaging", desc: "Secure in-app inbox between staff and families." },
|
||||||
|
{ icon: FileText, title: "E-sign forms", desc: "Push a form, parents complete it in the portal." },
|
||||||
|
{ icon: CalendarDays, title: "School calendar", desc: "Year-at-a-glance for the whole school." },
|
||||||
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div className="min-h-screen bg-background">
|
||||||
className="flex min-h-screen items-center justify-center"
|
<header className="border-b">
|
||||||
style={{ backgroundColor: "#fcfbf8" }}
|
<div className="max-w-6xl mx-auto px-6 h-16 flex items-center justify-between">
|
||||||
>
|
<div className="flex items-center gap-2 font-semibold">
|
||||||
<img
|
<GraduationCap className="h-6 w-6 text-primary" />
|
||||||
data-lovable-blank-page-placeholder="REMOVE_THIS"
|
<span>School Portal</span>
|
||||||
src="https://cdn.gpteng.co/blank-app-v1.svg"
|
</div>
|
||||||
alt="Your app will live here!"
|
<div className="flex gap-2">
|
||||||
/>
|
<Button asChild variant="ghost"><Link to="/auth">Sign in</Link></Button>
|
||||||
|
<Button asChild><Link to="/auth" search={{ mode: "signup" }}>Create account</Link></Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section className="max-w-6xl mx-auto px-6 py-20 text-center">
|
||||||
|
<h1 className="text-4xl md:text-5xl font-bold tracking-tight">A private portal for your school community</h1>
|
||||||
|
<p className="mt-4 text-lg text-muted-foreground max-w-2xl mx-auto">
|
||||||
|
One secure place for admins, teachers, and parents to manage students, attendance, tuition, and communication.
|
||||||
|
</p>
|
||||||
|
<div className="mt-8 flex gap-3 justify-center">
|
||||||
|
<Button asChild size="lg"><Link to="/auth">Sign in to portal</Link></Button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="max-w-6xl mx-auto px-6 pb-20 grid md:grid-cols-3 gap-6">
|
||||||
|
{features.map((f) => (
|
||||||
|
<div key={f.title} className="border rounded-lg p-6 bg-card">
|
||||||
|
<f.icon className="h-6 w-6 text-primary mb-3" />
|
||||||
|
<h3 className="font-semibold">{f.title}</h3>
|
||||||
|
<p className="text-sm text-muted-foreground mt-1">{f.desc}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<footer className="border-t py-6 text-center text-sm text-muted-foreground">
|
||||||
|
Private parent portal · sign in required
|
||||||
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user