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.
180 lines
5.9 KiB
TypeScript
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>
|
|
);
|
|
}
|