diff --git a/app/(main)/(auth)/applications/page.tsx b/app/(main)/(auth)/applications/page.tsx index f46d7fad..fa0ee1f3 100644 --- a/app/(main)/(auth)/applications/page.tsx +++ b/app/(main)/(auth)/applications/page.tsx @@ -1,6 +1,9 @@ import type { Metadata } from 'next'; -import { getMyApplications } from '@/prisma/data/applications'; +import { + getApplicationCompletion, + getMyApplications, +} from '@/prisma/data/applications'; import { getCurrentUser } from '@/lib/auth/server'; @@ -13,6 +16,11 @@ export default async function MyApplicationsPage() { const user = await getCurrentUser(); const applications = await getMyApplications(user.id); const now = new Date(); + const completion = await getApplicationCompletion( + applications + .filter((a) => a.status === 'draft') + .map((a) => ({ id: a.id, positionId: a.positionId, userId: user.id })), + ); return (
@@ -20,7 +28,11 @@ export default async function MyApplicationsPage() { title="My Applications" description="Track your drafts and submitted applications." /> - +
); } diff --git a/app/(main)/(auth)/manage/applications/[id]/page.tsx b/app/(main)/(auth)/manage/applications/[id]/page.tsx index 26a86ae4..b737df69 100644 --- a/app/(main)/(auth)/manage/applications/[id]/page.tsx +++ b/app/(main)/(auth)/manage/applications/[id]/page.tsx @@ -69,6 +69,7 @@ export default async function ApplicationDetailPage({ applicantName={applicantName} applicantEmail={application.user.email} history={history} + isAdmin={user.isAdmin} /> } /> diff --git a/components/features/application-status-actions.tsx b/components/features/application-status-actions.tsx index d57618ae..8e1d406e 100644 --- a/components/features/application-status-actions.tsx +++ b/components/features/application-status-actions.tsx @@ -15,6 +15,7 @@ import { isError } from '@/lib/utils'; import { ApplicationStatusDialog } from '@/components/features/application-status-dialog'; import { ApplicationStatusMenu } from '@/components/features/application-status-menu'; import { useApplicationStatusMove } from '@/components/features/use-application-status-move'; +import { useForceWithdrawApplication } from '@/components/features/use-force-withdraw-application'; import { Button } from '@/components/ui/button'; import { ConfirmDialog } from '@/components/ui/confirm-dialog'; import { @@ -28,6 +29,7 @@ interface ApplicationStatusActionsProps { currentStatus: $Enums.ApplicationStatus; applicantName?: string; applicantEmail?: string; + isAdmin: boolean; } // Table row `⋯` menu only — the detail page's header actions live in @@ -37,6 +39,7 @@ export function ApplicationStatusActions({ currentStatus, applicantName, applicantEmail, + isAdmin, }: ApplicationStatusActionsProps) { const displayName = applicantName ?? 'this application'; const { isPending, selectTarget, confirmDialogProps } = @@ -46,6 +49,13 @@ export function ApplicationStatusActions({ applicantEmail, currentStatus, }); + const forceWithdraw = useForceWithdrawApplication({ + applicationId, + applicantName, + currentStatus, + }); + const canForceWithdraw = + isAdmin && !isNonReviewableApplicationStatus(currentStatus); const [dialogOpen, setDialogOpen] = useState(false); const [history, setHistory] = useState([]); @@ -105,10 +115,15 @@ export function ApplicationStatusActions({ isPending={isPending} onSelect={selectTarget} onSeeMore={openDialog} + canForceWithdraw={canForceWithdraw} + onForceWithdraw={forceWithdraw.openConfirm} /> + {canForceWithdraw && ( + + )} diff --git a/components/features/application-status-dialog.tsx b/components/features/application-status-dialog.tsx index 6dbe33f9..a882faf6 100644 --- a/components/features/application-status-dialog.tsx +++ b/components/features/application-status-dialog.tsx @@ -17,6 +17,7 @@ import { } from '@/lib/utils'; import { useApplicationStatusMove } from '@/components/features/use-application-status-move'; +import { useForceWithdrawApplication } from '@/components/features/use-force-withdraw-application'; import { Button } from '@/components/ui/button'; import { ConfirmDialog } from '@/components/ui/confirm-dialog'; import { @@ -47,6 +48,7 @@ interface ApplicationStatusDialogProps { // its pre-fetched history and leaves these unset. isHistoryLoading?: boolean; historyFailed?: boolean; + isAdmin: boolean; open: boolean; onOpenChange: (open: boolean) => void; } @@ -59,6 +61,7 @@ export function ApplicationStatusDialog({ history, isHistoryLoading = false, historyFailed = false, + isAdmin, open, onOpenChange, }: ApplicationStatusDialogProps) { @@ -71,8 +74,15 @@ export function ApplicationStatusDialog({ applicantEmail, currentStatus, }); + const forceWithdraw = useForceWithdrawApplication({ + applicationId, + applicantName, + currentStatus, + }); const canOverride = !isNonReviewableApplicationStatus(currentStatus); + const canForceWithdraw = + isAdmin && !isNonReviewableApplicationStatus(currentStatus); const selectingDecision = selectedStatus === 'accepted' || selectedStatus === 'rejected'; @@ -110,7 +120,7 @@ export function ApplicationStatusDialog({ onValueChange={(v) => setSelectedStatus(v as $Enums.ApplicationStatus) } - disabled={move.isPending} + disabled={move.isPending || forceWithdraw.isPending} > + + )} +

History

{isHistoryLoading ? ( @@ -204,6 +238,9 @@ export function ApplicationStatusDialog({ + {canForceWithdraw && ( + + )} ); } diff --git a/components/features/application-status-header-actions.tsx b/components/features/application-status-header-actions.tsx index a1f3c76b..072558d6 100644 --- a/components/features/application-status-header-actions.tsx +++ b/components/features/application-status-header-actions.tsx @@ -18,6 +18,7 @@ import type { ApplicationStatusHistoryEntry } from '@/lib/types'; import { ApplicationStatusDialog } from '@/components/features/application-status-dialog'; import { ApplicationStatusMenu } from '@/components/features/application-status-menu'; import { useApplicationStatusMove } from '@/components/features/use-application-status-move'; +import { useForceWithdrawApplication } from '@/components/features/use-force-withdraw-application'; import { Button } from '@/components/ui/button'; import { ConfirmDialog } from '@/components/ui/confirm-dialog'; import { @@ -32,6 +33,7 @@ interface ApplicationStatusHeaderActionsProps { applicantName: string; applicantEmail: string; history: ApplicationStatusHistoryEntry[]; + isAdmin: boolean; } // Unresolved gets a split button; everything else gets a standalone caret — same dropdown either way. @@ -41,6 +43,7 @@ export function ApplicationStatusHeaderActions({ applicantName, applicantEmail, history, + isAdmin, }: ApplicationStatusHeaderActionsProps) { const [dialogOpen, setDialogOpen] = useState(false); const move = useApplicationStatusMove({ @@ -49,6 +52,13 @@ export function ApplicationStatusHeaderActions({ applicantEmail, currentStatus, }); + const forceWithdraw = useForceWithdrawApplication({ + applicationId, + applicantName, + currentStatus, + }); + const canForceWithdraw = + isAdmin && !isNonReviewableApplicationStatus(currentStatus); const dialog = ( @@ -99,10 +110,15 @@ export function ApplicationStatusHeaderActions({ isPending={move.isPending} onSelect={move.selectTarget} onSeeMore={() => setDialogOpen(true)} + canForceWithdraw={canForceWithdraw} + onForceWithdraw={forceWithdraw.openConfirm} /> {confirmDialog} + {canForceWithdraw && ( + + )} {dialog} ); @@ -145,12 +161,17 @@ export function ApplicationStatusHeaderActions({ isPending={move.isPending} onSelect={move.selectTarget} onSeeMore={() => setDialogOpen(true)} + canForceWithdraw={canForceWithdraw} + onForceWithdraw={forceWithdraw.openConfirm} />
{confirmDialog} + {canForceWithdraw && ( + + )} {dialog} ); diff --git a/components/features/application-status-menu.tsx b/components/features/application-status-menu.tsx index 6b96345e..4ad474a7 100644 --- a/components/features/application-status-menu.tsx +++ b/components/features/application-status-menu.tsx @@ -20,6 +20,8 @@ interface ApplicationStatusMenuProps { isPending?: boolean; onSelect: (target: $Enums.ApplicationStatus) => void; onSeeMore: () => void; + canForceWithdraw?: boolean; + onForceWithdraw?: () => void; } // One menu shape for both surfaces — the only prop that differs is hoistNext. @@ -29,6 +31,8 @@ export function ApplicationStatusMenu({ isPending = false, onSelect, onSeeMore, + canForceWithdraw = false, + onForceWithdraw, }: ApplicationStatusMenuProps) { const { next, decisions } = getApplicationStatusMenu(status); const showNext = !hoistNext && next !== null; @@ -61,6 +65,18 @@ export function ApplicationStatusMenu({ ))} {(showNext || decisions.length > 0) && } See more + {canForceWithdraw && ( + <> + + + Force withdraw + + + )} ); } diff --git a/components/features/applications-results.tsx b/components/features/applications-results.tsx index d5d733f8..9709df39 100644 --- a/components/features/applications-results.tsx +++ b/components/features/applications-results.tsx @@ -4,6 +4,7 @@ import { compareMergedApplicationRows, getAllApplications, getAllDraftApplications, + getApplicationCompletion, getApplications, getApplicationsCount, getDraftApplications, @@ -103,20 +104,30 @@ export async function ApplicationsResults({ filters, page, ); + const completion = await getApplicationCompletion( + rows.map((r) => ({ + id: r.id, + positionId: r.position.id, + userId: r.user.id, + })), + ); return (

- You can see who started an application, not what they've written. - Draft answers stay private until the applicant submits. + You can see who started an application and how far along it is, not + what they've written. Draft answers stay private until the + applicant submits.

buildApplicationsHref(filters, p)} @@ -152,13 +163,24 @@ export async function ApplicationsResults({ const { rows, total, totalPages, currentPage, rangeStart, rangeEnd } = paginateRows(merged, page); + const completion = await getApplicationCompletion( + rows + .filter((r) => r.isDraft) + .map((r) => ({ + id: r.id, + positionId: r.position.id, + userId: r.user.id, + })), + ); return (
buildApplicationsHref(filters, p)} @@ -180,8 +202,10 @@ export async function ApplicationsResults({
({ ...a, isDraft: false as const }))} + completion={{}} hasActiveFilters={hasActiveFilters} sort={filters.sort} + isAdmin={user.isAdmin} /> buildApplicationsHref(filters, p)} @@ -203,23 +227,27 @@ interface ApplicationsResultsSkeletonProps { export function ApplicationsResultsSkeleton({ isDraftView = false, }: ApplicationsResultsSkeletonProps) { - const columns: DataTableSkeletonColumn[] = [ - ...(isDraftView - ? [] - : ([ - { - head: 'w-10', - shape: 'checkbox', - headClassName: 'w-10', - cellClassName: 'w-10', - mobile: 'leading', - }, - ] satisfies DataTableSkeletonColumn[])), - { head: 'w-24', cell: 'w-36', subCell: 'w-48', mobile: 'primary' }, - { head: 'w-20', cell: 'w-28' }, - { head: 'w-16', cell: 'w-20', shape: 'badge', mobile: 'trailing' }, - { head: 'w-24', cell: 'w-20' }, - ]; + const columns: DataTableSkeletonColumn[] = isDraftView + ? [ + { head: 'w-24', cell: 'w-36', subCell: 'w-48', mobile: 'primary' }, + { head: 'w-20', cell: 'w-28' }, + { head: 'w-16', cell: 'w-16' }, + { head: 'w-20', cell: 'w-24' }, + { head: 'w-24', cell: 'w-20' }, + ] + : [ + { + head: 'w-10', + shape: 'checkbox', + headClassName: 'w-10', + cellClassName: 'w-10', + mobile: 'leading', + }, + { head: 'w-24', cell: 'w-36', subCell: 'w-48', mobile: 'primary' }, + { head: 'w-20', cell: 'w-28' }, + { head: 'w-16', cell: 'w-20', shape: 'badge', mobile: 'trailing' }, + { head: 'w-24', cell: 'w-20' }, + ]; return (
diff --git a/components/features/applications-table.tsx b/components/features/applications-table.tsx index 6f459c3d..f5b58a4b 100644 --- a/components/features/applications-table.tsx +++ b/components/features/applications-table.tsx @@ -14,6 +14,7 @@ import { STATE_ICONS, } from '@/lib/icons'; import type { + ApplicationCompletion, ApplicationSort, ApplicationSortDirection, ApplicationSortField, @@ -35,10 +36,13 @@ import { Checkbox } from '@/components/ui/checkbox'; import { DataTable } from '@/components/ui/data-table'; import { EmptyState } from '@/components/ui/empty-state'; import { LocalTime } from '@/components/ui/local-time'; +import { ProgressRing } from '@/components/ui/progress-ring'; interface BaseApplicationsTableProps { hasActiveFilters: boolean; sort?: ApplicationSort; + isAdmin: boolean; + completion: Record; } // Discriminated on isDraftView: true is the explicit "Draft" filter (pure @@ -58,7 +62,7 @@ function isAdminRow( } export function ApplicationsTable(props: ApplicationsTableProps) { - const { hasActiveFilters, sort } = props; + const { hasActiveFilters, sort, isAdmin, completion } = props; const router = useRouter(); const pathname = usePathname(); const searchParams = useSearchParams(); @@ -171,6 +175,17 @@ export function ApplicationsTable(props: ApplicationsTableProps) { ), }, + { + key: 'progress', + header: 'Progress', + // Server-side pagination sorts on ApplicationSortField, which progress isn't. + cell: (app) => { + const entry = completion[app.id]; + return entry ? ( + + ) : null; + }, + }, { key: 'started', header: 'Started', @@ -273,15 +288,11 @@ export function ApplicationsTable(props: ApplicationsTableProps) { sortAccessor: (a) => (a.isDraft ? 'draft' : a.status), cell: (app) => { if (app.isDraft) { + const entry = completion[app.id]; return (
- + {entry && }
); } @@ -294,6 +305,7 @@ export function ApplicationsTable(props: ApplicationsTableProps) { currentStatus={app.status} applicantName={displayName} applicantEmail={app.user.email} + isAdmin={isAdmin} />
); @@ -363,9 +375,14 @@ export function ApplicationsTable(props: ApplicationsTableProps) { > {app.position.title} - - Started · - Updated + + + Started · + Updated + + {completion[app.id] && ( + + )}
)} @@ -419,6 +436,7 @@ export function ApplicationsTable(props: ApplicationsTableProps) { onSortToggle={(key) => toggleSort(key as ApplicationSortField)} mobileCard={(app) => { if (app.isDraft) { + const entry = completion[app.id]; return (
@@ -429,12 +447,9 @@ export function ApplicationsTable(props: ApplicationsTableProps) {
- + {entry && ( + + )}
{app.user.name && ( @@ -485,6 +500,7 @@ export function ApplicationsTable(props: ApplicationsTableProps) { currentStatus={app.status} applicantName={displayName} applicantEmail={app.user.email} + isAdmin={isAdmin} />
diff --git a/components/features/my-applications-table.tsx b/components/features/my-applications-table.tsx index adb21e16..f46336bf 100644 --- a/components/features/my-applications-table.tsx +++ b/components/features/my-applications-table.tsx @@ -9,7 +9,10 @@ import { } from '@/lib/constants'; import { type DataTableColumn } from '@/lib/data-table'; import { CONCEPT_ICONS } from '@/lib/icons'; -import { type MyApplicationListItem } from '@/lib/types'; +import { + type ApplicationCompletion, + type MyApplicationListItem, +} from '@/lib/types'; import { getDeadlineInfo } from '@/lib/utils'; import { DeadlineIndicator } from '@/components/features/deadline-indicator'; @@ -20,13 +23,18 @@ import { Button } from '@/components/ui/button'; import { DataTable } from '@/components/ui/data-table'; import { EmptyState } from '@/components/ui/empty-state'; import { LocalTime } from '@/components/ui/local-time'; +import { ProgressRing } from '@/components/ui/progress-ring'; interface MyApplicationsTableProps { applications: MyApplicationListItem[]; now: Date; + completion: Record; } -function buildColumns(now: Date): DataTableColumn[] { +function buildColumns( + now: Date, + completion: Record, +): DataTableColumn[] { return [ { key: 'position', @@ -46,7 +54,15 @@ function buildColumns(now: Date): DataTableColumn[] { header: 'Status', // Sort by human label A-Z so order matches what the user reads in the badge. sortAccessor: (a) => APPLICATION_STATUS_LABELS[a.status], - cell: (a) => , + cell: (a) => { + const entry = a.status === 'draft' ? completion[a.id] : undefined; + return ( +
+ + {entry && } +
+ ); + }, }, { key: 'applied', @@ -106,8 +122,12 @@ function atRiskDeadlineDate(a: MyApplicationListItem, now: Date): Date | null { export function MyApplicationsTable({ applications, now, + completion, }: MyApplicationsTableProps) { - const columns = useMemo(() => buildColumns(now), [now]); + const columns = useMemo( + () => buildColumns(now, completion), + [now, completion], + ); // At-risk drafts float to the top, nearest deadline first; sort.key stays null so a header click still takes over. const rows = useMemo(() => { @@ -143,43 +163,49 @@ export function MyApplicationsTable({ columns={columns} getRowKey={(a) => a.id} caption="My applications" - mobileCard={(app) => ( -
-
- - {app.position.title} - - -
- -
- - {app.submittedAt ? ( - - ) : ( - 'Draft' - )} - -
- - + mobileCard={(app) => { + const entry = app.status === 'draft' ? completion[app.id] : undefined; + return ( +
+
+ + {app.position.title} + +
+ + {entry && } +
+
+ +
+ + {app.submittedAt ? ( + + ) : ( + 'Draft' + )} + +
+ + +
-
- )} + ); + }} /> ); } diff --git a/components/features/my-applications-widget.tsx b/components/features/my-applications-widget.tsx index 89293c33..32538977 100644 --- a/components/features/my-applications-widget.tsx +++ b/components/features/my-applications-widget.tsx @@ -1,6 +1,7 @@ import Link from 'next/link'; import { + getApplicationCompletion, getClosingSoonCount, getMyApplicationStatusCounts, getRecentMyApplications, @@ -8,11 +9,15 @@ import { import { APPLICATION_STATUS_LABELS } from '@/lib/constants'; import { CONCEPT_ICONS } from '@/lib/icons'; -import { type MyApplicationListItem } from '@/lib/types'; +import { + type ApplicationCompletion, + type MyApplicationListItem, +} from '@/lib/types'; import { DeadlineIndicator } from '@/components/features/deadline-indicator'; import { ApplicationStatusBadge } from '@/components/features/status-badge'; import { LocalTime } from '@/components/ui/local-time'; +import { ProgressRing } from '@/components/ui/progress-ring'; import { SectionCard, SectionCardEmpty } from '@/components/ui/section-card'; interface MyApplicationsWidgetProps { @@ -58,6 +63,18 @@ export async function MyApplicationsWidget({ getClosingSoonCount(userId, now), ]); + const draftRows = applications.filter((a) => a.status === 'draft'); + const completion = + draftRows.length > 0 + ? await getApplicationCompletion( + draftRows.map((a) => ({ + id: a.id, + positionId: a.positionId, + userId, + })), + ) + : {}; + const summary = buildCountsSummary(counts, closingSoonCount); return ( @@ -86,7 +103,11 @@ export async function MyApplicationsWidget({ } /> ) : ( - + )} ); @@ -95,40 +116,46 @@ export async function MyApplicationsWidget({ function ApplicationList({ applications, now, + completion, }: { applications: MyApplicationListItem[]; now: Date; + completion: Record; }) { return (
    - {applications.map((app) => ( -
  • - { + const entry = app.status === 'draft' ? completion[app.id] : undefined; + return ( +
  • - {app.position.title} - - - {app.status === 'draft' || app.status === 'withdrawn' ? ( - - ) : app.submittedAt ? ( - - ) : ( - '—' - )} - - -
  • - ))} + + {app.position.title} + + + {entry && } + + {app.status === 'draft' || app.status === 'withdrawn' ? ( + + ) : app.submittedAt ? ( + + ) : ( + '—' + )} + + + ); + })}
); } diff --git a/components/features/position-card.tsx b/components/features/position-card.tsx index 82ee7765..3ad650db 100644 --- a/components/features/position-card.tsx +++ b/components/features/position-card.tsx @@ -23,6 +23,7 @@ import { import { Button } from '@/components/ui/button'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Markdown } from '@/components/ui/markdown'; +import { ProgressRing } from '@/components/ui/progress-ring'; import { Skeleton } from '@/components/ui/skeleton'; interface PositionCardProps { @@ -187,12 +188,21 @@ export function PositionCard({ <> {myApplication ? ( canContinueOrResubmit ? ( - + <> + + {isDraft && myApplication.completion && ( + + )} + ) : (