From 8ba2cf62385e26a6a04f12853852d70e697143f6 Mon Sep 17 00:00:00 2001 From: Junnian Liu Date: Sun, 1 Mar 2026 22:35:53 -0800 Subject: [PATCH 1/5] implemented applications get request with filters --- app/api/admin/applications/route.ts | 36 ++++++++++++++++++++++++++--- 1 file changed, 33 insertions(+), 3 deletions(-) diff --git a/app/api/admin/applications/route.ts b/app/api/admin/applications/route.ts index ccaf148..918f798 100644 --- a/app/api/admin/applications/route.ts +++ b/app/api/admin/applications/route.ts @@ -1,11 +1,40 @@ -import { NextResponse } from "next/server"; -import { PrismaClient } from "@/generated/prisma/client"; +import { NextRequest, NextResponse } from "next/server"; +import { PrismaClient, Role } from "@/generated/prisma/client"; +import { auth } from "@/auth"; const prisma = new PrismaClient(); -export async function GET() { +export async function GET(request: NextRequest) { try { + const session = await auth(); + + if (!session?.user?.email) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const dbUser = await prisma.user.findUnique({ + where: { email: session.user.email }, + select: { role: true }, + }); + + if ( + !dbUser || + (dbUser.role !== Role.REVIEWER && + dbUser.role !== Role.SUPER_ADMIN) + ) { + return NextResponse.json({ error: "Forbidden" }, { status: 403 }); + } + + const { searchParams } = new URL(request.url); + const type = searchParams.get("type"); + const status = searchParams.get("status"); + + const where: any = {}; + if (type) where.type = type; + if (status) where.status = status; + const applications = await prisma.application.findMany({ + where, select: { id: true, type: true, @@ -19,6 +48,7 @@ export async function GET() { }); return NextResponse.json({ data: applications }, { status: 200 }); + } catch (err) { console.error(err); return NextResponse.json( From bba982035b426a8d67721059f4946f1b82a2f32a Mon Sep 17 00:00:00 2001 From: Junnian Liu Date: Sun, 1 Mar 2026 22:48:57 -0800 Subject: [PATCH 2/5] added dynamic id path --- app/api/admin/applications/[id]/route.ts | 38 ++++++++++++++++++++++++ 1 file changed, 38 insertions(+) create mode 100644 app/api/admin/applications/[id]/route.ts diff --git a/app/api/admin/applications/[id]/route.ts b/app/api/admin/applications/[id]/route.ts new file mode 100644 index 0000000..940311e --- /dev/null +++ b/app/api/admin/applications/[id]/route.ts @@ -0,0 +1,38 @@ +import { NextRequest, NextResponse } from "next/server"; +import { PrismaClient, Role } from "@/generated/prisma/client"; +import { auth } from "@/auth"; + +const prisma = new PrismaClient(); + +export async function GET( + request: NextRequest, + { params }: { params: { id: string } }, +) { + const session = await auth(); + + if (!session?.user?.email) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const dbUser = await prisma.user.findUnique({ + where: { email: session.user.email }, + select: { role: true }, + }); + + if ( + !dbUser || + (dbUser.role !== Role.REVIEWER && dbUser.role !== Role.SUPER_ADMIN) + ) { + return NextResponse.json({ error: "Forbidden" }, { status: 403 }); + } + + const application = await prisma.application.findUnique({ + where: { id: params.id }, + }); + + if (!application) { + return NextResponse.json({ error: "Not found" }, { status: 404 }); + } + + return NextResponse.json({ data: application }, { status: 200 }); +} \ No newline at end of file From 5a766f1b3b2f688911b20da7b976d058d6fda5ad Mon Sep 17 00:00:00 2001 From: Junnian Liu Date: Sun, 1 Mar 2026 22:58:32 -0800 Subject: [PATCH 3/5] implemented patch api endpoint for app status --- app/api/admin/applications/[id]/route.ts | 49 ++++++++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/app/api/admin/applications/[id]/route.ts b/app/api/admin/applications/[id]/route.ts index 940311e..fe26622 100644 --- a/app/api/admin/applications/[id]/route.ts +++ b/app/api/admin/applications/[id]/route.ts @@ -35,4 +35,53 @@ export async function GET( } return NextResponse.json({ data: application }, { status: 200 }); +} + + +export async function PATCH( + request: NextRequest, + { params }: { params: { id: string } } +) { + try { + const session = await auth(); + + if (!session?.user?.email) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const dbUser = await prisma.user.findUnique({ + where: { email: session.user.email }, + select: { role: true }, + }); + + if ( + !dbUser || + (dbUser.role !== Role.REVIEWER && + dbUser.role !== Role.SUPER_ADMIN) + ) { + return NextResponse.json({ error: "Forbidden" }, { status: 403 }); + } + + const { status } = await request.json(); + + if (status !== "approved" && status !== "rejected") { + return NextResponse.json( + { error: "Invalid status" }, + { status: 400 } + ); + } + + const updated = await prisma.application.update({ + where: { id: params.id }, + data: { status }, + }); + + return NextResponse.json({ data: updated }, { status: 200 }); + + } catch (err: any) { + return NextResponse.json( + { error: "Failed to update application" }, + { status: 500 } + ); + } } \ No newline at end of file From 859b0de03e5cc64120d07a1834df95484b4ba826 Mon Sep 17 00:00:00 2001 From: Junnian Liu Date: Fri, 6 Mar 2026 12:07:28 -0800 Subject: [PATCH 4/5] changed admin applications page --- app/admin/applications/page.tsx | 203 ++++++++++++++++++++++++++++---- 1 file changed, 180 insertions(+), 23 deletions(-) diff --git a/app/admin/applications/page.tsx b/app/admin/applications/page.tsx index 99d42c5..caeb291 100644 --- a/app/admin/applications/page.tsx +++ b/app/admin/applications/page.tsx @@ -1,40 +1,197 @@ +"use client"; + import Link from "next/link"; -import { getApplications } from "@/services/mockApplications"; +import { useEffect, useState, useCallback } from "react"; + +type ApplicationStatus = "pending" | "approved" | "rejected"; +interface Application { + id: string; + type: string; + status: ApplicationStatus; + submitterName: string; + submitterEmail: string; + createdAt: string; +} + +const STATUS_OPTIONS: { label: string; value: string }[] = [ + { label: "All Statuses", value: "" }, + { label: "Pending", value: "pending" }, + { label: "Approved", value: "approved" }, + { label: "Rejected", value: "rejected" }, +]; + +const STATUS_STYLES: Record = { + pending: "bg-amber-50 text-amber-700 ring-1 ring-amber-200", + approved: "bg-emerald-50 text-emerald-700 ring-1 ring-emerald-200", + rejected: "bg-red-50 text-red-600 ring-1 ring-red-200", +}; export default function ApplicationsPage() { - const applications = getApplications(); + const [applications, setApplications] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [statusFilter, setStatusFilter] = useState(""); + const [typeFilter, setTypeFilter] = useState(""); + const [typeInput, setTypeInput] = useState(""); + + const fetchApplications = useCallback(async () => { + setLoading(true); + setError(null); + try { + const params = new URLSearchParams(); + if (statusFilter) params.set("status", statusFilter); + if (typeFilter) params.set("type", typeFilter); + + const res = await fetch(`/api/admin/applications?${params.toString()}`); + if (!res.ok) { + const body = await res.json(); + throw new Error(body.error ?? "Failed to load applications"); + } + const { data } = await res.json(); + setApplications(data); + } catch (err: any) { + setError(err.message); + } finally { + setLoading(false); + } + }, [statusFilter, typeFilter]); + + useEffect(() => { + const timer = setTimeout(() => setTypeFilter(typeInput), 400); + return () => clearTimeout(timer); + }, [typeInput]); + + useEffect(() => { + fetchApplications(); + }, [fetchApplications]); return ( -
-

Applications

+
+
+

Applications

+

+ Review and manage submitted applications +

+
+ +
+ setTypeInput(e.target.value)} + placeholder="Filter by type..." + className="text-sm border border-gray-200 rounded-md px-3 py-2 bg-white text-gray-700 shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500" + /> + + + + {(statusFilter || typeInput) && ( + + )} +
+ + {error && ( +
+ {error} +
+ )} -
- - +
+
+ - - - - + {["Name", "Email", "Type", "Status", "Submitted"].map((h) => ( + + ))} - - - {applications.map((app) => ( - - + {loading ? ( + Array.from({ length: 5 }).map((_, i) => ( + + {Array.from({ length: 5 }).map((_, j) => ( + + ))} + + )) + ) : applications.length === 0 ? ( + + - - - - ))} + ) : ( + applications.map((app) => ( + + + + + + + + )) + )}
NameEmailRoleStatus + {h} +
- - {app.name} - +
+
+
+

No applications found

+ {(statusFilter || typeFilter) && ( +

+ Try adjusting your filters +

+ )}
{app.email}{app.role}{app.status}
+ + {app.submitterName} + + + {app.submitterEmail} + + {app.type} + + + {app.status} + + + {new Date(app.createdAt).toLocaleDateString("en-US", { + year: "numeric", + month: "short", + day: "numeric", + })} +
+ + {!loading && applications.length > 0 && ( +

+ Showing {applications.length} application{applications.length !== 1 ? "s" : ""} +

+ )}
); } From f410f0b2160dd46ecd5056012c9f833bbecff1ac Mon Sep 17 00:00:00 2001 From: Junnian Liu Date: Fri, 6 Mar 2026 12:16:25 -0800 Subject: [PATCH 5/5] added dynamic admin id page --- app/admin/applications/[id]/page.tsx | 268 ++++++++++++++++++--------- 1 file changed, 179 insertions(+), 89 deletions(-) diff --git a/app/admin/applications/[id]/page.tsx b/app/admin/applications/[id]/page.tsx index 9bd87a2..e286a7f 100644 --- a/app/admin/applications/[id]/page.tsx +++ b/app/admin/applications/[id]/page.tsx @@ -1,112 +1,202 @@ +"use client"; + import Link from "next/link"; -import { getApplicationById } from "@/services/mockApplications"; -<<<<<<< bsl-10-approve-reject-buttons -import ApproveRejectButtons from "@/components/admin/ApproveRejectButtons"; -======= ->>>>>>> main - -type Props = { - params: { id: string }; +import { useEffect, useState } from "react"; + +const STATUS_STYLES: Record = { + pending: "bg-amber-50 text-amber-700 ring-1 ring-amber-200", + approved: "bg-emerald-50 text-emerald-700 ring-1 ring-emerald-200", + rejected: "bg-red-50 text-red-600 ring-1 ring-red-200", }; -export default function ApplicationDetail({ params }: Props) { - const { id } = params; - const app = getApplicationById(id); - - if (!app) { - return ( -
-

Application not found

-

No mock application matches id {id}.

- - Back to applications - -
- ); +interface Application { + id: string; + type: string; + status: string; + submitterName: string; + submitterEmail: string; + payload: Record; + createdAt: string; + updatedAt: string; +} + +export default function ApplicationDetail({ params }: { params: { id: string } }) { + const [app, setApp] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [actioning, setActioning] = useState<"approved" | "rejected" | null>(null); + const [actionError, setActionError] = useState(null); + + async function handleAction(status: "approved" | "rejected") { + setActioning(status); + setActionError(null); + try { + const res = await fetch(`/api/admin/applications/${params.id}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ status }), + }); + if (!res.ok) { const b = await res.json(); throw new Error(b.error ?? "Failed to update"); } + const { data } = await res.json(); + setApp(data); + } catch (err: any) { + setActionError(err.message); + } finally { + setActioning(null); + } } + useEffect(() => { + async function fetchApp() { + try { + const res = await fetch(`/api/admin/applications/${params.id}`); + if (res.status === 404) { setError("not_found"); return; } + if (!res.ok) { const b = await res.json(); throw new Error(b.error ?? "Failed to load"); } + const { data } = await res.json(); + setApp(data); + } catch (err: any) { + setError(err.message); + } finally { + setLoading(false); + } + } + fetchApp(); + }, [params.id]); + + if (loading) return ; + + if (error === "not_found") return ( + +
+

Application not found

+ +
+
+ ); + + if (error) return ( + +
{error}
+
+ ); + + if (!app) return null; + return ( -
-
-

Application: {app.name}

-<<<<<<< bsl-10-approve-reject-buttons -
- - - Back - + + {/* Header */} +
+
+

{app.submitterName}

+

{app.submitterEmail}

-======= - - Back - ->>>>>>> main +
-
-
- - - - - - - - +
+
+

Summary

+
+ {app.id}} /> + {app.type}} /> + + {app.status} + + } /> + + +
+
-
-
-

Resume / Portfolio

- -
- -
-

Answers

-
- {app.answers && Object.entries(app.answers).length > 0 ? ( - Object.entries(app.answers).map(([q, a]) => ( -
-
{q}
-
{a}
-
- )) - ) : ( -
No answers provided.
- )} -
-
+
+
+

Submission Details

+
+
+ {Object.keys(app.payload).length === 0 ? ( +

No payload data.

+ ) : ( + Object.entries(app.payload).map(([key, value]) => ( +
+
+ {key.replace(/_/g, " ")} +
+
+ {typeof value === "object" ? JSON.stringify(value, null, 2) : String(value ?? "—")} +
+
+ )) + )} +
+
-
-

Notes

-
{app.notes ?? "—"}
+
+
+

Actions

+
+
+ {actionError && ( +
{actionError}
+ )} +
+ +
-
+ + ); +} + +function PageShell({ children }: { children: React.ReactNode }) { + return
{children}
; +} + +function BackLink() { + return ( + + Back to applications + ); } function DetailRow({ label, value }: { label: string; value: React.ReactNode }) { return ( -
-
{label}
-
{value}
+
+ {label} + {value}
); } + +function LoadingSkeleton() { + return ( +
+
+
+ {Array.from({ length: 5 }).map((_, i) => ( +
+ ))} +
+
+ {Array.from({ length: 4 }).map((_, i) => ( +
+ ))} +
+
+ ); +} \ No newline at end of file