1
0
Fork 0

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:
Jason Fraley 2026-09-07 05:07:53 -04:00
parent 91dc48ad2c
commit dcb4893b10
7 changed files with 457 additions and 313 deletions

View file

@ -21,6 +21,7 @@ import { pluralize } from "@/lib/utils";
import type { LaborClassificationOption, StructureStaffingOption } from "@/lib/base"; import type { LaborClassificationOption, StructureStaffingOption } from "@/lib/base";
import { currencyConfigFromGameRules } from "@/lib/banking"; import { currencyConfigFromGameRules } from "@/lib/banking";
import { hasLogisticsQualification } from "@/utils/access-control/hasLogisticsQualification"; import { hasLogisticsQualification } from "@/utils/access-control/hasLogisticsQualification";
import { isSuperuser } from "@/utils/access-control/hasPermission";
import { import {
ArrowLeftIcon, ArrowLeftIcon,
BoxIcon, BoxIcon,
@ -183,6 +184,8 @@ export default async function StructureDetailPage({ params }: StructureDetailPag
}; };
}); });
const canManage = await hasLogisticsQualification(payload, user); const canManage = await hasLogisticsQualification(payload, user);
const canManageStorage = await isSuperuser(payload, user);
const canTransferStorage = canManageStorage || canManage;
const classificationsRes = await payload.find({ const classificationsRes = await payload.find({
collection: "labor-classifications", collection: "labor-classifications",
@ -307,7 +310,13 @@ export default async function StructureDetailPage({ params }: StructureDetailPag
{structure.faction.name} {structure.faction.name}
</span> </span>
)} )}
{user && <ManageStorageButton structureId={structure.id} />} {user && (
<ManageStorageButton
structureId={structure.id}
canManage={canManageStorage}
canTransfer={canTransferStorage}
/>
)}
</div> </div>
</div> </div>
</header> </header>
@ -413,7 +422,11 @@ export default async function StructureDetailPage({ params }: StructureDetailPag
currencyConfig={currencyConfig} currencyConfig={currencyConfig}
/> />
<StructureStorageView structure={structure} /> <StructureStorageView
structure={structure}
canMutate={canManageStorage}
canArrange={canTransferStorage}
/>
<StorageRules <StorageRules
allowedStorage={structureType.allowedStorage} allowedStorage={structureType.allowedStorage}

View file

@ -3,7 +3,8 @@
import config from "@payload-config"; import config from "@payload-config";
import { getPayload } from "payload"; import { getPayload } from "payload";
import { GameStructure, Resource, Structure } from "@/payload-types"; 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 { emitGameEvent } from "@/utils/event-log/emit";
import { EventTypes } from "@/utils/event-log/eventTypes"; import { EventTypes } from "@/utils/event-log/eventTypes";
import { checkStorageDeposit, storageViolationMessage } from "@/lib/storageRules"; import { checkStorageDeposit, storageViolationMessage } from "@/lib/storageRules";
@ -14,6 +15,19 @@ interface ActionResult {
data?: GameStructure; 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() { async function authenticate() {
const payloadConfig = await config; const payloadConfig = await config;
const payload = await getPayload({ config: payloadConfig }); const payload = await getPayload({ config: payloadConfig });
@ -74,13 +88,30 @@ export async function addResource(
): Promise<ActionResult> { ): Promise<ActionResult> {
try { try {
const { payload, user } = await authenticate(); const { payload, user } = await authenticate();
if (!(await hasPermission(payload, user, "structures:update"))) { if (!(await canManageStorage(payload, user))) {
return { return {
success: false, 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) { if (amount <= 0) {
return { success: false, error: "Amount must be greater than zero." }; return { success: false, error: "Amount must be greater than zero." };
} }
@ -264,13 +295,30 @@ export async function removeResource(
): Promise<ActionResult> { ): Promise<ActionResult> {
try { try {
const { payload, user } = await authenticate(); const { payload, user } = await authenticate();
if (!(await hasPermission(payload, user, "structures:update"))) { if (!(await canManageStorage(payload, user))) {
return { return {
success: false, 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) { if (amount <= 0) {
return { success: false, error: "Amount must be greater than zero." }; return { success: false, error: "Amount must be greater than zero." };
} }
@ -373,10 +421,10 @@ export async function transferResource(
): Promise<ActionResult> { ): Promise<ActionResult> {
try { try {
const { payload, user } = await authenticate(); const { payload, user } = await authenticate();
if (!(await hasPermission(payload, user, "structures:update"))) { if (!(await canHandleStorage(payload, user))) {
return { return {
success: false, 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 // Remove from source (unguarded internal — gate already applied above)
const removeResult = await removeResource(fromStructureId, resourceId, amount); const removeResult = await removeResourceInternal(
payload,
user,
fromStructureId,
resourceId,
amount,
);
if (!removeResult.success) { if (!removeResult.success) {
return removeResult; return removeResult;
} }
// Add to destination // Add to destination
const addResult = await addResource(toStructureId, resourceId, amount); const addResult = await addResourceInternal(payload, user, toStructureId, resourceId, amount);
if (!addResult.success) { if (!addResult.success) {
// Rollback: re-add to source // 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}` }; return { success: false, error: `Transfer failed at destination: ${addResult.error}` };
} }
@ -524,10 +578,10 @@ export async function placeResourceOnGrid(
): Promise<GridActionResult> { ): Promise<GridActionResult> {
try { try {
const { payload, user } = await authenticate(); const { payload, user } = await authenticate();
if (!(await hasPermission(payload, user, "structures:update"))) { if (!(await canManageStorage(payload, user))) {
return { return {
success: false, 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> { ): Promise<GridActionResult> {
try { try {
const { payload, user } = await authenticate(); const { payload, user } = await authenticate();
if (!(await hasPermission(payload, user, "structures:update"))) { if (!(await canHandleStorage(payload, user))) {
return { return {
success: false, success: false,
error: "Insufficient permissions. User, Admin, or Developer role required.", error: "Logistics personnel or superuser role required.",
}; };
} }
const structure = await getStructure(payload, structureId); const structure = await getStructure(payload, structureId);
@ -730,10 +784,10 @@ export async function mergeGridStacks(
): Promise<GridActionResult> { ): Promise<GridActionResult> {
try { try {
const { payload, user } = await authenticate(); const { payload, user } = await authenticate();
if (!(await hasPermission(payload, user, "structures:update"))) { if (!(await canHandleStorage(payload, user))) {
return { return {
success: false, success: false,
error: "Insufficient permissions. User, Admin, or Developer role required.", error: "Logistics personnel or superuser role required.",
}; };
} }
const structure = await getStructure(payload, structureId); const structure = await getStructure(payload, structureId);
@ -812,10 +866,10 @@ export async function rotateGridItem(
): Promise<GridActionResult> { ): Promise<GridActionResult> {
try { try {
const { payload, user } = await authenticate(); const { payload, user } = await authenticate();
if (!(await hasPermission(payload, user, "structures:update"))) { if (!(await canHandleStorage(payload, user))) {
return { return {
success: false, success: false,
error: "Insufficient permissions. User, Admin, or Developer role required.", error: "Logistics personnel or superuser role required.",
}; };
} }
const structure = await getStructure(payload, structureId); const structure = await getStructure(payload, structureId);
@ -899,10 +953,10 @@ export async function removeGridItem(
): Promise<GridActionResult> { ): Promise<GridActionResult> {
try { try {
const { payload, user } = await authenticate(); const { payload, user } = await authenticate();
if (!(await hasPermission(payload, user, "structures:update"))) { if (!(await canManageStorage(payload, user))) {
return { return {
success: false, 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); const structure = await getStructure(payload, structureId);
@ -953,10 +1007,10 @@ export async function splitGridStack(
): Promise<GridActionResult> { ): Promise<GridActionResult> {
try { try {
const { payload, user } = await authenticate(); const { payload, user } = await authenticate();
if (!(await hasPermission(payload, user, "structures:update"))) { if (!(await canHandleStorage(payload, user))) {
return { return {
success: false, 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> { export async function retrieveFromVoid(structureId: number): Promise<GridActionResult> {
try { try {
const { payload, user } = await authenticate(); const { payload, user } = await authenticate();
if (!(await hasPermission(payload, user, "structures:update"))) { if (!(await canHandleStorage(payload, user))) {
return { success: false, error: "Insufficient permissions." }; return { success: false, error: "Logistics personnel or superuser role required." };
} }
const structure = await getStructure(payload, structureId); const structure = await getStructure(payload, structureId);
const gridW = structure.type.xSize ?? 0; 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> { export async function autoArrangeGrid(structureId: number): Promise<GridActionResult> {
try { try {
const { payload, user } = await authenticate(); const { payload, user } = await authenticate();
if (!(await hasPermission(payload, user, "structures:update"))) { if (!(await canHandleStorage(payload, user))) {
return { return {
success: false, success: false,
error: "Insufficient permissions. User, Admin, or Developer role required.", error: "Logistics personnel or superuser role required.",
}; };
} }
const structure = await getStructure(payload, structureId); const structure = await getStructure(payload, structureId);

View file

@ -24,6 +24,10 @@ interface GridToolbarProps {
onRotate: (entryId: string) => Promise<void>; onRotate: (entryId: string) => Promise<void>;
onRemove: (entryId: string) => Promise<void>; onRemove: (entryId: string) => Promise<void>;
onSplit: (entryId: string, amount: number) => 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({ export function GridToolbar({
@ -37,6 +41,8 @@ export function GridToolbar({
onRotate, onRotate,
onRemove, onRemove,
onSplit, onSplit,
canArrange = false,
canMutate = false,
}: GridToolbarProps) { }: GridToolbarProps) {
const occupancy = getGridOccupancy(storedResources, gridWidth, gridHeight); const occupancy = getGridOccupancy(storedResources, gridWidth, gridHeight);
const res = selectedItem ? (selectedItem.resource as Resource) : null; 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 flex-col gap-3">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Button variant="outline" size="sm" onClick={onAutoArrange}> {canArrange && (
<ShuffleIcon className="size-3.5 mr-1" /> <Button variant="outline" size="sm" onClick={onAutoArrange}>
Auto-Arrange <ShuffleIcon className="size-3.5 mr-1" />
</Button> Auto-Arrange
</Button>
)}
</div> </div>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
@ -86,12 +94,12 @@ export function GridToolbar({
</div> </div>
</div> </div>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
{res.rotatable !== false && ( {canArrange && res.rotatable !== false && (
<Button variant="outline" size="sm" onClick={() => onRotate(selectedItem.id)}> <Button variant="outline" size="sm" onClick={() => onRotate(selectedItem.id)}>
<RotateCwIcon className="size-3.5" /> <RotateCwIcon className="size-3.5" />
</Button> </Button>
)} )}
{res.stackable && selectedItem.amount > 1 && ( {canArrange && res.stackable && selectedItem.amount > 1 && (
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
@ -101,9 +109,11 @@ export function GridToolbar({
<ScissorsIcon className="size-3.5" /> <ScissorsIcon className="size-3.5" />
</Button> </Button>
)} )}
<Button variant="outline" size="sm" onClick={() => onRemove(selectedItem.id)}> {canMutate && (
<TrashIcon className="size-3.5" /> <Button variant="outline" size="sm" onClick={() => onRemove(selectedItem.id)}>
</Button> <TrashIcon className="size-3.5" />
</Button>
)}
</div> </div>
</div> </div>
)} )}

View file

@ -7,18 +7,32 @@ import { ManageStorageDialog } from "@/components/frontend/storage/ManageStorage
interface ManageStorageButtonProps { interface ManageStorageButtonProps {
structureId: number; structureId: number;
canManage: boolean;
canTransfer: boolean;
} }
export function ManageStorageButton({ structureId }: ManageStorageButtonProps) { export function ManageStorageButton({
structureId,
canManage,
canTransfer,
}: ManageStorageButtonProps) {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
if (!canManage && !canTransfer) return null;
return ( return (
<> <>
<Button variant="outline" size="sm" onClick={() => setOpen(true)}> <Button variant="outline" size="sm" onClick={() => setOpen(true)}>
<SettingsIcon className="size-3.5 mr-1.5" /> <SettingsIcon className="size-3.5 mr-1.5" />
Manage Manage
</Button> </Button>
<ManageStorageDialog structureId={structureId} open={open} onOpenChange={setOpen} /> <ManageStorageDialog
structureId={structureId}
open={open}
onOpenChange={setOpen}
canManage={canManage}
canTransfer={canTransfer}
/>
</> </>
); );
} }

View file

@ -46,9 +46,17 @@ interface ManageStorageDialogProps {
structureId: number; structureId: number;
open: boolean; open: boolean;
onOpenChange: (open: boolean) => void; 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 [structure, setStructure] = useState<(GameStructure & { type: Structure }) | null>(null);
const [allStructures, setAllStructures] = useState<(GameStructure & { type: Structure })[]>([]); const [allStructures, setAllStructures] = useState<(GameStructure & { type: Structure })[]>([]);
const [allResources, setAllResources] = useState<Resource[]>([]); const [allResources, setAllResources] = useState<Resource[]>([]);
@ -641,265 +649,288 @@ export function ManageStorageDialog({ structureId, open, onOpenChange }: ManageS
</div> </div>
</div> </div>
)} )}
<Tabs defaultValue="deposit" className="w-full"> <Tabs defaultValue={canManage ? "deposit" : "transfer"} className="w-full">
<TabsList className="grid w-full grid-cols-3"> <TabsList
<TabsTrigger value="deposit" className="flex items-center gap-1.5"> className={cn(
<ArrowDownToLineIcon className="size-3.5" /> "grid w-full",
Deposit canManage && canTransfer
</TabsTrigger> ? "grid-cols-3"
<TabsTrigger value="withdraw" className="flex items-center gap-1.5"> : canManage
<ArrowUpFromLineIcon className="size-3.5" /> ? "grid-cols-2"
Withdraw : "grid-cols-1",
</TabsTrigger> )}
<TabsTrigger value="transfer" className="flex items-center gap-1.5"> >
<ArrowRightLeftIcon className="size-3.5" /> {canManage && (
Transfer <TabsTrigger value="deposit" className="flex items-center gap-1.5">
</TabsTrigger> <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> </TabsList>
<TabsContent value="deposit" className="flex flex-col gap-4 pt-4"> {canManage && (
<div className="flex flex-col gap-2"> <TabsContent value="deposit" className="flex flex-col gap-4 pt-4">
<Label>Resource</Label> <div className="flex flex-col gap-2">
<Select <Label>Resource</Label>
value={depositResourceId} <Select
onValueChange={(val) => { value={depositResourceId}
setDepositResourceId(val); onValueChange={(val) => {
setDepositAmount( setDepositResourceId(val);
clampAmount(depositAmount, getDepositLimitInfoFor(val)?.max), 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> {isPending ? (
<SelectValue placeholder="Select a resource..." /> <Loader2Icon className="size-4 animate-spin mr-2" />
</SelectTrigger> ) : (
<SelectContent> <ArrowDownToLineIcon className="size-4 mr-2" />
{allResources )}
.filter((r) => isResourceAllowed(r.id)) Deposit
.map((resource) => { </Button>
const cap = getAllowedDepositCap(resource.id); </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 ( return (
<SelectItem key={resource.id} value={String(resource.id)}> <SelectItem key={entry.id ?? resource.id} value={String(resource.id)}>
{resource.name} {resource.name} ({entry.amount.toLocaleString()}{" "}
{cap !== undefined && ( {pluralize(entry.amount, resource.unitOfMeasure)})
<span className="text-muted-foreground">
{" "}
({cap.toLocaleString()} max)
</span>
)}
</SelectItem> </SelectItem>
); );
})} })}
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<Label>Amount</Label> <Label>Amount</Label>
<Input <Input
type="number" type="number"
min="1" min="1"
max={getDepositMax()} max={getWithdrawMax()}
value={depositAmount} value={withdrawAmount}
onChange={(e) => setDepositAmount(clampAmount(e.target.value, getDepositMax()))} onChange={(e) =>
/> setWithdrawAmount(clampAmount(e.target.value, getWithdrawMax()))
{getDepositLimitInfoFor(depositResourceId) !== undefined && ( }
<span className="text-xs text-muted-foreground"> />
Max: {getDepositLimitInfoFor(depositResourceId)!.max.toLocaleString()}{" "} {getWithdrawMax() !== undefined && (
(limited by {getDepositLimitInfoFor(depositResourceId)!.reason}) <span className="text-xs text-muted-foreground">
</span> Max: {getWithdrawMax()!.toLocaleString()} stored
)} </span>
</div> )}
{renderCapacityPreview("deposit")} </div>
<Button {renderCapacityPreview("withdraw")}
onClick={handleDeposit} <Button
disabled={isPending || !depositResourceId} onClick={handleWithdraw}
className="w-full" disabled={isPending || !withdrawResourceId}
> className="w-full"
{isPending ? ( variant="destructive"
<Loader2Icon className="size-4 animate-spin mr-2" /> >
) : ( {isPending ? (
<ArrowDownToLineIcon className="size-4 mr-2" /> <Loader2Icon className="size-4 animate-spin mr-2" />
)} ) : (
Deposit <ArrowUpFromLineIcon className="size-4 mr-2" />
</Button> )}
</TabsContent> Withdraw
</Button>
</TabsContent>
)}
<TabsContent value="withdraw" className="flex flex-col gap-4 pt-4"> {canTransfer && (
<div className="flex flex-col gap-2"> <TabsContent value="transfer" className="flex flex-col gap-4 pt-4">
<Label>Resource</Label> <div className="flex flex-col gap-2">
<Select <Label>Destination</Label>
value={withdrawResourceId} <Select
onValueChange={(val) => { value={transferToId}
setWithdrawResourceId(val); onValueChange={(val) => {
const stored = findStoredResource(val); setTransferToId(val);
setWithdrawAmount(clampAmount(withdrawAmount, stored?.amount)); setTransferAmount(clampAmount(transferAmount, getTransferMaxFor(val)));
}} }}
> >
<SelectTrigger> <SelectTrigger>
<SelectValue placeholder="Select a resource..." /> <SelectValue placeholder="Select a structure..." />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{storedResources.map((entry) => { {allStructures.map((s) => (
const resource = entry.resource as Resource; <SelectItem key={s.id} value={String(s.id)}>
if (!resource || typeof resource === "number") return null; {s.name}
return (
<SelectItem key={entry.id ?? resource.id} value={String(resource.id)}>
{resource.name} ({entry.amount.toLocaleString()}{" "}
{pluralize(entry.amount, resource.unitOfMeasure)})
</SelectItem> </SelectItem>
); ))}
})} </SelectContent>
</SelectContent> </Select>
</Select> </div>
</div> <div className="flex flex-col gap-2">
<div className="flex flex-col gap-2"> <Label>Resource</Label>
<Label>Amount</Label> <Select
<Input value={transferResourceId}
type="number" onValueChange={(val) => {
min="1" setTransferResourceId(val);
max={getWithdrawMax()} const stored = findStoredResource(val);
value={withdrawAmount} setTransferAmount(clampAmount(transferAmount, stored?.amount));
onChange={(e) => }}
setWithdrawAmount(clampAmount(e.target.value, getWithdrawMax())) >
} <SelectTrigger>
/> <SelectValue placeholder="Select a resource..." />
{getWithdrawMax() !== undefined && ( </SelectTrigger>
<span className="text-xs text-muted-foreground"> <SelectContent>
Max: {getWithdrawMax()!.toLocaleString()} stored {storedResources.map((entry) => {
</span> const resource = entry.resource as Resource;
)} if (!resource || typeof resource === "number") return null;
</div> return (
{renderCapacityPreview("withdraw")} <SelectItem key={entry.id ?? resource.id} value={String(resource.id)}>
<Button {resource.name} ({entry.amount.toLocaleString()}{" "}
onClick={handleWithdraw} {pluralize(entry.amount, resource.unitOfMeasure)})
disabled={isPending || !withdrawResourceId} </SelectItem>
className="w-full" );
variant="destructive" })}
> </SelectContent>
{isPending ? ( </Select>
<Loader2Icon className="size-4 animate-spin mr-2" /> </div>
) : ( <div className="flex flex-col gap-2">
<ArrowUpFromLineIcon className="size-4 mr-2" /> <Label>Amount</Label>
)} <Input
Withdraw type="number"
</Button> min="1"
</TabsContent> max={getTransferMax()}
value={transferAmount}
<TabsContent value="transfer" className="flex flex-col gap-4 pt-4"> onChange={(e) =>
<div className="flex flex-col gap-2"> setTransferAmount(clampAmount(e.target.value, getTransferMax()))
<Label>Destination</Label> }
<Select />
value={transferToId} {getTransferMax() !== undefined && (
onValueChange={(val) => { <span className="text-xs text-muted-foreground">
setTransferToId(val); Max: {getTransferMax()!.toLocaleString()} stored
setTransferAmount(clampAmount(transferAmount, getTransferMaxFor(val))); </span>
}} )}
> {transferToId &&
<SelectTrigger> transferResourceId &&
<SelectValue placeholder="Select a structure..." /> (() => {
</SelectTrigger> const dest = allStructures.find((s) => s.id === Number(transferToId));
<SelectContent> const destType = dest?.type;
{allStructures.map((s) => ( if (!dest || !destType) return null;
<SelectItem key={s.id} value={String(s.id)}> const stored = findStoredResource(transferResourceId);
{s.name} if (!stored) return null;
</SelectItem> const resId = stored.resource.id;
))} if (isResourceProhibited(destType, resId)) {
</SelectContent> return (
</Select> <span className="text-xs text-red-400">
</div> Destination prohibits this resource.
<div className="flex flex-col gap-2"> </span>
<Label>Resource</Label> );
<Select }
value={transferResourceId} if (!isResourceWhitelisted(destType, resId)) {
onValueChange={(val) => { return (
setTransferResourceId(val); <span className="text-xs text-amber-400">
const stored = findStoredResource(val); Destination only accepts items on its allowed storage list. This
setTransferAmount(clampAmount(transferAmount, stored?.amount)); resource is not permitted.
}} </span>
> );
<SelectTrigger> }
<SelectValue placeholder="Select a resource..." /> const cap = getResourceStorageCap(destType, resId);
</SelectTrigger> const destStored = getStoredAmountOf(dest, resId);
<SelectContent>
{storedResources.map((entry) => {
const resource = entry.resource as Resource;
if (!resource || typeof resource === "number") return null;
return ( return (
<SelectItem key={entry.id ?? resource.id} value={String(resource.id)}> <span className="text-xs text-muted-foreground">
{resource.name} ({entry.amount.toLocaleString()}{" "} {cap != null
{pluralize(entry.amount, resource.unitOfMeasure)}) ? `Destination allowance: ${Math.max(0, cap - destStored).toLocaleString()} / ${cap.toLocaleString()}`
</SelectItem> : "Destination has no per-item storage limit"}
);
})}
</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> </span>
); );
} })()}
if (!isResourceWhitelisted(destType, resId)) { </div>
return ( {renderCapacityPreview("transfer")}
<span className="text-xs text-amber-400"> <Button
Destination only accepts items on its allowed storage list. This onClick={handleTransfer}
resource is not permitted. disabled={
</span> isPending || !transferToId || !transferResourceId || getTransferMax() === 0
); }
} className="w-full"
const cap = getResourceStorageCap(destType, resId); >
const destStored = getStoredAmountOf(dest, resId); {isPending ? (
return ( <Loader2Icon className="size-4 animate-spin mr-2" />
<span className="text-xs text-muted-foreground"> ) : (
{cap != null <ArrowRightLeftIcon className="size-4 mr-2" />
? `Destination allowance: ${Math.max(0, cap - destStored).toLocaleString()} / ${cap.toLocaleString()}` )}
: "Destination has no per-item storage limit"} Transfer
</span> </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> </Tabs>
</> </>
)} )}

View file

@ -45,9 +45,17 @@ function getResourceIcon(type: Resource["type"]) {
interface StructureStorageViewProps { interface StructureStorageViewProps {
structure: GameStructure & { type: Structure }; 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 router = useRouter();
const [viewMode, setViewMode] = useState<"grid" | "list">("grid"); const [viewMode, setViewMode] = useState<"grid" | "list">("grid");
const [selectedItem, setSelectedItem] = useState<GridItem | null>(null); const [selectedItem, setSelectedItem] = useState<GridItem | null>(null);
@ -80,64 +88,72 @@ export function StructureStorageView({ structure }: StructureStorageViewProps) {
const handleMoveItem = useCallback( const handleMoveItem = useCallback(
async (entryId: string, gridX: number, gridY: number, rotated?: boolean) => { async (entryId: string, gridX: number, gridY: number, rotated?: boolean) => {
if (!canArrange) return;
const result = await moveGridItem(structure.id, entryId, gridX, gridY, rotated); const result = await moveGridItem(structure.id, entryId, gridX, gridY, rotated);
if (result.success) refresh(); if (result.success) refresh();
}, },
[structure.id, refresh], [structure.id, refresh, canArrange],
); );
const handleMergeStacks = useCallback( const handleMergeStacks = useCallback(
async (sourceId: string, targetId: string) => { async (sourceId: string, targetId: string) => {
if (!canArrange) return;
const result = await mergeGridStacks(structure.id, sourceId, targetId); const result = await mergeGridStacks(structure.id, sourceId, targetId);
if (result.success) refresh(); if (result.success) refresh();
}, },
[structure.id, refresh], [structure.id, refresh, canArrange],
); );
const handleRotateItem = useCallback( const handleRotateItem = useCallback(
async (entryId: string) => { async (entryId: string) => {
if (!canArrange) return;
const result = await rotateGridItem(structure.id, entryId); const result = await rotateGridItem(structure.id, entryId);
if (result.success) refresh(); if (result.success) refresh();
}, },
[structure.id, refresh], [structure.id, refresh, canArrange],
); );
const handleRemoveItem = useCallback( const handleRemoveItem = useCallback(
async (entryId: string) => { async (entryId: string) => {
if (!canMutate) return;
const result = await removeGridItem(structure.id, entryId); const result = await removeGridItem(structure.id, entryId);
if (result.success) { if (result.success) {
setSelectedItem(null); setSelectedItem(null);
refresh(); refresh();
} }
}, },
[structure.id, refresh], [structure.id, refresh, canMutate],
); );
const handleSplitStack = useCallback( const handleSplitStack = useCallback(
async (entryId: string, amount: number) => { async (entryId: string, amount: number) => {
if (!canArrange) return;
const result = await splitGridStack(structure.id, entryId, amount); const result = await splitGridStack(structure.id, entryId, amount);
if (result.success) refresh(); if (result.success) refresh();
}, },
[structure.id, refresh], [structure.id, refresh, canArrange],
); );
const handleAutoArrange = useCallback(async () => { const handleAutoArrange = useCallback(async () => {
if (!canArrange) return;
const result = await autoArrangeGrid(structure.id); const result = await autoArrangeGrid(structure.id);
if (result.success) refresh(); if (result.success) refresh();
}, [structure.id, refresh]); }, [structure.id, refresh, canArrange]);
const handleWithdrawFromVoid = useCallback( const handleWithdrawFromVoid = useCallback(
async (resourceId: number, amount: number) => { async (resourceId: number, amount: number) => {
if (!canMutate) return;
const result = await removeResource(structure.id, resourceId, amount); const result = await removeResource(structure.id, resourceId, amount);
if (result.success) refresh(); if (result.success) refresh();
}, },
[structure.id, refresh], [structure.id, refresh, canMutate],
); );
const handleRetrieveFromVoid = useCallback(async () => { const handleRetrieveFromVoid = useCallback(async () => {
if (!canArrange) return;
const result = await retrieveFromVoid(structure.id); const result = await retrieveFromVoid(structure.id);
if (result.success) refresh(); if (result.success) refresh();
}, [structure.id, refresh]); }, [structure.id, refresh, canArrange]);
if (!hasGrid) { if (!hasGrid) {
return ( return (
@ -179,6 +195,8 @@ export function StructureStorageView({ structure }: StructureStorageViewProps) {
onRotate={handleRotateItem} onRotate={handleRotateItem}
onRemove={handleRemoveItem} onRemove={handleRemoveItem}
onSplit={handleSplitStack} onSplit={handleSplitStack}
canArrange={canArrange}
canMutate={canMutate}
/> />
{viewMode === "grid" ? ( {viewMode === "grid" ? (
@ -213,13 +231,15 @@ export function StructureStorageView({ structure }: StructureStorageViewProps) {
{voidStorageItems.length} item{voidStorageItems.length !== 1 ? "s" : ""}: these no {voidStorageItems.length} item{voidStorageItems.length !== 1 ? "s" : ""}: these no
longer fit in the grid. longer fit in the grid.
</span> </span>
<button {canArrange && (
type="button" <button
onClick={handleRetrieveFromVoid} type="button"
className="text-xs bg-amber-500/10 text-amber-400 hover:bg-amber-500/20 px-2 py-0.5 rounded transition-colors" 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> Retrieve All to Grid
</button>
)}
</ItemDescription> </ItemDescription>
</ItemHeader> </ItemHeader>
<ItemContent> <ItemContent>
@ -262,14 +282,16 @@ export function StructureStorageView({ structure }: StructureStorageViewProps) {
{mass > 0 && `${mass.toLocaleString()} kg`} {mass > 0 && `${mass.toLocaleString()} kg`}
</div> </div>
</div> </div>
<button {canMutate && (
type="button" <button
onClick={() => handleWithdrawFromVoid(entry.resourceId, entry.amount)} type="button"
className="text-xs text-muted-foreground hover:text-foreground px-2 py-1 rounded transition-colors shrink-0" onClick={() => handleWithdrawFromVoid(entry.resourceId, entry.amount)}
title="Withdraw from structure" className="text-xs text-muted-foreground hover:text-foreground px-2 py-1 rounded transition-colors shrink-0"
> title="Withdraw from structure"
Withdraw >
</button> Withdraw
</button>
)}
</div> </div>
{index < voidStorageItems.length - 1 && <ItemSeparator />} {index < voidStorageItems.length - 1 && <ItemSeparator />}
</div> </div>

View file

@ -53,7 +53,6 @@ const USER_PERMISSIONS: Permission[] = [
"shims:read", "shims:read",
"shipments:create", "shipments:create",
"shipments:update", "shipments:update",
"structures:update",
"structures:delete", "structures:delete",
]; ];
@ -87,6 +86,7 @@ const ADMIN_PERMISSIONS: Permission[] = [
"discord:staff", "discord:staff",
"discord:announce", "discord:announce",
"structures:create", "structures:create",
"structures:update",
...ALL_COLLECTION_READS, ...ALL_COLLECTION_READS,
...ALL_ADMIN_PAGE_MANAGE, ...ALL_ADMIN_PAGE_MANAGE,
]), ]),