From c58e6e4e8015404ffa1251c747f81973d2a36ab7 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 11 Nov 2026 19:04:41 -0500 Subject: [PATCH] Add admin screens for dealerships and reps Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01GbkeAFMbVXRsvid4tiQsAw --- src/app/admin/dealerships/actions.ts | 42 +++++++++++++++++ src/app/admin/dealerships/page.tsx | 46 +++++++++++++++++++ src/app/admin/layout.tsx | 26 +++++++++++ src/app/admin/reps/actions.ts | 38 ++++++++++++++++ src/app/admin/reps/page.tsx | 68 ++++++++++++++++++++++++++++ src/app/page.tsx | 33 ++++---------- src/components/action-form.tsx | 25 ++++++++++ src/components/app-header.tsx | 42 +++++++++++++++++ src/lib/auth/session.ts | 7 +++ 9 files changed, 304 insertions(+), 23 deletions(-) create mode 100644 src/app/admin/dealerships/actions.ts create mode 100644 src/app/admin/dealerships/page.tsx create mode 100644 src/app/admin/layout.tsx create mode 100644 src/app/admin/reps/actions.ts create mode 100644 src/app/admin/reps/page.tsx create mode 100644 src/components/action-form.tsx create mode 100644 src/components/app-header.tsx diff --git a/src/app/admin/dealerships/actions.ts b/src/app/admin/dealerships/actions.ts new file mode 100644 index 0000000..74b4dbf --- /dev/null +++ b/src/app/admin/dealerships/actions.ts @@ -0,0 +1,42 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import { createClient } from "@/lib/supabase/server"; +import { requireSuperAdmin } from "@/lib/auth/session"; +import type { ActionState } from "@/components/action-form"; + +// These use the signed-in admin's own session (not the secret key), so the audit log records who made each change. + +function fields(formData: FormData) { + return { + name: String(formData.get("name") ?? "").trim(), + is_securitas: formData.get("is_securitas") === "on", + salespeople_enter_own_cards: formData.get("salespeople_enter_own_cards") === "on", + active: formData.get("active") === "on", + }; +} + +function message(error: { code?: string; message: string }) { + return error.code === "23505" ? "A dealership with that name already exists." : "Could not save. Please try again."; +} + +export async function addDealership(_prev: ActionState, formData: FormData): Promise { + await requireSuperAdmin(); + const f = fields(formData); + if (!f.name) return { error: "Enter a dealership name." }; + const { error } = await (await createClient()).from("dealerships").insert({ ...f, active: true }); + if (error) return { error: message(error) }; + revalidatePath("/admin/dealerships"); + return { notice: `Added ${f.name}.` }; +} + +export async function saveDealership(_prev: ActionState, formData: FormData): Promise { + await requireSuperAdmin(); + const id = String(formData.get("id") ?? ""); + const f = fields(formData); + if (!f.name) return { error: "Enter a dealership name." }; + const { error } = await (await createClient()).from("dealerships").update(f).eq("id", id); + if (error) return { error: message(error) }; + revalidatePath("/admin/dealerships"); + return { notice: "Saved." }; +} diff --git a/src/app/admin/dealerships/page.tsx b/src/app/admin/dealerships/page.tsx new file mode 100644 index 0000000..a35b302 --- /dev/null +++ b/src/app/admin/dealerships/page.tsx @@ -0,0 +1,46 @@ +import { ActionForm } from "@/components/action-form"; +import { createClient } from "@/lib/supabase/server"; +import { addDealership, saveDealership } from "./actions"; + +export const instant = false; + +const input = "rounded-xl border border-[#CFD1D6] px-3 py-2 text-sm"; +const save = "rounded-xl bg-[#111214] px-4 py-2 text-sm font-bold text-white"; + +export default async function DealershipsPage() { + const { data: dealerships } = await (await createClient()) + .from("dealerships") + .select("id, name, is_securitas, salespeople_enter_own_cards, active") + .order("active", { ascending: false }) + .order("name"); + + return ( +
+

