feat(storage): split storage permissions and rework manage-storage UI
Deposits/withdrawals of stock (economy-mutating) are now superuser-only, while moving items around (transfer/place/collect) stays open to logistics-qualified users; seed roles updated to match. ManageStorageDialog reworked alongside its toolbar/button/view wrappers, and the structure page wires up the new permission split.
This commit is contained in:
parent
91dc48ad2c
commit
dcb4893b10
7 changed files with 457 additions and 313 deletions
|
|
@ -21,6 +21,7 @@ import { pluralize } from "@/lib/utils";
|
|||
import type { LaborClassificationOption, StructureStaffingOption } from "@/lib/base";
|
||||
import { currencyConfigFromGameRules } from "@/lib/banking";
|
||||
import { hasLogisticsQualification } from "@/utils/access-control/hasLogisticsQualification";
|
||||
import { isSuperuser } from "@/utils/access-control/hasPermission";
|
||||
import {
|
||||
ArrowLeftIcon,
|
||||
BoxIcon,
|
||||
|
|
@ -183,6 +184,8 @@ export default async function StructureDetailPage({ params }: StructureDetailPag
|
|||
};
|
||||
});
|
||||
const canManage = await hasLogisticsQualification(payload, user);
|
||||
const canManageStorage = await isSuperuser(payload, user);
|
||||
const canTransferStorage = canManageStorage || canManage;
|
||||
|
||||
const classificationsRes = await payload.find({
|
||||
collection: "labor-classifications",
|
||||
|
|
@ -307,7 +310,13 @@ export default async function StructureDetailPage({ params }: StructureDetailPag
|
|||
{structure.faction.name}
|
||||
</span>
|
||||
)}
|
||||
{user && <ManageStorageButton structureId={structure.id} />}
|
||||
{user && (
|
||||
<ManageStorageButton
|
||||
structureId={structure.id}
|
||||
canManage={canManageStorage}
|
||||
canTransfer={canTransferStorage}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
|
@ -413,7 +422,11 @@ export default async function StructureDetailPage({ params }: StructureDetailPag
|
|||
currencyConfig={currencyConfig}
|
||||
/>
|
||||
|
||||
<StructureStorageView structure={structure} />
|
||||
<StructureStorageView
|
||||
structure={structure}
|
||||
canMutate={canManageStorage}
|
||||
canArrange={canTransferStorage}
|
||||
/>
|
||||
|
||||
<StorageRules
|
||||
allowedStorage={structureType.allowedStorage}
|
||||
|
|
|
|||
|
|
@ -3,7 +3,8 @@
|
|||
import config from "@payload-config";
|
||||
import { getPayload } from "payload";
|
||||
import { GameStructure, Resource, Structure } from "@/payload-types";
|
||||
import { hasPermission } from "@/utils/access-control/hasPermission";
|
||||
import { isSuperuser } from "@/utils/access-control/hasPermission";
|
||||
import { hasLogisticsQualification } from "@/utils/access-control/hasLogisticsQualification";
|
||||
import { emitGameEvent } from "@/utils/event-log/emit";
|
||||
import { EventTypes } from "@/utils/event-log/eventTypes";
|
||||
import { checkStorageDeposit, storageViolationMessage } from "@/lib/storageRules";
|
||||
|
|
@ -14,6 +15,19 @@ interface ActionResult {
|
|||
data?: GameStructure;
|
||||
}
|
||||
|
||||
type PayloadClient = Awaited<ReturnType<typeof getPayload>>;
|
||||
type AuthedUser = Awaited<ReturnType<typeof authenticate>>["user"];
|
||||
|
||||
// SECURITY: creates/destroys stored items, overriding economy/production — superusers only
|
||||
async function canManageStorage(payload: PayloadClient, user: AuthedUser): Promise<boolean> {
|
||||
return isSuperuser(payload, user);
|
||||
}
|
||||
|
||||
// SECURITY: conserves quantities (moves items around) — logistics personnel + superusers
|
||||
async function canHandleStorage(payload: PayloadClient, user: AuthedUser): Promise<boolean> {
|
||||
return (await isSuperuser(payload, user)) || (await hasLogisticsQualification(payload, user));
|
||||
}
|
||||
|
||||
async function authenticate() {
|
||||
const payloadConfig = await config;
|
||||
const payload = await getPayload({ config: payloadConfig });
|
||||
|
|
@ -74,13 +88,30 @@ export async function addResource(
|
|||
): Promise<ActionResult> {
|
||||
try {
|
||||
const { payload, user } = await authenticate();
|
||||
if (!(await hasPermission(payload, user, "structures:update"))) {
|
||||
if (!(await canManageStorage(payload, user))) {
|
||||
return {
|
||||
success: false,
|
||||
error: "Insufficient permissions. User, Admin, or Developer role required.",
|
||||
error: "Only superusers may deposit resources into structures.",
|
||||
};
|
||||
}
|
||||
return await addResourceInternal(payload, user, structureId, resourceId, amount);
|
||||
} 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 };
|
||||
}
|
||||
}
|
||||
|
||||
async function addResourceInternal(
|
||||
payload: PayloadClient,
|
||||
user: AuthedUser,
|
||||
structureId: number,
|
||||
resourceId: number,
|
||||
amount: number,
|
||||
): Promise<ActionResult> {
|
||||
try {
|
||||
if (amount <= 0) {
|
||||
return { success: false, error: "Amount must be greater than zero." };
|
||||
}
|
||||
|
|
@ -264,13 +295,30 @@ export async function removeResource(
|
|||
): Promise<ActionResult> {
|
||||
try {
|
||||
const { payload, user } = await authenticate();
|
||||
if (!(await hasPermission(payload, user, "structures:update"))) {
|
||||
if (!(await canManageStorage(payload, user))) {
|
||||
return {
|
||||
success: false,
|
||||
error: "Insufficient permissions. Admin or Developer role required for withdrawal.",
|
||||
error: "Only superusers may withdraw resources from structures.",
|
||||
};
|
||||
}
|
||||
return await removeResourceInternal(payload, user, structureId, resourceId, amount);
|
||||
} 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 };
|
||||
}
|
||||
}
|
||||
|
||||
async function removeResourceInternal(
|
||||
payload: PayloadClient,
|
||||
user: AuthedUser,
|
||||
structureId: number,
|
||||
resourceId: number,
|
||||
amount: number,
|
||||
): Promise<ActionResult> {
|
||||
try {
|
||||
if (amount <= 0) {
|
||||
return { success: false, error: "Amount must be greater than zero." };
|
||||
}
|
||||
|
|
@ -373,10 +421,10 @@ export async function transferResource(
|
|||
): Promise<ActionResult> {
|
||||
try {
|
||||
const { payload, user } = await authenticate();
|
||||
if (!(await hasPermission(payload, user, "structures:update"))) {
|
||||
if (!(await canHandleStorage(payload, user))) {
|
||||
return {
|
||||
success: false,
|
||||
error: "Insufficient permissions. User, Admin, or Developer role required for transfer.",
|
||||
error: "Logistics personnel or superuser role required for transfers.",
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -464,17 +512,23 @@ export async function transferResource(
|
|||
};
|
||||
}
|
||||
|
||||
// Remove from source
|
||||
const removeResult = await removeResource(fromStructureId, resourceId, amount);
|
||||
// Remove from source (unguarded internal — gate already applied above)
|
||||
const removeResult = await removeResourceInternal(
|
||||
payload,
|
||||
user,
|
||||
fromStructureId,
|
||||
resourceId,
|
||||
amount,
|
||||
);
|
||||
if (!removeResult.success) {
|
||||
return removeResult;
|
||||
}
|
||||
|
||||
// Add to destination
|
||||
const addResult = await addResource(toStructureId, resourceId, amount);
|
||||
const addResult = await addResourceInternal(payload, user, toStructureId, resourceId, amount);
|
||||
if (!addResult.success) {
|
||||
// Rollback: re-add to source
|
||||
await addResource(fromStructureId, resourceId, amount);
|
||||
await addResourceInternal(payload, user, fromStructureId, resourceId, amount);
|
||||
return { success: false, error: `Transfer failed at destination: ${addResult.error}` };
|
||||
}
|
||||
|
||||
|
|
@ -524,10 +578,10 @@ export async function placeResourceOnGrid(
|
|||
): Promise<GridActionResult> {
|
||||
try {
|
||||
const { payload, user } = await authenticate();
|
||||
if (!(await hasPermission(payload, user, "structures:update"))) {
|
||||
if (!(await canManageStorage(payload, user))) {
|
||||
return {
|
||||
success: false,
|
||||
error: "Insufficient permissions. Admin or Developer role required for placement.",
|
||||
error: "Only superusers may place resources on the storage grid.",
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -641,10 +695,10 @@ export async function moveGridItem(
|
|||
): Promise<GridActionResult> {
|
||||
try {
|
||||
const { payload, user } = await authenticate();
|
||||
if (!(await hasPermission(payload, user, "structures:update"))) {
|
||||
if (!(await canHandleStorage(payload, user))) {
|
||||
return {
|
||||
success: false,
|
||||
error: "Insufficient permissions. User, Admin, or Developer role required.",
|
||||
error: "Logistics personnel or superuser role required.",
|
||||
};
|
||||
}
|
||||
const structure = await getStructure(payload, structureId);
|
||||
|
|
@ -730,10 +784,10 @@ export async function mergeGridStacks(
|
|||
): Promise<GridActionResult> {
|
||||
try {
|
||||
const { payload, user } = await authenticate();
|
||||
if (!(await hasPermission(payload, user, "structures:update"))) {
|
||||
if (!(await canHandleStorage(payload, user))) {
|
||||
return {
|
||||
success: false,
|
||||
error: "Insufficient permissions. User, Admin, or Developer role required.",
|
||||
error: "Logistics personnel or superuser role required.",
|
||||
};
|
||||
}
|
||||
const structure = await getStructure(payload, structureId);
|
||||
|
|
@ -812,10 +866,10 @@ export async function rotateGridItem(
|
|||
): Promise<GridActionResult> {
|
||||
try {
|
||||
const { payload, user } = await authenticate();
|
||||
if (!(await hasPermission(payload, user, "structures:update"))) {
|
||||
if (!(await canHandleStorage(payload, user))) {
|
||||
return {
|
||||
success: false,
|
||||
error: "Insufficient permissions. User, Admin, or Developer role required.",
|
||||
error: "Logistics personnel or superuser role required.",
|
||||
};
|
||||
}
|
||||
const structure = await getStructure(payload, structureId);
|
||||
|
|
@ -899,10 +953,10 @@ export async function removeGridItem(
|
|||
): Promise<GridActionResult> {
|
||||
try {
|
||||
const { payload, user } = await authenticate();
|
||||
if (!(await hasPermission(payload, user, "structures:update"))) {
|
||||
if (!(await canManageStorage(payload, user))) {
|
||||
return {
|
||||
success: false,
|
||||
error: "Insufficient permissions. Admin or Developer role required for removal.",
|
||||
error: "Only superusers may remove items from the storage grid.",
|
||||
};
|
||||
}
|
||||
const structure = await getStructure(payload, structureId);
|
||||
|
|
@ -953,10 +1007,10 @@ export async function splitGridStack(
|
|||
): Promise<GridActionResult> {
|
||||
try {
|
||||
const { payload, user } = await authenticate();
|
||||
if (!(await hasPermission(payload, user, "structures:update"))) {
|
||||
if (!(await canHandleStorage(payload, user))) {
|
||||
return {
|
||||
success: false,
|
||||
error: "Insufficient permissions. User, Admin, or Developer role required.",
|
||||
error: "Logistics personnel or superuser role required.",
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -1061,8 +1115,8 @@ export async function splitGridStack(
|
|||
export async function retrieveFromVoid(structureId: number): Promise<GridActionResult> {
|
||||
try {
|
||||
const { payload, user } = await authenticate();
|
||||
if (!(await hasPermission(payload, user, "structures:update"))) {
|
||||
return { success: false, error: "Insufficient permissions." };
|
||||
if (!(await canHandleStorage(payload, user))) {
|
||||
return { success: false, error: "Logistics personnel or superuser role required." };
|
||||
}
|
||||
const structure = await getStructure(payload, structureId);
|
||||
const gridW = structure.type.xSize ?? 0;
|
||||
|
|
@ -1159,10 +1213,10 @@ export async function retrieveFromVoid(structureId: number): Promise<GridActionR
|
|||
export async function autoArrangeGrid(structureId: number): Promise<GridActionResult> {
|
||||
try {
|
||||
const { payload, user } = await authenticate();
|
||||
if (!(await hasPermission(payload, user, "structures:update"))) {
|
||||
if (!(await canHandleStorage(payload, user))) {
|
||||
return {
|
||||
success: false,
|
||||
error: "Insufficient permissions. User, Admin, or Developer role required.",
|
||||
error: "Logistics personnel or superuser role required.",
|
||||
};
|
||||
}
|
||||
const structure = await getStructure(payload, structureId);
|
||||
|
|
|
|||
|
|
@ -24,6 +24,10 @@ interface GridToolbarProps {
|
|||
onRotate: (entryId: string) => Promise<void>;
|
||||
onRemove: (entryId: string) => Promise<void>;
|
||||
onSplit: (entryId: string, amount: number) => Promise<void>;
|
||||
/** Rearranging/rotating/splitting conserves quantities — logistics personnel or superuser. */
|
||||
canArrange?: boolean;
|
||||
/** Removing grid items destroys stored stock — superuser only. */
|
||||
canMutate?: boolean;
|
||||
}
|
||||
|
||||
export function GridToolbar({
|
||||
|
|
@ -37,6 +41,8 @@ export function GridToolbar({
|
|||
onRotate,
|
||||
onRemove,
|
||||
onSplit,
|
||||
canArrange = false,
|
||||
canMutate = false,
|
||||
}: GridToolbarProps) {
|
||||
const occupancy = getGridOccupancy(storedResources, gridWidth, gridHeight);
|
||||
const res = selectedItem ? (selectedItem.resource as Resource) : null;
|
||||
|
|
@ -50,10 +56,12 @@ export function GridToolbar({
|
|||
<div className="flex flex-col gap-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" onClick={onAutoArrange}>
|
||||
<ShuffleIcon className="size-3.5 mr-1" />
|
||||
Auto-Arrange
|
||||
</Button>
|
||||
{canArrange && (
|
||||
<Button variant="outline" size="sm" onClick={onAutoArrange}>
|
||||
<ShuffleIcon className="size-3.5 mr-1" />
|
||||
Auto-Arrange
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
|
|
@ -86,12 +94,12 @@ export function GridToolbar({
|
|||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
{res.rotatable !== false && (
|
||||
{canArrange && res.rotatable !== false && (
|
||||
<Button variant="outline" size="sm" onClick={() => onRotate(selectedItem.id)}>
|
||||
<RotateCwIcon className="size-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
{res.stackable && selectedItem.amount > 1 && (
|
||||
{canArrange && res.stackable && selectedItem.amount > 1 && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
|
|
@ -101,9 +109,11 @@ export function GridToolbar({
|
|||
<ScissorsIcon className="size-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="outline" size="sm" onClick={() => onRemove(selectedItem.id)}>
|
||||
<TrashIcon className="size-3.5" />
|
||||
</Button>
|
||||
{canMutate && (
|
||||
<Button variant="outline" size="sm" onClick={() => onRemove(selectedItem.id)}>
|
||||
<TrashIcon className="size-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -7,18 +7,32 @@ import { ManageStorageDialog } from "@/components/frontend/storage/ManageStorage
|
|||
|
||||
interface ManageStorageButtonProps {
|
||||
structureId: number;
|
||||
canManage: boolean;
|
||||
canTransfer: boolean;
|
||||
}
|
||||
|
||||
export function ManageStorageButton({ structureId }: ManageStorageButtonProps) {
|
||||
export function ManageStorageButton({
|
||||
structureId,
|
||||
canManage,
|
||||
canTransfer,
|
||||
}: ManageStorageButtonProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
if (!canManage && !canTransfer) return null;
|
||||
|
||||
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} />
|
||||
<ManageStorageDialog
|
||||
structureId={structureId}
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
canManage={canManage}
|
||||
canTransfer={canTransfer}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -46,9 +46,17 @@ interface ManageStorageDialogProps {
|
|||
structureId: number;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
canManage: boolean;
|
||||
canTransfer: boolean;
|
||||
}
|
||||
|
||||
export function ManageStorageDialog({ structureId, open, onOpenChange }: ManageStorageDialogProps) {
|
||||
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[]>([]);
|
||||
|
|
@ -641,265 +649,288 @@ export function ManageStorageDialog({ structureId, open, onOpenChange }: ManageS
|
|||
</div>
|
||||
</div>
|
||||
)}
|
||||
<Tabs defaultValue="deposit" className="w-full">
|
||||
<TabsList className="grid w-full grid-cols-3">
|
||||
<TabsTrigger value="deposit" className="flex items-center gap-1.5">
|
||||
<ArrowDownToLineIcon className="size-3.5" />
|
||||
Deposit
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="withdraw" className="flex items-center gap-1.5">
|
||||
<ArrowUpFromLineIcon className="size-3.5" />
|
||||
Withdraw
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="transfer" className="flex items-center gap-1.5">
|
||||
<ArrowRightLeftIcon className="size-3.5" />
|
||||
Transfer
|
||||
</TabsTrigger>
|
||||
<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>
|
||||
|
||||
<TabsContent value="deposit" className="flex flex-col gap-4 pt-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label>Resource</Label>
|
||||
<Select
|
||||
value={depositResourceId}
|
||||
onValueChange={(val) => {
|
||||
setDepositResourceId(val);
|
||||
setDepositAmount(
|
||||
clampAmount(depositAmount, getDepositLimitInfoFor(val)?.max),
|
||||
);
|
||||
}}
|
||||
{canManage && (
|
||||
<TabsContent value="deposit" className="flex flex-col gap-4 pt-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label>Resource</Label>
|
||||
<Select
|
||||
value={depositResourceId}
|
||||
onValueChange={(val) => {
|
||||
setDepositResourceId(val);
|
||||
setDepositAmount(
|
||||
clampAmount(depositAmount, getDepositLimitInfoFor(val)?.max),
|
||||
);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a resource..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{allResources
|
||||
.filter((r) => isResourceAllowed(r.id))
|
||||
.map((resource) => {
|
||||
const cap = getAllowedDepositCap(resource.id);
|
||||
return (
|
||||
<SelectItem key={resource.id} value={String(resource.id)}>
|
||||
{resource.name}
|
||||
{cap !== undefined && (
|
||||
<span className="text-muted-foreground">
|
||||
{" "}
|
||||
({cap.toLocaleString()} max)
|
||||
</span>
|
||||
)}
|
||||
</SelectItem>
|
||||
);
|
||||
})}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label>Amount</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min="1"
|
||||
max={getDepositMax()}
|
||||
value={depositAmount}
|
||||
onChange={(e) =>
|
||||
setDepositAmount(clampAmount(e.target.value, getDepositMax()))
|
||||
}
|
||||
/>
|
||||
{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"
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a resource..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{allResources
|
||||
.filter((r) => isResourceAllowed(r.id))
|
||||
.map((resource) => {
|
||||
const cap = getAllowedDepositCap(resource.id);
|
||||
{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>
|
||||
<Select
|
||||
value={withdrawResourceId}
|
||||
onValueChange={(val) => {
|
||||
setWithdrawResourceId(val);
|
||||
const stored = findStoredResource(val);
|
||||
setWithdrawAmount(clampAmount(withdrawAmount, stored?.amount));
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a resource..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{storedResources.map((entry) => {
|
||||
const resource = entry.resource as Resource;
|
||||
if (!resource || typeof resource === "number") return null;
|
||||
return (
|
||||
<SelectItem key={resource.id} value={String(resource.id)}>
|
||||
{resource.name}
|
||||
{cap !== undefined && (
|
||||
<span className="text-muted-foreground">
|
||||
{" "}
|
||||
({cap.toLocaleString()} max)
|
||||
</span>
|
||||
)}
|
||||
<SelectItem key={entry.id ?? resource.id} value={String(resource.id)}>
|
||||
{resource.name} ({entry.amount.toLocaleString()}{" "}
|
||||
{pluralize(entry.amount, resource.unitOfMeasure)})
|
||||
</SelectItem>
|
||||
);
|
||||
})}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label>Amount</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min="1"
|
||||
max={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>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label>Amount</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min="1"
|
||||
max={getWithdrawMax()}
|
||||
value={withdrawAmount}
|
||||
onChange={(e) =>
|
||||
setWithdrawAmount(clampAmount(e.target.value, getWithdrawMax()))
|
||||
}
|
||||
/>
|
||||
{getWithdrawMax() !== undefined && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Max: {getWithdrawMax()!.toLocaleString()} stored
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{renderCapacityPreview("withdraw")}
|
||||
<Button
|
||||
onClick={handleWithdraw}
|
||||
disabled={isPending || !withdrawResourceId}
|
||||
className="w-full"
|
||||
variant="destructive"
|
||||
>
|
||||
{isPending ? (
|
||||
<Loader2Icon className="size-4 animate-spin mr-2" />
|
||||
) : (
|
||||
<ArrowUpFromLineIcon className="size-4 mr-2" />
|
||||
)}
|
||||
Withdraw
|
||||
</Button>
|
||||
</TabsContent>
|
||||
)}
|
||||
|
||||
<TabsContent value="withdraw" className="flex flex-col gap-4 pt-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label>Resource</Label>
|
||||
<Select
|
||||
value={withdrawResourceId}
|
||||
onValueChange={(val) => {
|
||||
setWithdrawResourceId(val);
|
||||
const stored = findStoredResource(val);
|
||||
setWithdrawAmount(clampAmount(withdrawAmount, stored?.amount));
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a resource..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{storedResources.map((entry) => {
|
||||
const resource = entry.resource as Resource;
|
||||
if (!resource || typeof resource === "number") return null;
|
||||
return (
|
||||
<SelectItem key={entry.id ?? resource.id} value={String(resource.id)}>
|
||||
{resource.name} ({entry.amount.toLocaleString()}{" "}
|
||||
{pluralize(entry.amount, resource.unitOfMeasure)})
|
||||
{canTransfer && (
|
||||
<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={(val) => {
|
||||
setTransferToId(val);
|
||||
setTransferAmount(clampAmount(transferAmount, getTransferMaxFor(val)));
|
||||
}}
|
||||
>
|
||||
<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>Amount</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min="1"
|
||||
max={getWithdrawMax()}
|
||||
value={withdrawAmount}
|
||||
onChange={(e) =>
|
||||
setWithdrawAmount(clampAmount(e.target.value, getWithdrawMax()))
|
||||
}
|
||||
/>
|
||||
{getWithdrawMax() !== undefined && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Max: {getWithdrawMax()!.toLocaleString()} stored
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{renderCapacityPreview("withdraw")}
|
||||
<Button
|
||||
onClick={handleWithdraw}
|
||||
disabled={isPending || !withdrawResourceId}
|
||||
className="w-full"
|
||||
variant="destructive"
|
||||
>
|
||||
{isPending ? (
|
||||
<Loader2Icon className="size-4 animate-spin mr-2" />
|
||||
) : (
|
||||
<ArrowUpFromLineIcon className="size-4 mr-2" />
|
||||
)}
|
||||
Withdraw
|
||||
</Button>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="transfer" className="flex flex-col gap-4 pt-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label>Destination</Label>
|
||||
<Select
|
||||
value={transferToId}
|
||||
onValueChange={(val) => {
|
||||
setTransferToId(val);
|
||||
setTransferAmount(clampAmount(transferAmount, getTransferMaxFor(val)));
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a structure..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{allStructures.map((s) => (
|
||||
<SelectItem key={s.id} value={String(s.id)}>
|
||||
{s.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label>Resource</Label>
|
||||
<Select
|
||||
value={transferResourceId}
|
||||
onValueChange={(val) => {
|
||||
setTransferResourceId(val);
|
||||
const stored = findStoredResource(val);
|
||||
setTransferAmount(clampAmount(transferAmount, stored?.amount));
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a resource..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{storedResources.map((entry) => {
|
||||
const resource = entry.resource as Resource;
|
||||
if (!resource || typeof resource === "number") return null;
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label>Resource</Label>
|
||||
<Select
|
||||
value={transferResourceId}
|
||||
onValueChange={(val) => {
|
||||
setTransferResourceId(val);
|
||||
const stored = findStoredResource(val);
|
||||
setTransferAmount(clampAmount(transferAmount, stored?.amount));
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a resource..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{storedResources.map((entry) => {
|
||||
const resource = entry.resource as Resource;
|
||||
if (!resource || typeof resource === "number") return null;
|
||||
return (
|
||||
<SelectItem key={entry.id ?? resource.id} value={String(resource.id)}>
|
||||
{resource.name} ({entry.amount.toLocaleString()}{" "}
|
||||
{pluralize(entry.amount, resource.unitOfMeasure)})
|
||||
</SelectItem>
|
||||
);
|
||||
})}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label>Amount</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min="1"
|
||||
max={getTransferMax()}
|
||||
value={transferAmount}
|
||||
onChange={(e) =>
|
||||
setTransferAmount(clampAmount(e.target.value, getTransferMax()))
|
||||
}
|
||||
/>
|
||||
{getTransferMax() !== undefined && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Max: {getTransferMax()!.toLocaleString()} stored
|
||||
</span>
|
||||
)}
|
||||
{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 (
|
||||
<SelectItem key={entry.id ?? resource.id} value={String(resource.id)}>
|
||||
{resource.name} ({entry.amount.toLocaleString()}{" "}
|
||||
{pluralize(entry.amount, resource.unitOfMeasure)})
|
||||
</SelectItem>
|
||||
);
|
||||
})}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label>Amount</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min="1"
|
||||
max={getTransferMax()}
|
||||
value={transferAmount}
|
||||
onChange={(e) =>
|
||||
setTransferAmount(clampAmount(e.target.value, getTransferMax()))
|
||||
}
|
||||
/>
|
||||
{getTransferMax() !== undefined && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Max: {getTransferMax()!.toLocaleString()} stored
|
||||
</span>
|
||||
)}
|
||||
{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 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>
|
||||
);
|
||||
}
|
||||
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>
|
||||
})()}
|
||||
</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>
|
||||
</>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -45,9 +45,17 @@ function getResourceIcon(type: Resource["type"]) {
|
|||
|
||||
interface StructureStorageViewProps {
|
||||
structure: GameStructure & { type: Structure };
|
||||
/** Rearranging/rotating/splitting conserves quantities — logistics personnel or superuser. */
|
||||
canArrange?: boolean;
|
||||
/** Removing grid items / withdrawing destroys stored stock — superuser only. */
|
||||
canMutate?: boolean;
|
||||
}
|
||||
|
||||
export function StructureStorageView({ structure }: StructureStorageViewProps) {
|
||||
export function StructureStorageView({
|
||||
structure,
|
||||
canArrange = false,
|
||||
canMutate = false,
|
||||
}: StructureStorageViewProps) {
|
||||
const router = useRouter();
|
||||
const [viewMode, setViewMode] = useState<"grid" | "list">("grid");
|
||||
const [selectedItem, setSelectedItem] = useState<GridItem | null>(null);
|
||||
|
|
@ -80,64 +88,72 @@ export function StructureStorageView({ structure }: StructureStorageViewProps) {
|
|||
|
||||
const handleMoveItem = useCallback(
|
||||
async (entryId: string, gridX: number, gridY: number, rotated?: boolean) => {
|
||||
if (!canArrange) return;
|
||||
const result = await moveGridItem(structure.id, entryId, gridX, gridY, rotated);
|
||||
if (result.success) refresh();
|
||||
},
|
||||
[structure.id, refresh],
|
||||
[structure.id, refresh, canArrange],
|
||||
);
|
||||
|
||||
const handleMergeStacks = useCallback(
|
||||
async (sourceId: string, targetId: string) => {
|
||||
if (!canArrange) return;
|
||||
const result = await mergeGridStacks(structure.id, sourceId, targetId);
|
||||
if (result.success) refresh();
|
||||
},
|
||||
[structure.id, refresh],
|
||||
[structure.id, refresh, canArrange],
|
||||
);
|
||||
|
||||
const handleRotateItem = useCallback(
|
||||
async (entryId: string) => {
|
||||
if (!canArrange) return;
|
||||
const result = await rotateGridItem(structure.id, entryId);
|
||||
if (result.success) refresh();
|
||||
},
|
||||
[structure.id, refresh],
|
||||
[structure.id, refresh, canArrange],
|
||||
);
|
||||
|
||||
const handleRemoveItem = useCallback(
|
||||
async (entryId: string) => {
|
||||
if (!canMutate) return;
|
||||
const result = await removeGridItem(structure.id, entryId);
|
||||
if (result.success) {
|
||||
setSelectedItem(null);
|
||||
refresh();
|
||||
}
|
||||
},
|
||||
[structure.id, refresh],
|
||||
[structure.id, refresh, canMutate],
|
||||
);
|
||||
|
||||
const handleSplitStack = useCallback(
|
||||
async (entryId: string, amount: number) => {
|
||||
if (!canArrange) return;
|
||||
const result = await splitGridStack(structure.id, entryId, amount);
|
||||
if (result.success) refresh();
|
||||
},
|
||||
[structure.id, refresh],
|
||||
[structure.id, refresh, canArrange],
|
||||
);
|
||||
|
||||
const handleAutoArrange = useCallback(async () => {
|
||||
if (!canArrange) return;
|
||||
const result = await autoArrangeGrid(structure.id);
|
||||
if (result.success) refresh();
|
||||
}, [structure.id, refresh]);
|
||||
}, [structure.id, refresh, canArrange]);
|
||||
|
||||
const handleWithdrawFromVoid = useCallback(
|
||||
async (resourceId: number, amount: number) => {
|
||||
if (!canMutate) return;
|
||||
const result = await removeResource(structure.id, resourceId, amount);
|
||||
if (result.success) refresh();
|
||||
},
|
||||
[structure.id, refresh],
|
||||
[structure.id, refresh, canMutate],
|
||||
);
|
||||
|
||||
const handleRetrieveFromVoid = useCallback(async () => {
|
||||
if (!canArrange) return;
|
||||
const result = await retrieveFromVoid(structure.id);
|
||||
if (result.success) refresh();
|
||||
}, [structure.id, refresh]);
|
||||
}, [structure.id, refresh, canArrange]);
|
||||
|
||||
if (!hasGrid) {
|
||||
return (
|
||||
|
|
@ -179,6 +195,8 @@ export function StructureStorageView({ structure }: StructureStorageViewProps) {
|
|||
onRotate={handleRotateItem}
|
||||
onRemove={handleRemoveItem}
|
||||
onSplit={handleSplitStack}
|
||||
canArrange={canArrange}
|
||||
canMutate={canMutate}
|
||||
/>
|
||||
|
||||
{viewMode === "grid" ? (
|
||||
|
|
@ -213,13 +231,15 @@ export function StructureStorageView({ structure }: StructureStorageViewProps) {
|
|||
{voidStorageItems.length} item{voidStorageItems.length !== 1 ? "s" : ""}: these no
|
||||
longer fit in the grid.
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleRetrieveFromVoid}
|
||||
className="text-xs bg-amber-500/10 text-amber-400 hover:bg-amber-500/20 px-2 py-0.5 rounded transition-colors"
|
||||
>
|
||||
Retrieve All to Grid
|
||||
</button>
|
||||
{canArrange && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleRetrieveFromVoid}
|
||||
className="text-xs bg-amber-500/10 text-amber-400 hover:bg-amber-500/20 px-2 py-0.5 rounded transition-colors"
|
||||
>
|
||||
Retrieve All to Grid
|
||||
</button>
|
||||
)}
|
||||
</ItemDescription>
|
||||
</ItemHeader>
|
||||
<ItemContent>
|
||||
|
|
@ -262,14 +282,16 @@ export function StructureStorageView({ structure }: StructureStorageViewProps) {
|
|||
{mass > 0 && `${mass.toLocaleString()} kg`}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleWithdrawFromVoid(entry.resourceId, entry.amount)}
|
||||
className="text-xs text-muted-foreground hover:text-foreground px-2 py-1 rounded transition-colors shrink-0"
|
||||
title="Withdraw from structure"
|
||||
>
|
||||
Withdraw
|
||||
</button>
|
||||
{canMutate && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleWithdrawFromVoid(entry.resourceId, entry.amount)}
|
||||
className="text-xs text-muted-foreground hover:text-foreground px-2 py-1 rounded transition-colors shrink-0"
|
||||
title="Withdraw from structure"
|
||||
>
|
||||
Withdraw
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{index < voidStorageItems.length - 1 && <ItemSeparator />}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -53,7 +53,6 @@ const USER_PERMISSIONS: Permission[] = [
|
|||
"shims:read",
|
||||
"shipments:create",
|
||||
"shipments:update",
|
||||
"structures:update",
|
||||
"structures:delete",
|
||||
];
|
||||
|
||||
|
|
@ -87,6 +86,7 @@ const ADMIN_PERMISSIONS: Permission[] = [
|
|||
"discord:staff",
|
||||
"discord:announce",
|
||||
"structures:create",
|
||||
"structures:update",
|
||||
...ALL_COLLECTION_READS,
|
||||
...ALL_ADMIN_PAGE_MANAGE,
|
||||
]),
|
||||
|
|
|
|||
Loading…
Reference in a new issue