1
0
Fork 0
polaris-task-force/src/components/frontend/roster/RosterView.tsx

570 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"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>
);
}