1
0
Fork 0

feat(frontend): Add assignment transfer UI components

Add React components for assignment transfer user interface:

1. AssignmentCard.tsx: Display user's current assignment with transfer button.
   Shows assignment details, current status, and allows initiating transfer requests.

2. RequestTransferDialog.tsx: Modal dialog for creating transfer requests.
   - Selects target assignment from assignment list
   - Optionally notes required for transfer
   - Submits request to AssignmentTransfers collection
   - Emits AssignmentTransferRequest event

3. Transfers page (src/app/(frontend)/transfers/): Transfer request list view.
   - Requests card showing pending requests with requestor, target, current
   - Approval cards for assignment leaders
   - Appeal cards for rejected requests
   - Completed/Cancelled status display

All components use shadcn/ui primitives, proper loading states, error handling,
and provide accessible keyboard navigation.
This commit is contained in:
Jason Fraley 2026-08-31 23:40:37 -04:00
parent 9001d5a796
commit 6fb8dee3f6
4 changed files with 550 additions and 0 deletions

View file

@ -0,0 +1,156 @@
"use server";
import config from "@payload-config";
import { getPayload } from "payload";
import type { AssignmentTransfer } from "@/payload-types";
import { isPayloadUser } from "@/utils/access-control/isPayloadUser";
import {
appealTransfer as appealTransferCore,
cancelTransfer as cancelTransferCore,
createTransferRequest,
recordTransferDecision,
resolveAppeal as resolveAppealCore,
} from "@/lib/transfers";
export interface ActionResult<T = undefined> {
readonly success: boolean;
readonly error?: string;
readonly data?: T;
}
/**
* Web action mapping: requestTransfer → createTransferRequest, approveTransfer and
* rejectTransfer → recordTransferDecision, appealTransfer → appealTransfer,
* resolveAppeal → resolveAppeal, and cancelTransfer → cancelTransfer. The authenticated
* user supplies each core function's actorUserId/requesterId.
*/
async function authenticate() {
const payloadConfig = await config;
const payload = await getPayload({ config: payloadConfig });
const { headers } = await import("next/headers");
const hdrs = await headers();
const { user } = await payload.auth({ headers: hdrs, canSetHeaders: false });
if (!isPayloadUser(user)) {
throw new Error("Unauthorized");
}
return { payload, user };
}
function actionFailure<T>(caughtError: unknown): ActionResult<T> {
const message = caughtError instanceof Error ? caughtError.message : "Unknown error";
return {
success: false,
error: message === "Unauthorized" ? "You must be logged in." : message,
};
}
export async function requestTransfer(input: {
readonly fromAssignmentId: number;
readonly toAssignmentId: number;
readonly note?: string;
}): Promise<ActionResult<AssignmentTransfer>> {
try {
const { payload, user } = await authenticate();
const transfer = await createTransferRequest(payload, {
requesterId: user.id,
fromAssignmentId: input.fromAssignmentId,
toAssignmentId: input.toAssignmentId,
...(input.note !== undefined ? { note: input.note } : {}),
});
return { success: true, data: transfer };
} catch (caughtError) {
return actionFailure<AssignmentTransfer>(caughtError);
}
}
export async function approveTransfer(
requestId: number,
side: "from" | "to",
): Promise<ActionResult<AssignmentTransfer>> {
try {
const { payload, user } = await authenticate();
const transfer = await recordTransferDecision(payload, {
requestId,
side,
decision: "approved",
actorUserId: user.id,
});
return { success: true, data: transfer };
} catch (caughtError) {
return actionFailure<AssignmentTransfer>(caughtError);
}
}
export async function rejectTransfer(
requestId: number,
side: "from" | "to",
reason: string,
): Promise<ActionResult<AssignmentTransfer>> {
try {
const { payload, user } = await authenticate();
const transfer = await recordTransferDecision(payload, {
requestId,
side,
decision: "rejected",
reason,
actorUserId: user.id,
});
return { success: true, data: transfer };
} catch (caughtError) {
return actionFailure<AssignmentTransfer>(caughtError);
}
}
export async function appealTransfer(
requestId: number,
note?: string,
): Promise<ActionResult<AssignmentTransfer>> {
try {
const { payload, user } = await authenticate();
const transfer = await appealTransferCore(payload, {
requestId,
actorUserId: user.id,
...(note !== undefined ? { note } : {}),
});
return { success: true, data: transfer };
} catch (caughtError) {
return actionFailure<AssignmentTransfer>(caughtError);
}
}
export async function resolveAppeal(
requestId: number,
approve: boolean,
note?: string,
): Promise<ActionResult<AssignmentTransfer>> {
try {
const { payload, user } = await authenticate();
const transfer = await resolveAppealCore(payload, {
requestId,
approve,
actorUserId: user.id,
...(note !== undefined ? { note } : {}),
});
return { success: true, data: transfer };
} catch (caughtError) {
return actionFailure<AssignmentTransfer>(caughtError);
}
}
export async function cancelTransfer(
requestId: number,
): Promise<ActionResult<AssignmentTransfer>> {
try {
const { payload, user } = await authenticate();
const transfer = await cancelTransferCore(payload, {
requestId,
actorUserId: user.id,
});
return { success: true, data: transfer };
} catch (caughtError) {
return actionFailure<AssignmentTransfer>(caughtError);
}
}

