1
0
Fork 0
polaris-task-force/src/components/frontend/transfers/TransferRequestCard.tsx
Z8MB1E 76e948fd59 feat: Add remaining transfer components and utilities
Add transfer workflow components and utilities:

1. Transfer types and core functions (src/lib/transfers/index.ts):
   - Type-safe transfer request and decision creation
   - Appeal, cancel, and resolve core functions
   - Server action conventions with error handling

2. Transfer UI components (src/components/frontend/transfers/):
   - TransferActionDialog.tsx: Handle approval/rejection appeals
   - TransferApprovalCard.tsx: Display approval decisions
   - TransferNoteDialog.tsx: Add notes for transfers/appeals
   - TransferRequestCard.tsx: Show transfer request details
   - TransferResolutionCard.tsx: Display superuser resolutions
   - TransfersView.tsx: Main transfers list page
   - transferTypes.ts: Shared TypeScript types

3. Notification types (src/lib/transfers/notificationTypes.ts):
   - Define notification type constants for transfer events
   - Include TRANSFER_NOTIFICATION_TYPES for bridge filtering

4. Integration tests (tests/int/):
   - transfer-requests.int.spec.ts: Test bot request polling
   - transfer-bot-delivery.int.spec.ts: Test Discord notification delivery

These components and utilities complete the transfer workflow
implementation including approvals, appeals, and delivery.
2026-08-31 23:41:29 -04:00

180 lines
5.9 KiB
TypeScript

"use client";
import { useState } from "react";
import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { appealTransfer, cancelTransfer } from "@/app/(frontend)/transfers/actions";
import { TransferNoteDialog } from "@/components/frontend/transfers/TransferNoteDialog";
import {
TRANSFER_ESCALATION_LABELS,
TRANSFER_STATUS_LABELS,
TRANSFER_STATUS_VARIANTS,
assignmentLabel,
formatTransferDate,
personLabel,
relationId,
type TransferRecord,
} from "@/components/frontend/transfers/transferTypes";
interface TransferRequestCardProps {
readonly transfer: TransferRecord;
readonly currentUserId: number;
readonly onUpdated: () => Promise<void>;
}
export function TransferRequestCard({
transfer,
currentUserId,
onUpdated,
}: TransferRequestCardProps) {
const [appealOpen, setAppealOpen] = useState(false);
const [cancelBusy, setCancelBusy] = useState(false);
const [confirmCancel, setConfirmCancel] = useState(false);
const [error, setError] = useState<string | null>(null);
const isRequester = relationId(transfer.requester) === currentUserId;
const canCancel = isRequester && transfer.status === "pending";
const canAppeal = isRequester && transfer.status === "rejected";
async function handleCancel() {
if (!confirmCancel) {
setConfirmCancel(true);
return;
}
setCancelBusy(true);
setError(null);
try {
const result = await cancelTransfer(transfer.id);
if (!result.success) {
setError(result.error ?? "Could not cancel the transfer request.");
return;
}
setConfirmCancel(false);
toast.success("Transfer request cancelled");
await onUpdated();
} catch (caughtError) {
setError(
caughtError instanceof Error
? caughtError.message
: "Could not cancel the transfer request.",
);
} finally {
setCancelBusy(false);
}
}
async function handleAppeal(note: string) {
const result = await appealTransfer(transfer.id, note || undefined);
if (result.success) void onUpdated();
return result;
}
return (
<Card>
<CardHeader className="flex-row items-start">
<div className="flex min-w-0 flex-col gap-1">
<CardTitle className="text-base">Transfer #{transfer.id}</CardTitle>
<CardDescription>
Requested {formatTransferDate(transfer.createdAt)} by {personLabel(transfer.requester)}
</CardDescription>
</div>
<Badge variant={TRANSFER_STATUS_VARIANTS[transfer.status]}>
{TRANSFER_STATUS_LABELS[transfer.status]}
</Badge>
</CardHeader>
<CardContent className="flex flex-col gap-3">
<div className="grid min-w-0 grid-cols-1 gap-3 sm:grid-cols-2">
<div className="flex min-w-0 flex-col gap-1">
<span className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
From
</span>
<span className="truncate text-sm">{assignmentLabel(transfer.fromAssignment)}</span>
</div>
<div className="flex min-w-0 flex-col gap-1">
<span className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
Requested destination
</span>
<span className="truncate text-sm">{assignmentLabel(transfer.toAssignment)}</span>
</div>
</div>
{transfer.requestNote && (
<div className="flex flex-col gap-1">
<span className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
Request note
</span>
<p className="text-sm text-muted-foreground">{transfer.requestNote}</p>
</div>
)}
{transfer.rejectionReason && (
<Alert variant="destructive">
<AlertTitle>Rejection reason</AlertTitle>
<AlertDescription>{transfer.rejectionReason}</AlertDescription>
</Alert>
)}
{transfer.status === "awaiting_superuser" && transfer.escalationReason && (
<Alert>
<AlertTitle>{TRANSFER_ESCALATION_LABELS[transfer.escalationReason]}</AlertTitle>
<AlertDescription>
A superuser will make the final decision. Remaining leader approvals are bypassed.
</AlertDescription>
</Alert>
)}
{error && (
<p role="alert" className="text-sm text-destructive">
{error}
</p>
)}
</CardContent>
{(canCancel || canAppeal) && (
<CardFooter className="flex flex-wrap gap-2">
{canCancel && (
<Button
variant="destructive"
size="sm"
onClick={() => void handleCancel()}
disabled={cancelBusy}
>
{cancelBusy ? "Cancelling..." : confirmCancel ? "Confirm cancellation?" : "Cancel request"}
</Button>
)}
{canAppeal && (
<Button variant="outline" size="sm" onClick={() => setAppealOpen(true)}>
Appeal rejection
</Button>
)}
</CardFooter>
)}
<TransferNoteDialog
open={appealOpen}
onOpenChange={setAppealOpen}
title="Appeal this rejection"
description="Explain why the rejected transfer should receive a final superuser review."
fieldId={`transfer-appeal-${transfer.id}`}
fieldLabel="Appeal note"
placeholder="Add context for the final reviewer."
submitLabel="Submit appeal"
successMessage="Appeal submitted for final review"
required={false}
onSubmit={handleAppeal}
/>
</Card>
);
}