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 = {
|
||||
profileId: number;
|
||||
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;
|
||||
};
|
||||
assignmentName?: string;
|
||||
|
|
@ -46,6 +48,8 @@ export default async function RosterPage() {
|
|||
select: {
|
||||
user: true,
|
||||
rank: true,
|
||||
dossier: true,
|
||||
progression: true,
|
||||
},
|
||||
}),
|
||||
payload.find({
|
||||
|
|
@ -97,7 +101,15 @@ export default async function RosterPage() {
|
|||
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 },
|
||||
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,
|
||||
assignmentType,
|
||||
assignmentCallsign,
|
||||
|
|
|
|||
|
|
@ -5,7 +5,14 @@ import Link from "next/link";
|
|||
import { cn, pluralize } from "@/lib/utils";
|
||||
import type { RosterAssignment, RosterMember } from "@/app/(frontend)/roster/page";
|
||||
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");
|
||||
|
||||
|
|
@ -102,7 +109,7 @@ function OrgChartView({
|
|||
const assignedIds = new Set(
|
||||
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 (
|
||||
<div className="flex flex-col gap-4">
|
||||
|
|
@ -137,7 +144,9 @@ function OrgChartView({
|
|||
function OrgChartNode({ node, depth }: { node: TreeNode; depth: number }) {
|
||||
const allMembers = collectAllMembers(node);
|
||||
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 (
|
||||
<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 ────────────────────────────────────────────────────
|
||||
|
||||
function MemberCard({ member }: { member: RosterMember }) {
|
||||
const leadOrActual = React.useContext(RosterDisplayContext);
|
||||
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 (
|
||||
<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"
|
||||
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 && (
|
||||
<img
|
||||
src={member.rank.insigniaUrl}
|
||||
|
|
@ -417,6 +543,10 @@ export function RosterView({
|
|||
<ListIcon className="size-3" />
|
||||
A–Z
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="seniority" className="gap-1.5">
|
||||
<CalendarDaysIcon className="size-3" />
|
||||
Years of Service
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="org" className="overflow-x-scroll">
|
||||
|
|
@ -431,6 +561,9 @@ export function RosterView({
|
|||
<TabsContent value="alpha">
|
||||
<AlphabeticalView members={members} />
|
||||
</TabsContent>
|
||||
<TabsContent value="seniority">
|
||||
<SeniorityView members={members} />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</RosterDisplayContext.Provider>
|
||||
);
|
||||
|
|
|
|||
Loading…
Reference in a new issue