1
0
Fork 0
polaris-task-force/src/components/frontend/storage/ManageStorageDialog.tsx
Z8MB1E 12d276d44d feat: implement storage system with capacity tracking and resource management
- Rebuild /logistics/storage overview with capacity bars, filters, and structure cards
- Add /logistics/structures/[id] detail page with resource list, storage rules, upkeep
- Add server actions for deposit, withdraw, and transfer of resources
- Add ManageStorageDialog with deposit/withdraw/transfer tabs
- Add capacity bar component with color-coded utilization
- Add StorageGrid, StructureCard, StorageFilters, ResourceList, StorageRules components
- Add logistics utility functions for mass calculations
- Install shadcn dialog, select, tabs, label components
2026-07-27 19:19:48 -04:00

372 lines
13 KiB
TypeScript

"use client";
import { useState, useEffect, useTransition, useCallback } from "react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
addResource,
removeResource,
transferResource,
} from "@/app/(frontend)/logistics/structures/actions";
import { GameStructure, Structure, Resource } from "@/payload-types";
import {
ArrowDownToLineIcon,
ArrowUpFromLineIcon,
ArrowRightLeftIcon,
Loader2Icon,
} from "lucide-react";
interface ManageStorageDialogProps {
structureId: number;
open: boolean;
onOpenChange: (open: boolean) => void;
}
export function ManageStorageDialog({
structureId,
open,
onOpenChange,
}: ManageStorageDialogProps) {
const [structure, setStructure] = useState<
(GameStructure & { type: Structure }) | null
>(null);
const [allStructures, setAllStructures] = useState<
(GameStructure & { type: Structure })[]
>([]);
const [allResources, setAllResources] = useState<Resource[]>([]);
const [loading, setLoading] = useState(false);
const [isPending, startTransition] = useTransition();
const [message, setMessage] = useState<{ type: "success" | "error"; text: string } | null>(null);
// Form state
const [depositResourceId, setDepositResourceId] = useState<string>("");
const [depositAmount, setDepositAmount] = useState<string>("1");
const [withdrawResourceId, setWithdrawResourceId] = useState<string>("");
const [withdrawAmount, setWithdrawAmount] = useState<string>("1");
const [transferToId, setTransferToId] = useState<string>("");
const [transferResourceId, setTransferResourceId] = useState<string>("");
const [transferAmount, setTransferAmount] = useState<string>("1");
const fetchData = useCallback(async () => {
setLoading(true);
try {
const [structRes, allRes, resourcesRes] = await Promise.all([
fetch(`/api/game-structures/${structureId}?depth=2`),
fetch(`/api/game-structures?depth=2&limit=500`),
fetch(`/api/resources?depth=0&limit=500`),
]);
const structData = await structRes.json();
const allData = await allRes.json();
const resourcesData = await resourcesRes.json();
setStructure(structData);
setAllStructures(
allData.docs.filter(
(s: GameStructure & { type: Structure }) => s.id !== structureId,
),
);
setAllResources(resourcesData.docs ?? []);
} catch {
setMessage({ type: "error", text: "Failed to load structure data." });
} finally {
setLoading(false);
}
}, [structureId]);
useEffect(() => {
if (open) {
fetchData();
setMessage(null);
setDepositResourceId("");
setDepositAmount("1");
setWithdrawResourceId("");
setWithdrawAmount("1");
setTransferToId("");
setTransferResourceId("");
setTransferAmount("1");
}
}, [open, fetchData]);
const storedResources = structure?.storedResources ?? [];
const structureType = structure?.type;
const isResourceAllowed = (resourceId: number): boolean => {
if (!structureType) return true;
const prohibited = structureType.prohibitedStorage?.some((rule) => {
if (!rule.item) return false;
const item = rule.item;
if (typeof item === "object" && "id" in item) {
return item.relationTo === "resources" && item.value === resourceId;
}
return false;
});
return !prohibited;
};
const handleDeposit = () => {
if (!depositResourceId) return;
const resId = Number(depositResourceId);
const amt = Number(depositAmount);
if (isNaN(amt) || amt <= 0) {
setMessage({ type: "error", text: "Amount must be a positive number." });
return;
}
startTransition(async () => {
const result = await addResource(structureId, resId, amt);
if (result.success) {
setMessage({ type: "success", text: "Resource deposited successfully." });
setDepositAmount("1");
fetchData();
} else {
setMessage({ type: "error", text: result.error ?? "Unknown error." });
}
});
};
const handleWithdraw = () => {
if (!withdrawResourceId) return;
const resId = Number(withdrawResourceId);
const amt = Number(withdrawAmount);
if (isNaN(amt) || amt <= 0) {
setMessage({ type: "error", text: "Amount must be a positive number." });
return;
}
startTransition(async () => {
const result = await removeResource(structureId, resId, amt);
if (result.success) {
setMessage({ type: "success", text: "Resource withdrawn successfully." });
setWithdrawAmount("1");
fetchData();
} else {
setMessage({ type: "error", text: result.error ?? "Unknown error." });
}
});
};
const handleTransfer = () => {
if (!transferToId || !transferResourceId) return;
const destId = Number(transferToId);
const resId = Number(transferResourceId);
const amt = Number(transferAmount);
if (isNaN(amt) || amt <= 0) {
setMessage({ type: "error", text: "Amount must be a positive number." });
return;
}
startTransition(async () => {
const result = await transferResource(structureId, destId, resId, amt);
if (result.success) {
setMessage({ type: "success", text: "Transfer completed successfully." });
setTransferAmount("1");
fetchData();
} else {
setMessage({ type: "error", text: result.error ?? "Unknown error." });
}
});
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Manage Storage</DialogTitle>
<DialogDescription>
{structure?.name ?? "Loading..."}
</DialogDescription>
</DialogHeader>
{loading ? (
<div className="flex items-center justify-center py-8">
<Loader2Icon className="size-6 animate-spin text-muted-foreground" />
</div>
) : (
<Tabs defaultValue="deposit" className="w-full">
<TabsList className="grid w-full grid-cols-3">
<TabsTrigger value="deposit" className="flex items-center gap-1.5">
<ArrowDownToLineIcon className="size-3.5" />
Deposit
</TabsTrigger>
<TabsTrigger value="withdraw" className="flex items-center gap-1.5">
<ArrowUpFromLineIcon className="size-3.5" />
Withdraw
</TabsTrigger>
<TabsTrigger value="transfer" className="flex items-center gap-1.5">
<ArrowRightLeftIcon className="size-3.5" />
Transfer
</TabsTrigger>
</TabsList>
<TabsContent value="deposit" className="flex flex-col gap-4 pt-4">
<div className="flex flex-col gap-2">
<Label>Resource</Label>
<Select value={depositResourceId} onValueChange={setDepositResourceId}>
<SelectTrigger>
<SelectValue placeholder="Select a resource..." />
</SelectTrigger>
<SelectContent>
{allResources
.filter((r) => isResourceAllowed(r.id))
.map((resource) => (
<SelectItem key={resource.id} value={String(resource.id)}>
{resource.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-2">
<Label>Amount</Label>
<Input
type="number"
min="1"
value={depositAmount}
onChange={(e) => setDepositAmount(e.target.value)}
/>
</div>
<Button
onClick={handleDeposit}
disabled={isPending || !depositResourceId}
className="w-full"
>
{isPending ? (
<Loader2Icon className="size-4 animate-spin mr-2" />
) : (
<ArrowDownToLineIcon className="size-4 mr-2" />
)}
Deposit
</Button>
</TabsContent>
<TabsContent value="withdraw" className="flex flex-col gap-4 pt-4">
<div className="flex flex-col gap-2">
<Label>Resource</Label>
<Select value={withdrawResourceId} onValueChange={setWithdrawResourceId}>
<SelectTrigger>
<SelectValue placeholder="Select a resource..." />
</SelectTrigger>
<SelectContent>
{storedResources.map((entry) => {
const resource = entry.resource as Resource;
if (!resource || typeof resource === "number") return null;
return (
<SelectItem key={entry.id ?? resource.id} value={String(resource.id)}>
{resource.name} ({entry.amount.toLocaleString()} {resource.unitOfMeasure})
</SelectItem>
);
})}
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-2">
<Label>Amount</Label>
<Input
type="number"
min="1"
value={withdrawAmount}
onChange={(e) => setWithdrawAmount(e.target.value)}
/>
</div>
<Button
onClick={handleWithdraw}
disabled={isPending || !withdrawResourceId}
className="w-full"
variant="destructive"
>
{isPending ? (
<Loader2Icon className="size-4 animate-spin mr-2" />
) : (
<ArrowUpFromLineIcon className="size-4 mr-2" />
)}
Withdraw
</Button>
</TabsContent>
<TabsContent value="transfer" className="flex flex-col gap-4 pt-4">
<div className="flex flex-col gap-2">
<Label>Destination</Label>
<Select value={transferToId} onValueChange={setTransferToId}>
<SelectTrigger>
<SelectValue placeholder="Select a structure..." />
</SelectTrigger>
<SelectContent>
{allStructures.map((s) => (
<SelectItem key={s.id} value={String(s.id)}>
{s.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-2">
<Label>Resource</Label>
<Select value={transferResourceId} onValueChange={setTransferResourceId}>
<SelectTrigger>
<SelectValue placeholder="Select a resource..." />
</SelectTrigger>
<SelectContent>
{storedResources.map((entry) => {
const resource = entry.resource as Resource;
if (!resource || typeof resource === "number") return null;
return (
<SelectItem key={entry.id ?? resource.id} value={String(resource.id)}>
{resource.name} ({entry.amount.toLocaleString()} {resource.unitOfMeasure})
</SelectItem>
);
})}
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-2">
<Label>Amount</Label>
<Input
type="number"
min="1"
value={transferAmount}
onChange={(e) => setTransferAmount(e.target.value)}
/>
</div>
<Button
onClick={handleTransfer}
disabled={isPending || !transferToId || !transferResourceId}
className="w-full"
>
{isPending ? (
<Loader2Icon className="size-4 animate-spin mr-2" />
) : (
<ArrowRightLeftIcon className="size-4 mr-2" />
)}
Transfer
</Button>
</TabsContent>
</Tabs>
)}
{message && (
<div
className={`text-sm px-3 py-2 rounded-md ${
message.type === "success"
? "bg-green-500/10 text-green-400 border border-green-500/20"
: "bg-red-500/10 text-red-400 border border-red-500/20"
}`}
>
{message.text}
</div>
)}
</DialogContent>
</Dialog>
);
}