- 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.
258 lines
8.2 KiB
TypeScript
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>
|
|
);
|
|
}
|