diff --git a/src/App.tsx b/src/App.tsx index 2bf67d6..8ad21a7 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -6,7 +6,6 @@ import { HomeScreen } from './screens/HomeScreen'; import { StartPickListScreen } from './screens/StartPickListScreen'; import { PickListsScreen } from './screens/PickListsScreen'; import { ActivePickListScreen } from './screens/ActivePickListScreen'; -import { AddItemScreen } from './screens/AddItemScreen'; import { ManageProductsScreen } from './screens/ManageProductsScreen'; import { ManageAreasScreen } from './screens/ManageAreasScreen'; import { ManageCategoriesScreen } from './screens/ManageCategoriesScreen'; @@ -31,7 +30,6 @@ const AppRoutes = () => { } /> } /> } /> - } /> } /> } /> } /> diff --git a/src/screens/ActivePickListScreen.test.tsx b/src/screens/ActivePickListScreen.test.tsx new file mode 100644 index 0000000..69cd8be --- /dev/null +++ b/src/screens/ActivePickListScreen.test.tsx @@ -0,0 +1,136 @@ +import { MemoryRouter, Route, Routes } from 'react-router-dom'; +import { render, screen, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { describe, expect, it, vi, beforeEach } from 'vitest'; +import { ActivePickListScreen } from './ActivePickListScreen'; +import { PickItem } from '../models/PickItem'; + +const addMock = vi.fn(); +const updateMock = vi.fn(); +const pickItemsMock = vi.fn(); + +vi.mock('../hooks/dataHooks', () => ({ + usePickItems: () => pickItemsMock(), + useProducts: () => [ + { + id: 'prod-1', + name: 'Cola', + category: 'Drinks', + unit_type: 'unit', + bulk_name: 'box', + barcode: '111', + archived: false, + created_at: 0, + updated_at: 0, + }, + { + id: 'prod-2', + name: 'Chips', + category: 'Snacks', + unit_type: 'unit', + bulk_name: 'box', + barcode: '222', + archived: false, + created_at: 0, + updated_at: 0, + }, + ], + usePickList: () => ({ id: 'list-1', area_id: 'area-1', created_at: 0 }), + useAreas: () => [{ id: 'area-1', name: 'Front Counter', created_at: 0, updated_at: 0 }], +})); + +vi.mock('../context/DBProvider', () => ({ + useDatabase: () => ({ + pickItems: { + add: addMock, + update: updateMock, + get: vi.fn(), + delete: vi.fn(), + }, + }), +})); + +describe('ActivePickListScreen product search', () => { + beforeEach(() => { + addMock.mockReset(); + updateMock.mockReset(); + pickItemsMock.mockReturnValue([]); + }); + + it('filters the product list based on the search query', async () => { + const user = userEvent.setup(); + + render( + + + } /> + + , + ); + + const combobox = screen.getByRole('combobox'); + await user.type(combobox, 'cola'); + + expect(await screen.findByRole('option', { name: /cola \(drinks\)/i })).toBeVisible(); + expect(screen.queryByRole('option', { name: /chips \(snacks\)/i })).not.toBeInTheDocument(); + }); + + it('adds a pick item when a product is selected', async () => { + const user = userEvent.setup(); + + render( + + + } /> + + , + ); + + const combobox = screen.getByRole('combobox'); + await user.click(combobox); + + const listbox = await screen.findByRole('listbox'); + await user.click(within(listbox).getByRole('option', { name: /cola \(drinks\)/i })); + + expect(addMock).toHaveBeenCalledTimes(1); + expect(addMock.mock.calls[0][0]).toMatchObject({ + product_id: 'prod-1', + quantity: 1, + is_carton: false, + }); + }); + + it('updates an existing pick item when the same packaging is selected', async () => { + pickItemsMock.mockReturnValue([ + { + id: 'item-1', + pick_list_id: 'list-1', + product_id: 'prod-1', + quantity: 2, + is_carton: false, + status: 'pending', + created_at: 0, + updated_at: 0, + }, + ]); + + const user = userEvent.setup(); + + render( + + + } /> + + , + ); + + const combobox = screen.getByRole('combobox'); + await user.click(combobox); + + const listbox = await screen.findByRole('listbox'); + await user.click(within(listbox).getByRole('option', { name: /cola \(drinks\)/i })); + + expect(updateMock).toHaveBeenCalledWith('item-1', expect.objectContaining({ quantity: 3 })); + expect(addMock).not.toHaveBeenCalled(); + }); +}); diff --git a/src/screens/ActivePickListScreen.tsx b/src/screens/ActivePickListScreen.tsx index ecc461e..74e96b4 100644 --- a/src/screens/ActivePickListScreen.tsx +++ b/src/screens/ActivePickListScreen.tsx @@ -1,10 +1,23 @@ -import { Button, Container, Stack, Typography } from '@mui/material'; -import { useParams, useNavigate, Link as RouterLink } from 'react-router-dom'; -import { useMemo } from 'react'; +import { + Autocomplete, + Button, + Checkbox, + Container, + FormControlLabel, + InputAdornment, + Stack, + TextField, + Typography, +} from '@mui/material'; +import SearchIcon from '@mui/icons-material/Search'; +import { useParams, useNavigate } from 'react-router-dom'; +import { useEffect, useMemo, useState } from 'react'; +import { v4 as uuidv4 } from 'uuid'; import { useAreas, usePickItems, usePickList, useProducts } from '../hooks/dataHooks'; import { useDatabase } from '../context/DBProvider'; import { PickItemRow } from '../components/PickItemRow'; import { PickItem } from '../models/PickItem'; +import { Product } from '../models/Product'; export const ActivePickListScreen = () => { const { id } = useParams(); @@ -14,12 +27,38 @@ export const ActivePickListScreen = () => { const areas = useAreas(); const db = useDatabase(); const navigate = useNavigate(); + const [selectedProduct, setSelectedProduct] = useState(null); + const [query, setQuery] = useState(''); + const [quantity, setQuantity] = useState(1); + const [isCarton, setIsCarton] = useState(false); const areaName = useMemo( () => areas.find((area) => area.id === pickList?.area_id)?.name ?? 'Area', [areas, pickList?.area_id], ); + const filteredProducts = useMemo(() => { + const normalizedQuery = query.trim().toLowerCase(); + if (!normalizedQuery) return products; + + return products.filter((product) => { + const searchableText = `${product.name} ${product.category} ${product.barcode ?? ''}`.toLowerCase(); + return searchableText.includes(normalizedQuery); + }); + }, [products, query]); + + useEffect(() => { + if (!selectedProduct) return; + if (!filteredProducts.some((product) => product.id === selectedProduct.id)) { + setSelectedProduct(null); + } + }, [filteredProducts, selectedProduct]); + + useEffect(() => { + setQuantity(1); + setIsCarton(false); + }, [selectedProduct]); + const handleIncrementQuantity = async (itemId: string) => { const existing = await db.pickItems.get(itemId); if (!existing) return; @@ -61,17 +100,121 @@ export const ActivePickListScreen = () => { await db.pickItems.delete(itemId); }; + const addOrUpdateItem = async (product: Product) => { + if (!id || quantity <= 0) return; + + const existing = items.find( + (item) => item.product_id === product.id && item.is_carton === isCarton, + ); + + if (existing) { + await db.pickItems.update(existing.id, { + quantity: existing.quantity + quantity, + updated_at: Date.now(), + }); + } else { + await db.pickItems.add({ + id: uuidv4(), + pick_list_id: id, + product_id: product.id, + quantity, + is_carton: isCarton, + status: 'pending', + created_at: Date.now(), + updated_at: Date.now(), + }); + } + + setSelectedProduct(null); + setQuery(''); + setQuantity(1); + setIsCarton(false); + }; + const returnToLists = () => { navigate('/pick-lists'); }; + const packagingLabel = isCarton + ? selectedProduct?.bulk_name ?? 'Cartons' + : selectedProduct?.unit_type ?? 'Units'; + const quantityHelperText = selectedProduct + ? `Enter ${packagingLabel.toLowerCase()} to pick` + : 'Select a product to add it to the list'; + const cartonCheckboxLabel = selectedProduct?.bulk_name + ? `Carton (${selectedProduct.bulk_name})` + : 'Carton pick'; + return ( - + {areaName} List - + + + Add products to this list + + `${option.name} (${option.category})`} + isOptionEqualToValue={(option, value) => option.id === value.id} + value={selectedProduct} + onChange={(_, value) => { + setSelectedProduct(value); + if (value) { + void addOrUpdateItem(value); + } + }} + inputValue={query} + onInputChange={(_, value, reason) => { + if (reason === 'input') { + setQuery(value); + } + + if (reason === 'clear') { + setQuery(''); + } + }} + filterOptions={(options) => options} + noOptionsText={query.trim() ? 'No matching products' : 'No products available'} + fullWidth + renderInput={(params) => ( + + + + + {params.InputProps.startAdornment} + + ), + }} + /> + )} + /> + + setQuantity(Math.max(1, Number(event.target.value)))} + inputProps={{ min: 1 }} + helperText={quantityHelperText} + fullWidth + /> + setIsCarton(event.target.checked)} />} + label={cartonCheckboxLabel} + disabled={!selectedProduct} + /> + + + Selecting a product immediately adds it to the pick list. + + {pickList?.notes ? ( diff --git a/src/screens/AddItemScreen.test.tsx b/src/screens/AddItemScreen.test.tsx deleted file mode 100644 index c11b174..0000000 --- a/src/screens/AddItemScreen.test.tsx +++ /dev/null @@ -1,62 +0,0 @@ -import { MemoryRouter, Route, Routes } from 'react-router-dom'; -import { render, screen } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -import { describe, expect, it, vi } from 'vitest'; -import { AddItemScreen } from './AddItemScreen'; - -const addMock = vi.fn(); - -vi.mock('../hooks/dataHooks', () => ({ - usePickItems: () => [], - useProducts: () => [ - { - id: 'prod-1', - name: 'Cola', - category: 'Drinks', - unit_type: 'unit', - bulk_name: 'box', - barcode: '111', - archived: false, - created_at: 0, - updated_at: 0, - }, - { - id: 'prod-2', - name: 'Chips', - category: 'Snacks', - unit_type: 'unit', - bulk_name: 'box', - barcode: '222', - archived: false, - created_at: 0, - updated_at: 0, - }, - ], -})); - -vi.mock('../context/DBProvider', () => ({ - useDatabase: () => ({ - pickItems: { - add: addMock, - }, - }), -})); - -describe('AddItemScreen product search', () => { - it('filters the product list based on the search query', async () => { - const user = userEvent.setup(); - - render( - - - } /> - - , - ); - - await user.type(screen.getByPlaceholderText(/search products/i), 'cola'); - - expect(await screen.findByRole('option', { name: /cola \(drinks\)/i })).toBeVisible(); - expect(screen.queryByRole('option', { name: /chips \(snacks\)/i })).not.toBeInTheDocument(); - }); -}); diff --git a/src/screens/AddItemScreen.tsx b/src/screens/AddItemScreen.tsx deleted file mode 100644 index b578d67..0000000 --- a/src/screens/AddItemScreen.tsx +++ /dev/null @@ -1,166 +0,0 @@ -import { - Autocomplete, - Button, - Checkbox, - Container, - FormControlLabel, - InputAdornment, - Stack, - TextField, - Typography, -} from '@mui/material'; -import SearchIcon from '@mui/icons-material/Search'; -import { useEffect, useMemo, useState } from 'react'; -import { useNavigate, useParams } from 'react-router-dom'; -import { v4 as uuidv4 } from 'uuid'; -import { usePickItems, useProducts } from '../hooks/dataHooks'; -import { useDatabase } from '../context/DBProvider'; - -export const AddItemScreen = () => { - const { id } = useParams(); - const db = useDatabase(); - const items = usePickItems(id); - const products = useProducts(); - const [selectedProduct, setSelectedProduct] = useState(null); - const [query, setQuery] = useState(''); - const [quantity, setQuantity] = useState(1); - const [isCarton, setIsCarton] = useState(false); - const navigate = useNavigate(); - const unitLabel = selectedProduct?.unit_type ?? 'Units'; - const cartonLabel = selectedProduct?.bulk_name ?? 'Cartons'; - - const filteredProducts = useMemo(() => { - const normalizedQuery = query.trim().toLowerCase(); - if (!normalizedQuery) return products; - - return products.filter((product) => { - const searchableText = `${product.name} ${product.category} ${product.barcode ?? ''}`.toLowerCase(); - return searchableText.includes(normalizedQuery); - }); - }, [products, query]); - - useEffect(() => { - if (!selectedProduct) return; - if (!filteredProducts.some((product) => product.id === selectedProduct.id)) { - setSelectedProduct(null); - } - }, [filteredProducts, selectedProduct]); - - useEffect(() => { - setQuantity(1); - setIsCarton(false); - }, [selectedProduct]); - - const packagingLabel = isCarton ? cartonLabel : unitLabel; - const quantityHelperText = selectedProduct - ? `Enter ${packagingLabel.toLowerCase()} to pick` - : 'Enter the quantity to pick'; - const cartonCheckboxLabel = selectedProduct?.bulk_name - ? `Carton (${selectedProduct.bulk_name})` - : 'Carton pick'; - - const addItem = async () => { - const productId = selectedProduct?.id; - - if (!id || !productId || quantity <= 0) return; - - const existing = items.find((item) => item.product_id === productId && item.is_carton === isCarton); - - if (existing) { - await db.pickItems.update(existing.id, { - quantity: existing.quantity + quantity, - updated_at: Date.now(), - }); - navigate(`/pick-lists/${id}`); - return; - } - - await db.pickItems.add({ - id: uuidv4(), - pick_list_id: id, - product_id: productId, - quantity, - is_carton: isCarton, - status: 'pending', - created_at: Date.now(), - updated_at: Date.now(), - }); - navigate(`/pick-lists/${id}`); - }; - - return ( - - - Add Item - - - `${option.name} (${option.category})`} - isOptionEqualToValue={(option, value) => option.id === value.id} - value={selectedProduct} - onChange={(_, value) => setSelectedProduct(value)} - inputValue={query} - onInputChange={(_, value, reason) => { - if (reason === 'input') { - setQuery(value); - } - - if (reason === 'clear') { - setQuery(''); - } - }} - filterOptions={(options) => options} - noOptionsText={query.trim() ? 'No matching products' : 'No products available'} - fullWidth - renderInput={(params) => ( - - - - - {params.InputProps.startAdornment} - - ), - }} - /> - )} - /> - {selectedProduct && ( - - - Selected product - - - {selectedProduct.name} - - - {selectedProduct.category} - - - )} - setQuantity(Math.max(1, Number(event.target.value)))} - inputProps={{ min: 1 }} - helperText={quantityHelperText} - /> - setIsCarton(event.target.checked)} />} - label={cartonCheckboxLabel} - disabled={!selectedProduct} - /> - - - - ); -};