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:
@@ -1,11 +1,13 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import Modal from '../ui/Modal';
|
||||
import SearchableSelect from '../ui/SearchableSelect';
|
||||
import { rialToToman, tomanToRial, toEnglishDigits } from '../../lib/utils';
|
||||
import type { InventoryItem, ItemPayload } from '../../hooks/useInventory';
|
||||
import type { InventoryItem, InventoryMeta, ItemPayload } from '../../hooks/useInventory';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
editing: InventoryItem | null;
|
||||
meta: InventoryMeta;
|
||||
saving: boolean;
|
||||
onClose: () => void;
|
||||
onSave: (payload: ItemPayload, uuid?: string) => void;
|
||||
@@ -14,20 +16,22 @@ interface Props {
|
||||
interface FormState {
|
||||
name: string;
|
||||
consumable: string;
|
||||
category: string;
|
||||
unit: string;
|
||||
price: string; // Toman, as typed
|
||||
stock: string;
|
||||
alertThreshold: string;
|
||||
}
|
||||
|
||||
const BLANK: FormState = { name: '', consumable: '', unit: '', price: '', stock: '', alertThreshold: '' };
|
||||
const DEFAULT_UNIT = 'عدد';
|
||||
const BLANK: FormState = { name: '', consumable: '', category: '', unit: DEFAULT_UNIT, price: '', stock: '', alertThreshold: '' };
|
||||
|
||||
const digits = (v: string) => toEnglishDigits(v).replace(/\D/g, '');
|
||||
// group thousands: "1200000" → "1,200,000"
|
||||
const group = (v: string) => v.replace(/\B(?=(\d{3})+(?!\d))/g, ',');
|
||||
|
||||
/** «افزودن/ویرایش کالای جدید» — mirrors tauri ModalAddInventory field-for-field. */
|
||||
export default function AddItemModal({ open, editing, saving, onClose, onSave }: Props) {
|
||||
/** «افزودن/ویرایش کالای جدید» — واحد و دستهبندی از لیست استاندارد Backend. */
|
||||
export default function AddItemModal({ open, editing, meta, saving, onClose, onSave }: Props) {
|
||||
const [form, setForm] = useState<FormState>(BLANK);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
@@ -38,7 +42,8 @@ export default function AddItemModal({ open, editing, saving, onClose, onSave }:
|
||||
? {
|
||||
name: editing.name,
|
||||
consumable: editing.consumable ?? '',
|
||||
unit: editing.unit,
|
||||
category: editing.category ?? '',
|
||||
unit: editing.unit || DEFAULT_UNIT,
|
||||
price: editing.price ? String(rialToToman(editing.price)) : '',
|
||||
stock: String(editing.stock),
|
||||
alertThreshold: String(editing.alertThreshold),
|
||||
@@ -50,13 +55,17 @@ export default function AddItemModal({ open, editing, saving, onClose, onSave }:
|
||||
setForm((f) => ({ ...f, [k]: e.target.value }));
|
||||
const setNum = (k: keyof FormState) => (e: React.ChangeEvent<HTMLInputElement>) =>
|
||||
setForm((f) => ({ ...f, [k]: digits(e.target.value) }));
|
||||
const setSelect = (k: keyof FormState) => (v: string | number | null) =>
|
||||
setForm((f) => ({ ...f, [k]: v == null ? '' : String(v) }));
|
||||
|
||||
const submit = () => {
|
||||
if (form.name.trim() === '') { setError('نام کالا الزامی است'); return; }
|
||||
if (form.category === '') { setError('دستهبندی کالا الزامی است'); return; }
|
||||
const payload: ItemPayload = {
|
||||
name: form.name.trim(),
|
||||
consumable: form.consumable.trim() || null,
|
||||
unit: form.unit.trim() || 'عدد',
|
||||
category: form.category,
|
||||
unit: form.unit || DEFAULT_UNIT,
|
||||
price: form.price ? tomanToRial(Number(form.price)) : 0,
|
||||
stock: form.stock ? Number(form.stock) : 0,
|
||||
alertThreshold: form.alertThreshold ? Number(form.alertThreshold) : 0,
|
||||
@@ -64,19 +73,47 @@ export default function AddItemModal({ open, editing, saving, onClose, onSave }:
|
||||
onSave(payload, editing?.uuid);
|
||||
};
|
||||
|
||||
const fields: { key: keyof FormState; label: string; placeholder: string; numeric?: boolean }[] = [
|
||||
const inputs: { key: keyof FormState; label: string; placeholder: string; numeric?: boolean }[] = [
|
||||
{ key: 'name', label: 'نام کالا', placeholder: 'نام کالا' },
|
||||
{ key: 'consumable', label: 'مصرفی', placeholder: 'مصرفی' },
|
||||
{ key: 'unit', label: 'واحد', placeholder: 'عدد' },
|
||||
{ key: 'price', label: 'قیمت (تومان)', placeholder: 'قیمت', numeric: true },
|
||||
{ key: 'stock', label: 'موجودی', placeholder: 'موجودی', numeric: true },
|
||||
{ key: 'alertThreshold', label: 'هشدار اتمام', placeholder: 'هشدار اتمام', numeric: true },
|
||||
];
|
||||
|
||||
const toOptions = (list: string[]) => list.map((v) => ({ value: v, label: v }));
|
||||
|
||||
return (
|
||||
<Modal open={open} onClose={onClose} title={editing ? 'ویرایش کالا' : 'افزودن کالای جدید'} size="lg">
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 20 }}>
|
||||
{fields.map((f) => (
|
||||
<div>
|
||||
<label className="field-label">نام کالا</label>
|
||||
<div className="field">
|
||||
<input value={form.name} onChange={set('name')} placeholder="نام کالا" autoFocus />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="field-label">دستهبندی</label>
|
||||
<SearchableSelect
|
||||
options={toOptions(meta.categories)}
|
||||
value={form.category || null}
|
||||
onChange={setSelect('category')}
|
||||
placeholder="انتخاب دستهبندی"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="field-label">واحد</label>
|
||||
<SearchableSelect
|
||||
options={toOptions(meta.units)}
|
||||
value={form.unit || DEFAULT_UNIT}
|
||||
onChange={setSelect('unit')}
|
||||
placeholder="انتخاب واحد"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{inputs.filter((f) => f.key !== 'name').map((f) => (
|
||||
<div key={f.key}>
|
||||
<label className="field-label">{f.label}</label>
|
||||
<div className="field">
|
||||
@@ -85,7 +122,6 @@ export default function AddItemModal({ open, editing, saving, onClose, onSave }:
|
||||
onChange={f.numeric ? setNum(f.key) : set(f.key)}
|
||||
placeholder={f.placeholder}
|
||||
inputMode={f.numeric ? 'numeric' : undefined}
|
||||
autoFocus={f.key === 'name'}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -11,7 +11,7 @@ interface Props {
|
||||
onDelete: (item: InventoryItem) => void;
|
||||
}
|
||||
|
||||
const HEAD = ['نام کالا', 'موجودی', 'واحد', 'قیمت', 'وضعیت', 'عملیات'];
|
||||
const HEAD = ['نام کالا', 'دستهبندی', 'موجودی', 'واحد', 'قیمت', 'وضعیت', 'عملیات'];
|
||||
|
||||
/** Consumable-items list: desktop table + mobile card grid (tauri InventoryList). */
|
||||
export default function InventoryItemsTable({ items, onEdit, onDelete }: Props) {
|
||||
@@ -30,6 +30,7 @@ export default function InventoryItemsTable({ items, onEdit, onDelete }: Props)
|
||||
{items.map((item) => (
|
||||
<tr key={item.uuid}>
|
||||
<td style={{ color: 'var(--text-2)' }}>{item.name}</td>
|
||||
<td style={{ color: 'var(--text-3)' }}>{item.category ?? '—'}</td>
|
||||
<td>{formatNumber(item.stock)}</td>
|
||||
<td>{item.unit}</td>
|
||||
<td>{formatRial(item.price)}</td>
|
||||
@@ -59,6 +60,7 @@ export default function InventoryItemsTable({ items, onEdit, onDelete }: Props)
|
||||
<InventoryStatusBadge status={item.status} />
|
||||
</div>
|
||||
{[
|
||||
['دستهبندی:', item.category ?? '—'],
|
||||
['موجودی:', formatNumber(item.stock)],
|
||||
['واحد:', item.unit],
|
||||
['قیمت:', formatRial(item.price)],
|
||||
|
||||
@@ -11,6 +11,7 @@ export interface InventoryItem {
|
||||
uuid: string;
|
||||
name: string;
|
||||
consumable: string | null;
|
||||
category: string | null;
|
||||
unit: string;
|
||||
price: number; // Rial
|
||||
stock: number;
|
||||
@@ -18,6 +19,12 @@ export interface InventoryItem {
|
||||
status: InventoryStatus;
|
||||
}
|
||||
|
||||
/** Backend-owned option lists (GET /api/v1/inventory-meta) — never hardcode client-side. */
|
||||
export interface InventoryMeta {
|
||||
units: string[];
|
||||
categories: string[];
|
||||
}
|
||||
|
||||
export interface InventoryStats {
|
||||
total: number;
|
||||
low: number;
|
||||
@@ -44,6 +51,7 @@ export interface InventoryPackage {
|
||||
export interface ItemPayload {
|
||||
name: string;
|
||||
consumable?: string | null;
|
||||
category?: string | null;
|
||||
unit?: string;
|
||||
price?: number;
|
||||
stock?: number;
|
||||
@@ -59,6 +67,7 @@ const EMPTY_STATS: InventoryStats = { total: 0, low: 0, inStock: 0, outOfStock:
|
||||
const EMPTY_ITEMS: InventoryItem[] = [];
|
||||
const EMPTY_PACKAGES: InventoryPackage[] = [];
|
||||
const EMPTY_CATS: string[] = [];
|
||||
const EMPTY_META: InventoryMeta = { units: [], categories: [] };
|
||||
|
||||
// ── Hook ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -81,6 +90,13 @@ export function useInventory() {
|
||||
queryFn: () => api.get('/api/v1/inventory-categories'),
|
||||
});
|
||||
|
||||
// Option lists are effectively static — fetch once, never refetch.
|
||||
const metaQuery = useQuery<ApiResponse<InventoryMeta>>({
|
||||
queryKey: ['inventory-meta'],
|
||||
queryFn: () => api.get('/api/v1/inventory-meta'),
|
||||
staleTime: Infinity,
|
||||
});
|
||||
|
||||
const invalidateItems = () => {
|
||||
qc.invalidateQueries({ queryKey: ['inventory-items'] });
|
||||
qc.invalidateQueries({ queryKey: ['inventory-categories'] });
|
||||
@@ -125,6 +141,7 @@ export function useInventory() {
|
||||
stats: itemsQuery.data?.data?.stats ?? EMPTY_STATS,
|
||||
packages: packagesQuery.data?.data ?? EMPTY_PACKAGES,
|
||||
categories: categoriesQuery.data?.data ?? EMPTY_CATS,
|
||||
meta: metaQuery.data?.data ?? EMPTY_META,
|
||||
itemsLoading: itemsQuery.isLoading,
|
||||
packagesLoading: packagesQuery.isLoading,
|
||||
createItem, updateItem, deleteItem,
|
||||
|
||||
@@ -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' });
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user