View file

@ -0,0 +1,42 @@
import config from "@payload-config";
import { headers as nextHeaders } from "next/headers";
import { redirect } from "next/navigation";
import { getPayload } from "payload";
import { ArrowLeftRightIcon } from "lucide-react";
import { TransfersView } from "@/components/frontend/transfers/TransfersView";
import { isPayloadUser } from "@/utils/access-control/isPayloadUser";
import { isSuperuser } from "@/utils/access-control/hasPermission";
export const metadata = {
title: "Transfers — Polaris Task Force",
};
export default async function TransfersPage() {
const payload = await getPayload({ config });
const { user: authUser } = await payload.auth({
headers: await nextHeaders(),
canSetHeaders: false,
});
if (!isPayloadUser(authUser)) {
redirect("/");
}
const canResolve = await isSuperuser(payload, authUser);
return (
<div className="flex flex-col gap-6 p-5">
<div className="flex flex-col gap-1">
<div className="flex items-center gap-2">
<ArrowLeftRightIcon className="size-5 text-muted-foreground" aria-hidden="true" />
<h1 className="text-lg font-semibold">Transfers</h1>
</div>
<p className="text-sm text-muted-foreground">
Request an assignment move, review approvals, and resolve escalated appeals.
</p>
</div>
<TransfersView currentUserId={authUser.id} canResolve={canResolve} />
</div>
);
}

View file

@ -0,0 +1,66 @@
import { ArrowLeftRightIcon } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
import { RequestTransferDialog } from "@/components/frontend/account/RequestTransferDialog";
import type { AccountAssignment } from "@/components/frontend/transfers/transferTypes";
interface AssignmentCardProps {
readonly assignments: readonly AccountAssignment[];
readonly requesterId: number;
}
export function AssignmentCard({ assignments, requesterId }: AssignmentCardProps) {
const canRequestTransfer = assignments.some((assignment) => assignment.leaderId !== requesterId);
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<ArrowLeftRightIcon className="size-4 text-muted-foreground" />
Assignment
</CardTitle>
<CardDescription>Your current place in the unit structure.</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-3">
{assignments.length === 0 ? (
<p className="text-sm text-muted-foreground">You are not assigned to a unit yet.</p>
) : (
<ul className="flex flex-col gap-2" aria-label="Current assignments">
{assignments.map((assignment) => (
<li
key={assignment.id}
className="flex flex-wrap items-center justify-between gap-2 rounded-md border border-border bg-muted/30 px-3 py-2"
>
<div className="flex min-w-0 flex-col gap-0.5">
<span className="truncate text-sm font-medium">
{assignment.callsign ? `[${assignment.callsign}] ` : ""}
{assignment.name}
</span>
<span className="text-xs text-muted-foreground">{assignment.type}</span>
</div>
<div className="flex flex-wrap items-center gap-1.5">
<Badge variant="outline">{assignment.type}</Badge>
{assignment.leaderId === requesterId && <Badge variant="secondary">Leader</Badge>}
</div>
</li>
))}
</ul>
)}
</CardContent>
{canRequestTransfer ? (
<CardFooter className="flex flex-col items-start gap-2">
<RequestTransferDialog currentUserId={requesterId} currentAssignments={assignments} />
<p className="text-xs text-muted-foreground">
Transfers require approval from the current assignment leaders.
</p>
</CardFooter>
) : assignments.length > 0 ? (
<CardFooter>
<p className="text-xs text-muted-foreground">
Leaders cannot request transfers while they hold an assignment leadership role.
</p>
</CardFooter>
) : null}
</Card>
);
}

