1
0
Fork 0
polaris-task-force/src/app/(frontend)/logistics/structures/[id]/page.tsx
Z8MB1E dcb4893b10 feat(storage): split storage permissions and rework manage-storage UI
Deposits/withdrawals of stock (economy-mutating) are now superuser-only, while moving items around (transfer/place/collect) stays open to logistics-qualified users; seed roles updated to match. ManageStorageDialog reworked alongside its toolbar/button/view wrappers, and the structure page wires up the new permission split.
2026-09-07 05:07:53 -04:00

467 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 { isSuperuser } from "@/utils/access-control/hasPermission";
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 canManageStorage = await isSuperuser(payload, user);
const canTransferStorage = canManageStorage || canManage;
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}
canManage={canManageStorage}
canTransfer={canTransferStorage}
/>
)}
</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}
canMutate={canManageStorage}
canArrange={canTransferStorage}
/>
<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>
);
}