diff --git a/frontend/src/pages/Inventory.editor-mode.test.tsx b/frontend/src/pages/Inventory.editor-mode.test.tsx index d3110c8..3f7ea53 100644 --- a/frontend/src/pages/Inventory.editor-mode.test.tsx +++ b/frontend/src/pages/Inventory.editor-mode.test.tsx @@ -1,9 +1,14 @@ import { MemoryRouter } from 'react-router-dom'; import { render, screen, waitFor, fireEvent } from '@testing-library/react'; import InventoryPage from './Inventory'; +import { locationCache } from '../services/locationCache'; +import type { LocationRecord } from '../services/location.service'; const mockUpdateItem = jest.fn(); const mockGetInventory = jest.fn(); +const mockCreateItem = jest.fn(); +const mockCreateOrgItem = jest.fn(); +const mockSearchItems = jest.fn(); jest.mock('../services/inventory.service', () => ({ inventoryService: { @@ -13,6 +18,8 @@ jest.mock('../services/inventory.service', () => ({ getOrgInventory: jest.fn(), updateItem: (...args: unknown[]) => mockUpdateItem(...args), updateOrgItem: jest.fn(), + createItem: (...args: unknown[]) => mockCreateItem(...args), + createOrgItem: (...args: unknown[]) => mockCreateOrgItem(...args), shareItem: jest.fn(), unshareItem: jest.fn(), }, @@ -26,6 +33,12 @@ jest.mock('../services/locationCache', () => ({ }, })); +jest.mock('../services/uex.service', () => ({ + uexService: { + searchItems: (...args: unknown[]) => mockSearchItems(...args), + getStarSystems: jest.fn(), + }, +})); const mockItem = { id: 'item-1', userId: 1, @@ -48,6 +61,31 @@ describe('Inventory editor mode inline controls', () => { jest.resetAllMocks(); mockGetInventory.mockResolvedValue({ items: [mockItem], total: 1, limit: 25, offset: 0 }); mockUpdateItem.mockResolvedValue({}); + mockCreateItem.mockResolvedValue({}); + mockSearchItems.mockResolvedValue({ + items: [ + { + id: 101, + uexId: 300, + name: 'New Catalog Item', + categoryName: 'Category', + }, + ], + total: 1, + limit: 20, + offset: 0, + }); + const mockedLocationCache = locationCache as jest.Mocked; + const mockLocation: LocationRecord = { + id: '200', + gameId: 1, + locationType: 'city', + displayName: 'Test Location', + shortName: 'Test Loc', + isAvailable: true, + hierarchyPath: {}, + }; + mockedLocationCache.getAllLocations.mockResolvedValue([mockLocation]); // minimal profile fetch global.fetch = jest.fn().mockResolvedValue({ ok: true, @@ -82,4 +120,154 @@ describe('Inventory editor mode inline controls', () => { quantity: 5, }); }); + + it('allows creating a new inventory item from the pinned row', async () => { + render( + + + , + ); + + await waitFor(() => expect(screen.getByText('Test Item')).toBeInTheDocument()); + + const viewModeSelect = screen.getByLabelText('View mode'); + fireEvent.mouseDown(viewModeSelect); + const editorOption = await screen.findByText('Editor Mode'); + fireEvent.click(editorOption); + + const itemInput = await screen.findByTestId('new-row-item-input'); + fireEvent.focus(itemInput); + fireEvent.change(itemInput, { target: { value: 'New' } }); + + const itemOption = await screen.findByText('New Catalog Item'); + fireEvent.click(itemOption); + + const locationInput = await screen.findByTestId('new-row-location-input'); + fireEvent.focus(locationInput); + fireEvent.change(locationInput, { target: { value: 'Test' } }); + const locationOption = await screen.findByText('Test Location'); + fireEvent.click(locationOption); + + const quantityInput = screen.getByTestId('new-row-quantity'); + fireEvent.change(quantityInput, { target: { value: '7' } }); + + const saveButton = screen.getByTestId('new-row-save'); + fireEvent.click(saveButton); + + await waitFor(() => expect(mockCreateItem).toHaveBeenCalled()); + expect(mockCreateItem).toHaveBeenCalledWith({ + gameId: 1, + uexItemId: 300, + locationId: 200, + quantity: 7, + }); + }); + + it('surfaces validation errors and focuses the first invalid field for new row', async () => { + render( + + + , + ); + + await waitFor(() => expect(screen.getByText('Test Item')).toBeInTheDocument()); + + const viewModeSelect = screen.getByLabelText('View mode'); + fireEvent.mouseDown(viewModeSelect); + const editorOption = await screen.findByText('Editor Mode'); + fireEvent.click(editorOption); + + const saveButton = await screen.findByTestId('new-row-save'); + fireEvent.click(saveButton); + + await waitFor(() => expect(screen.getByText('Select an item')).toBeInTheDocument()); + const itemInput = await screen.findByTestId('new-row-item-input'); + await waitFor(() => expect(document.activeElement).toBe(itemInput)); + }); + + it('prevents non-integer quantities and shows error', async () => { + render( + + + , + ); + + await waitFor(() => expect(screen.getByText('Test Item')).toBeInTheDocument()); + const viewModeSelect = screen.getByLabelText('View mode'); + fireEvent.mouseDown(viewModeSelect); + const editorOption = await screen.findByText('Editor Mode'); + fireEvent.click(editorOption); + + const itemInput = await screen.findByTestId('new-row-item-input'); + fireEvent.change(itemInput, { target: { value: 'New' } }); + fireEvent.click(await screen.findByText('New Catalog Item')); + + const locationInput = await screen.findByTestId('new-row-location-input'); + fireEvent.change(locationInput, { target: { value: 'Test' } }); + fireEvent.click(await screen.findByText('Test Location')); + + const quantityInput = screen.getByTestId('new-row-quantity'); + fireEvent.change(quantityInput, { target: { value: '7.5' } }); + 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(); + }); + + it('keeps the row dirty and shows retry on API failure', async () => { + mockCreateItem.mockRejectedValueOnce(new Error('fail')); + render( + + + , + ); + + await waitFor(() => expect(screen.getByText('Test Item')).toBeInTheDocument()); + const viewModeSelect = screen.getByLabelText('View mode'); + fireEvent.mouseDown(viewModeSelect); + const editorOption = await screen.findByText('Editor Mode'); + fireEvent.click(editorOption); + + fireEvent.change(await screen.findByTestId('new-row-item-input'), { target: { value: 'New' } }); + fireEvent.click(await screen.findByText('New Catalog Item')); + + const locationInput = await screen.findByTestId('new-row-location-input'); + fireEvent.change(locationInput, { target: { value: 'Test' } }); + fireEvent.click(await screen.findByText('Test Location')); + + const quantityInput = screen.getByTestId('new-row-quantity'); + fireEvent.change(quantityInput, { target: { value: '7' } }); + + fireEvent.click(screen.getByTestId('new-row-save')); + + await waitFor(() => + expect(screen.getByText('Unable to add item. Please try again.')).toBeInTheDocument(), + ); + expect((screen.getByTestId('new-row-quantity') as HTMLInputElement).value).toBe('7'); + }); + + it('shows inline quantity validation error for non-integer input and focuses the field', async () => { + render( + + + , + ); + + await waitFor(() => expect(screen.getByText('Test Item')).toBeInTheDocument()); + const viewModeSelect = screen.getByLabelText('View mode'); + fireEvent.mouseDown(viewModeSelect); + const editorOption = await screen.findByText('Editor Mode'); + fireEvent.click(editorOption); + + const quantityInput = await screen.findByTestId('inline-quantity-item-1'); + fireEvent.change(quantityInput, { target: { value: '3.5' } }); + fireEvent.click(screen.getByTestId('inline-save-item-1')); + + await waitFor(() => + expect(screen.getByText('Quantity must be an integer greater than 0')).toBeInTheDocument(), + ); + await waitFor(() => expect(document.activeElement).toBe(quantityInput)); + expect(mockUpdateItem).not.toHaveBeenCalled(); + }); }); diff --git a/frontend/src/pages/Inventory.tsx b/frontend/src/pages/Inventory.tsx index bcdd0c4..215884e 100644 --- a/frontend/src/pages/Inventory.tsx +++ b/frontend/src/pages/Inventory.tsx @@ -133,6 +133,30 @@ const InventoryPage = () => { const [inlineLocationInputs, setInlineLocationInputs] = useState>({}); const [locationEditing, setLocationEditing] = useState>({}); const [pendingFocusAfterPageChange, setPendingFocusAfterPageChange] = useState(false); + const [newRowDraft, setNewRowDraft] = useState<{ + itemId: number | ''; + locationId: number | ''; + quantity: number | ''; + }>({ + itemId: '', + locationId: '', + quantity: '', + }); + const [newRowSelectedItem, setNewRowSelectedItem] = useState(null); + const [newRowItemInput, setNewRowItemInput] = useState(''); + const [newRowItemOptions, setNewRowItemOptions] = useState([]); + const [newRowItemLoading, setNewRowItemLoading] = useState(false); + const [newRowItemError, setNewRowItemError] = useState(null); + const [newRowLocationInput, setNewRowLocationInput] = useState(''); + const [newRowLocationEditing, setNewRowLocationEditing] = useState(false); + const [newRowErrors, setNewRowErrors] = useState<{ + item?: string | null; + location?: string | null; + quantity?: string | null; + org?: string | null; + api?: string | null; + }>({}); + const [newRowSaving, setNewRowSaving] = useState(false); const itemGridTemplate = useMemo( () => density === 'compact' @@ -143,10 +167,13 @@ const InventoryPage = () => { const debouncedSearch = useDebounce(filters.search, 350); const debouncedCatalogSearch = useDebounce(catalogSearch, 350); + const debouncedNewItemSearch = useDebounce(newRowItemInput, 300); + const debouncedNewLocationSearch = useDebounce(newRowLocationInput, 200); const getRowOrder = useCallback( () => items.map((item) => item.id.toString()), [items], ); + const getNewRowOrder = useCallback((): Array<'new-row'> => ['new-row'], []); const handleFocusBoundary = useCallback(async () => { const totalPages = Math.ceil(totalCount / rowsPerPage); if (page < totalPages - 1) { @@ -156,13 +183,22 @@ const InventoryPage = () => { return null; }, [page, rowsPerPage, totalCount]); const focusController = useFocusController({ - fieldOrder: ['location', 'quantity', 'save'], + fieldOrder: useMemo(() => ['location', 'quantity', 'save'] as const, []), getRowOrder, onBoundary: handleFocusBoundary, }); + const newRowFocusController = useFocusController<'new-row', 'item' | 'location' | 'quantity' | 'save'>({ + fieldOrder: useMemo(() => ['item', 'location', 'quantity', 'save'] as const, []), + getRowOrder: getNewRowOrder, + }); const locationRefs = useRef>({}); const quantityRefs = useRef>({}); const saveRefs = useRef>({}); + const newRowItemRef = useRef(null); + const newRowLocationRef = useRef(null); + const newRowQuantityRef = useRef(null); + const newRowSaveRef = useRef(null); + const newRowItemCache = useRef>(new Map()); const handleLogout = () => { localStorage.removeItem('access_token'); @@ -577,6 +613,56 @@ const InventoryPage = () => { return groups; }, [groupBy, filteredItems]); + const newRowSelectedLocation = useMemo( + () => + allLocations.find( + (loc) => Number(loc.id) === Number(newRowDraft.locationId), + ) ?? null, + [allLocations, newRowDraft.locationId], + ); + + const newRowFilteredLocations = useMemo(() => { + const term = debouncedNewLocationSearch.trim().toLowerCase(); + return allLocations + .filter((opt) => opt.name.toLowerCase().includes(term)) + .sort((a, b) => { + const aName = a.name.toLowerCase(); + const bName = b.name.toLowerCase(); + const aStarts = aName.startsWith(term); + const bStarts = bName.startsWith(term); + if (aStarts !== bStarts) return aStarts ? -1 : 1; + const aIndex = aName.indexOf(term); + const bIndex = bName.indexOf(term); + if (aIndex !== bIndex) return aIndex - bIndex; + return a.name.localeCompare(b.name); + }); + }, [allLocations, debouncedNewLocationSearch]); + + const newRowQuantityNumber = useMemo( + () => (newRowDraft.quantity === '' ? NaN : Number(newRowDraft.quantity)), + [newRowDraft.quantity], + ); + + const newRowDirty = useMemo( + () => + Boolean( + newRowSelectedItem || + newRowDraft.locationId || + newRowDraft.quantity !== '' || + newRowLocationInput.trim() || + newRowItemInput.trim(), + ), + [ + newRowDraft.locationId, + newRowDraft.quantity, + newRowItemInput, + newRowLocationInput, + newRowSelectedItem, + ], + ); + const isEditorMode = density === 'compact'; + const newRowOrgBlocked = viewMode === 'org' && !selectedOrgId; + const setInlineDraft = ( id: string, changes: Partial<{ locationId: number | ''; quantity: number | '' }>, @@ -599,6 +685,85 @@ const InventoryPage = () => { }); setInlineError((prev) => ({ ...prev, [id]: null })); }; + + const resetNewRowDraft = () => { + setNewRowDraft({ + itemId: '', + locationId: '', + quantity: '', + }); + setNewRowSelectedItem(null); + setNewRowItemInput(''); + setNewRowLocationInput(''); + setNewRowLocationEditing(false); + setNewRowErrors({}); + }; + + const handleNewRowSave = async () => { + if (newRowOrgBlocked) { + setNewRowErrors((prev) => ({ + ...prev, + org: 'Select an organization to add items in org view.', + })); + return; + } + const selectedItemId = + newRowSelectedItem?.uexId ?? + (typeof newRowDraft.itemId === 'number' ? newRowDraft.itemId : null); + const parsedLocationId = + newRowDraft.locationId === '' ? NaN : Number(newRowDraft.locationId); + const parsedQuantity = Number(newRowDraft.quantity); + const errors: typeof newRowErrors = {}; + + if (!selectedItemId) { + errors.item = 'Select an item'; + } + 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 (errors.item || errors.location || errors.quantity) { + setNewRowErrors((prev) => ({ ...prev, ...errors, api: null })); + if (errors.item) { + newRowFocusController.focus('new-row', 'item'); + } else if (errors.location) { + newRowFocusController.focus('new-row', 'location'); + } else if (errors.quantity) { + newRowFocusController.focus('new-row', 'quantity'); + } + return; + } + + try { + setNewRowSaving(true); + setNewRowErrors({}); + const payload = { + gameId: GAME_ID, + uexItemId: selectedItemId as number, + locationId: parsedLocationId, + quantity: parsedQuantity, + }; + if (viewMode === 'org' && selectedOrgId) { + await inventoryService.createOrgItem(selectedOrgId, payload); + } else { + await inventoryService.createItem(payload); + } + await fetchInventory(); + resetNewRowDraft(); + newRowFocusController.focus('new-row', 'item'); + } catch (err) { + console.error('Failed to create inventory item from new row', err); + setNewRowErrors({ + api: 'Unable to add item. Please try again.', + }); + newRowFocusController.focus('new-row', 'save'); + } finally { + setNewRowSaving(false); + } + }; useEffect(() => { const unregisters: Array<() => void> = []; items.forEach((item) => { @@ -635,6 +800,70 @@ const InventoryPage = () => { }; }, [items, focusController]); + useEffect(() => { + const unregisters: Array<() => void> = []; + const itemRef = newRowItemRef.current; + const locationRef = newRowLocationRef.current; + const quantityRef = newRowQuantityRef.current; + const saveRef = newRowSaveRef.current; + + if (itemRef) { + unregisters.push( + newRowFocusController.register('new-row', 'item', () => { + itemRef.focus(); + itemRef.select?.(); + }), + ); + } + if (locationRef) { + unregisters.push( + newRowFocusController.register('new-row', 'location', () => { + locationRef.focus(); + locationRef.select?.(); + }), + ); + } + if (quantityRef) { + unregisters.push( + newRowFocusController.register('new-row', 'quantity', () => { + quantityRef.focus(); + quantityRef.select?.(); + }), + ); + } + if (saveRef) { + unregisters.push( + newRowFocusController.register('new-row', 'save', () => { + saveRef.focus(); + }), + ); + } + + return () => { + unregisters.forEach((fn) => fn()); + }; + }, [newRowFocusController, density]); + + useEffect(() => { + if (!isEditorMode) return; + const handle = window.requestAnimationFrame(() => { + newRowFocusController.focus('new-row', 'item'); + }); + return () => window.cancelAnimationFrame(handle); + }, [isEditorMode, newRowFocusController]); + + useEffect(() => { + if (!isEditorMode) { + resetNewRowDraft(); + } + }, [isEditorMode]); + + useEffect(() => { + if (!newRowOrgBlocked) { + setNewRowErrors((prev) => ({ ...prev, org: null })); + } + }, [newRowOrgBlocked]); + useEffect(() => { if (pendingFocusAfterPageChange && items.length > 0) { focusController.focus(items[0].id.toString(), 'location'); @@ -661,9 +890,56 @@ const InventoryPage = () => { }) .catch((err) => { console.error('Failed to load locations', err); - }); + }); }, []); + useEffect(() => { + let isMounted = true; + if (!isEditorMode) { + setNewRowItemLoading(false); + setNewRowItemOptions([]); + return; + } + const searchKey = debouncedNewItemSearch.trim().toLowerCase(); + const cached = newRowItemCache.current.get(searchKey); + if (cached) { + setNewRowItemOptions(cached); + setNewRowItemError(null); + setNewRowItemLoading(false); + return; + } + + const loadItems = async () => { + try { + setNewRowItemError(null); + setNewRowItemLoading(true); + const data = await uexService.searchItems({ + search: debouncedNewItemSearch || undefined, + limit: 20, + offset: 0, + }); + if (!isMounted) return; + newRowItemCache.current.set(searchKey, data.items); + setNewRowItemOptions(data.items); + } catch (err) { + console.error('Failed to search catalog items for new row', err); + if (isMounted) { + setNewRowItemError('Unable to load items.'); + setNewRowItemOptions([]); + } + } finally { + if (isMounted) { + setNewRowItemLoading(false); + } + } + }; + + loadItems(); + return () => { + isMounted = false; + }; + }, [debouncedNewItemSearch, isEditorMode]); + const handleInlineSave = async (item: InventoryRecord) => { const draft = inlineDrafts[item.id] ?? { locationId: item.locationId ?? '', @@ -673,14 +949,19 @@ const InventoryPage = () => { draft.locationId === '' ? NaN : Number(draft.locationId); const parsedQuantity = Number(draft.quantity); - if (!Number.isInteger(parsedLocationId) || !Number.isFinite(parsedQuantity) || parsedQuantity <= 0) { + if (!Number.isInteger(parsedLocationId) || !Number.isInteger(parsedQuantity) || parsedQuantity <= 0) { setInlineError((prev) => ({ ...prev, [item.id]: !Number.isInteger(parsedLocationId) ? 'Select a valid location' - : 'Quantity must be greater than 0', + : 'Quantity must be an integer greater than 0', })); + if (!Number.isInteger(parsedLocationId)) { + focusController.focus(item.id.toString(), 'location'); + } else { + focusController.focus(item.id.toString(), 'quantity'); + } return false; } @@ -1146,6 +1427,298 @@ const InventoryPage = () => { } }; + const renderNewItemRow = () => { + if (!isEditorMode) return null; + const showQuantityWarning = + Number.isFinite(newRowQuantityNumber) && newRowQuantityNumber > 100000; + return ( + + + options} + getOptionLabel={(option) => option?.name ?? ''} + isOptionEqualToValue={(option, value) => option.id === value.id} + onChange={(_, value) => { + setNewRowSelectedItem(value); + setNewRowDraft((prev) => ({ + ...prev, + itemId: value ? value.uexId : '', + })); + setNewRowItemInput(value?.name ?? newRowItemInput); + setNewRowErrors((prev) => ({ ...prev, item: null, api: null })); + if (value) { + newRowFocusController.focus('new-row', 'location'); + } + }} + onInputChange={(_, value, reason) => { + setNewRowItemInput(value); + if (reason === 'clear') { + setNewRowSelectedItem(null); + setNewRowDraft((prev) => ({ ...prev, itemId: '' })); + } + setNewRowErrors((prev) => ({ ...prev, item: null, api: null })); + }} + renderOption={(props, option) => ( +
  • + + + {option.name} + + {option.categoryName && ( + + {option.categoryName} + + )} + +
  • + )} + renderInput={(params) => ( + { + newRowItemRef.current = el; + }} + error={Boolean(newRowErrors.item)} + helperText={newRowErrors.item || newRowItemError || undefined} + onKeyDown={(event) => { + if (event.key === 'Enter') { + event.preventDefault(); + if (!newRowSelectedItem && newRowItemOptions.length > 0) { + const first = newRowItemOptions[0]; + setNewRowSelectedItem(first); + setNewRowDraft((prev) => ({ ...prev, itemId: first.uexId })); + setNewRowItemInput(first.name); + } + newRowFocusController.focus('new-row', 'location'); + } + }} + /> + )} + /> +
    + + options} + value={newRowLocationEditing ? null : newRowSelectedLocation} + inputValue={ + newRowLocationEditing + ? newRowLocationInput + : newRowSelectedLocation?.name ?? newRowLocationInput + } + getOptionLabel={(option) => option?.name ?? ''} + isOptionEqualToValue={(option, value) => option.id === value.id} + onChange={(_, value) => { + setNewRowDraft((prev) => ({ + ...prev, + locationId: value ? value.id : '', + })); + setNewRowLocationEditing(false); + setNewRowLocationInput(value?.name ?? ''); + setNewRowErrors((prev) => ({ ...prev, location: null, api: null })); + if (value) { + newRowFocusController.focus('new-row', 'quantity'); + } + }} + onInputChange={(_, value) => { + setNewRowLocationInput(value); + setNewRowLocationEditing(true); + setNewRowErrors((prev) => ({ ...prev, location: null, api: null })); + }} + onFocus={() => { + setNewRowLocationEditing(true); + setNewRowLocationInput(''); + }} + onBlur={() => { + setNewRowLocationEditing(false); + setNewRowLocationInput(newRowSelectedLocation?.name ?? ''); + setNewRowErrors((prev) => ({ ...prev, location: null })); + }} + renderOption={(props, option) => ( +
  • + {option.name} +
  • + )} + renderInput={(params) => ( + { + newRowLocationRef.current = el; + }} + error={Boolean(newRowErrors.location)} + helperText={newRowErrors.location || undefined} + onKeyDown={(event) => { + if (event.key === 'Enter') { + event.preventDefault(); + const bestMatch = newRowFilteredLocations[0]; + if (bestMatch) { + setNewRowDraft((prev) => ({ ...prev, locationId: bestMatch.id })); + setNewRowLocationInput(bestMatch.name); + setNewRowLocationEditing(false); + setNewRowErrors((prev) => ({ ...prev, location: null, api: null })); + newRowFocusController.focus('new-row', 'quantity'); + } else { + setNewRowErrors((prev) => ({ + ...prev, + location: 'No matches found', + })); + } + } + }} + /> + )} + /> +
    + + { + const raw = e.target.value.trim(); + if (raw === '') { + setNewRowDraft((prev) => ({ ...prev, quantity: '' })); + setNewRowErrors((prev) => ({ + ...prev, + quantity: 'Quantity is required', + api: null, + })); + return; + } + const numeric = Number(raw); + setNewRowDraft((prev) => ({ + ...prev, + quantity: Number.isNaN(numeric) ? '' : numeric, + })); + if (!Number.isInteger(numeric) || numeric <= 0) { + setNewRowErrors((prev) => ({ + ...prev, + quantity: 'Quantity must be an integer greater than 0', + api: null, + })); + } else { + setNewRowErrors((prev) => ({ ...prev, quantity: null, api: null })); + } + }} + inputProps={{ + inputMode: 'numeric', + pattern: '[0-9]*', + }} + inputRef={(el) => { + newRowQuantityRef.current = el; + }} + onKeyDown={(event) => { + if (event.key === 'Enter') { + event.preventDefault(); + newRowFocusController.focus('new-row', 'save'); + } + }} + error={Boolean(newRowErrors.quantity)} + helperText={newRowErrors.quantity || undefined} + /> + {showQuantityWarning && ( + + Large quantity entered - verify value. + + )} + + + + New entry + + {newRowErrors.org && ( + + {newRowErrors.org} + + )} + {newRowErrors.api && ( + + {newRowErrors.api} + + )} + + + {newRowDirty && ( + + )} + + + + + + +
    + ); + }; + if (!user || initialLoading) { return ( { ); } + const showEmptyState = filteredItems.length === 0 && !refreshing; + return ( @@ -1468,7 +2043,57 @@ const InventoryPage = () => { {error} )} - {filteredItems.length === 0 && !refreshing ? ( + {!showEmptyState && ( + + + + Showing {items.length.toLocaleString()} of {totalCount.toLocaleString()} items + + + )} + {isEditorMode && ( + <> + + + Item + Location + Quantity + Updated + + Actions + + + + {renderNewItemRow()} + + )} + {showEmptyState ? ( @@ -1480,52 +2105,6 @@ const InventoryPage = () => { ) : ( <> - - - - Showing {items.length.toLocaleString()} of{' '} - {totalCount.toLocaleString()} items - - - {density === 'compact' && ( - - - Item - Location - Quantity - Updated - - Actions - - - - )} {Array.from(groupedItems.entries()).map(([group, groupItems]) => ( { setInlineDraft(item.id, { quantity: numeric, }); - if (!Number.isFinite(numeric) || numeric <= 0) { + if (!Number.isInteger(numeric) || numeric <= 0) { setInlineError((prev) => ({ ...prev, - [item.id]: 'Quantity must be greater than 0', + [item.id]: 'Quantity must be an integer greater than 0', })); } else { setInlineError((prev) => ({ ...prev, [item.id]: null }));