feat: port inventory (انبارداری) page from tauri to admin dashboard

Add a per-tenant (doctor/clinic) Inventory domain and admin page, ported
from clinic-pro-tauri /inventory (which was static/mock) into a real feature.

Backend (src/Inventory/):
- Entities InventoryItem, InventoryPackage, InventoryPackageItem, scoped via
  entity_type/entity_id like TenantTag. Item status is derived, package total
  and availability derived at read time.
- InventoryService (stats, package assembly, availability), thin
  InventoryController with CRUD for items and packages + categories endpoint.
- Migration + docs/api/inventory.md + functional tests (10 tests, 42 assertions).

Frontend (assets/admin/):
- InventoryPage with two tabs (کالاهای مصرفی / پکیج), stat cards, items table
  (desktop + mobile cards), packages accordion, add/edit item and package
  modals, search + category filter — pixel-matched to the tauri source.
- useInventory hook (TanStack Query), route + sidebar link for doctor/clinic.
- Vitest coverage (real data, empty state, modal, packages tab).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
hamed
2026-07-15 13:48:29 +03:30
co-authored by Claude Opus 4.8
parent f1e9129a2c
commit 519bc8d7f6
23 changed files with 2124 additions and 0 deletions
+135
View File
@@ -0,0 +1,135 @@
# Inventory API
> **Prefix:** `/api/v1/inventory-*`
Per-tenant (doctor/clinic) consumable-stock management: **items** and **packages**
(bundles of items). Every row is scoped to the caller's resolved entity
(`doctor` / `clinic`), exactly like Tenant Tags — a tenant only ever sees and
mutates its own inventory. Prices are stored and returned in **Rial** (integer).
**Permission (all routes):** `IS_AUTHENTICATED_FULLY` (roles `doctor`, `clinic`;
`secretary` resolves to its active clinic/doctor context).
Item `status` is **derived, never stored**:
`stock <= 0``out_of_stock`; `stock <= alertThreshold``low_stock`; else `in_stock`.
---
## Items
### GET `/api/v1/inventory-items`
List the tenant's items plus the four derived stat counters.
#### Response `200`
```json
{
"success": true,
"data": {
"items": [
{
"uuid": "…",
"name": "دستکش جراحی",
"consumable": "جراحی",
"unit": "عدد",
"price": 250000,
"stock": 150,
"alertThreshold": 20,
"status": "in_stock"
}
],
"stats": { "total": 1, "low": 0, "inStock": 1, "outOfStock": 0 }
}
}
```
### GET `/api/v1/inventory-categories`
Distinct non-empty `consumable` values for the tenant — powers the filter dropdown.
#### Response `200`
```json
{ "success": true, "data": ["جراحی", "دندانپزشکی"] }
```
### POST `/api/v1/inventory-item`
Create an item.
#### Body
| Field | Type | Required | Notes |
|-------|------|----------|-------|
| `name` | string | ✅ | نام کالا |
| `consumable` | string | ❌ | «مصرفی» / گروه فیلتر |
| `unit` | string | ❌ | Default `عدد` |
| `price` | integer | ❌ | Rial, Default `0` |
| `stock` | integer | ❌ | Default `0` |
| `alertThreshold` | integer | ❌ | Default `0` |
#### Response `201`
```json
{ "success": true, "data": { "uuid": "…", "name": "دستکش جراحی", "status": "in_stock", "...": "..." } }
```
#### Errors
| Status | Code | Cause |
|--------|------|-------|
| `422` | `ERR_VALIDATION_001` | `name` خالی است |
| `403` | `ERR_FORBIDDEN_001` | پروفایل tenant یافت نشد |
### PATCH `/api/v1/inventory-item/{uuid}`
Partial update. Any of the create fields may be sent. Returns `200` with the item,
`404 ERR_NOT_FOUND_001` if the item does not belong to the caller, `422` on empty `name`.
### DELETE `/api/v1/inventory-item/{uuid}`
Delete an item. `200` with `{ "message": "کالا حذف شد" }`, or `404` if not owned.
Deleting an item also removes it from any package lines (FK `ON DELETE CASCADE`).
---
## Packages
A package's `total` (Rial) and `available` (boolean) are **derived at read time**
from its component items — `available` is `true` only if every component item has
`stock >= amount`.
### GET `/api/v1/inventory-packages`
#### Response `200`
```json
{
"success": true,
"data": [
{
"uuid": "…",
"title": "پکیج شماره یک",
"items": [
{ "itemUuid": "…", "name": "ژل", "unit": "سی‌سی", "price": 1200000, "amount": 2 }
],
"total": 2400000,
"available": true
}
]
}
```
### POST `/api/v1/inventory-package`
#### Body
| Field | Type | Required | Notes |
|-------|------|----------|-------|
| `title` | string | ✅ | نام پکیج |
| `items` | array | ❌ | `[{ "itemUuid": "…", "amount": 2 }]` — references not owned by the caller are silently skipped |
#### Response `201` — the serialized package (same shape as list). Errors: `422 ERR_VALIDATION_001` (empty `title`), `403 ERR_FORBIDDEN_001`.
### PATCH `/api/v1/inventory-package/{uuid}`
Partial update. `title` renames; sending `items` **replaces** all component lines.
`200` with the package, `404 ERR_NOT_FOUND_001` if not owned, `422` on empty `title`.
### DELETE `/api/v1/inventory-package/{uuid}`
`200` with `{ "message": "پکیج حذف شد" }`, or `404` if not owned.