Merge pull request #65 from beatz174-bit/codex/add-checkbox-for-new-products-on-pick-list

Add auto-add option for pick lists and sync new products
This commit is contained in:
beatz174-bit
2025-11-23 16:01:48 +10:00
committed by GitHub
8 changed files with 189 additions and 13 deletions
+32
View File
@@ -202,6 +202,38 @@ export class StockFillDB extends Dexie {
}),
);
});
this.version(7)
.stores({
products: 'id, name, category, &barcode, archived, created_at, updated_at',
areas: 'id, name, created_at, updated_at',
pickLists:
'id, area_id, created_at, completed_at, auto_add_new_products, categories',
pickItems:
'id, pick_list_id, product_id, status, is_carton, quantity, created_at, updated_at',
categories: 'id, name, created_at, updated_at',
})
.upgrade(async (tx) => {
const pickLists = await tx.table('pickLists').toArray();
await Promise.all(
pickLists.map((pickList) => {
const updates: Partial<PickList> = {};
if (!Array.isArray((pickList as PickList).categories)) {
updates.categories = [];
}
if (typeof (pickList as PickList).auto_add_new_products !== 'boolean') {
updates.auto_add_new_products = false;
}
if (Object.keys(updates).length === 0) return undefined;
return tx.table('pickLists').update(pickList.id, updates);
}),
);
});
}
}
+2
View File
@@ -4,4 +4,6 @@ export interface PickList {
created_at: number;
completed_at?: number;
notes?: string;
categories: string[];
auto_add_new_products: boolean;
}
+7 -1
View File
@@ -84,7 +84,13 @@ vi.mock('../hooks/dataHooks', () => ({
updated_at: 0,
},
],
usePickList: () => ({ id: 'list-1', area_id: 'area-1', created_at: 0 }),
usePickList: () => ({
id: 'list-1',
area_id: 'area-1',
created_at: 0,
categories: [],
auto_add_new_products: false,
}),
useAreas: () => [{ id: 'area-1', name: 'Front Counter', created_at: 0, updated_at: 0 }],
}));
+77 -3
View File
@@ -11,6 +11,7 @@ const mockUseProducts = vi.fn();
const mockUseCategories = vi.fn();
const productDeleteMock = vi.fn();
const pickItemCountMock = vi.fn();
const pickItemsStore: any[] = [];
const mockDb = {
products: {
@@ -21,7 +22,12 @@ const mockDb = {
},
pickItems: {
where: vi.fn(),
add: vi.fn(),
},
pickLists: {
toArray: vi.fn(),
},
transaction: vi.fn(),
};
vi.mock('../hooks/dataHooks', () => ({
@@ -33,6 +39,10 @@ vi.mock('../context/DBProvider', () => ({
useDatabase: () => mockDb,
}));
vi.mock('uuid', () => ({
v4: () => 'new-product-id',
}));
vi.mock('../components/BarcodeScannerView', () => ({
BarcodeScannerView: ({ onDetected }: { onDetected?: (code: string) => void }) => (
<button type="button" onClick={() => onDetected?.(mockScannedBarcode)}>
@@ -47,6 +57,7 @@ beforeEach(() => {
mockUseProducts.mockReset();
mockUseCategories.mockReset();
mockScannedBarcode = '123456';
pickItemsStore.length = 0;
Object.values(mockDb.products).forEach((fn) => fn.mockReset());
mockDb.products.where.mockImplementation(() => ({
equals: (value: string) => ({
@@ -54,10 +65,35 @@ beforeEach(() => {
}),
}));
pickItemCountMock.mockReset();
pickItemCountMock.mockImplementation((value?: string, field?: string) =>
Promise.resolve(pickItemsStore.filter((item) => item[field ?? 'product_id'] === value).length),
);
mockDb.pickItems.add.mockReset();
mockDb.pickItems.add.mockImplementation(async (item) => {
pickItemsStore.push(item);
return item.id;
});
mockDb.pickItems.where.mockReset();
mockDb.pickItems.where.mockImplementation(() => ({
equals: () => ({ count: pickItemCountMock }),
mockDb.pickItems.where.mockImplementation((field: string) => ({
equals: (value: string) => ({
count: () => pickItemCountMock(value, field),
filter: (predicate: (item: any) => boolean) => ({
first: () =>
Promise.resolve(
pickItemsStore.find((item) => item[field] === value && predicate(item)) ?? undefined,
),
}),
first: () =>
Promise.resolve(pickItemsStore.find((item) => item[field] === value) ?? undefined),
}),
}));
mockDb.pickLists.toArray.mockReset();
mockDb.pickLists.toArray.mockResolvedValue([]);
mockDb.transaction.mockReset();
mockDb.transaction.mockImplementation(async (_mode: string, ...args: unknown[]) => {
const callback = args[args.length - 1] as () => Promise<unknown>;
return callback();
});
});
describe('ManageProductsScreen barcode lookup', () => {
@@ -176,6 +212,41 @@ describe('ManageProductsScreen barcode lookup', () => {
});
});
describe('ManageProductsScreen auto-adding products to pick lists', () => {
it('adds a new product to matching pick lists when auto-add is enabled', async () => {
mockUseProducts.mockReturnValue([]);
mockUseCategories.mockReturnValue([{ id: 'cat-1', name: 'Snacks', created_at: 0, updated_at: 0 }]);
mockDb.pickLists.toArray.mockResolvedValue([
{
id: 'list-1',
area_id: 'area-1',
created_at: 0,
categories: ['Snacks'],
auto_add_new_products: true,
},
]);
const user = userEvent.setup();
render(
<MemoryRouter>
<ManageProductsScreen />
</MemoryRouter>,
);
await user.type(screen.getByLabelText(/name/i), 'Granola Bar');
await user.click(screen.getByRole('button', { name: /save product/i }));
await waitFor(() => {
expect(mockDb.pickItems.add).toHaveBeenCalledTimes(1);
});
const addedItem = mockDb.pickItems.add.mock.calls[0][0];
expect(addedItem.pick_list_id).toBe('list-1');
expect(addedItem.product_id).toBe('new-product-id');
expect(addedItem.status).toBe('pending');
});
});
describe('ManageProductsScreen deletion safeguards', () => {
it('blocks deletion when pick items reference the product', async () => {
mockUseProducts.mockReturnValue([
@@ -191,7 +262,10 @@ describe('ManageProductsScreen deletion safeguards', () => {
},
]);
mockUseCategories.mockReturnValue([{ id: 'cat-1', name: 'Snacks', created_at: 0, updated_at: 0 }]);
pickItemCountMock.mockResolvedValueOnce(2);
pickItemsStore.push(
{ id: 'item-1', product_id: 'prod-chips', pick_list_id: 'list-1' },
{ id: 'item-2', product_id: 'prod-chips', pick_list_id: 'list-2' },
);
const user = userEvent.setup();
render(
+50 -6
View File
@@ -19,9 +19,9 @@ import { v4 as uuidv4 } from 'uuid';
import { ProductRow } from '../components/ProductRow';
import { useCategories, useProducts } from '../hooks/dataHooks';
import { useDatabase } from '../context/DBProvider';
import { DEFAULT_BULK_NAME, DEFAULT_UNIT_TYPE } from '../models/Product';
import { BarcodeScannerView } from '../components/BarcodeScannerView';
import { ExternalProductInfo, fetchProductFromOFF } from '../modules/openFoodFacts';
import { DEFAULT_BULK_NAME, DEFAULT_UNIT_TYPE, Product } from '../models/Product';
export const ManageProductsScreen = () => {
const db = useDatabase();
@@ -94,6 +94,44 @@ export const ManageProductsScreen = () => {
[db.products, findBarcodeConflict],
);
const addProductToAutoLists = useCallback(
async (product: Product, timestamp: number) => {
const pickLists = await db.pickLists.toArray();
const eligibleLists = pickLists.filter(
(pickList) =>
pickList.auto_add_new_products && Array.isArray(pickList.categories)
? pickList.categories.includes(product.category)
: false,
);
if (eligibleLists.length === 0) return;
await Promise.all(
eligibleLists.map(async (pickList) => {
const existing = await db.pickItems
.where('pick_list_id')
.equals(pickList.id)
.filter((item) => item.product_id === product.id)
.first();
if (existing) return undefined;
return db.pickItems.add({
id: uuidv4(),
pick_list_id: pickList.id,
product_id: product.id,
quantity: 1,
is_carton: false,
status: 'pending',
created_at: timestamp,
updated_at: timestamp,
});
}),
);
},
[db.pickItems, db.pickLists],
);
useEffect(() => {
if (categoryOptions.length === 0) return;
if (!categoryOptions.includes(category)) {
@@ -137,17 +175,23 @@ export const ManageProductsScreen = () => {
}
throw error;
}
await db.products.add({
id: uuidv4(),
const timestamp = Date.now();
const productId = uuidv4();
const newProduct: Product = {
id: productId,
name,
category,
unit_type: DEFAULT_UNIT_TYPE,
bulk_name: DEFAULT_BULK_NAME,
barcode: barcode || undefined,
archived: false,
created_at: Date.now(),
updated_at: Date.now(),
created_at: timestamp,
updated_at: timestamp,
};
await db.transaction('rw', db.products, db.pickLists, db.pickItems, async () => {
await db.products.add(newProduct);
await addProductToAutoLists(newProduct, timestamp);
});
setName('');
setBarcode('');
+3 -3
View File
@@ -10,9 +10,9 @@ const areasMock = [
];
const pickListsMock = [
{ id: 'list-2', area_id: 'area-2', created_at: 3 },
{ id: 'list-3', area_id: 'area-3', created_at: 4 },
{ id: 'list-1', area_id: 'area-1', created_at: 5 },
{ id: 'list-2', area_id: 'area-2', created_at: 3, categories: [], auto_add_new_products: false },
{ id: 'list-3', area_id: 'area-3', created_at: 4, categories: [], auto_add_new_products: false },
{ id: 'list-1', area_id: 'area-1', created_at: 5, categories: [], auto_add_new_products: false },
];
vi.mock('../hooks/dataHooks', () => ({
+6
View File
@@ -100,6 +100,8 @@ describe('StartPickListScreen', () => {
categoriesMock.forEach((category) => {
expect(screen.getByRole('checkbox', { name: category.name })).toBeInTheDocument();
});
expect(screen.getByRole('checkbox', { name: /add new products/i })).toBeChecked();
});
it('prefills a new pick list with products from selected categories', async () => {
@@ -132,6 +134,10 @@ describe('StartPickListScreen', () => {
expect(item.quantity).toBe(1);
expect(item.status).toBe('pending');
});
const pickListRecord = pickListAddMock.mock.calls[0][0];
expect(pickListRecord.categories).toEqual(['Drinks', 'Snacks']);
expect(pickListRecord.auto_add_new_products).toBe(true);
});
it('deduplicates products when selected categories include overlaps', async () => {
+12
View File
@@ -23,6 +23,7 @@ export const StartPickListScreen = () => {
const [areaId, setAreaId] = useState('');
const [notes, setNotes] = useState('');
const [selectedCategories, setSelectedCategories] = useState<string[]>([]);
const [autoAddNewProducts, setAutoAddNewProducts] = useState(true);
const sortedCategories = useMemo(
() =>
@@ -55,6 +56,8 @@ export const StartPickListScreen = () => {
area_id: areaId,
created_at: timestamp,
notes: notes.trim() || undefined,
categories: selectedCategoryNames,
auto_add_new_products: autoAddNewProducts,
});
if (selectedCategoryNames.length === 0) {
@@ -143,6 +146,15 @@ export const StartPickListScreen = () => {
/>
))}
</FormGroup>
<FormControlLabel
control={
<Checkbox
checked={autoAddNewProducts}
onChange={(event) => setAutoAddNewProducts(event.target.checked)}
/>
}
label="Add new products"
/>
</Stack>
<Button variant="contained" disabled={!areaId} onClick={start}>
Save Pick List