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
This commit is contained in:
2025-11-25 11:41:15 +10:00
parent f9a093a39d
commit f61d078900
7 changed files with 218 additions and 106 deletions
-3
View File
@@ -1,3 +0,0 @@
id,name,created_at,updated_at
# id optional — you can use empty for new rows. created/updated optional
Area A,,
1 id,name,created_at,updated_at
2 # id optional — you can use empty for new rows. created/updated optional
3 Area A,,
-2
View File
@@ -1,2 +0,0 @@
id,name,created_at,updated_at
Clothing,,
1 id,name,created_at,updated_at
2 Clothing,,
-2
View File
@@ -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,,
1 id pick_list_id pick_list_name product_id product_name quantity is_carton status created_at updated_at
2 My Pick List Blue T-Shirt 2 false pending
-2
View File
@@ -1,2 +0,0 @@
id,area_id,area_name,created_at,completed_at,notes,categories,auto_add_new_products
,,"Area A",,,"","Clothing;Accessories",false
1 id area_id area_name created_at completed_at notes categories auto_add_new_products
2 Area A Clothing;Accessories false
+75 -1
View File
@@ -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 });
}
}),
);
});
};
+72 -44
View File
@@ -28,13 +28,17 @@ export const ManageCategoriesScreen = () => {
const [editingCategoryId, setEditingCategoryId] = useState<string | null>(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<Record<string, number>>((acc, product) => {
acc[product.category] = (acc[product.category] ?? 0) + 1;
return acc;
}, {});
}, [products]);
return products.reduce<Record<string, number>>((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 (
<Container sx={{ py: 4 }}>
+71 -52
View File
@@ -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();