From 0f7d3a57e377c86fc9b90b1c805f56c0797187ff Mon Sep 17 00:00:00 2001 From: Z8MB1E Date: Sun, 6 Sep 2026 01:24:14 -0400 Subject: [PATCH] feat(personnel): add npc contacts directory and statistics pages Adds a Personnel nav group with Contacts (searchable NPC directory with profile pages showing listings and employment) and Statistics pages; moves Wiki under Intelligence and checks off the corresponding TODO item. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- TODO.md | 2 +- .../personnel/contacts/[id]/page.tsx | 172 ++++++++++++++ .../(frontend)/personnel/contacts/page.tsx | 36 +++ .../(frontend)/personnel/statistics/page.tsx | 222 ++++++++++++++++++ src/components/frontend/blocks/AppSidebar.tsx | 7 +- src/components/frontend/blocks/sidebarData.ts | 22 ++ src/components/frontend/personnel/NpcCard.tsx | 51 ++++ .../frontend/personnel/NpcDirectory.tsx | 128 ++++++++++ .../frontend/personnel/NpcProfile.tsx | 199 ++++++++++++++++ 9 files changed, 832 insertions(+), 7 deletions(-) create mode 100644 src/app/(frontend)/personnel/contacts/[id]/page.tsx create mode 100644 src/app/(frontend)/personnel/contacts/page.tsx create mode 100644 src/app/(frontend)/personnel/statistics/page.tsx create mode 100644 src/components/frontend/personnel/NpcCard.tsx create mode 100644 src/components/frontend/personnel/NpcDirectory.tsx create mode 100644 src/components/frontend/personnel/NpcProfile.tsx diff --git a/TODO.md b/TODO.md index 17f94cc..610994f 100644 --- a/TODO.md +++ b/TODO.md @@ -8,7 +8,7 @@ - [ ] **Scoped admin UI pages** - The admin UI is fantastic, but it needs to be scoped so that users can only see and interact with certain pages even if they have read permissions for any/all pages. For example, if a user has read permissions for the Users collection, they can't just see the admin page for it - they need something like "admin:users:manage" or something like that (based on whatever format fits our current system best.) So long as they have BOTH the read and manage permissions, they can see the admin page. Otherwise, it's just hidden from them entirely. - [x] **Editable helpdesk tickets & user voting** - Having helpdesk tickets be editable by their authors after the fact would be very helpful. This would have to be auditable, of course. In addition, allow users to upvote specific tickets would help to prioritize desired features, pressing bugs, or other tickets wherein there is a shared desire for work from the userbase. - [ ] **User-maintained wiki** - There really should be a wiki system (similar to MediaWiki or Fandom) where members of the unit can contribute information they have gathered about the campaign, its world and lore, characters, plot points, media, etc., with revision history, collaborative editing, template and tag support, wikilinks, and similar functionality. This is a BIG lift, but also an essential component, ESPECIALLY for our intelligence division members. While everyone should be allowed to contribute and make edits, intelligence division members should have moderator powers over this wiki, with the capability to lockdown pages, restore versions, etc. -- [ ] **"Personnel" navigation group** - A "Personnel" nav group in the sidebar with subpages like "Contacts (NPCs)", "Statistics", etc., would be really helpful for future updates wherein the players will have interactions with NPCs via the market, in general conversation, long-standing faction interactions, etc. +- [x] **"Personnel" navigation group** - A "Personnel" nav group in the sidebar with subpages like "Contacts (NPCs)", "Statistics", etc., would be really helpful for future updates wherein the players will have interactions with NPCs via the market, in general conversation, long-standing faction interactions, etc. - [ ] **Respawn tickets based on attendance** - I want to track respawn tickets as a "global variable" that is automatically incremented based on reported attendance for a mission compared against real attendance checked via the webapp<->Arma sync system. For every person who marks that they will attend and DOES attend, 1 respawn ticket is awarded. For every person who marks they MIGHT attend and DOES attend, 0.5 respawn tickets will be awarded. For every person who marks that they will NOT attend and does attend, 0.25 respawn tickets will be awarded. All respawn tickets are accumulated and applied to the next operation. ## Desirable Additions, Medium Priority diff --git a/src/app/(frontend)/personnel/contacts/[id]/page.tsx b/src/app/(frontend)/personnel/contacts/[id]/page.tsx new file mode 100644 index 0000000..a11a745 --- /dev/null +++ b/src/app/(frontend)/personnel/contacts/[id]/page.tsx @@ -0,0 +1,172 @@ +import config from "@payload-config"; +import { getPayload } from "payload"; +import { notFound } from "next/navigation"; +import { headers as nextHeaders } from "next/headers"; +import type { GameNpc, MarketListing, NpcStaffing } from "@/payload-types"; +import { NpcProfile } from "@/components/frontend/personnel/NpcProfile"; +import { EmploymentCard } from "@/components/frontend/baseManagement/EmploymentCard"; +import { hasLogisticsQualification } from "@/utils/access-control/hasLogisticsQualification"; +import type { LaborClassificationOption, StructureStaffingOption } from "@/lib/base"; +import { currencyConfigFromGameRules } from "@/lib/banking"; + +interface NpcContactPageProps { + params: Promise<{ id: string }>; +} + +export default async function NpcContactPage({ params }: NpcContactPageProps) { + const { id: idParam } = await params; + const id = Number(idParam); + + if (!Number.isInteger(id) || id <= 0) notFound(); + + const payload = await getPayload({ config }); + const headers = await nextHeaders(); + const { user } = await payload.auth({ headers, canSetHeaders: false }); + + let npc: GameNpc; + try { + npc = (await payload.findByID({ + collection: "game-npcs", + id, + depth: 2, + })) as unknown as GameNpc; + } catch { + notFound(); + } + + if (!npc) notFound(); + + const staffingRes = await payload.find({ + collection: "npc-staffing", + where: { + and: [{ npc: { equals: id } }, { status: { equals: "active" } }], + }, + limit: 1, + depth: 2, + }); + const staffing = staffingRes.docs[0] as NpcStaffing | undefined; + const staffingStructure = staffing?.structure; + const structureName = + typeof staffingStructure === "object" && staffingStructure !== null + ? staffingStructure.name + : typeof staffingStructure === "number" + ? `Structure #${staffingStructure}` + : null; + const employment = + staffing && structureName + ? { + positionTitle: staffing.positionTitle, + positionType: staffing.positionType, + structureName, + salary: staffing.salary ?? 0, + } + : null; + + const structuresRes = await payload.find({ + collection: "game-structures", + limit: 200, + depth: 0, + }); + const activeStaffRes = await payload.find({ + collection: "npc-staffing", + where: { status: { equals: "active" } }, + limit: 1000, + depth: 0, + overrideAccess: true, + }); + const structures: StructureStaffingOption[] = structuresRes.docs.map((structure) => ({ + id: structure.id, + name: structure.name, + maintenanceCost: structure.maintenanceCost ?? 0, + laborRequirements: (structure.laborRequirements ?? []).map((requirement) => { + const classificationId = + typeof requirement.classification === "object" + ? requirement.classification.id + : requirement.classification; + return { + classificationId, + requiredHeadcount: requirement.requiredHeadcount, + maxHeadcount: requirement.maxHeadcount ?? 0, + enforcedSalary: requirement.enforcedSalary ?? null, + currentHeadcount: activeStaffRes.docs + .filter((entry) => { + const structureId = + typeof entry.structure === "object" ? entry.structure.id : entry.structure; + const laborClassificationId = + typeof entry.laborClassification === "object" && entry.laborClassification !== null + ? entry.laborClassification.id + : entry.laborClassification; + return structureId === structure.id && laborClassificationId === classificationId; + }) + .reduce((total, entry) => total + (entry.headcount ?? 1), 0), + }; + }), + staffPositions: (structure.staffPositions ?? []).map((position) => ({ + positionType: position.positionType, + max: position.max ?? null, + enforcedSalary: position.enforcedSalary ?? null, + currentHeadcount: activeStaffRes.docs.filter((entry) => { + const structureId = + typeof entry.structure === "object" ? entry.structure.id : entry.structure; + return ( + structureId === structure.id && + entry.npc !== null && + entry.positionType === position.positionType + ); + }).length, + })), + })); + + const classificationsRes = await payload.find({ + collection: "labor-classifications", + limit: 100, + depth: 0, + }); + const classifications: LaborClassificationOption[] = classificationsRes.docs.map((doc) => ({ + id: doc.id, + name: doc.name, + key: doc.key, + defaultUnitSalary: doc.defaultUnitSalary ?? null, + availableHeadcount: doc.availableHeadcount ?? 0, + })); + + const rules = await payload.findGlobal({ + slug: "game-rules", + depth: 1, + overrideAccess: true, + }); + const currencyConfig = currencyConfigFromGameRules(rules); + + const canManage = await hasLogisticsQualification(payload, user); + + const listingsRes = await payload.find({ + collection: "market-listings", + where: { + and: [{ npc: { equals: id } }, { status: { equals: "active" } }], + }, + limit: 100, + depth: 1, + }); + const listings = listingsRes.docs as unknown as MarketListing[]; + + return ( +
+
+ +
+
+ +
+
+ ); +} diff --git a/src/app/(frontend)/personnel/contacts/page.tsx b/src/app/(frontend)/personnel/contacts/page.tsx new file mode 100644 index 0000000..bf1f874 --- /dev/null +++ b/src/app/(frontend)/personnel/contacts/page.tsx @@ -0,0 +1,36 @@ +import config from "@payload-config"; +import { getPayload } from "payload"; +import type { GameNpc } from "@/payload-types"; +import { UsersIcon } from "lucide-react"; +import { NpcDirectory } from "@/components/frontend/personnel/NpcDirectory"; + +export const metadata = { + title: "Contacts — Polaris Task Force", +}; + +export default async function ContactsPage() { + const payload = await getPayload({ config }); + const npcsRes = await payload.find({ + collection: "game-npcs", + limit: 200, + depth: 1, + }); + const npcs = npcsRes.docs as unknown as GameNpc[]; + + return ( +
+
+
+ +

Contacts

+
+

+ {npcs.length} contact{npcs.length !== 1 ? "s" : ""} recorded in the theater of + operations. +

+
+ + +
+ ); +} diff --git a/src/app/(frontend)/personnel/statistics/page.tsx b/src/app/(frontend)/personnel/statistics/page.tsx new file mode 100644 index 0000000..0da4c15 --- /dev/null +++ b/src/app/(frontend)/personnel/statistics/page.tsx @@ -0,0 +1,222 @@ +import config from "@payload-config"; +import { getPayload } from "payload"; +import type { GameNpc, MarketListing } from "@/payload-types"; +import { TrendingUp } from "lucide-react"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; + +export const metadata = { + title: "Statistics — Polaris Task Force", +}; + +interface FactionStatistic { + name: string; + count: number; + percentage: number; +} + +interface VendorStatistic { + name: string; + occupation: string | null; + listingCount: number; +} + +function factionName(npc: GameNpc): string { + return typeof npc.faction === "object" && npc.faction !== null + ? npc.faction.name + : "Unaffiliated"; +} + +function npcIdFromListing(listing: MarketListing): number | null { + if (typeof listing.npc === "number") return listing.npc; + if (typeof listing.npc === "object" && listing.npc !== null) return listing.npc.id; + return null; +} + +export default async function StatisticsPage() { + const payload = await getPayload({ config }); + const [npcsRes, listingsRes] = await Promise.all([ + payload.find({ + collection: "game-npcs", + limit: 500, + depth: 1, + }), + payload.find({ + collection: "market-listings", + where: { + and: [ + { isAutoGenerated: { equals: true } }, + { status: { equals: "active" } }, + ], + }, + limit: 200, + depth: 0, + }), + ]); + + const npcs = npcsRes.docs as unknown as GameNpc[]; + const activeVendorListings = listingsRes.docs as unknown as MarketListing[]; + const totalNpcs = npcs.length; + const inGameCount = npcs.filter((npc) => npc.isInGame === true).length; + const generatedCount = npcs.filter((npc) => npc.isGenerated === true).length; + const vendorStallCount = npcs.filter((npc) => npc.vendor?.enabled === true).length; + + const factionCounts = new Map(); + for (const npc of npcs) { + const name = factionName(npc); + factionCounts.set(name, (factionCounts.get(name) ?? 0) + 1); + } + + const factionDistribution: FactionStatistic[] = [...factionCounts.entries()] + .map(([name, count]) => ({ + name, + count, + percentage: totalNpcs > 0 ? Math.round((count / totalNpcs) * 100) : 0, + })) + .sort((left, right) => right.count - left.count || left.name.localeCompare(right.name)); + + const npcById = new Map(npcs.map((npc) => [npc.id, npc])); + const listingCounts = new Map(); + for (const listing of activeVendorListings) { + const npcId = npcIdFromListing(listing); + if (npcId === null || !npcById.has(npcId)) continue; + listingCounts.set(npcId, (listingCounts.get(npcId) ?? 0) + 1); + } + + const topVendors: VendorStatistic[] = [...listingCounts.entries()] + .map(([npcId, listingCount]) => { + const npc = npcById.get(npcId); + return npc + ? { + name: npc.name, + occupation: npc.occupation ?? null, + listingCount, + } + : null; + }) + .filter((vendor): vendor is VendorStatistic => vendor !== null) + .sort( + (left, right) => + right.listingCount - left.listingCount || left.name.localeCompare(right.name), + ) + .slice(0, 5); + + return ( +
+
+
+ +

Statistics

+
+

+ A theater-wide view of NPC presence, affiliations, and vendor activity. +

+
+ +
+ + + Total contacts + {totalNpcs} + + + + + In-game contacts + {inGameCount} + + + + + Generated placeholders + {generatedCount} + + + + + Vendor stalls + {vendorStallCount} + + + + + Active vendor stock + {activeVendorListings.length} + + +
+ +
+ + + Faction distribution + NPC assignments across the current theater. + + + {factionDistribution.length === 0 ? ( +

+ No faction assignments recorded. +

+ ) : ( +
+ {factionDistribution.map((faction) => ( +
+
+ {faction.name} + + {faction.count} ({faction.percentage}%) + +
+
+
+
+
+ ))} +
+ )} + + + + + + Top vendors + Contacts with the most active auto-generated stock. + + + {topVendors.length === 0 ? ( +

No active vendor stock.

+ ) : ( +
+ {topVendors.map((vendor) => ( +
+
+

{vendor.name}

+

+ {vendor.occupation ?? "Vendor"} +

+
+
+

{vendor.listingCount}

+

listing(s)

+
+
+ ))} +
+ )} +
+
+
+
+ ); +} diff --git a/src/components/frontend/blocks/AppSidebar.tsx b/src/components/frontend/blocks/AppSidebar.tsx index aa2cd07..3810f45 100644 --- a/src/components/frontend/blocks/AppSidebar.tsx +++ b/src/components/frontend/blocks/AppSidebar.tsx @@ -3,7 +3,6 @@ import * as React from "react"; import { ArrowLeftRight, - BookOpen, Bolt, Shield, Users, @@ -51,11 +50,6 @@ const data = { url: "/transfers", icon: ArrowLeftRight, }, - { - title: "Wiki", - url: "/wiki", - icon: BookOpen, - }, ], ...sidebarData, }; @@ -161,6 +155,7 @@ export function AppSidebar({ )} + {/**/}
diff --git a/src/components/frontend/blocks/sidebarData.ts b/src/components/frontend/blocks/sidebarData.ts index bced781..a32e822 100644 --- a/src/components/frontend/blocks/sidebarData.ts +++ b/src/components/frontend/blocks/sidebarData.ts @@ -1,6 +1,7 @@ import { Award, Backpack, + Contact, Crosshair, Kanban, LifeBuoy, @@ -48,6 +49,10 @@ export const sidebarData = { title: "Factions", url: "/intelligence/factions", }, + { + title: "Wiki", + url: "/wiki", + }, ], }, ], @@ -92,6 +97,23 @@ export const sidebarData = { icon: ShoppingBag, }, ], + navPersonnel: [ + { + title: "Personnel", + url: "#", + icon: Contact, + items: [ + { + title: "Contacts", + url: "/personnel/contacts", + }, + { + title: "Statistics", + url: "/personnel/statistics", + }, + ], + }, + ], navPersonal: [ { title: "Awards", diff --git a/src/components/frontend/personnel/NpcCard.tsx b/src/components/frontend/personnel/NpcCard.tsx new file mode 100644 index 0000000..ad0dae0 --- /dev/null +++ b/src/components/frontend/personnel/NpcCard.tsx @@ -0,0 +1,51 @@ +"use client"; + +import Link from "next/link"; +import type { GameNpc } from "@/payload-types"; +import { Badge } from "@/components/ui/badge"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; + +interface NpcCardProps { + readonly npc: GameNpc; +} + +export function NpcCard({ npc }: NpcCardProps) { + const factionName = + typeof npc.faction === "object" && npc.faction !== null ? npc.faction.name : null; + + return ( + + + + {npc.name} +

+ {npc.occupation ?? "Occupation not recorded"} +

+
+ {npc.isInGame === true && ( + + In Game + + )} + {npc.isGenerated === true && ( + + Generated + + )} + {npc.vendor?.enabled === true && ( + + Vendor + + )} +
+
+ +
+ Faction + {factionName ?? "Unaffiliated"} +
+
+
+ + ); +} diff --git a/src/components/frontend/personnel/NpcDirectory.tsx b/src/components/frontend/personnel/NpcDirectory.tsx new file mode 100644 index 0000000..5233863 --- /dev/null +++ b/src/components/frontend/personnel/NpcDirectory.tsx @@ -0,0 +1,128 @@ +"use client"; + +import { useMemo, useState } from "react"; +import { SearchIcon } from "lucide-react"; +import type { GameNpc } from "@/payload-types"; +import { Badge } from "@/components/ui/badge"; +import { Card, CardContent } from "@/components/ui/card"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { cn } from "@/lib/utils"; +import { NpcCard } from "@/components/frontend/personnel/NpcCard"; + +interface NpcDirectoryProps { + readonly npcs: GameNpc[]; +} + +export function NpcDirectory({ npcs }: NpcDirectoryProps) { + const [search, setSearch] = useState(""); + const [inGameOnly, setInGameOnly] = useState(false); + const [vendorsOnly, setVendorsOnly] = useState(false); + + const filteredNpcs = useMemo(() => { + const query = search.trim().toLowerCase(); + + return [...npcs] + .filter((npc) => { + if (inGameOnly && npc.isInGame !== true) return false; + if (vendorsOnly && npc.vendor?.enabled !== true) return false; + if ( + query && + !`${npc.name} ${npc.occupation ?? ""}`.toLowerCase().includes(query) + ) { + return false; + } + return true; + }) + .sort((left, right) => left.name.localeCompare(right.name)); + }, [inGameOnly, npcs, search, vendorsOnly]); + + const hasFilters = Boolean(search.trim()) || inGameOnly || vendorsOnly; + + return ( +
+
+
+
+ +
+ + setSearch(event.target.value)} + /> +
+
+ +
+ Contact filters +
+ setInGameOnly(checked === true)} + /> + +
+
+ setVendorsOnly(checked === true)} + /> + +
+
+
+ + + {filteredNpcs.length} of {npcs.length} + +
+ + {filteredNpcs.length === 0 ? ( + + +

+ {npcs.length === 0 + ? "No contacts have been added." + : hasFilters + ? "No contacts match those filters." + : "No contacts found."} +

+

+ {npcs.length === 0 + ? "NPC records will appear here as the theater develops." + : "Try a different search or clear one of the filters."} +

+
+
+ ) : ( +
+ {filteredNpcs.map((npc) => ( + + ))} +
+ )} +
+ ); +} diff --git a/src/components/frontend/personnel/NpcProfile.tsx b/src/components/frontend/personnel/NpcProfile.tsx new file mode 100644 index 0000000..1b08d5f --- /dev/null +++ b/src/components/frontend/personnel/NpcProfile.tsx @@ -0,0 +1,199 @@ +"use client"; + +import { Fragment } from "react"; +import Link from "next/link"; +import { ArrowLeftIcon, MapPinIcon, ShieldIcon, StoreIcon } from "lucide-react"; +import type { Asset, GameNpc, MarketListing } from "@/payload-types"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { Separator } from "@/components/ui/separator"; +import { formatAmount } from "@/lib/banking/format"; + +interface NpcProfileProps { + readonly npc: GameNpc; + readonly listings: MarketListing[]; +} + +function assetName(asset: number | Asset): string { + return typeof asset === "object" && asset !== null ? asset.name : `Asset #${asset}`; +} + +function StatusBadges({ npc }: { npc: GameNpc }) { + return ( +
+ {npc.isInGame === true && ( + + In Game + + )} + {npc.isGenerated === true && ( + + Generated + + )} + {npc.vendor?.enabled === true && ( + + Vendor + + )} +
+ ); +} + +export function NpcProfile({ npc, listings }: NpcProfileProps) { + const faction = typeof npc.faction === "object" && npc.faction !== null ? npc.faction : null; + const location = + typeof npc.location === "object" && npc.location !== null ? npc.location : null; + const sellableAssets = (npc.vendor?.sells ?? []).filter( + (asset): asset is Asset => typeof asset === "object" && asset !== null, + ); + const vendorEnabled = npc.vendor?.enabled === true; + + return ( +
+ + + Back to Contacts + + +
+
+

{npc.name}

+ +
+

+ {npc.occupation ?? "Occupation not recorded"} +

+
+ +
+
+ + + Bio + Background information on this contact. + + +

+ {npc.bio?.trim() || "No bio on file."} +

+
+
+ + + + Assignment + Known affiliations and current station. + + +
+ +
+ + Stationed at + + {location?.name ?? "Location unknown"} +
+
+
+ +
+ + Faction + + {faction?.name ?? "Unaffiliated"} +
+
+
+
+
+ +
+ {vendorEnabled && ( + + +
+ + Vendor information +
+ Market preferences for this contact. +
+ +
+ Price modifier + + ×{(npc.vendor?.priceModifier ?? 1).toFixed(2)} + +
+
+ + Goods they sell + + {sellableAssets.length > 0 ? ( +
+ {sellableAssets.map((asset) => ( + + {asset.name} + + ))} +
+ ) : ( +

No goods assigned.

+ )} +
+
+
+ )} + + + +
+ Active Market Stock + {listings.length} active listing(s). +
+ +
+ + {listings.length === 0 ? ( +

No active stock.

+ ) : ( +
+ {listings.map((listing, index) => ( + + {index > 0 && } +
+
+

+ {assetName(listing.asset)} +

+

+ {listing.quantity.toLocaleString()} available +

+
+ + {formatAmount(listing.price, listing.currency)} + +
+
+ ))} +
+ )} +
+
+
+
+
+ ); +}