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