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:
parent
3797cbcc03
commit
f645eb16e5
2 changed files with 584 additions and 0 deletions
135
src/app/(frontend)/roster/page.tsx
Normal file
135
src/app/(frontend)/roster/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
449
src/components/frontend/roster/RosterView.tsx
Normal file
449
src/components/frontend/roster/RosterView.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in a new issue