- {fields.map((f) => (
+
+
+
+
+
+
+
+
+
+
+
+
+ {inputs.filter((f) => f.key !== 'name').map((f) => (
@@ -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'}
/>
diff --git a/assets/admin/components/inventory/InventoryItemsTable.tsx b/assets/admin/components/inventory/InventoryItemsTable.tsx
index 2fd116d6..b676119c 100644
--- a/assets/admin/components/inventory/InventoryItemsTable.tsx
+++ b/assets/admin/components/inventory/InventoryItemsTable.tsx
@@ -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) => (
| {item.name} |
+ {item.category ?? '—'} |
{formatNumber(item.stock)} |
{item.unit} |
{formatRial(item.price)} |
@@ -59,6 +60,7 @@ export default function InventoryItemsTable({ items, onEdit, onDelete }: Props)
{[
+ ['دستهبندی:', item.category ?? '—'],
['موجودی:', formatNumber(item.stock)],
['واحد:', item.unit],
['قیمت:', formatRial(item.price)],
diff --git a/assets/admin/hooks/useInventory.ts b/assets/admin/hooks/useInventory.ts
index ea5cffd5..7f01bc6d 100644
--- a/assets/admin/hooks/useInventory.ts
+++ b/assets/admin/hooks/useInventory.ts
@@ -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>({
+ 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,
diff --git a/assets/admin/pages/InventoryPage.test.tsx b/assets/admin/pages/InventoryPage.test.tsx
index f2632d29..5241bd51 100644
--- a/assets/admin/pages/InventoryPage.test.tsx
+++ b/assets/admin/pages/InventoryPage.test.tsx
@@ -14,18 +14,20 @@ import InventoryPage from './InventoryPage';
const get = api.get as ReturnType;
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(, { 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(, { route: '/admin/inventory' });
diff --git a/assets/admin/pages/InventoryPage.tsx b/assets/admin/pages/InventoryPage.tsx
index 84034d40..5a5517bf 100644
--- a/assets/admin/pages/InventoryPage.tsx
+++ b/assets/admin/pages/InventoryPage.tsx
@@ -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() {
setItemModal({ open: false, editing: null })}
onSave={saveItem}
diff --git a/docs/api/inventory.md b/docs/api/inventory.md
index 109ddffc..8c708ed3 100644
--- a/docs/api/inventory.md
+++ b/docs/api/inventory.md
@@ -31,6 +31,7 @@ List the tenant's items plus the four derived stat counters.
"uuid": "…",
"name": "دستکش جراحی",
"consumable": "جراحی",
+ "category": "لوازم مصرفی و تزریقات",
"unit": "عدد",
"price": 250000,
"stock": 150,
@@ -45,11 +46,29 @@ List the tenant's items plus the four derived stat counters.
### GET `/api/v1/inventory-categories`
-Distinct non-empty `consumable` values for the tenant — powers the filter dropdown.
+Distinct non-empty `category` values **actually in use** by the tenant — powers the
+filter dropdown. For the full list of allowed categories use `inventory-meta`.
#### Response `200`
```json
-{ "success": true, "data": ["جراحی", "دندانپزشکی"] }
+{ "success": true, "data": ["دارو", "لوازم آزمایشگاهی"] }
+```
+
+### GET `/api/v1/inventory-meta`
+
+Backend-owned option lists for the item form. **Single source of truth** — the
+admin never hardcodes units/categories. Values are plain Persian strings stored
+as-is; the create/update endpoints validate against these lists.
+
+#### Response `200`
+```json
+{
+ "success": true,
+ "data": {
+ "units": ["عدد", "بسته", "…", "سیسی", "میلیلیتر", "…"],
+ "categories": ["دارو", "لوازم مصرفی و تزریقات", "…", "سایر"]
+ }
+}
```
### POST `/api/v1/inventory-item`
@@ -60,8 +79,9 @@ Create an item.
| Field | Type | Required | Notes |
|-------|------|----------|-------|
| `name` | string | ✅ | نام کالا |
-| `consumable` | string | ❌ | «مصرفی» / گروه فیلتر |
-| `unit` | string | ❌ | Default `عدد` |
+| `consumable` | string | ❌ | «مصرفی» — یادداشت آزاد (سازگاری عقبرو) |
+| `category` | string | ❌ | دستهبندی؛ باید یکی از `inventory-meta.categories` باشد. خالی → `null`. فرم ادمین آن را الزامی میکند |
+| `unit` | string | ❌ | باید یکی از `inventory-meta.units` باشد. خالی → پیشفرض `عدد` |
| `price` | integer | ❌ | Rial, Default `0` |
| `stock` | integer | ❌ | Default `0` |
| `alertThreshold` | integer | ❌ | Default `0` |
@@ -74,7 +94,9 @@ Create an item.
#### Errors
| Status | Code | Cause |
|--------|------|-------|
-| `422` | `ERR_VALIDATION_001` | `name` خالی است |
+| `422` | `ERR_VALIDATION_001` | `name` خالی است (field `name`) |
+| `422` | `ERR_VALIDATION_001` | `unit` خارج از لیست مجاز (field `unit`) |
+| `422` | `ERR_VALIDATION_001` | `category` خارج از لیست مجاز (field `category`) |
| `403` | `ERR_FORBIDDEN_001` | پروفایل tenant یافت نشد |
### PATCH `/api/v1/inventory-item/{uuid}`
diff --git a/migrations/Version20260715105351.php b/migrations/Version20260715105351.php
new file mode 100644
index 00000000..056a1118
--- /dev/null
+++ b/migrations/Version20260715105351.php
@@ -0,0 +1,31 @@
+addSql('ALTER TABLE inventory_items ADD category VARCHAR(60) DEFAULT NULL');
+ }
+
+ public function down(Schema $schema): void
+ {
+ // this down() migration is auto-generated, please modify it to your needs
+ $this->addSql('ALTER TABLE inventory_items DROP category');
+ }
+}
diff --git a/src/Inventory/Controller/InventoryController.php b/src/Inventory/Controller/InventoryController.php
index 6488a5c4..48338d1a 100644
--- a/src/Inventory/Controller/InventoryController.php
+++ b/src/Inventory/Controller/InventoryController.php
@@ -13,6 +13,7 @@ use App\Inventory\Repository\InventoryPackageRepository;
use App\Inventory\Service\InventoryService;
use App\Shared\Constant\ErrorCodes;
use App\Shared\Controller\BaseController;
+use App\Shared\Exception\AppException;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Attribute\Route;
@@ -64,7 +65,16 @@ class InventoryController extends BaseController
return $this->error(ErrorCodes::ERR_FORBIDDEN_001, 'پروفایل یافت نشد', 403);
}
- return $this->success($this->itemRepo->findConsumables($type, $id));
+ return $this->success($this->itemRepo->findCategories($type, $id));
+ }
+
+ #[Route('/api/v1/inventory-meta', methods: ['GET'])]
+ public function meta(): JsonResponse
+ {
+ return $this->success([
+ 'units' => InventoryItem::UNITS,
+ 'categories' => InventoryItem::CATEGORIES,
+ ]);
}
#[Route('/api/v1/inventory-item', methods: ['POST'])]
@@ -210,7 +220,17 @@ class InventoryController extends BaseController
}
if (array_key_exists('unit', $data)) {
$unit = trim((string) $data['unit']);
- $item->setUnit($unit === '' ? 'عدد' : $unit);
+ if ($unit !== '' && !in_array($unit, InventoryItem::UNITS, true)) {
+ throw new AppException(ErrorCodes::ERR_VALIDATION_001, 'واحد نامعتبر است', 422, 'unit');
+ }
+ $item->setUnit($unit === '' ? InventoryItem::DEFAULT_UNIT : $unit);
+ }
+ if (array_key_exists('category', $data)) {
+ $category = trim((string) $data['category']);
+ if ($category !== '' && !in_array($category, InventoryItem::CATEGORIES, true)) {
+ throw new AppException(ErrorCodes::ERR_VALIDATION_001, 'دستهبندی نامعتبر است', 422, 'category');
+ }
+ $item->setCategory($category === '' ? null : $category);
}
if (array_key_exists('price', $data)) {
$item->setPrice((int) $data['price']);
diff --git a/src/Inventory/Entity/InventoryItem.php b/src/Inventory/Entity/InventoryItem.php
index f466f9b4..8a8d023a 100644
--- a/src/Inventory/Entity/InventoryItem.php
+++ b/src/Inventory/Entity/InventoryItem.php
@@ -23,6 +23,39 @@ class InventoryItem
public const STATUS_LOW_STOCK = 'low_stock';
public const STATUS_OUT_OF_STOCK = 'out_of_stock';
+ public const DEFAULT_UNIT = 'عدد';
+
+ /**
+ * Allowed measurement units for a stock item. Backend is the single source of
+ * truth (served via GET /api/v1/inventory-meta); the admin never hardcodes these.
+ * Extend by appending — values are plain Persian strings stored as-is, so no
+ * migration is needed. Most-used units are listed first for the picker.
+ */
+ public const UNITS = [
+ 'عدد', 'بسته', 'جعبه', 'قوطی', 'جفت', 'دست',
+ 'ویال', 'آمپول', 'قرص', 'کپسول', 'ورق (بلیستر)', 'ساشه', 'تیوب',
+ 'سیسی', 'میلیلیتر', 'لیتر', 'میلیگرم', 'گرم', 'کیلوگرم',
+ 'رول', 'متر', 'سانتیمتر', 'کیسه',
+ ];
+
+ /**
+ * Allowed inventory categories for a clinic/office. Same contract as {@see self::UNITS}:
+ * backend-owned, plain Persian strings, extend by appending (no migration).
+ */
+ public const CATEGORIES = [
+ 'دارو',
+ 'لوازم مصرفی و تزریقات',
+ 'لوازم پانسمان و بخیه',
+ 'مواد ضدعفونی و استریلیزاسیون',
+ 'تجهیزات پزشکی',
+ 'بیهوشی و بیحسی',
+ 'لوازم زیبایی و پوست',
+ 'لوازم آزمایشگاهی',
+ 'لوازم دندانپزشکی',
+ 'ملزومات اداری و مصرفی دفتری',
+ 'سایر',
+ ];
+
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column(type: 'integer')]
@@ -40,12 +73,16 @@ class InventoryItem
#[ORM\Column(type: 'string', length: 120)]
private string $name;
- /** Free-text "مصرفی" classifier from the source modal; doubles as filter group. */
+ /** Free-text "مصرفی" note carried over from the source modal (kept for compatibility). */
#[ORM\Column(type: 'string', length: 120, nullable: true)]
private ?string $consumable = null;
+ /** Standard category from {@see self::CATEGORIES}; primary grouping/filter dimension. */
+ #[ORM\Column(type: 'string', length: 60, nullable: true)]
+ private ?string $category = null;
+
#[ORM\Column(type: 'string', length: 30)]
- private string $unit = 'عدد';
+ private string $unit = self::DEFAULT_UNIT;
/** Unit price in Rial (integer), consistent with the rest of ClinicPro. */
#[ORM\Column(type: 'integer')]
@@ -80,6 +117,7 @@ class InventoryItem
public function getEntityId(): int { return $this->entityId; }
public function getName(): string { return $this->name; }
public function getConsumable(): ?string { return $this->consumable; }
+ public function getCategory(): ?string { return $this->category; }
public function getUnit(): string { return $this->unit; }
public function getPrice(): int { return $this->price; }
public function getStock(): int { return $this->stock; }
@@ -87,6 +125,7 @@ class InventoryItem
public function setName(string $v): self { $this->name = $v; return $this->touch(); }
public function setConsumable(?string $v): self { $this->consumable = $v; return $this->touch(); }
+ public function setCategory(?string $v): self { $this->category = $v; return $this->touch(); }
public function setUnit(string $v): self { $this->unit = $v; return $this->touch(); }
public function setPrice(int $v): self { $this->price = max(0, $v); return $this->touch(); }
public function setStock(int $v): self { $this->stock = max(0, $v); return $this->touch(); }
@@ -110,6 +149,7 @@ class InventoryItem
'uuid' => $this->uuid,
'name' => $this->name,
'consumable' => $this->consumable,
+ 'category' => $this->category,
'unit' => $this->unit,
'price' => $this->price,
'stock' => $this->stock,
diff --git a/src/Inventory/Repository/InventoryItemRepository.php b/src/Inventory/Repository/InventoryItemRepository.php
index d3119a41..e9109630 100644
--- a/src/Inventory/Repository/InventoryItemRepository.php
+++ b/src/Inventory/Repository/InventoryItemRepository.php
@@ -31,24 +31,24 @@ class InventoryItemRepository extends ServiceEntityRepository
}
/**
- * Distinct non-empty "consumable" values for the tenant — powers the
+ * Distinct non-empty category values actually in use by the tenant — powers the
* category filter dropdown on the inventory page.
*
* @return string[]
*/
- public function findConsumables(string $entityType, int $entityId): array
+ public function findCategories(string $entityType, int $entityId): array
{
$rows = $this->createQueryBuilder('i')
- ->select('DISTINCT i.consumable AS consumable')
- ->where('i.entityType = :type AND i.entityId = :id AND i.consumable IS NOT NULL AND i.consumable != :empty')
+ ->select('DISTINCT i.category AS category')
+ ->where('i.entityType = :type AND i.entityId = :id AND i.category IS NOT NULL AND i.category != :empty')
->setParameter('type', $entityType)
->setParameter('id', $entityId)
->setParameter('empty', '')
- ->orderBy('i.consumable', 'ASC')
+ ->orderBy('i.category', 'ASC')
->getQuery()
->getArrayResult();
- return array_map(static fn(array $r): string => $r['consumable'], $rows);
+ return array_map(static fn(array $r): string => $r['category'], $rows);
}
public function save(InventoryItem $item): void
diff --git a/tests/Inventory/InventoryApiTest.php b/tests/Inventory/InventoryApiTest.php
index 1834caf3..8dc10753 100644
--- a/tests/Inventory/InventoryApiTest.php
+++ b/tests/Inventory/InventoryApiTest.php
@@ -86,18 +86,62 @@ class InventoryApiTest extends ApiTestCase
);
}
- public function testCategoriesReturnsDistinctConsumables(): void
+ public function testCategoriesReturnsDistinctUsedCategories(): void
{
[$user] = $this->doctorUser();
- $this->authJson('POST', '/api/v1/inventory-item', $user, ['name' => 'a', 'consumable' => 'جراحی']);
- $this->authJson('POST', '/api/v1/inventory-item', $user, ['name' => 'b', 'consumable' => 'جراحی']);
- $this->authJson('POST', '/api/v1/inventory-item', $user, ['name' => 'c', 'consumable' => 'دندان']);
+ $this->authJson('POST', '/api/v1/inventory-item', $user, ['name' => 'a', 'category' => 'دارو']);
+ $this->authJson('POST', '/api/v1/inventory-item', $user, ['name' => 'b', 'category' => 'دارو']);
+ $this->authJson('POST', '/api/v1/inventory-item', $user, ['name' => 'c', 'category' => 'لوازم آزمایشگاهی']);
$cats = $this->authJson('GET', '/api/v1/inventory-categories', $user);
self::assertSame(200, $this->responseCode());
self::assertCount(2, $cats['data']);
- self::assertContains('جراحی', $cats['data']);
- self::assertContains('دندان', $cats['data']);
+ self::assertContains('دارو', $cats['data']);
+ self::assertContains('لوازم آزمایشگاهی', $cats['data']);
+ }
+
+ public function testMetaReturnsUnitAndCategoryLists(): void
+ {
+ [$user] = $this->doctorUser();
+ $meta = $this->authJson('GET', '/api/v1/inventory-meta', $user);
+ self::assertSame(200, $this->responseCode());
+ self::assertContains('عدد', $meta['data']['units']);
+ self::assertContains('سیسی', $meta['data']['units']);
+ self::assertContains('دارو', $meta['data']['categories']);
+ self::assertContains('سایر', $meta['data']['categories']);
+ }
+
+ public function testCreateStoresValidUnitAndCategory(): void
+ {
+ [$user] = $this->doctorUser();
+ $item = $this->authJson('POST', '/api/v1/inventory-item', $user, [
+ 'name' => 'سرنگ', 'unit' => 'بسته', 'category' => 'لوازم مصرفی و تزریقات',
+ ]);
+ self::assertSame(201, $this->responseCode());
+ self::assertSame('بسته', $item['data']['unit']);
+ self::assertSame('لوازم مصرفی و تزریقات', $item['data']['category']);
+ }
+
+ public function testCreateRejectsInvalidUnit(): void
+ {
+ [$user] = $this->doctorUser();
+ $this->authJson('POST', '/api/v1/inventory-item', $user, ['name' => 'x', 'unit' => 'واحدجعلی']);
+ self::assertSame(422, $this->responseCode());
+ }
+
+ public function testCreateRejectsInvalidCategory(): void
+ {
+ [$user] = $this->doctorUser();
+ $this->authJson('POST', '/api/v1/inventory-item', $user, ['name' => 'x', 'category' => 'دستهجعلی']);
+ self::assertSame(422, $this->responseCode());
+ }
+
+ public function testEmptyUnitFallsBackToDefault(): void
+ {
+ [$user] = $this->doctorUser();
+ $item = $this->authJson('POST', '/api/v1/inventory-item', $user, ['name' => 'x', 'unit' => '']);
+ self::assertSame(201, $this->responseCode());
+ self::assertSame('عدد', $item['data']['unit']);
}
public function testCannotTouchAnotherTenantsItem(): void