Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions src/app/admin/dealerships/actions.ts
Original file line number Diff line number Diff line change
@@ -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<ActionState> {
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<ActionState> {
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." };
}
46 changes: 46 additions & 0 deletions src/app/admin/dealerships/page.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="flex flex-col gap-6">
<h1 className="text-2xl font-extrabold">Dealerships</h1>

<section className="rounded-2xl border border-[#E6E7EA] bg-white p-5">
<h2 className="mb-3 text-lg font-extrabold">Add a dealership</h2>
<ActionForm action={addDealership} className="flex flex-wrap items-center gap-3">
<input name="name" placeholder="Dealership name" required className={`${input} min-w-64 flex-1`} />
<label className="flex items-center gap-2 text-sm"><input type="checkbox" name="is_securitas" /> Securitas</label>
<label className="flex items-center gap-2 text-sm"><input type="checkbox" name="salespeople_enter_own_cards" /> Salespeople enter their own cards</label>
<button className={save}>Add</button>
</ActionForm>
</section>

<section className="flex flex-col gap-2">
{(dealerships ?? []).map((d) => (
<ActionForm key={d.id} action={saveDealership} className="flex flex-wrap items-center gap-3 rounded-2xl border border-[#E6E7EA] bg-white p-4">
<input type="hidden" name="id" value={d.id} />
<input name="name" defaultValue={d.name} required className={`${input} min-w-64 flex-1`} />
<label className="flex items-center gap-2 text-sm"><input type="checkbox" name="is_securitas" defaultChecked={d.is_securitas} /> Securitas</label>
<label className="flex items-center gap-2 text-sm"><input type="checkbox" name="salespeople_enter_own_cards" defaultChecked={d.salespeople_enter_own_cards} /> Salespeople enter own cards</label>
<label className="flex items-center gap-2 text-sm"><input type="checkbox" name="active" defaultChecked={d.active} /> Active</label>
<button className={save}>Save</button>
</ActionForm>
))}
{(dealerships ?? []).length === 0 && <p className="text-sm text-[#5B5F66]">No dealerships yet. Add the first one above.</p>}
</section>
</div>
);
}
26 changes: 26 additions & 0 deletions src/app/admin/layout.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<>
<AppHeader profile={profile} active="Admin" />
<main className="mx-auto flex max-w-5xl flex-col gap-6 px-4 py-8">
<div className="flex gap-2">
{tabs.map((t) => (
<Link key={t.label} href={t.href} className="rounded-full border border-[#CFD1D6] bg-white px-4 py-1.5 text-sm font-semibold">{t.label}</Link>
))}
</div>
{children}
</main>
</>
);
}
38 changes: 38 additions & 0 deletions src/app/admin/reps/actions.ts
Original file line number Diff line number Diff line change
@@ -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<ActionState> {
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<ActionState> {
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." };
}
68 changes: 68 additions & 0 deletions src/app/admin/reps/page.tsx
Original file line number Diff line number Diff line change
@@ -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) => (
<option key={d.id} value={d.id}>{d.name}</option>
));

