1
0
Fork 0
polaris-task-force/src/app/(frontend)/logistics/structures/[id]/page.tsx
Z8MB1E 9e44b69714 feat(base): add structure staffing and upgrade management UI
Adds hire/terminate staff actions and an upgradeStructure server action plus baseManagement components (employment card, hire dialog, staff roster, upgrade card) wired into the structure detail page.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-09-06 01:24:14 -04:00

454 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import config from "@payload-config";
import { getPayload } from "payload";
import { notFound } from "next/navigation";
import { headers as nextHeaders } from "next/headers";
import Link from "next/link";
import { Faction, GameStructure, Resource, Structure } from "@/payload-types";
import { convertLexicalToPlaintext } from "@payloadcms/richtext-lexical/plaintext";
import { CapacityBar } from "@/components/frontend/storage/CapacityBar";
import { StorageRules } from "@/components/frontend/storage/StorageRules";
import { StructureStorageView } from "@/components/frontend/storage/StructureStorageView";
import { StaffRoster } from "@/components/frontend/baseManagement/StaffRoster";
import { UpgradeCard } from "@/components/frontend/baseManagement/UpgradeCard";
import {
calculateStoredMass,
calculateUtilization,
getCategoryLabel,
getFunctionTypeLabel,
getGridSlots,
} from "@/lib/logistics";
import { pluralize } from "@/lib/utils";
import type { LaborClassificationOption, StructureStaffingOption } from "@/lib/base";
import { currencyConfigFromGameRules } from "@/lib/banking";
import { hasLogisticsQualification } from "@/utils/access-control/hasLogisticsQualification";
import {
ArrowLeftIcon,
BoxIcon,
Building2Icon,
CoinsIcon,
MapPinIcon,
WrenchIcon,
} from "lucide-react";
import {
Item,
ItemContent,
ItemDescription,
ItemHeader,
ItemMedia,
ItemTitle,
} from "@/components/ui/item";
import { ManageStorageButton } from "@/components/frontend/storage/ManageStorageButton";
interface StructureDetailPageProps {
params: Promise<{ id: string }>;
}
export default async function StructureDetailPage({ params }: StructureDetailPageProps) {
const { id } = await params;
const payloadConfig = await config;
const payload = await getPayload({ config: payloadConfig });
const headers = await nextHeaders();
const { user } = await payload.auth({ headers, canSetHeaders: false });
const structureRes = await payload
.findByID({
collection: "game-structures",
id: Number(id),
depth: 2,
})
.catch(() => null);
if (!structureRes) {
notFound();
}
const structure = structureRes as unknown as GameStructure & {
type: Structure;
faction?: Faction | null;
};
const structureType = structure.type;
const storedResources = structure.storedResources ?? [];
const currentMass = calculateStoredMass(storedResources);
const utilization = calculateUtilization(structureType, storedResources);
const gridSlots = getGridSlots(structureType);
const functionLabel = getFunctionTypeLabel(structureType.functionType);
const description = structureType.description
? convertLexicalToPlaintext({ data: structureType.description })
: null;
const image =
typeof structureType.image === "object" && structureType.image !== null
? structureType.image
: null;
const imageUrl = image?.url ?? null;
const upgradeTarget = structureType.upgradesInto;
const upgradeTargetId =
typeof upgradeTarget === "object" && upgradeTarget !== null ? upgradeTarget.id : upgradeTarget;
let upgrade: {
targetName: string;
materials: { name: string; needed: number; have: number }[];
} | null = null;
if (typeof upgradeTargetId === "number") {
const targetRes = await payload
.findByID({ collection: "structures", id: upgradeTargetId, depth: 1 })
.catch(() => null);
if (targetRes) {
const target = targetRes as unknown as Structure;
const resourceIds = target.materials
.map((material) =>
typeof material.resource === "object" && material.resource !== null
? material.resource.id
: material.resource,
)
.filter((resourceId): resourceId is number => typeof resourceId === "number");
const resourceRes =
resourceIds.length > 0
? await payload.find({
collection: "resources",
where: { id: { in: resourceIds } },
limit: resourceIds.length,
depth: 0,
})
: null;
const resourceNames = new Map(
(resourceRes?.docs ?? []).map((resource) => [resource.id, resource.name]),
);
const inventory = [...(structure.storedResources ?? []), ...(structure.voidStorage ?? [])];
upgrade = {
targetName: target.name,
materials: target.materials.map((material) => {
const resourceId =
typeof material.resource === "object" && material.resource !== null
? material.resource.id
: material.resource;
const resourceName =
typeof material.resource === "object" && material.resource !== null
? material.resource.name
: (resourceNames.get(resourceId) ?? `Resource #${resourceId}`);
const have = inventory.reduce((total, entry) => {
const entryResourceId =
typeof entry.resource === "object" && entry.resource !== null
? entry.resource.id
: entry.resource;
return entryResourceId === resourceId ? total + entry.amount : total;
}, 0);
return {
name: resourceName,
needed: Math.ceil((material.amount ?? 0) * (structureType.upgradeCostMultiplier ?? 1)),
have,
};
}),
};
}
}
const staffRes = await payload.find({
collection: "npc-staffing",
where: {
and: [{ structure: { equals: structure.id } }, { status: { equals: "active" } }],
},
limit: 50,
depth: 1,
});
const staff = staffRes.docs.map((entry) => {
const npc = entry.npc;
const npcName =
typeof npc === "object" && npc !== null
? npc.name
: typeof npc === "number"
? `NPC #${npc}`
: null;
const laborClassificationId =
typeof entry.laborClassification === "object" && entry.laborClassification !== null
? entry.laborClassification.id
: typeof entry.laborClassification === "number"
? entry.laborClassification
: null;
return {
id: entry.id,
npcName,
positionType: entry.positionType,
positionTitle: entry.positionTitle,
salary: entry.salary ?? 0,
headcount: entry.headcount ?? 1,
laborClassificationId,
hiredAt: new Date(entry.hiredAt ?? entry.createdAt).toISOString(),
};
});
const canManage = await hasLogisticsQualification(payload, user);
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 structureStaffing: StructureStaffingOption = {
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: staff
.filter((member) => member.laborClassificationId === classificationId)
.reduce((total, member) => total + member.headcount, 0),
};
}),
staffPositions: (structure.staffPositions ?? []).map((position) => ({
positionType: position.positionType,
max: position.max ?? null,
enforcedSalary: position.enforcedSalary ?? null,
currentHeadcount: staff.filter(
(member) => member.npcName !== null && member.positionType === position.positionType,
).length,
})),
};
const npcsRes = await payload.find({
collection: "game-npcs",
limit: 200,
depth: 0,
});
const npcs = npcsRes.docs.map((npc) => ({ id: npc.id, name: npc.name }));
const totalValue = storedResources.reduce((sum, entry) => {
const resource = entry.resource as Resource;
if (!resource || typeof resource === "number") return sum;
return sum + (resource.baseValue ?? 0) * entry.amount;
}, 0);
return (
<div className="p-5 flex flex-col gap-6 max-w-7xl">
<header
className={`relative flex flex-col gap-3 overflow-hidden rounded-lg${
imageUrl ? " min-h-[280px] justify-between border border-border p-5" : ""
}`}
>
{imageUrl ? (
<>
<img
src={imageUrl}
alt={structure.name}
fetchPriority="high"
className="absolute inset-0 size-full object-cover"
/>
<div className="absolute inset-0 bg-gradient-to-b from-black/50 via-black/40 to-background" />
<div className="absolute inset-0 bg-gradient-to-r from-black/30 via-transparent to-black/40" />
</>
) : null}
<Link
href="/logistics"
className="relative z-10 text-sm text-muted-foreground hover:text-primary transition-colors flex items-center gap-1 w-fit"
>
<ArrowLeftIcon className="size-3" />
Back to Structures
</Link>
<div className="relative z-10 flex items-start justify-between gap-4">
<div className="flex flex-col gap-1">
<div className="flex items-center gap-2">
<Building2Icon
className={`size-5 ${imageUrl ? "text-white/80" : "text-muted-foreground"}`}
/>
<h1
className={`text-lg font-semibold ${imageUrl ? "text-white drop-shadow-md" : ""}`}
>
{structure.name}
</h1>
</div>
<div
className={`flex items-center gap-2 text-sm ${
imageUrl ? "text-white/70" : "text-muted-foreground"
}`}
>
<span>{structureType.name}</span>
<span>·</span>
<span>{getCategoryLabel(structureType.category)}</span>
{functionLabel && (
<>
<span>·</span>
<span>{functionLabel}</span>
</>
)}
</div>
</div>
<div className="flex items-center gap-2">
{structure.faction && typeof structure.faction === "object" && (
<span className="text-xs bg-muted px-2.5 py-1 rounded-md">
{structure.faction.name}
</span>
)}
{user && <ManageStorageButton structureId={structure.id} />}
</div>
</div>
</header>
{description && <p className="text-sm text-muted-foreground">{description}</p>}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<Item variant="outline">
<ItemHeader>
<ItemTitle className="flex items-center gap-2">
<BoxIcon className="size-4 text-muted-foreground" />
Storage Capacity
</ItemTitle>
</ItemHeader>
<ItemContent>
<CapacityBar
utilization={utilization}
currentMass={currentMass}
maxMass={structureType.maxCapacityMass ?? null}
/>
</ItemContent>
</Item>
<Item variant="outline">
<ItemHeader>
<ItemTitle className="flex items-center gap-2">
<CoinsIcon className="size-4 text-muted-foreground" />
Total Value
</ItemTitle>
</ItemHeader>
<ItemContent>
<div className="flex items-center gap-2">
<span className="text-2xl font-bold">{totalValue.toLocaleString()}</span>
<span className="text-sm text-muted-foreground">base value</span>
</div>
</ItemContent>
</Item>
</div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
{structure.coordinates && (
<Item variant="muted" size="sm">
<ItemMedia variant="icon">
<MapPinIcon />
</ItemMedia>
<ItemContent>
<ItemTitle>Coordinates</ItemTitle>
<ItemDescription>
{structure.coordinates[0].toFixed(1)}, {structure.coordinates[1].toFixed(1)}
</ItemDescription>
</ItemContent>
</Item>
)}
{gridSlots !== null && (
<Item variant="muted" size="sm">
<ItemMedia variant="icon">
<BoxIcon />
</ItemMedia>
<ItemContent>
<ItemTitle>Grid</ItemTitle>
<ItemDescription>
{structureType.xSize}×{structureType.ySize} ({gridSlots} slots)
</ItemDescription>
</ItemContent>
</Item>
)}
{structureType.maxCapacityMass && (
<Item variant="muted" size="sm">
<ItemMedia variant="icon">
<BoxIcon />
</ItemMedia>
<ItemContent>
<ItemTitle>Max Capacity</ItemTitle>
<ItemDescription>
{structureType.maxCapacityMass.toLocaleString()} kg (mass)
</ItemDescription>
</ItemContent>
</Item>
)}
{structureType.maxHealth && (
<Item variant="muted" size="sm">
<ItemMedia variant="icon">
<WrenchIcon />
</ItemMedia>
<ItemContent>
<ItemTitle>Max Health</ItemTitle>
<ItemDescription>{structureType.maxHealth.toLocaleString()}</ItemDescription>
</ItemContent>
</Item>
)}
</div>
<UpgradeCard gameStructureId={structure.id} upgrade={upgrade} />
<StaffRoster
structure={structureStaffing}
staff={staff}
classifications={classifications}
npcs={npcs}
canManage={canManage}
hiringEnabled={Boolean(rules.staffHiringEnabled)}
hiringDisabledMessage={rules.staffHiringDisabledMessage ?? undefined}
currencyConfig={currencyConfig}
/>
<StructureStorageView structure={structure} />
<StorageRules
allowedStorage={structureType.allowedStorage}
prohibitedStorage={structureType.prohibitedStorage}
restrictToAllowed={structureType.restrictToAllowed}
/>
{structureType.upkeep && structureType.upkeep.length > 0 && (
<Item variant="outline">
<ItemHeader>
<ItemTitle className="flex items-center gap-2">
<WrenchIcon className="size-4 text-muted-foreground" />
Upkeep Costs
</ItemTitle>
<ItemDescription>Per game tick (every hour)</ItemDescription>
</ItemHeader>
<ItemContent>
<div className="flex flex-wrap gap-3">
{structureType.upkeep.map((upkeep, index) => {
const resource = upkeep.resource as Resource;
if (!resource || typeof resource === "number") return null;
return (
<span
key={upkeep.id ?? index}
className="text-sm bg-muted px-2.5 py-1 rounded-md"
>
{resource.name}: {upkeep.amount?.toLocaleString()}{" "}
{pluralize(upkeep.amount ?? 0, resource.unitOfMeasure)}/tick
</span>
);
})}
</div>
</ItemContent>
</Item>
)}
</div>
);
}