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>
454 lines
16 KiB
TypeScript
454 lines
16 KiB
TypeScript
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>
|
||
);
|
||
}
|