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.
+