From 3564c7db5237f9713ae38c92b7af8b4c12cc402a Mon Sep 17 00:00:00 2001 From: Andre Coullard Date: Sat, 23 May 2026 15:59:30 -0400 Subject: [PATCH 01/12] add in name column --- .../src/components/admin/applicants/ApplicantsTable.tsx | 5 +++++ apps/app-portal/src/lib/applicants/service.ts | 8 ++++++++ apps/app-portal/src/lib/applicants/types.ts | 1 + 3 files changed, 14 insertions(+) diff --git a/apps/app-portal/src/components/admin/applicants/ApplicantsTable.tsx b/apps/app-portal/src/components/admin/applicants/ApplicantsTable.tsx index 159dfff7..fe663511 100644 --- a/apps/app-portal/src/components/admin/applicants/ApplicantsTable.tsx +++ b/apps/app-portal/src/components/admin/applicants/ApplicantsTable.tsx @@ -29,6 +29,11 @@ interface ApplicantsTableProps { } const columns: ColumnDef[] = [ + { + accessorKey: "name", + header: "Name", + cell: ({ row }) => row.original.name ?? "—", + }, { accessorKey: "email", header: ({ column }) => ( diff --git a/apps/app-portal/src/lib/applicants/service.ts b/apps/app-portal/src/lib/applicants/service.ts index 05ce03c6..fd42a93f 100644 --- a/apps/app-portal/src/lib/applicants/service.ts +++ b/apps/app-portal/src/lib/applicants/service.ts @@ -90,9 +90,17 @@ const MOCK_APPLICANTS: ApplicantDetail[] = [ ]; function toSummary(detail: ApplicantDetail): ApplicantSummary { + const first = detail.applicationResponses?.["firstName"]; + const last = detail.applicationResponses?.["lastName"]; + const name = + first || last + ? [first, last].filter(Boolean).join(" ") + : undefined; + return { id: detail.id, email: detail.email, + name, applicationStatus: detail.applicationStatus, decisionStatus: detail.decisionStatus, rsvpStatus: detail.rsvpStatus, diff --git a/apps/app-portal/src/lib/applicants/types.ts b/apps/app-portal/src/lib/applicants/types.ts index dd0a1838..63976fdb 100644 --- a/apps/app-portal/src/lib/applicants/types.ts +++ b/apps/app-portal/src/lib/applicants/types.ts @@ -11,6 +11,7 @@ import type { export interface ApplicantSummary { id: string; email: string; + name?: string; applicationStatus: ApplicationStatus; decisionStatus?: DecisionStatus; rsvpStatus: RsvpStatus; From 79c613e9cf0bb27b2f3be9211d1ca5a54f17a0b0 Mon Sep 17 00:00:00 2001 From: Andre Coullard Date: Sun, 24 May 2026 00:22:38 -0400 Subject: [PATCH 02/12] make rows clickable --- .../admin/applicants/ApplicantsTable.tsx | 24 ++++++++----------- 1 file changed, 10 insertions(+), 14 deletions(-) diff --git a/apps/app-portal/src/components/admin/applicants/ApplicantsTable.tsx b/apps/app-portal/src/components/admin/applicants/ApplicantsTable.tsx index fe663511..0e632d1c 100644 --- a/apps/app-portal/src/components/admin/applicants/ApplicantsTable.tsx +++ b/apps/app-portal/src/components/admin/applicants/ApplicantsTable.tsx @@ -1,6 +1,6 @@ "use client"; -import Link from "next/link"; +import { useRouter } from "next/navigation"; import React, { useState } from "react"; import { ColumnDef, @@ -69,22 +69,11 @@ const columns: ColumnDef[] = [ ? new Date(row.original.appSubmissionTime).toLocaleDateString() : "—", }, - { - id: "actions", - header: "", - cell: ({ row }) => ( - - View - - ), - }, ]; export function ApplicantsTable({ rows }: ApplicantsTableProps) { const [sorting, setSorting] = useState([]); + const router = useRouter(); const table = useReactTable({ data: rows, @@ -129,7 +118,14 @@ export function ApplicantsTable({ rows }: ApplicantsTableProps) { ) : ( table.getRowModel().rows.map((row) => ( - + { + if (window.getSelection()?.toString()) return; + router.push(`/admin/applicants/${row.original.id}`); + }} + > {row.getVisibleCells().map((cell) => ( {flexRender( From ab356242910d108792b0031b18322c457815d7da Mon Sep 17 00:00:00 2001 From: Andre Coullard Date: Sun, 24 May 2026 18:23:21 -0400 Subject: [PATCH 03/12] add more mock data --- apps/app-portal/src/lib/applicants/service.ts | 270 +++++++++++++++++- 1 file changed, 267 insertions(+), 3 deletions(-) diff --git a/apps/app-portal/src/lib/applicants/service.ts b/apps/app-portal/src/lib/applicants/service.ts index fd42a93f..d26a522c 100644 --- a/apps/app-portal/src/lib/applicants/service.ts +++ b/apps/app-portal/src/lib/applicants/service.ts @@ -87,15 +87,279 @@ const MOCK_APPLICANTS: ApplicantDetail[] = [ appSubmissionTime: "2026-02-11T08:00:00.000Z", rsvpSubmissionTime: "2026-03-03T09:30:00.000Z", }, + { + id: "mock-009", + email: "tim.berners-lee@example.com", + applicationStatus: "submitted", + decisionStatus: "admitted", + rsvpStatus: "confirmed", + appSubmissionTime: "2026-02-09T10:00:00.000Z", + applicationResponses: { + firstName: "Tim", + lastName: "Berners-Lee", + school: "MIT", + year: "Senior", + }, + }, + { + id: "mock-010", + email: "edsger.dijkstra@example.com", + applicationStatus: "submitted", + decisionStatus: "pending", + rsvpStatus: "unconfirmed", + appSubmissionTime: "2026-02-16T14:00:00.000Z", + applicationResponses: { + firstName: "Edsger", + lastName: "Dijkstra", + school: "UT Austin", + year: "Junior", + }, + }, + { + id: "mock-011", + email: "john.mccarthy@example.com", + applicationStatus: "submitted", + decisionStatus: "waitlisted", + rsvpStatus: "unconfirmed", + appSubmissionTime: "2026-02-17T09:20:00.000Z", + applicationResponses: { + firstName: "John", + lastName: "McCarthy", + school: "Stanford", + year: "Senior", + }, + }, + { + id: "mock-012", + email: "claude.shannon@example.com", + applicationStatus: "submitted", + decisionStatus: "admitted", + rsvpStatus: "confirmed", + appSubmissionTime: "2026-02-08T13:45:00.000Z", + applicationResponses: { + firstName: "Claude", + lastName: "Shannon", + school: "Bell Labs University", + year: "Graduate", + }, + }, + { + id: "mock-013", + email: "donald.knuth@example.com", + applicationStatus: "incomplete", + rsvpStatus: "unconfirmed", + applicationResponses: { + firstName: "Donald", + lastName: "Knuth", + school: "Stanford", + year: "Graduate", + }, + }, + { + id: "mock-014", + email: "frances.allen@example.com", + applicationStatus: "submitted", + decisionStatus: "admitted", + rsvpStatus: "confirmed", + appSubmissionTime: "2026-02-12T07:30:00.000Z", + applicationResponses: { + firstName: "Frances", + lastName: "Allen", + school: "IBM Institute", + year: "Senior", + }, + }, + { + id: "mock-015", + email: "vint.cerf@example.com", + applicationStatus: "submitted", + decisionStatus: "declined", + rsvpStatus: "unconfirmed", + appSubmissionTime: "2026-02-13T11:00:00.000Z", + applicationResponses: { + firstName: "Vint", + lastName: "Cerf", + school: "UCLA", + year: "Junior", + }, + }, + { + id: "mock-016", + email: "guido.vanrossum@example.com", + applicationStatus: "submitted", + decisionStatus: "admitted", + rsvpStatus: "not-attending", + appSubmissionTime: "2026-02-10T16:00:00.000Z", + applicationResponses: { + firstName: "Guido", + lastName: "van Rossum", + school: "CWI University", + year: "Graduate", + }, + }, + { + id: "mock-017", + email: "james.gosling@example.com", + applicationStatus: "submitted", + decisionStatus: "pending", + rsvpStatus: "unconfirmed", + appSubmissionTime: "2026-02-18T10:30:00.000Z", + applicationResponses: { + firstName: "James", + lastName: "Gosling", + school: "University of Calgary", + year: "Senior", + }, + }, + { + id: "mock-018", + email: "bjarne.stroustrup@example.com", + applicationStatus: "not-started", + rsvpStatus: "unconfirmed", + applicationResponses: { + firstName: "Bjarne", + lastName: "Stroustrup", + school: "Aarhus University", + year: "Junior", + }, + }, + { + id: "mock-019", + email: "ken.thompson@example.com", + applicationStatus: "submitted", + decisionStatus: "admitted", + rsvpStatus: "confirmed", + appSubmissionTime: "2026-02-07T08:15:00.000Z", + applicationResponses: { + firstName: "Ken", + lastName: "Thompson", + school: "UC Berkeley", + year: "Senior", + }, + }, + { + id: "mock-020", + email: "rob.pike@example.com", + applicationStatus: "submitted", + decisionStatus: "admitted", + rsvpStatus: "confirmed", + appSubmissionTime: "2026-02-09T15:45:00.000Z", + applicationResponses: { + firstName: "Rob", + lastName: "Pike", + school: "University of Toronto", + year: "Junior", + }, + }, + { + id: "mock-021", + email: "niklaus.wirth@example.com", + applicationStatus: "submitted", + decisionStatus: "waitlisted", + rsvpStatus: "unconfirmed", + appSubmissionTime: "2026-02-19T12:00:00.000Z", + applicationResponses: { + firstName: "Niklaus", + lastName: "Wirth", + school: "ETH Zurich", + year: "Graduate", + }, + }, + { + id: "mock-022", + email: "tony.hoare@example.com", + applicationStatus: "submitted", + decisionStatus: "pending", + rsvpStatus: "unconfirmed", + appSubmissionTime: "2026-02-20T09:00:00.000Z", + applicationResponses: { + firstName: "Tony", + lastName: "Hoare", + school: "Oxford", + year: "Senior", + }, + }, + { + id: "mock-023", + email: "leslie.lamport@example.com", + applicationStatus: "incomplete", + rsvpStatus: "unconfirmed", + applicationResponses: { + firstName: "Leslie", + lastName: "Lamport", + school: "MIT", + year: "Graduate", + }, + }, + { + id: "mock-024", + email: "ivan.sutherland@example.com", + applicationStatus: "submitted", + decisionStatus: "admitted", + rsvpStatus: "confirmed", + appSubmissionTime: "2026-02-06T14:30:00.000Z", + applicationResponses: { + firstName: "Ivan", + lastName: "Sutherland", + school: "MIT", + year: "Graduate", + }, + }, + { + id: "mock-025", + email: "john.backus@example.com", + applicationStatus: "submitted", + decisionStatus: "declined", + rsvpStatus: "unconfirmed", + appSubmissionTime: "2026-02-21T11:30:00.000Z", + applicationResponses: { + firstName: "John", + lastName: "Backus", + school: "Columbia", + year: "Senior", + }, + }, + { + id: "mock-026", + email: "peter.naur@example.com", + applicationStatus: "submitted", + decisionStatus: "admitted", + rsvpStatus: "not-attending", + appSubmissionTime: "2026-02-22T08:45:00.000Z", + applicationResponses: { + firstName: "Peter", + lastName: "Naur", + school: "University of Copenhagen", + year: "Junior", + }, + }, + { + id: "mock-027", + email: "dana.scott@example.com", + applicationStatus: "not-started", + rsvpStatus: "unconfirmed", + }, + { + id: "mock-028", + email: "michael.rabin@example.com", + applicationStatus: "submitted", + decisionStatus: "pending", + rsvpStatus: "unconfirmed", + appSubmissionTime: "2026-02-23T16:00:00.000Z", + applicationResponses: { + firstName: "Michael", + lastName: "Rabin", + school: "NYU", + year: "Graduate", + }, + }, ]; function toSummary(detail: ApplicantDetail): ApplicantSummary { const first = detail.applicationResponses?.["firstName"]; const last = detail.applicationResponses?.["lastName"]; const name = - first || last - ? [first, last].filter(Boolean).join(" ") - : undefined; + first || last ? [first, last].filter(Boolean).join(" ") : undefined; return { id: detail.id, From 9797c83d0cb3a38af064c8a16a78cc4ef666bd79 Mon Sep 17 00:00:00 2001 From: ACoullard Date: Mon, 25 May 2026 01:33:43 -0400 Subject: [PATCH 04/12] add adjustable page number --- .../admin/applicants/ApplicantsTable.tsx | 26 ++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/apps/app-portal/src/components/admin/applicants/ApplicantsTable.tsx b/apps/app-portal/src/components/admin/applicants/ApplicantsTable.tsx index 0e632d1c..5089206d 100644 --- a/apps/app-portal/src/components/admin/applicants/ApplicantsTable.tsx +++ b/apps/app-portal/src/components/admin/applicants/ApplicantsTable.tsx @@ -23,6 +23,7 @@ import { TableRow, } from "@/components/ui/table"; import type { ApplicantSummary } from "@/lib/applicants/types"; +import { Input } from "@/components/ui/input"; interface ApplicantsTableProps { rows: ApplicantSummary[]; @@ -140,7 +141,7 @@ export function ApplicantsTable({ rows }: ApplicantsTableProps) { -
+
+
+ { + const raw = e.target.value; + if (raw === "") { + table.setPageIndex(0); + return; + } + if (!/^\d+$/.test(raw)) return; + + const parsed = Number.parseInt(raw, 10); + const lastIndex = Math.max(0, table.getPageCount() - 1); + const clamped = Math.min(Math.max(parsed - 1, 0), lastIndex); + table.setPageIndex(clamped); + }} + /> +
of {table.getPageCount()}
+
), }, - { accessorKey: "applicationStatus", header: "Application" }, + { + accessorKey: "applicationStatus", + header: "Application", + filterFn: "equalsString", + }, { accessorKey: "decisionStatus", header: "Decision", cell: ({ row }) => row.original.decisionStatus ?? "—", + filterFn: "equalsString", }, - { accessorKey: "rsvpStatus", header: "RSVP" }, + { accessorKey: "rsvpStatus", header: "RSVP", filterFn: "equalsString" }, { accessorKey: "appSubmissionTime", header: ({ column }) => ( @@ -75,14 +82,27 @@ const columns: ColumnDef[] = [ export function ApplicantsTable({ rows }: ApplicantsTableProps) { const [sorting, setSorting] = useState([]); const router = useRouter(); + const searchParams = useSearchParams(); + + const columnFilters: ColumnFiltersState = useMemo(() => { + const filters: ColumnFiltersState = []; + const status = searchParams.get("status"); + const decision = searchParams.get("decision"); + const rsvp = searchParams.get("rsvp"); + if (status) filters.push({ id: "applicationStatus", value: status }); + if (decision) filters.push({ id: "decisionStatus", value: decision }); + if (rsvp) filters.push({ id: "rsvpStatus", value: rsvp }); + return filters; + }, [searchParams]); const table = useReactTable({ data: rows, columns, - state: { sorting }, + state: { sorting, columnFilters }, onSortingChange: setSorting, getCoreRowModel: getCoreRowModel(), getSortedRowModel: getSortedRowModel(), + getFilteredRowModel: getFilteredRowModel(), getPaginationRowModel: getPaginationRowModel(), initialState: { pagination: { pageSize: 25 } }, }); From 8e1c2f7123761ed4149351510bb5fcc09c0b6600 Mon Sep 17 00:00:00 2001 From: ACoullard Date: Mon, 25 May 2026 14:55:21 -0400 Subject: [PATCH 06/12] add clear button and adjust sorting --- .../admin/applicants/ApplicantsFilters.tsx | 19 +++++++++++++++++++ .../admin/applicants/ApplicantsTable.tsx | 14 ++++++++++++-- 2 files changed, 31 insertions(+), 2 deletions(-) diff --git a/apps/app-portal/src/components/admin/applicants/ApplicantsFilters.tsx b/apps/app-portal/src/components/admin/applicants/ApplicantsFilters.tsx index 3cdb8601..729bdd73 100644 --- a/apps/app-portal/src/components/admin/applicants/ApplicantsFilters.tsx +++ b/apps/app-portal/src/components/admin/applicants/ApplicantsFilters.tsx @@ -3,6 +3,7 @@ import React from "react"; import { usePathname, useRouter, useSearchParams } from "next/navigation"; +import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Select, @@ -41,6 +42,19 @@ export function ApplicantsFilters() { const decision = searchParams.get("decision") ?? ALL; const rsvp = searchParams.get("rsvp") ?? ALL; + const hasActiveFilters = status !== ALL || decision !== ALL || rsvp !== ALL; + + const clearFilters = () => { + const next = new URLSearchParams(searchParams.toString()); + next.delete("status"); + next.delete("decision"); + next.delete("rsvp"); + const query = next.toString(); + router.replace(query ? `${pathname}?${query}` : pathname, { + scroll: false, + }); + }; + return (
@@ -83,6 +97,11 @@ export function ApplicantsFilters() { ))} + {hasActiveFilters && ( + + )}
); } diff --git a/apps/app-portal/src/components/admin/applicants/ApplicantsTable.tsx b/apps/app-portal/src/components/admin/applicants/ApplicantsTable.tsx index 9e1abe6c..365654e7 100644 --- a/apps/app-portal/src/components/admin/applicants/ApplicantsTable.tsx +++ b/apps/app-portal/src/components/admin/applicants/ApplicantsTable.tsx @@ -34,7 +34,15 @@ interface ApplicantsTableProps { const columns: ColumnDef[] = [ { accessorKey: "name", - header: "Name", + header: ({ column }) => ( + + ), cell: ({ row }) => row.original.name ?? "—", }, { @@ -80,7 +88,9 @@ const columns: ColumnDef[] = [ ]; export function ApplicantsTable({ rows }: ApplicantsTableProps) { - const [sorting, setSorting] = useState([]); + const [sorting, setSorting] = useState([ + { id: "appSubmissionTime", desc: true }, + ]); const router = useRouter(); const searchParams = useSearchParams(); From 66be623491c7b7ad57ca07a1e3eae460b7c00960 Mon Sep 17 00:00:00 2001 From: ACoullard Date: Mon, 25 May 2026 15:33:24 -0400 Subject: [PATCH 07/12] Add pagination and sorting to query params and ensure that it is relfected when returning to table page --- .../(admin)/admin/applicants/[id]/page.tsx | 15 ++-- .../admin/applicants/ApplicantsFilters.tsx | 2 + .../admin/applicants/ApplicantsTable.tsx | 76 ++++++++++++++++--- 3 files changed, 79 insertions(+), 14 deletions(-) diff --git a/apps/app-portal/src/app/(admin)/admin/applicants/[id]/page.tsx b/apps/app-portal/src/app/(admin)/admin/applicants/[id]/page.tsx index 7f7b93b9..2b2b0b7c 100644 --- a/apps/app-portal/src/app/(admin)/admin/applicants/[id]/page.tsx +++ b/apps/app-portal/src/app/(admin)/admin/applicants/[id]/page.tsx @@ -10,19 +10,24 @@ import { getApplicant } from "@/lib/applicants/service"; interface PageProps { params: { id: string }; + searchParams: { from?: string }; } -export default async function ApplicantDetailPage({ params }: PageProps) { +export default async function ApplicantDetailPage({ + params, + searchParams, +}: PageProps) { const applicant = await getApplicant(params.id); if (!applicant) notFound(); + const backHref = searchParams.from + ? `/admin/applicants?${searchParams.from}` + : "/admin/applicants"; + return (
- + Back to applicants
diff --git a/apps/app-portal/src/components/admin/applicants/ApplicantsFilters.tsx b/apps/app-portal/src/components/admin/applicants/ApplicantsFilters.tsx index 729bdd73..fd96c4a7 100644 --- a/apps/app-portal/src/components/admin/applicants/ApplicantsFilters.tsx +++ b/apps/app-portal/src/components/admin/applicants/ApplicantsFilters.tsx @@ -32,6 +32,7 @@ export function ApplicantsFilters() { } else { next.set(key, value); } + next.delete("page"); const query = next.toString(); router.replace(query ? `${pathname}?${query}` : pathname, { scroll: false, @@ -49,6 +50,7 @@ export function ApplicantsFilters() { next.delete("status"); next.delete("decision"); next.delete("rsvp"); + next.delete("page"); const query = next.toString(); router.replace(query ? `${pathname}?${query}` : pathname, { scroll: false, diff --git a/apps/app-portal/src/components/admin/applicants/ApplicantsTable.tsx b/apps/app-portal/src/components/admin/applicants/ApplicantsTable.tsx index 365654e7..70ef0ce9 100644 --- a/apps/app-portal/src/components/admin/applicants/ApplicantsTable.tsx +++ b/apps/app-portal/src/components/admin/applicants/ApplicantsTable.tsx @@ -1,10 +1,12 @@ "use client"; -import { useRouter, useSearchParams } from "next/navigation"; -import React, { useMemo, useState } from "react"; +import { usePathname, useRouter, useSearchParams } from "next/navigation"; +import React, { useMemo } from "react"; import { ColumnDef, ColumnFiltersState, + OnChangeFn, + PaginationState, SortingState, flexRender, getCoreRowModel, @@ -87,11 +89,12 @@ const columns: ColumnDef[] = [ }, ]; +const PAGE_SIZE = 25; +const DEFAULT_SORT: SortingState = [{ id: "appSubmissionTime", desc: true }]; + export function ApplicantsTable({ rows }: ApplicantsTableProps) { - const [sorting, setSorting] = useState([ - { id: "appSubmissionTime", desc: true }, - ]); const router = useRouter(); + const pathname = usePathname(); const searchParams = useSearchParams(); const columnFilters: ColumnFiltersState = useMemo(() => { @@ -105,16 +108,67 @@ export function ApplicantsTable({ rows }: ApplicantsTableProps) { return filters; }, [searchParams]); + const sorting: SortingState = useMemo(() => { + const sort = searchParams.get("sort"); + if (!sort) return DEFAULT_SORT; + const [id, dir] = sort.split("."); + if (!id) return DEFAULT_SORT; + return [{ id, desc: dir === "desc" }]; + }, [searchParams]); + + const pagination: PaginationState = useMemo(() => { + const page = searchParams.get("page"); + const parsed = page ? Number.parseInt(page, 10) : 1; + const pageIndex = Number.isFinite(parsed) && parsed > 0 ? parsed - 1 : 0; + return { pageIndex, pageSize: PAGE_SIZE }; + }, [searchParams]); + + const writeParams = (mutate: (p: URLSearchParams) => void) => { + const next = new URLSearchParams(searchParams.toString()); + mutate(next); + const query = next.toString(); + router.replace(query ? `${pathname}?${query}` : pathname, { + scroll: false, + }); + }; + + const onSortingChange: OnChangeFn = (updater) => { + const nextSorting = + typeof updater === "function" ? updater(sorting) : updater; + writeParams((p) => { + const s = nextSorting[0]; + if (!s) { + p.delete("sort"); + } else { + p.set("sort", `${s.id}.${s.desc ? "desc" : "asc"}`); + } + p.delete("page"); + }); + }; + + const onPaginationChange: OnChangeFn = (updater) => { + const nextPagination = + typeof updater === "function" ? updater(pagination) : updater; + writeParams((p) => { + if (nextPagination.pageIndex <= 0) { + p.delete("page"); + } else { + p.set("page", String(nextPagination.pageIndex + 1)); + } + }); + }; + const table = useReactTable({ data: rows, columns, - state: { sorting, columnFilters }, - onSortingChange: setSorting, + state: { sorting, columnFilters, pagination }, + onSortingChange, + onPaginationChange, + autoResetPageIndex: false, getCoreRowModel: getCoreRowModel(), getSortedRowModel: getSortedRowModel(), getFilteredRowModel: getFilteredRowModel(), getPaginationRowModel: getPaginationRowModel(), - initialState: { pagination: { pageSize: 25 } }, }); return ( @@ -154,7 +208,11 @@ export function ApplicantsTable({ rows }: ApplicantsTableProps) { className="cursor-pointer" onClick={() => { if (window.getSelection()?.toString()) return; - router.push(`/admin/applicants/${row.original.id}`); + const qs = searchParams.toString(); + const href = qs + ? `/admin/applicants/${row.original.id}?from=${encodeURIComponent(qs)}` + : `/admin/applicants/${row.original.id}`; + router.push(href); }} > {row.getVisibleCells().map((cell) => ( From 7efc3206f9d5b688c572cb442c07a44839a8c142 Mon Sep 17 00:00:00 2001 From: ACoullard Date: Mon, 25 May 2026 19:40:35 -0400 Subject: [PATCH 08/12] Add loading state handling --- .../(admin)/admin/applicants/[id]/loading.tsx | 10 +++++ .../app/(admin)/admin/applicants/loading.tsx | 20 ++++++++++ .../src/app/(admin)/admin/applicants/page.tsx | 17 ++++++-- .../admin/applicants/ApplicantsFilters.tsx | 40 ++++++++++--------- .../admin/applicants/ApplicantsTable.tsx | 32 ++++++++++----- 5 files changed, 87 insertions(+), 32 deletions(-) create mode 100644 apps/app-portal/src/app/(admin)/admin/applicants/[id]/loading.tsx create mode 100644 apps/app-portal/src/app/(admin)/admin/applicants/loading.tsx diff --git a/apps/app-portal/src/app/(admin)/admin/applicants/[id]/loading.tsx b/apps/app-portal/src/app/(admin)/admin/applicants/[id]/loading.tsx new file mode 100644 index 00000000..89759e22 --- /dev/null +++ b/apps/app-portal/src/app/(admin)/admin/applicants/[id]/loading.tsx @@ -0,0 +1,10 @@ +import { Loader2 } from "lucide-react"; +import React from "react"; + +export default function ApplicantDetailLoading() { + return ( +
+ +
+ ); +} diff --git a/apps/app-portal/src/app/(admin)/admin/applicants/loading.tsx b/apps/app-portal/src/app/(admin)/admin/applicants/loading.tsx new file mode 100644 index 00000000..8b077123 --- /dev/null +++ b/apps/app-portal/src/app/(admin)/admin/applicants/loading.tsx @@ -0,0 +1,20 @@ +import React from "react"; + +import { ApplicantsFilters } from "@/components/admin/applicants/ApplicantsFilters"; +import { ApplicantsTable } from "@/components/admin/applicants/ApplicantsTable"; +import { ExportButtons } from "@/components/admin/applicants/ExportButtons"; + +export default function ApplicantsLoading() { + return ( +
+
+
+

Applicants

+
+ +
+ + +
+ ); +} diff --git a/apps/app-portal/src/app/(admin)/admin/applicants/page.tsx b/apps/app-portal/src/app/(admin)/admin/applicants/page.tsx index 907029b9..f9ba0107 100644 --- a/apps/app-portal/src/app/(admin)/admin/applicants/page.tsx +++ b/apps/app-portal/src/app/(admin)/admin/applicants/page.tsx @@ -1,24 +1,33 @@ -import React from "react"; +import React, { Suspense } from "react"; import { ApplicantsFilters } from "@/components/admin/applicants/ApplicantsFilters"; import { ApplicantsTable } from "@/components/admin/applicants/ApplicantsTable"; import { ExportButtons } from "@/components/admin/applicants/ExportButtons"; import { listApplicants } from "@/lib/applicants/service"; -export default async function ApplicantsPage() { +async function ApplicantsData() { const { rows, total } = await listApplicants(); + return ( + <> +

{total} total

+ + + ); +} +export default function ApplicantsPage() { return (

Applicants

-

{total} total

- + }> + +
); } diff --git a/apps/app-portal/src/components/admin/applicants/ApplicantsFilters.tsx b/apps/app-portal/src/components/admin/applicants/ApplicantsFilters.tsx index fd96c4a7..746f6d6a 100644 --- a/apps/app-portal/src/components/admin/applicants/ApplicantsFilters.tsx +++ b/apps/app-portal/src/components/admin/applicants/ApplicantsFilters.tsx @@ -1,7 +1,7 @@ "use client"; import React from "react"; -import { usePathname, useRouter, useSearchParams } from "next/navigation"; +import { usePathname, useSearchParams } from "next/navigation"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; @@ -21,21 +21,26 @@ import { const ALL = "all"; export function ApplicantsFilters() { - const router = useRouter(); const pathname = usePathname(); const searchParams = useSearchParams(); - const setParam = (key: string, value: string) => { + const writeParams = (mutate: (p: URLSearchParams) => void) => { const next = new URLSearchParams(searchParams.toString()); - if (!value || value === ALL) { - next.delete(key); - } else { - next.set(key, value); - } - next.delete("page"); + mutate(next); const query = next.toString(); - router.replace(query ? `${pathname}?${query}` : pathname, { - scroll: false, + const url = query ? `${pathname}?${query}` : pathname; + // Bypass router.replace to avoid an RSC refetch — filter state is client-only. + window.history.replaceState(null, "", url); + }; + + const setParam = (key: string, value: string) => { + writeParams((p) => { + if (!value || value === ALL) { + p.delete(key); + } else { + p.set(key, value); + } + p.delete("page"); }); }; @@ -46,14 +51,11 @@ export function ApplicantsFilters() { const hasActiveFilters = status !== ALL || decision !== ALL || rsvp !== ALL; const clearFilters = () => { - const next = new URLSearchParams(searchParams.toString()); - next.delete("status"); - next.delete("decision"); - next.delete("rsvp"); - next.delete("page"); - const query = next.toString(); - router.replace(query ? `${pathname}?${query}` : pathname, { - scroll: false, + writeParams((p) => { + p.delete("status"); + p.delete("decision"); + p.delete("rsvp"); + p.delete("page"); }); }; diff --git a/apps/app-portal/src/components/admin/applicants/ApplicantsTable.tsx b/apps/app-portal/src/components/admin/applicants/ApplicantsTable.tsx index 70ef0ce9..6ec0e01e 100644 --- a/apps/app-portal/src/components/admin/applicants/ApplicantsTable.tsx +++ b/apps/app-portal/src/components/admin/applicants/ApplicantsTable.tsx @@ -30,9 +30,11 @@ import type { ApplicantSummary } from "@/lib/applicants/types"; import { Input } from "@/components/ui/input"; interface ApplicantsTableProps { - rows: ApplicantSummary[]; + rows?: ApplicantSummary[]; } +const SKELETON_ROW_COUNT = 8; + const columns: ColumnDef[] = [ { accessorKey: "name", @@ -96,6 +98,7 @@ export function ApplicantsTable({ rows }: ApplicantsTableProps) { const router = useRouter(); const pathname = usePathname(); const searchParams = useSearchParams(); + const isLoading = rows === undefined; const columnFilters: ColumnFiltersState = useMemo(() => { const filters: ColumnFiltersState = []; @@ -127,9 +130,9 @@ export function ApplicantsTable({ rows }: ApplicantsTableProps) { const next = new URLSearchParams(searchParams.toString()); mutate(next); const query = next.toString(); - router.replace(query ? `${pathname}?${query}` : pathname, { - scroll: false, - }); + const url = query ? `${pathname}?${query}` : pathname; + // Bypass router.replace to avoid an RSC refetch — filter/sort/page state is client-only. + window.history.replaceState(null, "", url); }; const onSortingChange: OnChangeFn = (updater) => { @@ -159,7 +162,7 @@ export function ApplicantsTable({ rows }: ApplicantsTableProps) { }; const table = useReactTable({ - data: rows, + data: rows ?? [], columns, state: { sorting, columnFilters, pagination }, onSortingChange, @@ -192,7 +195,17 @@ export function ApplicantsTable({ rows }: ApplicantsTableProps) { ))} - {table.getRowModel().rows.length === 0 ? ( + {isLoading ? ( + Array.from({ length: SKELETON_ROW_COUNT }).map((_, i) => ( + + {columns.map((_col, j) => ( + +
+ + ))} + + )) + ) : table.getRowModel().rows.length === 0 ? ( table.previousPage()} - disabled={!table.getCanPreviousPage()} + disabled={isLoading || !table.getCanPreviousPage()} > Previous @@ -244,6 +257,7 @@ export function ApplicantsTable({ rows }: ApplicantsTableProps) { type="text" inputMode="numeric" pattern="[0-9]*" + disabled={isLoading} value={table.getState().pagination.pageIndex + 1} onChange={(e) => { const raw = e.target.value; @@ -259,13 +273,13 @@ export function ApplicantsTable({ rows }: ApplicantsTableProps) { table.setPageIndex(clamped); }} /> -
of {table.getPageCount()}
+
of {isLoading ? "—" : table.getPageCount()}
From 62c876010c4c58e9686e2fd5917c0325243f1233 Mon Sep 17 00:00:00 2001 From: ACoullard Date: Mon, 25 May 2026 22:42:06 -0400 Subject: [PATCH 09/12] Add filter through search --- .../admin/applicants/ApplicantsFilters.tsx | 12 ++++++++++-- .../admin/applicants/ApplicantsTable.tsx | 16 +++++++++++++++- 2 files changed, 25 insertions(+), 3 deletions(-) diff --git a/apps/app-portal/src/components/admin/applicants/ApplicantsFilters.tsx b/apps/app-portal/src/components/admin/applicants/ApplicantsFilters.tsx index 746f6d6a..502623ad 100644 --- a/apps/app-portal/src/components/admin/applicants/ApplicantsFilters.tsx +++ b/apps/app-portal/src/components/admin/applicants/ApplicantsFilters.tsx @@ -44,14 +44,17 @@ export function ApplicantsFilters() { }); }; + const q = searchParams.get("q") ?? ""; const status = searchParams.get("status") ?? ALL; const decision = searchParams.get("decision") ?? ALL; const rsvp = searchParams.get("rsvp") ?? ALL; - const hasActiveFilters = status !== ALL || decision !== ALL || rsvp !== ALL; + const hasActiveFilters = + q !== "" || status !== ALL || decision !== ALL || rsvp !== ALL; const clearFilters = () => { writeParams((p) => { + p.delete("q"); p.delete("status"); p.delete("decision"); p.delete("rsvp"); @@ -61,7 +64,12 @@ export function ApplicantsFilters() { return (
- + setParam("q", e.target.value)} + /> + + + + + {DECISION_STATUSES.map((s) => ( + + {s} + + ))} + + + + ); } diff --git a/apps/app-portal/src/components/admin/applicants/RsvpEditor.tsx b/apps/app-portal/src/components/admin/applicants/RsvpEditor.tsx index 4472b459..bb3f402f 100644 --- a/apps/app-portal/src/components/admin/applicants/RsvpEditor.tsx +++ b/apps/app-portal/src/components/admin/applicants/RsvpEditor.tsx @@ -1,10 +1,17 @@ "use client"; +import { useRouter } from "next/navigation"; import React from "react"; import { toast } from "sonner"; -import { Button } from "@/components/ui/button"; -import type { RsvpStatus } from "@/lib/types/user"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { RSVP_STATUSES, type RsvpStatus } from "@/lib/types/user"; interface RsvpEditorProps { applicantId: string; @@ -12,21 +19,45 @@ interface RsvpEditorProps { } export function RsvpEditor({ applicantId, value }: RsvpEditorProps) { + const router = useRouter(); + const [current, setCurrent] = React.useState(value); + const [isSaving, setIsSaving] = React.useState(false); + + async function handleChange(next: RsvpStatus) { + const prev = current; + setCurrent(next); + setIsSaving(true); + try { + const res = await fetch(`/api/v1/applicants/${applicantId}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ rsvpStatus: next }), + }); + if (!res.ok) throw new Error("Request failed"); + toast.success(`RSVP updated to ${next}`); + router.refresh(); + } catch { + setCurrent(prev); + toast.error("Could not update RSVP. Please try again."); + } finally { + setIsSaving(false); + } + } + return ( -
-
- - RSVP - - {value} -
- -
+ <> + + ); } diff --git a/apps/app-portal/src/components/ui/card.tsx b/apps/app-portal/src/components/ui/card.tsx index dc5a24d4..f167f2aa 100644 --- a/apps/app-portal/src/components/ui/card.tsx +++ b/apps/app-portal/src/components/ui/card.tsx @@ -57,7 +57,7 @@ const CardContent = React.forwardRef< HTMLDivElement, React.HTMLAttributes >(({ className, ...props }, ref) => ( -
+
)); CardContent.displayName = "CardContent"; diff --git a/apps/app-portal/src/lib/applicants/service.ts b/apps/app-portal/src/lib/applicants/service.ts index 22bfa78e..ccdcde13 100644 --- a/apps/app-portal/src/lib/applicants/service.ts +++ b/apps/app-portal/src/lib/applicants/service.ts @@ -391,6 +391,7 @@ export async function updateApplicant( id: string, update: ApplicantUpdate, ): Promise { + await new Promise((resolve) => setTimeout(resolve, 500)); throw new Error( `Not implemented: updateApplicant(${id}, ${JSON.stringify(update)})`, ); From 95c7d7f080bc0868d24ef68357f241447cb6f5cf Mon Sep 17 00:00:00 2001 From: ACoullard Date: Sun, 31 May 2026 01:57:10 -0400 Subject: [PATCH 12/12] Add download attribute to export buttons --- .../src/components/admin/applicants/ExportButtons.tsx | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/apps/app-portal/src/components/admin/applicants/ExportButtons.tsx b/apps/app-portal/src/components/admin/applicants/ExportButtons.tsx index fadf472d..ce2fcbbd 100644 --- a/apps/app-portal/src/components/admin/applicants/ExportButtons.tsx +++ b/apps/app-portal/src/components/admin/applicants/ExportButtons.tsx @@ -8,10 +8,14 @@ export function ExportButtons() { return ( );