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
This commit is contained in:
parent
0280d564f9
commit
12d276d44d
17 changed files with 2049 additions and 96 deletions
|
|
@ -51,6 +51,7 @@
|
||||||
"nodemailer": "^9.0.3",
|
"nodemailer": "^9.0.3",
|
||||||
"payload": "3.68.5",
|
"payload": "3.68.5",
|
||||||
"postcss": "^8.5.6",
|
"postcss": "^8.5.6",
|
||||||
|
"radix-ui": "^1.6.7",
|
||||||
"react": "19.2.3",
|
"react": "19.2.3",
|
||||||
"react-dom": "19.2.3",
|
"react-dom": "19.2.3",
|
||||||
"sharp": "0.34.5",
|
"sharp": "0.34.5",
|
||||||
|
|
|
||||||
|
|
@ -1,113 +1,50 @@
|
||||||
import {
|
|
||||||
Item,
|
|
||||||
ItemActions,
|
|
||||||
ItemContent,
|
|
||||||
ItemDescription,
|
|
||||||
ItemGroup,
|
|
||||||
ItemHeader,
|
|
||||||
ItemMedia,
|
|
||||||
ItemSeparator,
|
|
||||||
ItemTitle,
|
|
||||||
} from "@/components/ui/item";
|
|
||||||
import config from "@payload-config";
|
import config from "@payload-config";
|
||||||
import { getPayload } from "payload";
|
import { getPayload } from "payload";
|
||||||
import { GameStructure, Resource } from "@/payload-types";
|
import { GameStructure, Faction, Structure } from "@/payload-types";
|
||||||
import { convertLexicalToPlaintext } from "@payloadcms/richtext-lexical/plaintext";
|
import { StorageGrid } from "@/components/frontend/storage/StorageGrid";
|
||||||
import { BoxIcon, PackageOpenIcon } from "lucide-react";
|
import { WarehouseIcon } from "lucide-react";
|
||||||
|
|
||||||
export default async function LogisticsStoragePage() {
|
export default async function LogisticsStoragePage() {
|
||||||
// const headers = await getHeaders();
|
|
||||||
const payloadConfig = await config;
|
const payloadConfig = await config;
|
||||||
const payload = await getPayload({ config: payloadConfig });
|
const payload = await getPayload({ config: payloadConfig });
|
||||||
// const { user } = await payload.auth({ headers });
|
|
||||||
|
|
||||||
// const fileURL = `webstorm://file/${fileURLToPath(import.meta.url)}`;
|
const structuresRes = await payload.find({
|
||||||
|
|
||||||
const structures = await payload.find({
|
|
||||||
collection: "game-structures",
|
collection: "game-structures",
|
||||||
|
depth: 2,
|
||||||
|
limit: 500,
|
||||||
});
|
});
|
||||||
|
|
||||||
const ItemResource = ({
|
const factionsRes = await payload.find({
|
||||||
resource,
|
collection: "factions",
|
||||||
amount,
|
depth: 0,
|
||||||
includeSeparator,
|
limit: 500,
|
||||||
}: {
|
});
|
||||||
resource: Resource;
|
|
||||||
amount: number;
|
|
||||||
includeSeparator: boolean;
|
|
||||||
}) => (
|
|
||||||
<>
|
|
||||||
<Item>
|
|
||||||
<ItemHeader>
|
|
||||||
<ItemTitle>
|
|
||||||
<span>{resource.name}</span>
|
|
||||||
<span className="text-xs">
|
|
||||||
<span className="text-muted-foreground">{amount.toLocaleString()}</span>
|
|
||||||
{/*<span className="text-muted-foreground font-normal">x</span>*/}
|
|
||||||
</span>
|
|
||||||
</ItemTitle>
|
|
||||||
<ItemDescription>
|
|
||||||
{resource.description ? convertLexicalToPlaintext({ data: resource.description }) : ""}
|
|
||||||
</ItemDescription>
|
|
||||||
</ItemHeader>
|
|
||||||
{/*<ItemContent>
|
|
||||||
<Item size="sm" className="w-fit">
|
|
||||||
<ItemMedia variant="icon">
|
|
||||||
<PackageOpenIcon />
|
|
||||||
</ItemMedia>
|
|
||||||
<ItemContent>
|
|
||||||
<ItemTitle>In Storage</ItemTitle>
|
|
||||||
<ItemDescription>{amount}</ItemDescription>
|
|
||||||
</ItemContent>
|
|
||||||
</Item>
|
|
||||||
</ItemContent>*/}
|
|
||||||
</Item>
|
|
||||||
{includeSeparator && <ItemSeparator />}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
|
|
||||||
const ItemStructure = (structure: GameStructure) => (
|
const structures = structuresRes.docs as unknown as (GameStructure & {
|
||||||
<Item variant="outline">
|
type: Structure;
|
||||||
<ItemHeader>
|
faction?: Faction | null;
|
||||||
<p>{structure.name}</p>
|
})[];
|
||||||
<ItemDescription>A building.</ItemDescription>
|
|
||||||
</ItemHeader>
|
const factions = factionsRes.docs as unknown as Faction[];
|
||||||
{/*<ItemMedia />*/}
|
|
||||||
<ItemContent>
|
const categories: Structure["category"][] = ["logistics", "military", "civilian"];
|
||||||
<Item variant="muted" size="sm">
|
|
||||||
<ItemHeader>Stored Resources</ItemHeader>
|
|
||||||
<ItemMedia variant="icon">
|
|
||||||
<BoxIcon />
|
|
||||||
</ItemMedia>
|
|
||||||
<ItemContent>
|
|
||||||
<ItemGroup className="flex flex-col">
|
|
||||||
{structure.storedResources?.map((res, index) => {
|
|
||||||
const resource = res.resource as Resource;
|
|
||||||
return (
|
|
||||||
<ItemResource
|
|
||||||
key={resource.id}
|
|
||||||
resource={resource}
|
|
||||||
amount={res.amount}
|
|
||||||
includeSeparator={index !== structure.storedResources!.length - 1}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</ItemGroup>
|
|
||||||
</ItemContent>
|
|
||||||
</Item>
|
|
||||||
</ItemContent>
|
|
||||||
<ItemActions />
|
|
||||||
{/*<ItemFooter>{new Date(structure.updatedAt).toLocaleString()}</ItemFooter>*/}
|
|
||||||
</Item>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-5">
|
<div className="p-5 flex flex-col gap-6">
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-1">
|
||||||
{structures.docs?.map((structure) => (
|
<div className="flex items-center gap-2">
|
||||||
<ItemStructure key={structure.id} {...structure} />
|
<WarehouseIcon className="size-5 text-muted-foreground" />
|
||||||
))}
|
<h1 className="text-lg font-semibold">Storage</h1>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Overview of all structures and their stored resources across the theater of operations.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
<StorageGrid
|
||||||
|
structures={structures}
|
||||||
|
categories={categories}
|
||||||
|
factions={factions}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
239
src/app/(frontend)/logistics/structures/[id]/page.tsx
Normal file
239
src/app/(frontend)/logistics/structures/[id]/page.tsx
Normal file
|
|
@ -0,0 +1,239 @@
|
||||||
|
import config from "@payload-config";
|
||||||
|
import { getPayload } from "payload";
|
||||||
|
import { notFound } from "next/navigation";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { GameStructure, Structure, Faction, Resource } from "@/payload-types";
|
||||||
|
import { convertLexicalToPlaintext } from "@payloadcms/richtext-lexical/plaintext";
|
||||||
|
import { CapacityBar } from "@/components/frontend/storage/CapacityBar";
|
||||||
|
import { ResourceList } from "@/components/frontend/storage/ResourceList";
|
||||||
|
import { StorageRules } from "@/components/frontend/storage/StorageRules";
|
||||||
|
import {
|
||||||
|
calculateStoredMass,
|
||||||
|
calculateUtilization,
|
||||||
|
getCategoryLabel,
|
||||||
|
getFunctionTypeLabel,
|
||||||
|
getGridSlots,
|
||||||
|
} from "@/lib/logistics";
|
||||||
|
import {
|
||||||
|
ArrowLeftIcon,
|
||||||
|
BoxIcon,
|
||||||
|
Building2Icon,
|
||||||
|
CoinsIcon,
|
||||||
|
MapPinIcon,
|
||||||
|
WrenchIcon,
|
||||||
|
} from "lucide-react";
|
||||||
|
import {
|
||||||
|
Item,
|
||||||
|
ItemContent,
|
||||||
|
ItemDescription,
|
||||||
|
ItemHeader,
|
||||||
|
ItemMedia,
|
||||||
|
ItemTitle,
|
||||||
|
} from "@/components/ui/item";
|
||||||
|
import { ManageStorageButton } from "@/components/frontend/storage/ManageStorageButton";
|
||||||
|
|
||||||
|
interface StructureDetailPageProps {
|
||||||
|
params: Promise<{ id: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function StructureDetailPage({ params }: StructureDetailPageProps) {
|
||||||
|
const { id } = await params;
|
||||||
|
const payloadConfig = await config;
|
||||||
|
const payload = await getPayload({ config: payloadConfig });
|
||||||
|
|
||||||
|
const structureRes = await payload.findByID({
|
||||||
|
collection: "game-structures",
|
||||||
|
id: Number(id),
|
||||||
|
depth: 2,
|
||||||
|
}).catch(() => null);
|
||||||
|
|
||||||
|
if (!structureRes) {
|
||||||
|
notFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
const structure = structureRes as unknown as GameStructure & {
|
||||||
|
type: Structure;
|
||||||
|
faction?: Faction | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const structureType = structure.type;
|
||||||
|
const storedResources = structure.storedResources ?? [];
|
||||||
|
const currentMass = calculateStoredMass(storedResources);
|
||||||
|
const utilization = calculateUtilization(structureType, storedResources);
|
||||||
|
const gridSlots = getGridSlots(structureType);
|
||||||
|
const functionLabel = getFunctionTypeLabel(structureType.functionType);
|
||||||
|
const description = structureType.description
|
||||||
|
? convertLexicalToPlaintext({ data: structureType.description })
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const totalValue = storedResources.reduce((sum, entry) => {
|
||||||
|
const resource = entry.resource as Resource;
|
||||||
|
if (!resource || typeof resource === "number") return sum;
|
||||||
|
return sum + (resource.baseValue ?? 0) * entry.amount;
|
||||||
|
}, 0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-5 flex flex-col gap-6 max-w-4xl">
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<Link
|
||||||
|
href="/logistics/storage"
|
||||||
|
className="text-sm text-muted-foreground hover:text-primary transition-colors flex items-center gap-1 w-fit"
|
||||||
|
>
|
||||||
|
<ArrowLeftIcon className="size-3" />
|
||||||
|
Back to Storage
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<div className="flex items-start justify-between gap-4">
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Building2Icon className="size-5 text-muted-foreground" />
|
||||||
|
<h1 className="text-lg font-semibold">{structure.name}</h1>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||||
|
<span>{structureType.name}</span>
|
||||||
|
<span>·</span>
|
||||||
|
<span>{getCategoryLabel(structureType.category)}</span>
|
||||||
|
{functionLabel && (
|
||||||
|
<>
|
||||||
|
<span>·</span>
|
||||||
|
<span>{functionLabel}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{structure.faction && typeof structure.faction === "object" && (
|
||||||
|
<span className="text-xs bg-muted px-2.5 py-1 rounded-md">
|
||||||
|
{structure.faction.name}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<ManageStorageButton structureId={structure.id} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{description && (
|
||||||
|
<p className="text-sm text-muted-foreground">{description}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<Item variant="outline">
|
||||||
|
<ItemHeader>
|
||||||
|
<ItemTitle className="flex items-center gap-2">
|
||||||
|
<BoxIcon className="size-4 text-muted-foreground" />
|
||||||
|
Storage Capacity
|
||||||
|
</ItemTitle>
|
||||||
|
</ItemHeader>
|
||||||
|
<ItemContent>
|
||||||
|
<CapacityBar
|
||||||
|
utilization={utilization}
|
||||||
|
currentMass={currentMass}
|
||||||
|
maxMass={structureType.maxCapacityMass ?? null}
|
||||||
|
/>
|
||||||
|
</ItemContent>
|
||||||
|
</Item>
|
||||||
|
|
||||||
|
<Item variant="outline">
|
||||||
|
<ItemHeader>
|
||||||
|
<ItemTitle className="flex items-center gap-2">
|
||||||
|
<CoinsIcon className="size-4 text-muted-foreground" />
|
||||||
|
Total Value
|
||||||
|
</ItemTitle>
|
||||||
|
</ItemHeader>
|
||||||
|
<ItemContent>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-2xl font-bold">{totalValue.toLocaleString()}</span>
|
||||||
|
<span className="text-sm text-muted-foreground">base value</span>
|
||||||
|
</div>
|
||||||
|
</ItemContent>
|
||||||
|
</Item>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||||
|
{structure.coordinates && (
|
||||||
|
<Item variant="muted" size="sm">
|
||||||
|
<ItemMedia variant="icon">
|
||||||
|
<MapPinIcon />
|
||||||
|
</ItemMedia>
|
||||||
|
<ItemContent>
|
||||||
|
<ItemTitle>Coordinates</ItemTitle>
|
||||||
|
<ItemDescription>
|
||||||
|
{structure.coordinates[0].toFixed(1)}, {structure.coordinates[1].toFixed(1)}
|
||||||
|
</ItemDescription>
|
||||||
|
</ItemContent>
|
||||||
|
</Item>
|
||||||
|
)}
|
||||||
|
{gridSlots !== null && (
|
||||||
|
<Item variant="muted" size="sm">
|
||||||
|
<ItemMedia variant="icon">
|
||||||
|
<BoxIcon />
|
||||||
|
</ItemMedia>
|
||||||
|
<ItemContent>
|
||||||
|
<ItemTitle>Grid</ItemTitle>
|
||||||
|
<ItemDescription>
|
||||||
|
{structureType.xSize}×{structureType.ySize} ({gridSlots} slots)
|
||||||
|
</ItemDescription>
|
||||||
|
</ItemContent>
|
||||||
|
</Item>
|
||||||
|
)}
|
||||||
|
{structureType.maxCapacityMass && (
|
||||||
|
<Item variant="muted" size="sm">
|
||||||
|
<ItemMedia variant="icon">
|
||||||
|
<BoxIcon />
|
||||||
|
</ItemMedia>
|
||||||
|
<ItemContent>
|
||||||
|
<ItemTitle>Max Mass</ItemTitle>
|
||||||
|
<ItemDescription>{structureType.maxCapacityMass.toLocaleString()} kg</ItemDescription>
|
||||||
|
</ItemContent>
|
||||||
|
</Item>
|
||||||
|
)}
|
||||||
|
{structureType.maxHealth && (
|
||||||
|
<Item variant="muted" size="sm">
|
||||||
|
<ItemMedia variant="icon">
|
||||||
|
<WrenchIcon />
|
||||||
|
</ItemMedia>
|
||||||
|
<ItemContent>
|
||||||
|
<ItemTitle>Max Health</ItemTitle>
|
||||||
|
<ItemDescription>{structureType.maxHealth.toLocaleString()}</ItemDescription>
|
||||||
|
</ItemContent>
|
||||||
|
</Item>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ResourceList
|
||||||
|
storedResources={storedResources}
|
||||||
|
maxCapacityMass={structureType.maxCapacityMass ?? null}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<StorageRules
|
||||||
|
allowedStorage={structureType.allowedStorage}
|
||||||
|
prohibitedStorage={structureType.prohibitedStorage}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{structureType.upkeep && structureType.upkeep.length > 0 && (
|
||||||
|
<Item variant="outline">
|
||||||
|
<ItemHeader>
|
||||||
|
<ItemTitle className="flex items-center gap-2">
|
||||||
|
<WrenchIcon className="size-4 text-muted-foreground" />
|
||||||
|
Upkeep Costs
|
||||||
|
</ItemTitle>
|
||||||
|
<ItemDescription>Per game tick (every hour)</ItemDescription>
|
||||||
|
</ItemHeader>
|
||||||
|
<ItemContent>
|
||||||
|
<div className="flex flex-wrap gap-3">
|
||||||
|
{structureType.upkeep.map((upkeep, index) => {
|
||||||
|
const resource = upkeep.resource as Resource;
|
||||||
|
if (!resource || typeof resource === "number") return null;
|
||||||
|
return (
|
||||||
|
<span key={upkeep.id ?? index} className="text-sm bg-muted px-2.5 py-1 rounded-md">
|
||||||
|
{resource.name}: {upkeep.amount?.toLocaleString()} {resource.unitOfMeasure}/tick
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</ItemContent>
|
||||||
|
</Item>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
289
src/app/(frontend)/logistics/structures/actions.ts
Normal file
289
src/app/(frontend)/logistics/structures/actions.ts
Normal file
|
|
@ -0,0 +1,289 @@
|
||||||
|
"use server";
|
||||||
|
|
||||||
|
import config from "@payload-config";
|
||||||
|
import { getPayload } from "payload";
|
||||||
|
import { GameStructure, Resource, Structure } from "@/payload-types";
|
||||||
|
|
||||||
|
interface ActionResult {
|
||||||
|
success: boolean;
|
||||||
|
error?: string;
|
||||||
|
data?: GameStructure;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function authenticate() {
|
||||||
|
const payloadConfig = await config;
|
||||||
|
const payload = await getPayload({ config: payloadConfig });
|
||||||
|
const { headers } = await import("next/headers");
|
||||||
|
const hdrs = await headers();
|
||||||
|
const { user } = await payload.auth({ headers: hdrs, canSetHeaders: false });
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
throw new Error("Unauthorized");
|
||||||
|
}
|
||||||
|
|
||||||
|
return { payload, user };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getStructure(payload: Awaited<ReturnType<typeof getPayload>>, id: number) {
|
||||||
|
const structure = await payload.findByID({
|
||||||
|
collection: "game-structures",
|
||||||
|
id,
|
||||||
|
depth: 2,
|
||||||
|
}).catch(() => null);
|
||||||
|
|
||||||
|
if (!structure) {
|
||||||
|
throw new Error("Structure not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
return structure as unknown as GameStructure & { type: Structure };
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStoredAmount(storedResources: GameStructure["storedResources"], resourceId: number): number {
|
||||||
|
if (!storedResources) return 0;
|
||||||
|
const entry = storedResources.find((e) => {
|
||||||
|
const res = e.resource;
|
||||||
|
return typeof res === "object" && res !== null && "id" in res
|
||||||
|
? res.id === resourceId
|
||||||
|
: res === resourceId;
|
||||||
|
});
|
||||||
|
return entry?.amount ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function calculateMass(storedResources: GameStructure["storedResources"]): number {
|
||||||
|
if (!storedResources) return 0;
|
||||||
|
return 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function addResource(
|
||||||
|
structureId: number,
|
||||||
|
resourceId: number,
|
||||||
|
amount: number,
|
||||||
|
): Promise<ActionResult> {
|
||||||
|
try {
|
||||||
|
const { payload } = await authenticate();
|
||||||
|
|
||||||
|
if (amount <= 0) {
|
||||||
|
return { success: false, error: "Amount must be greater than zero." };
|
||||||
|
}
|
||||||
|
|
||||||
|
const structure = await getStructure(payload, structureId);
|
||||||
|
const structureType = structure.type;
|
||||||
|
|
||||||
|
// Check prohibited storage
|
||||||
|
const isProhibited = 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;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isProhibited) {
|
||||||
|
return { success: false, error: "This resource is prohibited from storage at this structure." };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch the resource to check mass
|
||||||
|
const resource = await payload.findByID({
|
||||||
|
collection: "resources",
|
||||||
|
id: resourceId,
|
||||||
|
depth: 0,
|
||||||
|
}).catch(() => null);
|
||||||
|
|
||||||
|
if (!resource) {
|
||||||
|
return { success: false, error: "Resource not found." };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check capacity
|
||||||
|
const currentMass = calculateMass(structure.storedResources);
|
||||||
|
const addedMass = (resource.massPerUnit ?? 0) * amount;
|
||||||
|
const maxMass = structureType.maxCapacityMass;
|
||||||
|
|
||||||
|
if (maxMass && currentMass + addedMass > maxMass) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: `Exceeds capacity. Available: ${(maxMass - currentMass).toLocaleString()} kg, needed: ${addedMass.toLocaleString()} kg.`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update stored resources
|
||||||
|
const existing = [...(structure.storedResources ?? [])];
|
||||||
|
const existingIndex = existing.findIndex((e) => {
|
||||||
|
const res = e.resource;
|
||||||
|
return typeof res === "object" && res !== null && "id" in res
|
||||||
|
? res.id === resourceId
|
||||||
|
: res === resourceId;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existingIndex >= 0) {
|
||||||
|
existing[existingIndex] = {
|
||||||
|
...existing[existingIndex],
|
||||||
|
amount: existing[existingIndex].amount + amount,
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
existing.push({ resource: resourceId, amount });
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = await payload.update({
|
||||||
|
collection: "game-structures",
|
||||||
|
id: structureId,
|
||||||
|
data: { storedResources: existing },
|
||||||
|
});
|
||||||
|
|
||||||
|
return { success: true, data: updated as unknown as GameStructure };
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : "Unknown error";
|
||||||
|
if (message === "Unauthorized") {
|
||||||
|
return { success: false, error: "You must be logged in." };
|
||||||
|
}
|
||||||
|
return { success: false, error: message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function removeResource(
|
||||||
|
structureId: number,
|
||||||
|
resourceId: number,
|
||||||
|
amount: number,
|
||||||
|
): Promise<ActionResult> {
|
||||||
|
try {
|
||||||
|
const { payload } = await authenticate();
|
||||||
|
|
||||||
|
if (amount <= 0) {
|
||||||
|
return { success: false, error: "Amount must be greater than zero." };
|
||||||
|
}
|
||||||
|
|
||||||
|
const structure = await getStructure(payload, structureId);
|
||||||
|
const currentAmount = getStoredAmount(structure.storedResources, resourceId);
|
||||||
|
|
||||||
|
if (currentAmount <= 0) {
|
||||||
|
return { success: false, error: "This resource is not stored here." };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (amount > currentAmount) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: `Cannot remove more than stored. Available: ${currentAmount.toLocaleString()}.`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = [...(structure.storedResources ?? [])];
|
||||||
|
const newAmount = currentAmount - amount;
|
||||||
|
|
||||||
|
if (newAmount <= 0) {
|
||||||
|
const filtered = existing.filter((e) => {
|
||||||
|
const res = e.resource;
|
||||||
|
return !(typeof res === "object" && res !== null && "id" in res
|
||||||
|
? res.id === resourceId
|
||||||
|
: res === resourceId);
|
||||||
|
});
|
||||||
|
const updated = await payload.update({
|
||||||
|
collection: "game-structures",
|
||||||
|
id: structureId,
|
||||||
|
data: { storedResources: filtered },
|
||||||
|
});
|
||||||
|
return { success: true, data: updated as unknown as GameStructure };
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedIndex = existing.findIndex((e) => {
|
||||||
|
const res = e.resource;
|
||||||
|
return typeof res === "object" && res !== null && "id" in res
|
||||||
|
? res.id === resourceId
|
||||||
|
: res === resourceId;
|
||||||
|
});
|
||||||
|
|
||||||
|
existing[updatedIndex] = { ...existing[updatedIndex], amount: newAmount };
|
||||||
|
|
||||||
|
const updated = await payload.update({
|
||||||
|
collection: "game-structures",
|
||||||
|
id: structureId,
|
||||||
|
data: { storedResources: existing },
|
||||||
|
});
|
||||||
|
|
||||||
|
return { success: true, data: updated as unknown as GameStructure };
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : "Unknown error";
|
||||||
|
if (message === "Unauthorized") {
|
||||||
|
return { success: false, error: "You must be logged in." };
|
||||||
|
}
|
||||||
|
return { success: false, error: message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function transferResource(
|
||||||
|
fromStructureId: number,
|
||||||
|
toStructureId: number,
|
||||||
|
resourceId: number,
|
||||||
|
amount: number,
|
||||||
|
): Promise<ActionResult> {
|
||||||
|
try {
|
||||||
|
const { payload } = await authenticate();
|
||||||
|
|
||||||
|
if (amount <= 0) {
|
||||||
|
return { success: false, error: "Amount must be greater than zero." };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fromStructureId === toStructureId) {
|
||||||
|
return { success: false, error: "Source and destination cannot be the same." };
|
||||||
|
}
|
||||||
|
|
||||||
|
const fromStructure = await getStructure(payload, fromStructureId);
|
||||||
|
const toStructure = await getStructure(payload, toStructureId);
|
||||||
|
|
||||||
|
const sourceAmount = getStoredAmount(fromStructure.storedResources, resourceId);
|
||||||
|
|
||||||
|
if (sourceAmount < amount) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: `Insufficient resources. Available: ${sourceAmount.toLocaleString()}.`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check destination capacity
|
||||||
|
const resource = await payload.findByID({
|
||||||
|
collection: "resources",
|
||||||
|
id: resourceId,
|
||||||
|
depth: 0,
|
||||||
|
}).catch(() => null);
|
||||||
|
|
||||||
|
if (!resource) {
|
||||||
|
return { success: false, error: "Resource not found." };
|
||||||
|
}
|
||||||
|
|
||||||
|
const destMass = calculateMass(toStructure.storedResources);
|
||||||
|
const addedMass = (resource.massPerUnit ?? 0) * amount;
|
||||||
|
const destMaxMass = toStructure.type.maxCapacityMass;
|
||||||
|
|
||||||
|
if (destMaxMass && destMass + addedMass > destMaxMass) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: `Destination exceeds capacity. Available: ${(destMaxMass - destMass).toLocaleString()} kg.`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove from source
|
||||||
|
const removeResult = await removeResource(fromStructureId, resourceId, amount);
|
||||||
|
if (!removeResult.success) {
|
||||||
|
return removeResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add to destination
|
||||||
|
const addResult = await addResource(toStructureId, resourceId, amount);
|
||||||
|
if (!addResult.success) {
|
||||||
|
// Rollback: re-add to source
|
||||||
|
await addResource(fromStructureId, resourceId, amount);
|
||||||
|
return { success: false, error: `Transfer failed at destination: ${addResult.error}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: true, data: addResult.data };
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : "Unknown error";
|
||||||
|
if (message === "Unauthorized") {
|
||||||
|
return { success: false, error: "You must be logged in." };
|
||||||
|
}
|
||||||
|
return { success: false, error: message };
|
||||||
|
}
|
||||||
|
}
|
||||||
56
src/components/frontend/storage/CapacityBar.tsx
Normal file
56
src/components/frontend/storage/CapacityBar.tsx
Normal file
|
|
@ -0,0 +1,56 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Progress } from "@/components/ui/progress";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
interface CapacityBarProps {
|
||||||
|
utilization: number | null;
|
||||||
|
currentMass: number;
|
||||||
|
maxMass: number | null;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getUtilizationColor(utilization: number): string {
|
||||||
|
if (utilization > 85) return "text-red-400";
|
||||||
|
if (utilization > 60) return "text-yellow-400";
|
||||||
|
return "text-green-400";
|
||||||
|
}
|
||||||
|
|
||||||
|
function getProgressColor(utilization: number): string {
|
||||||
|
if (utilization > 85) return "[&>div]:bg-red-500";
|
||||||
|
if (utilization > 60) return "[&>div]:bg-yellow-500";
|
||||||
|
return "[&>div]:bg-green-500";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CapacityBar({ utilization, currentMass, maxMass, className }: CapacityBarProps) {
|
||||||
|
if (utilization === null || maxMass === null) {
|
||||||
|
return (
|
||||||
|
<div className={cn("flex flex-col gap-1", className)}>
|
||||||
|
<div className="flex items-center justify-between text-xs">
|
||||||
|
<span className="text-muted-foreground">Mass stored</span>
|
||||||
|
<span className="text-muted-foreground">{currentMass.toLocaleString()} kg</span>
|
||||||
|
</div>
|
||||||
|
<Progress value={0} className="h-1.5" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const colorClass = getUtilizationColor(utilization);
|
||||||
|
const progressClass = getProgressColor(utilization);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn("flex flex-col gap-1", className)}>
|
||||||
|
<div className="flex items-center justify-between text-xs">
|
||||||
|
<span className="text-muted-foreground">Capacity</span>
|
||||||
|
<span className={cn("font-medium", colorClass)}>
|
||||||
|
{Math.round(utilization)}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Progress value={utilization} className={cn("h-1.5", progressClass)} />
|
||||||
|
<div className="flex items-center justify-between text-xs text-muted-foreground">
|
||||||
|
<span>{currentMass.toLocaleString()} kg</span>
|
||||||
|
<span>{maxMass.toLocaleString()} kg</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
28
src/components/frontend/storage/ManageStorageButton.tsx
Normal file
28
src/components/frontend/storage/ManageStorageButton.tsx
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { SettingsIcon } from "lucide-react";
|
||||||
|
import { ManageStorageDialog } from "@/components/frontend/storage/ManageStorageDialog";
|
||||||
|
|
||||||
|
interface ManageStorageButtonProps {
|
||||||
|
structureId: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ManageStorageButton({ structureId }: ManageStorageButtonProps) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Button variant="outline" size="sm" onClick={() => setOpen(true)}>
|
||||||
|
<SettingsIcon className="size-3.5 mr-1.5" />
|
||||||
|
Manage
|
||||||
|
</Button>
|
||||||
|
<ManageStorageDialog
|
||||||
|
structureId={structureId}
|
||||||
|
open={open}
|
||||||
|
onOpenChange={setOpen}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
372
src/components/frontend/storage/ManageStorageDialog.tsx
Normal file
372
src/components/frontend/storage/ManageStorageDialog.tsx
Normal file
|
|
@ -0,0 +1,372 @@
|
||||||
|
"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>
|
||||||
|
);
|
||||||
|
}
|
||||||
123
src/components/frontend/storage/ResourceList.tsx
Normal file
123
src/components/frontend/storage/ResourceList.tsx
Normal file
|
|
@ -0,0 +1,123 @@
|
||||||
|
import { Resource, GameStructure } from "@/payload-types";
|
||||||
|
import { convertLexicalToPlaintext } from "@payloadcms/richtext-lexical/plaintext";
|
||||||
|
import { Progress } from "@/components/ui/progress";
|
||||||
|
import {
|
||||||
|
Item,
|
||||||
|
ItemContent,
|
||||||
|
ItemDescription,
|
||||||
|
ItemHeader,
|
||||||
|
ItemMedia,
|
||||||
|
ItemSeparator,
|
||||||
|
ItemTitle,
|
||||||
|
} from "@/components/ui/item";
|
||||||
|
import { PackageOpenIcon, DropletIcon, ZapIcon, BrainIcon } from "lucide-react";
|
||||||
|
|
||||||
|
interface ResourceListProps {
|
||||||
|
storedResources: GameStructure["storedResources"];
|
||||||
|
maxCapacityMass: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getResourceIcon(type: Resource["type"]) {
|
||||||
|
switch (type) {
|
||||||
|
case "fluid":
|
||||||
|
return <DropletIcon className="size-4" />;
|
||||||
|
case "energy":
|
||||||
|
return <ZapIcon className="size-4" />;
|
||||||
|
case "abstract":
|
||||||
|
return <BrainIcon className="size-4" />;
|
||||||
|
default:
|
||||||
|
return <PackageOpenIcon className="size-4" />;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ResourceList({ storedResources, maxCapacityMass }: ResourceListProps) {
|
||||||
|
const resources = storedResources ?? [];
|
||||||
|
|
||||||
|
if (resources.length === 0) {
|
||||||
|
return (
|
||||||
|
<Item variant="outline">
|
||||||
|
<ItemHeader>
|
||||||
|
<ItemTitle className="flex items-center gap-2">
|
||||||
|
<PackageOpenIcon className="size-4 text-muted-foreground" />
|
||||||
|
Stored Resources
|
||||||
|
</ItemTitle>
|
||||||
|
</ItemHeader>
|
||||||
|
<ItemContent>
|
||||||
|
<p className="text-sm text-muted-foreground py-2">No resources currently stored.</p>
|
||||||
|
</ItemContent>
|
||||||
|
</Item>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Item variant="outline">
|
||||||
|
<ItemHeader>
|
||||||
|
<ItemTitle className="flex items-center gap-2">
|
||||||
|
<PackageOpenIcon className="size-4 text-muted-foreground" />
|
||||||
|
Stored Resources
|
||||||
|
</ItemTitle>
|
||||||
|
<ItemDescription>
|
||||||
|
{resources.length} resource type{resources.length !== 1 ? "s" : ""}
|
||||||
|
</ItemDescription>
|
||||||
|
</ItemHeader>
|
||||||
|
<ItemContent>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
{resources.map((entry, index) => {
|
||||||
|
const resource = entry.resource as Resource;
|
||||||
|
if (!resource || typeof resource === "number") return null;
|
||||||
|
|
||||||
|
const mass = (resource.massPerUnit ?? 0) * entry.amount;
|
||||||
|
const massPercent = maxCapacityMass
|
||||||
|
? Math.min((mass / maxCapacityMass) * 100, 100)
|
||||||
|
: null;
|
||||||
|
const description = resource.description
|
||||||
|
? convertLexicalToPlaintext({ data: resource.description })
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={entry.id ?? index}>
|
||||||
|
<div className="flex items-center gap-3 py-2.5">
|
||||||
|
<ItemMedia variant="icon">
|
||||||
|
{resource.icon ? (
|
||||||
|
<img
|
||||||
|
src={typeof resource.icon === "object" ? resource.icon.url ?? "" : ""}
|
||||||
|
alt={resource.name}
|
||||||
|
className="size-4 object-contain"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
getResourceIcon(resource.type)
|
||||||
|
)}
|
||||||
|
</ItemMedia>
|
||||||
|
<ItemContent className="flex-1 min-w-0">
|
||||||
|
<ItemTitle>
|
||||||
|
<span
|
||||||
|
className="w-2 h-2 rounded-full shrink-0"
|
||||||
|
style={{ backgroundColor: resource.color ?? "#888" }}
|
||||||
|
/>
|
||||||
|
<span>{resource.name}</span>
|
||||||
|
<span className="text-xs font-normal text-muted-foreground">
|
||||||
|
{entry.amount.toLocaleString()} {resource.unitOfMeasure}
|
||||||
|
</span>
|
||||||
|
</ItemTitle>
|
||||||
|
{description && (
|
||||||
|
<ItemDescription className="line-clamp-1">
|
||||||
|
{description}
|
||||||
|
</ItemDescription>
|
||||||
|
)}
|
||||||
|
</ItemContent>
|
||||||
|
<div className="text-right shrink-0">
|
||||||
|
<div className="text-sm font-medium">{mass.toLocaleString()} kg</div>
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
{(resource.baseValue * entry.amount).toLocaleString()} value
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{index < resources.length - 1 && <ItemSeparator />}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</ItemContent>
|
||||||
|
</Item>
|
||||||
|
);
|
||||||
|
}
|
||||||
71
src/components/frontend/storage/StorageFilters.tsx
Normal file
71
src/components/frontend/storage/StorageFilters.tsx
Normal file
|
|
@ -0,0 +1,71 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Faction, Structure } from "@/payload-types";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { getCategoryLabel } from "@/lib/logistics";
|
||||||
|
|
||||||
|
interface StorageFiltersProps {
|
||||||
|
categories: Structure["category"][];
|
||||||
|
factions: Faction[];
|
||||||
|
onFilterChange: (filters: { category: Structure["category"] | null; factionId: number | null }) => void;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StorageFilters({ categories, factions, onFilterChange, className }: StorageFiltersProps) {
|
||||||
|
const [selectedCategory, setSelectedCategory] = useState<Structure["category"] | null>(null);
|
||||||
|
const [selectedFaction, setSelectedFaction] = useState<number | null>(null);
|
||||||
|
|
||||||
|
const handleCategoryChange = (category: Structure["category"] | null) => {
|
||||||
|
const next = selectedCategory === category ? null : category;
|
||||||
|
setSelectedCategory(next);
|
||||||
|
onFilterChange({ category: next, factionId: selectedFaction });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFactionChange = (factionId: number | null) => {
|
||||||
|
const next = selectedFaction === factionId ? null : factionId;
|
||||||
|
setSelectedFaction(next);
|
||||||
|
onFilterChange({ category: selectedCategory, factionId: next });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn("flex flex-wrap gap-2", className)}>
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<span className="text-xs text-muted-foreground">Category:</span>
|
||||||
|
{categories.map((cat) => (
|
||||||
|
<button
|
||||||
|
key={cat}
|
||||||
|
onClick={() => handleCategoryChange(cat)}
|
||||||
|
className={cn(
|
||||||
|
"text-xs px-2.5 py-1 rounded-md border transition-colors",
|
||||||
|
selectedCategory === cat
|
||||||
|
? "bg-primary text-primary-foreground border-primary"
|
||||||
|
: "bg-muted text-muted-foreground border-border hover:bg-accent hover:text-accent-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{getCategoryLabel(cat)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{factions.length > 0 && (
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<span className="text-xs text-muted-foreground">Faction:</span>
|
||||||
|
{factions.map((faction) => (
|
||||||
|
<button
|
||||||
|
key={faction.id}
|
||||||
|
onClick={() => handleFactionChange(faction.id)}
|
||||||
|
className={cn(
|
||||||
|
"text-xs px-2.5 py-1 rounded-md border transition-colors",
|
||||||
|
selectedFaction === faction.id
|
||||||
|
? "bg-primary text-primary-foreground border-primary"
|
||||||
|
: "bg-muted text-muted-foreground border-border hover:bg-accent hover:text-accent-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{faction.name}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
58
src/components/frontend/storage/StorageGrid.tsx
Normal file
58
src/components/frontend/storage/StorageGrid.tsx
Normal file
|
|
@ -0,0 +1,58 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { GameStructure, Faction, Structure, Resource } from "@/payload-types";
|
||||||
|
import { StructureCard } from "@/components/frontend/storage/StructureCard";
|
||||||
|
import { StorageFilters } from "@/components/frontend/storage/StorageFilters";
|
||||||
|
|
||||||
|
interface StorageGridProps {
|
||||||
|
structures: (GameStructure & { type: Structure; faction?: Faction | null })[];
|
||||||
|
categories: Structure["category"][];
|
||||||
|
factions: Faction[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StorageGrid({ structures, categories, factions }: StorageGridProps) {
|
||||||
|
const [filters, setFilters] = useState<{
|
||||||
|
category: Structure["category"] | null;
|
||||||
|
factionId: number | null;
|
||||||
|
}>({ category: null, factionId: null });
|
||||||
|
|
||||||
|
const filtered = structures.filter((s) => {
|
||||||
|
if (filters.category && s.type.category !== filters.category) return false;
|
||||||
|
if (filters.factionId) {
|
||||||
|
const factionId =
|
||||||
|
typeof s.faction === "object" && s.faction ? s.faction.id : typeof s.faction === "number" ? s.faction : null;
|
||||||
|
if (factionId !== filters.factionId) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<StorageFilters
|
||||||
|
categories={categories}
|
||||||
|
factions={factions}
|
||||||
|
onFilterChange={setFilters}
|
||||||
|
/>
|
||||||
|
{filtered.length === 0 ? (
|
||||||
|
<div className="flex flex-col items-center justify-center py-12 text-muted-foreground">
|
||||||
|
<p className="text-sm">No structures found.</p>
|
||||||
|
{filters.category || filters.factionId ? (
|
||||||
|
<button
|
||||||
|
onClick={() => setFilters({ category: null, factionId: null })}
|
||||||
|
className="text-sm text-primary hover:underline mt-1"
|
||||||
|
>
|
||||||
|
Clear filters
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
{filtered.map((structure) => (
|
||||||
|
<StructureCard key={structure.id} structure={structure} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
93
src/components/frontend/storage/StorageRules.tsx
Normal file
93
src/components/frontend/storage/StorageRules.tsx
Normal file
|
|
@ -0,0 +1,93 @@
|
||||||
|
import { Structure } from "@/payload-types";
|
||||||
|
import {
|
||||||
|
Item,
|
||||||
|
ItemContent,
|
||||||
|
ItemDescription,
|
||||||
|
ItemHeader,
|
||||||
|
ItemTitle,
|
||||||
|
} from "@/components/ui/item";
|
||||||
|
import { CheckCircleIcon, XCircleIcon, ShieldCheckIcon } from "lucide-react";
|
||||||
|
|
||||||
|
interface StorageRulesProps {
|
||||||
|
allowedStorage: Structure["allowedStorage"];
|
||||||
|
prohibitedStorage: Structure["prohibitedStorage"];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StorageRules({ allowedStorage, prohibitedStorage }: StorageRulesProps) {
|
||||||
|
const hasAllowed = allowedStorage && allowedStorage.length > 0;
|
||||||
|
const hasProhibited = prohibitedStorage && prohibitedStorage.length > 0;
|
||||||
|
|
||||||
|
if (!hasAllowed && !hasProhibited) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Item variant="outline">
|
||||||
|
<ItemHeader>
|
||||||
|
<ItemTitle className="flex items-center gap-2">
|
||||||
|
<ShieldCheckIcon className="size-4 text-muted-foreground" />
|
||||||
|
Storage Rules
|
||||||
|
</ItemTitle>
|
||||||
|
</ItemHeader>
|
||||||
|
<ItemContent>
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
{hasAllowed && (
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||||
|
Allowed
|
||||||
|
</span>
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{allowedStorage!.map((rule, index) => {
|
||||||
|
if (!rule.item) return null;
|
||||||
|
const item = rule.item;
|
||||||
|
const value = item.value;
|
||||||
|
const name =
|
||||||
|
typeof value === "object" && value !== null && "name" in value
|
||||||
|
? String(value.name)
|
||||||
|
: `Item #${value}`;
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
key={rule.id ?? index}
|
||||||
|
className="text-xs bg-green-500/10 text-green-400 border border-green-500/20 px-2 py-0.5 rounded-md flex items-center gap-1"
|
||||||
|
>
|
||||||
|
<CheckCircleIcon className="size-3" />
|
||||||
|
{name}
|
||||||
|
{rule.amount && (
|
||||||
|
<span className="text-green-400/60">×{rule.amount}</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{hasProhibited && (
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||||
|
Prohibited
|
||||||
|
</span>
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{prohibitedStorage!.map((rule, index) => {
|
||||||
|
if (!rule.item) return null;
|
||||||
|
const item = rule.item;
|
||||||
|
const value = item.value;
|
||||||
|
const name =
|
||||||
|
typeof value === "object" && value !== null && "name" in value
|
||||||
|
? String(value.name)
|
||||||
|
: `Item #${value}`;
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
key={rule.id ?? index}
|
||||||
|
className="text-xs bg-red-500/10 text-red-400 border border-red-500/20 px-2 py-0.5 rounded-md flex items-center gap-1"
|
||||||
|
>
|
||||||
|
<XCircleIcon className="size-3" />
|
||||||
|
{name}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</ItemContent>
|
||||||
|
</Item>
|
||||||
|
);
|
||||||
|
}
|
||||||
118
src/components/frontend/storage/StructureCard.tsx
Normal file
118
src/components/frontend/storage/StructureCard.tsx
Normal file
|
|
@ -0,0 +1,118 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import {
|
||||||
|
Item,
|
||||||
|
ItemContent,
|
||||||
|
ItemDescription,
|
||||||
|
ItemHeader,
|
||||||
|
ItemMedia,
|
||||||
|
ItemTitle,
|
||||||
|
} from "@/components/ui/item";
|
||||||
|
import { CapacityBar } from "@/components/frontend/storage/CapacityBar";
|
||||||
|
import {
|
||||||
|
getCategoryLabel,
|
||||||
|
getFunctionTypeLabel,
|
||||||
|
getGridSlots,
|
||||||
|
getResourceCount,
|
||||||
|
calculateStoredMass,
|
||||||
|
calculateUtilization,
|
||||||
|
} from "@/lib/logistics";
|
||||||
|
import { GameStructure, Faction, Structure, Resource } from "@/payload-types";
|
||||||
|
import {
|
||||||
|
BoxIcon,
|
||||||
|
Building2Icon,
|
||||||
|
ChevronRightIcon,
|
||||||
|
Grid3x3Icon,
|
||||||
|
MapPinIcon,
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
|
interface StructureCardProps {
|
||||||
|
structure: GameStructure & {
|
||||||
|
type: Structure;
|
||||||
|
faction?: Faction | null;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StructureCard({ structure }: StructureCardProps) {
|
||||||
|
const structureType = structure.type;
|
||||||
|
const storedResources = structure.storedResources ?? [];
|
||||||
|
|
||||||
|
const currentMass = calculateStoredMass(storedResources);
|
||||||
|
const utilization = calculateUtilization(structureType, storedResources);
|
||||||
|
const resourceCount = getResourceCount(storedResources);
|
||||||
|
const gridSlots = getGridSlots(structureType);
|
||||||
|
const functionLabel = getFunctionTypeLabel(structureType.functionType);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link href={`/logistics/structures/${structure.id}`}>
|
||||||
|
<Item variant="outline" className="cursor-pointer group">
|
||||||
|
<ItemHeader>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<ItemMedia variant="icon">
|
||||||
|
<Building2Icon />
|
||||||
|
</ItemMedia>
|
||||||
|
<ItemTitle className="flex flex-col">
|
||||||
|
<span>{structure.name}</span>
|
||||||
|
<span className="text-xs font-normal text-muted-foreground">
|
||||||
|
{structureType.name}
|
||||||
|
</span>
|
||||||
|
</ItemTitle>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{structure.faction && typeof structure.faction === "object" && (
|
||||||
|
<span className="text-xs text-muted-foreground bg-muted px-2 py-0.5 rounded-md">
|
||||||
|
{structure.faction.name}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="text-xs text-muted-foreground bg-muted px-2 py-0.5 rounded-md">
|
||||||
|
{getCategoryLabel(structureType.category)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</ItemHeader>
|
||||||
|
<ItemContent>
|
||||||
|
<div className="flex flex-col gap-3 w-full">
|
||||||
|
<CapacityBar
|
||||||
|
utilization={utilization}
|
||||||
|
currentMass={currentMass}
|
||||||
|
maxMass={structureType.maxCapacityMass ?? null}
|
||||||
|
/>
|
||||||
|
<div className="flex items-center justify-between text-xs text-muted-foreground">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<BoxIcon className="size-3" />
|
||||||
|
{resourceCount} resource{resourceCount !== 1 ? "s" : ""}
|
||||||
|
</span>
|
||||||
|
{gridSlots !== null && (
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<Grid3x3Icon className="size-3" />
|
||||||
|
{structureType.xSize}×{structureType.ySize} grid
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{functionLabel && (
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
{functionLabel}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{structure.coordinates && (
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<MapPinIcon className="size-3" />
|
||||||
|
{structure.coordinates[0].toFixed(1)}, {structure.coordinates[1].toFixed(1)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ItemContent>
|
||||||
|
<ItemDescription>
|
||||||
|
{resourceCount === 0
|
||||||
|
? "Empty — no resources stored."
|
||||||
|
: `${resourceCount} resource type${resourceCount !== 1 ? "s" : ""} stored`}
|
||||||
|
</ItemDescription>
|
||||||
|
<span className="text-xs text-muted-foreground group-hover:text-primary transition-colors flex items-center gap-1">
|
||||||
|
View details <ChevronRightIcon className="size-3" />
|
||||||
|
</span>
|
||||||
|
</Item>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
158
src/components/ui/dialog.tsx
Normal file
158
src/components/ui/dialog.tsx
Normal file
|
|
@ -0,0 +1,158 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import { XIcon } from "lucide-react"
|
||||||
|
import { Dialog as DialogPrimitive } from "radix-ui"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
|
||||||
|
function Dialog({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
|
||||||
|
return <DialogPrimitive.Root data-slot="dialog" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogTrigger({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
|
||||||
|
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogPortal({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
|
||||||
|
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogClose({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
|
||||||
|
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogOverlay({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
|
||||||
|
return (
|
||||||
|
<DialogPrimitive.Overlay
|
||||||
|
data-slot="dialog-overlay"
|
||||||
|
className={cn(
|
||||||
|
"fixed inset-0 z-50 bg-black/50 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogContent({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
showCloseButton = true,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
|
||||||
|
showCloseButton?: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<DialogPortal data-slot="dialog-portal">
|
||||||
|
<DialogOverlay />
|
||||||
|
<DialogPrimitive.Content
|
||||||
|
data-slot="dialog-content"
|
||||||
|
className={cn(
|
||||||
|
"fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border bg-background p-6 shadow-lg duration-200 outline-none data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 sm:max-w-lg",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
{showCloseButton && (
|
||||||
|
<DialogPrimitive.Close
|
||||||
|
data-slot="dialog-close"
|
||||||
|
className="absolute top-4 right-4 rounded-xs opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:ring-2 focus:ring-ring focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
|
||||||
|
>
|
||||||
|
<XIcon />
|
||||||
|
<span className="sr-only">Close</span>
|
||||||
|
</DialogPrimitive.Close>
|
||||||
|
)}
|
||||||
|
</DialogPrimitive.Content>
|
||||||
|
</DialogPortal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="dialog-header"
|
||||||
|
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogFooter({
|
||||||
|
className,
|
||||||
|
showCloseButton = false,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div"> & {
|
||||||
|
showCloseButton?: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="dialog-footer"
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
{showCloseButton && (
|
||||||
|
<DialogPrimitive.Close asChild>
|
||||||
|
<Button variant="outline">Close</Button>
|
||||||
|
</DialogPrimitive.Close>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogTitle({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
|
||||||
|
return (
|
||||||
|
<DialogPrimitive.Title
|
||||||
|
data-slot="dialog-title"
|
||||||
|
className={cn("text-lg leading-none font-semibold", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogDescription({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
|
||||||
|
return (
|
||||||
|
<DialogPrimitive.Description
|
||||||
|
data-slot="dialog-description"
|
||||||
|
className={cn("text-sm text-muted-foreground", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Dialog,
|
||||||
|
DialogClose,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogOverlay,
|
||||||
|
DialogPortal,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
}
|
||||||
24
src/components/ui/label.tsx
Normal file
24
src/components/ui/label.tsx
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import { Label as LabelPrimitive } from "radix-ui"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Label({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
|
||||||
|
return (
|
||||||
|
<LabelPrimitive.Root
|
||||||
|
data-slot="label"
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Label }
|
||||||
190
src/components/ui/select.tsx
Normal file
190
src/components/ui/select.tsx
Normal file
|
|
@ -0,0 +1,190 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react"
|
||||||
|
import { Select as SelectPrimitive } from "radix-ui"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Select({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
|
||||||
|
return <SelectPrimitive.Root data-slot="select" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectGroup({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
|
||||||
|
return <SelectPrimitive.Group data-slot="select-group" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectValue({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
|
||||||
|
return <SelectPrimitive.Value data-slot="select-value" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectTrigger({
|
||||||
|
className,
|
||||||
|
size = "default",
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
|
||||||
|
size?: "sm" | "default"
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Trigger
|
||||||
|
data-slot="select-trigger"
|
||||||
|
data-size={size}
|
||||||
|
className={cn(
|
||||||
|
"flex w-fit items-center justify-between gap-2 rounded-md border border-input bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 data-[placeholder]:text-muted-foreground data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<SelectPrimitive.Icon asChild>
|
||||||
|
<ChevronDownIcon className="size-4 opacity-50" />
|
||||||
|
</SelectPrimitive.Icon>
|
||||||
|
</SelectPrimitive.Trigger>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectContent({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
position = "item-aligned",
|
||||||
|
align = "center",
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Portal>
|
||||||
|
<SelectPrimitive.Content
|
||||||
|
data-slot="select-content"
|
||||||
|
className={cn(
|
||||||
|
"relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border bg-popover text-popover-foreground shadow-md data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
|
||||||
|
position === "popper" &&
|
||||||
|
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
position={position}
|
||||||
|
align={align}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<SelectScrollUpButton />
|
||||||
|
<SelectPrimitive.Viewport
|
||||||
|
className={cn(
|
||||||
|
"p-1",
|
||||||
|
position === "popper" &&
|
||||||
|
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</SelectPrimitive.Viewport>
|
||||||
|
<SelectScrollDownButton />
|
||||||
|
</SelectPrimitive.Content>
|
||||||
|
</SelectPrimitive.Portal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectLabel({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Label
|
||||||
|
data-slot="select-label"
|
||||||
|
className={cn("px-2 py-1.5 text-xs text-muted-foreground", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectItem({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Item
|
||||||
|
data-slot="select-item"
|
||||||
|
className={cn(
|
||||||
|
"relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
data-slot="select-item-indicator"
|
||||||
|
className="absolute right-2 flex size-3.5 items-center justify-center"
|
||||||
|
>
|
||||||
|
<SelectPrimitive.ItemIndicator>
|
||||||
|
<CheckIcon className="size-4" />
|
||||||
|
</SelectPrimitive.ItemIndicator>
|
||||||
|
</span>
|
||||||
|
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||||
|
</SelectPrimitive.Item>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectSeparator({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Separator
|
||||||
|
data-slot="select-separator"
|
||||||
|
className={cn("pointer-events-none -mx-1 my-1 h-px bg-border", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectScrollUpButton({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.ScrollUpButton
|
||||||
|
data-slot="select-scroll-up-button"
|
||||||
|
className={cn(
|
||||||
|
"flex cursor-default items-center justify-center py-1",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ChevronUpIcon className="size-4" />
|
||||||
|
</SelectPrimitive.ScrollUpButton>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectScrollDownButton({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.ScrollDownButton
|
||||||
|
data-slot="select-scroll-down-button"
|
||||||
|
className={cn(
|
||||||
|
"flex cursor-default items-center justify-center py-1",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ChevronDownIcon className="size-4" />
|
||||||
|
</SelectPrimitive.ScrollDownButton>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectGroup,
|
||||||
|
SelectItem,
|
||||||
|
SelectLabel,
|
||||||
|
SelectScrollDownButton,
|
||||||
|
SelectScrollUpButton,
|
||||||
|
SelectSeparator,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
}
|
||||||
91
src/components/ui/tabs.tsx
Normal file
91
src/components/ui/tabs.tsx
Normal file
|
|
@ -0,0 +1,91 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
import { Tabs as TabsPrimitive } from "radix-ui"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Tabs({
|
||||||
|
className,
|
||||||
|
orientation = "horizontal",
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof TabsPrimitive.Root>) {
|
||||||
|
return (
|
||||||
|
<TabsPrimitive.Root
|
||||||
|
data-slot="tabs"
|
||||||
|
data-orientation={orientation}
|
||||||
|
orientation={orientation}
|
||||||
|
className={cn(
|
||||||
|
"group/tabs flex gap-2 data-[orientation=horizontal]:flex-col",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const tabsListVariants = cva(
|
||||||
|
"group/tabs-list inline-flex w-fit items-center justify-center rounded-lg p-[3px] text-muted-foreground group-data-[orientation=horizontal]/tabs:h-9 group-data-[orientation=vertical]/tabs:h-fit group-data-[orientation=vertical]/tabs:flex-col data-[variant=line]:rounded-none",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "bg-muted",
|
||||||
|
line: "gap-1 bg-transparent",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
function TabsList({
|
||||||
|
className,
|
||||||
|
variant = "default",
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof TabsPrimitive.List> &
|
||||||
|
VariantProps<typeof tabsListVariants>) {
|
||||||
|
return (
|
||||||
|
<TabsPrimitive.List
|
||||||
|
data-slot="tabs-list"
|
||||||
|
data-variant={variant}
|
||||||
|
className={cn(tabsListVariants({ variant }), className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TabsTrigger({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
|
||||||
|
return (
|
||||||
|
<TabsPrimitive.Trigger
|
||||||
|
data-slot="tabs-trigger"
|
||||||
|
className={cn(
|
||||||
|
"relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap text-foreground/60 transition-all group-data-[orientation=vertical]/tabs:w-full group-data-[orientation=vertical]/tabs:justify-start hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1 focus-visible:outline-ring disabled:pointer-events-none disabled:opacity-50 group-data-[variant=default]/tabs-list:data-[state=active]:shadow-sm group-data-[variant=line]/tabs-list:data-[state=active]:shadow-none dark:text-muted-foreground dark:hover:text-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
"group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-[state=active]:bg-transparent dark:group-data-[variant=line]/tabs-list:data-[state=active]:border-transparent dark:group-data-[variant=line]/tabs-list:data-[state=active]:bg-transparent",
|
||||||
|
"data-[state=active]:bg-background data-[state=active]:text-foreground dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 dark:data-[state=active]:text-foreground",
|
||||||
|
"after:absolute after:bg-foreground after:opacity-0 after:transition-opacity group-data-[orientation=horizontal]/tabs:after:inset-x-0 group-data-[orientation=horizontal]/tabs:after:bottom-[-5px] group-data-[orientation=horizontal]/tabs:after:h-0.5 group-data-[orientation=vertical]/tabs:after:inset-y-0 group-data-[orientation=vertical]/tabs:after:-right-1 group-data-[orientation=vertical]/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-[state=active]:after:opacity-100",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TabsContent({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof TabsPrimitive.Content>) {
|
||||||
|
return (
|
||||||
|
<TabsPrimitive.Content
|
||||||
|
data-slot="tabs-content"
|
||||||
|
className={cn("flex-1 outline-none", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants }
|
||||||
105
src/lib/logistics.ts
Normal file
105
src/lib/logistics.ts
Normal file
|
|
@ -0,0 +1,105 @@
|
||||||
|
import { GameStructure, Resource, Structure } from "@/payload-types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate the total mass of all resources stored in a game structure.
|
||||||
|
* Requires resources to be populated (depth >= 1 on storedResources.resource).
|
||||||
|
*/
|
||||||
|
export function calculateStoredMass(storedResources: GameStructure["storedResources"]): number {
|
||||||
|
if (!storedResources) return 0;
|
||||||
|
|
||||||
|
return 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate the storage utilization percentage for a game structure.
|
||||||
|
* Returns a value between 0 and 100 (or null if maxCapacityMass is not set).
|
||||||
|
*/
|
||||||
|
export function calculateUtilization(
|
||||||
|
structure: Structure,
|
||||||
|
storedResources: GameStructure["storedResources"],
|
||||||
|
): number | null {
|
||||||
|
const maxMass = structure.maxCapacityMass;
|
||||||
|
if (!maxMass || maxMass <= 0) return null;
|
||||||
|
|
||||||
|
const currentMass = calculateStoredMass(storedResources);
|
||||||
|
return Math.min((currentMass / maxMass) * 100, 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the total number of distinct resources stored.
|
||||||
|
*/
|
||||||
|
export function getResourceCount(storedResources: GameStructure["storedResources"]): number {
|
||||||
|
if (!storedResources) return 0;
|
||||||
|
return storedResources.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the total grid slots available (xSize * ySize), or null if not defined.
|
||||||
|
*/
|
||||||
|
export function getGridSlots(structure: Structure): number | null {
|
||||||
|
const x = structure.xSize;
|
||||||
|
const y = structure.ySize;
|
||||||
|
if (!x || !y) return null;
|
||||||
|
return x * y;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map a resource type to a fallback icon name (used when no icon upload is set).
|
||||||
|
*/
|
||||||
|
export function getResourceTypeLabel(type: Resource["type"]): string {
|
||||||
|
switch (type) {
|
||||||
|
case "physical":
|
||||||
|
return "Physical";
|
||||||
|
case "fluid":
|
||||||
|
return "Fluid";
|
||||||
|
case "energy":
|
||||||
|
return "Energy";
|
||||||
|
case "abstract":
|
||||||
|
return "Abstract";
|
||||||
|
default:
|
||||||
|
return "Resource";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map a structure category to a display-friendly label.
|
||||||
|
*/
|
||||||
|
export function getCategoryLabel(category: Structure["category"]): string {
|
||||||
|
switch (category) {
|
||||||
|
case "logistics":
|
||||||
|
return "Logistics";
|
||||||
|
case "military":
|
||||||
|
return "Military";
|
||||||
|
case "civilian":
|
||||||
|
return "Civilian";
|
||||||
|
default:
|
||||||
|
return category;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map a structure functionType to a display-friendly label.
|
||||||
|
*/
|
||||||
|
export function getFunctionTypeLabel(functionType: Structure["functionType"]): string | null {
|
||||||
|
if (!functionType) return null;
|
||||||
|
switch (functionType) {
|
||||||
|
case "producer":
|
||||||
|
return "Producer";
|
||||||
|
case "storage":
|
||||||
|
return "Storage";
|
||||||
|
case "sensor":
|
||||||
|
return "Sensor";
|
||||||
|
case "converter":
|
||||||
|
return "Converter";
|
||||||
|
case "defense":
|
||||||
|
return "Defense";
|
||||||
|
case "miscellaneous":
|
||||||
|
return "Miscellaneous";
|
||||||
|
default:
|
||||||
|
return functionType;
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue