feat(roster): build org chart from explicit subassignment hierarchy
- Resolve parents via subAssignments instead of inferred member overlap - Drop cyclic or dangling subassignment edges - Render members only at their deepest unit; widen unit boxes and pluralize member counts
This commit is contained in:
parent
d4ff6ba1a1
commit
9bf16980cc
2 changed files with 57 additions and 105 deletions
|
|
@ -27,6 +27,7 @@ export type RosterAssignment = {
|
|||
callsign?: string;
|
||||
leaderId?: number;
|
||||
memberIds: number[];
|
||||
subAssignmentIds: number[];
|
||||
};
|
||||
|
||||
export default async function RosterPage() {
|
||||
|
|
@ -111,6 +112,9 @@ export default async function RosterPage() {
|
|||
memberIds: (a.members ?? [])
|
||||
.map((m) => (typeof m === "object" ? m.id : m))
|
||||
.filter(Boolean) as number[],
|
||||
subAssignmentIds: (a.subAssignments ?? [])
|
||||
.map((s) => (typeof s === "object" ? s.id : s))
|
||||
.filter(Boolean) as number[],
|
||||
}));
|
||||
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -1,16 +1,10 @@
|
|||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { cn, pluralize } from "@/lib/utils";
|
||||
import type { RosterMember, RosterAssignment } from "@/app/(frontend)/roster/page";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import {
|
||||
NetworkIcon,
|
||||
Building2Icon,
|
||||
ShieldIcon,
|
||||
ListIcon,
|
||||
CrownIcon,
|
||||
} from "lucide-react";
|
||||
import { NetworkIcon, Building2Icon, ShieldIcon, ListIcon, CrownIcon } from "lucide-react";
|
||||
|
||||
const TYPE_ORDER: Record<string, number> = {
|
||||
division: 0,
|
||||
|
|
@ -29,93 +23,59 @@ const TYPE_LABELS: Record<string, string> = {
|
|||
type TreeNode = RosterAssignment & { children: TreeNode[]; members: RosterMember[] };
|
||||
|
||||
function buildTree(assignments: RosterAssignment[], members: RosterMember[]): TreeNode[] {
|
||||
// Group assignments by type for parent resolution
|
||||
const byType = new Map<string, RosterAssignment[]>();
|
||||
for (const a of assignments) {
|
||||
const list = byType.get(a.type) ?? [];
|
||||
list.push(a);
|
||||
byType.set(a.type, list);
|
||||
}
|
||||
|
||||
// Sort by type depth so parents are processed before children
|
||||
const sorted = [...assignments].sort(
|
||||
(a, b) => (TYPE_ORDER[a.type] ?? 99) - (TYPE_ORDER[b.type] ?? 99),
|
||||
);
|
||||
|
||||
// Resolve parent for each assignment
|
||||
const parentMap = new Map<number, number | null>();
|
||||
for (const a of sorted) {
|
||||
const typeIdx = TYPE_ORDER[a.type] ?? 99;
|
||||
if (typeIdx === 0) {
|
||||
parentMap.set(a.id, null);
|
||||
continue;
|
||||
}
|
||||
|
||||
let found = false;
|
||||
for (let t = typeIdx - 1; t >= 0; t--) {
|
||||
const typeName = Object.keys(TYPE_ORDER).find((k) => TYPE_ORDER[k] === t);
|
||||
const candidates = typeName ? byType.get(typeName) : undefined;
|
||||
if (!candidates) continue;
|
||||
|
||||
for (const parent of candidates) {
|
||||
const parentAllIds = new Set(
|
||||
[...parent.memberIds, parent.leaderId].filter(Boolean),
|
||||
);
|
||||
const thisAllIds = new Set(
|
||||
[...a.memberIds, a.leaderId].filter(Boolean),
|
||||
);
|
||||
const overlap = [...thisAllIds].some((id) => parentAllIds.has(id));
|
||||
if (overlap) {
|
||||
parentMap.set(a.id, parent.id);
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (found) break;
|
||||
}
|
||||
if (!found) {
|
||||
parentMap.set(a.id, null);
|
||||
}
|
||||
}
|
||||
|
||||
// Build tree nodes — each node only holds direct members (not members of children)
|
||||
const nodeMap = new Map<number, TreeNode>();
|
||||
for (const a of assignments) {
|
||||
nodeMap.set(a.id, {
|
||||
...a,
|
||||
children: [],
|
||||
members: members.filter((m) => {
|
||||
const inThisAssignment =
|
||||
a.memberIds.includes(m.user.id) || a.leaderId === m.user.id;
|
||||
const inChildAssignment = assignments.some(
|
||||
(other) =>
|
||||
parentMap.get(other.id) === a.id &&
|
||||
(other.memberIds.includes(m.user.id) || other.leaderId === m.user.id),
|
||||
);
|
||||
return inThisAssignment && !inChildAssignment;
|
||||
}),
|
||||
});
|
||||
nodeMap.set(a.id, { ...a, children: [], members: [] });
|
||||
}
|
||||
|
||||
// Wire parent-child relationships
|
||||
for (const [childId, parentId] of parentMap) {
|
||||
if (parentId != null) {
|
||||
const parent = nodeMap.get(parentId);
|
||||
const child = nodeMap.get(childId);
|
||||
if (parent && child) parent.children.push(child);
|
||||
// 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);
|
||||
|
||||
// Collect root nodes (no parent)
|
||||
const roots: TreeNode[] = [];
|
||||
for (const [id, parentId] of parentMap) {
|
||||
if (parentId == null) {
|
||||
const node = nodeMap.get(id);
|
||||
if (node) roots.push(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),
|
||||
);
|
||||
}
|
||||
|
||||
return roots.sort((a, b) => (TYPE_ORDER[a.type] ?? 99) - (TYPE_ORDER[b.type] ?? 99));
|
||||
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[] {
|
||||
|
|
@ -173,9 +133,7 @@ function OrgChartView({
|
|||
|
||||
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 leader = node.leaderId ? allMembers.find((m) => m.user.id === node.leaderId) : null;
|
||||
const directMembers = node.members.filter((m) => m.user.id !== node.leaderId);
|
||||
|
||||
return (
|
||||
|
|
@ -217,17 +175,13 @@ function UnitBox({
|
|||
directMembers: RosterMember[];
|
||||
memberCount: number;
|
||||
}) {
|
||||
const depthBorders = [
|
||||
"border-l-blue-600",
|
||||
"border-l-amber-600",
|
||||
"border-l-emerald-600",
|
||||
];
|
||||
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-xs",
|
||||
"border rounded-lg bg-muted/20 border-l-4 p-3 w-full min-w-56 max-w-lg",
|
||||
borderClass,
|
||||
)}
|
||||
>
|
||||
|
|
@ -243,7 +197,7 @@ function UnitBox({
|
|||
</span>
|
||||
)}
|
||||
<span className="text-[10px] text-muted-foreground ml-auto shrink-0">
|
||||
{memberCount}
|
||||
{memberCount} {pluralize(memberCount, "member")}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
|
|
@ -378,9 +332,7 @@ function ByRankView({ members }: { members: RosterMember[] }) {
|
|||
// ─── Alphabetical ───────────────────────────────────────────────────
|
||||
|
||||
function AlphabeticalView({ members }: { members: RosterMember[] }) {
|
||||
const sorted = [...members].sort((a, b) =>
|
||||
a.user.displayName.localeCompare(b.user.displayName),
|
||||
);
|
||||
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">
|
||||
|
|
@ -407,9 +359,7 @@ function MemberCard({ member }: { member: RosterMember }) {
|
|||
/>
|
||||
)}
|
||||
<div className="flex flex-col min-w-0 flex-1">
|
||||
<span className="text-sm font-medium truncate">
|
||||
{member.user.displayName}
|
||||
</span>
|
||||
<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>
|
||||
|
|
@ -422,9 +372,7 @@ function MemberCard({ member }: { member: RosterMember }) {
|
|||
)}
|
||||
{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}
|
||||
{member.assignmentCallsign ? `[${member.assignmentCallsign}]` : member.assignmentName}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
Loading…
Reference in a new issue