- 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.
287 lines
10 KiB
TypeScript
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>
|
|
);
|
|
}
|