From 519bc8d7f6afdf4ab9cb73e2fed7eba8de4c9f15 Mon Sep 17 00:00:00 2001 From: hamed <15238-genius.ha@users.noreply.drupalcode.org> Date: Wed, 15 Jul 2026 13:48:29 +0330 Subject: [PATCH] =?UTF-8?q?feat:=20port=20inventory=20(=D8=A7=D9=86=D8=A8?= =?UTF-8?q?=D8=A7=D8=B1=D8=AF=D8=A7=D8=B1=DB=8C)=20page=20from=20tauri=20t?= =?UTF-8?q?o=20admin=20dashboard?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- assets/admin/App.tsx | 2 + .../components/inventory/AddItemModal.tsx | 103 +++++++ .../components/inventory/AddPackageModal.tsx | 136 +++++++++ .../inventory/InventoryActionsMenu.tsx | 75 +++++ .../inventory/InventoryItemsTable.tsx | 89 ++++++ .../inventory/InventoryStatCards.tsx | 26 ++ .../inventory/InventoryStatusBadge.tsx | 14 + .../components/inventory/PackagesView.tsx | 112 +++++++ assets/admin/components/layout/Sidebar.tsx | 11 + assets/admin/hooks/useInventory.ts | 133 +++++++++ assets/admin/pages/InventoryPage.test.tsx | 77 +++++ assets/admin/pages/InventoryPage.tsx | 165 +++++++++++ assets/admin/styles.css | 53 ++++ docs/api/inventory.md | 135 +++++++++ migrations/Version20260715100324.php | 39 +++ .../Controller/InventoryController.php | 274 ++++++++++++++++++ src/Inventory/Entity/InventoryItem.php | 126 ++++++++ src/Inventory/Entity/InventoryPackage.php | 87 ++++++ src/Inventory/Entity/InventoryPackageItem.php | 44 +++ .../Repository/InventoryItemRepository.php | 65 +++++ .../Repository/InventoryPackageRepository.php | 46 +++ src/Inventory/Service/InventoryService.php | 116 ++++++++ tests/Inventory/InventoryApiTest.php | 196 +++++++++++++ 23 files changed, 2124 insertions(+) create mode 100644 assets/admin/components/inventory/AddItemModal.tsx create mode 100644 assets/admin/components/inventory/AddPackageModal.tsx create mode 100644 assets/admin/components/inventory/InventoryActionsMenu.tsx create mode 100644 assets/admin/components/inventory/InventoryItemsTable.tsx create mode 100644 assets/admin/components/inventory/InventoryStatCards.tsx create mode 100644 assets/admin/components/inventory/InventoryStatusBadge.tsx create mode 100644 assets/admin/components/inventory/PackagesView.tsx create mode 100644 assets/admin/hooks/useInventory.ts create mode 100644 assets/admin/pages/InventoryPage.test.tsx create mode 100644 assets/admin/pages/InventoryPage.tsx create mode 100644 docs/api/inventory.md create mode 100644 migrations/Version20260715100324.php create mode 100644 src/Inventory/Controller/InventoryController.php create mode 100644 src/Inventory/Entity/InventoryItem.php create mode 100644 src/Inventory/Entity/InventoryPackage.php create mode 100644 src/Inventory/Entity/InventoryPackageItem.php create mode 100644 src/Inventory/Repository/InventoryItemRepository.php create mode 100644 src/Inventory/Repository/InventoryPackageRepository.php create mode 100644 src/Inventory/Service/InventoryService.php create mode 100644 tests/Inventory/InventoryApiTest.php diff --git a/assets/admin/App.tsx b/assets/admin/App.tsx index 87b3ad28..4de6d69b 100644 --- a/assets/admin/App.tsx +++ b/assets/admin/App.tsx @@ -58,6 +58,7 @@ import AccountSettingsPage from './pages/AccountSettingsPage'; import TagsSettingsPage from './pages/TagsSettingsPage'; import AppointmentSettingsPage from './pages/AppointmentSettingsPage'; import PatientsListPage from './pages/PatientsListPage'; +import InventoryPage from './pages/InventoryPage'; import PatientRecordFormPage from './pages/PatientRecordFormPage'; import PatientDetailPage from './pages/PatientDetailPage'; import PaymentSuccessPage from './pages/PaymentSuccessPage'; @@ -221,6 +222,7 @@ export default function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/assets/admin/components/inventory/AddItemModal.tsx b/assets/admin/components/inventory/AddItemModal.tsx new file mode 100644 index 00000000..cb0a591e --- /dev/null +++ b/assets/admin/components/inventory/AddItemModal.tsx @@ -0,0 +1,103 @@ +import React, { useEffect, useState } from 'react'; +import Modal from '../ui/Modal'; +import { rialToToman, tomanToRial, toEnglishDigits } from '../../lib/utils'; +import type { InventoryItem, ItemPayload } from '../../hooks/useInventory'; + +interface Props { + open: boolean; + editing: InventoryItem | null; + saving: boolean; + onClose: () => void; + onSave: (payload: ItemPayload, uuid?: string) => void; +} + +interface FormState { + name: string; + consumable: string; + unit: string; + price: string; // Toman, as typed + stock: string; + alertThreshold: string; +} + +const BLANK: FormState = { name: '', consumable: '', unit: '', price: '', stock: '', alertThreshold: '' }; + +const digits = (v: string) => toEnglishDigits(v).replace(/\D/g, ''); + +/** «افزودن/ویرایش کالای جدید» — mirrors tauri ModalAddInventory field-for-field. */ +export default function AddItemModal({ open, editing, saving, onClose, onSave }: Props) { + const [form, setForm] = useState(BLANK); + const [error, setError] = useState(''); + + useEffect(() => { + if (!open) return; + setError(''); + setForm(editing + ? { + name: editing.name, + consumable: editing.consumable ?? '', + unit: editing.unit, + price: editing.price ? String(rialToToman(editing.price)) : '', + stock: String(editing.stock), + alertThreshold: String(editing.alertThreshold), + } + : BLANK); + }, [open, editing]); + + const set = (k: keyof FormState) => (e: React.ChangeEvent) => + setForm((f) => ({ ...f, [k]: e.target.value })); + const setNum = (k: keyof FormState) => (e: React.ChangeEvent) => + setForm((f) => ({ ...f, [k]: digits(e.target.value) })); + + const submit = () => { + if (form.name.trim() === '') { setError('نام کالا الزامی است'); return; } + const payload: ItemPayload = { + name: form.name.trim(), + consumable: form.consumable.trim() || null, + unit: form.unit.trim() || 'عدد', + price: form.price ? tomanToRial(Number(form.price)) : 0, + stock: form.stock ? Number(form.stock) : 0, + alertThreshold: form.alertThreshold ? Number(form.alertThreshold) : 0, + }; + onSave(payload, editing?.uuid); + }; + + const fields: { 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 }, + ]; + + return ( + +
+ {fields.map((f) => ( +
+ +
+ +
+
+ ))} + {error && {error}} + +
+
+ ); +} diff --git a/assets/admin/components/inventory/AddPackageModal.tsx b/assets/admin/components/inventory/AddPackageModal.tsx new file mode 100644 index 00000000..8038a8f4 --- /dev/null +++ b/assets/admin/components/inventory/AddPackageModal.tsx @@ -0,0 +1,136 @@ +import React, { useEffect, useMemo, useState } from 'react'; +import { TrashIcon } from '@heroicons/react/24/outline'; +import Modal from '../ui/Modal'; +import { formatRial } from '../../lib/utils'; +import type { InventoryItem, InventoryPackage, PackagePayload } from '../../hooks/useInventory'; + +interface Props { + open: boolean; + editing: InventoryPackage | null; + items: InventoryItem[]; // the tenant's available items to pick from + saving: boolean; + onClose: () => void; + onSave: (payload: PackagePayload, uuid?: string) => void; +} + +interface Line { itemUuid: string; name: string; unit: string; price: number; amount: number } + +/** «افزودن/ویرایش پکیج» — mirrors tauri ModalAddPackage (two-column builder). */ +export default function AddPackageModal({ open, editing, items, saving, onClose, onSave }: Props) { + const [title, setTitle] = useState(''); + const [pickUuid, setPickUuid] = useState(''); + const [amount, setAmount] = useState('1'); + const [lines, setLines] = useState([]); + const [error, setError] = useState(''); + + useEffect(() => { + if (!open) return; + setError(''); + setPickUuid(items[0]?.uuid ?? ''); + setAmount('1'); + if (editing) { + setTitle(editing.title); + setLines(editing.items.map((l) => ({ itemUuid: l.itemUuid, name: l.name, unit: l.unit, price: l.price, amount: l.amount }))); + } else { + setTitle(''); + setLines([]); + } + }, [open, editing, items]); + + const total = useMemo(() => lines.reduce((s, l) => s + l.price * l.amount, 0), [lines]); + + const addLine = () => { + const item = items.find((i) => i.uuid === pickUuid); + if (!item) return; + const qty = Math.max(1, Number(amount) || 1); + setLines((prev) => [...prev, { itemUuid: item.uuid, name: item.name, unit: item.unit, price: item.price, amount: qty }]); + setAmount('1'); + }; + const changeAmount = (idx: number, delta: number) => + setLines((prev) => prev.map((l, i) => (i === idx ? { ...l, amount: Math.max(1, l.amount + delta) } : l))); + const removeLine = (idx: number) => setLines((prev) => prev.filter((_, i) => i !== idx)); + + const submit = () => { + if (title.trim() === '') { setError('نام پکیج الزامی است'); return; } + onSave({ title: title.trim(), items: lines.map((l) => ({ itemUuid: l.itemUuid, amount: l.amount })) }, editing?.uuid); + }; + + const footer = ( +
+ + +
+ ); + + return ( + +
+
+ {/* Left — builder */} +
+
+ +
setTitle(e.target.value)} placeholder="نام پکیج" />
+
+
+ +
+ +
+
+
+ +
+ setAmount(e.target.value.replace(/[^0-9]/g, ''))} placeholder="1" /> +
+
+ +
+ + {/* Right — selected items */} +
+
+
کالاهای انتخاب شده:
+
+ {lines.length === 0 ? ( + هنوز کالایی به پکیج اضافه نشده است. + ) : ( + lines.map((l, idx) => ( +
+ {l.name} +
+ + {l.amount} + +
+ {formatRial(l.price)} + +
+ )) + )} +
+
+
+ قیمت کل پکیج: {formatRial(total)} +
+
+
+ {error && {error}} +
+
+ ); +} diff --git a/assets/admin/components/inventory/InventoryActionsMenu.tsx b/assets/admin/components/inventory/InventoryActionsMenu.tsx new file mode 100644 index 00000000..7eaaec10 --- /dev/null +++ b/assets/admin/components/inventory/InventoryActionsMenu.tsx @@ -0,0 +1,75 @@ +import React, { useState } from 'react'; +import { EllipsisHorizontalCircleIcon, PencilSquareIcon, TrashIcon } from '@heroicons/react/24/outline'; +import type { InventoryItem } from '../../hooks/useInventory'; + +interface Props { + item: InventoryItem; + onEdit: (item: InventoryItem) => void; + onDelete: (item: InventoryItem) => void; +} + +/** «عملیات» trigger + dropdown — mirrors the tauri InventoryActionsPopover. */ +export default function InventoryActionsMenu({ item, onEdit, onDelete }: Props) { + const [open, setOpen] = useState(false); + + return ( +
+ + + {open && ( + <> +
setOpen(false)} + style={{ position: 'fixed', inset: 0, zIndex: 30 }} + /> +
+ + +
+ + )} +
+ ); +} diff --git a/assets/admin/components/inventory/InventoryItemsTable.tsx b/assets/admin/components/inventory/InventoryItemsTable.tsx new file mode 100644 index 00000000..2fd116d6 --- /dev/null +++ b/assets/admin/components/inventory/InventoryItemsTable.tsx @@ -0,0 +1,89 @@ +import React from 'react'; +import { PencilSquareIcon, TrashIcon } from '@heroicons/react/24/outline'; +import { formatRial, formatNumber } from '../../lib/utils'; +import type { InventoryItem } from '../../hooks/useInventory'; +import InventoryStatusBadge from './InventoryStatusBadge'; +import InventoryActionsMenu from './InventoryActionsMenu'; + +interface Props { + items: InventoryItem[]; + onEdit: (item: InventoryItem) => void; + onDelete: (item: InventoryItem) => void; +} + +const HEAD = ['نام کالا', 'موجودی', 'واحد', 'قیمت', 'وضعیت', 'عملیات']; + +/** Consumable-items list: desktop table + mobile card grid (tauri InventoryList). */ +export default function InventoryItemsTable({ items, onEdit, onDelete }: Props) { + return ( +
+ {/* Desktop table */} +
+ + + {HEAD.map((h) => )} + + + {items.map((item) => ( + + + + + + + + + ))} + +
{h}
{item.name}{formatNumber(item.stock)}{item.unit}{formatRial(item.price)}
+
+ + {/* Mobile cards */} +
    + {items.map((item) => ( +
  • +
    + {item.name} + +
    + {[ + ['موجودی:', formatNumber(item.stock)], + ['واحد:', item.unit], + ['قیمت:', formatRial(item.price)], + ].map(([label, value], i, arr) => ( +
    +
    + {label} + {value} +
    + {i < arr.length - 1 && ( +
    + )} +
    + ))} +
    + + +
    +
  • + ))} +
+
+ ); +} diff --git a/assets/admin/components/inventory/InventoryStatCards.tsx b/assets/admin/components/inventory/InventoryStatCards.tsx new file mode 100644 index 00000000..ce52da0f --- /dev/null +++ b/assets/admin/components/inventory/InventoryStatCards.tsx @@ -0,0 +1,26 @@ +import React from 'react'; +import { ArchiveBoxIcon } from '@heroicons/react/24/outline'; +import StatCard from '../ui/StatCard'; +import { formatNumber } from '../../lib/utils'; +import type { InventoryStats } from '../../hooks/useInventory'; + +const ICON = ; + +/** Four headline counters — order/colors mirror the tauri InventoryCards. */ +export default function InventoryStatCards({ stats }: { stats: InventoryStats }) { + return ( +
+ + + + +
+ ); +} diff --git a/assets/admin/components/inventory/InventoryStatusBadge.tsx b/assets/admin/components/inventory/InventoryStatusBadge.tsx new file mode 100644 index 00000000..0f564029 --- /dev/null +++ b/assets/admin/components/inventory/InventoryStatusBadge.tsx @@ -0,0 +1,14 @@ +import React from 'react'; +import type { InventoryStatus } from '../../hooks/useInventory'; + +const CONFIG: Record = { + in_stock: { label: 'موجود', cls: 'in-stock' }, + low_stock: { label: 'کم موجود', cls: 'low-stock' }, + out_of_stock: { label: 'اتمام یافته', cls: 'out-of-stock' }, +}; + +/** Colored availability pill — colors mirror the tauri InventoryStatus component. */ +export default function InventoryStatusBadge({ status }: { status: InventoryStatus }) { + const c = CONFIG[status] ?? CONFIG.out_of_stock; + return {c.label}; +} diff --git a/assets/admin/components/inventory/PackagesView.tsx b/assets/admin/components/inventory/PackagesView.tsx new file mode 100644 index 00000000..c671c50f --- /dev/null +++ b/assets/admin/components/inventory/PackagesView.tsx @@ -0,0 +1,112 @@ +import React, { useState } from 'react'; +import { ChevronDownIcon, PencilSquareIcon, TrashIcon } from '@heroicons/react/24/outline'; +import { formatRial } from '../../lib/utils'; +import type { InventoryPackage } from '../../hooks/useInventory'; + +interface Props { + packages: InventoryPackage[]; + onEdit: (pkg: InventoryPackage) => void; + onDelete: (pkg: InventoryPackage) => void; +} + +/** Package cards with an expandable component list — mirrors tauri AddStockView. */ +export default function PackagesView({ packages, onEdit, onDelete }: Props) { + return ( +
+ {packages.map((pkg) => ( + + ))} +
+ ); +} + +function PackageCard({ pkg, onEdit, onDelete }: { pkg: InventoryPackage } & Pick) { + const [expanded, setExpanded] = useState(false); + const divider =
; + + return ( +
+ {/* Header */} +
+
+ + {pkg.title} +
+ + {pkg.available ? 'موجودی کافی' : 'موجودی ناکافی'} + +
+ + {divider} + + {/* Accordion */} + + + {expanded && ( +
+ {pkg.items.length === 0 ? ( + این پکیج کالایی ندارد. + ) : ( + pkg.items.map((it) => ( +
+ {it.name} + + {it.amount} {it.unit} + +
+ )) + )} +
+ )} + + {divider} + + {/* Footer */} +
+ + قیمت پکیج: {formatRial(pkg.total)} + +
+ + +
+
+
+ ); +} diff --git a/assets/admin/components/layout/Sidebar.tsx b/assets/admin/components/layout/Sidebar.tsx index f33691e3..191cc260 100644 --- a/assets/admin/components/layout/Sidebar.tsx +++ b/assets/admin/components/layout/Sidebar.tsx @@ -1,4 +1,5 @@ import { + ArchiveBoxIcon, ArrowLeftOnRectangleIcon, ArrowsRightLeftIcon, BanknotesIcon, @@ -222,6 +223,11 @@ function buildSections( label: "مطالبات بیمه", feature: "insurance", }, + { + to: "/admin/inventory", + icon: ArchiveBoxIcon, + label: "انبارداری", + }, ], }, { @@ -286,6 +292,11 @@ function buildSections( label: "مطالبات بیمه", feature: "insurance", }, + { + to: "/admin/inventory", + icon: ArchiveBoxIcon, + label: "انبارداری", + }, ], }, { diff --git a/assets/admin/hooks/useInventory.ts b/assets/admin/hooks/useInventory.ts new file mode 100644 index 00000000..ea5cffd5 --- /dev/null +++ b/assets/admin/hooks/useInventory.ts @@ -0,0 +1,133 @@ +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { toast } from 'sonner'; +import { api } from '../lib/api'; +import type { ApiResponse } from '../lib/api'; + +// ── Types ──────────────────────────────────────────────────────────────────── + +export type InventoryStatus = 'in_stock' | 'low_stock' | 'out_of_stock'; + +export interface InventoryItem { + uuid: string; + name: string; + consumable: string | null; + unit: string; + price: number; // Rial + stock: number; + alertThreshold: number; + status: InventoryStatus; +} + +export interface InventoryStats { + total: number; + low: number; + inStock: number; + outOfStock: number; +} + +export interface PackageLine { + itemUuid: string; + name: string; + unit: string; + price: number; // Rial + amount: number; +} + +export interface InventoryPackage { + uuid: string; + title: string; + items: PackageLine[]; + total: number; // Rial + available: boolean; +} + +export interface ItemPayload { + name: string; + consumable?: string | null; + unit?: string; + price?: number; + stock?: number; + alertThreshold?: number; +} + +export interface PackagePayload { + title: string; + items: { itemUuid: string; amount: number }[]; +} + +const EMPTY_STATS: InventoryStats = { total: 0, low: 0, inStock: 0, outOfStock: 0 }; +const EMPTY_ITEMS: InventoryItem[] = []; +const EMPTY_PACKAGES: InventoryPackage[] = []; +const EMPTY_CATS: string[] = []; + +// ── Hook ───────────────────────────────────────────────────────────────────── + +/** Per-tenant inventory: items (+stats), packages and category filter options. */ +export function useInventory() { + const qc = useQueryClient(); + + const itemsQuery = useQuery>({ + queryKey: ['inventory-items'], + queryFn: () => api.get('/api/v1/inventory-items'), + }); + + const packagesQuery = useQuery>({ + queryKey: ['inventory-packages'], + queryFn: () => api.get('/api/v1/inventory-packages'), + }); + + const categoriesQuery = useQuery>({ + queryKey: ['inventory-categories'], + queryFn: () => api.get('/api/v1/inventory-categories'), + }); + + const invalidateItems = () => { + qc.invalidateQueries({ queryKey: ['inventory-items'] }); + qc.invalidateQueries({ queryKey: ['inventory-categories'] }); + qc.invalidateQueries({ queryKey: ['inventory-packages'] }); + }; + const invalidatePackages = () => qc.invalidateQueries({ queryKey: ['inventory-packages'] }); + + const createItem = useMutation({ + mutationFn: (d: ItemPayload) => api.post('/api/v1/inventory-item', d), + onSuccess: () => { invalidateItems(); toast.success('کالا اضافه شد'); }, + onError: (e: any) => toast.error(e.message), + }); + const updateItem = useMutation({ + mutationFn: ({ uuid, d }: { uuid: string; d: ItemPayload }) => api.patch(`/api/v1/inventory-item/${uuid}`, d), + onSuccess: () => { invalidateItems(); toast.success('کالا ویرایش شد'); }, + onError: (e: any) => toast.error(e.message), + }); + const deleteItem = useMutation({ + mutationFn: (uuid: string) => api.delete(`/api/v1/inventory-item/${uuid}`), + onSuccess: () => { invalidateItems(); toast.success('کالا حذف شد'); }, + onError: (e: any) => toast.error(e.message), + }); + + const createPackage = useMutation({ + mutationFn: (d: PackagePayload) => api.post('/api/v1/inventory-package', d), + onSuccess: () => { invalidatePackages(); toast.success('پکیج اضافه شد'); }, + onError: (e: any) => toast.error(e.message), + }); + const updatePackage = useMutation({ + mutationFn: ({ uuid, d }: { uuid: string; d: PackagePayload }) => api.patch(`/api/v1/inventory-package/${uuid}`, d), + onSuccess: () => { invalidatePackages(); toast.success('پکیج ویرایش شد'); }, + onError: (e: any) => toast.error(e.message), + }); + const deletePackage = useMutation({ + mutationFn: (uuid: string) => api.delete(`/api/v1/inventory-package/${uuid}`), + onSuccess: () => { invalidatePackages(); toast.success('پکیج حذف شد'); }, + onError: (e: any) => toast.error(e.message), + }); + + return { + items: itemsQuery.data?.data?.items ?? EMPTY_ITEMS, + stats: itemsQuery.data?.data?.stats ?? EMPTY_STATS, + packages: packagesQuery.data?.data ?? EMPTY_PACKAGES, + categories: categoriesQuery.data?.data ?? EMPTY_CATS, + itemsLoading: itemsQuery.isLoading, + packagesLoading: packagesQuery.isLoading, + createItem, updateItem, deleteItem, + createPackage, updatePackage, deletePackage, + }; +} diff --git a/assets/admin/pages/InventoryPage.test.tsx b/assets/admin/pages/InventoryPage.test.tsx new file mode 100644 index 00000000..0060834f --- /dev/null +++ b/assets/admin/pages/InventoryPage.test.tsx @@ -0,0 +1,77 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { screen, fireEvent, waitFor } from '@testing-library/react'; +import { renderWithProviders } from '../test/utils'; + +vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } })); +vi.mock('../lib/api', () => ({ + api: { get: vi.fn(), post: vi.fn(), patch: vi.fn(), put: vi.fn(), delete: vi.fn() }, + ApiError: class extends Error {}, +})); + +import { api } from '../lib/api'; +import InventoryPage from './InventoryPage'; + +const get = api.get as ReturnType; + +const ITEM = { + uuid: 'i-1', name: 'دستکش جراحی', consumable: 'جراحی', unit: 'عدد', + price: 250000, stock: 150, alertThreshold: 20, status: 'in_stock', +}; +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[] } = {}) { + get.mockImplementation((url: string = '') => { + if (url.includes('/inventory-packages')) return Promise.resolve({ success: true, data: opts.packages ?? [] }); + 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 } } }); + }); +} + +beforeEach(() => get.mockReset()); + +describe('InventoryPage', () => { + it('renders the header, tabs and item row with real data', async () => { + mockApi({ items: [ITEM], stats: STATS, categories: ['جراحی'] }); + renderWithProviders(, { route: '/admin/inventory' }); + + expect(screen.getByText('انبارداری')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'کالاهای مصرفی' })).toBeInTheDocument(); + // desktop table + mobile card both render in jsdom (no CSS media) → allow both + expect((await screen.findAllByText('دستکش جراحی')).length).toBeGreaterThan(0); + // stat card counter + status badge + expect(screen.getByText('کالاهای موجود')).toBeInTheDocument(); + expect(screen.getAllByText('موجود').length).toBeGreaterThan(0); + }); + + it('shows the empty state when there are no items', async () => { + mockApi({ items: [], stats: { total: 0, low: 0, inStock: 0, outOfStock: 0 } }); + renderWithProviders(, { route: '/admin/inventory' }); + expect(await screen.findByText('هنوز کالایی ثبت نشده است.')).toBeInTheDocument(); + }); + + it('opens the add-item modal from the header button', async () => { + mockApi({ items: [], stats: { total: 0, low: 0, inStock: 0, outOfStock: 0 } }); + renderWithProviders(, { route: '/admin/inventory' }); + + fireEvent.click(await screen.findByRole('button', { name: /افزودن کالا/ })); + expect(await screen.findByText('افزودن کالای جدید')).toBeInTheDocument(); + // the source's distinctive "هشدار اتمام" field is present + expect(screen.getByText('هشدار اتمام')).toBeInTheDocument(); + }); + + it('switches to the packages tab and lists a package with its price', async () => { + mockApi({ packages: [PKG] }); + renderWithProviders(, { route: '/admin/inventory' }); + + fireEvent.click(screen.getByRole('button', { name: 'پکیج' })); + expect(await screen.findByText('پکیج شماره یک')).toBeInTheDocument(); + expect(screen.getByText('موجودی کافی')).toBeInTheDocument(); + // 2,400,000 Rial → 240,000 Toman + await waitFor(() => expect(screen.getByText(/۲۴۰٬۰۰۰ تومان/)).toBeInTheDocument()); + }); +}); diff --git a/assets/admin/pages/InventoryPage.tsx b/assets/admin/pages/InventoryPage.tsx new file mode 100644 index 00000000..84034d40 --- /dev/null +++ b/assets/admin/pages/InventoryPage.tsx @@ -0,0 +1,165 @@ +import React, { useMemo, useState } from 'react'; +import { PlusIcon, MagnifyingGlassIcon, ArchiveBoxIcon } from '@heroicons/react/24/outline'; +import ConfirmDialog from '../components/ui/ConfirmDialog'; +import { useInventory } from '../hooks/useInventory'; +import type { InventoryItem, InventoryPackage, ItemPayload, PackagePayload } from '../hooks/useInventory'; +import InventoryStatCards from '../components/inventory/InventoryStatCards'; +import InventoryItemsTable from '../components/inventory/InventoryItemsTable'; +import PackagesView from '../components/inventory/PackagesView'; +import AddItemModal from '../components/inventory/AddItemModal'; +import AddPackageModal from '../components/inventory/AddPackageModal'; + +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, + createItem, updateItem, deleteItem, createPackage, updatePackage, deletePackage, + } = useInventory(); + + const [tab, setTab] = useState('stock'); + const [search, setSearch] = useState(''); + const [category, setCategory] = useState(''); + + const [itemModal, setItemModal] = useState<{ open: boolean; editing: InventoryItem | null }>({ open: false, editing: null }); + const [pkgModal, setPkgModal] = useState<{ open: boolean; editing: InventoryPackage | null }>({ open: false, editing: null }); + const [itemToDelete, setItemToDelete] = useState(null); + const [pkgToDelete, setPkgToDelete] = useState(null); + + const filteredItems = useMemo(() => { + const q = search.trim(); + return items.filter((it) => + (q === '' || it.name.includes(q)) && + (category === '' || it.consumable === category) + ); + }, [items, search, category]); + + const saveItem = (payload: ItemPayload, uuid?: string) => { + const opts = { onSuccess: () => setItemModal({ open: false, editing: null }) }; + if (uuid) updateItem.mutate({ uuid, d: payload }, opts); + else createItem.mutate(payload, opts); + }; + const savePackage = (payload: PackagePayload, uuid?: string) => { + const opts = { onSuccess: () => setPkgModal({ open: false, editing: null }) }; + if (uuid) updatePackage.mutate({ uuid, d: payload }, opts); + else createPackage.mutate(payload, opts); + }; + + return ( +
+ {/* Header */} +
+

انبارداری

+
+ + +
+ + {tab === 'stock' ? ( +
+
+
+ + setSearch(e.target.value)} + placeholder="جستجو در کالاهای مصرفی..." + style={{ border: 'none', background: 'transparent', flex: 1, padding: 0 }} + /> +
+
+ +
+
+ +
+ ) : ( +
+ +
+ )} +
+ + {/* Body */} + {tab === 'stock' ? ( + <> + + {itemsLoading ? ( +
در حال بارگذاری...
+ ) : filteredItems.length === 0 ? ( + + ) : ( + setItemModal({ open: true, editing: item })} + onDelete={setItemToDelete} + /> + )} + + ) : packagesLoading ? ( +
در حال بارگذاری...
+ ) : packages.length === 0 ? ( + + ) : ( + setPkgModal({ open: true, editing: pkg })} + onDelete={setPkgToDelete} + /> + )} + + {/* Modals */} + setItemModal({ open: false, editing: null })} + onSave={saveItem} + /> + setPkgModal({ open: false, editing: null })} + onSave={savePackage} + /> + + itemToDelete && deleteItem.mutate(itemToDelete.uuid, { onSuccess: () => setItemToDelete(null) })} + onCancel={() => setItemToDelete(null)} + /> + pkgToDelete && deletePackage.mutate(pkgToDelete.uuid, { onSuccess: () => setPkgToDelete(null) })} + onCancel={() => setPkgToDelete(null)} + /> +
+ ); +} + +function EmptyState({ label }: { label: string }) { + return ( +
+ +
{label}
+
+ ); +} diff --git a/assets/admin/styles.css b/assets/admin/styles.css index ef1d9cc5..4bb64ca0 100644 --- a/assets/admin/styles.css +++ b/assets/admin/styles.css @@ -852,3 +852,56 @@ html, body { max-width: 100%; overflow-x: hidden; } @media (max-width: 560px) { .settings-grid { grid-template-columns: 1fr; } } + +/* ── Inventory (انبارداری) ───────────────────────────────────────────────── + Status pill colors mirror clinic-pro-tauri src/components/inventory/list/ + InventoryStatus.jsx exactly, in both light and dark. */ +.inv-badge { + display: inline-block; width: 100px; text-align: center; + padding: 4px 8px; border-radius: 4px; font-size: 14px; font-weight: 500; +} +.inv-badge.in-stock { background: #E8FADD; color: #3c9a4f; } +.inv-badge.low-stock { background: #FFF3DD; color: #FDBA35; } +.inv-badge.out-of-stock { background: #FFE3E2; color: #FF5450; } +.dark .inv-badge.in-stock { background: #324A32; color: #75E22E; } +.dark .inv-badge.low-stock { background: #4E4234; color: #FDBA35; } +.dark .inv-badge.out-of-stock { background: rgba(255,84,80,0.20); color: #FF5450; } + +/* Tab row — mirrors inventory/TabsRow + TabItem. */ +.inv-tabs { + display: flex; align-items: flex-end; gap: 24px; + border-bottom: 1px solid #EDEDED; margin: 4px 0 18px; width: 100%; + overflow-x: auto; scrollbar-width: none; +} +.inv-tabs::-webkit-scrollbar { display: none; } +.dark .inv-tabs { border-color: #35343D; } +.inv-tab { + display: flex; align-items: center; gap: 6px; padding: 0 8px 6px; + font-size: 16px; cursor: pointer; white-space: nowrap; + border-bottom: 2px solid transparent; color: #6B7280; background: none; +} +.dark .inv-tab { color: #A1A1A1; } +.inv-tab.active { border-bottom: 3px solid #5559ce; color: #5559ce; } +.dark .inv-tab.active { color: #5559ce; } + +/* Inventory responsive table/card switch (md breakpoint = 768px, like tauri). */ +.inv-desktop { display: block; } +.inv-mobile { display: none; } +@media (max-width: 767px) { + .inv-desktop { display: none; } + .inv-mobile { display: grid; } +} +.inv-table { width: 100%; border-collapse: collapse; } +.inv-table thead tr { background: #e1e1e1; } +.inv-table th { + color: #616161; font-size: 14px; font-weight: 400; + padding: 10px 18px; text-align: start; white-space: nowrap; +} +.inv-table td { + padding: 12px 18px; text-align: start; white-space: nowrap; + font-size: 16px; font-weight: 500; color: var(--text-2); + border-bottom: 1px solid #DBDBDB; +} +.dark .inv-table td { border-color: var(--border); } +.inv-table tbody tr:hover { background: #f4f5fd; } +.dark .inv-table tbody tr:hover { background: var(--surface-2); } diff --git a/docs/api/inventory.md b/docs/api/inventory.md new file mode 100644 index 00000000..109ddffc --- /dev/null +++ b/docs/api/inventory.md @@ -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. diff --git a/migrations/Version20260715100324.php b/migrations/Version20260715100324.php new file mode 100644 index 00000000..dee9dfdc --- /dev/null +++ b/migrations/Version20260715100324.php @@ -0,0 +1,39 @@ +addSql('CREATE TABLE inventory_items (id INT AUTO_INCREMENT NOT NULL, uuid VARCHAR(36) NOT NULL, entity_type VARCHAR(20) NOT NULL, entity_id INT NOT NULL, name VARCHAR(120) NOT NULL, consumable VARCHAR(120) DEFAULT NULL, unit VARCHAR(30) NOT NULL, price INT NOT NULL, stock INT NOT NULL, alert_threshold INT NOT NULL, created_at INT NOT NULL, updated_at INT NOT NULL, UNIQUE INDEX UNIQ_3D82424DD17F50A6 (uuid), INDEX idx_inventory_items_owner (entity_type, entity_id), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4'); + $this->addSql('CREATE TABLE inventory_package_items (id INT AUTO_INCREMENT NOT NULL, amount INT NOT NULL, package_id INT NOT NULL, item_id INT NOT NULL, INDEX IDX_17172D5EF44CABFF (package_id), INDEX IDX_17172D5E126F525E (item_id), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4'); + $this->addSql('CREATE TABLE inventory_packages (id INT AUTO_INCREMENT NOT NULL, uuid VARCHAR(36) NOT NULL, entity_type VARCHAR(20) NOT NULL, entity_id INT NOT NULL, title VARCHAR(120) NOT NULL, created_at INT NOT NULL, updated_at INT NOT NULL, UNIQUE INDEX UNIQ_2FFF7A36D17F50A6 (uuid), INDEX idx_inventory_packages_owner (entity_type, entity_id), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4'); + $this->addSql('ALTER TABLE inventory_package_items ADD CONSTRAINT FK_17172D5EF44CABFF FOREIGN KEY (package_id) REFERENCES inventory_packages (id) ON DELETE CASCADE'); + $this->addSql('ALTER TABLE inventory_package_items ADD CONSTRAINT FK_17172D5E126F525E FOREIGN KEY (item_id) REFERENCES inventory_items (id) ON DELETE CASCADE'); + } + + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql('ALTER TABLE inventory_package_items DROP FOREIGN KEY FK_17172D5EF44CABFF'); + $this->addSql('ALTER TABLE inventory_package_items DROP FOREIGN KEY FK_17172D5E126F525E'); + $this->addSql('DROP TABLE inventory_items'); + $this->addSql('DROP TABLE inventory_package_items'); + $this->addSql('DROP TABLE inventory_packages'); + } +} diff --git a/src/Inventory/Controller/InventoryController.php b/src/Inventory/Controller/InventoryController.php new file mode 100644 index 00000000..6488a5c4 --- /dev/null +++ b/src/Inventory/Controller/InventoryController.php @@ -0,0 +1,274 @@ +resolveEntity($user); + if ($id === null) { + return $this->error(ErrorCodes::ERR_FORBIDDEN_001, 'پروفایل یافت نشد', 403); + } + + $items = $this->itemRepo->findByEntity($type, $id); + + return $this->success([ + 'items' => array_map(fn(InventoryItem $i) => $i->toArray(), $items), + 'stats' => $this->service->stats($items), + ]); + } + + #[Route('/api/v1/inventory-categories', methods: ['GET'])] + public function listCategories(#[CurrentUser] User $user): JsonResponse + { + [$type, $id] = $this->resolveEntity($user); + if ($id === null) { + return $this->error(ErrorCodes::ERR_FORBIDDEN_001, 'پروفایل یافت نشد', 403); + } + + return $this->success($this->itemRepo->findConsumables($type, $id)); + } + + #[Route('/api/v1/inventory-item', methods: ['POST'])] + public function createItem(Request $request, #[CurrentUser] User $user): JsonResponse + { + [$type, $id] = $this->resolveEntity($user); + if ($id === null) { + return $this->error(ErrorCodes::ERR_FORBIDDEN_001, 'پروفایل یافت نشد', 403); + } + + $data = json_decode($request->getContent(), true) ?? []; + $name = trim($data['name'] ?? ''); + if ($name === '') { + return $this->error(ErrorCodes::ERR_VALIDATION_001, 'نام کالا الزامی است', 422, 'name'); + } + + $item = new InventoryItem($type, $id, $name); + $this->applyItemFields($item, $data); + $this->itemRepo->save($item); + + return $this->success($item->toArray(), 201); + } + + #[Route('/api/v1/inventory-item/{uuid}', methods: ['PATCH'])] + public function updateItem(string $uuid, Request $request, #[CurrentUser] User $user): JsonResponse + { + $item = $this->ownedItem($uuid, $user); + if ($item === null) { + return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'کالا یافت نشد', 404); + } + + $data = json_decode($request->getContent(), true) ?? []; + if (array_key_exists('name', $data)) { + $name = trim($data['name']); + if ($name === '') { + return $this->error(ErrorCodes::ERR_VALIDATION_001, 'نام کالا الزامی است', 422, 'name'); + } + $item->setName($name); + } + $this->applyItemFields($item, $data); + $this->itemRepo->save($item); + + return $this->success($item->toArray()); + } + + #[Route('/api/v1/inventory-item/{uuid}', methods: ['DELETE'])] + public function deleteItem(string $uuid, #[CurrentUser] User $user): JsonResponse + { + $item = $this->ownedItem($uuid, $user); + if ($item === null) { + return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'کالا یافت نشد', 404); + } + + $this->itemRepo->remove($item); + + return $this->success(['message' => 'کالا حذف شد']); + } + + // ── Packages ───────────────────────────────────────────────────────────── + + #[Route('/api/v1/inventory-packages', methods: ['GET'])] + public function listPackages(#[CurrentUser] User $user): JsonResponse + { + [$type, $id] = $this->resolveEntity($user); + if ($id === null) { + return $this->error(ErrorCodes::ERR_FORBIDDEN_001, 'پروفایل یافت نشد', 403); + } + + return $this->success(array_map( + fn(InventoryPackage $p) => $this->service->packageToArray($p), + $this->packageRepo->findByEntity($type, $id) + )); + } + + #[Route('/api/v1/inventory-package', methods: ['POST'])] + public function createPackage(Request $request, #[CurrentUser] User $user): JsonResponse + { + [$type, $id] = $this->resolveEntity($user); + if ($id === null) { + return $this->error(ErrorCodes::ERR_FORBIDDEN_001, 'پروفایل یافت نشد', 403); + } + + $data = json_decode($request->getContent(), true) ?? []; + $title = trim($data['title'] ?? ''); + if ($title === '') { + return $this->error(ErrorCodes::ERR_VALIDATION_001, 'نام پکیج الزامی است', 422, 'title'); + } + + $package = new InventoryPackage($type, $id, $title); + $this->service->syncPackageItems($package, $data['items'] ?? [], $type, $id); + $this->packageRepo->save($package); + + return $this->success($this->service->packageToArray($package), 201); + } + + #[Route('/api/v1/inventory-package/{uuid}', methods: ['PATCH'])] + public function updatePackage(string $uuid, Request $request, #[CurrentUser] User $user): JsonResponse + { + $package = $this->ownedPackage($uuid, $user); + if ($package === null) { + return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'پکیج یافت نشد', 404); + } + + [$type, $id] = $this->resolveEntity($user); + $data = json_decode($request->getContent(), true) ?? []; + + if (array_key_exists('title', $data)) { + $title = trim($data['title']); + if ($title === '') { + return $this->error(ErrorCodes::ERR_VALIDATION_001, 'نام پکیج الزامی است', 422, 'title'); + } + $package->setTitle($title); + } + if (array_key_exists('items', $data)) { + $this->service->syncPackageItems($package, $data['items'], $type, (int) $id); + } + $this->packageRepo->save($package); + + return $this->success($this->service->packageToArray($package)); + } + + #[Route('/api/v1/inventory-package/{uuid}', methods: ['DELETE'])] + public function deletePackage(string $uuid, #[CurrentUser] User $user): JsonResponse + { + $package = $this->ownedPackage($uuid, $user); + if ($package === null) { + return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'پکیج یافت نشد', 404); + } + + $this->packageRepo->remove($package); + + return $this->success(['message' => 'پکیج حذف شد']); + } + + // ── Helpers ────────────────────────────────────────────────────────────── + + /** Apply optional mutable item fields present in the payload. */ + private function applyItemFields(InventoryItem $item, array $data): void + { + if (array_key_exists('consumable', $data)) { + $c = trim((string) $data['consumable']); + $item->setConsumable($c === '' ? null : $c); + } + if (array_key_exists('unit', $data)) { + $unit = trim((string) $data['unit']); + $item->setUnit($unit === '' ? 'عدد' : $unit); + } + if (array_key_exists('price', $data)) { + $item->setPrice((int) $data['price']); + } + if (array_key_exists('stock', $data)) { + $item->setStock((int) $data['stock']); + } + if (array_key_exists('alertThreshold', $data)) { + $item->setAlertThreshold((int) $data['alertThreshold']); + } + } + + /** The item only if it belongs to the caller's entity, else null. */ + private function ownedItem(string $uuid, User $user): ?InventoryItem + { + [$type, $id] = $this->resolveEntity($user); + $item = $this->itemRepo->findByUuid($uuid); + if ($item === null || $id === null || $item->getEntityType() !== $type || $item->getEntityId() !== $id) { + return null; + } + return $item; + } + + /** The package only if it belongs to the caller's entity, else null. */ + private function ownedPackage(string $uuid, User $user): ?InventoryPackage + { + [$type, $id] = $this->resolveEntity($user); + $package = $this->packageRepo->findByUuid($uuid); + if ($package === null || $id === null || $package->getEntityType() !== $type || $package->getEntityId() !== $id) { + return null; + } + return $package; + } + + /** @return array{0: string, 1: int|null} [entityType, entityId] */ + private function resolveEntity(User $user): array + { + if ($user->hasRole('ROLE_DOCTOR')) { + $doctor = $this->doctorRepo->findByUser($user); + return ['doctor', $doctor?->getId()]; + } + if ($user->hasRole('ROLE_CLINIC')) { + $clinic = $this->clinicRepo->findByUser($user); + return ['clinic', $clinic?->getId()]; + } + if ($user->hasRole('ROLE_SECRETARY')) { + $dbUuid = $this->contextRepo->findByUser($user)?->getDbUuid(); + if ($dbUuid !== null) { + $clinic = $this->clinicRepo->findByUuid($dbUuid); + if ($clinic !== null) { + return ['clinic', $clinic->getId()]; + } + $doctor = $this->doctorRepo->findByUuid($dbUuid); + if ($doctor !== null) { + return ['doctor', $doctor->getId()]; + } + } + } + return ['unknown', null]; + } +} diff --git a/src/Inventory/Entity/InventoryItem.php b/src/Inventory/Entity/InventoryItem.php new file mode 100644 index 00000000..f466f9b4 --- /dev/null +++ b/src/Inventory/Entity/InventoryItem.php @@ -0,0 +1,126 @@ +uuid = Uuid::v4()->toRfc4122(); + $this->entityType = $entityType; + $this->entityId = $entityId; + $this->name = $name; + $this->createdAt = time(); + $this->updatedAt = time(); + } + + public function getId(): ?int { return $this->id; } + public function getUuid(): string { return $this->uuid; } + public function getEntityType(): string { return $this->entityType; } + public function getEntityId(): int { return $this->entityId; } + public function getName(): string { return $this->name; } + public function getConsumable(): ?string { return $this->consumable; } + public function getUnit(): string { return $this->unit; } + public function getPrice(): int { return $this->price; } + public function getStock(): int { return $this->stock; } + public function getAlertThreshold(): int { return $this->alertThreshold; } + + 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 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(); } + public function setAlertThreshold(int $v): self { $this->alertThreshold = max(0, $v); return $this->touch(); } + + /** Derived availability — see class docblock. */ + public function getStatus(): string + { + if ($this->stock <= 0) { + return self::STATUS_OUT_OF_STOCK; + } + if ($this->stock <= $this->alertThreshold) { + return self::STATUS_LOW_STOCK; + } + return self::STATUS_IN_STOCK; + } + + public function toArray(): array + { + return [ + 'uuid' => $this->uuid, + 'name' => $this->name, + 'consumable' => $this->consumable, + 'unit' => $this->unit, + 'price' => $this->price, + 'stock' => $this->stock, + 'alertThreshold' => $this->alertThreshold, + 'status' => $this->getStatus(), + ]; + } + + private function touch(): self + { + $this->updatedAt = time(); + return $this; + } +} diff --git a/src/Inventory/Entity/InventoryPackage.php b/src/Inventory/Entity/InventoryPackage.php new file mode 100644 index 00000000..ee12e953 --- /dev/null +++ b/src/Inventory/Entity/InventoryPackage.php @@ -0,0 +1,87 @@ + */ + #[ORM\OneToMany(mappedBy: 'package', targetEntity: InventoryPackageItem::class, cascade: ['persist', 'remove'], orphanRemoval: true)] + private Collection $items; + + #[ORM\Column(name: 'created_at', type: 'integer')] + private int $createdAt; + + #[ORM\Column(name: 'updated_at', type: 'integer')] + private int $updatedAt; + + public function __construct(string $entityType, int $entityId, string $title) + { + $this->uuid = Uuid::v4()->toRfc4122(); + $this->entityType = $entityType; + $this->entityId = $entityId; + $this->title = $title; + $this->items = new ArrayCollection(); + $this->createdAt = time(); + $this->updatedAt = time(); + } + + public function getId(): ?int { return $this->id; } + public function getUuid(): string { return $this->uuid; } + public function getEntityType(): string { return $this->entityType; } + public function getEntityId(): int { return $this->entityId; } + public function getTitle(): string { return $this->title; } + + public function setTitle(string $v): self { $this->title = $v; $this->updatedAt = time(); return $this; } + + /** @return Collection */ + public function getItems(): Collection { return $this->items; } + + public function addItem(InventoryPackageItem $item): self + { + if (!$this->items->contains($item)) { + $this->items->add($item); + $item->setPackage($this); + } + $this->updatedAt = time(); + return $this; + } + + /** Drop every component item (used before re-populating on update). */ + public function clearItems(): self + { + $this->items->clear(); + $this->updatedAt = time(); + return $this; + } +} diff --git a/src/Inventory/Entity/InventoryPackageItem.php b/src/Inventory/Entity/InventoryPackageItem.php new file mode 100644 index 00000000..5859227b --- /dev/null +++ b/src/Inventory/Entity/InventoryPackageItem.php @@ -0,0 +1,44 @@ +item = $item; + $this->amount = max(1, $amount); + } + + public function getId(): ?int { return $this->id; } + public function getPackage(): InventoryPackage { return $this->package; } + public function getItem(): InventoryItem { return $this->item; } + public function getAmount(): int { return $this->amount; } + + public function setPackage(InventoryPackage $p): self { $this->package = $p; return $this; } + public function setAmount(int $v): self { $this->amount = max(1, $v); return $this; } +} diff --git a/src/Inventory/Repository/InventoryItemRepository.php b/src/Inventory/Repository/InventoryItemRepository.php new file mode 100644 index 00000000..d3119a41 --- /dev/null +++ b/src/Inventory/Repository/InventoryItemRepository.php @@ -0,0 +1,65 @@ +findOneBy(['uuid' => $uuid]); + } + + /** @return InventoryItem[] */ + public function findByEntity(string $entityType, int $entityId): array + { + return $this->createQueryBuilder('i') + ->where('i.entityType = :type AND i.entityId = :id') + ->setParameter('type', $entityType) + ->setParameter('id', $entityId) + ->orderBy('i.name', 'ASC') + ->getQuery() + ->getResult(); + } + + /** + * Distinct non-empty "consumable" values for the tenant — powers the + * category filter dropdown on the inventory page. + * + * @return string[] + */ + public function findConsumables(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') + ->setParameter('type', $entityType) + ->setParameter('id', $entityId) + ->setParameter('empty', '') + ->orderBy('i.consumable', 'ASC') + ->getQuery() + ->getArrayResult(); + + return array_map(static fn(array $r): string => $r['consumable'], $rows); + } + + public function save(InventoryItem $item): void + { + $this->getEntityManager()->persist($item); + $this->getEntityManager()->flush(); + } + + public function remove(InventoryItem $item): void + { + $this->getEntityManager()->remove($item); + $this->getEntityManager()->flush(); + } +} diff --git a/src/Inventory/Repository/InventoryPackageRepository.php b/src/Inventory/Repository/InventoryPackageRepository.php new file mode 100644 index 00000000..92954d2e --- /dev/null +++ b/src/Inventory/Repository/InventoryPackageRepository.php @@ -0,0 +1,46 @@ +findOneBy(['uuid' => $uuid]); + } + + /** @return InventoryPackage[] */ + public function findByEntity(string $entityType, int $entityId): array + { + return $this->createQueryBuilder('p') + ->leftJoin('p.items', 'pi')->addSelect('pi') + ->leftJoin('pi.item', 'it')->addSelect('it') + ->where('p.entityType = :type AND p.entityId = :id') + ->setParameter('type', $entityType) + ->setParameter('id', $entityId) + ->orderBy('p.createdAt', 'DESC') + ->getQuery() + ->getResult(); + } + + public function save(InventoryPackage $package): void + { + $this->getEntityManager()->persist($package); + $this->getEntityManager()->flush(); + } + + public function remove(InventoryPackage $package): void + { + $this->getEntityManager()->remove($package); + $this->getEntityManager()->flush(); + } +} diff --git a/src/Inventory/Service/InventoryService.php b/src/Inventory/Service/InventoryService.php new file mode 100644 index 00000000..c177cb80 --- /dev/null +++ b/src/Inventory/Service/InventoryService.php @@ -0,0 +1,116 @@ +getStatus()) { + InventoryItem::STATUS_LOW_STOCK => $low++, + InventoryItem::STATUS_IN_STOCK => $inStock++, + InventoryItem::STATUS_OUT_OF_STOCK => $out++, + default => null, + }; + } + + return [ + 'total' => count($items), + 'low' => $low, + 'inStock' => $inStock, + 'outOfStock' => $out, + ]; + } + + /** + * Serialize a package with its component items, derived total price (Rial) + * and availability (true only if every component has enough stock). + */ + public function packageToArray(InventoryPackage $package): array + { + $items = []; + $total = 0; + $available = true; + + foreach ($package->getItems() as $line) { + /** @var InventoryPackageItem $line */ + $item = $line->getItem(); + $amount = $line->getAmount(); + $total += $item->getPrice() * $amount; + + if ($item->getStock() < $amount) { + $available = false; + } + + $items[] = [ + 'itemUuid' => $item->getUuid(), + 'name' => $item->getName(), + 'unit' => $item->getUnit(), + 'price' => $item->getPrice(), + 'amount' => $amount, + ]; + } + + return [ + 'uuid' => $package->getUuid(), + 'title' => $package->getTitle(), + 'items' => $items, + 'total' => $total, + 'available' => $available, + ]; + } + + /** + * Replace a package's component lines from a list of {itemUuid, amount}. + * Silently skips references the tenant does not own. Returns the number of + * lines actually attached. + * + * @param array $lines + */ + public function syncPackageItems(InventoryPackage $package, array $lines, string $entityType, int $entityId): int + { + $package->clearItems(); + $count = 0; + + foreach ($lines as $line) { + $uuid = trim((string) ($line['itemUuid'] ?? '')); + if ($uuid === '') { + continue; + } + $item = $this->itemRepo->findByUuid($uuid); + // Only attach items the caller owns — never leak another tenant's stock. + if ($item === null || $item->getEntityType() !== $entityType || $item->getEntityId() !== $entityId) { + continue; + } + $amount = (int) ($line['amount'] ?? 1); + $package->addItem(new InventoryPackageItem($item, $amount)); + $count++; + } + + return $count; + } +} diff --git a/tests/Inventory/InventoryApiTest.php b/tests/Inventory/InventoryApiTest.php new file mode 100644 index 00000000..1834caf3 --- /dev/null +++ b/tests/Inventory/InventoryApiTest.php @@ -0,0 +1,196 @@ +createUser(['ROLE_DOCTOR']); + $doctor = new Doctor($user, 'دکتر'); + $this->em->persist($doctor); + $this->em->flush(); + return [$user, $doctor]; + } + + // ── Items ──────────────────────────────────────────────────────────────── + + public function testItemCreateListUpdateDelete(): void + { + [$user] = $this->doctorUser(); + + $created = $this->authJson('POST', '/api/v1/inventory-item', $user, [ + 'name' => 'دستکش', 'unit' => 'عدد', 'price' => 250000, 'stock' => 150, 'alertThreshold' => 20, + ]); + self::assertSame(201, $this->responseCode()); + self::assertSame('دستکش', $created['data']['name']); + self::assertSame('in_stock', $created['data']['status']); + $uuid = $created['data']['uuid']; + + $list = $this->authJson('GET', '/api/v1/inventory-items', $user); + self::assertSame(200, $this->responseCode()); + self::assertCount(1, $list['data']['items']); + self::assertSame(1, $list['data']['stats']['total']); + self::assertSame(1, $list['data']['stats']['inStock']); + + // drop stock below threshold → low_stock + $this->authJson('PATCH', '/api/v1/inventory-item/' . $uuid, $user, ['stock' => 5]); + self::assertSame(200, $this->responseCode()); + $afterPatch = $this->authJson('GET', '/api/v1/inventory-items', $user); + self::assertSame('low_stock', $afterPatch['data']['items'][0]['status']); + self::assertSame(1, $afterPatch['data']['stats']['low']); + + $this->authJson('DELETE', '/api/v1/inventory-item/' . $uuid, $user); + self::assertSame(200, $this->responseCode()); + $after = $this->authJson('GET', '/api/v1/inventory-items', $user); + self::assertCount(0, $after['data']['items']); + } + + public function testStatusDerivation(): void + { + [$user] = $this->doctorUser(); + + // zero stock → out_of_stock, regardless of threshold + $out = $this->authJson('POST', '/api/v1/inventory-item', $user, ['name' => 'تمام‌شده', 'stock' => 0, 'alertThreshold' => 5]); + self::assertSame('out_of_stock', $out['data']['status']); + + // stock above threshold → in_stock + $in = $this->authJson('POST', '/api/v1/inventory-item', $user, ['name' => 'موجود', 'stock' => 100, 'alertThreshold' => 10]); + self::assertSame('in_stock', $in['data']['status']); + } + + public function testItemRejectsEmptyName(): void + { + [$user] = $this->doctorUser(); + $this->authJson('POST', '/api/v1/inventory-item', $user, ['name' => '']); + self::assertSame(422, $this->responseCode()); + } + + public function testEmptyStateReturnsZeroStats(): void + { + [$user] = $this->doctorUser(); + $list = $this->authJson('GET', '/api/v1/inventory-items', $user); + self::assertSame(200, $this->responseCode()); + self::assertCount(0, $list['data']['items']); + self::assertSame( + ['total' => 0, 'low' => 0, 'inStock' => 0, 'outOfStock' => 0], + $list['data']['stats'] + ); + } + + public function testCategoriesReturnsDistinctConsumables(): 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' => 'دندان']); + + $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']); + } + + public function testCannotTouchAnotherTenantsItem(): void + { + [$ownerA] = $this->doctorUser(); + $created = $this->authJson('POST', '/api/v1/inventory-item', $ownerA, ['name' => 'مال A']); + $uuid = $created['data']['uuid']; + + [$ownerB] = $this->doctorUser(); + $this->authJson('PATCH', '/api/v1/inventory-item/' . $uuid, $ownerB, ['name' => 'دزدی']); + self::assertSame(404, $this->responseCode()); + $this->authJson('DELETE', '/api/v1/inventory-item/' . $uuid, $ownerB); + self::assertSame(404, $this->responseCode()); + } + + // ── Packages ───────────────────────────────────────────────────────────── + + public function testPackageCreateComputesTotalAndAvailability(): void + { + [$user] = $this->doctorUser(); + + $gel = $this->authJson('POST', '/api/v1/inventory-item', $user, ['name' => 'ژل', 'price' => 1200000, 'stock' => 10])['data']['uuid']; + $glove = $this->authJson('POST', '/api/v1/inventory-item', $user, ['name' => 'دستکش', 'price' => 500000, 'stock' => 1])['data']['uuid']; + + // 2×ژل (stock 10, ok) + 3×دستکش (stock 1, NOT enough) → unavailable + $pkg = $this->authJson('POST', '/api/v1/inventory-package', $user, [ + 'title' => 'پکیج یک', + 'items' => [ + ['itemUuid' => $gel, 'amount' => 2], + ['itemUuid' => $glove, 'amount' => 3], + ], + ]); + self::assertSame(201, $this->responseCode()); + self::assertSame(2 * 1200000 + 3 * 500000, $pkg['data']['total']); + self::assertFalse($pkg['data']['available']); + self::assertCount(2, $pkg['data']['items']); + + // list reflects the same package + $list = $this->authJson('GET', '/api/v1/inventory-packages', $user); + self::assertCount(1, $list['data']); + self::assertSame('پکیج یک', $list['data'][0]['title']); + } + + public function testPackageUpdateReplacesItemsAndDelete(): void + { + [$user] = $this->doctorUser(); + $a = $this->authJson('POST', '/api/v1/inventory-item', $user, ['name' => 'A', 'price' => 1000, 'stock' => 50])['data']['uuid']; + + $uuid = $this->authJson('POST', '/api/v1/inventory-package', $user, [ + 'title' => 'p', 'items' => [['itemUuid' => $a, 'amount' => 1]], + ])['data']['uuid']; + + $updated = $this->authJson('PATCH', '/api/v1/inventory-package/' . $uuid, $user, [ + 'title' => 'p2', 'items' => [['itemUuid' => $a, 'amount' => 5]], + ]); + self::assertSame(200, $this->responseCode()); + self::assertSame('p2', $updated['data']['title']); + self::assertCount(1, $updated['data']['items']); + self::assertSame(5, $updated['data']['items'][0]['amount']); + self::assertSame(5000, $updated['data']['total']); + + $this->authJson('DELETE', '/api/v1/inventory-package/' . $uuid, $user); + self::assertSame(200, $this->responseCode()); + $after = $this->authJson('GET', '/api/v1/inventory-packages', $user); + self::assertCount(0, $after['data']); + } + + public function testPackageSkipsForeignItemReferences(): void + { + [$ownerA] = $this->doctorUser(); + $foreign = $this->authJson('POST', '/api/v1/inventory-item', $ownerA, ['name' => 'خارجی', 'price' => 999, 'stock' => 5])['data']['uuid']; + + [$ownerB] = $this->doctorUser(); + $mine = $this->authJson('POST', '/api/v1/inventory-item', $ownerB, ['name' => 'مال من', 'price' => 100, 'stock' => 5])['data']['uuid']; + + // package for B referencing A's item → foreign line dropped, only B's stays + $pkg = $this->authJson('POST', '/api/v1/inventory-package', $ownerB, [ + 'title' => 'mix', + 'items' => [ + ['itemUuid' => $foreign, 'amount' => 1], + ['itemUuid' => $mine, 'amount' => 2], + ], + ]); + self::assertSame(201, $this->responseCode()); + self::assertCount(1, $pkg['data']['items']); + self::assertSame($mine, $pkg['data']['items'][0]['itemUuid']); + self::assertSame(200, $pkg['data']['total']); + } + + public function testPackageRejectsEmptyTitle(): void + { + [$user] = $this->doctorUser(); + $this->authJson('POST', '/api/v1/inventory-package', $user, ['title' => '']); + self::assertSame(422, $this->responseCode()); + } +}