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;
|
callsign?: string;
|
||||||
leaderId?: number;
|
leaderId?: number;
|
||||||
memberIds: number[];
|
memberIds: number[];
|
||||||
|
subAssignmentIds: number[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export default async function RosterPage() {
|
export default async function RosterPage() {
|
||||||
|
|
@ -111,6 +112,9 @@ export default async function RosterPage() {
|
||||||
memberIds: (a.members ?? [])
|
memberIds: (a.members ?? [])
|
||||||
.map((m) => (typeof m === "object" ? m.id : m))
|
.map((m) => (typeof m === "object" ? m.id : m))
|
||||||
.filter(Boolean) as number[],
|
.filter(Boolean) as number[],
|
||||||
|
subAssignmentIds: (a.subAssignments ?? [])
|
||||||
|
.map((s) => (typeof s === "object" ? s.id : s))
|
||||||
|
.filter(Boolean) as number[],
|
||||||
}));
|
}));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,10 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import Link from "next/link";
|
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 type { RosterMember, RosterAssignment } from "@/app/(frontend)/roster/page";
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
import {
|
import { NetworkIcon, Building2Icon, ShieldIcon, ListIcon, CrownIcon } from "lucide-react";
|
||||||
NetworkIcon,
|
|
||||||
Building2Icon,
|
|
||||||
ShieldIcon,
|
|
||||||
ListIcon,
|
|
||||||
CrownIcon,
|
|
||||||
} from "lucide-react";
|
|
||||||
|
|
||||||
const TYPE_ORDER: Record<string, number> = {
|
const TYPE_ORDER: Record<string, number> = {
|
||||||
division: 0,
|
division: 0,
|
||||||
|
|
@ -29,93 +23,59 @@ const TYPE_LABELS: Record<string, string> = {
|
||||||
type TreeNode = RosterAssignment & { children: TreeNode[]; members: RosterMember[] };
|
type TreeNode = RosterAssignment & { children: TreeNode[]; members: RosterMember[] };
|
||||||
|
|
||||||
function buildTree(assignments: RosterAssignment[], members: RosterMember[]): TreeNode[] {
|
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>();
|
const nodeMap = new Map<number, TreeNode>();
|
||||||
for (const a of assignments) {
|
for (const a of assignments) {
|
||||||
nodeMap.set(a.id, {
|
nodeMap.set(a.id, { ...a, children: [], members: [] });
|
||||||
...a,
|
}
|
||||||
children: [],
|
|
||||||
members: members.filter((m) => {
|
// Wire the explicit hierarchy from subAssignments. Drop any edge that
|
||||||
const inThisAssignment =
|
// would create a cycle (a unit cannot contain itself) or that points at
|
||||||
a.memberIds.includes(m.user.id) || a.leaderId === m.user.id;
|
// an assignment that wasn't fetched.
|
||||||
const inChildAssignment = assignments.some(
|
const childIds = new Set<number>();
|
||||||
(other) =>
|
const visited = new Set<number>();
|
||||||
parentMap.get(other.id) === a.id &&
|
const inStack = new Set<number>();
|
||||||
(other.memberIds.includes(m.user.id) || other.leaderId === m.user.id),
|
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),
|
||||||
);
|
);
|
||||||
return inThisAssignment && !inChildAssignment;
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Wire parent-child relationships
|
const byOrder = (x: TreeNode, y: TreeNode) =>
|
||||||
for (const [childId, parentId] of parentMap) {
|
(TYPE_ORDER[x.type] ?? 99) - (TYPE_ORDER[y.type] ?? 99) || x.name.localeCompare(y.name);
|
||||||
if (parentId != null) {
|
for (const node of nodeMap.values()) node.children.sort(byOrder);
|
||||||
const parent = nodeMap.get(parentId);
|
return roots.sort(byOrder);
|
||||||
const child = nodeMap.get(childId);
|
|
||||||
if (parent && child) parent.children.push(child);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return roots.sort((a, b) => (TYPE_ORDER[a.type] ?? 99) - (TYPE_ORDER[b.type] ?? 99));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function collectAllMembers(node: TreeNode): RosterMember[] {
|
function collectAllMembers(node: TreeNode): RosterMember[] {
|
||||||
|
|
@ -173,9 +133,7 @@ function OrgChartView({
|
||||||
|
|
||||||
function OrgChartNode({ node, depth }: { node: TreeNode; depth: number }) {
|
function OrgChartNode({ node, depth }: { node: TreeNode; depth: number }) {
|
||||||
const allMembers = collectAllMembers(node);
|
const allMembers = collectAllMembers(node);
|
||||||
const leader = node.leaderId
|
const leader = node.leaderId ? allMembers.find((m) => m.user.id === node.leaderId) : null;
|
||||||
? allMembers.find((m) => m.user.id === node.leaderId)
|
|
||||||
: null;
|
|
||||||
const directMembers = node.members.filter((m) => m.user.id !== node.leaderId);
|
const directMembers = node.members.filter((m) => m.user.id !== node.leaderId);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -217,17 +175,13 @@ function UnitBox({
|
||||||
directMembers: RosterMember[];
|
directMembers: RosterMember[];
|
||||||
memberCount: number;
|
memberCount: number;
|
||||||
}) {
|
}) {
|
||||||
const depthBorders = [
|
const depthBorders = ["border-l-blue-600", "border-l-amber-600", "border-l-emerald-600"];
|
||||||
"border-l-blue-600",
|
|
||||||
"border-l-amber-600",
|
|
||||||
"border-l-emerald-600",
|
|
||||||
];
|
|
||||||
const borderClass = depthBorders[depth] ?? "border-l-zinc-600";
|
const borderClass = depthBorders[depth] ?? "border-l-zinc-600";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
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,
|
borderClass,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|
@ -243,7 +197,7 @@ function UnitBox({
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<span className="text-[10px] text-muted-foreground ml-auto shrink-0">
|
<span className="text-[10px] text-muted-foreground ml-auto shrink-0">
|
||||||
{memberCount}
|
{memberCount} {pluralize(memberCount, "member")}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -378,9 +332,7 @@ function ByRankView({ members }: { members: RosterMember[] }) {
|
||||||
// ─── Alphabetical ───────────────────────────────────────────────────
|
// ─── Alphabetical ───────────────────────────────────────────────────
|
||||||
|
|
||||||
function AlphabeticalView({ members }: { members: RosterMember[] }) {
|
function AlphabeticalView({ members }: { members: RosterMember[] }) {
|
||||||
const sorted = [...members].sort((a, b) =>
|
const sorted = [...members].sort((a, b) => a.user.displayName.localeCompare(b.user.displayName));
|
||||||
a.user.displayName.localeCompare(b.user.displayName),
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-1.5">
|
<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">
|
<div className="flex flex-col min-w-0 flex-1">
|
||||||
<span className="text-sm font-medium truncate">
|
<span className="text-sm font-medium truncate">{member.user.displayName}</span>
|
||||||
{member.user.displayName}
|
|
||||||
</span>
|
|
||||||
<span className="text-[10px] text-muted-foreground font-mono truncate">
|
<span className="text-[10px] text-muted-foreground font-mono truncate">
|
||||||
{member.rank.abbreviation} · @{member.user.username}
|
{member.rank.abbreviation} · @{member.user.username}
|
||||||
</span>
|
</span>
|
||||||
|
|
@ -422,9 +372,7 @@ function MemberCard({ member }: { member: RosterMember }) {
|
||||||
)}
|
)}
|
||||||
{member.assignmentName && (
|
{member.assignmentName && (
|
||||||
<span className="text-[10px] text-muted-foreground bg-muted px-1.5 py-0.5 rounded font-mono hidden sm:block">
|
<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.assignmentCallsign}]` : member.assignmentName}
|
||||||
? `[${member.assignmentCallsign}]`
|
|
||||||
: member.assignmentName}
|
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue