diff --git a/src/app/(frontend)/transfers/actions.ts b/src/app/(frontend)/transfers/actions.ts new file mode 100644 index 0000000..1e5d8f3 --- /dev/null +++ b/src/app/(frontend)/transfers/actions.ts @@ -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 { + 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(caughtError: unknown): ActionResult { + 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> { + 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(caughtError); + } +} + +export async function approveTransfer( + requestId: number, + side: "from" | "to", +): Promise> { + 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(caughtError); + } +} + +export async function rejectTransfer( + requestId: number, + side: "from" | "to", + reason: string, +): Promise> { + 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(caughtError); + } +} + +export async function appealTransfer( + requestId: number, + note?: string, +): Promise> { + 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(caughtError); + } +} + +export async function resolveAppeal( + requestId: number, + approve: boolean, + note?: string, +): Promise> { + 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(caughtError); + } +} + +export async function cancelTransfer( + requestId: number, +): Promise> { + try { + const { payload, user } = await authenticate(); + const transfer = await cancelTransferCore(payload, { + requestId, + actorUserId: user.id, + }); + return { success: true, data: transfer }; + } catch (caughtError) { + return actionFailure(caughtError); + } +} diff --git a/src/app/(frontend)/transfers/page.tsx b/src/app/(frontend)/transfers/page.tsx new file mode 100644 index 0000000..9605bbb --- /dev/null +++ b/src/app/(frontend)/transfers/page.tsx @@ -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 ( +
+
+
+
+

+ Request an assignment move, review approvals, and resolve escalated appeals. +

+
+ + +
+ ); +} diff --git a/src/components/frontend/account/AssignmentCard.tsx b/src/components/frontend/account/AssignmentCard.tsx new file mode 100644 index 0000000..03a3b4a --- /dev/null +++ b/src/components/frontend/account/AssignmentCard.tsx @@ -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 ( + + + + + Assignment + + Your current place in the unit structure. + + + {assignments.length === 0 ? ( +

You are not assigned to a unit yet.

+ ) : ( +
    + {assignments.map((assignment) => ( +
  • +
    + + {assignment.callsign ? `[${assignment.callsign}] ` : ""} + {assignment.name} + + {assignment.type} +
    +
    + {assignment.type} + {assignment.leaderId === requesterId && Leader} +
    +
  • + ))} +
+ )} +
+ {canRequestTransfer ? ( + + +

+ Transfers require approval from the current assignment leaders. +

+
+ ) : assignments.length > 0 ? ( + +

+ Leaders cannot request transfers while they hold an assignment leadership role. +

+
+ ) : null} +
+ ); +} diff --git a/src/components/frontend/account/RequestTransferDialog.tsx b/src/components/frontend/account/RequestTransferDialog.tsx new file mode 100644 index 0000000..5703556 --- /dev/null +++ b/src/components/frontend/account/RequestTransferDialog.tsx @@ -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([]); + const [note, setNote] = useState(""); + const [loadingDestinations, setLoadingDestinations] = useState(false); + const [pending, setPending] = useState(false); + const [error, setError] = useState(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) { + 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 ( + { + setInternalOpen(nextOpen); + if (!nextOpen) reset(); + }} + > + + + + + + Request assignment transfer + + Choose an assignment you are not already part of. Leaders cannot request transfers. + + +
+
+ + +
+ +
+ + {loadingDestinations ? ( + + ) : destinations.length > 0 ? ( + + ) : ( + + + No eligible destination assignments are available right now. + + + )} +
+ +
+ +