1
0
Fork 0
polaris-task-force/src/components/frontend/roster/RosterView.tsx
Z8MB1E 3348466f3b fix(roster): tighten tab icon spacing and allow org chart scroll
Add gap-1.5 to the roster tab triggers so icons and labels are not
glued together, and let the org chart tab scroll horizontally instead
of overflowing the page.
2026-08-25 12:31:53 -04:00

437 lines
15 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, 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));
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);
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>
);
}
// ─── Member Card ────────────────────────────────────────────────────
function MemberCard({ member }: { member: RosterMember }) {
const leadOrActual = React.useContext(RosterDisplayContext);
const leadLabel = leadOrActual === "actual" ? "Actual" : "Lead";
return (
<Link
href={`/profile/${member.user.username}`}
className="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"
>
{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>
</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>
</Tabs>
</RosterDisplayContext.Provider>
);
}