1
0
Fork 0
polaris-task-force/src/components/frontend/storage/ManageStorageDialog.tsx
Z8MB1E 947a5c1bbf feat(ui): searchable combobox across data-driven dropdowns
- new ui/combobox primitive (popover plus cmdk): search input, scrolling
  list, trigger-width popover, per-option disabled and hint lines
- converts every data-driven single-value select site-wide (blueprints,
  factions, accounts, assignments, NPCs, structures, locker items, seats,
  missions, ranks, resources, mod lists, event targets, sprints)
- long multi-select checkbox menus (event log types, kanban labels) gain
  search inputs following the resource picker pattern
- static short enum selects stay as plain selects
2026-09-17 16:28:17 -04:00

932 lines
37 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client";
import { useCallback, useEffect, useState, useTransition } from "react";
import { useRouter } from "next/navigation";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Combobox } from "@/components/ui/combobox";
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 { createShipment } from "@/app/(frontend)/logistics/shipments/actions";
import {
getResourceStorageCap,
isResourceProhibited,
isResourceWhitelisted,
} from "@/lib/storageRules";
import { GameStructure, GameVehicle, Resource, Structure, Vehicle } from "@/payload-types";
import {
ArrowDownToLineIcon,
ArrowRightLeftIcon,
ArrowUpFromLineIcon,
Loader2Icon,
TruckIcon,
} 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;
canManage: boolean;
canTransfer: boolean;
}
export function ManageStorageDialog({
structureId,
open,
onOpenChange,
canManage,
canTransfer,
}: ManageStorageDialogProps) {
const [structure, setStructure] = useState<(GameStructure & { type: Structure }) | null>(null);
const [allStructures, setAllStructures] = useState<(GameStructure & { type: Structure })[]>([]);
const [allResources, setAllResources] = useState<Resource[]>([]);
const [availableVehicles, setAvailableVehicles] = useState<(GameVehicle & { type: Vehicle })[]>(
[],
);
const [loading, setLoading] = useState(false);
const [isPending, startTransition] = useTransition();
const router = useRouter();
const [message, setMessage] = useState<{ type: "success" | "error"; text: string } | null>(null);
// Shipment flow state
const [shipmentMode, setShipmentMode] = useState(false);
const [shipmentResourceId, setShipmentResourceId] = useState<string>("");
const [shipmentAmount, setShipmentAmount] = useState<string>("1");
const [shipmentDestId, setShipmentDestId] = useState<string>("");
const [shipmentVehicleId, setShipmentVehicleId] = useState<string>("");
// 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, vehiclesRes] = 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`),
fetch(
`/api/game-vehicles?depth=2&limit=500&where[deployedAt][equals]=${structureId}&where[status][equals]=idle`,
),
]);
const anyUnauthorized = [structRes, allRes, resourcesRes, vehiclesRes].some(
(res) => res.status === 401 || res.status === 403,
);
if (anyUnauthorized) {
setMessage({
type: "error",
text: "You must be logged in to manage storage.",
});
return;
}
const structData = await structRes.json();
const allData = await allRes.json();
const resourcesData = await resourcesRes.json();
const vehiclesData = await vehiclesRes.json();
setStructure(structData);
setAllStructures(
allData.docs.filter((s: GameStructure & { type: Structure }) => s.id !== structureId),
);
setAllResources(resourcesData.docs ?? []);
setAvailableVehicles(vehiclesData.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");
setShipmentMode(false);
setShipmentResourceId("");
setShipmentAmount("1");
setShipmentDestId("");
setShipmentVehicleId("");
}
}, [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 getStoredAmountOf = (
s: GameStructure & { type: Structure },
resourceId: number,
): number => {
const grid = (s.storedResources ?? []).reduce((sum, e) => {
const res = e.resource;
const rid = typeof res === "object" && res !== null && "id" in res ? res.id : (res as number);
return sum + (rid === resourceId ? e.amount : 0);
}, 0);
const voidAmount = (s.voidStorage ?? []).reduce((sum, e) => {
const res = e.resource;
const rid = typeof res === "object" && res !== null && "id" in res ? res.id : (res as number);
return sum + (rid === resourceId ? e.amount : 0);
}, 0);
return grid + voidAmount;
};
const getMassOf = (s: GameStructure & { type: Structure }): number => {
return (s.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 getAllowedDepositCap = (resourceId: number): number | undefined => {
if (!structureType) return undefined;
const cap = getResourceStorageCap(structureType, resourceId);
if (cap == null) return undefined;
const stored = structure ? getStoredAmountOf(structure, resourceId) : 0;
return Math.max(0, cap - stored);
};
const getDepositLimitInfoFor = (
resourceId: string,
): { max: number; reason: string } | undefined => {
const resource = findResource(resourceId);
if (!resource) return undefined;
const reasons: { value: number; label: string }[] = [];
if (remainingMass !== null) {
const massPerUnit = resource.massPerUnit ?? 0;
if (massPerUnit > 0) {
reasons.push({
value: Math.floor(remainingMass / massPerUnit),
label: "mass capacity",
});
}
}
const allowance = getAllowedDepositCap(resource.id);
if (allowance !== undefined) {
reasons.push({ value: allowance, label: "storage allowance" });
}
if (reasons.length === 0) return undefined;
const binding = reasons.reduce((min, r) => (r.value < min.value ? r : min), reasons[0]);
return { max: binding.value, reason: binding.label };
};
const getDepositMax = (): number | undefined => getDepositLimitInfoFor(depositResourceId)?.max;
const getWithdrawMax = (): number | undefined => {
const stored = findStoredResource(withdrawResourceId);
return stored?.amount;
};
const getTransferMaxFor = (destId: string): number | undefined => {
const stored = findStoredResource(transferResourceId);
if (!stored) return undefined;
const resId = stored.resource.id;
const dest = allStructures.find((s) => s.id === Number(destId));
const destType = dest?.type;
if (dest && destType) {
if (isResourceProhibited(destType, resId)) return 0;
if (!isResourceWhitelisted(destType, resId)) return 0;
const caps: number[] = [stored.amount];
const cap = getResourceStorageCap(destType, resId);
if (cap != null) {
caps.push(Math.max(0, cap - getStoredAmountOf(dest, resId)));
}
const destMaxMass = destType.maxCapacityMass;
if (destMaxMass != null) {
const massPerUnit = stored.resource.massPerUnit ?? 0;
if (massPerUnit > 0) {
caps.push(Math.floor((destMaxMass - getMassOf(dest)) / massPerUnit));
}
}
return Math.min(...caps);
}
return stored.amount;
};
const getTransferMax = (): number | undefined => getTransferMaxFor(transferToId);
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;
if (isResourceProhibited(structureType, resourceId)) return false;
return isResourceWhitelisted(structureType, resourceId);
};
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 {
const errorText = result.error ?? "Unknown error.";
// Detect distance error — offer shipment flow
if (errorText.includes("too far apart")) {
setShipmentMode(true);
setShipmentResourceId(transferResourceId);
setShipmentAmount(transferAmount);
setShipmentDestId(transferToId);
setMessage({
type: "error",
text: "These structures are too far for instant transfer. Create a shipment instead?",
});
} else {
setMessage({ type: "error", text: errorText });
}
}
});
};
const handleCreateShipment = () => {
if (!shipmentDestId || !shipmentResourceId || !shipmentVehicleId) return;
const destId = Number(shipmentDestId);
const resId = Number(shipmentResourceId);
const vehicleId = Number(shipmentVehicleId);
const amt = Number(shipmentAmount);
if (isNaN(amt) || amt <= 0) {
setMessage({ type: "error", text: "Amount must be a positive number." });
return;
}
startTransition(async () => {
const result = await createShipment({
originId: structureId,
destinationId: destId,
vehicleId,
cargo: [{ relationTo: "resources", value: resId, amount: amt }],
});
if (result.success) {
setMessage({ type: "success", text: "Shipment created and dispatched!" });
setShipmentMode(false);
setShipmentAmount("1");
await fetchData();
router.refresh();
} else {
setMessage({ type: "error", text: result.error ?? "Failed to create shipment." });
}
});
};
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>
) : shipmentMode ? (
/* ── Shipment Creation Mode ─────────────────────── */
<div className="flex flex-col gap-4">
<div className="flex items-center gap-2 text-sm">
<TruckIcon className="size-4 text-muted-foreground" />
<span className="font-semibold">Create Shipment</span>
</div>
<div className="flex flex-col gap-1.5 p-3 rounded-md border border-border bg-muted/30 text-xs text-muted-foreground">
<div className="flex justify-between">
<span>From</span>
<span className="font-medium text-foreground">{structure?.name}</span>
</div>
<div className="flex justify-between">
<span>To</span>
<span className="font-medium text-foreground">
{allStructures.find((s) => s.id === Number(shipmentDestId))?.name ?? "Unknown"}
</span>
</div>
<div className="flex justify-between">
<span>Cargo</span>
<span className="font-medium text-foreground">
{shipmentAmount}×{" "}
{allResources.find((r) => r.id === Number(shipmentResourceId))?.name ?? "Unknown"}
</span>
</div>
</div>
<div className="flex flex-col gap-2">
<Label>Transport Vehicle</Label>
{availableVehicles.length > 0 ? (
<Combobox
value={shipmentVehicleId}
onValueChange={setShipmentVehicleId}
placeholder="Select a vehicle..."
searchPlaceholder="Search vehicles..."
emptyMessage="No vehicles with range."
options={availableVehicles.map((v) => {
const vType = typeof v.type === "object" ? v.type : null;
return {
value: String(v.id),
label: `${v.name} (${vType?.name ?? "Unknown"}), ${v.currentFuel?.toFixed(0)} fuel`,
};
})}
/>
) : (
<div className="flex items-center gap-2 p-3 rounded-md border border-amber-500/30 bg-amber-500/5 text-sm text-amber-400">
<TruckIcon className="size-4 shrink-0" />
<span>
No idle vehicles stationed at this structure. Deploy a vehicle here first to
create shipments.
</span>
</div>
)}
</div>
{shipmentVehicleId &&
(() => {
const selectedVehicle = availableVehicles.find(
(v) => v.id === Number(shipmentVehicleId),
);
const vType =
selectedVehicle && typeof selectedVehicle.type === "object"
? selectedVehicle.type
: null;
if (!selectedVehicle || !vType) return null;
const destStructure = allStructures.find((s) => s.id === Number(shipmentDestId));
const originCoords = structure?.coordinates as [number, number] | null;
const destCoords = destStructure?.coordinates as [number, number] | null;
let distance = 0;
if (originCoords && destCoords) {
distance = Math.sqrt(
(destCoords[0] - originCoords[0]) ** 2 + (destCoords[1] - originCoords[1]) ** 2,
);
}
const consumptionRate = vType.fuel?.fuelConsumptionRate ?? 1;
const fuelCost = Math.ceil(distance * consumptionRate);
const speed =
vType.maxSpeedOnRoad ?? vType.maxSpeedOffRoad ?? vType.maxAirspeed ?? 50;
const transitHours = distance / speed;
const canAfford = (selectedVehicle.currentFuel ?? 0) >= fuelCost;
return (
<div className="flex flex-col gap-2">
<div className="flex flex-col gap-1.5 p-3 rounded-md border border-border bg-muted/30 text-xs">
<div className="flex justify-between text-muted-foreground">
<span>Distance</span>
<span className="font-mono">{distance.toFixed(1)} units</span>
</div>
<div className="flex justify-between text-muted-foreground">
<span>Fuel Cost</span>
<span className={cn("font-mono", !canAfford && "text-red-400 font-medium")}>
{fuelCost} units
</span>
</div>
<div className="flex justify-between text-muted-foreground">
<span>Transit Time</span>
<span className="font-mono">
{transitHours < 1
? `${Math.ceil(transitHours * 60)} min`
: `${transitHours.toFixed(1)} hr`}
</span>
</div>
<div className="flex justify-between text-muted-foreground">
<span>Vehicle Fuel</span>
<span className="font-mono">
{selectedVehicle.currentFuel?.toFixed(1)} / {vType.fuel?.fuelCapacity}
</span>
</div>
</div>
{!canAfford && (
<div className="flex items-center gap-2 p-3 rounded-md border border-red-500/30 bg-red-500/5 text-sm text-red-400">
<span>
Vehicle does not have enough fuel for this trip. Need {fuelCost} units,
has {selectedVehicle.currentFuel?.toFixed(1)}.
</span>
</div>
)}
</div>
);
})()}
<div className="flex gap-2">
<Button
variant="outline"
onClick={() => {
setShipmentMode(false);
setMessage(null);
}}
className="flex-1"
>
Cancel
</Button>
<Button
onClick={handleCreateShipment}
disabled={
isPending ||
!shipmentVehicleId ||
!shipmentDestId ||
availableVehicles.length === 0
}
className="flex-1"
>
{isPending ? (
<Loader2Icon className="size-4 animate-spin mr-2" />
) : (
<TruckIcon className="size-4 mr-2" />
)}
Dispatch Shipment
</Button>
</div>
</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={canManage ? "deposit" : "transfer"} className="w-full">
<TabsList
className={cn(
"grid w-full",
canManage && canTransfer
? "grid-cols-3"
: canManage
? "grid-cols-2"
: "grid-cols-1",
)}
>
{canManage && (
<TabsTrigger value="deposit" className="flex items-center gap-1.5">
<ArrowDownToLineIcon className="size-3.5" />
Deposit
</TabsTrigger>
)}
{canManage && (
<TabsTrigger value="withdraw" className="flex items-center gap-1.5">
<ArrowUpFromLineIcon className="size-3.5" />
Withdraw
</TabsTrigger>
)}
{canTransfer && (
<TabsTrigger value="transfer" className="flex items-center gap-1.5">
<ArrowRightLeftIcon className="size-3.5" />
Transfer
</TabsTrigger>
)}
</TabsList>
{canManage && (
<TabsContent value="deposit" className="flex flex-col gap-4 pt-4">
<div className="flex flex-col gap-2">
<Label>Resource</Label>
<Combobox
value={depositResourceId}
onValueChange={(val) => {
setDepositResourceId(val);
setDepositAmount(
clampAmount(depositAmount, getDepositLimitInfoFor(val)?.max),
);
}}
placeholder="Select a resource..."
searchPlaceholder="Search resources..."
emptyMessage="Nothing may be deposited here."
options={allResources
.filter((r) => isResourceAllowed(r.id))
.map((resource) => {
const cap = getAllowedDepositCap(resource.id);
return {
value: String(resource.id),
label: resource.name,
hint: cap !== undefined ? `${cap.toLocaleString()} max` : undefined,
};
})}
/>
</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()))
}
/>
{getDepositLimitInfoFor(depositResourceId) !== undefined && (
<span className="text-xs text-muted-foreground">
Max: {getDepositLimitInfoFor(depositResourceId)!.max.toLocaleString()}{" "}
(limited by {getDepositLimitInfoFor(depositResourceId)!.reason})
</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>
)}
{canManage && (
<TabsContent value="withdraw" className="flex flex-col gap-4 pt-4">
<div className="flex flex-col gap-2">
<Label>Resource</Label>
<Combobox
value={withdrawResourceId}
onValueChange={(val) => {
setWithdrawResourceId(val);
const stored = findStoredResource(val);
setWithdrawAmount(clampAmount(withdrawAmount, stored?.amount));
}}
placeholder="Select a resource..."
searchPlaceholder="Search resources..."
emptyMessage="Nothing stored yet."
options={storedResources
.map((entry) => {
const resource = entry.resource as Resource;
if (!resource || typeof resource === "number") return null;
return {
value: String(resource.id),
label: `${resource.name} (${entry.amount.toLocaleString()} ${pluralize(entry.amount, resource.unitOfMeasure)})`,
};
})
.filter((option) => option !== null)}
/>
</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>
)}
{canTransfer && (
<TabsContent value="transfer" className="flex flex-col gap-4 pt-4">
<div className="flex flex-col gap-2">
<Label>Destination</Label>
<Combobox
value={transferToId}
onValueChange={(val) => {
setTransferToId(val);
setTransferAmount(clampAmount(transferAmount, getTransferMaxFor(val)));
}}
placeholder="Select a structure..."
searchPlaceholder="Search structures..."
options={allStructures.map((s) => ({
value: String(s.id),
label: s.name,
}))}
/>
</div>
<div className="flex flex-col gap-2">
<Label>Resource</Label>
<Combobox
value={transferResourceId}
onValueChange={(val) => {
setTransferResourceId(val);
const stored = findStoredResource(val);
setTransferAmount(clampAmount(transferAmount, stored?.amount));
}}
placeholder="Select a resource..."
searchPlaceholder="Search resources..."
emptyMessage="Nothing stored yet."
options={storedResources
.map((entry) => {
const resource = entry.resource as Resource;
if (!resource || typeof resource === "number") return null;
return {
value: String(resource.id),
label: `${resource.name} (${entry.amount.toLocaleString()} ${pluralize(entry.amount, resource.unitOfMeasure)})`,
};
})
.filter((option) => option !== null)}
/>
</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>
)}
{transferToId &&
transferResourceId &&
(() => {
const dest = allStructures.find((s) => s.id === Number(transferToId));
const destType = dest?.type;
if (!dest || !destType) return null;
const stored = findStoredResource(transferResourceId);
if (!stored) return null;
const resId = stored.resource.id;
if (isResourceProhibited(destType, resId)) {
return (
<span className="text-xs text-red-400">
Destination prohibits this resource.
</span>
);
}
if (!isResourceWhitelisted(destType, resId)) {
return (
<span className="text-xs text-amber-400">
Destination only accepts items on its allowed storage list. This
resource is not permitted.
</span>
);
}
const cap = getResourceStorageCap(destType, resId);
const destStored = getStoredAmountOf(dest, resId);
return (
<span className="text-xs text-muted-foreground">
{cap != null
? `Destination allowance: ${Math.max(0, cap - destStored).toLocaleString()} / ${cap.toLocaleString()}`
: "Destination has no per-item storage limit"}
</span>
);
})()}
</div>
{renderCapacityPreview("transfer")}
<Button
onClick={handleTransfer}
disabled={
isPending || !transferToId || !transferResourceId || getTransferMax() === 0
}
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>
);
}