- 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
372 lines
13 KiB
TypeScript
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>
|
|
);
|
|
}
|