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 <clio-agent@sisyphuslabs.ai>
This commit is contained in:
parent
9e44b69714
commit
0f7d3a57e3
9 changed files with 832 additions and 7 deletions
2
TODO.md
2
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
|
||||
|
|
|
|||
172
src/app/(frontend)/personnel/contacts/[id]/page.tsx
Normal file
172
src/app/(frontend)/personnel/contacts/[id]/page.tsx
Normal file
|
|
@ -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 (
|
||||
<div className="grid gap-6 lg:grid-cols-2">
|
||||
<div className="lg:col-span-2">
|
||||
<NpcProfile npc={npc} listings={listings} />
|
||||
</div>
|
||||
<div className="px-5 pb-5 lg:col-start-2">
|
||||
<EmploymentCard
|
||||
employment={employment}
|
||||
structures={structures}
|
||||
canManage={canManage}
|
||||
npcId={npc.id}
|
||||
npcName={npc.name}
|
||||
classifications={classifications}
|
||||
hiringEnabled={Boolean(rules.staffHiringEnabled)}
|
||||
hiringDisabledMessage={rules.staffHiringDisabledMessage ?? undefined}
|
||||
currencyConfig={currencyConfig}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
36
src/app/(frontend)/personnel/contacts/page.tsx
Normal file
36
src/app/(frontend)/personnel/contacts/page.tsx
Normal file
|
|
@ -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 (
|
||||
<div className="flex flex-col gap-6 p-5">
|
||||
<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">Contacts</h1>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{npcs.length} contact{npcs.length !== 1 ? "s" : ""} recorded in the theater of
|
||||
operations.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<NpcDirectory npcs={npcs} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
222
src/app/(frontend)/personnel/statistics/page.tsx
Normal file
222
src/app/(frontend)/personnel/statistics/page.tsx
Normal file
|
|
@ -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<string, number>();
|
||||
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<number, number>();
|
||||
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 (
|
||||
<div className="flex flex-col gap-6 p-5">
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<TrendingUp className="size-5 text-muted-foreground" />
|
||||
<h1 className="text-lg font-semibold">Statistics</h1>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
A theater-wide view of NPC presence, affiliations, and vendor activity.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-5">
|
||||
<Card>
|
||||
<CardHeader className="gap-1">
|
||||
<CardDescription>Total contacts</CardDescription>
|
||||
<CardTitle className="text-2xl tabular-nums">{totalNpcs}</CardTitle>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="gap-1">
|
||||
<CardDescription>In-game contacts</CardDescription>
|
||||
<CardTitle className="text-2xl tabular-nums">{inGameCount}</CardTitle>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="gap-1">
|
||||
<CardDescription>Generated placeholders</CardDescription>
|
||||
<CardTitle className="text-2xl tabular-nums">{generatedCount}</CardTitle>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="gap-1">
|
||||
<CardDescription>Vendor stalls</CardDescription>
|
||||
<CardTitle className="text-2xl tabular-nums">{vendorStallCount}</CardTitle>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="gap-1">
|
||||
<CardDescription>Active vendor stock</CardDescription>
|
||||
<CardTitle className="text-2xl tabular-nums">{activeVendorListings.length}</CardTitle>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-2">
|
||||
<Card>
|
||||
<CardHeader className="gap-2">
|
||||
<CardTitle>Faction distribution</CardTitle>
|
||||
<CardDescription>NPC assignments across the current theater.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{factionDistribution.length === 0 ? (
|
||||
<p className="text-sm italic text-muted-foreground">
|
||||
No faction assignments recorded.
|
||||
</p>
|
||||
) : (
|
||||
<div className="flex flex-col gap-4">
|
||||
{factionDistribution.map((faction) => (
|
||||
<div key={faction.name} className="flex flex-col gap-2">
|
||||
<div className="flex items-center justify-between gap-3 text-sm">
|
||||
<span className="truncate">{faction.name}</span>
|
||||
<span className="shrink-0 tabular-nums text-muted-foreground">
|
||||
{faction.count} ({faction.percentage}%)
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-2 overflow-hidden rounded-full bg-muted">
|
||||
<div
|
||||
className="h-full rounded-full bg-primary"
|
||||
style={{ width: `${faction.percentage}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="gap-2">
|
||||
<CardTitle>Top vendors</CardTitle>
|
||||
<CardDescription>Contacts with the most active auto-generated stock.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{topVendors.length === 0 ? (
|
||||
<p className="text-sm italic text-muted-foreground">No active vendor stock.</p>
|
||||
) : (
|
||||
<div className="flex flex-col gap-4">
|
||||
{topVendors.map((vendor) => (
|
||||
<div
|
||||
key={vendor.name}
|
||||
className="flex items-center justify-between gap-4"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm font-semibold">{vendor.name}</p>
|
||||
<p className="truncate text-xs text-muted-foreground">
|
||||
{vendor.occupation ?? "Vendor"}
|
||||
</p>
|
||||
</div>
|
||||
<div className="shrink-0 text-right">
|
||||
<p className="text-sm font-semibold tabular-nums">{vendor.listingCount}</p>
|
||||
<p className="text-xs text-muted-foreground">listing(s)</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -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({
|
|||
)}
|
||||
<NavCore label="Helpdesk" items={data.navHelpdesk} />
|
||||
<NavCore label="Commerce" items={data.navCommerce} />
|
||||
<NavCore label="Personnel" items={data.navPersonnel} />
|
||||
<NavCore label="Personal" items={data.navPersonal} />
|
||||
{/*<NavProjects projects={data.projects} />*/}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
51
src/components/frontend/personnel/NpcCard.tsx
Normal file
51
src/components/frontend/personnel/NpcCard.tsx
Normal file
|
|
@ -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 (
|
||||
<Link href={`/personnel/contacts/${npc.id}`} className="block h-full">
|
||||
<Card className="h-full transition-colors hover:bg-accent/50">
|
||||
<CardHeader className="gap-2">
|
||||
<CardTitle className="text-base">{npc.name}</CardTitle>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{npc.occupation ?? "Occupation not recorded"}
|
||||
</p>
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
{npc.isInGame === true && (
|
||||
<Badge variant="secondary" className="border-emerald-500/30 text-emerald-500">
|
||||
In Game
|
||||
</Badge>
|
||||
)}
|
||||
{npc.isGenerated === true && (
|
||||
<Badge variant="outline" className="border-amber-500/40 text-amber-500">
|
||||
Generated
|
||||
</Badge>
|
||||
)}
|
||||
{npc.vendor?.enabled === true && (
|
||||
<Badge variant="outline" className="border-violet-500/40 text-violet-500">
|
||||
Vendor
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-2 text-sm">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span className="text-muted-foreground">Faction</span>
|
||||
<span className="truncate text-right">{factionName ?? "Unaffiliated"}</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
128
src/components/frontend/personnel/NpcDirectory.tsx
Normal file
128
src/components/frontend/personnel/NpcDirectory.tsx
Normal file
|
|
@ -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 (
|
||||
<div className="flex flex-col gap-5">
|
||||
<div className="flex flex-wrap items-end justify-between gap-3">
|
||||
<div className="flex flex-wrap items-end gap-3">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="npc-search">Search contacts</Label>
|
||||
<div className="relative">
|
||||
<SearchIcon className="pointer-events-none absolute left-2.5 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
id="npc-search"
|
||||
aria-label="Search contacts by name or occupation"
|
||||
className="w-full pl-8 sm:w-72"
|
||||
placeholder="Search name or occupation..."
|
||||
value={search}
|
||||
onChange={(event) => setSearch(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<fieldset className="flex flex-wrap items-center gap-2">
|
||||
<legend className="sr-only">Contact filters</legend>
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-2 rounded-md border px-3 py-2",
|
||||
inGameOnly && "border-primary/50 bg-primary/5",
|
||||
)}
|
||||
>
|
||||
<Checkbox
|
||||
id="npc-in-game-only"
|
||||
checked={inGameOnly}
|
||||
onCheckedChange={(checked) => setInGameOnly(checked === true)}
|
||||
/>
|
||||
<Label htmlFor="npc-in-game-only" className="cursor-pointer text-sm font-normal">
|
||||
In-game only
|
||||
</Label>
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-2 rounded-md border px-3 py-2",
|
||||
vendorsOnly && "border-primary/50 bg-primary/5",
|
||||
)}
|
||||
>
|
||||
<Checkbox
|
||||
id="npc-vendors-only"
|
||||
checked={vendorsOnly}
|
||||
onCheckedChange={(checked) => setVendorsOnly(checked === true)}
|
||||
/>
|
||||
<Label htmlFor="npc-vendors-only" className="cursor-pointer text-sm font-normal">
|
||||
Vendors only
|
||||
</Label>
|
||||
</div>
|
||||
</fieldset>
|
||||
</div>
|
||||
|
||||
<Badge variant="outline" className="w-fit">
|
||||
{filteredNpcs.length} of {npcs.length}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{filteredNpcs.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent className="flex flex-col gap-1 p-6">
|
||||
<p className="font-semibold">
|
||||
{npcs.length === 0
|
||||
? "No contacts have been added."
|
||||
: hasFilters
|
||||
? "No contacts match those filters."
|
||||
: "No contacts found."}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{npcs.length === 0
|
||||
? "NPC records will appear here as the theater develops."
|
||||
: "Try a different search or clear one of the filters."}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-3 md:grid-cols-2 lg:grid-cols-3">
|
||||
{filteredNpcs.map((npc) => (
|
||||
<NpcCard key={npc.id} npc={npc} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
199
src/components/frontend/personnel/NpcProfile.tsx
Normal file
199
src/components/frontend/personnel/NpcProfile.tsx
Normal file
|
|
@ -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 (
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
{npc.isInGame === true && (
|
||||
<Badge variant="secondary" className="border-emerald-500/30 text-emerald-500">
|
||||
In Game
|
||||
</Badge>
|
||||
)}
|
||||
{npc.isGenerated === true && (
|
||||
<Badge variant="outline" className="border-amber-500/40 text-amber-500">
|
||||
Generated
|
||||
</Badge>
|
||||
)}
|
||||
{npc.vendor?.enabled === true && (
|
||||
<Badge variant="outline" className="border-violet-500/40 text-violet-500">
|
||||
Vendor
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="flex flex-col gap-6 p-5">
|
||||
<Link
|
||||
href="/personnel/contacts"
|
||||
className="inline-flex w-fit items-center gap-2 text-sm text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<ArrowLeftIcon className="size-4" />
|
||||
Back to Contacts
|
||||
</Link>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<h1 className="text-lg font-semibold">{npc.name}</h1>
|
||||
<StatusBadges npc={npc} />
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{npc.occupation ?? "Occupation not recorded"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-2">
|
||||
<div className="flex flex-col gap-6">
|
||||
<Card>
|
||||
<CardHeader className="gap-2">
|
||||
<CardTitle>Bio</CardTitle>
|
||||
<CardDescription>Background information on this contact.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="whitespace-pre-wrap text-sm text-muted-foreground">
|
||||
{npc.bio?.trim() || "No bio on file."}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="gap-2">
|
||||
<CardTitle>Assignment</CardTitle>
|
||||
<CardDescription>Known affiliations and current station.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="flex items-start gap-3">
|
||||
<MapPinIcon className="mt-0.5 size-4 shrink-0 text-muted-foreground" />
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-xs uppercase tracking-wider text-muted-foreground">
|
||||
Stationed at
|
||||
</span>
|
||||
<span className="text-sm">{location?.name ?? "Location unknown"}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start gap-3">
|
||||
<ShieldIcon className="mt-0.5 size-4 shrink-0 text-muted-foreground" />
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-xs uppercase tracking-wider text-muted-foreground">
|
||||
Faction
|
||||
</span>
|
||||
<span className="text-sm">{faction?.name ?? "Unaffiliated"}</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-6">
|
||||
{vendorEnabled && (
|
||||
<Card>
|
||||
<CardHeader className="gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<StoreIcon className="size-4 text-muted-foreground" />
|
||||
<CardTitle>Vendor information</CardTitle>
|
||||
</div>
|
||||
<CardDescription>Market preferences for this contact.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-4">
|
||||
<div className="flex items-center justify-between gap-3 text-sm">
|
||||
<span className="text-muted-foreground">Price modifier</span>
|
||||
<span className="font-mono tabular-nums">
|
||||
×{(npc.vendor?.priceModifier ?? 1).toFixed(2)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<span className="text-xs uppercase tracking-wider text-muted-foreground">
|
||||
Goods they sell
|
||||
</span>
|
||||
{sellableAssets.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{sellableAssets.map((asset) => (
|
||||
<Badge key={asset.id} variant="secondary" className="font-normal">
|
||||
{asset.name}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">No goods assigned.</p>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex-row items-start justify-between gap-3">
|
||||
<div className="flex flex-col gap-2">
|
||||
<CardTitle>Active Market Stock</CardTitle>
|
||||
<CardDescription>{listings.length} active listing(s).</CardDescription>
|
||||
</div>
|
||||
<Button asChild variant="outline" size="sm">
|
||||
<Link href="/logistics/market">View market</Link>
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{listings.length === 0 ? (
|
||||
<p className="text-sm italic text-muted-foreground">No active stock.</p>
|
||||
) : (
|
||||
<div className="flex flex-col">
|
||||
{listings.map((listing, index) => (
|
||||
<Fragment key={listing.id}>
|
||||
{index > 0 && <Separator />}
|
||||
<div className="flex items-center justify-between gap-4 py-3 first:pt-0 last:pb-0">
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm font-semibold">
|
||||
{assetName(listing.asset)}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{listing.quantity.toLocaleString()} available
|
||||
</p>
|
||||
</div>
|
||||
<span className="shrink-0 text-sm font-semibold tabular-nums">
|
||||
{formatAmount(listing.price, listing.currency)}
|
||||
</span>
|
||||
</div>
|
||||
</Fragment>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in a new issue