return (
<div className="flex flex-col gap-6">
<h1 className="text-2xl font-extrabold">Reps</h1>
<p className="text-sm text-[#5B5F66]">Reps are the people who compete. They are separate from logins, so a rep keeps their history if their role or dealership changes.</p>

<section className="rounded-2xl border border-[#E6E7EA] bg-white p-5">
<h2 className="mb-3 text-lg font-extrabold">Add a rep</h2>
<ActionForm action={addRep} className="flex flex-wrap items-center gap-3">
<input name="first_name" placeholder="First name" required className={input} />
<input name="last_name" placeholder="Last name" required className={input} />
<select name="dealership_id" required defaultValue={dealership ?? ""} className={`${input} min-w-56`}>
<option value="" disabled>Dealership</option>
{options}
</select>
<button className={save}>Add</button>
</ActionForm>
</section>

<form className="flex flex-wrap items-center gap-3" method="get">
<select name="dealership" defaultValue={dealership ?? ""} className={`${input} min-w-56`}>
<option value="">All dealerships</option>
{options}
</select>
<label className="flex items-center gap-2 text-sm"><input type="checkbox" name="inactive" value="1" defaultChecked={inactive === "1"} /> Show inactive</label>
<button className="rounded-xl border border-[#CFD1D6] px-4 py-2 text-sm font-semibold">Filter</button>
<Link href="/admin/reps" className="text-sm font-semibold text-[#C8173F]">Clear</Link>
</form>

<section className="flex flex-col gap-2">
{(reps ?? []).map((r) => (
<ActionForm key={r.id} action={saveRep} className="flex flex-wrap items-center gap-3 rounded-2xl border border-[#E6E7EA] bg-white p-4">
<input type="hidden" name="id" value={r.id} />
<input name="first_name" defaultValue={r.first_name} required className={input} />
<input name="last_name" defaultValue={r.last_name} required className={input} />
<select name="dealership_id" defaultValue={r.dealership_id} className={`${input} min-w-56`}>{options}</select>
<label className="flex items-center gap-2 text-sm"><input type="checkbox" name="active" defaultChecked={r.active} /> Active</label>
<button className={save}>Save</button>
</ActionForm>
))}
{(reps ?? []).length === 0 && <p className="text-sm text-[#5B5F66]">No reps to show.</p>}
</section>
</div>
);
}
33 changes: 10 additions & 23 deletions src/app/page.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<main className="mx-auto flex min-h-screen max-w-3xl flex-col gap-8 px-4 py-10">
<header className="flex items-center justify-between gap-4">
<Image src="/titan-logo.png" alt="SNDA Sales Titan Program" width={220} height={72} priority className="h-auto w-48" />
<form action={signOut}>
<button className="rounded-xl border border-[#CFD1D6] px-4 py-2 text-sm font-semibold">Sign out</button>
</form>
</header>
<section className="rounded-2xl border border-[#E6E7EA] bg-white p-6">
<h1 className="text-2xl font-extrabold">Welcome, {profile.first_name}</h1>
<p className="mt-1 text-[#5B5F66]">{ROLE_LABELS[profile.preset]}</p>
<p className="mt-4 text-sm text-[#5B5F66]">You are signed in. Leaderboards and tally cards are coming next.</p>
</section>
</main>
<>
<AppHeader profile={profile} active="Home" />
<main className="mx-auto max-w-3xl px-4 py-10">
<section className="rounded-2xl border border-[#E6E7EA] bg-white p-6">
<h1 className="text-2xl font-extrabold">Welcome, {profile.first_name}</h1>
<p className="mt-4 text-sm text-[#5B5F66]">You are signed in. Leaderboards and tally cards are coming next.</p>
</section>
</main>
</>
);
}
25 changes: 25 additions & 0 deletions src/components/action-form.tsx
Original file line number Diff line number Diff line change
@@ -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<ActionState>;
children: ReactNode;
className?: string;
}) {
const [state, formAction, pending] = useActionState(action, {} as ActionState);
return (
<form action={formAction} className={className} aria-busy={pending}>
{children}
{state.error && <p role="alert" className="w-full text-sm font-semibold text-[#C8173F]">{state.error}</p>}
{state.notice && <p role="status" className="w-full text-sm font-semibold text-[#1F7A3D]">{state.notice}</p>}
</form>
);
}
42 changes: 42 additions & 0 deletions src/components/app-header.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<header className="flex flex-wrap items-center gap-x-8 gap-y-2 border-b border-[#E6E7EA] bg-white px-8 py-2">
<Image src="/titan-logo.png" alt="SNDA Sales Titan Program" width={220} height={72} priority className="h-11 w-auto" />
<nav className="flex gap-1">
{nav.map((n) => (
<Link key={n.label} href={n.href}
className={`rounded-[10px] px-3.5 py-2.5 text-sm font-semibold ${n.label === active ? "bg-[#EEEFF1] text-[#111214]" : "text-[#5B5F66]"}`}>
{n.label}
</Link>
))}
</nav>
<div className="flex-1" />
<div className="flex items-center gap-3">
<div className="flex size-9 items-center justify-center rounded-full bg-[#111214] text-sm font-bold text-white">
{profile.first_name.slice(0, 1).toUpperCase()}
</div>
<div className="leading-tight">
<div className="text-sm font-semibold">{profile.first_name}</div>
<div className="text-xs text-[#5B5F66]">{ROLE_LABELS[profile.preset]}</div>
</div>
<form action={signOut}>
<button className="ml-2 rounded-xl border border-[#CFD1D6] px-3 py-1.5 text-sm font-semibold">Sign out</button>
</form>
</div>
</header>
);
}
7 changes: 7 additions & 0 deletions src/lib/auth/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,3 +45,10 @@ export async function requireUser(): Promise<Profile> {
}
return profile;
}

/** For admin pages: only super admins get in. */
export async function requireSuperAdmin(): Promise<Profile> {
const profile = await requireUser();
if (profile.preset !== "super_admin") redirect("/");
return profile;
}