1
0
Fork 0
polaris-task-force/src/components/frontend/transfers/LeadershipTransfersSection.tsx
Z8MB1E 62573f74e6 feat(promotions): add promotion nominations and leadership transfers
- Promotion nominations: members can nominate a peer for promotion from their
  profile, and a new /personnel/promotions page lets staff review and action
  the nominations (with an optional note on each).
- Leadership transfers: a member's leadership can be handed over to another
  member, surfaced on the transfers page and in the roster (which also gains a
  staff remove-member dialog).
- Adds the PromotionNominations and LeadershipTransfers collections (registered
  in the Payload config), their service libs, the migration, and integration
  coverage.
2026-10-03 03:29:24 -04:00

287 lines
10 KiB
TypeScript

"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import { toast } from "sonner";
import { UserMinusIcon } from "lucide-react";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
import { cancelDivisionRemoval } from "@/app/(frontend)/roster/actions";
import { LeadershipTransferCard } from "@/components/frontend/transfers/LeadershipTransferCard";
import { formatTransferDate } from "@/components/frontend/transfers/transferTypes";
import type { Assignment, LeadershipTransfer, User } from "@/payload-types";
interface LeadershipTransfersSectionProps {
readonly currentUserId: number;
readonly canResolve: boolean;
}
const LEADERSHIP_QUERY = new URLSearchParams({
depth: "1",
limit: "50",
sort: "-createdAt",
});
const STATUS_LABELS: Record<LeadershipTransfer["status"], string> = {
pending: "Pending approval",
approved: "Approved",
denied: "Denied",
cancelled: "Cancelled",
};
const STATUS_VARIANTS: Record<
LeadershipTransfer["status"],
"default" | "secondary" | "destructive" | "outline"
> = {
pending: "secondary",
approved: "default",
denied: "destructive",
cancelled: "outline",
};
function initiatorId(value: number | User | null | undefined): number | null {
if (value == null) return null;
return typeof value === "number" ? value : value.id;
}
function personRefLabel(value: number | User | null | undefined, fallback = "Unknown member"): string {
if (value == null) return fallback;
if (typeof value === "number") return `User #${value}`;
return value.displayName || value.username || fallback;
}
function assignmentRef(value: number | Assignment | null | undefined): string {
if (value == null) return "Unknown assignment";
if (typeof value === "number") return `Assignment #${value}`;
return value.callsign ? `[${value.callsign}] ${value.name}` : value.name;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
/** Shallow-shape-check the REST page so a malformed response surfaces as an error. */
function parseDocs(data: unknown): LeadershipTransfer[] | null {
if (!isRecord(data) || !Array.isArray(data.docs)) return null;
return data.docs.filter((doc) => isRecord(doc) && typeof doc.id === "number") as LeadershipTransfer[];
}
/**
* Leadership-initiated removal requests on the transfers page.
*
* Superusers get the pending queue (Approve/Deny with note) plus a collapsed
* history. Regular members only ever see their own requests as status chips;
* the whole section stays hidden when they have none (pending removals are
* invisible to the unit by design).
*/
export function LeadershipTransfersSection({
currentUserId,
canResolve,
}: LeadershipTransfersSectionProps) {
const [docs, setDocs] = useState<readonly LeadershipTransfer[] | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [cancellingId, setCancellingId] = useState<number | null>(null);
const [historyOpen, setHistoryOpen] = useState(false);
const load = useCallback(async () => {
setLoading(true);
setError(null);
try {
const response = await fetch(`/api/leadership-transfers?${LEADERSHIP_QUERY.toString()}`, {
cache: "no-store",
});
if (!response.ok) throw new Error("Could not load leadership transfers.");
const body: unknown = await response.json();
const parsed = parseDocs(body);
if (!parsed) throw new Error("The leadership transfer response was invalid.");
setDocs(parsed);
} catch (caughtError) {
setError(
caughtError instanceof Error
? caughtError.message
: "Could not load leadership transfers.",
);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
void load();
}, [load]);
const pending = useMemo(() => (docs ?? []).filter((doc) => doc.status === "pending"), [docs]);
const mine = useMemo(
() => (docs ?? []).filter((doc) => initiatorId(doc.initiator) === currentUserId),
[currentUserId, docs],
);
const resolved = useMemo(() => (docs ?? []).filter((doc) => doc.status !== "pending"), [docs]);
async function handleCancel(transferId: number) {
setCancellingId(transferId);
try {
const result = await cancelDivisionRemoval(transferId);
if (result.success) {
toast.success("Removal request cancelled");
void load();
} else {
toast.error(result.error ?? "Could not cancel the removal request.");
}
} finally {
setCancellingId(null);
}
}
// Regular members: hide the section entirely while loading, on fetch errors,
// and whenever they have no requests of their own.
if (!canResolve) {
if (docs === null || mine.length === 0) return null;
return (
<section aria-labelledby="leadership-transfers-heading" className="flex flex-col gap-3">
<div className="flex items-center gap-2">
<UserMinusIcon className="size-4 text-muted-foreground" aria-hidden="true" />
<h2
id="leadership-transfers-heading"
className="text-sm font-semibold uppercase tracking-wider"
>
Leadership transfers
</h2>
<Badge variant="outline" className="text-xs">
{mine.length}
</Badge>
</div>
<div className="flex flex-col gap-2">
{mine.map((doc) => (
<div
key={doc.id}
className="flex flex-wrap items-center gap-2 rounded-md border bg-muted/20 px-3 py-2 text-sm"
>
<Badge variant={STATUS_VARIANTS[doc.status]}>{STATUS_LABELS[doc.status]}</Badge>
<span>
Removal of <span className="font-medium">{personRefLabel(doc.subject)}</span> from{" "}
{assignmentRef(doc.fromAssignment)} to {assignmentRef(doc.toAssignment)}
</span>
<span className="text-xs text-muted-foreground">
requested {formatTransferDate(doc.createdAt)}
</span>
{doc.status === "pending" && (
<Button
size="sm"
variant="outline"
className="ml-auto"
onClick={() => void handleCancel(doc.id)}
disabled={cancellingId === doc.id}
>
Cancel request
</Button>
)}
</div>
))}
</div>
</section>
);
}
if (docs === null && loading) {
return (
<div className="flex flex-col gap-3" aria-busy="true" aria-label="Loading leadership transfers">
<Skeleton className="h-5 w-48" />
<Skeleton className="h-40 w-full" />
</div>
);
}
if (docs === null && error) {
return (
<Alert variant="destructive">
<AlertTitle>Leadership transfers unavailable</AlertTitle>
<AlertDescription className="flex flex-wrap items-center gap-3">
<span>{error}</span>
<Button size="sm" variant="outline" onClick={() => void load()}>
Try again
</Button>
</AlertDescription>
</Alert>
);
}
return (
<section aria-labelledby="leadership-transfers-heading" className="flex flex-col gap-3">
<div className="flex items-center gap-2">
<UserMinusIcon className="size-4 text-muted-foreground" aria-hidden="true" />
<h2
id="leadership-transfers-heading"
className="text-sm font-semibold uppercase tracking-wider"
>
Leadership transfers
</h2>
<Badge variant="outline" className="text-xs">
{pending.length}
</Badge>
</div>
{error && (
<Alert variant="destructive">
<AlertTitle>Could not refresh leadership transfers</AlertTitle>
<AlertDescription className="flex flex-wrap items-center gap-3">
<span>{error}</span>
<Button size="sm" variant="outline" onClick={() => void load()}>
Try again
</Button>
</AlertDescription>
</Alert>
)}
{pending.length === 0 ? (
<Alert>
<AlertDescription>No pending removal requests need a final call.</AlertDescription>
</Alert>
) : (
<div className="grid grid-cols-1 gap-4 xl:grid-cols-2">
{pending.map((doc) => (
<LeadershipTransferCard key={doc.id} transfer={doc} onUpdated={load} />
))}
</div>
)}
{resolved.length > 0 && (
<Collapsible
open={historyOpen}
onOpenChange={setHistoryOpen}
className="flex flex-col gap-2"
>
<CollapsibleTrigger asChild>
<Button variant="ghost" size="sm" className="w-fit">
History ({resolved.length})
</Button>
</CollapsibleTrigger>
<CollapsibleContent className="flex flex-col gap-1.5">
{resolved.map((doc) => (
<div
key={doc.id}
className="flex flex-wrap items-center gap-2 border-b border-border/60 pb-1.5 text-sm"
>
<span className="font-medium">{personRefLabel(doc.initiator)}</span>
<span className="text-muted-foreground">removed</span>
<span>{personRefLabel(doc.subject)}</span>
<span className="text-muted-foreground">
from {assignmentRef(doc.fromAssignment)} to {assignmentRef(doc.toAssignment)}
</span>
<Badge variant={STATUS_VARIANTS[doc.status]}>{STATUS_LABELS[doc.status]}</Badge>
{doc.resolvedAt && (
<span className="ml-auto text-xs text-muted-foreground">
{formatTransferDate(doc.resolvedAt)}
</span>
)}
</div>
))}
</CollapsibleContent>
</Collapsible>
)}
</section>
);
}