diff --git a/src/app/(frontend)/roster/page.tsx b/src/app/(frontend)/roster/page.tsx new file mode 100644 index 0000000..56f1653 --- /dev/null +++ b/src/app/(frontend)/roster/page.tsx @@ -0,0 +1,135 @@ +import config from "@payload-config"; +import { getPayload } from "payload"; +import type { User, Rank, Media } from "@/payload-types"; +import { UsersIcon } from "lucide-react"; +import { RosterView } from "@/components/frontend/roster/RosterView"; + +export const metadata = { + title: "Unit Roster — Polaris Task Force", +}; + +export type RosterMember = { + profileId: number; + user: Pick; + rank: Pick & { + insigniaUrl?: string | null; + }; + assignmentName?: string; + assignmentType?: string; + assignmentCallsign?: string; + isLeader: boolean; +}; + +export type RosterAssignment = { + id: number; + name: string; + type: string; + callsign?: string; + leaderId?: number; + memberIds: number[]; +}; + +export default async function RosterPage() { + const payload = await getPayload({ config }); + + const [profileRes, assignmentRes] = await Promise.all([ + payload.find({ + collection: "profiles", + limit: 500, + depth: 2, + select: { + user: true, + rank: true, + }, + }), + payload.find({ + collection: "assignments", + limit: 200, + depth: 1, + select: { + name: true, + type: true, + callsign: true, + leader: true, + members: true, + subAssignments: true, + }, + }), + ]); + + const profiles = profileRes.docs; + + const members: RosterMember[] = profiles.map((profile) => { + const user = profile.user as User; + const rank = profile.rank as Rank; + const insigniaUrl = + rank.insignia && typeof rank.insignia === "object" + ? (rank.insignia as Media).url + : null; + + let assignmentName: string | undefined; + let assignmentType: string | undefined; + let assignmentCallsign: string | undefined; + let isLeader = false; + + for (const assignment of assignmentRes.docs) { + const memberIds = (assignment.members ?? []) + .map((m) => (typeof m === "object" ? m.id : m)) + .filter(Boolean); + const leaderId = + assignment.leader && typeof assignment.leader === "object" + ? assignment.leader.id + : assignment.leader; + + if (memberIds.includes(user.id) || leaderId === user.id) { + assignmentName = assignment.name; + assignmentType = assignment.type; + assignmentCallsign = assignment.callsign ?? undefined; + if (leaderId === user.id) isLeader = true; + break; + } + } + + return { + profileId: profile.id, + user: { id: user.id, displayName: user.displayName, username: user.username }, + rank: { id: rank.id, name: rank.name, abbreviation: rank.abbreviation, insigniaUrl }, + assignmentName, + assignmentType, + assignmentCallsign, + isLeader, + }; + }); + + const assignments: RosterAssignment[] = assignmentRes.docs.map((a) => ({ + id: a.id, + name: a.name, + type: a.type, + callsign: a.callsign ?? undefined, + leaderId: + a.leader && typeof a.leader === "object" ? a.leader.id : (a.leader as number | undefined), + memberIds: (a.members ?? []) + .map((m) => (typeof m === "object" ? m.id : m)) + .filter(Boolean) as number[], + })); + + return ( +
+
+
+ +

Unit Roster

+
+

+ {members.length} operator{members.length !== 1 ? "s" : ""} across{" "} + {assignments.length} assignment{assignments.length !== 1 ? "s" : ""} +

+
+ + +
+ ); +} diff --git a/src/components/frontend/roster/RosterView.tsx b/src/components/frontend/roster/RosterView.tsx new file mode 100644 index 0000000..6a332e5 --- /dev/null +++ b/src/components/frontend/roster/RosterView.tsx @@ -0,0 +1,449 @@ +"use client"; + +import Link from "next/link"; +import { cn } 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"; + +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[] { + // Group assignments by type for parent resolution + const byType = new Map(); + 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(); + 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(); + 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; + }), + }); + } + + // 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); + } + } + + // 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[] { + const result = [...node.members]; + for (const child of node.children) { + result.push(...collectAllMembers(child)); + } + return result; +} + +// ─── Org Chart ────────────────────────────────────────────────────── + +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 ( +
+ {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 hasChildren = node.children.length > 0; + const depthBorders = [ + "border-l-blue-600", + "border-l-amber-600", + "border-l-emerald-600", + ]; + const borderClass = depthBorders[depth] ?? "border-l-zinc-600"; + const leader = node.leaderId + ? allMembers.find((m) => m.user.id === node.leaderId) + : null; + + return ( +
0 && "ml-5 border-l-2 border-border pl-4")}> +
+
+ + {node.name} + + {TYPE_LABELS[node.type] ?? node.type} + + {node.callsign && ( + + [{node.callsign}] + + )} + + {allMembers.length} member{allMembers.length !== 1 ? "s" : ""} + +
+ + {leader && ( +
+ + Commanding + + +
+ )} + + {node.members.filter((m) => m.user.id !== node.leaderId).length > 0 && ( +
+ {node.members + .filter((m) => m.user.id !== node.leaderId) + .map((m) => ( + + ))} +
+ )} + + {hasChildren && ( +
+ {node.children.map((child) => ( + + ))} +
+ )} +
+
+ ); +} + +// ─── 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) => ( + + ))} +
+ ); +} + +// ─── Member Card ──────────────────────────────────────────────────── + +function MemberCard({ member }: { member: RosterMember }) { + return ( + + {member.rank.insigniaUrl && ( + {member.rank.name} + )} +
+ + {member.user.displayName} + + + {member.rank.abbreviation} · @{member.user.username} + +
+
+ {member.isLeader && ( + + Lead + + )} + {member.assignmentName && ( + + {member.assignmentCallsign + ? `[${member.assignmentCallsign}]` + : member.assignmentName} + + )} +
+ + ); +} + +// ─── Main RosterView ──────────────────────────────────────────────── + +export function RosterView({ + members, + assignments, +}: { + members: RosterMember[]; + assignments: RosterAssignment[]; +}) { + return ( + + + + + Org Chart + + + + By Assignment + + + + By Rank + + + + A–Z + + + + + + + + + + + + + + + + + ); +}