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 (
+
-
+
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 (
+
+ );
+}
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 (
-
-
);
}
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 ?? "—"}
-
-
-
+ <>
+
+
+
+
+
+ {DECISION_STATUSES.map((s) => (
+
+ {s}
+
+ ))}
+
+
+
+ >
);
}
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}
-
-
-
+ <>
+
+
+
+
+
+ {RSVP_STATUSES.map((s) => (
+
+ {s}
+
+ ))}
+
+
+ >
);
}
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 {