View file

@ -0,0 +1,286 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { useRouter } from "next/navigation";
import { toast } from "sonner";
import { ArrowLeftRightIcon, Loader2Icon } from "lucide-react";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Skeleton } from "@/components/ui/skeleton";
import { Textarea } from "@/components/ui/textarea";
import { requestTransfer } from "@/app/(frontend)/transfers/actions";
import {
parseAssignmentPage,
relationId,
type AccountAssignment,
type TransferAssignmentSummary,
} from "@/components/frontend/transfers/transferTypes";
interface RequestTransferDialogProps {
readonly currentAssignments: readonly AccountAssignment[];
readonly currentUserId: number;
}
function isMemberOf(assignment: TransferAssignmentSummary, userId: number): boolean {
if (relationId(assignment.leader) === userId) return true;
return (assignment.members ?? []).some((member) => relationId(member) === userId);
}
export function RequestTransferDialog({
currentAssignments,
currentUserId,
}: RequestTransferDialogProps) {
const router = useRouter();
const [internalOpen, setInternalOpen] = useState(false);
const [sourceId, setSourceId] = useState("");
const [destinationId, setDestinationId] = useState("");
const [destinations, setDestinations] = useState<readonly TransferAssignmentSummary[]>([]);
const [note, setNote] = useState("");
const [loadingDestinations, setLoadingDestinations] = useState(false);
const [pending, setPending] = useState(false);
const [error, setError] = useState<string | null>(null);
const requestableAssignments = useMemo(
() => currentAssignments.filter((assignment) => assignment.leaderId !== currentUserId),
[currentAssignments, currentUserId],
);
const currentAssignmentIds = useMemo(
() => new Set(currentAssignments.map((assignment) => assignment.id)),
[currentAssignments],
);
const currentAssignmentKey = currentAssignments.map((assignment) => assignment.id).join(",");
const open = internalOpen;
useEffect(() => {
if (!requestableAssignments.some((assignment) => String(assignment.id) === sourceId)) {
setSourceId(String(requestableAssignments[0]?.id ?? ""));
}
}, [requestableAssignments, sourceId]);
useEffect(() => {
if (!open) return;
const controller = new AbortController();
setLoadingDestinations(true);
setError(null);
async function loadDestinations() {
try {
const response = await fetch("/api/assignments?depth=1&limit=100&sort=name", {
signal: controller.signal,
cache: "no-store",
});
if (!response.ok) throw new Error("Could not load eligible assignments.");
const body: unknown = await response.json();
const parsed = parseAssignmentPage(body);
if (!parsed) throw new Error("The assignment response was invalid.");
const available = parsed.filter(
(assignment) =>
!currentAssignmentIds.has(assignment.id) && !isMemberOf(assignment, currentUserId),
);
setDestinations(available);
setDestinationId((previous) =>
available.some((assignment) => String(assignment.id) === previous)
? previous
: String(available[0]?.id ?? ""),
);
} catch (caughtError) {
if (caughtError instanceof DOMException && caughtError.name === "AbortError") return;
setError(
caughtError instanceof Error
? caughtError.message
: "Could not load eligible assignments.",
);
} finally {
if (!controller.signal.aborted) setLoadingDestinations(false);
}
}
void loadDestinations();
return () => controller.abort();
}, [currentAssignmentIds, currentAssignmentKey, currentUserId, open]);
function reset() {
setDestinationId("");
setDestinations([]);
setNote("");
setError(null);
setPending(false);
}
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
const fromAssignmentId = Number(sourceId);
const toAssignmentId = Number(destinationId);
if (!Number.isInteger(fromAssignmentId) || fromAssignmentId <= 0) {
setError("Choose a current assignment.");
return;
}
if (!Number.isInteger(toAssignmentId) || toAssignmentId <= 0) {
setError("Choose a destination assignment.");
return;
}
if (fromAssignmentId === toAssignmentId) {
setError("Choose an assignment different from your current one.");
return;
}
setPending(true);
setError(null);
try {
const result = await requestTransfer({
fromAssignmentId,
toAssignmentId,
note: note.trim() || undefined,
});
if (!result.success) {
setError(result.error ?? "Could not submit the transfer request.");
return;
}
toast.success("Transfer request submitted");
setInternalOpen(false);
reset();
router.refresh();
} catch (caughtError) {
setError(
caughtError instanceof Error
? caughtError.message
: "Could not submit the transfer request.",
);
} finally {
setPending(false);
}
}
return (
<Dialog
open={open}
onOpenChange={(nextOpen) => {
setInternalOpen(nextOpen);
if (!nextOpen) reset();
}}
>
<DialogTrigger asChild>
<Button
size="sm"
disabled={requestableAssignments.length === 0}
title={
requestableAssignments.length === 0
? "Leaders cannot request assignment transfers"
: undefined
}
>
<ArrowLeftRightIcon data-icon="inline-start" />
Request transfer
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Request assignment transfer</DialogTitle>
<DialogDescription>
Choose an assignment you are not already part of. Leaders cannot request transfers.
</DialogDescription>
</DialogHeader>
<form className="flex flex-col gap-4" onSubmit={handleSubmit}>
<div className="flex flex-col gap-1.5">
<Label htmlFor="transfer-source">Current assignment</Label>
<Select value={sourceId} onValueChange={setSourceId} disabled={pending}>
<SelectTrigger id="transfer-source">
<SelectValue placeholder="Select current assignment" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
{requestableAssignments.map((assignment) => (
<SelectItem key={assignment.id} value={String(assignment.id)}>
{assignment.callsign
? `${assignment.name} · ${assignment.callsign}`
: assignment.name}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-1.5" aria-busy={loadingDestinations}>
<Label htmlFor="transfer-destination">Destination assignment</Label>
{loadingDestinations ? (
<Skeleton className="h-9 w-full" />
) : destinations.length > 0 ? (
<Select
value={destinationId}
onValueChange={setDestinationId}
disabled={pending}
>
<SelectTrigger id="transfer-destination">
<SelectValue placeholder="Select destination assignment" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
{destinations.map((assignment) => (
<SelectItem key={assignment.id} value={String(assignment.id)}>
{assignment.callsign
? `${assignment.name} · ${assignment.callsign}`
: assignment.name}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
) : (
<Alert>
<AlertDescription>
No eligible destination assignments are available right now.
</AlertDescription>
</Alert>
)}
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="transfer-note">Note (optional)</Label>
<Textarea
id="transfer-note"
value={note}
onChange={(event) => setNote(event.target.value)}
placeholder="Tell both assignment leaders why you would like to move..."
rows={4}
maxLength={1000}
disabled={pending}
/>
</div>
{error && (
<p role="alert" className="text-sm text-destructive">
{error}
</p>
)}
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setInternalOpen(false)} disabled={pending}>
Cancel
</Button>
<Button type="submit" disabled={pending || loadingDestinations || destinations.length === 0}>
{pending && <Loader2Icon data-icon="inline-start" className="animate-spin" />}
{pending ? "Submitting..." : "Submit request"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}