diff --git a/frontend/src/components/inventory/InventoryFiltersPanel.tsx b/frontend/src/components/inventory/InventoryFiltersPanel.tsx new file mode 100644 index 0000000..a26a325 --- /dev/null +++ b/frontend/src/components/inventory/InventoryFiltersPanel.tsx @@ -0,0 +1,318 @@ +import { + Avatar, + Box, + Button, + FormControl, + FormControlLabel, + Grid, + InputLabel, + ListItemText, + MenuItem, + Select, + Slider, + Stack, + Switch, + TextField, + Typography, +} from '@mui/material'; +import FilterAltIcon from '@mui/icons-material/FilterAlt'; +import GroupWorkIcon from '@mui/icons-material/GroupWork'; +import SortIcon from '@mui/icons-material/Sort'; +import ViewAgendaIcon from '@mui/icons-material/ViewAgenda'; +import ApartmentIcon from '@mui/icons-material/Apartment'; +import type { InventoryCategory } from '../../services/inventory.service'; + +interface FiltersPanelProps { + filters: { + search: string; + categoryId: number | ''; + locationId: number | ''; + sharedOnly: boolean; + valueRange: [number, number]; + }; + setFilters: ( + updater: + | FiltersPanelProps['filters'] + | ((prev: FiltersPanelProps['filters']) => FiltersPanelProps['filters']), + ) => void; + categories: InventoryCategory[]; + locationOptions: { id: number; name: string }[]; + valueText: (value: number) => string; + maxQuantity: number; + sortBy: 'name' | 'quantity' | 'location' | 'date'; + sortDir: 'asc' | 'desc'; + setSortBy: (value: 'name' | 'quantity' | 'location' | 'date') => void; + setSortDir: (updater: (prev: 'asc' | 'desc') => 'asc' | 'desc') => void; + groupBy: 'none' | 'category' | 'location' | 'share'; + setGroupBy: (value: 'none' | 'category' | 'location' | 'share') => void; + density: 'standard' | 'compact'; + setDensity: (value: 'standard' | 'compact') => void; + viewMode: 'personal' | 'org'; + setViewMode: (mode: 'personal' | 'org') => void; + selectedOrgId: number | null; + setSelectedOrgId: (value: number | null) => void; + orgOptions: { id: number; name: string }[]; + userInitial: string; + onOpenAddDialog: () => void; + showAddButton: boolean; + totalCount: number; + itemCount: number; +} + +export const InventoryFiltersPanel = ({ + filters, + setFilters, + categories, + locationOptions, + valueText, + maxQuantity, + sortBy, + sortDir, + setSortBy, + setSortDir, + groupBy, + setGroupBy, + density, + setDensity, + viewMode, + setViewMode, + selectedOrgId, + setSelectedOrgId, + orgOptions, + userInitial, + onOpenAddDialog, + showAddButton, + totalCount, + itemCount, +}: FiltersPanelProps) => { + return ( + <> + + + setFilters((prev) => ({ ...prev, search: e.target.value }))} + /> + + + + Category + + + + + + Location + + + + + + + Value (quantity) range + + + setFilters((prev) => ({ + ...prev, + valueRange: value as [number, number], + })) + } + valueLabelDisplay="auto" + getAriaValueText={valueText} + /> + + + + + View + + + + + + + + Showing {itemCount.toLocaleString()} of {totalCount.toLocaleString()} items + + + + + + setFilters((prev) => ({ + ...prev, + sharedOnly: e.target.checked, + })) + } + size="small" + disabled={viewMode === 'org'} + /> + } + label="Shared only" + /> + + + + + + + Sort By + + + + + + Group By + + + + + + + + + View mode + + + + {showAddButton && ( + + + + )} + + + ); +}; + +export default InventoryFiltersPanel; diff --git a/frontend/src/components/inventory/InventoryInlineRow.tsx b/frontend/src/components/inventory/InventoryInlineRow.tsx new file mode 100644 index 0000000..f338786 --- /dev/null +++ b/frontend/src/components/inventory/InventoryInlineRow.tsx @@ -0,0 +1,351 @@ +import { useMemo } from 'react'; +import type { MouseEvent } from 'react'; +import { + Autocomplete, + Box, + Chip, + IconButton, + Stack, + TextField, + Tooltip, + Typography, +} from '@mui/material'; +import CheckIcon from '@mui/icons-material/Check'; +import MoreVertIcon from '@mui/icons-material/MoreVert'; +import type { InventoryItem, OrgInventoryItem } from '../../services/inventory.service'; +import type { FocusController } from '../../utils/focusController'; + +export type InventoryRecord = InventoryItem | OrgInventoryItem; + +interface LocationOption { + id: number; + name: string; +} + +interface InventoryInlineRowProps { + item: InventoryRecord; + density: 'standard' | 'compact'; + allLocations: LocationOption[]; + inlineDraft: { locationId: number | ''; quantity: number | '' }; + inlineLocationInput: string; + locationEditing: boolean; + inlineSaving: boolean; + inlineError?: string | null; + isDirty: 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; + setLocationRef: (ref: HTMLInputElement | null, key: string) => void; + setQuantityRef: (ref: HTMLInputElement | null, key: string) => void; + setSaveRef: (ref: HTMLButtonElement | null, key: string) => void; +} + +export const InventoryInlineRow = ({ + item, + density, + allLocations, + inlineDraft, + inlineLocationInput, + locationEditing, + inlineSaving, + inlineError, + isDirty, + focusController, + rowKey, + onDraftChange, + onErrorChange, + onLocationInputChange, + onLocationFocus, + onLocationBlur, + onSave, + onOpenActions, + setLocationRef, + setQuantityRef, + setSaveRef, +}: InventoryInlineRowProps) => { + const draftLocationId = + typeof inlineDraft.locationId === 'string' + ? Number(inlineDraft.locationId) + : inlineDraft.locationId; + const selectedLocation = + allLocations.find((loc) => loc.id === draftLocationId) || + (typeof draftLocationId === 'number' + ? { + id: draftLocationId, + name: item.locationName || `Location #${draftLocationId}`, + } + : null); + + const filteredOptions = useMemo(() => { + const filterTerm = inlineLocationInput.trim().toLowerCase(); + return allLocations + .filter((opt) => opt.name.toLowerCase().includes(filterTerm)) + .sort((a, b) => { + const aName = a.name.toLowerCase(); + const bName = b.name.toLowerCase(); + const aStarts = aName.startsWith(filterTerm); + const bStarts = bName.startsWith(filterTerm); + if (aStarts !== bStarts) return aStarts ? -1 : 1; + const aIndex = aName.indexOf(filterTerm); + const bIndex = bName.indexOf(filterTerm); + if (aIndex !== bIndex) return aIndex - bIndex; + return a.name.localeCompare(b.name); + }); + }, [allLocations, inlineLocationInput]); + + const draftQuantityNumber = Number(inlineDraft.quantity); + + return ( + + + + + {item.itemName || `Item #${item.uexItemId}`} + + + {item.sharedOrgId && ( + + )} + + + + {density !== 'compact' ? ( + <> + + Location + + + {item.locationName || 'Unknown'} + + + ) : ( + 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); + }} + onKeyDown={(event) => { + if (event.key === 'Enter') { + 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'); + } + } + }} + /> + )} + /> + )} +
    + + {density !== 'compact' ? ( + <> + + Quantity + + + {Number(item.quantity).toLocaleString()} + + + ) : ( + { + const raw = e.target.value.trim(); + if (raw === '') { + onDraftChange({ quantity: '' }); + onErrorChange('Quantity is required'); + return; + } + const numeric = Number(raw); + onDraftChange({ quantity: Number.isNaN(numeric) ? '' : numeric }); + 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, + }, + }} + /> + )} + + + {density !== 'compact' && ( + + Updated + + )} + + {new Date(item.dateModified || item.dateAdded || '').toLocaleDateString()} + + {Number.isFinite(draftQuantityNumber) && draftQuantityNumber > 100000 && ( + + Large quantity entered - verify value. + + )} + {inlineError && ( + + {inlineError} + + )} + + + {density === 'compact' && isDirty && ( + + )} + {density === 'compact' ? ( + { + if (event.key === 'Enter') { + event.preventDefault(); + onSave(); + } + }} + ref={(el: HTMLButtonElement | null) => { + setSaveRef(el, rowKey); + }} + > + + + ) : ( + + + + + + )} + +
    + ); +}; + +export default InventoryInlineRow; diff --git a/frontend/src/components/inventory/InventoryNewRow.tsx b/frontend/src/components/inventory/InventoryNewRow.tsx new file mode 100644 index 0000000..98e9f7c --- /dev/null +++ b/frontend/src/components/inventory/InventoryNewRow.tsx @@ -0,0 +1,299 @@ +import { + Autocomplete, + Box, + Button, + Chip, + Stack, + TextField, + Tooltip, + Typography, +} from '@mui/material'; +import type { RefObject } from 'react'; +import type { CatalogItem } from '../../services/uex.service'; + +interface LocationOption { + id: number; + name: string; +} + +interface InventoryNewRowProps { + isEditorMode: boolean; + itemOptions: CatalogItem[]; + itemInput: string; + selectedItem: CatalogItem | null; + itemLoading: boolean; + itemError: string | null; + locationInput: string; + locationEditing: boolean; + selectedLocation: LocationOption | null; + filteredLocations: LocationOption[]; + draft: { itemId: number | ''; locationId: number | ''; quantity: number | '' }; + errors: { + item?: string | null; + location?: string | null; + quantity?: string | null; + org?: string | null; + api?: string | null; + }; + dirty: boolean; + saving: boolean; + orgBlocked: boolean; + showQuantityWarning: boolean; + onItemInputChange: (value: string, reason: string) => void; + onItemSelect: (item: CatalogItem | null) => void; + onLocationInputChange: (value: string) => void; + onLocationSelect: (location: LocationOption | null) => void; + onLocationEnter: (bestMatch: LocationOption | undefined) => void; + onLocationFocus: () => void; + onLocationBlur: (value: string) => void; + onQuantityChange: (value: string) => void; + onQuantityEnter: () => void; + onSave: () => void; + onRetry: () => void; + itemRef: RefObject; + locationRef: RefObject; + quantityRef: RefObject; + saveRef: RefObject; +} + +export const InventoryNewRow = ({ + isEditorMode, + itemOptions, + itemInput, + selectedItem, + itemLoading, + itemError, + locationInput, + locationEditing, + selectedLocation, + filteredLocations, + draft, + errors, + dirty, + saving, + orgBlocked, + showQuantityWarning, + onItemInputChange, + onItemSelect, + onLocationInputChange, + onLocationSelect, + onLocationEnter, + onLocationFocus, + onLocationBlur, + onQuantityChange, + onQuantityEnter, + onSave, + onRetry, + itemRef, + locationRef, + quantityRef, + saveRef, +}: InventoryNewRowProps) => { + if (!isEditorMode) return null; + + return ( + + + options} + getOptionLabel={(option) => option?.name ?? ''} + isOptionEqualToValue={(option, value) => option.id === value.id} + onChange={(_, value) => onItemSelect(value)} + onInputChange={(_, value, reason) => onItemInputChange(value, reason)} + renderOption={(props, option) => ( +
  • + + + {option.name} + + {option.categoryName && ( + + {option.categoryName} + + )} + +
  • + )} + renderInput={(params) => ( + } + error={Boolean(errors.item)} + helperText={errors.item || itemError || undefined} + onKeyDown={(event) => { + if (event.key === 'Enter') { + event.preventDefault(); + onSave(); + } + }} + /> + )} + /> +
    + + options} + value={locationEditing ? null : selectedLocation} + inputValue={ + locationEditing ? locationInput : selectedLocation?.name ?? locationInput + } + getOptionLabel={(option) => option?.name ?? ''} + isOptionEqualToValue={(option, value) => option.id === value.id} + onChange={(_, value) => onLocationSelect(value)} + onInputChange={(_, value) => onLocationInputChange(value)} + onFocus={onLocationFocus} + onBlur={() => onLocationBlur(selectedLocation?.name ?? '')} + renderOption={(props, option) => ( +
  • + {option.name} +
  • + )} + renderInput={(params) => ( + } + error={Boolean(errors.location)} + helperText={errors.location || undefined} + onKeyDown={(event) => { + if (event.key === 'Enter') { + event.preventDefault(); + onLocationEnter(filteredLocations[0]); + } + }} + /> + )} + /> +
    + + onQuantityChange(e.target.value)} + inputProps={{ + inputMode: 'numeric', + pattern: '[0-9]*', + }} + inputRef={quantityRef as RefObject} + onKeyDown={(event) => { + if (event.key === 'Enter') { + event.preventDefault(); + onQuantityEnter(); + } + }} + error={Boolean(errors.quantity)} + helperText={errors.quantity || undefined} + /> + {showQuantityWarning && ( + + Large quantity entered - verify value. + + )} + + + + New entry + + {errors.org && ( + + {errors.org} + + )} + {errors.api && ( + + + {errors.api} + + + + )} + + + {dirty && ( + + )} + + + + + + +
    + ); +}; + +export default InventoryNewRow; diff --git a/frontend/src/pages/Inventory.tsx b/frontend/src/pages/Inventory.tsx index 0655bb6..3025390 100644 --- a/frontend/src/pages/Inventory.tsx +++ b/frontend/src/pages/Inventory.tsx @@ -16,9 +16,6 @@ import { InputLabel, Select, MenuItem, - Switch, - FormControlLabel, - Slider, Chip, Button, Divider, @@ -36,31 +33,27 @@ import { ListItemButton, Radio, TablePagination, - Tooltip, - LinearProgress, Autocomplete, + LinearProgress, Alert, } from '@mui/material'; import LogoutIcon from '@mui/icons-material/Logout'; import ArrowBackIcon from '@mui/icons-material/ArrowBack'; -import FilterAltIcon from '@mui/icons-material/FilterAlt'; -import SortIcon from '@mui/icons-material/Sort'; -import GroupWorkIcon from '@mui/icons-material/GroupWork'; import InventoryIcon from '@mui/icons-material/Inventory'; import DeleteForeverIcon from '@mui/icons-material/DeleteForever'; import EditIcon from '@mui/icons-material/Edit'; import CallSplitIcon from '@mui/icons-material/CallSplit'; import ShareIcon from '@mui/icons-material/Share'; import UnpublishedIcon from '@mui/icons-material/Unpublished'; -import MoreVertIcon from '@mui/icons-material/MoreVert'; import ViewAgendaIcon from '@mui/icons-material/ViewAgenda'; -import ApartmentIcon from '@mui/icons-material/Apartment'; -import CheckIcon from '@mui/icons-material/Check'; import { inventoryService, InventoryCategory, InventoryItem, OrgInventoryItem } from '../services/inventory.service'; import { uexService, CatalogItem } from '../services/uex.service'; import { locationCache } from '../services/locationCache'; import type { SystemLocationValue } from '../components/location/SystemLocationSelector'; import { useDebounce } from '../hooks/useDebounce'; import { useFocusController } from '../hooks/useFocusController'; +import InventoryInlineRow from '../components/inventory/InventoryInlineRow'; +import InventoryNewRow from '../components/inventory/InventoryNewRow'; +import InventoryFiltersPanel from '../components/inventory/InventoryFiltersPanel'; type InventoryRecord = InventoryItem | OrgInventoryItem; type ActionMode = 'edit' | 'split' | 'share' | 'delete' | null; @@ -1429,309 +1422,6 @@ 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 ( { }} > - - - - setFilters((prev) => ({ ...prev, search: e.target.value })) - } - /> - - - - Category - - - - - - Location - - - - - - - Value (quantity) range - - - setFilters((prev) => ({ - ...prev, - valueRange: value as [number, number], - })) - } - valueLabelDisplay="auto" - getAriaValueText={valueText} - /> - - - - - View - - - - - - - - - - - setFilters((prev) => ({ - ...prev, - sharedOnly: e.target.checked, - })) - } - size="small" - disabled={viewMode === 'org'} - /> - } - label="Shared only" - /> - - - - - - - Sort By - - - - - - Group By - - - - - - - - - View mode - - - - {viewMode === 'personal' && ( - - - - )} - + setSortBy(value)} + setSortDir={(updater) => setSortDir(updater)} + groupBy={groupBy} + setGroupBy={(value) => setGroupBy(value)} + density={density} + setDensity={(value) => setDensity(value)} + viewMode={viewMode} + setViewMode={(mode) => setViewMode(mode)} + selectedOrgId={selectedOrgId} + setSelectedOrgId={(value) => setSelectedOrgId(value)} + orgOptions={orgOptions} + userInitial={user?.username?.charAt(0).toUpperCase() || 'U'} + onOpenAddDialog={openAddDialog} + showAddButton={viewMode === 'personal'} + totalCount={totalCount} + itemCount={items.length} + /> @@ -2103,7 +1584,119 @@ const InventoryPage = () => { - {renderNewItemRow()} + 100000 + } + onItemInputChange={(value, reason) => { + setNewRowItemInput(value); + if (reason === 'clear') { + setNewRowSelectedItem(null); + setNewRowDraft((prev) => ({ ...prev, itemId: '' })); + } + setNewRowErrors((prev) => ({ ...prev, item: null, api: null })); + }} + onItemSelect={(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'); + } + }} + onLocationInputChange={(value) => { + setNewRowLocationInput(value); + setNewRowLocationEditing(true); + setNewRowErrors((prev) => ({ ...prev, location: null, api: null })); + }} + onLocationSelect={(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'); + } + }} + onLocationEnter={(bestMatch) => { + 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', + })); + } + }} + onLocationFocus={() => { + setNewRowLocationEditing(true); + setNewRowLocationInput(''); + }} + onLocationBlur={(value) => { + setNewRowLocationEditing(false); + setNewRowLocationInput(value); + setNewRowErrors((prev) => ({ ...prev, location: null })); + }} + onQuantityChange={(value) => { + const raw = 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 })); + } + }} + onQuantityEnter={() => newRowFocusController.focus('new-row', 'save')} + onSave={handleNewRowSave} + onRetry={handleNewRowSave} + itemRef={newRowItemRef} + locationRef={newRowLocationRef} + quantityRef={newRowQuantityRef} + saveRef={newRowSaveRef} + /> )} {showEmptyState ? ( @@ -2164,32 +1757,6 @@ const InventoryPage = () => { typeof draft.locationId === 'string' ? Number(draft.locationId) : draft.locationId; - const selectedLocation = - allLocations.find((loc) => loc.id === draftLocationId) || - (typeof draftLocationId === 'number' - ? { - id: draftLocationId, - name: - item.locationName || `Location #${draftLocationId}`, - } - : null); - const inputValue = - inlineLocationInputs[rowKey] ?? - (locationEditing[rowKey] ? '' : selectedLocation?.name ?? ''); - const filterTerm = inputValue.trim().toLowerCase(); - const filteredOptions = allLocations - .filter((opt) => opt.name.toLowerCase().includes(filterTerm)) - .sort((a, b) => { - const aName = a.name.toLowerCase(); - const bName = b.name.toLowerCase(); - const aStarts = aName.startsWith(filterTerm); - const bStarts = bName.startsWith(filterTerm); - if (aStarts !== bStarts) return aStarts ? -1 : 1; - const aIndex = aName.indexOf(filterTerm); - const bIndex = bName.indexOf(filterTerm); - if (aIndex !== bIndex) return aIndex - bIndex; - return a.name.localeCompare(b.name); - }); const saving = inlineSaving.has(item.id); const errorText = inlineError[item.id]; const draftQuantityNumber = Number(draft.quantity); @@ -2197,288 +1764,63 @@ const InventoryPage = () => { draftLocationId !== originalLocationId || draftQuantityNumber !== originalQuantity; return ( - 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; }} - > - - - - {item.itemName || `Item #${item.uexItemId}`} - - - {item.sharedOrgId && ( - - )} - - - - {density !== 'compact' ? ( - <> - - Location - - - {item.locationName || 'Unknown'} - - - ) : ( - options} - value={locationEditing[rowKey] ? null : selectedLocation} - inputValue={inputValue} - getOptionLabel={(option) => option?.name ?? ''} - isOptionEqualToValue={(option, value) => option.id === value.id} - onChange={(_, value) => { - setInlineDraft(item.id, { - locationId: value ? value.id : '', - }); - setInlineLocationInputs((prev) => ({ - ...prev, - [rowKey]: value?.name ?? '', - })); - setLocationEditing((prev) => ({ ...prev, [rowKey]: false })); - setInlineError((prev) => ({ ...prev, [item.id]: null })); - }} - onInputChange={(_, value) => { - setInlineLocationInputs((prev) => ({ - ...prev, - [rowKey]: value, - })); - }} - onFocus={() => { - setInlineLocationInputs((prev) => ({ - ...prev, - [rowKey]: '', - })); - setLocationEditing((prev) => ({ ...prev, [rowKey]: true })); - setInlineError((prev) => ({ ...prev, [item.id]: null })); - }} - onBlur={() => { - setInlineLocationInputs((prev) => ({ - ...prev, - [rowKey]: selectedLocation?.name ?? '', - })); - setLocationEditing((prev) => ({ ...prev, [rowKey]: false })); - setInlineError((prev) => ({ ...prev, [item.id]: null })); - }} - renderOption={(props, option) => ( -
  • - {option.name} -
  • - )} - renderInput={(params) => ( - { - locationRefs.current[rowKey] = el; - }} - onKeyDown={(event) => { - if (event.key === 'Enter') { - event.preventDefault(); - const bestMatch = filteredOptions[0]; - if (bestMatch) { - setInlineDraft(item.id, { locationId: bestMatch.id }); - setInlineLocationInputs((prev) => ({ - ...prev, - [rowKey]: bestMatch.name, - })); - setLocationEditing((prev) => ({ - ...prev, - [rowKey]: false, - })); - setInlineError((prev) => ({ ...prev, [item.id]: null })); - focusController.focus(rowKey, 'quantity'); - } else { - setInlineError((prev) => ({ - ...prev, - [item.id]: 'No matches found', - })); - } - } - }} - /> - )} - /> - )} -
    - - {density !== 'compact' ? ( - <> - - Quantity - - - {Number(item.quantity).toLocaleString()} - - - ) : ( - { - const raw = e.target.value.trim(); - if (raw === '') { - setInlineDraft(item.id, { quantity: '' }); - setInlineError((prev) => ({ - ...prev, - [item.id]: 'Quantity is required', - })); - return; - } - const numeric = Number(raw); - setInlineDraft(item.id, { - quantity: numeric, - }); - if (!Number.isInteger(numeric) || numeric <= 0) { - setInlineError((prev) => ({ - ...prev, - [item.id]: 'Quantity must be an integer greater than 0', - })); - } else { - setInlineError((prev) => ({ ...prev, [item.id]: null })); - } - }} - inputProps={{ - inputMode: 'numeric', - pattern: '[0-9]*', - }} - inputRef={(el) => { - quantityRefs.current[rowKey] = el; - }} - 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, - }, - }} - /> - )} - - - {density !== 'compact' && ( - - Updated - - )} - - {new Date(item.dateModified || item.dateAdded || '').toLocaleDateString()} - - {Number.isFinite(draftQuantityNumber) && draftQuantityNumber > 100000 && ( - - Large quantity entered — verify value. - - )} - {errorText && ( - - {errorText} - - )} - - - {density === 'compact' && isDirty && ( - - )} - {density === 'compact' ? ( - handleInlineSaveAndAdvance(item)} - disabled={saving} - data-testid={`inline-save-${item.id}`} - onKeyDown={(event) => { - if (event.key === 'Enter') { - event.preventDefault(); - handleInlineSaveAndAdvance(item); - } - }} - ref={(el: HTMLButtonElement | null) => { - saveRefs.current[rowKey] = el; - }} - > - - - ) : ( - - handleActionOpen(e, item)}> - - - - )} - -
    + /> ); })}