Skip to content
Open
10 changes: 10 additions & 0 deletions apps/app-portal/src/app/(admin)/admin/applicants/[id]/loading.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { Loader2 } from "lucide-react";
import React from "react";

export default function ApplicantDetailLoading() {
return (
<div className="flex min-h-[80vh] items-center justify-center text-neutral-500">
<Loader2 className="h-6 w-6 animate-spin" />
</div>
);
}
15 changes: 10 additions & 5 deletions apps/app-portal/src/app/(admin)/admin/applicants/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<div className="space-y-6 p-6">
<div>
<Link
href="/admin/applicants"
className="text-sm text-neutral-500 underline"
>
<Link href={backHref} className="text-sm text-neutral-500 underline">
Back to applicants
</Link>
</div>
Expand Down
20 changes: 20 additions & 0 deletions apps/app-portal/src/app/(admin)/admin/applicants/loading.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="space-y-6 p-6">
<header className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-semibold">Applicants</h1>
</div>
<ExportButtons />
</header>
<ApplicantsFilters />
<ApplicantsTable />
</div>
);
}
17 changes: 13 additions & 4 deletions apps/app-portal/src/app/(admin)/admin/applicants/page.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<>
<p className="text-sm text-neutral-500">{total} total</p>
<ApplicantsTable rows={rows} />
</>
);
}

export default function ApplicantsPage() {
return (
<div className="space-y-6 p-6">
<header className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-semibold">Applicants</h1>
<p className="text-sm text-neutral-500">{total} total</p>
</div>
<ExportButtons />
</header>
<ApplicantsFilters />
<ApplicantsTable rows={rows} />
<Suspense fallback={<ApplicantsTable />}>
<ApplicantsData />
</Suspense>
</div>
);
}
61 changes: 58 additions & 3 deletions apps/app-portal/src/app/api/v1/applicants/[id]/route.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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);
}
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,27 @@ function ResponseList({ responses }: { responses?: Record<string, unknown> }) {
);
}

function Resume({ resume }: { resume?: ApplicantDetailType["resume"] }) {
return (
<div className="flex flex-col justify-between border-b border-neutral-100 py-2">
<div>
<dt className="text-xs font-medium uppercase text-neutral-500">
Resume
</dt>
<dd className="text-sm">{resume?.filename ?? "—"}</dd>
</div>
{resume ? (
<a
href={`/api/v1/uploads/${resume.id}`}
className="text-sm font-medium text-blue-600 hover:underline"
>
Download
</a>
) : null}
</div>
);
}

export function ApplicantDetail({ applicant }: ApplicantDetailProps) {
return (
<div className="flex flex-col gap-4">
Expand Down Expand Up @@ -64,8 +85,9 @@ export function ApplicantDetail({ applicant }: ApplicantDetailProps) {
<CardHeader>
<CardTitle className="text-base">Application responses</CardTitle>
</CardHeader>
<CardContent>
<CardContent className="flex flex-col gap-2">
<ResponseList responses={applicant.applicationResponses} />
<Resume resume={applicant.resume} />
</CardContent>
</Card>

Expand Down
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -10,36 +12,108 @@ 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 (
<div className="flex flex-wrap items-center gap-3">
<Input placeholder="Search" className="max-w-xs" />
<Select>
<Input
placeholder="Search name or email"
className="max-w-xs"
value={q}
onChange={(e) => setParam("q", e.target.value)}
/>
<Select value={status} onValueChange={(v) => setParam("status", v)}>
<SelectTrigger className="w-48">
<SelectValue placeholder="Status" />
</SelectTrigger>
<SelectContent>
<SelectItem value={ALL}>All statuses</SelectItem>
{APPLICATION_STATUSES.map((s) => (
<SelectItem key={s} value={s}>
{s}
</SelectItem>
))}
</SelectContent>
</Select>
<Select>
<Select value={decision} onValueChange={(v) => setParam("decision", v)}>
<SelectTrigger className="w-48">
<SelectValue placeholder="Decision" />
</SelectTrigger>
<SelectContent>
<SelectItem value={ALL}>All decisions</SelectItem>
{DECISION_STATUSES.map((s) => (
<SelectItem key={s} value={s}>
{s}
</SelectItem>
))}
</SelectContent>
</Select>
<Select value={rsvp} onValueChange={(v) => setParam("rsvp", v)}>
<SelectTrigger className="w-48">
<SelectValue placeholder="RSVP" />
</SelectTrigger>
<SelectContent>
<SelectItem value={ALL}>All RSVPs</SelectItem>
{RSVP_STATUSES.map((s) => (
<SelectItem key={s} value={s}>
{s}
</SelectItem>
))}
</SelectContent>
</Select>
{hasActiveFilters && (
<Button variant="outline" size="sm" onClick={clearFilters}>
Clear filters
</Button>
)}
</div>
);
}
Loading