Dealerships

+ +
+

Add a dealership

+ + + + + + +
+ +
+ {(dealerships ?? []).map((d) => ( + + + + + + + + + ))} + {(dealerships ?? []).length === 0 &&

No dealerships yet. Add the first one above.

} +
+
+ ); +} diff --git a/src/app/admin/layout.tsx b/src/app/admin/layout.tsx new file mode 100644 index 0000000..a77536c --- /dev/null +++ b/src/app/admin/layout.tsx @@ -0,0 +1,26 @@ +import Link from "next/link"; +import { AppHeader } from "@/components/app-header"; +import { requireSuperAdmin } from "@/lib/auth/session"; + +export const instant = false; + +export default async function AdminLayout({ children }: { children: React.ReactNode }) { + const profile = await requireSuperAdmin(); + const tabs = [ + { label: "Dealerships", href: "/admin/dealerships" as const }, + { label: "Reps", href: "/admin/reps" as const }, + ]; + return ( + <> + +
+
+ {tabs.map((t) => ( + {t.label} + ))} +
+ {children} +
+ + ); +} diff --git a/src/app/admin/reps/actions.ts b/src/app/admin/reps/actions.ts new file mode 100644 index 0000000..c9324c8 --- /dev/null +++ b/src/app/admin/reps/actions.ts @@ -0,0 +1,38 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import { createClient } from "@/lib/supabase/server"; +import { requireSuperAdmin } from "@/lib/auth/session"; +import type { ActionState } from "@/components/action-form"; + +function fields(formData: FormData) { + return { + first_name: String(formData.get("first_name") ?? "").trim(), + last_name: String(formData.get("last_name") ?? "").trim(), + dealership_id: String(formData.get("dealership_id") ?? ""), + }; +} + +export async function addRep(_prev: ActionState, formData: FormData): Promise { + await requireSuperAdmin(); + const f = fields(formData); + if (!f.first_name || !f.last_name || !f.dealership_id) return { error: "Enter a first name, last name and dealership." }; + const { error } = await (await createClient()).from("reps").insert(f); + if (error) return { error: "Could not save. Please try again." }; + revalidatePath("/admin/reps"); + return { notice: `Added ${f.first_name} ${f.last_name}.` }; +} + +export async function saveRep(_prev: ActionState, formData: FormData): Promise { + await requireSuperAdmin(); + const id = String(formData.get("id") ?? ""); + const f = fields(formData); + if (!f.first_name || !f.last_name || !f.dealership_id) return { error: "Enter a first name, last name and dealership." }; + const { error } = await (await createClient()) + .from("reps") + .update({ ...f, active: formData.get("active") === "on" }) + .eq("id", id); + if (error) return { error: "Could not save. Please try again." }; + revalidatePath("/admin/reps"); + return { notice: "Saved." }; +} diff --git a/src/app/admin/reps/page.tsx b/src/app/admin/reps/page.tsx new file mode 100644 index 0000000..38b3673 --- /dev/null +++ b/src/app/admin/reps/page.tsx @@ -0,0 +1,68 @@ +import Link from "next/link"; +import { ActionForm } from "@/components/action-form"; +import { createClient } from "@/lib/supabase/server"; +import { addRep, saveRep } from "./actions"; + +export const instant = false; + +const input = "rounded-xl border border-[#CFD1D6] px-3 py-2 text-sm"; +const save = "rounded-xl bg-[#111214] px-4 py-2 text-sm font-bold text-white"; + +export default async function RepsPage({ searchParams }: { searchParams: Promise<{ dealership?: string; inactive?: string }> }) { + const { dealership, inactive } = await searchParams; + const supabase = await createClient(); + const { data: dealerships } = await supabase.from("dealerships").select("id, name").eq("active", true).order("name"); + + let query = supabase.from("reps").select("id, first_name, last_name, dealership_id, active").order("last_name").order("first_name"); + if (dealership) query = query.eq("dealership_id", dealership); + if (inactive !== "1") query = query.eq("active", true); + const { data: reps } = await query; + + const options = (dealerships ?? []).map((d) => ( + + )); + + return ( +
+

Reps

+

Reps are the people who compete. They are separate from logins, so a rep keeps their history if their role or dealership changes.

+ +
+

Add a rep

+ + + + + + +
+ +
+ + + + Clear +
+ +
+ {(reps ?? []).map((r) => ( + + + + + + + + + ))} + {(reps ?? []).length === 0 &&

No reps to show.

} +
+
+ ); +} diff --git a/src/app/page.tsx b/src/app/page.tsx index b251c4e..bb1c4df 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,32 +1,19 @@ -import Image from "next/image"; +import { AppHeader } from "@/components/app-header"; import { requireUser } from "@/lib/auth/session"; -import { signOut } from "./login/actions"; - -const ROLE_LABELS = { - salesperson_view: "Salesperson (view only)", - salesperson_enters: "Salesperson (enters own cards)", - team_admin: "Team Admin (entry-only)", - manager_owner: "Manager / Owner", - super_admin: "Super admin", -} as const; export const instant = false; export default async function Home() { const profile = await requireUser(); return ( -
-
- SNDA Sales Titan Program -
- -
-
-
-

Welcome, {profile.first_name}

-

{ROLE_LABELS[profile.preset]}

-

You are signed in. Leaderboards and tally cards are coming next.

-
-
+ <> + +
+
+

Welcome, {profile.first_name}

+

You are signed in. Leaderboards and tally cards are coming next.

+
+
+ ); } diff --git a/src/components/action-form.tsx b/src/components/action-form.tsx new file mode 100644 index 0000000..c22aa54 --- /dev/null +++ b/src/components/action-form.tsx @@ -0,0 +1,25 @@ +"use client"; + +import { useActionState, type ReactNode } from "react"; + +export type ActionState = { error?: string; notice?: string }; + +/** A form that runs a server action and shows its success or error message under the fields. */ +export function ActionForm({ + action, + children, + className, +}: { + action: (prev: ActionState, formData: FormData) => Promise; + children: ReactNode; + className?: string; +}) { + const [state, formAction, pending] = useActionState(action, {} as ActionState); + return ( +
+ {children} + {state.error &&

{state.error}

} + {state.notice &&

{state.notice}

} +
+ ); +} diff --git a/src/components/app-header.tsx b/src/components/app-header.tsx new file mode 100644 index 0000000..a63b8f0 --- /dev/null +++ b/src/components/app-header.tsx @@ -0,0 +1,42 @@ +import Image from "next/image"; +import Link from "next/link"; +import type { Profile } from "@/lib/auth/session"; +import { signOut } from "@/app/login/actions"; + +export const ROLE_LABELS = { + salesperson_view: "Salesperson (view only)", + salesperson_enters: "Salesperson (enters own cards)", + team_admin: "Team Admin (entry-only)", + manager_owner: "Manager / Owner", + super_admin: "Super admin", +} as const; + +export function AppHeader({ profile, active }: { profile: Profile; active: "Home" | "Admin" }) { + const nav = [{ label: "Home", href: "/" as const }, ...(profile.preset === "super_admin" ? [{ label: "Admin", href: "/admin/dealerships" as const }] : [])]; + return ( +
+ SNDA Sales Titan Program + +
+
+
+ {profile.first_name.slice(0, 1).toUpperCase()} +
+
+
{profile.first_name}
+
{ROLE_LABELS[profile.preset]}
+
+
+ +
+
+
+ ); +} diff --git a/src/lib/auth/session.ts b/src/lib/auth/session.ts index 23d594f..004caba 100644 --- a/src/lib/auth/session.ts +++ b/src/lib/auth/session.ts @@ -45,3 +45,10 @@ export async function requireUser(): Promise { } return profile; } + +/** For admin pages: only super admins get in. */ +export async function requireSuperAdmin(): Promise { + const profile = await requireUser(); + if (profile.preset !== "super_admin") redirect("/"); + return profile; +}