feat(roster): enhance member data with enlistment date and experience points, add seniority view
This commit is contained in:
parent
90195286c3
commit
fe38054606
2 changed files with 151 additions and 6 deletions
|
|
@ -13,7 +13,9 @@ export const metadata = {
|
||||||
export type RosterMember = {
|
export type RosterMember = {
|
||||||
profileId: number;
|
profileId: number;
|
||||||
user: Pick<User, "id" | "displayName" | "username">;
|
user: Pick<User, "id" | "displayName" | "username">;
|
||||||
rank: Pick<Rank, "id" | "name" | "abbreviation"> & {
|
enlistmentDate: string | null;
|
||||||
|
xp: number;
|
||||||
|
rank: Pick<Rank, "id" | "name" | "abbreviation" | "_order"> & {
|
||||||
insigniaUrl?: string | null;
|
insigniaUrl?: string | null;
|
||||||
};
|
};
|
||||||
assignmentName?: string;
|
assignmentName?: string;
|
||||||
|
|
@ -46,6 +48,8 @@ export default async function RosterPage() {
|
||||||
select: {
|
select: {
|
||||||
user: true,
|
user: true,
|
||||||
rank: true,
|
rank: true,
|
||||||
|
dossier: true,
|
||||||
|
progression: true,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
payload.find({
|
payload.find({
|
||||||
|
|
@ -97,7 +101,15 @@ export default async function RosterPage() {
|
||||||
return {
|
return {
|
||||||
profileId: profile.id,
|
profileId: profile.id,
|
||||||
user: { id: user.id, displayName: user.displayName, username: user.username },
|
user: { id: user.id, displayName: user.displayName, username: user.username },
|
||||||
rank: { id: rank.id, name: rank.name, abbreviation: rank.abbreviation, insigniaUrl },
|
enlistmentDate: profile.dossier?.enlistmentDate ?? null,
|
||||||
|
xp: profile.progression?.xp ?? 0,
|
||||||
|
rank: {
|
||||||
|
id: rank.id,
|
||||||
|
name: rank.name,
|
||||||
|
abbreviation: rank.abbreviation,
|
||||||
|
_order: rank._order,
|
||||||
|
insigniaUrl,
|
||||||
|
},
|
||||||
assignmentName,
|
assignmentName,
|
||||||
assignmentType,
|
assignmentType,
|
||||||
assignmentCallsign,
|
assignmentCallsign,
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,14 @@ import Link from "next/link";
|
||||||
import { cn, pluralize } from "@/lib/utils";
|
import { cn, pluralize } from "@/lib/utils";
|
||||||
import type { RosterAssignment, RosterMember } from "@/app/(frontend)/roster/page";
|
import type { RosterAssignment, RosterMember } from "@/app/(frontend)/roster/page";
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
import { Building2Icon, CrownIcon, ListIcon, NetworkIcon, ShieldIcon } from "lucide-react";
|
import {
|
||||||
|
Building2Icon,
|
||||||
|
CalendarDaysIcon,
|
||||||
|
CrownIcon,
|
||||||
|
ListIcon,
|
||||||
|
NetworkIcon,
|
||||||
|
ShieldIcon,
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
const RosterDisplayContext = React.createContext<"lead" | "actual">("lead");
|
const RosterDisplayContext = React.createContext<"lead" | "actual">("lead");
|
||||||
|
|
||||||
|
|
@ -102,7 +109,7 @@ function OrgChartView({
|
||||||
const assignedIds = new Set(
|
const assignedIds = new Set(
|
||||||
assignments.flatMap((a) => [...a.memberIds, a.leaderId]).filter(Boolean),
|
assignments.flatMap((a) => [...a.memberIds, a.leaderId]).filter(Boolean),
|
||||||
);
|
);
|
||||||
const unassigned = members.filter((m) => !assignedIds.has(m.user.id));
|
const unassigned = members.filter((m) => !assignedIds.has(m.user.id)).sort(rosterMemberOrder);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
|
|
@ -137,7 +144,9 @@ 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 ? 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);
|
const directMembers = node.members
|
||||||
|
.filter((m) => m.user.id !== node.leaderId)
|
||||||
|
.sort(rosterMemberOrder);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center">
|
<div className="flex flex-col items-center">
|
||||||
|
|
@ -346,17 +355,134 @@ function AlphabeticalView({ members }: { members: RosterMember[] }) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function completedServiceYears(enlistmentDate: string | null): number | null {
|
||||||
|
if (!enlistmentDate) return null;
|
||||||
|
|
||||||
|
const enlistment = new Date(enlistmentDate);
|
||||||
|
if (Number.isNaN(enlistment.getTime())) return null;
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
let years = now.getUTCFullYear() - enlistment.getUTCFullYear();
|
||||||
|
const anniversaryMonth = enlistment.getUTCMonth();
|
||||||
|
const anniversaryDay = enlistment.getUTCDate();
|
||||||
|
const anniversaryPassed =
|
||||||
|
now.getUTCMonth() > anniversaryMonth ||
|
||||||
|
(now.getUTCMonth() === anniversaryMonth && now.getUTCDate() >= anniversaryDay);
|
||||||
|
|
||||||
|
if (!anniversaryPassed) years -= 1;
|
||||||
|
return Math.max(0, years);
|
||||||
|
}
|
||||||
|
|
||||||
|
function rosterMemberOrder(a: RosterMember, b: RosterMember): number {
|
||||||
|
const aRankOrder = a.rank._order ?? null;
|
||||||
|
const bRankOrder = b.rank._order ?? null;
|
||||||
|
const aMissingRank = aRankOrder === null;
|
||||||
|
const bMissingRank = bRankOrder === null;
|
||||||
|
|
||||||
|
if (aMissingRank && !bMissingRank) return 1;
|
||||||
|
if (!aMissingRank && bMissingRank) return -1;
|
||||||
|
if (aRankOrder !== null && bRankOrder !== null && aRankOrder !== bRankOrder) {
|
||||||
|
return bRankOrder.localeCompare(aRankOrder);
|
||||||
|
}
|
||||||
|
|
||||||
|
const aYears = completedServiceYears(a.enlistmentDate);
|
||||||
|
const bYears = completedServiceYears(b.enlistmentDate);
|
||||||
|
if (aYears === null && bYears !== null) return 1;
|
||||||
|
if (aYears !== null && bYears === null) return -1;
|
||||||
|
if (aYears !== null && bYears !== null && aYears !== bYears) return bYears - aYears;
|
||||||
|
|
||||||
|
if (a.xp !== b.xp) return b.xp - a.xp;
|
||||||
|
return a.user.displayName.localeCompare(b.user.displayName);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SeniorityView({ members }: { members: RosterMember[] }) {
|
||||||
|
const sorted = [...members].sort((a, b) => {
|
||||||
|
const aTime = a.enlistmentDate ? new Date(a.enlistmentDate).getTime() : Number.NaN;
|
||||||
|
const bTime = b.enlistmentDate ? new Date(b.enlistmentDate).getTime() : Number.NaN;
|
||||||
|
const aMissing = Number.isNaN(aTime);
|
||||||
|
const bMissing = Number.isNaN(bTime);
|
||||||
|
|
||||||
|
if (aMissing && !bMissing) return 1;
|
||||||
|
if (!aMissing && bMissing) return -1;
|
||||||
|
if (!aMissing && !bMissing && aTime !== bTime) return aTime - bTime;
|
||||||
|
return a.user.displayName.localeCompare(b.user.displayName);
|
||||||
|
});
|
||||||
|
const groups = new Map<number | "unknown", RosterMember[]>();
|
||||||
|
for (const member of sorted) {
|
||||||
|
const years = completedServiceYears(member.enlistmentDate);
|
||||||
|
const group = years === null ? "unknown" : years;
|
||||||
|
groups.set(group, [...(groups.get(group) ?? []), member]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<div>
|
||||||
|
<h3 className="flex items-center gap-2 text-sm font-semibold">
|
||||||
|
<CalendarDaysIcon className="size-4 text-muted-foreground" />
|
||||||
|
Seniority by Years of Service
|
||||||
|
</h3>
|
||||||
|
<p className="mt-1 text-xs text-muted-foreground">Longest-serving members appear first.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{groups.size > 0 ? (
|
||||||
|
<div className="flex flex-col gap-5">
|
||||||
|
{[...groups.entries()].map(([years, groupMembers]) => (
|
||||||
|
<section key={years} className="flex flex-col gap-2">
|
||||||
|
<div className="flex items-baseline gap-2 border-b border-border/60 pb-1">
|
||||||
|
<h4 className="text-xs font-semibold uppercase tracking-wider text-foreground">
|
||||||
|
{years === 0
|
||||||
|
? "Less than a year of service"
|
||||||
|
: years === "unknown"
|
||||||
|
? "Service history unavailable"
|
||||||
|
: `${years} ${years === 1 ? "year" : "years"} of service`}
|
||||||
|
</h4>
|
||||||
|
<span className="text-[10px] font-mono text-muted-foreground">
|
||||||
|
{groupMembers.length}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
{groupMembers.map((member, index) => (
|
||||||
|
<div
|
||||||
|
key={member.profileId}
|
||||||
|
className={cn("rounded-md p-0.5", index % 2 === 0 && "bg-muted/20")}
|
||||||
|
>
|
||||||
|
<MemberCard member={member} />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-muted-foreground">No roster members found.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Member Card ────────────────────────────────────────────────────
|
// ─── Member Card ────────────────────────────────────────────────────
|
||||||
|
|
||||||
function MemberCard({ member }: { member: RosterMember }) {
|
function MemberCard({ member }: { member: RosterMember }) {
|
||||||
const leadOrActual = React.useContext(RosterDisplayContext);
|
const leadOrActual = React.useContext(RosterDisplayContext);
|
||||||
const leadLabel = leadOrActual === "actual" ? "Actual" : "Lead";
|
const leadLabel = leadOrActual === "actual" ? "Actual" : "Lead";
|
||||||
|
const serviceYears = completedServiceYears(member.enlistmentDate);
|
||||||
|
const serviceLabel =
|
||||||
|
serviceYears === null
|
||||||
|
? null
|
||||||
|
: serviceYears === 0
|
||||||
|
? "Less than a year"
|
||||||
|
: `${serviceYears} year${serviceYears === 1 ? "" : "s"}`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
href={`/profile/${member.user.username}`}
|
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"
|
className="relative 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"
|
||||||
>
|
>
|
||||||
|
{serviceLabel && (
|
||||||
|
<span className="absolute top-1 right-2 text-[9px] leading-none text-muted-foreground/70 font-mono pointer-events-none">
|
||||||
|
{serviceLabel} of service
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
{member.rank.insigniaUrl && (
|
{member.rank.insigniaUrl && (
|
||||||
<img
|
<img
|
||||||
src={member.rank.insigniaUrl}
|
src={member.rank.insigniaUrl}
|
||||||
|
|
@ -417,6 +543,10 @@ export function RosterView({
|
||||||
<ListIcon className="size-3" />
|
<ListIcon className="size-3" />
|
||||||
A–Z
|
A–Z
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="seniority" className="gap-1.5">
|
||||||
|
<CalendarDaysIcon className="size-3" />
|
||||||
|
Years of Service
|
||||||
|
</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
<TabsContent value="org" className="overflow-x-scroll">
|
<TabsContent value="org" className="overflow-x-scroll">
|
||||||
|
|
@ -431,6 +561,9 @@ export function RosterView({
|
||||||
<TabsContent value="alpha">
|
<TabsContent value="alpha">
|
||||||
<AlphabeticalView members={members} />
|
<AlphabeticalView members={members} />
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
<TabsContent value="seniority">
|
||||||
|
<SeniorityView members={members} />
|
||||||
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</RosterDisplayContext.Provider>
|
</RosterDisplayContext.Provider>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue