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
@@ -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)],