Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions app/(main)/(auth)/manage/applications/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ export default async function ApplicationDetailPage({
applicantName={applicantName}
applicantEmail={application.user.email}
history={history}
isAdmin={user.isAdmin}
/>
}
/>
Expand Down
16 changes: 16 additions & 0 deletions components/features/application-status-actions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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
Expand All @@ -37,6 +39,7 @@ export function ApplicationStatusActions({
currentStatus,
applicantName,
applicantEmail,
isAdmin,
}: ApplicationStatusActionsProps) {
const displayName = applicantName ?? 'this application';
const { isPending, selectTarget, confirmDialogProps } =
Expand All @@ -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<ApplicationStatusHistoryEntry[]>([]);
Expand Down Expand Up @@ -105,10 +115,15 @@ export function ApplicationStatusActions({
isPending={isPending}
onSelect={selectTarget}
onSeeMore={openDialog}
canForceWithdraw={canForceWithdraw}
onForceWithdraw={forceWithdraw.openConfirm}
/>
</DropdownMenuContent>
</DropdownMenu>
<ConfirmDialog {...confirmDialogProps} />
{canForceWithdraw && (
<ConfirmDialog {...forceWithdraw.confirmDialogProps} />
)}
<ApplicationStatusDialog
applicationId={applicationId}
applicantName={displayName}
Expand All @@ -117,6 +132,7 @@ export function ApplicationStatusActions({
history={history}
isHistoryLoading={isHistoryLoading}
historyFailed={historyFailed}
isAdmin={isAdmin}
open={dialogOpen}
onOpenChange={setDialogOpen}
/>
Expand Down
41 changes: 39 additions & 2 deletions components/features/application-status-dialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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;
}
Expand All @@ -59,6 +61,7 @@ export function ApplicationStatusDialog({
history,
isHistoryLoading = false,
historyFailed = false,
isAdmin,
open,
onOpenChange,
}: ApplicationStatusDialogProps) {
Expand All @@ -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';

Expand Down Expand Up @@ -110,7 +120,7 @@ export function ApplicationStatusDialog({
onValueChange={(v) =>
setSelectedStatus(v as $Enums.ApplicationStatus)
}
disabled={move.isPending}
disabled={move.isPending || forceWithdraw.isPending}
>
<SelectTrigger
id="status-dialog-select"
Expand All @@ -131,7 +141,11 @@ export function ApplicationStatusDialog({
</Select>
<Button
size="sm"
disabled={!selectedStatus || move.isPending}
disabled={
!selectedStatus ||
move.isPending ||
forceWithdraw.isPending
}
onClick={handleApply}
>
{move.isPending &&
Expand All @@ -156,6 +170,26 @@ export function ApplicationStatusDialog({
</div>
)}

{canForceWithdraw && (
<div className="flex flex-col gap-2 border-t pt-4">
<h3 className="text-sm font-medium">Force withdraw</h3>
<p className="text-muted-foreground text-xs">
Return this application to {applicantName} without telling
them. No email is sent.
</p>
<Button
variant="destructive"
size="sm"
className="w-fit"
disabled={move.isPending || forceWithdraw.isPending}
onClick={forceWithdraw.openConfirm}
>
<ACTION_ICONS.forceWithdraw />
Force withdraw
</Button>
</div>
)}

<div className="flex flex-col gap-2">
<h3 className="text-sm font-medium">History</h3>
{isHistoryLoading ? (
Expand Down Expand Up @@ -204,6 +238,9 @@ export function ApplicationStatusDialog({
</DialogContent>
</Dialog>
<ConfirmDialog {...move.confirmDialogProps} />
{canForceWithdraw && (
<ConfirmDialog {...forceWithdraw.confirmDialogProps} />
)}
</>
);
}
21 changes: 21 additions & 0 deletions components/features/application-status-header-actions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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.
Expand All @@ -41,6 +43,7 @@ export function ApplicationStatusHeaderActions({
applicantName,
applicantEmail,
history,
isAdmin,
}: ApplicationStatusHeaderActionsProps) {
const [dialogOpen, setDialogOpen] = useState(false);
const move = useApplicationStatusMove({
Expand All @@ -49,6 +52,13 @@ export function ApplicationStatusHeaderActions({
applicantEmail,
currentStatus,
});
const forceWithdraw = useForceWithdrawApplication({
applicationId,
applicantName,
currentStatus,
});
const canForceWithdraw =
isAdmin && !isNonReviewableApplicationStatus(currentStatus);

const dialog = (
<ApplicationStatusDialog
Expand All @@ -57,6 +67,7 @@ export function ApplicationStatusHeaderActions({
applicantEmail={applicantEmail}
currentStatus={currentStatus}
history={history}
isAdmin={isAdmin}
open={dialogOpen}
onOpenChange={setDialogOpen}
/>
Expand Down Expand Up @@ -99,10 +110,15 @@ export function ApplicationStatusHeaderActions({
isPending={move.isPending}
onSelect={move.selectTarget}
onSeeMore={() => setDialogOpen(true)}
canForceWithdraw={canForceWithdraw}
onForceWithdraw={forceWithdraw.openConfirm}
/>
</DropdownMenuContent>
</DropdownMenu>
{confirmDialog}
{canForceWithdraw && (
<ConfirmDialog {...forceWithdraw.confirmDialogProps} />
)}
{dialog}
</>
);
Expand Down Expand Up @@ -145,12 +161,17 @@ export function ApplicationStatusHeaderActions({
isPending={move.isPending}
onSelect={move.selectTarget}
onSeeMore={() => setDialogOpen(true)}
canForceWithdraw={canForceWithdraw}
onForceWithdraw={forceWithdraw.openConfirm}
/>
</DropdownMenuContent>
</DropdownMenu>
</div>

{confirmDialog}
{canForceWithdraw && (
<ConfirmDialog {...forceWithdraw.confirmDialogProps} />
)}
{dialog}
</>
);
Expand Down
16 changes: 16 additions & 0 deletions components/features/application-status-menu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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;
Expand Down Expand Up @@ -61,6 +65,18 @@ export function ApplicationStatusMenu({
))}
{(showNext || decisions.length > 0) && <DropdownMenuSeparator />}
<DropdownMenuItem onSelect={onSeeMore}>See more</DropdownMenuItem>
{canForceWithdraw && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem
variant="destructive"
disabled={isPending}
onSelect={onForceWithdraw}
>
Force withdraw
</DropdownMenuItem>
</>
)}
</>
);
}
3 changes: 3 additions & 0 deletions components/features/applications-results.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,7 @@ export async function ApplicationsResults({
applications={rows}
hasActiveFilters={hasActiveFilters}
sort={filters.sort}
isAdmin={user.isAdmin}
/>
<TablePagination
buildHref={(p) => buildApplicationsHref(filters, p)}
Expand Down Expand Up @@ -159,6 +160,7 @@ export async function ApplicationsResults({
applications={rows}
hasActiveFilters={hasActiveFilters}
sort={filters.sort}
isAdmin={user.isAdmin}
/>
<TablePagination
buildHref={(p) => buildApplicationsHref(filters, p)}
Expand All @@ -182,6 +184,7 @@ export async function ApplicationsResults({
applications={rows.map((a) => ({ ...a, isDraft: false as const }))}
hasActiveFilters={hasActiveFilters}
sort={filters.sort}
isAdmin={user.isAdmin}
/>
<TablePagination
buildHref={(p) => buildApplicationsHref(filters, p)}
Expand Down
7 changes: 6 additions & 1 deletion components/features/applications-table.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ import { LocalTime } from '@/components/ui/local-time';
interface BaseApplicationsTableProps {
hasActiveFilters: boolean;
sort?: ApplicationSort;
isAdmin: boolean;
}

// Discriminated on isDraftView: true is the explicit "Draft" filter (pure
Expand All @@ -58,7 +59,7 @@ function isAdminRow(
}

export function ApplicationsTable(props: ApplicationsTableProps) {
const { hasActiveFilters, sort } = props;
const { hasActiveFilters, sort, isAdmin } = props;
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
Expand Down Expand Up @@ -281,6 +282,7 @@ export function ApplicationsTable(props: ApplicationsTableProps) {
currentStatus="draft"
applicantName={displayUserName(app.user)}
applicantEmail={app.user.email}
isAdmin={isAdmin}
/>
</div>
);
Expand All @@ -294,6 +296,7 @@ export function ApplicationsTable(props: ApplicationsTableProps) {
currentStatus={app.status}
applicantName={displayName}
applicantEmail={app.user.email}
isAdmin={isAdmin}
/>
</div>
);
Expand Down Expand Up @@ -434,6 +437,7 @@ export function ApplicationsTable(props: ApplicationsTableProps) {
currentStatus="draft"
applicantName={displayUserName(app.user)}
applicantEmail={app.user.email}
isAdmin={isAdmin}
/>
</div>
</div>
Expand Down Expand Up @@ -485,6 +489,7 @@ export function ApplicationsTable(props: ApplicationsTableProps) {
currentStatus={app.status}
applicantName={displayName}
applicantEmail={app.user.email}
isAdmin={isAdmin}
/>
</div>
</div>
Expand Down
Loading
Loading