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.
306 lines
10 KiB
TypeScript
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>
|
|
);
|
|
}
|