1
0
Fork 0
polaris-task-force/src/components/frontend/storage/StructureStorageView.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

306 lines
10 KiB
TypeScript

"use client";
import { useCallback, useState } from "react";
import { useRouter } from "next/navigation";
import { GameStructure, Resource, Structure } from "@/payload-types";
import { InventoryGrid } from "./InventoryGrid";
import { GridToolbar } from "./GridToolbar";
import { ResourceList } from "./ResourceList";
import { type GridItem } from "@/lib/logistics";
import { pluralize } from "@/lib/utils";
import {
Item,
ItemContent,
ItemDescription,
ItemHeader,
ItemMedia,
ItemSeparator,
ItemTitle,
} from "@/components/ui/item";
import { AlertTriangleIcon, BrainIcon, DropletIcon, PackageOpenIcon, ZapIcon } from "lucide-react";
import { EventLedger } from "./EventLedger";
import {
autoArrangeGrid,
mergeGridStacks,
moveGridItem,
removeGridItem,
removeResource,
retrieveFromVoid,
rotateGridItem,
splitGridStack,
} from "@/app/(frontend)/logistics/structures/actions";
function getResourceIcon(type: Resource["type"]) {
switch (type) {
case "fluid":
return <DropletIcon className="size-4" />;
case "energy":
return <ZapIcon className="size-4" />;
case "abstract":
return <BrainIcon className="size-4" />;
default:
return <PackageOpenIcon className="size-4" />;
}
}
interface StructureStorageViewProps {
structure: GameStructure & { type: Structure };
/** Rearranging/rotating/splitting conserves quantities — logistics personnel or superuser. */
canArrange?: boolean;
/** Removing grid items / withdrawing destroys stored stock — superuser only. */
canMutate?: boolean;
}
export function StructureStorageView({
structure,
canArrange = false,
canMutate = false,
}: StructureStorageViewProps) {
const router = useRouter();
const [viewMode, setViewMode] = useState<"grid" | "list">("grid");
const [selectedItem, setSelectedItem] = useState<GridItem | null>(null);
const [eventLogKey, setEventLogKey] = useState(0);
const storedRaw = structure.storedResources ?? [];
const gridWidth = structure.type.xSize ?? 0;
const gridHeight = structure.type.ySize ?? 0;
const hasGrid = gridWidth > 0 && gridHeight > 0;
const storedResources: GridItem[] = storedRaw
.filter((entry) => {
const res = entry.resource as Resource;
return res && typeof res === "object" && "id" in res;
})
.map((entry) => ({
id: entry.id ?? `${(entry.resource as Resource).id}-${entry.gridX}-${entry.gridY}`,
resourceId: (entry.resource as Resource).id,
amount: entry.amount,
gridX: entry.gridX ?? 0,
gridY: entry.gridY ?? 0,
rotated: entry.rotated ?? false,
resource: entry.resource as Resource,
}));
const refresh = useCallback(() => {
router.refresh();
setEventLogKey((k) => k + 1);
}, [router]);
const handleMoveItem = useCallback(
async (entryId: string, gridX: number, gridY: number, rotated?: boolean) => {
if (!canArrange) return;
const result = await moveGridItem(structure.id, entryId, gridX, gridY, rotated);
if (result.success) refresh();
},
[structure.id, refresh, canArrange],
);
const handleMergeStacks = useCallback(
async (sourceId: string, targetId: string) => {
if (!canArrange) return;
const result = await mergeGridStacks(structure.id, sourceId, targetId);
if (result.success) refresh();
},
[structure.id, refresh, canArrange],
);
const handleRotateItem = useCallback(
async (entryId: string) => {
if (!canArrange) return;
const result = await rotateGridItem(structure.id, entryId);
if (result.success) refresh();
},
[structure.id, refresh, canArrange],
);
const handleRemoveItem = useCallback(
async (entryId: string) => {
if (!canMutate) return;
const result = await removeGridItem(structure.id, entryId);
if (result.success) {
setSelectedItem(null);
refresh();
}
},
[structure.id, refresh, canMutate],
);
const handleSplitStack = useCallback(
async (entryId: string, amount: number) => {
if (!canArrange) return;
const result = await splitGridStack(structure.id, entryId, amount);
if (result.success) refresh();
},
[structure.id, refresh, canArrange],
);
const handleAutoArrange = useCallback(async () => {
if (!canArrange) return;
const result = await autoArrangeGrid(structure.id);
if (result.success) refresh();
}, [structure.id, refresh, canArrange]);
const handleWithdrawFromVoid = useCallback(
async (resourceId: number, amount: number) => {
if (!canMutate) return;
const result = await removeResource(structure.id, resourceId, amount);
if (result.success) refresh();
},
[structure.id, refresh, canMutate],
);
const handleRetrieveFromVoid = useCallback(async () => {
if (!canArrange) return;
const result = await retrieveFromVoid(structure.id);
if (result.success) refresh();
}, [structure.id, refresh, canArrange]);
if (!hasGrid) {
return (
<ResourceList
storedResources={structure.storedResources ?? []}
maxCapacityMass={structure.type.maxCapacityMass ?? null}
/>
);
}
const voidStorageRaw = (structure as any).voidStorage ?? [];
const voidStorageItems = voidStorageRaw
.filter((entry: any) => {
const res = entry.resource as Resource;
return res && typeof res === "object" && "id" in res;
})
.map((entry: any) => ({
id: entry.id ?? `${(entry.resource as Resource).id}-void`,
resourceId: (entry.resource as Resource).id,
amount: entry.amount,
gridX: entry.gridX ?? 0,
gridY: entry.gridY ?? 0,
rotated: entry.rotated ?? false,
resource: entry.resource as Resource,
}));
const hasVoidStorage = voidStorageItems.length > 0;
return (
<div className="flex flex-col gap-4">
<GridToolbar
gridWidth={gridWidth}
gridHeight={gridHeight}
storedResources={storedResources}
selectedItem={selectedItem}
viewMode={viewMode}
onViewModeChange={setViewMode}
onAutoArrange={handleAutoArrange}
onRotate={handleRotateItem}
onRemove={handleRemoveItem}
onSplit={handleSplitStack}
canArrange={canArrange}
canMutate={canMutate}
/>
{viewMode === "grid" ? (
<InventoryGrid
structure={structure}
storedResources={storedResources}
onMoveItem={handleMoveItem}
onMergeStacks={handleMergeStacks}
onRotateItem={handleRotateItem}
onRemoveItem={handleRemoveItem}
onSelectItem={setSelectedItem}
selectedItem={selectedItem}
/>
) : (
<ResourceList
storedResources={structure.storedResources ?? []}
maxCapacityMass={structure.type.maxCapacityMass ?? null}
/>
)}
<EventLedger structureId={structure.id} refreshKey={eventLogKey} />
{hasVoidStorage && (
<Item variant="outline">
<ItemHeader>
<ItemTitle className="flex items-center gap-2 text-amber-400">
<AlertTriangleIcon className="size-4" />
Void Storage
</ItemTitle>
<ItemDescription className="flex items-center gap-2">
<span>
{voidStorageItems.length} item{voidStorageItems.length !== 1 ? "s" : ""}: these no
longer fit in the grid.
</span>
{canArrange && (
<button
type="button"
onClick={handleRetrieveFromVoid}
className="text-xs bg-amber-500/10 text-amber-400 hover:bg-amber-500/20 px-2 py-0.5 rounded transition-colors"
>
Retrieve All to Grid
</button>
)}
</ItemDescription>
</ItemHeader>
<ItemContent>
<div className="flex flex-col">
{voidStorageItems.map((entry: any, index: number) => {
const resource = entry.resource as Resource;
if (!resource || typeof resource === "number") return null;
const mass = (resource.massPerUnit ?? 0) * entry.amount;
return (
<div key={entry.id ?? index}>
<div className="flex items-center gap-3 py-2.5">
<ItemMedia variant="icon">
{resource.icon ? (
<img
src={typeof resource.icon === "object" ? (resource.icon.url ?? "") : ""}
alt={resource.name}
className="size-4 object-contain"
/>
) : (
getResourceIcon(resource.type)
)}
</ItemMedia>
<ItemContent className="flex-1 min-w-0">
<ItemTitle>
<span
className="w-2 h-2 rounded-full shrink-0"
style={{ backgroundColor: resource.color ?? "#888" }}
/>
<span>{resource.name}</span>
</ItemTitle>
</ItemContent>
<div className="text-right shrink-0">
<div className="text-sm font-medium">
{entry.amount.toLocaleString()}{" "}
{pluralize(entry.amount, resource.unitOfMeasure)}
</div>
<div className="text-xs text-muted-foreground">
{mass > 0 && `${mass.toLocaleString()} kg`}
</div>
</div>
{canMutate && (
<button
type="button"
onClick={() => handleWithdrawFromVoid(entry.resourceId, entry.amount)}
className="text-xs text-muted-foreground hover:text-foreground px-2 py-1 rounded transition-colors shrink-0"
title="Withdraw from structure"
>
Withdraw
</button>
)}
</div>
{index < voidStorageItems.length - 1 && <ItemSeparator />}
</div>
);
})}
</div>
</ItemContent>
</Item>
)}
</div>
);
}