feat: Implement category and unit selection for inventory items

- Added a new 'category' field to the InventoryItem entity and updated the database schema.
- Replaced free-text input for 'unit' and 'category' with select dropdowns in the AddItemModal.
- Introduced a new API endpoint to fetch metadata for units and categories.
- Updated inventory filtering logic to use the new 'category' field instead of 'consumable'.
- Enhanced validation for item creation and updates to ensure valid unit and category values.
- Updated tests to cover new functionality and ensure proper validation.
This commit is contained in:
hamed
2026-07-15 14:32:29 +03:30
parent defa0db023
commit 3e5dee0ad5
12 changed files with 513 additions and 36 deletions
+19 -2
View File
@@ -14,18 +14,20 @@ import InventoryPage from './InventoryPage';
const get = api.get as ReturnType<typeof vi.fn>;
const ITEM = {
uuid: 'i-1', name: 'دستکش جراحی', consumable: 'جراحی', unit: 'عدد',
uuid: 'i-1', name: 'دستکش جراحی', consumable: 'جراحی', category: 'لوازم مصرفی و تزریقات', unit: 'عدد',
price: 250000, stock: 150, alertThreshold: 20, status: 'in_stock',
};
const META = { units: ['عدد', 'بسته', 'ویال'], categories: ['دارو', 'لوازم مصرفی و تزریقات', 'سایر'] };
const STATS = { total: 1, low: 0, inStock: 1, outOfStock: 0 };
const PKG = {
uuid: 'p-1', title: 'پکیج شماره یک', total: 2400000, available: true,
items: [{ itemUuid: 'i-1', name: 'ژل', unit: 'سی‌سی', price: 1200000, amount: 2 }],
};
function mockApi(opts: { items?: any[]; stats?: any; packages?: any[]; categories?: string[] } = {}) {
function mockApi(opts: { items?: any[]; stats?: any; packages?: any[]; categories?: string[]; meta?: any } = {}) {
get.mockImplementation((url: string = '') => {
if (url.includes('/inventory-packages')) return Promise.resolve({ success: true, data: opts.packages ?? [] });
if (url.includes('/inventory-meta')) return Promise.resolve({ success: true, data: opts.meta ?? META });
if (url.includes('/inventory-categories')) return Promise.resolve({ success: true, data: opts.categories ?? [] });
if (url.includes('/inventory-items')) return Promise.resolve({ success: true, data: { items: opts.items ?? [], stats: opts.stats ?? { total: 0, low: 0, inStock: 0, outOfStock: 0 } } });
return Promise.resolve({ success: true, data: { items: [], stats: { total: 0, low: 0, inStock: 0, outOfStock: 0 } } });
@@ -69,6 +71,21 @@ describe('InventoryPage', () => {
expect(price.value).toBe('1,200,000');
});
it('blocks submit when category is missing and unit is a picker, not free text', async () => {
mockApi({ items: [], stats: { total: 0, low: 0, inStock: 0, outOfStock: 0 } });
renderWithProviders(<InventoryPage />, { route: '/admin/inventory' });
fireEvent.click(await screen.findByRole('button', { name: /افزودن کالا/ }));
await screen.findByText('افزودن کالای جدید');
// unit is now a select — no free-text input with the old placeholder
expect(screen.queryByPlaceholderText('عدد')).not.toBeInTheDocument();
fireEvent.change(screen.getByPlaceholderText('نام کالا'), { target: { value: 'ماسک' } });
fireEvent.click(screen.getByRole('button', { name: 'اضافه کردن کالا' }));
expect(await screen.findByText('دسته‌بندی کالا الزامی است')).toBeInTheDocument();
});
it('switches to the packages tab and lists a package with its price', async () => {
mockApi({ packages: [PKG] });
renderWithProviders(<InventoryPage />, { route: '/admin/inventory' });
+3 -2
View File
@@ -14,7 +14,7 @@ type Tab = 'stock' | 'packages';
/** انبارداری — consumable stock items + packages. Ported from clinic-pro-tauri /inventory. */
export default function InventoryPage() {
const {
items, stats, packages, categories, itemsLoading, packagesLoading,
items, stats, packages, categories, meta, itemsLoading, packagesLoading,
createItem, updateItem, deleteItem, createPackage, updatePackage, deletePackage,
} = useInventory();
@@ -31,7 +31,7 @@ export default function InventoryPage() {
const q = search.trim();
return items.filter((it) =>
(q === '' || it.name.includes(q)) &&
(category === '' || it.consumable === category)
(category === '' || it.category === category)
);
}, [items, search, category]);
@@ -120,6 +120,7 @@ export default function InventoryPage() {
<AddItemModal
open={itemModal.open}
editing={itemModal.editing}
meta={meta}
saving={createItem.isPending || updateItem.isPending}
onClose={() => setItemModal({ open: false, editing: null })}
onSave={saveItem}