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 && (
+
- 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 (
);
}
@@ -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) {
{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