From a2a4e6c811b25ad32cfb4c4d689f0e9daf683dcb Mon Sep 17 00:00:00 2001 From: "gpt-engineer-app[bot]" <159125892+gpt-engineer-app[bot]@users.noreply.github.com> Date: Tue, 30 Jun 2026 20:41:27 +0000 Subject: [PATCH] Changes Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com> --- src/hooks/use-auth.ts | 56 +++++++++++++++ src/routes/_authenticated/dashboard.tsx | 80 +++++++++++++++++++++ src/routes/_authenticated/route.tsx | 74 +++++++++++++++++++ src/routes/auth.tsx | 96 +++++++++++++++++++++++++ src/routes/index.tsx | 70 +++++++++++++----- 5 files changed, 359 insertions(+), 17 deletions(-) create mode 100644 src/hooks/use-auth.ts create mode 100644 src/routes/_authenticated/dashboard.tsx create mode 100644 src/routes/_authenticated/route.tsx create mode 100644 src/routes/auth.tsx diff --git a/src/hooks/use-auth.ts b/src/hooks/use-auth.ts new file mode 100644 index 0000000..7c5788a --- /dev/null +++ b/src/hooks/use-auth.ts @@ -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(null); + const [roles, setRoles] = useState([]); + 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; +} diff --git a/src/routes/_authenticated/dashboard.tsx b/src/routes/_authenticated/dashboard.tsx new file mode 100644 index 0000000..b1b65c8 --- /dev/null +++ b/src/routes/_authenticated/dashboard.tsx @@ -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 ( +
+

Welcome back

+

Signed in as {role}

+ +
+ + + +
+ +
+

Upcoming on the calendar

+ {upcoming && upcoming.length > 0 ? ( +
    + {upcoming.map((e) => ( +
  • + {e.title} + {new Date(e.date).toLocaleDateString()} +
  • + ))} +
+ ) : ( +

No upcoming events.

+ )} +
+
+ ); +} + +function Stat({ icon: Icon, label, value }: { icon: typeof Users; label: string; value: number | string }) { + return ( +
+ +
{value}
+
{label}
+
+ ); +} diff --git a/src/routes/_authenticated/route.tsx b/src/routes/_authenticated/route.tsx new file mode 100644 index 0000000..4a92014 --- /dev/null +++ b/src/routes/_authenticated/route.tsx @@ -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
; + } + + 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 ( +
+ +
+
+ ); +} diff --git a/src/routes/auth.tsx b/src/routes/auth.tsx new file mode 100644 index 0000000..55a086c --- /dev/null +++ b/src/routes/auth.tsx @@ -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 ( +
+
+
+ +

School Portal

+
+ setTab(v as "signin" | "signup")}> + + Sign in + Create account + + +
+
setEmail(e.target.value)} />
+
setPassword(e.target.value)} />
+ +
+
+ +
+
setFullName(e.target.value)} />
+
setEmail(e.target.value)} />
+
setPassword(e.target.value)} />
+ +

The first account becomes admin. Other accounts default to parent — an admin can change your role.

+
+
+
+ +
+
OR
+
+ +
+
+ ); +} diff --git a/src/routes/index.tsx b/src/routes/index.tsx index 8b47a58..363add4 100644 --- a/src/routes/index.tsx +++ b/src/routes/index.tsx @@ -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("/")({ head: () => ({ meta: [ - { title: "Your App" }, - { name: "description", content: "Replace this with a one-sentence description of your app." }, - { property: "og:title", content: "Your App" }, - { property: "og:description", content: "Replace this with a one-sentence description of your app." }, + { title: "School Portal" }, + { name: "description", content: "Secure portal for staff and parents — attendance, tuition, messaging, and more." }, ], }), - component: Index, + component: Landing, }); -// IMPORTANT: Replace this placeholder. See ./README.md for routing conventions. -function Index() { +function Landing() { + 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 ( -
- Your app will live here! +
+
+
+
+ + School Portal +
+
+ + +
+
+
+ +
+

A private portal for your school community

+

+ One secure place for admins, teachers, and parents to manage students, attendance, tuition, and communication. +

+
+ +
+
+ +
+ {features.map((f) => ( +
+ +

{f.title}

+

{f.desc}

+
+ ))} +
+ +
); }