1
0
Fork 0

feat: add roster page with member management

- Add roster page displaying unit members with rank and assignment info
- Add RosterView component with sortable member table
This commit is contained in:
Jason Fraley 2026-07-31 20:02:43 -04:00
parent 3797cbcc03
commit f645eb16e5
2 changed files with 584 additions and 0 deletions

View file

@ -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<User, "id" | "displayName" | "username">;
rank: Pick<Rank, "id" | "name" | "abbreviation"> & {
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 (
<div className="p-5 flex flex-col gap-6">
<div className="flex flex-col gap-1">
<div className="flex items-center gap-2">
<UsersIcon className="size-5 text-muted-foreground" />
<h1 className="text-lg font-semibold">Unit Roster</h1>
</div>
<p className="text-sm text-muted-foreground">
{members.length} operator{members.length !== 1 ? "s" : ""} across{" "}
{assignments.length} assignment{assignments.length !== 1 ? "s" : ""}
</p>
</div>
<RosterView
members={members}
assignments={assignments}
/>
</div>
);
}

View file

@ -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<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[] {
// 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;
}),
});
}
// 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 (
<div className="flex flex-col gap-4">
{roots.length > 0 ? (
<div className="flex flex-col gap-3">
{roots.map((root) => (
<OrgChartNode key={root.id} node={root} depth={0} />
))}
</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 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 (
<div className={cn(depth > 0 && "ml-5 border-l-2 border-border pl-4")}>
<div
className={cn(
"border rounded-lg p-3 bg-muted/20 border-l-4",
borderClass,
)}
>
<div className="flex items-center gap-2 mb-2">
<Building2Icon className="size-3.5 text-muted-foreground" />
<span className="text-sm font-semibold">{node.name}</span>
<span className="text-[10px] uppercase tracking-wider text-muted-foreground bg-muted px-1.5 py-0.5 rounded font-mono">
{TYPE_LABELS[node.type] ?? node.type}
</span>
{node.callsign && (
<span className="text-[10px] text-muted-foreground font-mono">
[{node.callsign}]
</span>
)}
<span className="text-[10px] text-muted-foreground ml-auto">
{allMembers.length} member{allMembers.length !== 1 ? "s" : ""}
</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>
)}
{node.members.filter((m) => m.user.id !== node.leaderId).length > 0 && (
<div className="flex flex-col gap-1">
{node.members
.filter((m) => m.user.id !== node.leaderId)
.map((m) => (
<MemberCard key={m.profileId} member={m} />
))}
</div>
)}
{hasChildren && (
<div className="mt-3 flex flex-col gap-3">
{node.children.map((child) => (
<OrgChartNode key={child.id} node={child} depth={depth + 1} />
))}
</div>
)}
</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 }) {
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">
Lead
</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,
}: {
members: RosterMember[];
assignments: RosterAssignment[];
}) {
return (
<Tabs defaultValue="org">
<TabsList variant="line">
<TabsTrigger value="org">
<NetworkIcon className="size-3" />
Org Chart
</TabsTrigger>
<TabsTrigger value="assignment">
<Building2Icon className="size-3" />
By Assignment
</TabsTrigger>
<TabsTrigger value="rank">
<ShieldIcon className="size-3" />
By Rank
</TabsTrigger>
<TabsTrigger value="alpha">
<ListIcon className="size-3" />
A–Z
</TabsTrigger>
</TabsList>
<TabsContent value="org">
<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>
);
}