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"
+ />
+
+
+ }
+ variant="outlined"
+ color="inherit"
+ onClick={() => setSortDir((dir) => (dir === 'asc' ? 'desc' : 'asc'))}
+ >
+ Sort: {sortBy} ({sortDir})
+
+
+
+
+ Sort By
+
+
+
+
+
+ Group By
+
+
+
+
+ }
+ onClick={() =>
+ setFilters({
+ search: '',
+ categoryId: '',
+ locationId: '',
+ sharedOnly: false,
+ valueRange: [0, maxQuantity || 100000],
+ })
+ }
+ >
+ Clear filters
+
+
+
+
+ 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"
- />
-
-
- }
- variant="outlined"
- color="inherit"
- onClick={() =>
- setSortDir((dir) => (dir === 'asc' ? 'desc' : 'asc'))
- }
- >
- Sort: {sortBy} ({sortDir})
-
-
-
-
- Sort By
-
-
-
-
-
- Group By
-
-
-
-
- }
- onClick={() =>
- setFilters({
- search: '',
- categoryId: '',
- locationId: '',
- sharedOnly: false,
- valueRange: [0, maxQuantity || 100000],
- })
- }
- >
- Clear filters
-
-
-
-
- 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)}>
-
-
-
- )}
-
-
+ />
);
})}