570 lines
20 KiB
TypeScript
570 lines
20 KiB
TypeScript
"use client";
|
||
|
||
import * as React from "react";
|
||
import Link from "next/link";
|
||
import { cn, pluralize } from "@/lib/utils";
|
||
import type { RosterAssignment, RosterMember } from "@/app/(frontend)/roster/page";
|
||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||
import {
|
||
Building2Icon,
|
||
CalendarDaysIcon,
|
||
CrownIcon,
|
||
ListIcon,
|
||
NetworkIcon,
|
||
ShieldIcon,
|
||
} from "lucide-react";
|
||
|
||
const RosterDisplayContext = React.createContext<"lead" | "actual">("lead");
|
||
|
||
const TYPE_ORDER: Record<string, number> = {
|
||
division: 0,
|
||
squad: 1,
|
||
team: 2,
|
||
special: 3,
|
||
};
|
||
|
||
const TYPE_LABELS: Record<string, string> = {
|
||
division: "Division",
|
||
squad: "Squad",
|
||
team: "Team",
|
||
special: "Special",
|
||
};
|
||
|
||
type TreeNode = RosterAssignment & { children: TreeNode[]; members: RosterMember[] };
|
||
|
||
function buildTree(assignments: RosterAssignment[], members: RosterMember[]): TreeNode[] {
|
||
const nodeMap = new Map<number, TreeNode>();
|
||
for (const a of assignments) {
|
||
nodeMap.set(a.id, { ...a, children: [], members: [] });
|
||
}
|
||
|
||
// Wire the explicit hierarchy from subAssignments. Drop any edge that
|
||
// would create a cycle (a unit cannot contain itself) or that points at
|
||
// an assignment that wasn't fetched.
|
||
const childIds = new Set<number>();
|
||
const visited = new Set<number>();
|
||
const inStack = new Set<number>();
|
||
const wire = (node: TreeNode): void => {
|
||
if (visited.has(node.id) || inStack.has(node.id)) return;
|
||
inStack.add(node.id);
|
||
for (const subId of node.subAssignmentIds ?? []) {
|
||
const child = nodeMap.get(subId);
|
||
if (!child || inStack.has(child.id)) continue;
|
||
node.children.push(child);
|
||
childIds.add(child.id);
|
||
wire(child);
|
||
}
|
||
inStack.delete(node.id);
|
||
visited.add(node.id);
|
||
};
|
||
for (const node of nodeMap.values()) wire(node);
|
||
|
||
const roots = assignments.filter((a) => !childIds.has(a.id)).map((a) => nodeMap.get(a.id)!);
|
||
|
||
// Each node only holds the members/leader it claims directly — anyone a
|
||
// descendant also claims renders at the deepest unit only.
|
||
const descendantClaimed = (node: TreeNode): Set<number> => {
|
||
const ids = new Set<number>();
|
||
for (const child of node.children) {
|
||
for (const id of [...child.memberIds, child.leaderId]) {
|
||
if (id != null) ids.add(id);
|
||
}
|
||
for (const id of descendantClaimed(child)) ids.add(id);
|
||
}
|
||
return ids;
|
||
};
|
||
for (const a of assignments) {
|
||
const node = nodeMap.get(a.id)!;
|
||
const claimed = descendantClaimed(node);
|
||
node.members = members.filter(
|
||
(m) =>
|
||
(a.memberIds.includes(m.user.id) || a.leaderId === m.user.id) && !claimed.has(m.user.id),
|
||
);
|
||
}
|
||
|
||
const byOrder = (x: TreeNode, y: TreeNode) =>
|
||
(TYPE_ORDER[x.type] ?? 99) - (TYPE_ORDER[y.type] ?? 99) || x.name.localeCompare(y.name);
|
||
for (const node of nodeMap.values()) node.children.sort(byOrder);
|
||
return roots.sort(byOrder);
|
||
}
|
||
|
||
function collectAllMembers(node: TreeNode): RosterMember[] {
|
||
const result = [...node.members];
|
||
for (const child of node.children) {
|
||
result.push(...collectAllMembers(child));
|
||
}
|
||
return result;
|
||
}
|
||
|
||
// ─── Org Chart (top-down ORBAT tree) ────────────────────────────────
|
||
|
||
function OrgChartView({
|
||
assignments,
|
||
members,
|
||
}: {
|
||
assignments: RosterAssignment[];
|
||
members: RosterMember[];
|
||
}) {
|
||
const roots = buildTree(assignments, members);
|
||
const assignedIds = new Set(
|
||
assignments.flatMap((a) => [...a.memberIds, a.leaderId]).filter(Boolean),
|
||
);
|
||
const unassigned = members.filter((m) => !assignedIds.has(m.user.id)).sort(rosterMemberOrder);
|
||
|
||
return (
|
||
<div className="flex flex-col gap-4">
|
||
{roots.length > 0 ? (
|
||
<div className="overflow-x-auto pb-2">
|
||
<div className="flex flex-col items-center gap-4 min-w-max">
|
||
{roots.map((root) => (
|
||
<OrgChartNode key={root.id} node={root} depth={0} />
|
||
))}
|
||
</div>
|
||
</div>
|
||
) : (
|
||
<p className="text-sm text-muted-foreground">No assignments configured.</p>
|
||
)}
|
||
|
||
{unassigned.length > 0 && (
|
||
<div className="flex flex-col gap-2 mt-2">
|
||
<h3 className="text-xs uppercase tracking-wider text-muted-foreground font-semibold">
|
||
Unassigned
|
||
</h3>
|
||
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-1.5">
|
||
{unassigned.map((m) => (
|
||
<MemberCard key={m.profileId} member={m} />
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function OrgChartNode({ node, depth }: { node: TreeNode; depth: number }) {
|
||
const allMembers = collectAllMembers(node);
|
||
const leader = node.leaderId ? allMembers.find((m) => m.user.id === node.leaderId) : null;
|
||
const directMembers = node.members
|
||
.filter((m) => m.user.id !== node.leaderId)
|
||
.sort(rosterMemberOrder);
|
||
|
||
return (
|
||
<div className="flex flex-col items-center">
|
||
<UnitBox
|
||
node={node}
|
||
depth={depth}
|
||
leader={leader}
|
||
directMembers={directMembers}
|
||
memberCount={allMembers.length}
|
||
/>
|
||
|
||
{node.children.length > 0 && (
|
||
<>
|
||
<div className="h-4 w-px bg-border" />
|
||
<div className="flex items-start">
|
||
{node.children.map((child) => (
|
||
<div key={child.id} className="org-tree-child px-3">
|
||
<OrgChartNode node={child} depth={depth + 1} />
|
||
</div>
|
||
))}
|
||
</div>
|
||
</>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function UnitBox({
|
||
node,
|
||
depth,
|
||
leader,
|
||
directMembers,
|
||
memberCount,
|
||
}: {
|
||
node: TreeNode;
|
||
depth: number;
|
||
leader: RosterMember | null | undefined;
|
||
directMembers: RosterMember[];
|
||
memberCount: number;
|
||
}) {
|
||
const depthBorders = ["border-l-blue-600", "border-l-amber-600", "border-l-emerald-600"];
|
||
const borderClass = depthBorders[depth] ?? "border-l-zinc-600";
|
||
|
||
return (
|
||
<div
|
||
className={cn(
|
||
"border rounded-lg bg-muted/20 border-l-4 p-3 w-full min-w-56 max-w-lg",
|
||
borderClass,
|
||
)}
|
||
>
|
||
<div className="flex items-center gap-2 mb-2">
|
||
<Building2Icon className="size-3.5 text-muted-foreground shrink-0" />
|
||
<span className="text-sm font-semibold truncate">{node.name}</span>
|
||
<span className="text-[10px] uppercase tracking-wider text-muted-foreground bg-muted px-1.5 py-0.5 rounded font-mono shrink-0">
|
||
{TYPE_LABELS[node.type] ?? node.type}
|
||
</span>
|
||
{node.callsign && (
|
||
<span className="text-[10px] text-muted-foreground font-mono shrink-0">
|
||
[{node.callsign}]
|
||
</span>
|
||
)}
|
||
<span className="text-[10px] text-muted-foreground ml-auto shrink-0">
|
||
{memberCount} {pluralize(memberCount, "member")}
|
||
</span>
|
||
</div>
|
||
|
||
{leader && (
|
||
<div className="mb-2">
|
||
<span className="text-[10px] uppercase tracking-wider text-muted-foreground flex items-center gap-1 mb-1">
|
||
<CrownIcon className="size-3" /> Commanding
|
||
</span>
|
||
<MemberCard member={leader} />
|
||
</div>
|
||
)}
|
||
|
||
{directMembers.length > 0 && (
|
||
<div className="flex flex-col gap-1">
|
||
{directMembers.map((m) => (
|
||
<MemberCard key={m.profileId} member={m} />
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ─── By Assignment ──────────────────────────────────────────────────
|
||
|
||
function ByAssignmentView({
|
||
assignments,
|
||
members,
|
||
}: {
|
||
assignments: RosterAssignment[];
|
||
members: RosterMember[];
|
||
}) {
|
||
const grouped = new Map<string, RosterMember[]>();
|
||
for (const m of members) {
|
||
const key = m.assignmentName ?? "__unassigned";
|
||
const list = grouped.get(key) ?? [];
|
||
list.push(m);
|
||
grouped.set(key, list);
|
||
}
|
||
|
||
const sorted = [...grouped.entries()].sort(([a], [b]) => {
|
||
if (a === "__unassigned") return 1;
|
||
if (b === "__unassigned") return -1;
|
||
const aType = assignments.find((asgn) => asgn.name === a)?.type ?? "";
|
||
const bType = assignments.find((asgn) => asgn.name === b)?.type ?? "";
|
||
return (TYPE_ORDER[aType] ?? 99) - (TYPE_ORDER[bType] ?? 99) || a.localeCompare(b);
|
||
});
|
||
|
||
return (
|
||
<div className="flex flex-col gap-4">
|
||
{sorted.map(([key, groupMembers]) => {
|
||
const assignment = assignments.find((a) => a.name === key);
|
||
return (
|
||
<div key={key} className="flex flex-col gap-2">
|
||
<div className="flex items-center gap-2">
|
||
<Building2Icon className="size-3.5 text-muted-foreground" />
|
||
<h3 className="text-sm font-semibold">
|
||
{key === "__unassigned" ? "Unassigned" : key}
|
||
</h3>
|
||
{assignment && (
|
||
<span className="text-[10px] uppercase tracking-wider text-muted-foreground bg-muted px-1.5 py-0.5 rounded font-mono">
|
||
{TYPE_LABELS[assignment.type] ?? assignment.type}
|
||
</span>
|
||
)}
|
||
{assignment?.callsign && (
|
||
<span className="text-[10px] text-muted-foreground font-mono">
|
||
[{assignment.callsign}]
|
||
</span>
|
||
)}
|
||
<span className="text-[10px] text-muted-foreground ml-auto">
|
||
{groupMembers.length}
|
||
</span>
|
||
</div>
|
||
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-1.5">
|
||
{groupMembers.map((m) => (
|
||
<MemberCard key={m.profileId} member={m} />
|
||
))}
|
||
</div>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ─── By Rank ────────────────────────────────────────────────────────
|
||
|
||
function ByRankView({ members }: { members: RosterMember[] }) {
|
||
const grouped = new Map<string, RosterMember[]>();
|
||
for (const m of members) {
|
||
const list = grouped.get(m.rank.name) ?? [];
|
||
list.push(m);
|
||
grouped.set(m.rank.name, list);
|
||
}
|
||
|
||
const sorted = [...grouped.entries()].sort(([a], [b]) => a.localeCompare(b));
|
||
|
||
return (
|
||
<div className="flex flex-col gap-4">
|
||
{sorted.map(([rankName, groupMembers]) => {
|
||
const first = groupMembers[0];
|
||
return (
|
||
<div key={rankName} className="flex flex-col gap-2">
|
||
<div className="flex items-center gap-2">
|
||
{first.rank.insigniaUrl && (
|
||
<img
|
||
src={first.rank.insigniaUrl}
|
||
alt={rankName}
|
||
className="size-5 object-contain"
|
||
/>
|
||
)}
|
||
<h3 className="text-sm font-semibold">{rankName}</h3>
|
||
<span className="text-[10px] text-muted-foreground font-mono">
|
||
({first.rank.abbreviation})
|
||
</span>
|
||
<span className="text-[10px] text-muted-foreground ml-auto">
|
||
{groupMembers.length}
|
||
</span>
|
||
</div>
|
||
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-1.5">
|
||
{groupMembers.map((m) => (
|
||
<MemberCard key={m.profileId} member={m} />
|
||
))}
|
||
</div>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ─── Alphabetical ───────────────────────────────────────────────────
|
||
|
||
function AlphabeticalView({ members }: { members: RosterMember[] }) {
|
||
const sorted = [...members].sort((a, b) => a.user.displayName.localeCompare(b.user.displayName));
|
||
|
||
return (
|
||
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-1.5">
|
||
{sorted.map((m) => (
|
||
<MemberCard key={m.profileId} member={m} />
|
||
))}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function completedServiceYears(enlistmentDate: string | null): number | null {
|
||
if (!enlistmentDate) return null;
|
||
|
||
const enlistment = new Date(enlistmentDate);
|
||
if (Number.isNaN(enlistment.getTime())) return null;
|
||
|
||
const now = new Date();
|
||
let years = now.getUTCFullYear() - enlistment.getUTCFullYear();
|
||
const anniversaryMonth = enlistment.getUTCMonth();
|
||
const anniversaryDay = enlistment.getUTCDate();
|
||
const anniversaryPassed =
|
||
now.getUTCMonth() > anniversaryMonth ||
|
||
(now.getUTCMonth() === anniversaryMonth && now.getUTCDate() >= anniversaryDay);
|
||
|
||
if (!anniversaryPassed) years -= 1;
|
||
return Math.max(0, years);
|
||
}
|
||
|
||
function rosterMemberOrder(a: RosterMember, b: RosterMember): number {
|
||
const aRankOrder = a.rank._order ?? null;
|
||
const bRankOrder = b.rank._order ?? null;
|
||
const aMissingRank = aRankOrder === null;
|
||
const bMissingRank = bRankOrder === null;
|
||
|
||
if (aMissingRank && !bMissingRank) return 1;
|
||
if (!aMissingRank && bMissingRank) return -1;
|
||
if (aRankOrder !== null && bRankOrder !== null && aRankOrder !== bRankOrder) {
|
||
return bRankOrder.localeCompare(aRankOrder);
|
||
}
|
||
|
||
const aYears = completedServiceYears(a.enlistmentDate);
|
||
const bYears = completedServiceYears(b.enlistmentDate);
|
||
if (aYears === null && bYears !== null) return 1;
|
||
if (aYears !== null && bYears === null) return -1;
|
||
if (aYears !== null && bYears !== null && aYears !== bYears) return bYears - aYears;
|
||
|
||
if (a.xp !== b.xp) return b.xp - a.xp;
|
||
return a.user.displayName.localeCompare(b.user.displayName);
|
||
}
|
||
|
||
function SeniorityView({ members }: { members: RosterMember[] }) {
|
||
const sorted = [...members].sort((a, b) => {
|
||
const aTime = a.enlistmentDate ? new Date(a.enlistmentDate).getTime() : Number.NaN;
|
||
const bTime = b.enlistmentDate ? new Date(b.enlistmentDate).getTime() : Number.NaN;
|
||
const aMissing = Number.isNaN(aTime);
|
||
const bMissing = Number.isNaN(bTime);
|
||
|
||
if (aMissing && !bMissing) return 1;
|
||
if (!aMissing && bMissing) return -1;
|
||
if (!aMissing && !bMissing && aTime !== bTime) return aTime - bTime;
|
||
return a.user.displayName.localeCompare(b.user.displayName);
|
||
});
|
||
const groups = new Map<number | "unknown", RosterMember[]>();
|
||
for (const member of sorted) {
|
||
const years = completedServiceYears(member.enlistmentDate);
|
||
const group = years === null ? "unknown" : years;
|
||
groups.set(group, [...(groups.get(group) ?? []), member]);
|
||
}
|
||
|
||
return (
|
||
<div className="flex flex-col gap-4">
|
||
<div>
|
||
<h3 className="flex items-center gap-2 text-sm font-semibold">
|
||
<CalendarDaysIcon className="size-4 text-muted-foreground" />
|
||
Seniority by Years of Service
|
||
</h3>
|
||
<p className="mt-1 text-xs text-muted-foreground">Longest-serving members appear first.</p>
|
||
</div>
|
||
|
||
{groups.size > 0 ? (
|
||
<div className="flex flex-col gap-5">
|
||
{[...groups.entries()].map(([years, groupMembers]) => (
|
||
<section key={years} className="flex flex-col gap-2">
|
||
<div className="flex items-baseline gap-2 border-b border-border/60 pb-1">
|
||
<h4 className="text-xs font-semibold uppercase tracking-wider text-foreground">
|
||
{years === 0
|
||
? "Less than a year of service"
|
||
: years === "unknown"
|
||
? "Service history unavailable"
|
||
: `${years} ${years === 1 ? "year" : "years"} of service`}
|
||
</h4>
|
||
<span className="text-[10px] font-mono text-muted-foreground">
|
||
{groupMembers.length}
|
||
</span>
|
||
</div>
|
||
<div className="flex flex-col gap-1">
|
||
{groupMembers.map((member, index) => (
|
||
<div
|
||
key={member.profileId}
|
||
className={cn("rounded-md p-0.5", index % 2 === 0 && "bg-muted/20")}
|
||
>
|
||
<MemberCard member={member} />
|
||
</div>
|
||
))}
|
||
</div>
|
||
</section>
|
||
))}
|
||
</div>
|
||
) : (
|
||
<p className="text-sm text-muted-foreground">No roster members found.</p>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ─── Member Card ────────────────────────────────────────────────────
|
||
|
||
function MemberCard({ member }: { member: RosterMember }) {
|
||
const leadOrActual = React.useContext(RosterDisplayContext);
|
||
const leadLabel = leadOrActual === "actual" ? "Actual" : "Lead";
|
||
const serviceYears = completedServiceYears(member.enlistmentDate);
|
||
const serviceLabel =
|
||
serviceYears === null
|
||
? null
|
||
: serviceYears === 0
|
||
? "Less than a year"
|
||
: `${serviceYears} year${serviceYears === 1 ? "" : "s"}`;
|
||
|
||
return (
|
||
<Link
|
||
href={`/profile/${member.user.username}`}
|
||
className="relative flex items-center gap-2.5 rounded-md border border-border bg-muted/30 px-3 py-2 hover:bg-accent hover:border-accent-foreground/20 transition-colors"
|
||
>
|
||
{serviceLabel && (
|
||
<span className="absolute top-1 right-2 text-[9px] leading-none text-muted-foreground/70 font-mono pointer-events-none">
|
||
{serviceLabel} of service
|
||
</span>
|
||
)}
|
||
{member.rank.insigniaUrl && (
|
||
<img
|
||
src={member.rank.insigniaUrl}
|
||
alt={member.rank.name}
|
||
className="size-5 object-contain shrink-0"
|
||
/>
|
||
)}
|
||
<div className="flex flex-col min-w-0 flex-1">
|
||
<span className="text-sm font-medium truncate">{member.user.displayName}</span>
|
||
<span className="text-[10px] text-muted-foreground font-mono truncate">
|
||
{member.rank.abbreviation} · @{member.user.username}
|
||
</span>
|
||
</div>
|
||
<div className="flex items-center gap-1.5 shrink-0">
|
||
{member.isLeader && (
|
||
<span className="text-[10px] uppercase tracking-wider text-amber-400 bg-amber-950/50 border border-amber-800/50 px-1.5 py-0.5 rounded font-semibold">
|
||
{leadLabel}
|
||
</span>
|
||
)}
|
||
{member.assignmentName && (
|
||
<span className="text-[10px] text-muted-foreground bg-muted px-1.5 py-0.5 rounded font-mono hidden sm:block">
|
||
{member.assignmentCallsign ? `[${member.assignmentCallsign}]` : member.assignmentName}
|
||
</span>
|
||
)}
|
||
</div>
|
||
</Link>
|
||
);
|
||
}
|
||
|
||
// ─── Main RosterView ────────────────────────────────────────────────
|
||
|
||
export function RosterView({
|
||
members,
|
||
assignments,
|
||
leadOrActual = "lead",
|
||
}: {
|
||
members: RosterMember[];
|
||
assignments: RosterAssignment[];
|
||
leadOrActual?: "lead" | "actual";
|
||
}) {
|
||
return (
|
||
<RosterDisplayContext.Provider value={leadOrActual}>
|
||
<Tabs defaultValue="org">
|
||
<TabsList variant="line">
|
||
<TabsTrigger value="org" className="gap-1.5">
|
||
<NetworkIcon className="size-3" />
|
||
Org Chart
|
||
</TabsTrigger>
|
||
<TabsTrigger value="assignment" className="gap-1.5">
|
||
<Building2Icon className="size-3" />
|
||
By Assignment
|
||
</TabsTrigger>
|
||
<TabsTrigger value="rank" className="gap-1.5">
|
||
<ShieldIcon className="size-3" />
|
||
By Rank
|
||
</TabsTrigger>
|
||
<TabsTrigger value="alpha" className="gap-1.5">
|
||
<ListIcon className="size-3" />
|
||
A–Z
|
||
</TabsTrigger>
|
||
<TabsTrigger value="seniority" className="gap-1.5">
|
||
<CalendarDaysIcon className="size-3" />
|
||
Years of Service
|
||
</TabsTrigger>
|
||
</TabsList>
|
||
|
||
<TabsContent value="org" className="overflow-x-scroll">
|
||
<OrgChartView assignments={assignments} members={members} />
|
||
</TabsContent>
|
||
<TabsContent value="assignment">
|
||
<ByAssignmentView assignments={assignments} members={members} />
|
||
</TabsContent>
|
||
<TabsContent value="rank">
|
||
<ByRankView members={members} />
|
||
</TabsContent>
|
||
<TabsContent value="alpha">
|
||
<AlphabeticalView members={members} />
|
||
</TabsContent>
|
||
<TabsContent value="seniority">
|
||
<SeniorityView members={members} />
|
||
</TabsContent>
|
||
</Tabs>
|
||
</RosterDisplayContext.Provider>
|
||
);
|
||
}
|