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:
@@ -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,2 +0,0 @@
|
||||
id,name,created_at,updated_at
|
||||
Clothing,,
|
||||
|
@@ -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,2 +0,0 @@
|
||||
id,area_id,area_name,created_at,completed_at,notes,categories,auto_add_new_products
|
||||
,,"Area A",,,"","Clothing;Accessories",false
|
||||
|
+75
-1
@@ -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 });
|
||||
}
|
||||
}),
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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 }}>
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user