1
0
Fork 0
polaris-task-force/src/components/frontend/transfers/TransfersView.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

258 lines
8.2 KiB
TypeScript

"use client";
import { useCallback, useEffect, useMemo, useState, type ReactNode } from "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 { TransferApprovalCard } from "@/components/frontend/transfers/TransferApprovalCard";
import { TransferResolutionCard } from "@/components/frontend/transfers/TransferResolutionCard";
import { TransferRequestCard } from "@/components/frontend/transfers/TransferRequestCard";
import { LeadershipTransfersSection } from "@/components/frontend/transfers/LeadershipTransfersSection";
import {
relationId,
assignmentLeaderId,
parseTransferPage,
type TransferRequest,
type TransferSide,
} from "@/components/frontend/transfers/transferTypes";
import {
ClipboardCheckIcon,
FileClockIcon,
RefreshCwIcon,
ShieldAlertIcon,
} from "lucide-react";
import type { LucideIcon } from "lucide-react";
interface TransfersViewProps {
readonly currentUserId: number;
readonly canResolve: boolean;
}
interface ApprovalEntry {
readonly request: TransferRequest;
readonly sides: readonly TransferSide[];
}
const TRANSFER_QUERY = new URLSearchParams({
depth: "1",
limit: "100",
sort: "-createdAt",
});
function approvalSides(request: TransferRequest, currentUserId: number): readonly TransferSide[] {
if (request.status !== "pending") return [];
const sides: TransferSide[] = [];
if (
request.fromLeaderDecision === "pending" &&
assignmentLeaderId(request.fromAssignment) === currentUserId
) {
sides.push("from");
}
if (
request.toLeaderDecision === "pending" &&
assignmentLeaderId(request.toAssignment) === currentUserId
) {
sides.push("to");
}
return sides;
}
function TransferSection({
icon: Icon,
title,
count,
children,
}: {
readonly icon: LucideIcon;
readonly title: string;
readonly count: number;
readonly children: ReactNode;
}) {
return (
<section aria-labelledby={`${title}-heading`} className="flex flex-col gap-3">
<div className="flex items-center gap-2">
<Icon className="size-4 text-muted-foreground" aria-hidden="true" />
<h2 id={`${title}-heading`} className="text-sm font-semibold uppercase tracking-wider">
{title}
</h2>
<Badge variant="outline" className="text-xs">
{count}
</Badge>
</div>
{children}
</section>
);
}
function EmptyTransferSection({ message }: { readonly message: string }) {
return (
<Alert>
<AlertDescription>{message}</AlertDescription>
</Alert>
);
}
function LoadingTransferSections({ canResolve }: { readonly canResolve: boolean }) {
return (
<div className="flex flex-col gap-8" aria-busy="true" aria-label="Loading transfer requests">
{["my-requests", "approvals", ...(canResolve ? ["appeals"] : [])].map((section) => (
<div key={section} className="flex flex-col gap-3">
<Skeleton className="h-5 w-48" />
<Skeleton className="h-48 w-full" />
</div>
))}
</div>
);
}
export function TransfersView({ currentUserId, canResolve }: TransfersViewProps) {
const [requests, setRequests] = useState<readonly TransferRequest[] | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const loadRequests = useCallback(async () => {
setLoading(true);
setError(null);
try {
const response = await fetch(`/api/assignment-transfers?${TRANSFER_QUERY.toString()}`, {
cache: "no-store",
});
if (!response.ok) throw new Error("Could not load transfer requests.");
const body: unknown = await response.json();
const parsed = parseTransferPage(body);
if (!parsed) throw new Error("The transfer request response was invalid.");
setRequests(parsed);
} catch (caughtError) {
setError(
caughtError instanceof Error ? caughtError.message : "Could not load transfer requests.",
);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
void loadRequests();
}, [loadRequests]);
const myRequests = useMemo(
() =>
(requests ?? []).filter((request) => relationId(request.requester) === currentUserId),
[currentUserId, requests],
);
const approvals = useMemo<readonly ApprovalEntry[]>(
() =>
(requests ?? [])
.map((request) => ({ request, sides: approvalSides(request, currentUserId) }))
.filter((entry) => entry.sides.length > 0),
[currentUserId, requests],
);
const appeals = useMemo(
() => (canResolve ? (requests ?? []).filter((request) => request.status === "awaiting_superuser") : []),
[canResolve, requests],
);
if (!requests && loading) return <LoadingTransferSections canResolve={canResolve} />;
if (!requests && error) {
return (
<div className="flex flex-col gap-8">
<Alert variant="destructive">
<AlertTitle>Transfer requests unavailable</AlertTitle>
<AlertDescription className="flex flex-wrap items-center gap-3">
<span>{error}</span>
<Button size="sm" variant="outline" onClick={() => void loadRequests()}>
<RefreshCwIcon data-icon="inline-start" />
Try again
</Button>
</AlertDescription>
</Alert>
<LeadershipTransfersSection currentUserId={currentUserId} canResolve={canResolve} />
</div>
);
}
return (
<div className="flex flex-col gap-8">
{error && (
<Alert variant="destructive">
<AlertTitle>Could not refresh transfer requests</AlertTitle>
<AlertDescription className="flex flex-wrap items-center gap-3">
<span>{error}</span>
<Button size="sm" variant="outline" onClick={() => void loadRequests()}>
<RefreshCwIcon data-icon="inline-start" />
Try again
</Button>
</AlertDescription>
</Alert>
)}
<div className="flex items-center justify-end" aria-live="polite">
{loading && <span className="text-xs text-muted-foreground">Refreshing requests...</span>}
</div>
<TransferSection icon={FileClockIcon} title="My Requests" count={myRequests.length}>
{myRequests.length === 0 ? (
<EmptyTransferSection message="You have not requested an assignment transfer." />
) : (
<div className="grid grid-cols-1 gap-4 xl:grid-cols-2">
{myRequests.map((request) => (
<TransferRequestCard
key={request.id}
transfer={request}
currentUserId={currentUserId}
onUpdated={loadRequests}
/>
))}
</div>
)}
</TransferSection>
<TransferSection
icon={ClipboardCheckIcon}
title="Awaiting Your Approval"
count={approvals.length}
>
{approvals.length === 0 ? (
<EmptyTransferSection message="No pending requests need your approval." />
) : (
<div className="grid grid-cols-1 gap-4 xl:grid-cols-2">
{approvals.map((entry) => (
entry.sides.map((side) => (
<TransferApprovalCard
key={`${entry.request.id}-${side}`}
transfer={entry.request}
side={side}
onUpdated={loadRequests}
/>
))
))}
</div>
)}
</TransferSection>
{canResolve && (
<TransferSection icon={ShieldAlertIcon} title="Appeals" count={appeals.length}>
{appeals.length === 0 ? (
<EmptyTransferSection message="No escalated transfer requests need a final call." />
) : (
<div className="grid grid-cols-1 gap-4 xl:grid-cols-2">
{appeals.map((request) => (
<TransferResolutionCard
key={request.id}
transfer={request}
onUpdated={loadRequests}
/>
))}
</div>
)}
</TransferSection>
)}
<LeadershipTransfersSection currentUserId={currentUserId} canResolve={canResolve} />
</div>
);
}