Add auto-add option for pick lists and sync new products
This commit is contained in:
1 parent
c29c779fd8
commit
3fa9e6df5f
8 files changed
+189
-13
No files matched your search
@@ -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);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,4 +4,6 @@ export interface PickList {
|
|||||||
created_at: number;
|
created_at: number;
|
||||||
completed_at?: number;
|
completed_at?: number;
|
||||||
notes?: string;
|
notes?: string;
|
||||||
|
categories: string[];
|
||||||
|
auto_add_new_products: boolean;
|
||||||
}
|
}
|
||||||
@@ -84,7 +84,13 @@ vi.mock('../hooks/dataHooks', () => ({
|
|||||||
updated_at: 0,
|
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 }],
|
useAreas: () => [{ id: 'area-1', name: 'Front Counter', created_at: 0, updated_at: 0 }],
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ const mockUseProducts = vi.fn();
|
|||||||
const mockUseCategories = vi.fn();
|
const mockUseCategories = vi.fn();
|
||||||
const productDeleteMock = vi.fn();
|
const productDeleteMock = vi.fn();
|
||||||
const pickItemCountMock = vi.fn();
|
const pickItemCountMock = vi.fn();
|
||||||
|
const pickItemsStore: any[] = [];
|
||||||
|
|
||||||
const mockDb = {
|
const mockDb = {
|
||||||
products: {
|
products: {
|
||||||
@@ -21,7 +22,12 @@ const mockDb = {
|
|||||||
},
|
},
|
||||||
pickItems: {
|
pickItems: {
|
||||||
where: vi.fn(),
|
where: vi.fn(),
|
||||||
|
add: vi.fn(),
|
||||||
},
|
},
|
||||||
|
pickLists: {
|
||||||
|
toArray: vi.fn(),
|
||||||
|
},
|
||||||
|
transaction: vi.fn(),
|
||||||
};
|
};
|
||||||
|
|
||||||
vi.mock('../hooks/dataHooks', () => ({
|
vi.mock('../hooks/dataHooks', () => ({
|
||||||
@@ -33,6 +39,10 @@ vi.mock('../context/DBProvider', () => ({
|
|||||||
useDatabase: () => mockDb,
|
useDatabase: () => mockDb,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.mock('uuid', () => ({
|
||||||
|
v4: () => 'new-product-id',
|
||||||
|
}));
|
||||||
|
|
||||||
vi.mock('../components/BarcodeScannerView', () => ({
|
vi.mock('../components/BarcodeScannerView', () => ({
|
||||||
BarcodeScannerView: ({ onDetected }: { onDetected?: (code: string) => void }) => (
|
BarcodeScannerView: ({ onDetected }: { onDetected?: (code: string) => void }) => (
|
||||||
<button type="button" onClick={() => onDetected?.(mockScannedBarcode)}>
|
<button type="button" onClick={() => onDetected?.(mockScannedBarcode)}>
|
||||||
@@ -47,6 +57,7 @@ beforeEach(() => {
|
|||||||
mockUseProducts.mockReset();
|
mockUseProducts.mockReset();
|
||||||
mockUseCategories.mockReset();
|
mockUseCategories.mockReset();
|
||||||
mockScannedBarcode = '123456';
|
mockScannedBarcode = '123456';
|
||||||
|
pickItemsStore.length = 0;
|
||||||
Object.values(mockDb.products).forEach((fn) => fn.mockReset());
|
Object.values(mockDb.products).forEach((fn) => fn.mockReset());
|
||||||
mockDb.products.where.mockImplementation(() => ({
|
mockDb.products.where.mockImplementation(() => ({
|
||||||
equals: (value: string) => ({
|
equals: (value: string) => ({
|
||||||
@@ -54,10 +65,35 @@ beforeEach(() => {
|
|||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
pickItemCountMock.mockReset();
|
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.mockReset();
|
||||||
mockDb.pickItems.where.mockImplementation(() => ({
|
mockDb.pickItems.where.mockImplementation((field: string) => ({
|
||||||
equals: () => ({ count: pickItemCountMock }),
|
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', () => {
|
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', () => {
|
describe('ManageProductsScreen deletion safeguards', () => {
|
||||||
it('blocks deletion when pick items reference the product', async () => {
|
it('blocks deletion when pick items reference the product', async () => {
|
||||||
mockUseProducts.mockReturnValue([
|
mockUseProducts.mockReturnValue([
|
||||||
@@ -191,7 +262,10 @@ describe('ManageProductsScreen deletion safeguards', () => {
|
|||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
mockUseCategories.mockReturnValue([{ id: 'cat-1', name: 'Snacks', created_at: 0, updated_at: 0 }]);
|
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();
|
const user = userEvent.setup();
|
||||||
render(
|
render(
|
||||||
|
|||||||
@@ -19,9 +19,9 @@ import { v4 as uuidv4 } from 'uuid';
|
|||||||
import { ProductRow } from '../components/ProductRow';
|
import { ProductRow } from '../components/ProductRow';
|
||||||
import { useCategories, useProducts } from '../hooks/dataHooks';
|
import { useCategories, useProducts } from '../hooks/dataHooks';
|
||||||
import { useDatabase } from '../context/DBProvider';
|
import { useDatabase } from '../context/DBProvider';
|
||||||
import { DEFAULT_BULK_NAME, DEFAULT_UNIT_TYPE } from '../models/Product';
|
|
||||||
import { BarcodeScannerView } from '../components/BarcodeScannerView';
|
import { BarcodeScannerView } from '../components/BarcodeScannerView';
|
||||||
import { ExternalProductInfo, fetchProductFromOFF } from '../modules/openFoodFacts';
|
import { ExternalProductInfo, fetchProductFromOFF } from '../modules/openFoodFacts';
|
||||||
|
import { DEFAULT_BULK_NAME, DEFAULT_UNIT_TYPE, Product } from '../models/Product';
|
||||||
|
|
||||||
export const ManageProductsScreen = () => {
|
export const ManageProductsScreen = () => {
|
||||||
const db = useDatabase();
|
const db = useDatabase();
|
||||||
@@ -94,6 +94,44 @@ export const ManageProductsScreen = () => {
|
|||||||
[db.products, findBarcodeConflict],
|
[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(() => {
|
useEffect(() => {
|
||||||
if (categoryOptions.length === 0) return;
|
if (categoryOptions.length === 0) return;
|
||||||
if (!categoryOptions.includes(category)) {
|
if (!categoryOptions.includes(category)) {
|
||||||
@@ -137,17 +175,23 @@ export const ManageProductsScreen = () => {
|
|||||||
}
|
}
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
const timestamp = Date.now();
|
||||||
await db.products.add({
|
const productId = uuidv4();
|
||||||
id: uuidv4(),
|
const newProduct: Product = {
|
||||||
|
id: productId,
|
||||||
name,
|
name,
|
||||||
category,
|
category,
|
||||||
unit_type: DEFAULT_UNIT_TYPE,
|
unit_type: DEFAULT_UNIT_TYPE,
|
||||||
bulk_name: DEFAULT_BULK_NAME,
|
bulk_name: DEFAULT_BULK_NAME,
|
||||||
barcode: barcode || undefined,
|
barcode: barcode || undefined,
|
||||||
archived: false,
|
archived: false,
|
||||||
created_at: Date.now(),
|
created_at: timestamp,
|
||||||
updated_at: Date.now(),
|
updated_at: timestamp,
|
||||||
|
};
|
||||||
|
|
||||||
|
await db.transaction('rw', db.products, db.pickLists, db.pickItems, async () => {
|
||||||
|
await db.products.add(newProduct);
|
||||||
|
await addProductToAutoLists(newProduct, timestamp);
|
||||||
});
|
});
|
||||||
setName('');
|
setName('');
|
||||||
setBarcode('');
|
setBarcode('');
|
||||||
|
|||||||
@@ -10,9 +10,9 @@ const areasMock = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
const pickListsMock = [
|
const pickListsMock = [
|
||||||
{ id: 'list-2', area_id: 'area-2', created_at: 3 },
|
{ 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 },
|
{ 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 },
|
{ id: 'list-1', area_id: 'area-1', created_at: 5, categories: [], auto_add_new_products: false },
|
||||||
];
|
];
|
||||||
|
|
||||||
vi.mock('../hooks/dataHooks', () => ({
|
vi.mock('../hooks/dataHooks', () => ({
|
||||||
|
|||||||
@@ -100,6 +100,8 @@ describe('StartPickListScreen', () => {
|
|||||||
categoriesMock.forEach((category) => {
|
categoriesMock.forEach((category) => {
|
||||||
expect(screen.getByRole('checkbox', { name: category.name })).toBeInTheDocument();
|
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 () => {
|
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.quantity).toBe(1);
|
||||||
expect(item.status).toBe('pending');
|
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 () => {
|
it('deduplicates products when selected categories include overlaps', async () => {
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ export const StartPickListScreen = () => {
|
|||||||
const [areaId, setAreaId] = useState('');
|
const [areaId, setAreaId] = useState('');
|
||||||
const [notes, setNotes] = useState('');
|
const [notes, setNotes] = useState('');
|
||||||
const [selectedCategories, setSelectedCategories] = useState<string[]>([]);
|
const [selectedCategories, setSelectedCategories] = useState<string[]>([]);
|
||||||
|
const [autoAddNewProducts, setAutoAddNewProducts] = useState(true);
|
||||||
|
|
||||||
const sortedCategories = useMemo(
|
const sortedCategories = useMemo(
|
||||||
() =>
|
() =>
|
||||||
@@ -55,6 +56,8 @@ export const StartPickListScreen = () => {
|
|||||||
area_id: areaId,
|
area_id: areaId,
|
||||||
created_at: timestamp,
|
created_at: timestamp,
|
||||||
notes: notes.trim() || undefined,
|
notes: notes.trim() || undefined,
|
||||||
|
categories: selectedCategoryNames,
|
||||||
|
auto_add_new_products: autoAddNewProducts,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (selectedCategoryNames.length === 0) {
|
if (selectedCategoryNames.length === 0) {
|
||||||
@@ -143,6 +146,15 @@ export const StartPickListScreen = () => {
|
|||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</FormGroup>
|
</FormGroup>
|
||||||
|
<FormControlLabel
|
||||||
|
control={
|
||||||
|
<Checkbox
|
||||||
|
checked={autoAddNewProducts}
|
||||||
|
onChange={(event) => setAutoAddNewProducts(event.target.checked)}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
label="Add new products"
|
||||||
|
/>
|
||||||
</Stack>
|
</Stack>
|
||||||
<Button variant="contained" disabled={!areaId} onClick={start}>
|
<Button variant="contained" disabled={!areaId} onClick={start}>
|
||||||
Save Pick List
|
Save Pick List
|
||||||
|
|||||||
Reference in new issue
Block a user