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:
parent
9001d5a796
commit
6fb8dee3f6
4 changed files with 550 additions and 0 deletions
156
src/app/(frontend)/transfers/actions.ts
Normal file
156
src/app/(frontend)/transfers/actions.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
42
src/app/(frontend)/transfers/page.tsx
Normal file
42
src/app/(frontend)/transfers/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
66
src/components/frontend/account/AssignmentCard.tsx
Normal file
66
src/components/frontend/account/AssignmentCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
286
src/components/frontend/account/RequestTransferDialog.tsx
Normal file
286
src/components/frontend/account/RequestTransferDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in a new issue