From 4bbafcd676df509833083a28e12aada0356fedc1 Mon Sep 17 00:00:00 2001 From: Demian Date: Mon, 30 Mar 2026 19:10:42 -0400 Subject: [PATCH 1/6] feat: add org catalog item flow for inventory - add org-mode inventory add flow with permission gating - handle duplicate org items with merge prompt and 409 recovery - wire backend org inventory duplicate detection and frontend permissions service --- .../org-inventory/org-inventory.controller.ts | 44 +- .../org-inventory/org-inventory.repository.ts | 24 +- .../org-inventory.service.spec.ts | 26 +- .../org-inventory/org-inventory.service.ts | 14 + .../inventory/InventoryFiltersPanel.tsx | 50 +- .../inventory/InventoryInlineRow.tsx | 420 ++++++++---- frontend/src/hooks/useMemoizedLocations.ts | 15 +- frontend/src/pages/Inventory.tsx | 604 ++++++++++++++---- frontend/src/services/permissions.service.ts | 35 + 9 files changed, 954 insertions(+), 278 deletions(-) create mode 100644 frontend/src/services/permissions.service.ts diff --git a/backend/src/modules/org-inventory/org-inventory.controller.ts b/backend/src/modules/org-inventory/org-inventory.controller.ts index ef92804..489bbe0 100644 --- a/backend/src/modules/org-inventory/org-inventory.controller.ts +++ b/backend/src/modules/org-inventory/org-inventory.controller.ts @@ -4,6 +4,7 @@ import { Post, Put, Delete, + BadRequestException, Body, Param, Query, @@ -53,7 +54,7 @@ export class OrgInventoryController { async list( @Request() req: any, @Param('orgId', ParseIntPipe) orgId: number, - @Query() searchDto: OrgInventorySearchDto, + @Query() query: Record, ): Promise<{ items: OrgInventoryItemDto[]; total: number; @@ -61,11 +62,43 @@ export class OrgInventoryController { offset: number; }> { const userId = req.user.userId; - // Extract gameId from query params and use search - return this.orgInventoryService.search(userId, { - ...searchDto, + const parsedMinQuantity = Number( + query.min_quantity ?? query.minQuantity ?? Number.NaN, + ); + const parsedMaxQuantity = Number( + query.max_quantity ?? query.maxQuantity ?? Number.NaN, + ); + + const searchDto: OrgInventorySearchDto = { orgId, - }); + gameId: Number(query.game_id ?? query.gameId), + categoryId: query.category_id ? Number(query.category_id) : undefined, + uexItemId: query.uex_item_id ? Number(query.uex_item_id) : undefined, + locationId: query.location_id ? Number(query.location_id) : undefined, + search: query.search, + limit: query.limit ? Number(query.limit) : undefined, + offset: query.offset ? Number(query.offset) : undefined, + sort: query.sort, + order: query.order, + activeOnly: + query.active_only !== undefined + ? query.active_only === 'true' || query.active_only === true + : query.activeOnly !== undefined + ? query.activeOnly === 'true' || query.activeOnly === true + : undefined, + minQuantity: Number.isNaN(parsedMinQuantity) + ? undefined + : parsedMinQuantity, + maxQuantity: Number.isNaN(parsedMaxQuantity) + ? undefined + : parsedMaxQuantity, + }; + + if (!searchDto.gameId) { + throw new BadRequestException('game_id is required'); + } + + return this.orgInventoryService.search(userId, searchDto); } /** @@ -83,6 +116,7 @@ export class OrgInventoryController { }) @ApiResponse({ status: 400, description: 'Invalid input' }) @ApiResponse({ status: 403, description: 'Permission denied' }) + @ApiResponse({ status: 409, description: 'Inventory item already exists' }) async create( @Request() req: any, @Param('orgId', ParseIntPipe) orgId: number, diff --git a/backend/src/modules/org-inventory/org-inventory.repository.ts b/backend/src/modules/org-inventory/org-inventory.repository.ts index 377add7..f86bd83 100644 --- a/backend/src/modules/org-inventory/org-inventory.repository.ts +++ b/backend/src/modules/org-inventory/org-inventory.repository.ts @@ -89,6 +89,26 @@ export class OrgInventoryRepository extends Repository { }); } + /** + * Find an existing org inventory item with matching composite keys + */ + async findExistingItem(params: { + orgId: number; + gameId: number; + uexItemId: number; + locationId: number; + }): Promise { + return this.findOne({ + where: { + orgId: params.orgId, + gameId: params.gameId, + uexItemId: params.uexItemId, + locationId: params.locationId, + deleted: false, + }, + }); + } + /** * Soft delete an inventory item */ @@ -227,10 +247,10 @@ export class OrgInventoryRepository extends Repository { case 'location': return 'location.displayName'; case 'date_added': - return 'oii.date_added'; + return 'oii.dateAdded'; case 'date_modified': default: - return 'oii.date_modified'; + return 'oii.dateModified'; } } } diff --git a/backend/src/modules/org-inventory/org-inventory.service.spec.ts b/backend/src/modules/org-inventory/org-inventory.service.spec.ts index 28fc96b..cfdcf47 100644 --- a/backend/src/modules/org-inventory/org-inventory.service.spec.ts +++ b/backend/src/modules/org-inventory/org-inventory.service.spec.ts @@ -1,5 +1,9 @@ import { Test, TestingModule } from '@nestjs/testing'; -import { ForbiddenException, NotFoundException } from '@nestjs/common'; +import { + ForbiddenException, + NotFoundException, + ConflictException, +} from '@nestjs/common'; import { OrgInventoryService } from './org-inventory.service'; import { OrgInventoryRepository } from './org-inventory.repository'; import { PermissionsService } from '../permissions/permissions.service'; @@ -68,6 +72,7 @@ describe('OrgInventoryService', () => { useValue: { findByOrgIdAndGameId: jest.fn(), findByIdNotDeleted: jest.fn(), + findExistingItem: jest.fn(), create: jest.fn(), save: jest.fn(), softDeleteItem: jest.fn(), @@ -107,6 +112,7 @@ describe('OrgInventoryService', () => { it('should create org inventory item with manage permission', async () => { jest.spyOn(permissionsService, 'hasPermission').mockResolvedValue(true); + jest.spyOn(repository, 'findExistingItem').mockResolvedValue(null); jest.spyOn(repository, 'create').mockReturnValue(mockOrgInventoryItem); jest.spyOn(repository, 'save').mockResolvedValue(mockOrgInventoryItem); jest @@ -121,6 +127,12 @@ describe('OrgInventoryService', () => { 1, OrgPermission.CAN_EDIT_ORG_INVENTORY, ); + expect(repository.findExistingItem).toHaveBeenCalledWith({ + orgId: createDto.orgId, + gameId: createDto.gameId, + uexItemId: createDto.uexItemId, + locationId: createDto.locationId, + }); expect(repository.create).toHaveBeenCalledWith({ ...createDto, addedBy: 1, @@ -138,6 +150,18 @@ describe('OrgInventoryService', () => { ); expect(repository.create).not.toHaveBeenCalled(); }); + + it('should throw ConflictException when item already exists', async () => { + jest.spyOn(permissionsService, 'hasPermission').mockResolvedValue(true); + jest + .spyOn(repository, 'findExistingItem') + .mockResolvedValue(mockOrgInventoryItem); + + await expect(service.create(1, createDto)).rejects.toThrow( + ConflictException, + ); + expect(repository.create).not.toHaveBeenCalled(); + }); }); describe('findByOrgAndGame', () => { diff --git a/backend/src/modules/org-inventory/org-inventory.service.ts b/backend/src/modules/org-inventory/org-inventory.service.ts index 35e95c3..1967c5d 100644 --- a/backend/src/modules/org-inventory/org-inventory.service.ts +++ b/backend/src/modules/org-inventory/org-inventory.service.ts @@ -3,6 +3,7 @@ import { NotFoundException, ForbiddenException, BadRequestException, + ConflictException, } from '@nestjs/common'; import { OrgInventoryRepository } from './org-inventory.repository'; import { PermissionsService } from '../permissions/permissions.service'; @@ -88,6 +89,19 @@ export class OrgInventoryService { await this.verifyInventoryPermission(userId, dto.orgId, 'manage'); + const existing = await this.orgInventoryRepository.findExistingItem({ + orgId: dto.orgId, + gameId: dto.gameId, + uexItemId: dto.uexItemId, + locationId: dto.locationId, + }); + + if (existing) { + throw new ConflictException( + 'Inventory item already exists for this organization and location', + ); + } + const item = this.orgInventoryRepository.create({ ...dto, orgId: dto.orgId, // Ensure orgId is set diff --git a/frontend/src/components/inventory/InventoryFiltersPanel.tsx b/frontend/src/components/inventory/InventoryFiltersPanel.tsx index b5ab4f2..8c80650 100644 --- a/frontend/src/components/inventory/InventoryFiltersPanel.tsx +++ b/frontend/src/components/inventory/InventoryFiltersPanel.tsx @@ -55,9 +55,11 @@ interface FiltersPanelProps { userInitial: string; onOpenAddDialog: () => void; showAddButton: boolean; + addButtonLabel?: string; totalCount: number; itemCount: number; autoFocusSearch?: boolean; + disabled?: boolean; } export const InventoryFiltersPanel = ({ @@ -83,9 +85,11 @@ export const InventoryFiltersPanel = ({ userInitial, onOpenAddDialog, showAddButton, + addButtonLabel = 'Add item', totalCount, itemCount, autoFocusSearch = false, + disabled = false, }: FiltersPanelProps) => { return ( <> @@ -97,6 +101,7 @@ export const InventoryFiltersPanel = ({ placeholder="Prospector, Lorville, armors..." value={filters.search} autoFocus={autoFocusSearch} + disabled={disabled} onChange={(e) => setFilters((prev) => ({ ...prev, search: e.target.value }))} /> @@ -107,6 +112,7 @@ export const InventoryFiltersPanel = ({ labelId="category-filter-label" label="Category" value={filters.categoryId} + disabled={disabled} onChange={(e) => setFilters((prev) => ({ ...prev, @@ -132,6 +138,7 @@ export const InventoryFiltersPanel = ({ labelId="location-filter-label" label="Location" value={filters.locationId} + disabled={disabled} onChange={(e) => setFilters((prev) => ({ ...prev, @@ -159,6 +166,7 @@ export const InventoryFiltersPanel = ({ value={filters.valueRange} min={0} max={Math.max(filters.valueRange[1], maxQuantity || 1000)} + disabled={disabled} onChange={(_, value) => setFilters((prev) => ({ ...prev, @@ -177,6 +185,7 @@ export const InventoryFiltersPanel = ({ labelId="org-selector-label" label="View" value={viewMode === 'personal' ? 'personal' : selectedOrgId ?? ''} + disabled={disabled} onChange={(e) => { const value = e.target.value; if (value === 'personal') { @@ -214,28 +223,29 @@ export const InventoryFiltersPanel = ({ - - setFilters((prev) => ({ - ...prev, - sharedOnly: e.target.checked, - })) - } - size="small" - disabled={viewMode === 'org'} - /> - } - label="Shared only" - /> + + setFilters((prev) => ({ + ...prev, + sharedOnly: e.target.checked, + })) + } + size="small" + disabled={disabled || viewMode === 'org'} + /> + } + label="Shared only" + /> )} diff --git a/frontend/src/components/inventory/InventoryInlineRow.tsx b/frontend/src/components/inventory/InventoryInlineRow.tsx index e564b49..40d7ff9 100644 --- a/frontend/src/components/inventory/InventoryInlineRow.tsx +++ b/frontend/src/components/inventory/InventoryInlineRow.tsx @@ -1,3 +1,4 @@ +import { memo, useMemo } from 'react'; import type { MouseEvent } from 'react'; import { Autocomplete, @@ -11,6 +12,7 @@ import { } from '@mui/material'; import CheckIcon from '@mui/icons-material/Check'; import MoreVertIcon from '@mui/icons-material/MoreVert'; +import EditIcon from '@mui/icons-material/Edit'; import type { InventoryItem, OrgInventoryItem } from '../../services/inventory.service'; import type { FocusController } from '../../utils/focusController'; import { useMemoizedLocations } from '../../hooks/useMemoizedLocations'; @@ -28,38 +30,54 @@ interface InventoryInlineRowProps { item: InventoryRecord; density: 'standard' | 'compact'; allLocations: LocationOption[]; + locationNameById: Map; inlineDraft: { locationId: number | ''; quantity: number | '' }; inlineLocationInput: string; locationEditing: boolean; + quantityEditing: boolean; inlineSaving: boolean; inlineSaved?: boolean; inlineError?: string | null; isDirty: boolean; + isRowActive: boolean; focusController: FocusController; rowKey: string; - onDraftChange: (changes: Partial<{ locationId: number | ''; quantity: number | '' }>) => void; - onErrorChange: (message: string | null) => void; - onLocationInputChange: (value: string) => void; - onLocationFocus: () => void; - onLocationBlur: (selectedName?: string) => void; - onSave: () => void; - onOpenActions?: (event: MouseEvent) => void; + onDraftChange: ( + itemId: string, + changes: Partial<{ locationId: number | ''; quantity: number | '' }>, + ) => void; + onErrorChange: (itemId: string, message: string | null) => void; + onLocationInputChange: (rowKey: string, value: string) => void; + onLocationFocus: (itemId: string) => void; + onLocationBlur: ( + rowKey: string, + itemId: string, + draftLocationId: number | '', + selectedName?: string, + ) => void; + onQuantityBlur: (rowKey: string) => void; + onActivateField: (rowKey: string, field: 'location' | 'quantity', initialInput?: string) => void; + onSave: (item: InventoryRecord) => void; + onOpenActions?: (event: MouseEvent, item: InventoryRecord) => void; setLocationRef: (ref: HTMLInputElement | null, key: string) => void; setQuantityRef: (ref: HTMLInputElement | null, key: string) => void; setSaveRef: (ref: HTMLButtonElement | null, key: string) => void; } -export const InventoryInlineRow = ({ +const InventoryInlineRow = ({ item, density, allLocations, + locationNameById, inlineDraft, inlineLocationInput, locationEditing, + quantityEditing, inlineSaving, inlineSaved, inlineError, isDirty, + isRowActive, focusController, rowKey, onDraftChange, @@ -67,6 +85,8 @@ export const InventoryInlineRow = ({ onLocationInputChange, onLocationFocus, onLocationBlur, + onQuantityBlur, + onActivateField, onSave, onOpenActions, setLocationRef, @@ -78,19 +98,22 @@ export const InventoryInlineRow = ({ ? Number(inlineDraft.locationId) : inlineDraft.locationId; - const { filtered: filteredOptions, getSelected } = useMemoizedLocations( + const { filtered: filteredOptions } = useMemoizedLocations( allLocations, inlineLocationInput, + locationEditing, ); - const selectedLocation = - typeof draftLocationId === 'number' - ? getSelected(draftLocationId) || - (item.locationName - ? { id: draftLocationId, name: item.locationName } - : null) - : null; + const selectedLocation = useMemo(() => { + if (typeof draftLocationId !== 'number') return null; + const name = locationNameById.get(draftLocationId) ?? item.locationName; + return name ? { id: draftLocationId, name } : null; + }, [draftLocationId, locationNameById, item.locationName]); const draftQuantityNumber = Number(inlineDraft.quantity); + const displayQuantity = + Number.isFinite(draftQuantityNumber) && draftQuantityNumber > 0 + ? draftQuantityNumber + : Number(item.quantity); return ( @@ -152,64 +178,133 @@ export const InventoryInlineRow = ({ ) : ( - options} - value={locationEditing ? null : selectedLocation} - inputValue={inlineLocationInput} - getOptionLabel={(option) => option?.name ?? ''} - isOptionEqualToValue={(option, value) => option.id === value.id} - onChange={(_, value) => { - onDraftChange({ locationId: value ? value.id : '' }); - onLocationInputChange(value?.name ?? ''); - onLocationBlur(value?.name ?? ''); - onErrorChange(null); - }} - onInputChange={(_, value) => { - onLocationInputChange(value); - }} - onFocus={() => { - onLocationFocus(); - onLocationInputChange(''); - }} - onBlur={() => { - onLocationBlur(selectedLocation?.name ?? ''); - }} - renderOption={(props, option) => ( -
  • - {option.name} -
  • - )} - renderInput={(params) => ( - { - setLocationRef(el, rowKey); + <> + {locationEditing ? ( + options} + value={locationEditing ? null : selectedLocation} + inputValue={inlineLocationInput} + getOptionLabel={(option) => option?.name ?? ''} + isOptionEqualToValue={(option, value) => option.id === value.id} + onChange={(_, value) => { + onDraftChange(item.id, { locationId: value ? value.id : '' }); + onLocationInputChange(rowKey, value?.name ?? ''); + onLocationBlur(rowKey, item.id, draftLocationId, value?.name ?? ''); + onErrorChange(item.id, null); + }} + onInputChange={(_, value) => { + onLocationInputChange(rowKey, value); + }} + onFocus={() => { + onLocationFocus(item.id); + }} + onBlur={() => { + onLocationBlur(rowKey, item.id, draftLocationId, selectedLocation?.name ?? ''); }} + renderOption={(props, option) => ( +
  • + {option.name} +
  • + )} + renderInput={(params) => ( + { + setLocationRef(el, rowKey); + }} + onKeyDown={(event) => { + if (event.key === 'Enter') { + event.preventDefault(); + const bestMatch = filteredOptions[0]; + if (bestMatch) { + onDraftChange(item.id, { locationId: bestMatch.id }); + onLocationInputChange(rowKey, bestMatch.name); + onLocationBlur(rowKey, item.id, draftLocationId, bestMatch.name); + onErrorChange(item.id, null); + focusController.focus(rowKey, 'quantity'); + } else { + onErrorChange(item.id, 'No matches found'); + } + } else if (event.key === 'Escape') { + event.preventDefault(); + onLocationInputChange(rowKey, selectedLocation?.name ?? ''); + onLocationBlur( + rowKey, + item.id, + draftLocationId, + selectedLocation?.name ?? '', + ); + } + }} + /> + )} + /> + ) : ( + + onActivateField( + rowKey, + 'location', + selectedLocation?.name ?? item.locationName ?? '', + ) + } + onFocus={() => + onActivateField( + rowKey, + 'location', + selectedLocation?.name ?? item.locationName ?? '', + ) + } onKeyDown={(event) => { - if (event.key === 'Enter') { + if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); - const bestMatch = filteredOptions[0]; - if (bestMatch) { - onDraftChange({ locationId: bestMatch.id }); - onLocationInputChange(bestMatch.name); - onLocationBlur(bestMatch.name); - onErrorChange(null); - focusController.focus(rowKey, 'quantity'); - } else { - onErrorChange('No matches found'); - } + onActivateField( + rowKey, + 'location', + selectedLocation?.name ?? item.locationName ?? '', + ); } }} - /> + sx={{ + display: 'flex', + alignItems: 'center', + gap: 0.75, + cursor: 'text', + color: 'text.primary', + textDecoration: 'underline dotted', + textUnderlineOffset: '4px', + textDecorationColor: 'rgba(255,255,255,0.35)', + '&:hover .inline-edit-icon': { + opacity: 0.75, + }, + '&:focus-visible': { + outline: '1px solid rgba(74, 158, 255, 0.6)', + borderRadius: 1, + outlineOffset: 2, + }, + }} + aria-label={`Edit location for ${item.itemName ?? `Item ${item.uexItemId}`}`} + > + + {selectedLocation?.name || item.locationName || 'Select location'} + + + )} - /> + )} @@ -223,54 +318,107 @@ export const InventoryInlineRow = ({ ) : ( - { - const raw = e.target.value.trim(); - if (raw === '') { - onDraftChange({ quantity: '' }); - onErrorChange('Quantity is required'); - return; - } - const numeric = Number(raw); - if (Number.isNaN(numeric)) { - onDraftChange({ quantity: '' }); - } else { - onDraftChange({ quantity: Math.min(numeric, EDITOR_MODE_QUANTITY_MAX) }); - } - if (!Number.isInteger(numeric) || numeric <= 0) { - onErrorChange('Quantity must be an integer greater than 0'); - } else { - onErrorChange(null); - } - }} - inputProps={{ - inputMode: 'numeric', - pattern: '[0-9]*', - }} - inputRef={(el) => { - setQuantityRef(el, rowKey); - }} - onKeyDown={(event) => { - if (event.key === 'Enter') { - event.preventDefault(); - focusController.focus(rowKey, 'save'); - } - }} - sx={{ - maxWidth: 120, - '& input': { - MozAppearance: 'textfield', - }, - '& input::-webkit-outer-spin-button, & input::-webkit-inner-spin-button': { - WebkitAppearance: 'none', - margin: 0, - }, - }} - /> + <> + {quantityEditing ? ( + { + const raw = e.target.value.trim(); + if (raw === '') { + onDraftChange(item.id, { quantity: '' }); + onErrorChange(item.id, 'Quantity is required'); + return; + } + const numeric = Number(raw); + if (Number.isNaN(numeric)) { + onDraftChange(item.id, { quantity: '' }); + } else { + onDraftChange(item.id, { + quantity: Math.min(numeric, EDITOR_MODE_QUANTITY_MAX), + }); + } + if (!Number.isInteger(numeric) || numeric <= 0) { + onErrorChange(item.id, 'Quantity must be an integer greater than 0'); + } else { + onErrorChange(item.id, null); + } + }} + onBlur={() => onQuantityBlur(rowKey)} + inputProps={{ + inputMode: 'numeric', + pattern: '[0-9]*', + }} + inputRef={(el) => { + setQuantityRef(el, rowKey); + }} + onKeyDown={(event) => { + if (event.key === 'Enter') { + event.preventDefault(); + focusController.focus(rowKey, 'save'); + } else if (event.key === 'Escape') { + event.preventDefault(); + onDraftChange(item.id, { quantity: Number(item.quantity) || 0 }); + onErrorChange(item.id, null); + onQuantityBlur(rowKey); + } + }} + sx={{ + maxWidth: 120, + '& input': { + MozAppearance: 'textfield', + }, + '& input::-webkit-outer-spin-button, & input::-webkit-inner-spin-button': { + WebkitAppearance: 'none', + margin: 0, + }, + }} + /> + ) : ( + onActivateField(rowKey, 'quantity')} + onFocus={() => onActivateField(rowKey, 'quantity')} + onKeyDown={(event) => { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + onActivateField(rowKey, 'quantity'); + } + }} + sx={{ + display: 'flex', + alignItems: 'center', + gap: 0.75, + cursor: 'text', + color: 'text.primary', + textDecoration: 'underline dotted', + textUnderlineOffset: '4px', + textDecorationColor: 'rgba(255,255,255,0.35)', + '&:hover .inline-edit-icon': { + opacity: 0.75, + }, + '&:focus-visible': { + outline: '1px solid rgba(74, 158, 255, 0.6)', + borderRadius: 1, + outlineOffset: 2, + }, + }} + aria-label={`Edit quantity for ${item.itemName ?? `Item ${item.uexItemId}`}`} + > + + {displayQuantity.toLocaleString()} + + + + )} + )} @@ -327,13 +475,13 @@ export const InventoryInlineRow = ({ onSave(item)} disabled={inlineSaving} data-testid={`inline-save-${item.id}`} onKeyDown={(event) => { if (event.key === 'Enter') { event.preventDefault(); - onSave(); + onSave(item); } }} ref={(el: HTMLButtonElement | null) => { @@ -344,7 +492,7 @@ export const InventoryInlineRow = ({ ) : ( - + onOpenActions?.(event, item)}> @@ -354,4 +502,34 @@ export const InventoryInlineRow = ({ ); }; -export default InventoryInlineRow; +const areEqual = (prev: InventoryInlineRowProps, next: InventoryInlineRowProps) => + prev.item === next.item && + prev.density === next.density && + prev.allLocations === next.allLocations && + prev.locationNameById === next.locationNameById && + prev.inlineDraft === next.inlineDraft && + prev.inlineLocationInput === next.inlineLocationInput && + prev.locationEditing === next.locationEditing && + prev.quantityEditing === next.quantityEditing && + prev.inlineSaving === next.inlineSaving && + prev.inlineSaved === next.inlineSaved && + prev.inlineError === next.inlineError && + prev.isDirty === next.isDirty && + prev.isRowActive === next.isRowActive && + prev.focusController === next.focusController && + prev.rowKey === next.rowKey && + prev.onDraftChange === next.onDraftChange && + prev.onErrorChange === next.onErrorChange && + prev.onLocationInputChange === next.onLocationInputChange && + prev.onLocationFocus === next.onLocationFocus && + prev.onLocationBlur === next.onLocationBlur && + prev.onQuantityBlur === next.onQuantityBlur && + prev.onActivateField === next.onActivateField && + prev.onSave === next.onSave && + prev.onOpenActions === next.onOpenActions && + prev.setLocationRef === next.setLocationRef && + prev.setQuantityRef === next.setQuantityRef && + prev.setSaveRef === next.setSaveRef; + +export { InventoryInlineRow }; +export default memo(InventoryInlineRow, areEqual); diff --git a/frontend/src/hooks/useMemoizedLocations.ts b/frontend/src/hooks/useMemoizedLocations.ts index 1239456..05074a6 100644 --- a/frontend/src/hooks/useMemoizedLocations.ts +++ b/frontend/src/hooks/useMemoizedLocations.ts @@ -5,8 +5,13 @@ interface LocationOption { name: string; } -export const useMemoizedLocations = (allLocations: LocationOption[], input: string) => { +export const useMemoizedLocations = ( + allLocations: LocationOption[], + input: string, + isActive: boolean, +) => { const filtered = useMemo(() => { + if (!isActive) return []; const term = input.trim().toLowerCase(); return allLocations .filter((opt) => opt.name.toLowerCase().includes(term)) @@ -21,11 +26,7 @@ export const useMemoizedLocations = (allLocations: LocationOption[], input: stri if (aIndex !== bIndex) return aIndex - bIndex; return a.name.localeCompare(b.name); }); - }, [allLocations, input]); + }, [allLocations, input, isActive]); - const getSelected = (id: number | '') => - typeof id === 'number' ? allLocations.find((loc) => loc.id === id) ?? null : null; - - return { filtered, getSelected }; + return { filtered }; }; - diff --git a/frontend/src/pages/Inventory.tsx b/frontend/src/pages/Inventory.tsx index c2b603d..7a59fed 100644 --- a/frontend/src/pages/Inventory.tsx +++ b/frontend/src/pages/Inventory.tsx @@ -45,6 +45,7 @@ import CallSplitIcon from '@mui/icons-material/CallSplit'; import ShareIcon from '@mui/icons-material/Share'; import UnpublishedIcon from '@mui/icons-material/Unpublished'; import ViewAgendaIcon from '@mui/icons-material/ViewAgenda'; +import BusinessIcon from '@mui/icons-material/Business'; import { inventoryService, InventoryCategory, InventoryItem, OrgInventoryItem } from '../services/inventory.service'; import { uexService, CatalogItem } from '../services/uex.service'; import { locationCache } from '../services/locationCache'; @@ -54,12 +55,37 @@ import { useFocusController } from '../hooks/useFocusController'; import InventoryInlineRow from '../components/inventory/InventoryInlineRow'; import InventoryNewRow from '../components/inventory/InventoryNewRow'; import InventoryFiltersPanel from '../components/inventory/InventoryFiltersPanel'; +import { OrgPermission, permissionsService } from '../services/permissions.service'; type InventoryRecord = InventoryItem | OrgInventoryItem; type ActionMode = 'edit' | 'split' | 'share' | 'delete' | null; const GAME_ID = 1; const EDITOR_MODE_QUANTITY_MAX = 100000; +const ORG_ACCENT = '#f2a255'; +const VIEW_MODE_STORAGE_KEY = 'inventory:viewMode'; +const ORG_ID_STORAGE_KEY = 'inventory:selectedOrgId'; +const DENSITY_STORAGE_KEY = 'inventory:density'; + +const readStoredViewMode = (): 'personal' | 'org' => { + if (typeof window === 'undefined') return 'personal'; + const stored = window.sessionStorage.getItem(VIEW_MODE_STORAGE_KEY); + return stored === 'org' ? 'org' : 'personal'; +}; + +const readStoredOrgId = (): number | null => { + if (typeof window === 'undefined') return null; + const stored = window.sessionStorage.getItem(ORG_ID_STORAGE_KEY); + if (!stored) return null; + const parsed = Number.parseInt(stored, 10); + return Number.isNaN(parsed) ? null : parsed; +}; + +const readStoredDensity = (): 'standard' | 'compact' => { + if (typeof window === 'undefined') return 'standard'; + const stored = window.sessionStorage.getItem(DENSITY_STORAGE_KEY); + return stored === 'compact' ? 'compact' : 'standard'; +}; const valueText = (value: number) => `${value.toLocaleString()} qty`; @@ -73,8 +99,8 @@ const InventoryPage = () => { const systemSelectRef = useRef(null); const [user, setUser] = useState<{ userId: number; username: string } | null>(null); const [orgOptions, setOrgOptions] = useState<{ id: number; name: string }[]>([]); - const [selectedOrgId, setSelectedOrgId] = useState(null); - const [viewMode, setViewMode] = useState<'personal' | 'org'>('personal'); + const [selectedOrgId, setSelectedOrgId] = useState(() => readStoredOrgId()); + const [viewMode, setViewMode] = useState<'personal' | 'org'>(() => readStoredViewMode()); const [categories, setCategories] = useState([]); const [items, setItems] = useState([]); const [totalCount, setTotalCount] = useState(0); @@ -93,7 +119,7 @@ const InventoryPage = () => { const [catalogItems, setCatalogItems] = useState([]); const [catalogTotal, setCatalogTotal] = useState(0); const [catalogPage, setCatalogPage] = useState(0); - const [catalogRowsPerPage, setCatalogRowsPerPage] = useState(10); + const [catalogRowsPerPage, setCatalogRowsPerPage] = useState(25); const [catalogLoading, setCatalogLoading] = useState(false); const [catalogError, setCatalogError] = useState(null); const [selectedCatalogItem, setSelectedCatalogItem] = useState(null); @@ -104,6 +130,9 @@ const InventoryPage = () => { const [newItemQuantity, setNewItemQuantity] = useState(1); const [newItemNotes, setNewItemNotes] = useState(''); const [addSubmitting, setAddSubmitting] = useState(false); + const [orgPermissions, setOrgPermissions] = useState([]); + const [orgPermissionsLoading, setOrgPermissionsLoading] = useState(false); + const [orgPermissionsError, setOrgPermissionsError] = useState(null); const [filters, setFilters] = useState({ search: '', @@ -117,7 +146,7 @@ const InventoryPage = () => { const [groupBy, setGroupBy] = useState<'none' | 'category' | 'location' | 'share'>('none'); const [page, setPage] = useState(0); const [rowsPerPage, setRowsPerPage] = useState(25); - const [density, setDensity] = useState<'standard' | 'compact'>('standard'); + const [density, setDensity] = useState<'standard' | 'compact'>(() => readStoredDensity()); const [inlineDrafts, setInlineDrafts] = useState< Record >({}); @@ -125,8 +154,15 @@ const InventoryPage = () => { const [inlineSaved, setInlineSaved] = useState>(new Set()); const [inlineError, setInlineError] = useState>({}); const [allLocations, setAllLocations] = useState<{ id: number; name: string }[]>([]); + const locationNameById = useMemo( + () => new Map(allLocations.map((loc) => [loc.id, loc.name])), + [allLocations], + ); const [inlineLocationInputs, setInlineLocationInputs] = useState>({}); - const [locationEditing, setLocationEditing] = useState>({}); + const [inlineActiveField, setInlineActiveField] = useState<{ + rowKey: string; + field: 'location' | 'quantity'; + } | null>(null); const [pendingFocusAfterPageChange, setPendingFocusAfterPageChange] = useState(false); const [newRowDraft, setNewRowDraft] = useState<{ itemId: number | ''; @@ -154,6 +190,34 @@ const InventoryPage = () => { const [newRowSaving, setNewRowSaving] = useState(false); const debouncedSearch = useDebounce(filters.search, 350); const debouncedCatalogSearch = useDebounce(catalogSearch, 350); + const isOrgMode = viewMode === 'org'; + + useEffect(() => { + window.sessionStorage.setItem(VIEW_MODE_STORAGE_KEY, viewMode); + }, [viewMode]); + + useEffect(() => { + if (selectedOrgId === null) { + window.sessionStorage.removeItem(ORG_ID_STORAGE_KEY); + return; + } + window.sessionStorage.setItem(ORG_ID_STORAGE_KEY, selectedOrgId.toString()); + }, [selectedOrgId]); + + useEffect(() => { + window.sessionStorage.setItem(DENSITY_STORAGE_KEY, density); + }, [density]); + + useEffect(() => { + if (orgOptions.length === 0 || selectedOrgId === null) return; + const isValidOrg = orgOptions.some((org) => org.id === selectedOrgId); + if (!isValidOrg) { + setSelectedOrgId(null); + if (viewMode === 'org') { + setViewMode('personal'); + } + } + }, [orgOptions, selectedOrgId, viewMode]); const debouncedNewItemSearch = useDebounce(newRowItemInput, 300); const debouncedNewLocationSearch = useDebounce(newRowLocationInput, 200); const getRowOrder = useCallback( @@ -187,6 +251,107 @@ const InventoryPage = () => { const newRowSaveRef = useRef(null); const newRowItemCache = useRef>(new Map()); + const activateInlineField = useCallback( + ( + rowKey: string, + field: 'location' | 'quantity', + initialInput?: string, + ) => { + setInlineActiveField({ rowKey, field }); + if (field === 'location') { + setInlineLocationInputs((prev) => ({ + ...prev, + [rowKey]: initialInput ?? prev[rowKey] ?? '', + })); + } + const parsedId = Number(rowKey); + if (!Number.isNaN(parsedId)) { + setInlineError((prev) => ({ ...prev, [parsedId]: null })); + } + }, + [], + ); + const handleInlineDraftChange = useCallback( + (itemId: string, changes: Partial<{ locationId: number | ''; quantity: number | '' }>) => { + setInlineDrafts((prev) => { + const nextLocation = + changes.locationId === undefined + ? prev[itemId]?.locationId ?? '' + : Number.isNaN(Number(changes.locationId)) + ? '' + : (Number(changes.locationId) as number); + return { + ...prev, + [itemId]: { + locationId: nextLocation, + quantity: + changes.quantity === undefined + ? prev[itemId]?.quantity ?? 0 + : (changes.quantity as number | ''), + }, + }; + }); + setInlineError((prev) => ({ ...prev, [itemId]: null })); + }, + [], + ); + const handleInlineErrorChange = useCallback((itemId: string, message: string | null) => { + setInlineError((prev) => ({ + ...prev, + [itemId]: message, + })); + }, []); + const handleInlineLocationInputChange = useCallback((rowKey: string, value: string) => { + setInlineLocationInputs((prev) => ({ + ...prev, + [rowKey]: value, + })); + }, []); + const handleInlineLocationFocus = useCallback((itemId: string) => { + setInlineError((prev) => ({ ...prev, [itemId]: null })); + }, []); + const handleInlineLocationBlur = useCallback( + ( + rowKey: string, + itemId: string, + draftLocationId: number | '', + selectedName?: string, + ) => { + setInlineLocationInputs((prev) => ({ + ...prev, + [rowKey]: + selectedName ?? + (Number.isFinite(draftLocationId) + ? locationNameById.get(draftLocationId as number) + : undefined) ?? + '', + })); + setInlineError((prev) => ({ ...prev, [itemId]: null })); + setInlineActiveField((prev) => { + if (!prev || prev.rowKey !== rowKey) return prev; + if (prev.field !== 'location') return prev; + return null; + }); + }, + [locationNameById], + ); + const handleInlineQuantityBlur = useCallback((rowKey: string) => { + setInlineActiveField((prev) => { + if (!prev || prev.rowKey !== rowKey) return prev; + if (prev.field !== 'quantity') return prev; + return null; + }); + }, []); + const handleLocationRef = useCallback((ref: HTMLInputElement | null, key: string) => { + locationRefs.current[key] = ref; + }, []); + const handleQuantityRef = useCallback((ref: HTMLInputElement | null, key: string) => { + quantityRefs.current[key] = ref; + }, []); + const handleSaveRef = useCallback((ref: HTMLButtonElement | null, key: string) => { + saveRefs.current[key] = ref; + }, []); + const handleLogout = () => { localStorage.removeItem('access_token'); localStorage.removeItem('refresh_token'); @@ -199,10 +364,28 @@ const InventoryPage = () => { setActionMode(null); }; - const handleActionOpen = (event: React.MouseEvent, item: InventoryRecord) => { - setActionAnchor(event.currentTarget); - setActionItem(item); - }; + const handleActionOpen = useCallback( + (event: React.MouseEvent, item: InventoryRecord) => { + setActionAnchor(event.currentTarget); + setActionItem(item); + }, + [], + ); + + const canManageOrgInventory = useMemo( + () => + orgPermissions.includes(OrgPermission.CAN_EDIT_ORG_INVENTORY) || + orgPermissions.includes(OrgPermission.CAN_ADMIN_ORG_INVENTORY), + [orgPermissions], + ); + const showAddButton = + viewMode === 'personal' || + (viewMode === 'org' && Boolean(selectedOrgId) && canManageOrgInventory); + const addButtonLabel = viewMode === 'org' ? 'Add org item' : 'Add item'; + const selectedOrgName = useMemo( + () => orgOptions.find((org) => org.id === selectedOrgId)?.name ?? 'Organization', + [orgOptions, selectedOrgId], + ); const fetchProfile = useCallback(async () => { try { @@ -311,7 +494,7 @@ const InventoryPage = () => { const limit = rowsPerPage; const offset = page * rowsPerPage; - if (viewMode === 'org' && selectedOrgId) { + if (isOrgMode && selectedOrgId) { const data = await inventoryService.getOrgInventory(selectedOrgId, { gameId: GAME_ID, search: debouncedSearch || undefined, @@ -384,6 +567,10 @@ const InventoryPage = () => { ]); const openAddDialog = () => { + if (viewMode === 'org' && !canManageOrgInventory) { + setCatalogError('You do not have permission to add items to this organization.'); + return; + } setAddDialogOpen(true); setSelectedCatalogItem(null); setCatalogSearch(''); @@ -401,10 +588,19 @@ const InventoryPage = () => { }; const handleCreateInventoryItem = async (options?: { stayOpen?: boolean }) => { + const isOrgView = viewMode === 'org' && selectedOrgId !== null; if (!selectedCatalogItem) { setCatalogError('Select an item to add.'); return; } + if (viewMode === 'org' && !selectedOrgId) { + setCatalogError('Select an organization.'); + return; + } + if (viewMode === 'org' && !canManageOrgInventory) { + setCatalogError('You do not have permission to add items to this organization.'); + return; + } if (destinationSelection.locationId === '') { setCatalogError('Select a location.'); return; @@ -426,14 +622,44 @@ const InventoryPage = () => { if (existing) { const shouldMerge = window.confirm( - 'An item with this location already exists. Merge quantities?', + isOrgView + ? 'This org already has this item at the selected location. Merge quantities?' + : 'An item with this location already exists. Merge quantities?', ); if (shouldMerge) { const newQuantity = (Number(existing.quantity) || 0) + Number(newItemQuantity); - await inventoryService.updateItem(existing.id, { - quantity: newQuantity, + if (isOrgView && selectedOrgId !== null) { + await inventoryService.updateOrgItem(selectedOrgId, existing.id, { + quantity: newQuantity, + locationId: numericLocationId, + }); + } else { + await inventoryService.updateItem(existing.id, { + quantity: newQuantity, + locationId: numericLocationId, + }); + } + } else if (isOrgView) { + setCatalogError('This item already exists for the selected location.'); + return; + } else { + await inventoryService.createItem({ + gameId: GAME_ID, + uexItemId: selectedCatalogItem.uexId, + locationId: numericLocationId, + quantity: newItemQuantity, + notes: newItemNotes || undefined, + }); + } + } else { + if (isOrgView && selectedOrgId !== null) { + await inventoryService.createOrgItem(selectedOrgId, { + gameId: GAME_ID, + uexItemId: selectedCatalogItem.uexId, locationId: numericLocationId, + quantity: newItemQuantity, + notes: newItemNotes || undefined, }); } else { await inventoryService.createItem({ @@ -444,14 +670,6 @@ const InventoryPage = () => { notes: newItemNotes || undefined, }); } - } else { - await inventoryService.createItem({ - gameId: GAME_ID, - uexItemId: selectedCatalogItem.uexId, - locationId: numericLocationId, - quantity: newItemQuantity, - notes: newItemNotes || undefined, - }); } await fetchInventory(); @@ -464,7 +682,45 @@ const InventoryPage = () => { } } catch (err) { console.error('Error adding inventory item', err); - setCatalogError('Unable to add item right now.'); + const status = err && typeof err === 'object' && 'response' in err + ? (err as { response?: { status?: number } }).response?.status + : undefined; + if (status === 403) { + setCatalogError('You do not have permission to add items to this organization.'); + } else if (status === 409 && viewMode === 'org' && selectedOrgId) { + const numericLocationId = Number(destinationSelection.locationId); + const existing = items.find( + (item) => + item.uexItemId === selectedCatalogItem?.uexId && + item.locationId === numericLocationId, + ); + if (existing) { + const shouldMerge = window.confirm( + 'This org already has this item at the selected location. Merge quantities?', + ); + if (shouldMerge) { + const newQuantity = + (Number(existing.quantity) || 0) + Number(newItemQuantity); + await inventoryService.updateOrgItem(selectedOrgId, existing.id, { + quantity: newQuantity, + locationId: numericLocationId, + }); + await fetchInventory(); + if (options?.stayOpen) { + setNewItemQuantity(1); + setNewItemNotes(''); + setCatalogError(null); + } else { + closeAddDialog(); + } + setAddSubmitting(false); + return; + } + } + setCatalogError('This item already exists for the selected location.'); + } else { + setCatalogError('Unable to add item right now.'); + } } finally { setAddSubmitting(false); } @@ -487,6 +743,39 @@ const InventoryPage = () => { } }, [user, fetchOrganizations]); + useEffect(() => { + if (viewMode !== 'org' || !user?.userId || !selectedOrgId) { + setOrgPermissions([]); + setOrgPermissionsError(null); + return; + } + let isMounted = true; + setOrgPermissionsLoading(true); + permissionsService + .getUserPermissions(user.userId, selectedOrgId) + .then((permissions) => { + if (isMounted) { + setOrgPermissions(permissions); + setOrgPermissionsError(null); + } + }) + .catch((err) => { + console.error('Failed to load org permissions', err); + if (isMounted) { + setOrgPermissions([]); + setOrgPermissionsError('Unable to load organization permissions.'); + } + }) + .finally(() => { + if (isMounted) { + setOrgPermissionsLoading(false); + } + }); + return () => { + isMounted = false; + }; + }, [viewMode, user?.userId, selectedOrgId]); + useEffect(() => { if (user) { fetchInventory(); @@ -507,6 +796,12 @@ const InventoryPage = () => { } }, [addDialogOpen, fetchCatalog]); + useEffect(() => { + if (addDialogOpen && viewMode === 'org' && !canManageOrgInventory) { + setAddDialogOpen(false); + } + }, [addDialogOpen, viewMode, canManageOrgInventory]); + useEffect(() => { if (!addDialogOpen) return; const handle = window.requestAnimationFrame(() => { @@ -650,29 +945,6 @@ const InventoryPage = () => { const isEditorMode = density === 'compact'; const newRowOrgBlocked = viewMode === 'org' && !selectedOrgId; - const setInlineDraft = ( - id: string, - changes: Partial<{ locationId: number | ''; quantity: number | '' }>, - ) => { - setInlineDrafts((prev) => { - const nextLocation = - changes.locationId === undefined - ? prev[id]?.locationId ?? '' - : Number.isNaN(Number(changes.locationId)) - ? '' - : (Number(changes.locationId) as number); - return { - ...prev, - [id]: { - locationId: nextLocation, - quantity: - changes.quantity === undefined ? prev[id]?.quantity ?? 0 : (changes.quantity as number | ''), - }, - }; - }); - setInlineError((prev) => ({ ...prev, [id]: null })); - }; - const resetNewRowDraft = () => { setNewRowDraft({ itemId: '', @@ -755,25 +1027,17 @@ const InventoryPage = () => { const unregisters: Array<() => void> = []; items.forEach((item) => { const key = item.id.toString(); - const locationRef = locationRefs.current[key]; - const quantityRef = quantityRefs.current[key]; + unregisters.push( + focusController.register(key, 'location', () => { + activateInlineField(key, 'location'); + }), + ); + unregisters.push( + focusController.register(key, 'quantity', () => { + activateInlineField(key, 'quantity'); + }), + ); const saveRef = saveRefs.current[key]; - if (locationRef) { - unregisters.push( - focusController.register(key, 'location', () => { - locationRef.focus(); - locationRef.select?.(); - }), - ); - } - if (quantityRef) { - unregisters.push( - focusController.register(key, 'quantity', () => { - quantityRef.focus(); - quantityRef.select?.(); - }), - ); - } if (saveRef) { unregisters.push( focusController.register(key, 'save', () => { @@ -785,7 +1049,22 @@ const InventoryPage = () => { return () => { unregisters.forEach((fn) => fn()); }; - }, [items, focusController]); + }, [items, focusController, activateInlineField]); + + useEffect(() => { + if (!inlineActiveField) return; + const { rowKey, field } = inlineActiveField; + const ref = + field === 'location' + ? locationRefs.current[rowKey] + : quantityRefs.current[rowKey]; + if (!ref) return; + const handle = window.requestAnimationFrame(() => { + ref.focus(); + ref.select?.(); + }); + return () => window.cancelAnimationFrame(handle); + }, [inlineActiveField]); useEffect(() => { const unregisters: Array<() => void> = []; @@ -842,6 +1121,7 @@ const InventoryPage = () => { useEffect(() => { if (!isEditorMode) { resetNewRowDraft(); + setInlineActiveField(null); } }, [isEditorMode]); @@ -928,7 +1208,7 @@ const InventoryPage = () => { }; }, [debouncedNewItemSearch, isEditorMode]); - const handleInlineSave = async (item: InventoryRecord) => { + const handleInlineSave = useCallback(async (item: InventoryRecord) => { const draft = inlineDrafts[item.id] ?? { locationId: item.locationId ?? '', quantity: Number(item.quantity) || 0, @@ -965,8 +1245,7 @@ const InventoryPage = () => { ...item, locationId: parsedLocationId, quantity: parsedQuantity, - locationName: - allLocations.find((loc) => loc.id === parsedLocationId)?.name || item.locationName, + locationName: locationNameById.get(parsedLocationId) || item.locationName, }; setItems((prev) => prev.map((entry) => (entry.id === item.id ? updatedItem : entry)), @@ -1013,9 +1292,18 @@ const InventoryPage = () => { updated.delete(item.id); setInlineSaving(updated); } - }; + }, [ + focusController, + inlineDrafts, + inlineSaving, + inventoryService, + isOrgMode, + items, + locationNameById, + selectedOrgId, + ]); - const handleInlineSaveAndAdvance = async (item: InventoryRecord) => { + const handleInlineSaveAndAdvance = useCallback(async (item: InventoryRecord) => { const saved = await handleInlineSave(item); if (saved) { const advanced = await focusController.focusNext(item.id.toString(), 'save'); @@ -1023,7 +1311,7 @@ const InventoryPage = () => { focusController.focus(items[0].id.toString(), 'location'); } } - }; + }, [focusController, handleInlineSave, items]); const openActionDialog = (mode: ActionMode) => { setActionMode(mode); @@ -1461,6 +1749,7 @@ const InventoryPage = () => { ); } + const inventoryBusy = refreshing; const showEmptyState = filteredItems.length === 0 && !refreshing; const renderInlineRow = (item: InventoryRecord) => { const rowKey = item.id.toString(); @@ -1475,13 +1764,17 @@ const InventoryPage = () => { const draftQuantityNumber = Number(draft.quantity); const isDirty = draftLocationId !== originalLocationId || draftQuantityNumber !== originalQuantity; + const isRowActive = inlineActiveField?.rowKey === rowKey; + const isLocationActive = isRowActive && inlineActiveField?.field === 'location'; + const isQuantityActive = isRowActive && inlineActiveField?.field === 'quantity'; + const resolvedLocationName = Number.isFinite(draftLocationId) + ? locationNameById.get(draftLocationId as number) + : undefined; const inlineLocationValue = inlineLocationInputs[rowKey] ?? - (locationEditing[rowKey] + (isLocationActive ? '' - : allLocations.find((loc) => loc.id === (typeof draft.locationId === 'number' ? draft.locationId : Number(draft.locationId)))?.name ?? - item.locationName ?? - ''); + : resolvedLocationName ?? item.locationName ?? ''); const saving = inlineSaving.has(item.id); const errorText = inlineError[item.id]; const saved = inlineSaved.has(item.id.toString()); @@ -1492,58 +1785,30 @@ const InventoryPage = () => { item={item} density={density} allLocations={allLocations} + locationNameById={locationNameById} inlineDraft={draft} inlineLocationInput={inlineLocationValue} - locationEditing={Boolean(locationEditing[rowKey])} + locationEditing={isLocationActive} + quantityEditing={isQuantityActive} inlineSaving={saving} inlineSaved={saved} inlineError={errorText} isDirty={isDirty} + isRowActive={isRowActive} focusController={focusController} rowKey={rowKey} - onDraftChange={(changes) => setInlineDraft(item.id, changes)} - onErrorChange={(message) => - setInlineError((prev) => ({ - ...prev, - [item.id]: message, - })) - } - onLocationInputChange={(value) => - setInlineLocationInputs((prev) => ({ - ...prev, - [rowKey]: value, - })) - } - onLocationFocus={() => { - setInlineLocationInputs((prev) => ({ - ...prev, - [rowKey]: '', - })); - setLocationEditing((prev) => ({ ...prev, [rowKey]: true })); - setInlineError((prev) => ({ ...prev, [item.id]: null })); - }} - onLocationBlur={(selectedName) => { - setInlineLocationInputs((prev) => ({ - ...prev, - [rowKey]: - selectedName ?? - allLocations.find((loc) => loc.id === draftLocationId)?.name ?? - '', - })); - setLocationEditing((prev) => ({ ...prev, [rowKey]: false })); - setInlineError((prev) => ({ ...prev, [item.id]: null })); - }} - onSave={() => handleInlineSaveAndAdvance(item)} - onOpenActions={(e) => handleActionOpen(e, item)} - setLocationRef={(ref, key) => { - locationRefs.current[key] = ref; - }} - setQuantityRef={(ref, key) => { - quantityRefs.current[key] = ref; - }} - setSaveRef={(ref, key) => { - saveRefs.current[key] = ref; - }} + onDraftChange={handleInlineDraftChange} + onErrorChange={handleInlineErrorChange} + onLocationInputChange={handleInlineLocationInputChange} + onLocationFocus={handleInlineLocationFocus} + onLocationBlur={handleInlineLocationBlur} + onQuantityBlur={handleInlineQuantityBlur} + onActivateField={activateInlineField} + onSave={handleInlineSaveAndAdvance} + onOpenActions={handleActionOpen} + setLocationRef={handleLocationRef} + setQuantityRef={handleQuantityRef} + setSaveRef={handleSaveRef} /> ); }; @@ -1586,8 +1851,75 @@ const InventoryPage = () => { - - + {isOrgMode && ( + + + + + + Organization mode + + + + {selectedOrgId ? `Working in ${selectedOrgName}` : 'Select an organization to continue.'} + + + + + )} + + + {inventoryBusy && ( + + + + + Loading inventory... + + + + )} + + { orgOptions={orgOptions} userInitial={user?.username?.charAt(0).toUpperCase() || 'U'} onOpenAddDialog={openAddDialog} - showAddButton={viewMode === 'personal'} + showAddButton={showAddButton} + addButtonLabel={addButtonLabel} totalCount={totalCount} itemCount={items.length} autoFocusSearch + disabled={inventoryBusy} /> + {viewMode === 'org' && selectedOrgId && !canManageOrgInventory && !orgPermissionsLoading && ( + + You do not have permission to add items to this organization. + + )} + {orgPermissionsError && ( + + {orgPermissionsError} + + )} @@ -1873,13 +2217,20 @@ const InventoryPage = () => { component="div" count={totalCount} page={page} - onPageChange={(_, newPage) => setPage(newPage)} + onPageChange={(_, newPage) => { + if (inventoryBusy) return; + setPage(newPage); + }} rowsPerPage={rowsPerPage} onRowsPerPageChange={(event) => { + if (inventoryBusy) return; setRowsPerPage(parseInt(event.target.value, 10)); setPage(0); }} rowsPerPageOptions={[10, 25, 50, 100, 250]} + backIconButtonProps={{ disabled: inventoryBusy }} + nextIconButtonProps={{ disabled: inventoryBusy }} + SelectProps={{ disabled: inventoryBusy }} sx={{ mt: 2 }} /> @@ -1888,6 +2239,7 @@ const InventoryPage = () => { +
    { fullWidth maxWidth="lg" > - Quick add inventory item + + {viewMode === 'org' + ? `Add org inventory item ยท ${selectedOrgName}` + : 'Quick add inventory item'} + {catalogError && {catalogError}} @@ -2075,7 +2431,7 @@ const InventoryPage = () => { setCatalogRowsPerPage(parseInt(event.target.value, 10)); setCatalogPage(0); }} - rowsPerPageOptions={[10, 25, 50]} + rowsPerPageOptions={[25, 50]} />
    diff --git a/frontend/src/services/permissions.service.ts b/frontend/src/services/permissions.service.ts new file mode 100644 index 0000000..42099e3 --- /dev/null +++ b/frontend/src/services/permissions.service.ts @@ -0,0 +1,35 @@ +import axios from 'axios'; + +const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:3001'; + +export const OrgPermission = { + CAN_VIEW_ORG_INVENTORY: 'can_view_org_inventory', + CAN_EDIT_ORG_INVENTORY: 'can_edit_org_inventory', + CAN_ADMIN_ORG_INVENTORY: 'can_admin_org_inventory', + CAN_VIEW_MEMBER_SHARED_ITEMS: 'can_view_member_shared_items', +} as const; + +export type OrgPermission = (typeof OrgPermission)[keyof typeof OrgPermission]; + +const getAuthHeader = () => { + const token = localStorage.getItem('access_token'); + return { + Authorization: `Bearer ${token}`, + }; +}; + +export const permissionsService = { + async getUserPermissions( + userId: number, + organizationId: number, + ): Promise { + const response = await axios.get( + `${API_URL}/permissions/user/${userId}/organization/${organizationId}`, + { + headers: getAuthHeader(), + }, + ); + const permissions = response.data?.permissions; + return Array.isArray(permissions) ? permissions : []; + }, +}; From 31e6e55e80e9a20f9c9e17092da76417a73b053d Mon Sep 17 00:00:00 2001 From: Demian Date: Mon, 30 Mar 2026 23:19:16 -0400 Subject: [PATCH 2/6] fix: satisfy inventory hook lint dependencies --- frontend/src/pages/Inventory.tsx | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/frontend/src/pages/Inventory.tsx b/frontend/src/pages/Inventory.tsx index 7a59fed..53580f8 100644 --- a/frontend/src/pages/Inventory.tsx +++ b/frontend/src/pages/Inventory.tsx @@ -552,7 +552,7 @@ const InventoryPage = () => { } }, [ user, - viewMode, + isOrgMode, selectedOrgId, filters.categoryId, filters.locationId, @@ -1296,11 +1296,10 @@ const InventoryPage = () => { focusController, inlineDrafts, inlineSaving, - inventoryService, - isOrgMode, items, locationNameById, selectedOrgId, + viewMode, ]); const handleInlineSaveAndAdvance = useCallback(async (item: InventoryRecord) => { From bffdab9b953cde08ca67090e18588e2a8cdd2db0 Mon Sep 17 00:00:00 2001 From: Demian Date: Mon, 30 Mar 2026 23:57:07 -0400 Subject: [PATCH 3/6] fix: address review feedback on org inventory flow --- .../org-inventory.controller.spec.ts | 61 +++++++ .../org-inventory/org-inventory.controller.ts | 70 ++++++-- .../inventory/InventoryInlineRow.tsx | 9 +- .../src/pages/Inventory.editor-mode.test.tsx | 151 ++++++++++++++++-- frontend/src/pages/Inventory.tsx | 22 +-- 5 files changed, 273 insertions(+), 40 deletions(-) create mode 100644 backend/src/modules/org-inventory/org-inventory.controller.spec.ts diff --git a/backend/src/modules/org-inventory/org-inventory.controller.spec.ts b/backend/src/modules/org-inventory/org-inventory.controller.spec.ts new file mode 100644 index 0000000..73c96ac --- /dev/null +++ b/backend/src/modules/org-inventory/org-inventory.controller.spec.ts @@ -0,0 +1,61 @@ +import { BadRequestException } from '@nestjs/common'; +import { OrgInventoryController } from './org-inventory.controller'; +import { OrgInventoryService } from './org-inventory.service'; + +describe('OrgInventoryController', () => { + let controller: OrgInventoryController; + let orgInventoryService: jest.Mocked>; + + beforeEach(() => { + orgInventoryService = { + search: jest.fn().mockResolvedValue({ + items: [], + total: 0, + limit: 25, + offset: 0, + }), + }; + + controller = new OrgInventoryController( + orgInventoryService as unknown as OrgInventoryService, + ); + }); + + it('accepts camelCase query aliases for org inventory filters', async () => { + await controller.list({ user: { userId: 7 } }, 42, { + gameId: '1', + categoryId: '5', + uexItemId: '9', + locationId: '12', + minQuantity: '0.25', + maxQuantity: '10.5', + limit: '25', + offset: '50', + }); + + expect(orgInventoryService.search).toHaveBeenCalledWith(7, { + orgId: 42, + gameId: 1, + categoryId: 5, + uexItemId: 9, + locationId: 12, + search: undefined, + limit: 25, + offset: 50, + sort: undefined, + order: undefined, + activeOnly: undefined, + minQuantity: 0.25, + maxQuantity: 10.5, + }); + }); + + it('throws a bad request for invalid numeric pagination params', async () => { + await expect( + controller.list({ user: { userId: 7 } }, 42, { + gameId: '1', + limit: 'abc', + }), + ).rejects.toThrow(new BadRequestException('limit must be a number')); + }); +}); diff --git a/backend/src/modules/org-inventory/org-inventory.controller.ts b/backend/src/modules/org-inventory/org-inventory.controller.ts index 489bbe0..8cda85b 100644 --- a/backend/src/modules/org-inventory/org-inventory.controller.ts +++ b/backend/src/modules/org-inventory/org-inventory.controller.ts @@ -39,6 +39,27 @@ import { export class OrgInventoryController { constructor(private readonly orgInventoryService: OrgInventoryService) {} + private readOptionalNumber( + query: Record, + keys: string[], + fieldName: string, + ): number | undefined { + const rawValue = keys + .map((key) => query[key]) + .find((value) => value !== undefined); + + if (rawValue === undefined || rawValue === '') { + return undefined; + } + + const parsedValue = Number(rawValue); + if (Number.isNaN(parsedValue)) { + throw new BadRequestException(`${fieldName} must be a number`); + } + + return parsedValue; + } + /** * List org inventory items with filtering * GET /api/orgs/:orgId/inventory @@ -62,22 +83,33 @@ export class OrgInventoryController { offset: number; }> { const userId = req.user.userId; - const parsedMinQuantity = Number( - query.min_quantity ?? query.minQuantity ?? Number.NaN, - ); - const parsedMaxQuantity = Number( - query.max_quantity ?? query.maxQuantity ?? Number.NaN, + const gameId = this.readOptionalNumber( + query, + ['game_id', 'gameId'], + 'game_id', ); const searchDto: OrgInventorySearchDto = { orgId, - gameId: Number(query.game_id ?? query.gameId), - categoryId: query.category_id ? Number(query.category_id) : undefined, - uexItemId: query.uex_item_id ? Number(query.uex_item_id) : undefined, - locationId: query.location_id ? Number(query.location_id) : undefined, + gameId: gameId ?? 0, + categoryId: this.readOptionalNumber( + query, + ['category_id', 'categoryId'], + 'category_id', + ), + uexItemId: this.readOptionalNumber( + query, + ['uex_item_id', 'uexItemId'], + 'uex_item_id', + ), + locationId: this.readOptionalNumber( + query, + ['location_id', 'locationId'], + 'location_id', + ), search: query.search, - limit: query.limit ? Number(query.limit) : undefined, - offset: query.offset ? Number(query.offset) : undefined, + limit: this.readOptionalNumber(query, ['limit'], 'limit'), + offset: this.readOptionalNumber(query, ['offset'], 'offset'), sort: query.sort, order: query.order, activeOnly: @@ -86,12 +118,16 @@ export class OrgInventoryController { : query.activeOnly !== undefined ? query.activeOnly === 'true' || query.activeOnly === true : undefined, - minQuantity: Number.isNaN(parsedMinQuantity) - ? undefined - : parsedMinQuantity, - maxQuantity: Number.isNaN(parsedMaxQuantity) - ? undefined - : parsedMaxQuantity, + minQuantity: this.readOptionalNumber( + query, + ['min_quantity', 'minQuantity'], + 'min_quantity', + ), + maxQuantity: this.readOptionalNumber( + query, + ['max_quantity', 'maxQuantity'], + 'max_quantity', + ), }; if (!searchDto.gameId) { diff --git a/frontend/src/components/inventory/InventoryInlineRow.tsx b/frontend/src/components/inventory/InventoryInlineRow.tsx index 40d7ff9..dc86390 100644 --- a/frontend/src/components/inventory/InventoryInlineRow.tsx +++ b/frontend/src/components/inventory/InventoryInlineRow.tsx @@ -18,6 +18,7 @@ import type { FocusController } from '../../utils/focusController'; import { useMemoizedLocations } from '../../hooks/useMemoizedLocations'; const EDITOR_MODE_QUANTITY_MAX = 100000; +const MIN_INVENTORY_QUANTITY = 0.01; export type InventoryRecord = InventoryItem | OrgInventoryItem; @@ -340,16 +341,16 @@ const InventoryInlineRow = ({ quantity: Math.min(numeric, EDITOR_MODE_QUANTITY_MAX), }); } - if (!Number.isInteger(numeric) || numeric <= 0) { - onErrorChange(item.id, 'Quantity must be an integer greater than 0'); + if (!Number.isFinite(numeric) || numeric < MIN_INVENTORY_QUANTITY) { + onErrorChange(item.id, 'Quantity must be at least 0.01'); } else { onErrorChange(item.id, null); } }} onBlur={() => onQuantityBlur(rowKey)} inputProps={{ - inputMode: 'numeric', - pattern: '[0-9]*', + inputMode: 'decimal', + pattern: '[0-9]*\\.?[0-9]*', }} inputRef={(el) => { setQuantityRef(el, rowKey); diff --git a/frontend/src/pages/Inventory.editor-mode.test.tsx b/frontend/src/pages/Inventory.editor-mode.test.tsx index fc61bdf..e50c23f 100644 --- a/frontend/src/pages/Inventory.editor-mode.test.tsx +++ b/frontend/src/pages/Inventory.editor-mode.test.tsx @@ -7,19 +7,22 @@ import type { LocationRecord } from '../services/location.service'; const mockUpdateItem = jest.fn(); const mockGetInventory = jest.fn(); +const mockGetOrgInventory = jest.fn(); const mockGetUserOrganizations = jest.fn(); const mockCreateItem = jest.fn(); const mockCreateOrgItem = jest.fn(); +const mockUpdateOrgItem = jest.fn(); const mockSearchItems = jest.fn(); +const mockGetUserPermissions = jest.fn(); jest.mock('../services/inventory.service', () => ({ inventoryService: { getCategories: jest.fn().mockResolvedValue([]), getUserOrganizations: (...args: unknown[]) => mockGetUserOrganizations(...args), getInventory: (...args: unknown[]) => mockGetInventory(...args), - getOrgInventory: jest.fn(), + getOrgInventory: (...args: unknown[]) => mockGetOrgInventory(...args), updateItem: (...args: unknown[]) => mockUpdateItem(...args), - updateOrgItem: jest.fn(), + updateOrgItem: (...args: unknown[]) => mockUpdateOrgItem(...args), createItem: (...args: unknown[]) => mockCreateItem(...args), createOrgItem: (...args: unknown[]) => mockCreateOrgItem(...args), shareItem: jest.fn(), @@ -41,6 +44,33 @@ jest.mock('../services/uex.service', () => ({ getStarSystems: jest.fn(), }, })); +jest.mock('../services/permissions.service', () => ({ + OrgPermission: { + CAN_VIEW_ORG_INVENTORY: 'can_view_org_inventory', + CAN_EDIT_ORG_INVENTORY: 'can_edit_org_inventory', + CAN_ADMIN_ORG_INVENTORY: 'can_admin_org_inventory', + CAN_VIEW_MEMBER_SHARED_ITEMS: 'can_view_member_shared_items', + }, + permissionsService: { + getUserPermissions: (...args: unknown[]) => mockGetUserPermissions(...args), + }, +})); +jest.mock('../components/location/SystemLocationSelector', () => ({ + __esModule: true, + default: ({ + onChange, + }: { + onChange: (value: { systemId: string; locationId: string }) => void; + }) => ( + + ), +})); jest.mock('../hooks/useMemoizedLocations', () => { const original = jest.requireActual('../hooks/useMemoizedLocations'); return { @@ -69,8 +99,10 @@ describe('Inventory editor mode inline controls', () => { beforeEach(() => { jest.resetAllMocks(); mockGetInventory.mockResolvedValue({ items: [mockItem], total: 1, limit: 25, offset: 0 }); + mockGetOrgInventory.mockResolvedValue({ items: [mockItem], total: 1, limit: 25, offset: 0 }); mockGetUserOrganizations.mockResolvedValue([]); mockUpdateItem.mockResolvedValue({}); + mockUpdateOrgItem.mockResolvedValue({}); mockCreateItem.mockResolvedValue({}); mockSearchItems.mockResolvedValue({ items: [ @@ -85,6 +117,7 @@ describe('Inventory editor mode inline controls', () => { limit: 20, offset: 0, }); + mockGetUserPermissions.mockResolvedValue(['can_edit_org_inventory']); const mockedLocationCache = locationCache as jest.Mocked; const mockLocations: LocationRecord[] = [ { @@ -308,7 +341,7 @@ describe('Inventory editor mode inline controls', () => { await waitFor(() => expect(document.activeElement).toBe(itemInput)); }); - it('prevents non-integer quantities and shows error', async () => { + it('prevents quantities below the minimum and shows error', async () => { render( @@ -330,12 +363,12 @@ describe('Inventory editor mode inline controls', () => { fireEvent.click(await screen.findByText('Test Location')); const quantityInput = screen.getByTestId('new-row-quantity'); - fireEvent.change(quantityInput, { target: { value: '7.5' } }); + fireEvent.change(quantityInput, { target: { value: '0' } }); const saveButton = screen.getByTestId('new-row-save'); fireEvent.click(saveButton); expect(mockCreateItem).not.toHaveBeenCalled(); - expect(screen.getByText('Quantity must be an integer greater than 0')).toBeInTheDocument(); + expect(screen.getByText('Quantity must be at least 0.01')).toBeInTheDocument(); }); it('keeps the row dirty and shows retry on API failure', async () => { @@ -374,7 +407,7 @@ describe('Inventory editor mode inline controls', () => { await waitFor(() => expect(mockCreateItem).toHaveBeenCalledTimes(2)); }); - it('shows inline quantity validation error for non-integer input and focuses the field', async () => { + it('allows decimal inline quantities and saves them', async () => { render( @@ -392,10 +425,110 @@ describe('Inventory editor mode inline controls', () => { fireEvent.click(screen.getByTestId('inline-save-item-1')); await waitFor(() => - expect(screen.getByText('Quantity must be an integer greater than 0')).toBeInTheDocument(), + expect(mockUpdateItem).toHaveBeenCalledWith('item-1', { + locationId: 200, + quantity: 3.5, + }), ); - await waitFor(() => expect(document.activeElement).toBe(quantityInput)); - expect(mockUpdateItem).not.toHaveBeenCalled(); + }); + + it('hides the org add flow when org permissions do not include edit/admin', async () => { + mockGetUserOrganizations.mockResolvedValue([ + { + id: 1, + userId: 1, + organizationId: 42, + roleId: 1, + organization: { id: 42, name: 'Test Org' }, + }, + ]); + mockGetUserPermissions.mockResolvedValue(['can_view_org_inventory']); + + render( + + + , + ); + + await waitFor(() => expect(screen.getByText('Test Item')).toBeInTheDocument()); + + const viewSelect = screen.getByLabelText('View'); + fireEvent.mouseDown(viewSelect); + fireEvent.click(await screen.findByText('Test Org')); + + await waitFor(() => + expect( + screen.getByText('You do not have permission to add items to this organization.'), + ).toBeInTheDocument(), + ); + expect(screen.queryByRole('button', { name: 'Add org item' })).not.toBeInTheDocument(); + }); + + it('handles org add conflicts by loading the existing item and merging quantities', async () => { + const confirmSpy = jest.spyOn(window, 'confirm').mockReturnValue(true); + mockGetUserOrganizations.mockResolvedValue([ + { + id: 1, + userId: 1, + organizationId: 42, + roleId: 1, + organization: { id: 42, name: 'Test Org' }, + }, + ]); + mockGetUserPermissions.mockResolvedValue(['can_edit_org_inventory']); + mockGetOrgInventory + .mockResolvedValueOnce({ items: [mockItem], total: 1, limit: 25, offset: 0 }) + .mockResolvedValueOnce({ + items: [ + { + id: 'org-item-1', + orgId: 42, + gameId: 1, + uexItemId: 300, + locationId: 200, + quantity: 4, + notes: '', + active: true, + dateAdded: new Date().toISOString(), + dateModified: new Date().toISOString(), + itemName: 'New Catalog Item', + locationName: 'Test Location', + }, + ], + total: 1, + limit: 1, + offset: 0, + }) + .mockResolvedValue({ items: [mockItem], total: 1, limit: 25, offset: 0 }); + mockCreateOrgItem.mockRejectedValueOnce({ response: { status: 409 } }); + + render( + + + , + ); + + await waitFor(() => expect(screen.getByText('Test Item')).toBeInTheDocument()); + + const viewSelect = screen.getByLabelText('View'); + fireEvent.mouseDown(viewSelect); + fireEvent.click(await screen.findByText('Test Org')); + + const addButton = await screen.findByRole('button', { name: 'Add org item' }); + fireEvent.click(addButton); + + fireEvent.click(await screen.findByText('New Catalog Item')); + fireEvent.click(screen.getByTestId('mock-system-location-selector')); + fireEvent.click(screen.getByRole('button', { name: 'Add & close' })); + + await waitFor(() => + expect(mockUpdateOrgItem).toHaveBeenCalledWith(42, 'org-item-1', { + quantity: 5, + locationId: 200, + }), + ); + + confirmSpy.mockRestore(); }); it('focuses inline save on API failure', async () => { diff --git a/frontend/src/pages/Inventory.tsx b/frontend/src/pages/Inventory.tsx index 53580f8..a404f71 100644 --- a/frontend/src/pages/Inventory.tsx +++ b/frontend/src/pages/Inventory.tsx @@ -62,6 +62,7 @@ type ActionMode = 'edit' | 'split' | 'share' | 'delete' | null; const GAME_ID = 1; const EDITOR_MODE_QUANTITY_MAX = 100000; +const MIN_INVENTORY_QUANTITY = 0.01; const ORG_ACCENT = '#f2a255'; const VIEW_MODE_STORAGE_KEY = 'inventory:viewMode'; const ORG_ID_STORAGE_KEY = 'inventory:selectedOrgId'; @@ -264,10 +265,7 @@ const InventoryPage = () => { [rowKey]: initialInput ?? prev[rowKey] ?? '', })); } - const parsedId = Number(rowKey); - if (!Number.isNaN(parsedId)) { - setInlineError((prev) => ({ ...prev, [parsedId]: null })); - } + setInlineError((prev) => ({ ...prev, [rowKey]: null })); }, [], ); @@ -980,8 +978,8 @@ const InventoryPage = () => { if (!Number.isInteger(parsedLocationId) || parsedLocationId <= 0) { errors.location = 'Select a valid location'; } - if (!Number.isInteger(parsedQuantity) || parsedQuantity <= 0) { - errors.quantity = 'Quantity must be an integer greater than 0'; + if (!Number.isFinite(parsedQuantity) || parsedQuantity < MIN_INVENTORY_QUANTITY) { + errors.quantity = 'Quantity must be at least 0.01'; } if (errors.item || errors.location || errors.quantity) { @@ -1217,13 +1215,17 @@ const InventoryPage = () => { draft.locationId === '' ? NaN : Number(draft.locationId); const parsedQuantity = Number(draft.quantity); - if (!Number.isInteger(parsedLocationId) || !Number.isInteger(parsedQuantity) || parsedQuantity <= 0) { + if ( + !Number.isInteger(parsedLocationId) || + !Number.isFinite(parsedQuantity) || + parsedQuantity < MIN_INVENTORY_QUANTITY + ) { setInlineError((prev) => ({ ...prev, [item.id]: !Number.isInteger(parsedLocationId) ? 'Select a valid location' - : 'Quantity must be an integer greater than 0', + : 'Quantity must be at least 0.01', })); if (!Number.isInteger(parsedLocationId)) { focusController.focus(item.id.toString(), 'location'); @@ -2141,10 +2143,10 @@ const InventoryPage = () => { ...prev, quantity: nextQuantity, })); - if (!Number.isInteger(numeric) || numeric <= 0) { + if (!Number.isFinite(numeric) || numeric < MIN_INVENTORY_QUANTITY) { setNewRowErrors((prev) => ({ ...prev, - quantity: 'Quantity must be an integer greater than 0', + quantity: 'Quantity must be at least 0.01', api: null, })); } else { From 7a5fd147ce8a6f2d99ca600e2ada44ee50526985 Mon Sep 17 00:00:00 2001 From: Demian Date: Tue, 31 Mar 2026 00:16:31 -0400 Subject: [PATCH 4/6] fix: tighten inventory query and inline validation --- .../org-inventory.controller.spec.ts | 18 ++++++++++++++ .../org-inventory/org-inventory.controller.ts | 24 +++++++++++++++++-- frontend/src/pages/Inventory.tsx | 7 ++++-- 3 files changed, 45 insertions(+), 4 deletions(-) diff --git a/backend/src/modules/org-inventory/org-inventory.controller.spec.ts b/backend/src/modules/org-inventory/org-inventory.controller.spec.ts index 73c96ac..6cd5e73 100644 --- a/backend/src/modules/org-inventory/org-inventory.controller.spec.ts +++ b/backend/src/modules/org-inventory/org-inventory.controller.spec.ts @@ -58,4 +58,22 @@ describe('OrgInventoryController', () => { }), ).rejects.toThrow(new BadRequestException('limit must be a number')); }); + + it('throws a bad request for non-integer or out-of-range pagination params', async () => { + await expect( + controller.list({ user: { userId: 7 } }, 42, { + gameId: '1', + limit: '10.5', + }), + ).rejects.toThrow(new BadRequestException('limit must be an integer')); + + await expect( + controller.list({ user: { userId: 7 } }, 42, { + gameId: '1', + offset: '-1', + }), + ).rejects.toThrow( + new BadRequestException('offset must be greater than or equal to 0'), + ); + }); }); diff --git a/backend/src/modules/org-inventory/org-inventory.controller.ts b/backend/src/modules/org-inventory/org-inventory.controller.ts index 8cda85b..8a0040b 100644 --- a/backend/src/modules/org-inventory/org-inventory.controller.ts +++ b/backend/src/modules/org-inventory/org-inventory.controller.ts @@ -43,6 +43,10 @@ export class OrgInventoryController { query: Record, keys: string[], fieldName: string, + options?: { + integer?: boolean; + min?: number; + }, ): number | undefined { const rawValue = keys .map((key) => query[key]) @@ -57,6 +61,16 @@ export class OrgInventoryController { throw new BadRequestException(`${fieldName} must be a number`); } + if (options?.integer && !Number.isInteger(parsedValue)) { + throw new BadRequestException(`${fieldName} must be an integer`); + } + + if (options?.min !== undefined && parsedValue < options.min) { + throw new BadRequestException( + `${fieldName} must be greater than or equal to ${options.min}`, + ); + } + return parsedValue; } @@ -108,8 +122,14 @@ export class OrgInventoryController { 'location_id', ), search: query.search, - limit: this.readOptionalNumber(query, ['limit'], 'limit'), - offset: this.readOptionalNumber(query, ['offset'], 'offset'), + limit: this.readOptionalNumber(query, ['limit'], 'limit', { + integer: true, + min: 1, + }), + offset: this.readOptionalNumber(query, ['offset'], 'offset', { + integer: true, + min: 0, + }), sort: query.sort, order: query.order, activeOnly: diff --git a/frontend/src/pages/Inventory.tsx b/frontend/src/pages/Inventory.tsx index a404f71..423efcf 100644 --- a/frontend/src/pages/Inventory.tsx +++ b/frontend/src/pages/Inventory.tsx @@ -275,6 +275,8 @@ const InventoryPage = () => { const nextLocation = changes.locationId === undefined ? prev[itemId]?.locationId ?? '' + : changes.locationId === '' + ? '' : Number.isNaN(Number(changes.locationId)) ? '' : (Number(changes.locationId) as number); @@ -1217,17 +1219,18 @@ const InventoryPage = () => { if ( !Number.isInteger(parsedLocationId) || + parsedLocationId <= 0 || !Number.isFinite(parsedQuantity) || parsedQuantity < MIN_INVENTORY_QUANTITY ) { setInlineError((prev) => ({ ...prev, [item.id]: - !Number.isInteger(parsedLocationId) + !Number.isInteger(parsedLocationId) || parsedLocationId <= 0 ? 'Select a valid location' : 'Quantity must be at least 0.01', })); - if (!Number.isInteger(parsedLocationId)) { + if (!Number.isInteger(parsedLocationId) || parsedLocationId <= 0) { focusController.focus(item.id.toString(), 'location'); } else { focusController.focus(item.id.toString(), 'quantity'); From 787e60985ec43ce67ba432a030829d99ade9f630 Mon Sep 17 00:00:00 2001 From: Demian Date: Tue, 31 Mar 2026 00:36:02 -0400 Subject: [PATCH 5/6] fix: align org inventory add and query validation --- .../org-inventory.controller.spec.ts | 17 ++++++++++ .../org-inventory/org-inventory.controller.ts | 16 ++++++++++ frontend/src/pages/Inventory.tsx | 31 ++++++++++++++++--- 3 files changed, 59 insertions(+), 5 deletions(-) diff --git a/backend/src/modules/org-inventory/org-inventory.controller.spec.ts b/backend/src/modules/org-inventory/org-inventory.controller.spec.ts index 6cd5e73..fbaaf8f 100644 --- a/backend/src/modules/org-inventory/org-inventory.controller.spec.ts +++ b/backend/src/modules/org-inventory/org-inventory.controller.spec.ts @@ -76,4 +76,21 @@ describe('OrgInventoryController', () => { new BadRequestException('offset must be greater than or equal to 0'), ); }); + + it('throws a bad request for non-integer id-like filters', async () => { + await expect( + controller.list({ user: { userId: 7 } }, 42, { + gameId: '1.5', + }), + ).rejects.toThrow(new BadRequestException('game_id must be an integer')); + + await expect( + controller.list({ user: { userId: 7 } }, 42, { + gameId: '1', + locationId: '2.5', + }), + ).rejects.toThrow( + new BadRequestException('location_id must be an integer'), + ); + }); }); diff --git a/backend/src/modules/org-inventory/org-inventory.controller.ts b/backend/src/modules/org-inventory/org-inventory.controller.ts index 8a0040b..6ac23e2 100644 --- a/backend/src/modules/org-inventory/org-inventory.controller.ts +++ b/backend/src/modules/org-inventory/org-inventory.controller.ts @@ -101,6 +101,10 @@ export class OrgInventoryController { query, ['game_id', 'gameId'], 'game_id', + { + integer: true, + min: 1, + }, ); const searchDto: OrgInventorySearchDto = { @@ -110,16 +114,28 @@ export class OrgInventoryController { query, ['category_id', 'categoryId'], 'category_id', + { + integer: true, + min: 1, + }, ), uexItemId: this.readOptionalNumber( query, ['uex_item_id', 'uexItemId'], 'uex_item_id', + { + integer: true, + min: 1, + }, ), locationId: this.readOptionalNumber( query, ['location_id', 'locationId'], 'location_id', + { + integer: true, + min: 1, + }, ), search: query.search, limit: this.readOptionalNumber(query, ['limit'], 'limit', { diff --git a/frontend/src/pages/Inventory.tsx b/frontend/src/pages/Inventory.tsx index 423efcf..38522bc 100644 --- a/frontend/src/pages/Inventory.tsx +++ b/frontend/src/pages/Inventory.tsx @@ -605,8 +605,8 @@ const InventoryPage = () => { setCatalogError('Select a location.'); return; } - if (newItemQuantity <= 0) { - setCatalogError('Quantity must be greater than 0.'); + if (newItemQuantity < MIN_INVENTORY_QUANTITY) { + setCatalogError('Quantity must be at least 0.01.'); return; } @@ -689,11 +689,27 @@ const InventoryPage = () => { setCatalogError('You do not have permission to add items to this organization.'); } else if (status === 409 && viewMode === 'org' && selectedOrgId) { const numericLocationId = Number(destinationSelection.locationId); - const existing = items.find( + let existing = items.find( (item) => item.uexItemId === selectedCatalogItem?.uexId && item.locationId === numericLocationId, ); + if (!existing && selectedCatalogItem) { + try { + const lookupResult = await inventoryService.getOrgInventory(selectedOrgId, { + gameId: GAME_ID, + uexItemId: selectedCatalogItem.uexId, + locationId: numericLocationId, + limit: 1, + offset: 0, + }); + if (lookupResult.items.length > 0) { + existing = lookupResult.items[0]; + } + } catch (lookupErr) { + console.error('Error looking up conflicting org inventory item', lookupErr); + } + } if (existing) { const shouldMerge = window.confirm( 'This org already has this item at the selected location. Merge quantities?', @@ -797,10 +813,15 @@ const InventoryPage = () => { }, [addDialogOpen, fetchCatalog]); useEffect(() => { - if (addDialogOpen && viewMode === 'org' && !canManageOrgInventory) { + if ( + addDialogOpen && + viewMode === 'org' && + !orgPermissionsLoading && + !canManageOrgInventory + ) { setAddDialogOpen(false); } - }, [addDialogOpen, viewMode, canManageOrgInventory]); + }, [addDialogOpen, viewMode, orgPermissionsLoading, canManageOrgInventory]); useEffect(() => { if (!addDialogOpen) return; From fa71a1522bab914596be08a2bc3844506f25050c Mon Sep 17 00:00:00 2001 From: Demian Date: Tue, 31 Mar 2026 01:01:36 -0400 Subject: [PATCH 6/6] fix: preserve inline draft values during invalid edits --- .../components/inventory/InventoryInlineRow.tsx | 14 +++++++++----- frontend/src/pages/Inventory.tsx | 10 ++++++++-- 2 files changed, 17 insertions(+), 7 deletions(-) diff --git a/frontend/src/components/inventory/InventoryInlineRow.tsx b/frontend/src/components/inventory/InventoryInlineRow.tsx index dc86390..277db6e 100644 --- a/frontend/src/components/inventory/InventoryInlineRow.tsx +++ b/frontend/src/components/inventory/InventoryInlineRow.tsx @@ -20,6 +20,13 @@ import { useMemoizedLocations } from '../../hooks/useMemoizedLocations'; const EDITOR_MODE_QUANTITY_MAX = 100000; const MIN_INVENTORY_QUANTITY = 0.01; +const normalizeDraftLocationId = (locationId: number | ''): number | '' => + typeof locationId === 'string' + ? locationId === '' + ? '' + : Number(locationId) + : locationId; + export type InventoryRecord = InventoryItem | OrgInventoryItem; interface LocationOption { @@ -94,10 +101,7 @@ const InventoryInlineRow = ({ setQuantityRef, setSaveRef, }: InventoryInlineRowProps) => { - const draftLocationId = - typeof inlineDraft.locationId === 'string' - ? Number(inlineDraft.locationId) - : inlineDraft.locationId; + const draftLocationId = normalizeDraftLocationId(inlineDraft.locationId); const { filtered: filteredOptions } = useMemoizedLocations( allLocations, @@ -112,7 +116,7 @@ const InventoryInlineRow = ({ const draftQuantityNumber = Number(inlineDraft.quantity); const displayQuantity = - Number.isFinite(draftQuantityNumber) && draftQuantityNumber > 0 + Number.isFinite(draftQuantityNumber) ? draftQuantityNumber : Number(item.quantity); diff --git a/frontend/src/pages/Inventory.tsx b/frontend/src/pages/Inventory.tsx index 38522bc..bbcd1ed 100644 --- a/frontend/src/pages/Inventory.tsx +++ b/frontend/src/pages/Inventory.tsx @@ -68,6 +68,13 @@ const VIEW_MODE_STORAGE_KEY = 'inventory:viewMode'; const ORG_ID_STORAGE_KEY = 'inventory:selectedOrgId'; const DENSITY_STORAGE_KEY = 'inventory:density'; +const normalizeDraftLocationId = (locationId: number | ''): number | '' => + typeof locationId === 'string' + ? locationId === '' + ? '' + : Number(locationId) + : locationId; + const readStoredViewMode = (): 'personal' | 'org' => { if (typeof window === 'undefined') return 'personal'; const stored = window.sessionStorage.getItem(VIEW_MODE_STORAGE_KEY); @@ -1783,8 +1790,7 @@ const InventoryPage = () => { quantity: Number(item.quantity) || 0, }; const originalLocationId = Number(item.locationId) || ''; - const draftLocationId = - typeof draft.locationId === 'string' ? Number(draft.locationId) : draft.locationId; + const draftLocationId = normalizeDraftLocationId(draft.locationId); const originalQuantity = Number(item.quantity) || 0; const draftQuantityNumber = Number(draft.quantity); const isDirty =