"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 = { division: 0, squad: 1, team: 2, special: 3, }; const TYPE_LABELS: Record = { 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(); 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(); const visited = new Set(); const inStack = new Set(); 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 => { const ids = new Set(); 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 (
{roots.length > 0 ? (
{roots.map((root) => ( ))}
) : (

No assignments configured.

)} {unassigned.length > 0 && (

Unassigned

{unassigned.map((m) => ( ))}
)}
); } 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 (
{node.children.length > 0 && ( <>
{node.children.map((child) => (
))}
)}
); } 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 (
{node.name} {TYPE_LABELS[node.type] ?? node.type} {node.callsign && ( [{node.callsign}] )} {memberCount} {pluralize(memberCount, "member")}
{leader && (
Commanding
)} {directMembers.length > 0 && (
{directMembers.map((m) => ( ))}
)}
); } // ─── By Assignment ────────────────────────────────────────────────── function ByAssignmentView({ assignments, members, }: { assignments: RosterAssignment[]; members: RosterMember[]; }) { const grouped = new Map(); 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 (
{sorted.map(([key, groupMembers]) => { const assignment = assignments.find((a) => a.name === key); return (

{key === "__unassigned" ? "Unassigned" : key}

{assignment && ( {TYPE_LABELS[assignment.type] ?? assignment.type} )} {assignment?.callsign && ( [{assignment.callsign}] )} {groupMembers.length}
{groupMembers.map((m) => ( ))}
); })}
); } // ─── By Rank ──────────────────────────────────────────────────────── function ByRankView({ members }: { members: RosterMember[] }) { const grouped = new Map(); 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 (
{sorted.map(([rankName, groupMembers]) => { const first = groupMembers[0]; return (
{first.rank.insigniaUrl && ( {rankName} )}

{rankName}

({first.rank.abbreviation}) {groupMembers.length}
{groupMembers.map((m) => ( ))}
); })}
); } // ─── Alphabetical ─────────────────────────────────────────────────── function AlphabeticalView({ members }: { members: RosterMember[] }) { const sorted = [...members].sort((a, b) => a.user.displayName.localeCompare(b.user.displayName)); return (
{sorted.map((m) => ( ))}
); } 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(); for (const member of sorted) { const years = completedServiceYears(member.enlistmentDate); const group = years === null ? "unknown" : years; groups.set(group, [...(groups.get(group) ?? []), member]); } return (

Seniority by Years of Service

Longest-serving members appear first.

{groups.size > 0 ? (
{[...groups.entries()].map(([years, groupMembers]) => (

{years === 0 ? "Less than a year of service" : years === "unknown" ? "Service history unavailable" : `${years} ${years === 1 ? "year" : "years"} of service`}

{groupMembers.length}
{groupMembers.map((member, index) => (
))}
))}
) : (

No roster members found.

)}
); } // ─── 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 ( {serviceLabel && ( {serviceLabel} of service )} {member.rank.insigniaUrl && ( {member.rank.name} )}
{member.user.displayName} {member.rank.abbreviation} · @{member.user.username}
{member.isLeader && ( {leadLabel} )} {member.assignmentName && ( {member.assignmentCallsign ? `[${member.assignmentCallsign}]` : member.assignmentName} )}
); } // ─── Main RosterView ──────────────────────────────────────────────── export function RosterView({ members, assignments, leadOrActual = "lead", }: { members: RosterMember[]; assignments: RosterAssignment[]; leadOrActual?: "lead" | "actual"; }) { return ( Org Chart By Assignment By Rank A–Z Years of Service ); }