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 { StockFillDB } from './index';
|
||||||
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
|
|
||||||
export const applyMigrations = async (db: StockFillDB) => {
|
export const applyMigrations = async (db: StockFillDB) => {
|
||||||
// Future migrations can be added here.
|
// Ensure DB is open and ready
|
||||||
await db.open();
|
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 [editingCategoryId, setEditingCategoryId] = useState<string | null>(null);
|
||||||
const [editName, setEditName] = useState('');
|
const [editName, setEditName] = useState('');
|
||||||
const [feedback, setFeedback] = useState<{ text: string; severity: AlertColor } | null>(null);
|
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(() => {
|
const usageByCategory = useMemo(() => {
|
||||||
return products.reduce<Record<string, number>>((acc, product) => {
|
return products.reduce<Record<string, number>>((acc, product) => {
|
||||||
acc[product.category] = (acc[product.category] ?? 0) + 1;
|
const categoryName = categoriesById.get(product.category) ?? product.category ?? '';
|
||||||
|
if (!categoryName) return acc;
|
||||||
|
acc[categoryName] = (acc[categoryName] ?? 0) + 1;
|
||||||
return acc;
|
return acc;
|
||||||
}, {});
|
}, {});
|
||||||
}, [products]);
|
}, [products, categoriesById]);
|
||||||
|
|
||||||
|
|
||||||
const addCategory = async () => {
|
const addCategory = async () => {
|
||||||
const trimmed = name.trim();
|
const trimmed = name.trim();
|
||||||
@@ -55,7 +59,7 @@ export const ManageCategoriesScreen = () => {
|
|||||||
setFeedback(null);
|
setFeedback(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
const saveCategory = async () => {
|
const saveCategory = async () => {
|
||||||
if (!editingCategoryId) return;
|
if (!editingCategoryId) return;
|
||||||
const trimmed = editName.trim();
|
const trimmed = editName.trim();
|
||||||
if (!trimmed) return;
|
if (!trimmed) return;
|
||||||
@@ -71,18 +75,36 @@ export const ManageCategoriesScreen = () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await db.transaction('rw', db.categories, db.products, async () => {
|
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() });
|
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
|
await db.products
|
||||||
.where('category')
|
.where('category')
|
||||||
.equals(category.name)
|
.equals(category.name)
|
||||||
.modify({ category: trimmed, updated_at: Date.now() });
|
.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 });
|
||||||
|
}),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
setEditingCategoryId(null);
|
setEditingCategoryId(null);
|
||||||
setEditName('');
|
setEditName('');
|
||||||
setFeedback({ text: 'Category updated. Linked products were refreshed.', severity: 'success' });
|
setFeedback({ text: 'Category updated. Linked products were refreshed.', severity: 'success' });
|
||||||
};
|
};
|
||||||
|
|
||||||
const cancelEditing = () => {
|
const cancelEditing = () => {
|
||||||
setEditingCategoryId(null);
|
setEditingCategoryId(null);
|
||||||
@@ -90,8 +112,14 @@ export const ManageCategoriesScreen = () => {
|
|||||||
setFeedback(null);
|
setFeedback(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
const deleteCategory = async (categoryId: string, categoryName: string) => {
|
const deleteCategory = async (categoryId: string, categoryName: string) => {
|
||||||
const usageCount = usageByCategory[categoryName] ?? 0;
|
// 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) {
|
if (usageCount > 0) {
|
||||||
setFeedback({
|
setFeedback({
|
||||||
text: `Cannot delete '${categoryName}' while ${usageCount} product(s) use it. Update those products first.`,
|
text: `Cannot delete '${categoryName}' while ${usageCount} product(s) use it. Update those products first.`,
|
||||||
@@ -99,12 +127,12 @@ export const ManageCategoriesScreen = () => {
|
|||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await db.categories.delete(categoryId);
|
await db.categories.delete(categoryId);
|
||||||
if (editingCategoryId === categoryId) {
|
if (editingCategoryId === categoryId) cancelEditing();
|
||||||
cancelEditing();
|
|
||||||
}
|
|
||||||
setFeedback({ text: 'Category deleted.', severity: 'success' });
|
setFeedback({ text: 'Category deleted.', severity: 'success' });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Container sx={{ py: 4 }}>
|
<Container sx={{ py: 4 }}>
|
||||||
|
|||||||
@@ -93,12 +93,23 @@ export const ManageProductsScreen = () => {
|
|||||||
[findNameConflict],
|
[findNameConflict],
|
||||||
);
|
);
|
||||||
|
|
||||||
const addProductToAutoLists = useCallback(
|
const addProductToAutoLists = useCallback(
|
||||||
async (product: Product, timestamp: number) => {
|
async (product: Product, timestamp: number) => {
|
||||||
const pickLists = await db.pickLists.toArray();
|
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) =>
|
const eligibleLists = pickLists.filter((pickList) =>
|
||||||
pickList.auto_add_new_products && Array.isArray(pickList.categories) ? pickList.categories.includes(product.category) : false,
|
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;
|
if (eligibleLists.length === 0) return;
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
eligibleLists.map(async (pickList) => {
|
eligibleLists.map(async (pickList) => {
|
||||||
@@ -121,8 +132,9 @@ export const ManageProductsScreen = () => {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
[db.pickItems, db.pickLists],
|
[db.pickItems, db.pickLists, db.categories],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (categoryOptions.length === 0) return;
|
if (categoryOptions.length === 0) return;
|
||||||
@@ -192,28 +204,23 @@ export const ManageProductsScreen = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const addProduct = async () => {
|
const addProduct = async () => {
|
||||||
if (!name || !category) return;
|
// Resolve selected category name -> id if possible, otherwise create
|
||||||
try {
|
let categoryIdToSave: string;
|
||||||
await assertUniqueName(name);
|
let chosenCategoryObj = categories.find((c) => c.name === category);
|
||||||
await assertUniqueBarcode(barcode || undefined);
|
|
||||||
} catch (error) {
|
if (chosenCategoryObj) {
|
||||||
if (error instanceof Error && error.name === 'DuplicateNameError') {
|
categoryIdToSave = chosenCategoryObj.id;
|
||||||
setNameError(error.message);
|
} else {
|
||||||
return;
|
// create a category automatically (or you can prompt the user if you prefer)
|
||||||
}
|
const now = Date.now();
|
||||||
if (error instanceof Error && error.name === 'DuplicateBarcodeError') {
|
const newCatId = uuidv4();
|
||||||
setBarcodeError(error.message);
|
await db.categories.add({ id: newCatId, name: category, created_at: now, updated_at: now });
|
||||||
return;
|
categoryIdToSave = newCatId;
|
||||||
}
|
|
||||||
throw error;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const timestamp = Date.now();
|
const timestamp = Date.now();
|
||||||
const productId = uuidv4();
|
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 = {
|
const newProduct: Product = {
|
||||||
id: productId,
|
id: productId,
|
||||||
name,
|
name,
|
||||||
@@ -250,10 +257,22 @@ export const ManageProductsScreen = () => {
|
|||||||
const existing = await db.products.get(productId);
|
const existing = await db.products.get(productId);
|
||||||
if (!existing) return;
|
if (!existing) return;
|
||||||
|
|
||||||
// Map the provided category name back to the id (if it exists)
|
// Map the provided category name back to the id (if it exists), or create one
|
||||||
let categoryIdToSave = updates.category;
|
let categoryIdToSave = updates.category;
|
||||||
const matchingCategory = categories.find((c) => c.name === updates.category);
|
const matchingCategory = categories.find((c) => c.name === updates.category);
|
||||||
if (matchingCategory) categoryIdToSave = matchingCategory.id;
|
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 normalizedName = updates.name.trim();
|
||||||
const oldNameKey = existing.name.trim().toLowerCase();
|
const oldNameKey = existing.name.trim().toLowerCase();
|
||||||
|
|||||||
Reference in New Issue
Block a user