- 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
932 lines
37 KiB
TypeScript
932 lines
37 KiB
TypeScript
"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>
|
||
);
|
||
}
|