ManageStorageDialog now calls router.refresh() after deposit, withdraw, and transfer so the grid view reflects changes. Await fetchData() before refreshing. Resource labels now use pluralize() for correct unit display (e.g. "1 liter" vs "3 liters").
572 lines
21 KiB
TypeScript
572 lines
21 KiB
TypeScript
"use client";
|
|
|
|
import { useState, useEffect, useTransition, useCallback } from "react";
|
|
import { useRouter } from "next/navigation";
|
|
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";
|
|
import { Progress } from "@/components/ui/progress";
|
|
import { cn, pluralize } from "@/lib/utils";
|
|
|
|
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 router = useRouter();
|
|
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 maxMass = structureType?.maxCapacityMass ?? null;
|
|
|
|
const currentMass = storedResources.reduce((total, entry) => {
|
|
const resource = entry.resource as Resource;
|
|
if (!resource || typeof resource === "number") return total;
|
|
return total + (resource.massPerUnit ?? 0) * entry.amount;
|
|
}, 0);
|
|
|
|
const remainingMass = maxMass ? maxMass - currentMass : null;
|
|
|
|
const findResource = (id: string): Resource | undefined =>
|
|
allResources.find((r) => r.id === Number(id));
|
|
|
|
const findStoredResource = (id: string): { resource: Resource; amount: number } | undefined => {
|
|
const entry = storedResources.find((e) => {
|
|
const res = e.resource;
|
|
return typeof res === "object" && res !== null && "id" in res
|
|
? res.id === Number(id)
|
|
: res === Number(id);
|
|
});
|
|
if (!entry) return undefined;
|
|
const resource = entry.resource as Resource;
|
|
if (!resource || typeof resource === "number") return undefined;
|
|
return { resource, amount: entry.amount };
|
|
};
|
|
|
|
const getDepositMax = (): number | undefined => {
|
|
const resource = findResource(depositResourceId);
|
|
if (!resource) return undefined;
|
|
if (remainingMass === null) return undefined;
|
|
const massPerUnit = resource.massPerUnit ?? 0;
|
|
if (massPerUnit <= 0) return undefined;
|
|
return Math.floor(remainingMass / massPerUnit);
|
|
};
|
|
|
|
const getWithdrawMax = (): number | undefined => {
|
|
const stored = findStoredResource(withdrawResourceId);
|
|
return stored?.amount;
|
|
};
|
|
|
|
const getTransferMax = (): number | undefined => {
|
|
const stored = findStoredResource(transferResourceId);
|
|
return stored?.amount;
|
|
};
|
|
|
|
const clampAmount = (value: string, max: number | undefined): string => {
|
|
const num = Number(value);
|
|
if (isNaN(num) || num < 1) return "1";
|
|
if (max !== undefined && num > max) return String(max);
|
|
return value;
|
|
};
|
|
|
|
const getPreviewMass = (tab: "deposit" | "withdraw" | "transfer"): number | null => {
|
|
if (!maxMass) return null;
|
|
|
|
if (tab === "deposit") {
|
|
const resource = findResource(depositResourceId);
|
|
if (!resource) return null;
|
|
const amt = Number(depositAmount) || 0;
|
|
return currentMass + (resource.massPerUnit ?? 0) * amt;
|
|
}
|
|
|
|
if (tab === "withdraw") {
|
|
const stored = findStoredResource(withdrawResourceId);
|
|
if (!stored) return null;
|
|
const amt = Number(withdrawAmount) || 0;
|
|
return currentMass - (stored.resource.massPerUnit ?? 0) * amt;
|
|
}
|
|
|
|
if (tab === "transfer") {
|
|
const stored = findStoredResource(transferResourceId);
|
|
if (!stored) return null;
|
|
const amt = Number(transferAmount) || 0;
|
|
return currentMass - (stored.resource.massPerUnit ?? 0) * amt;
|
|
}
|
|
|
|
return null;
|
|
};
|
|
|
|
const renderCapacityPreview = (tab: "deposit" | "withdraw" | "transfer") => {
|
|
if (!maxMass) return null;
|
|
|
|
const previewMass = getPreviewMass(tab);
|
|
if (previewMass === null) return null;
|
|
|
|
const previewPercent = Math.min((previewMass / maxMass) * 100, 100);
|
|
const delta = previewMass - currentMass;
|
|
const isOverCapacity = previewMass > maxMass;
|
|
|
|
const getColor = (pct: number) => {
|
|
if (pct > 85) return "text-red-400";
|
|
if (pct > 60) return "text-yellow-400";
|
|
return "text-green-400";
|
|
};
|
|
|
|
const getBarColor = (pct: number) => {
|
|
if (pct > 85) return "[&>div]:bg-red-500";
|
|
if (pct > 60) return "[&>div]:bg-yellow-500";
|
|
return "[&>div]:bg-green-500";
|
|
};
|
|
|
|
return (
|
|
<div className={cn(
|
|
"flex flex-col gap-1.5 p-3 rounded-md border",
|
|
isOverCapacity ? "border-red-500/30 bg-red-500/5" : "border-border bg-muted/30",
|
|
)}>
|
|
<div className="flex items-center justify-between text-xs">
|
|
<span className="text-muted-foreground">
|
|
{tab === "deposit" ? "After deposit" : tab === "withdraw" ? "After withdrawal" : "After transfer"}
|
|
</span>
|
|
<span className={cn("font-medium", getColor(previewPercent))}>
|
|
{Math.round(previewPercent)}%
|
|
</span>
|
|
</div>
|
|
<Progress value={previewPercent} className={cn("h-1.5", getBarColor(previewPercent))} />
|
|
<div className="flex items-center justify-between text-xs text-muted-foreground">
|
|
<span>{currentMass.toLocaleString()} kg → {previewMass.toLocaleString()} kg</span>
|
|
<span className={cn(isOverCapacity && "text-red-400 font-medium")}>
|
|
{delta >= 0 ? "+" : ""}{delta.toLocaleString()} kg
|
|
{isOverCapacity && " (over capacity!)"}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
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");
|
|
await fetchData();
|
|
router.refresh();
|
|
} 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");
|
|
await fetchData();
|
|
router.refresh();
|
|
} 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");
|
|
await fetchData();
|
|
router.refresh();
|
|
} 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>
|
|
) : (
|
|
<>
|
|
{maxMass && (
|
|
<div className="flex flex-col gap-1.5 px-1">
|
|
<div className="flex items-center justify-between text-xs">
|
|
<span className="text-muted-foreground">Current capacity</span>
|
|
<span className="text-muted-foreground">
|
|
{Math.round(Math.min((currentMass / maxMass) * 100, 100))}%
|
|
</span>
|
|
</div>
|
|
<Progress
|
|
value={Math.min((currentMass / maxMass) * 100, 100)}
|
|
className="h-1.5"
|
|
/>
|
|
<div className="flex items-center justify-between text-xs text-muted-foreground">
|
|
<span>{currentMass.toLocaleString()} kg used</span>
|
|
<span>{maxMass.toLocaleString()} kg max</span>
|
|
</div>
|
|
</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={(val) => {
|
|
setDepositResourceId(val);
|
|
const res = findResource(val);
|
|
if (res) {
|
|
const massPerUnit = res.massPerUnit ?? 0;
|
|
const depositMax = remainingMass !== null && massPerUnit > 0
|
|
? Math.floor(remainingMass / massPerUnit)
|
|
: undefined;
|
|
setDepositAmount(clampAmount(depositAmount, depositMax));
|
|
}
|
|
}}
|
|
>
|
|
<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"
|
|
max={getDepositMax()}
|
|
value={depositAmount}
|
|
onChange={(e) => setDepositAmount(clampAmount(e.target.value, getDepositMax()))}
|
|
/>
|
|
{getDepositMax() !== undefined && (
|
|
<span className="text-xs text-muted-foreground">
|
|
Max: {getDepositMax()!.toLocaleString()} (limited by remaining capacity)
|
|
</span>
|
|
)}
|
|
</div>
|
|
{renderCapacityPreview("deposit")}
|
|
<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={(val) => {
|
|
setWithdrawResourceId(val);
|
|
const stored = findStoredResource(val);
|
|
setWithdrawAmount(clampAmount(withdrawAmount, stored?.amount));
|
|
}}
|
|
>
|
|
<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()} {pluralize(entry.amount, resource.unitOfMeasure)})
|
|
</SelectItem>
|
|
);
|
|
})}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
<div className="flex flex-col gap-2">
|
|
<Label>Amount</Label>
|
|
<Input
|
|
type="number"
|
|
min="1"
|
|
max={getWithdrawMax()}
|
|
value={withdrawAmount}
|
|
onChange={(e) => setWithdrawAmount(clampAmount(e.target.value, getWithdrawMax()))}
|
|
/>
|
|
{getWithdrawMax() !== undefined && (
|
|
<span className="text-xs text-muted-foreground">
|
|
Max: {getWithdrawMax()!.toLocaleString()} stored
|
|
</span>
|
|
)}
|
|
</div>
|
|
{renderCapacityPreview("withdraw")}
|
|
<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={(val) => {
|
|
setTransferResourceId(val);
|
|
const stored = findStoredResource(val);
|
|
setTransferAmount(clampAmount(transferAmount, stored?.amount));
|
|
}}
|
|
>
|
|
<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()} {pluralize(entry.amount, resource.unitOfMeasure)})
|
|
</SelectItem>
|
|
);
|
|
})}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
<div className="flex flex-col gap-2">
|
|
<Label>Amount</Label>
|
|
<Input
|
|
type="number"
|
|
min="1"
|
|
max={getTransferMax()}
|
|
value={transferAmount}
|
|
onChange={(e) => setTransferAmount(clampAmount(e.target.value, getTransferMax()))}
|
|
/>
|
|
{getTransferMax() !== undefined && (
|
|
<span className="text-xs text-muted-foreground">
|
|
Max: {getTransferMax()!.toLocaleString()} stored
|
|
</span>
|
|
)}
|
|
</div>
|
|
{renderCapacityPreview("transfer")}
|
|
<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>
|
|
);
|
|
}
|