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/[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/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/app/api/v1/applicants/[id]/route.ts b/apps/app-portal/src/app/api/v1/applicants/[id]/route.ts index d59f279a..da5ca91b 100644 --- a/apps/app-portal/src/app/api/v1/applicants/[id]/route.ts +++ b/apps/app-portal/src/app/api/v1/applicants/[id]/route.ts @@ -1,6 +1,21 @@ import { NextRequest, NextResponse } from "next/server"; -import { getApplicant } from "@/lib/applicants/service"; +import { getApplicant, updateApplicant } from "@/lib/applicants/service"; +import type { ApplicantUpdate } from "@/lib/applicants/types"; +import { + DECISION_STATUSES, + RSVP_STATUSES, + type DecisionStatus, + type RsvpStatus, +} from "@/lib/types/user"; + +function isDecisionStatus(v: unknown): v is DecisionStatus { + return DECISION_STATUSES.includes(v as DecisionStatus); +} + +function isRsvpStatus(v: unknown): v is RsvpStatus { + return RSVP_STATUSES.includes(v as RsvpStatus); +} export async function GET( _req: NextRequest, @@ -13,6 +28,46 @@ export async function GET( return NextResponse.json(applicant); } -export async function POST() { - return NextResponse.json({ error: "Not implemented" }, { status: 501 }); +export async function POST( + req: NextRequest, + { params }: { params: { id: string } }, +) { + let body: unknown; + try { + body = await req.json(); + } catch { + return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 }); + } + + const { decisionStatus, rsvpStatus } = (body ?? {}) as Record< + string, + unknown + >; + const update: ApplicantUpdate = {}; + + if (decisionStatus !== undefined) { + if (!isDecisionStatus(decisionStatus)) { + return NextResponse.json( + { error: "Invalid decisionStatus" }, + { status: 400 }, + ); + } + update.decisionStatus = decisionStatus; + } + + if (rsvpStatus !== undefined) { + if (!isRsvpStatus(rsvpStatus)) { + return NextResponse.json( + { error: "Invalid rsvpStatus" }, + { status: 400 }, + ); + } + update.rsvpStatus = rsvpStatus; + } + + const updated = await updateApplicant(params.id, update); + if (!updated) { + return NextResponse.json({ error: "Not found" }, { status: 404 }); + } + return NextResponse.json(updated); } diff --git a/apps/app-portal/src/components/admin/applicants/ApplicantDetail.tsx b/apps/app-portal/src/components/admin/applicants/ApplicantDetail.tsx index b1162ba3..f208a9f7 100644 --- a/apps/app-portal/src/components/admin/applicants/ApplicantDetail.tsx +++ b/apps/app-portal/src/components/admin/applicants/ApplicantDetail.tsx @@ -27,6 +27,27 @@ function ResponseList({ responses }: { responses?: Record }) { ); } +function Resume({ resume }: { resume?: ApplicantDetailType["resume"] }) { + return ( +
+
+
+ Resume +
+
{resume?.filename ?? "—"}
+
+ {resume ? ( + + Download + + ) : null} +
+ ); +} + export function ApplicantDetail({ applicant }: ApplicantDetailProps) { return (
@@ -64,8 +85,9 @@ export function ApplicantDetail({ applicant }: ApplicantDetailProps) { Application responses - + + diff --git a/apps/app-portal/src/components/admin/applicants/ApplicantsFilters.tsx b/apps/app-portal/src/components/admin/applicants/ApplicantsFilters.tsx index 66d1654e..502623ad 100644 --- a/apps/app-portal/src/components/admin/applicants/ApplicantsFilters.tsx +++ b/apps/app-portal/src/components/admin/applicants/ApplicantsFilters.tsx @@ -1,7 +1,9 @@ "use client"; import React from "react"; +import { usePathname, useSearchParams } from "next/navigation"; +import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Select, @@ -10,17 +12,70 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; -import { APPLICATION_STATUSES, DECISION_STATUSES } from "@/lib/types/user"; +import { + APPLICATION_STATUSES, + DECISION_STATUSES, + RSVP_STATUSES, +} from "@/lib/types/user"; + +const ALL = "all"; export function ApplicantsFilters() { + const pathname = usePathname(); + const searchParams = useSearchParams(); + + const writeParams = (mutate: (p: URLSearchParams) => void) => { + const next = new URLSearchParams(searchParams.toString()); + mutate(next); + const query = next.toString(); + 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"); + }); + }; + + 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 = + q !== "" || status !== ALL || decision !== ALL || rsvp !== ALL; + + const clearFilters = () => { + writeParams((p) => { + p.delete("q"); + p.delete("status"); + p.delete("decision"); + p.delete("rsvp"); + p.delete("page"); + }); + }; + return (
- - setParam("q", e.target.value)} + /> + - setParam("decision", v)}> + All decisions {DECISION_STATUSES.map((s) => ( {s} @@ -40,6 +96,24 @@ 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 159dfff7..7cb4fc9e 100644 --- a/apps/app-portal/src/components/admin/applicants/ApplicantsTable.tsx +++ b/apps/app-portal/src/components/admin/applicants/ApplicantsTable.tsx @@ -1,12 +1,17 @@ "use client"; -import Link from "next/link"; -import React, { useState } from "react"; +import { usePathname, useRouter, useSearchParams } from "next/navigation"; +import React, { useMemo } from "react"; import { ColumnDef, + ColumnFiltersState, + FilterFn, + OnChangeFn, + PaginationState, SortingState, flexRender, getCoreRowModel, + getFilteredRowModel, getPaginationRowModel, getSortedRowModel, useReactTable, @@ -23,12 +28,28 @@ import { TableRow, } from "@/components/ui/table"; 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", + header: ({ column }) => ( + + ), + cell: ({ row }) => row.original.name ?? "—", + }, { accessorKey: "email", header: ({ column }) => ( @@ -41,13 +62,18 @@ const columns: ColumnDef[] = [ ), }, - { 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 }) => ( @@ -64,32 +90,102 @@ const columns: ColumnDef[] = [ ? new Date(row.original.appSubmissionTime).toLocaleDateString() : "—", }, - { - id: "actions", - header: "", - cell: ({ row }) => ( - - View - - ), - }, ]; +const PAGE_SIZE = 25; +const DEFAULT_SORT: SortingState = [{ id: "appSubmissionTime", desc: true }]; + +const nameEmailFilter: FilterFn = (row, _columnId, value) => { + const q = String(value ?? "") + .trim() + .toLowerCase(); + if (!q) return true; + const name = row.original.name?.toLowerCase() ?? ""; + const email = row.original.email.toLowerCase(); + return name.includes(q) || email.includes(q); +}; + export function ApplicantsTable({ rows }: ApplicantsTableProps) { - const [sorting, setSorting] = useState([]); + const router = useRouter(); + const pathname = usePathname(); + const searchParams = useSearchParams(); + const isLoading = rows === undefined; + + const globalFilter = searchParams.get("q") ?? ""; + + 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 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(); + 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) => { + 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, + data: rows ?? [], columns, - state: { sorting }, - onSortingChange: setSorting, + state: { sorting, columnFilters, globalFilter, pagination }, + onSortingChange, + onPaginationChange, + globalFilterFn: nameEmailFilter, + autoResetPageIndex: false, getCoreRowModel: getCoreRowModel(), getSortedRowModel: getSortedRowModel(), + getFilteredRowModel: getFilteredRowModel(), getPaginationRowModel: getPaginationRowModel(), - initialState: { pagination: { pageSize: 25 } }, }); return ( @@ -113,7 +209,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.getRowModel().rows.map((row) => ( - + { + if (window.getSelection()?.toString()) return; + 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) => ( {flexRender( @@ -139,20 +256,44 @@ 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 {isLoading ? "—" : table.getPageCount()}
+
diff --git a/apps/app-portal/src/components/admin/applicants/DecisionEditor.tsx b/apps/app-portal/src/components/admin/applicants/DecisionEditor.tsx index 459fa499..696ac2f4 100644 --- a/apps/app-portal/src/components/admin/applicants/DecisionEditor.tsx +++ b/apps/app-portal/src/components/admin/applicants/DecisionEditor.tsx @@ -1,10 +1,17 @@ "use client"; +import { useRouter } from "next/navigation"; import React from "react"; -import { toast } from "sonner"; +import { Toaster, toast } from "sonner"; -import { Button } from "@/components/ui/button"; -import type { DecisionStatus } from "@/lib/types/user"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { DECISION_STATUSES, type DecisionStatus } from "@/lib/types/user"; interface DecisionEditorProps { applicantId: string; @@ -12,21 +19,48 @@ interface DecisionEditorProps { } export function DecisionEditor({ applicantId, value }: DecisionEditorProps) { + const router = useRouter(); + const [current, setCurrent] = React.useState( + value ?? "pending", + ); + const [isSaving, setIsSaving] = React.useState(false); + + async function handleChange(next: DecisionStatus) { + 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({ decisionStatus: next }), + }); + if (!res.ok) throw new Error("Request failed"); + toast.success(`Decision updated to ${next}`); + router.refresh(); + } catch { + setCurrent(prev); + toast.error("Could not update decision. Please try again."); + } finally { + setIsSaving(false); + } + } + return ( -
-
- - Decision - - {value ?? "—"} -
- -
+ <> + + + ); } 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 ( ); 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 05ce03c6..ccdcde13 100644 --- a/apps/app-portal/src/lib/applicants/service.ts +++ b/apps/app-portal/src/lib/applicants/service.ts @@ -14,6 +14,7 @@ const MOCK_APPLICANTS: ApplicantDetail[] = [ rsvpStatus: "confirmed", appSubmissionTime: "2026-02-12T14:03:00.000Z", rsvpSubmissionTime: "2026-03-01T18:22:00.000Z", + resume: { id: "mock-upload-001", filename: "ada-lovelace-resume.pdf" }, applicationResponses: { firstName: "Ada", lastName: "Lovelace", @@ -87,12 +88,286 @@ 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", + resume: { id: "mock-upload-002", filename: "tim-berners-lee-resume.pdf" }, + 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", + resume: { id: "mock-upload-003", filename: "ken-thompson-resume.pdf" }, + 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; + return { id: detail.id, email: detail.email, + name, applicationStatus: detail.applicationStatus, decisionStatus: detail.decisionStatus, rsvpStatus: detail.rsvpStatus, @@ -101,6 +376,7 @@ function toSummary(detail: ApplicantDetail): ApplicantSummary { } export async function listApplicants(): Promise { + await new Promise((resolve) => setTimeout(resolve, 1000)); const rows = MOCK_APPLICANTS.map(toSummary); return { rows, total: rows.length, page: 1, pageSize: rows.length }; } @@ -115,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)})`, ); diff --git a/apps/app-portal/src/lib/applicants/types.ts b/apps/app-portal/src/lib/applicants/types.ts index dd0a1838..977c1d32 100644 --- a/apps/app-portal/src/lib/applicants/types.ts +++ b/apps/app-portal/src/lib/applicants/types.ts @@ -11,16 +11,23 @@ import type { export interface ApplicantSummary { id: string; email: string; + name?: string; applicationStatus: ApplicationStatus; decisionStatus?: DecisionStatus; rsvpStatus: RsvpStatus; appSubmissionTime?: string; } +export interface UploadedFile { + id: string; + filename: string; +} + export interface ApplicantDetail extends ApplicantSummary { applicationResponses?: ApplicationResponse; postAcceptanceResponses?: PostAcceptanceResponse; rsvpSubmissionTime?: string; + resume?: UploadedFile; } export interface ApplicantFilters {