From f61d0789000d7d53726927e6ad1882e035bd922e Mon Sep 17 00:00:00 2001 From: beatzaplenty Date: Tue, 25 Nov 2025 11:41:15 +1000 Subject: [PATCH] deleted: public/templates/areas_template.csv deleted: public/templates/categories_template.csv deleted: public/templates/pickitems_template.csv deleted: public/templates/picklists_template.csv modified: src/db/migrations.ts modified: src/screens/ManageCategoriesScreen.tsx modified: src/screens/ManageProductsScreen.tsx --- public/templates/areas_template.csv | 3 - public/templates/categories_template.csv | 2 - public/templates/pickitems_template.csv | 2 - public/templates/picklists_template.csv | 2 - src/db/migrations.ts | 76 +++++++++++++- src/screens/ManageCategoriesScreen.tsx | 116 +++++++++++++-------- src/screens/ManageProductsScreen.tsx | 123 +++++++++++++---------- 7 files changed, 218 insertions(+), 106 deletions(-) delete mode 100644 public/templates/areas_template.csv delete mode 100644 public/templates/categories_template.csv delete mode 100644 public/templates/pickitems_template.csv delete mode 100644 public/templates/picklists_template.csv diff --git a/public/templates/areas_template.csv b/public/templates/areas_template.csv deleted file mode 100644 index 1059124..0000000 --- a/public/templates/areas_template.csv +++ /dev/null @@ -1,3 +0,0 @@ -id,name,created_at,updated_at -# id optional — you can use empty for new rows. created/updated optional -Area A,, diff --git a/public/templates/categories_template.csv b/public/templates/categories_template.csv deleted file mode 100644 index fd24cfb..0000000 --- a/public/templates/categories_template.csv +++ /dev/null @@ -1,2 +0,0 @@ -id,name,created_at,updated_at -Clothing,, diff --git a/public/templates/pickitems_template.csv b/public/templates/pickitems_template.csv deleted file mode 100644 index 22bbf7d..0000000 --- a/public/templates/pickitems_template.csv +++ /dev/null @@ -1,2 +0,0 @@ -id,pick_list_id,pick_list_name,product_id,product_name,quantity,is_carton,status,created_at,updated_at -,,"My Pick List",,"Blue T-Shirt",2,false,pending,, diff --git a/public/templates/picklists_template.csv b/public/templates/picklists_template.csv deleted file mode 100644 index 19406c2..0000000 --- a/public/templates/picklists_template.csv +++ /dev/null @@ -1,2 +0,0 @@ -id,area_id,area_name,created_at,completed_at,notes,categories,auto_add_new_products -,,"Area A",,,"","Clothing;Accessories",false diff --git a/src/db/migrations.ts b/src/db/migrations.ts index 6b8cd14..f404f49 100644 --- a/src/db/migrations.ts +++ b/src/db/migrations.ts @@ -1,6 +1,80 @@ +// src/db/migrations.ts import { StockFillDB } from './index'; +import { v4 as uuidv4 } from 'uuid'; export const applyMigrations = async (db: StockFillDB) => { - // Future migrations can be added here. + // Ensure DB is open and ready await db.open(); + + // Run a single transaction that normalizes products and pickLists + await db.transaction('rw', db.categories, db.products, db.pickLists, async () => { + const now = Date.now(); + + // Load categories (existing) + const categories = await db.categories.toArray(); + const categoriesByName = new Map(categories.map((c) => [c.name, c.id])); + const categoriesById = new Map(categories.map((c) => [c.id, c.name])); + + // 1) Normalize products: category -> categoryId + const products = await db.products.toArray(); + await Promise.all( + products.map(async (product) => { + const cat = product.category ?? ''; + + // Skip empty + if (!cat) return; + + // If already an id that matches a known category, skip + if (categoriesById.has(cat)) return; + + // If it's a name that matches an existing category, update + const matchingId = categoriesByName.get(cat); + if (matchingId) { + await db.products.update(product.id, { category: matchingId }); + return; + } + + // Otherwise: create a new category with this name and use its id + const newCatId = uuidv4(); + await db.categories.add({ + id: newCatId, + name: cat, + created_at: now, + updated_at: now, + }); + categoriesByName.set(cat, newCatId); + categoriesById.set(newCatId, cat); + + await db.products.update(product.id, { category: newCatId }); + }), + ); + + // Refresh categories maps (in case we added new ones) + const updatedCategories = await db.categories.toArray(); + const updatedByName = new Map(updatedCategories.map((c) => [c.name, c.id])); + const updatedById = new Map(updatedCategories.map((c) => [c.id, c.name])); + + // 2) Normalize pickLists.categories (array) to hold ids (not names) + const pickLists = await db.pickLists.toArray(); + await Promise.all( + pickLists.map(async (pl) => { + if (!Array.isArray((pl as any).categories)) return; + + const newCats = (pl as any).categories.map((entry: string) => { + // If the entry is already an id we know, keep it + if (updatedById.has(entry)) return entry; + // If entry is a name, return its id (if exists) + const id = updatedByName.get(entry); + if (id) return id; + // If it's neither, keep as-is (or optionally create category) + return entry; + }); + + // Update only if changed + if (JSON.stringify(newCats) !== JSON.stringify((pl as any).categories)) { + await db.pickLists.update(pl.id, { categories: newCats }); + } + }), + ); + }); }; diff --git a/src/screens/ManageCategoriesScreen.tsx b/src/screens/ManageCategoriesScreen.tsx index 9ea838a..5ea1422 100644 --- a/src/screens/ManageCategoriesScreen.tsx +++ b/src/screens/ManageCategoriesScreen.tsx @@ -28,13 +28,17 @@ export const ManageCategoriesScreen = () => { const [editingCategoryId, setEditingCategoryId] = useState(null); const [editName, setEditName] = useState(''); const [feedback, setFeedback] = useState<{ text: string; severity: AlertColor } | null>(null); + const categoriesById = useMemo(() => new Map(categories.map((c) => [c.id, c.name])), [categories]); const usageByCategory = useMemo(() => { - return products.reduce>((acc, product) => { - acc[product.category] = (acc[product.category] ?? 0) + 1; - return acc; - }, {}); - }, [products]); + return products.reduce>((acc, product) => { + const categoryName = categoriesById.get(product.category) ?? product.category ?? ''; + if (!categoryName) return acc; + acc[categoryName] = (acc[categoryName] ?? 0) + 1; + return acc; + }, {}); +}, [products, categoriesById]); + const addCategory = async () => { const trimmed = name.trim(); @@ -55,34 +59,52 @@ export const ManageCategoriesScreen = () => { setFeedback(null); }; - const saveCategory = async () => { - if (!editingCategoryId) return; - const trimmed = editName.trim(); - if (!trimmed) return; +const saveCategory = async () => { + if (!editingCategoryId) return; + const trimmed = editName.trim(); + if (!trimmed) return; - const category = categories.find((item) => item.id === editingCategoryId); - if (!category) return; + const category = categories.find((item) => item.id === editingCategoryId); + if (!category) return; - const nameExists = categories.some( - (item) => item.id !== editingCategoryId && item.name.toLowerCase() === trimmed.toLowerCase(), + const nameExists = categories.some( + (item) => item.id !== editingCategoryId && item.name.toLowerCase() === trimmed.toLowerCase(), + ); + if (nameExists) { + setFeedback({ text: 'A category with this name already exists.', severity: 'error' }); + return; + } + + await db.transaction('rw', db.categories, db.products, db.pickLists, async () => { + // Update category name + await db.categories.update(editingCategoryId, { name: trimmed, updated_at: Date.now() }); + + // For backward-compat products that stored category as the old name, + // update them to reference the new name OR ideally, to the id. + // We map products that still have category === oldName to the new name value. + // (If you ran the normalization migration earlier, most products will already have ids.) + await db.products + .where('category') + .equals(category.name) + .modify({ category: trimmed, updated_at: Date.now() }); + + // Update pickLists that used the old category name + const pickLists = await db.pickLists.toArray(); + await Promise.all( + pickLists.map(async (pl) => { + if (!Array.isArray((pl as any).categories)) return; + const needs = (pl as any).categories.includes(category.name); + if (!needs) return; + const updated = (pl as any).categories.map((c: string) => (c === category.name ? trimmed : c)); + await db.pickLists.update(pl.id, { categories: updated }); + }), ); - if (nameExists) { - setFeedback({ text: 'A category with this name already exists.', severity: 'error' }); - return; - } + }); - await db.transaction('rw', db.categories, db.products, async () => { - await db.categories.update(editingCategoryId, { name: trimmed, updated_at: Date.now() }); - await db.products - .where('category') - .equals(category.name) - .modify({ category: trimmed, updated_at: Date.now() }); - }); - - setEditingCategoryId(null); - setEditName(''); - setFeedback({ text: 'Category updated. Linked products were refreshed.', severity: 'success' }); - }; + setEditingCategoryId(null); + setEditName(''); + setFeedback({ text: 'Category updated. Linked products were refreshed.', severity: 'success' }); +}; const cancelEditing = () => { setEditingCategoryId(null); @@ -90,21 +112,27 @@ export const ManageCategoriesScreen = () => { setFeedback(null); }; - const deleteCategory = async (categoryId: string, categoryName: string) => { - const usageCount = usageByCategory[categoryName] ?? 0; - if (usageCount > 0) { - setFeedback({ - text: `Cannot delete '${categoryName}' while ${usageCount} product(s) use it. Update those products first.`, - severity: 'error', - }); - return; - } - await db.categories.delete(categoryId); - if (editingCategoryId === categoryId) { - cancelEditing(); - } - setFeedback({ text: 'Category deleted.', severity: 'success' }); - }; +const deleteCategory = async (categoryId: string, categoryName: string) => { + // Count products referencing either the id or the name (legacy) + const [countById, countByName] = await Promise.all([ + db.products.where('category').equals(categoryId).count(), + db.products.where('category').equals(categoryName).count(), + ]); + const usageCount = countById + countByName; + + if (usageCount > 0) { + setFeedback({ + text: `Cannot delete '${categoryName}' while ${usageCount} product(s) use it. Update those products first.`, + severity: 'error', + }); + return; + } + + await db.categories.delete(categoryId); + if (editingCategoryId === categoryId) cancelEditing(); + setFeedback({ text: 'Category deleted.', severity: 'success' }); +}; + return ( diff --git a/src/screens/ManageProductsScreen.tsx b/src/screens/ManageProductsScreen.tsx index f7e838d..3e26b08 100644 --- a/src/screens/ManageProductsScreen.tsx +++ b/src/screens/ManageProductsScreen.tsx @@ -93,36 +93,48 @@ export const ManageProductsScreen = () => { [findNameConflict], ); - 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], - ); +const addProductToAutoLists = useCallback( + async (product: Product, timestamp: number) => { + const pickLists = await db.pickLists.toArray(); + const categoriesAll = await db.categories.toArray(); + const categoriesByName = new Map(categoriesAll.map((c) => [c.name, c.id])); + + const eligibleLists = pickLists.filter((pickList) => + pickList.auto_add_new_products && Array.isArray(pickList.categories) + ? pickList.categories.some((catRef: string) => { + // catRef can be an id or a name — resolve both + if (catRef === product.category) return true; + const resolvedId = categoriesByName.get(catRef); + return resolvedId === 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, db.categories], +); + useEffect(() => { if (categoryOptions.length === 0) return; @@ -192,28 +204,23 @@ export const ManageProductsScreen = () => { } const addProduct = async () => { - if (!name || !category) return; - try { - await assertUniqueName(name); - await assertUniqueBarcode(barcode || undefined); - } catch (error) { - if (error instanceof Error && error.name === 'DuplicateNameError') { - setNameError(error.message); - return; - } - if (error instanceof Error && error.name === 'DuplicateBarcodeError') { - setBarcodeError(error.message); - return; - } - throw error; + // Resolve selected category name -> id if possible, otherwise create + let categoryIdToSave: string; + let chosenCategoryObj = categories.find((c) => c.name === category); + + if (chosenCategoryObj) { + categoryIdToSave = chosenCategoryObj.id; + } else { + // create a category automatically (or you can prompt the user if you prefer) + const now = Date.now(); + const newCatId = uuidv4(); + await db.categories.add({ id: newCatId, name: category, created_at: now, updated_at: now }); + categoryIdToSave = newCatId; } + const timestamp = Date.now(); const productId = uuidv4(); - // Resolve selected category name -> id if possible - const chosenCategoryObj = categories.find((c) => c.name === category); - const categoryIdToSave = chosenCategoryObj ? chosenCategoryObj.id : category || ''; - const newProduct: Product = { id: productId, name, @@ -250,10 +257,22 @@ export const ManageProductsScreen = () => { const existing = await db.products.get(productId); if (!existing) return; - // Map the provided category name back to the id (if it exists) - let categoryIdToSave = updates.category; - const matchingCategory = categories.find((c) => c.name === updates.category); - if (matchingCategory) categoryIdToSave = matchingCategory.id; + // Map the provided category name back to the id (if it exists), or create one + let categoryIdToSave = updates.category; + const matchingCategory = categories.find((c) => c.name === updates.category); + if (matchingCategory) { + categoryIdToSave = matchingCategory.id; + } else { + // If updates.category already looks like an id, keep it. Otherwise create a new category + const isExistingId = categories.some((c) => c.id === updates.category); + if (!isExistingId) { + const newCatId = uuidv4(); + const now = Date.now(); + await db.categories.add({ id: newCatId, name: updates.category, created_at: now, updated_at: now }); + categoryIdToSave = newCatId; + } + } + const normalizedName = updates.name.trim(); const oldNameKey = existing.name.trim().toLowerCase();