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:
@@ -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<typeof vi.fn>;
|
||||
|
||||
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(<InventoryPage />, { 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(<InventoryPage />, { 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(<InventoryPage />, { 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(<InventoryPage />, { 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());
|
||||
});
|
||||
});
|
||||
@@ -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<Tab>('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<InventoryItem | null>(null);
|
||||
const [pkgToDelete, setPkgToDelete] = useState<InventoryPackage | null>(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 (
|
||||
<div className="fade-in" style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h1 style={{ fontSize: 18, fontWeight: 800, color: 'var(--text)', marginBottom: 12 }}>انبارداری</h1>
|
||||
<div className="inv-tabs">
|
||||
<button className={`inv-tab${tab === 'stock' ? ' active' : ''}`} onClick={() => setTab('stock')}>کالاهای مصرفی</button>
|
||||
<button className={`inv-tab${tab === 'packages' ? ' active' : ''}`} onClick={() => setTab('packages')}>پکیج</button>
|
||||
</div>
|
||||
|
||||
{tab === 'stock' ? (
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 16, flexWrap: 'wrap' }}>
|
||||
<div style={{ display: 'flex', gap: 16, flexWrap: 'wrap', flex: 1 }}>
|
||||
<div className="field" style={{ display: 'flex', alignItems: 'center', gap: 8, minWidth: 240, flex: '1 1 300px', maxWidth: 442 }}>
|
||||
<MagnifyingGlassIcon style={{ width: 18, height: 18, color: 'var(--text-3)', flexShrink: 0 }} />
|
||||
<input
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="جستجو در کالاهای مصرفی..."
|
||||
style={{ border: 'none', background: 'transparent', flex: 1, padding: 0 }}
|
||||
/>
|
||||
</div>
|
||||
<div className="field" style={{ minWidth: 200, flex: '1 1 260px', maxWidth: 397 }}>
|
||||
<select value={category} onChange={(e) => setCategory(e.target.value)}>
|
||||
<option value="">دستهبندی کالا را انتخاب کنید...</option>
|
||||
{categories.map((c) => <option key={c} value={c}>{c}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<button className="btn primary" onClick={() => setItemModal({ open: true, editing: null })}>
|
||||
<PlusIcon style={{ width: 16 }} /> افزودن کالا
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
|
||||
<button className="btn primary" onClick={() => setPkgModal({ open: true, editing: null })}>
|
||||
<PlusIcon style={{ width: 16 }} /> افزودن پکیج
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
{tab === 'stock' ? (
|
||||
<>
|
||||
<InventoryStatCards stats={stats} />
|
||||
{itemsLoading ? (
|
||||
<div style={{ padding: 16, color: 'var(--text-3)', fontSize: 13 }}>در حال بارگذاری...</div>
|
||||
) : filteredItems.length === 0 ? (
|
||||
<EmptyState label={items.length === 0 ? 'هنوز کالایی ثبت نشده است.' : 'کالایی با این فیلتر یافت نشد.'} />
|
||||
) : (
|
||||
<InventoryItemsTable
|
||||
items={filteredItems}
|
||||
onEdit={(item) => setItemModal({ open: true, editing: item })}
|
||||
onDelete={setItemToDelete}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
) : packagesLoading ? (
|
||||
<div style={{ padding: 16, color: 'var(--text-3)', fontSize: 13 }}>در حال بارگذاری...</div>
|
||||
) : packages.length === 0 ? (
|
||||
<EmptyState label="هنوز پکیجی ثبت نشده است." />
|
||||
) : (
|
||||
<PackagesView
|
||||
packages={packages}
|
||||
onEdit={(pkg) => setPkgModal({ open: true, editing: pkg })}
|
||||
onDelete={setPkgToDelete}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Modals */}
|
||||
<AddItemModal
|
||||
open={itemModal.open}
|
||||
editing={itemModal.editing}
|
||||
saving={createItem.isPending || updateItem.isPending}
|
||||
onClose={() => setItemModal({ open: false, editing: null })}
|
||||
onSave={saveItem}
|
||||
/>
|
||||
<AddPackageModal
|
||||
open={pkgModal.open}
|
||||
editing={pkgModal.editing}
|
||||
items={items}
|
||||
saving={createPackage.isPending || updatePackage.isPending}
|
||||
onClose={() => setPkgModal({ open: false, editing: null })}
|
||||
onSave={savePackage}
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
open={!!itemToDelete}
|
||||
title="حذف کالا"
|
||||
message={`آیا از حذف «${itemToDelete?.name}» مطمئن هستید؟`}
|
||||
confirmLabel="حذف"
|
||||
loading={deleteItem.isPending}
|
||||
onConfirm={() => itemToDelete && deleteItem.mutate(itemToDelete.uuid, { onSuccess: () => setItemToDelete(null) })}
|
||||
onCancel={() => setItemToDelete(null)}
|
||||
/>
|
||||
<ConfirmDialog
|
||||
open={!!pkgToDelete}
|
||||
title="حذف پکیج"
|
||||
message={`آیا از حذف «${pkgToDelete?.title}» مطمئن هستید؟`}
|
||||
confirmLabel="حذف"
|
||||
loading={deletePackage.isPending}
|
||||
onConfirm={() => pkgToDelete && deletePackage.mutate(pkgToDelete.uuid, { onSuccess: () => setPkgToDelete(null) })}
|
||||
onCancel={() => setPkgToDelete(null)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EmptyState({ label }: { label: string }) {
|
||||
return (
|
||||
<div className="card" style={{ padding: '56px 0', textAlign: 'center', color: 'var(--text-3)' }}>
|
||||
<ArchiveBoxIcon style={{ width: 48, margin: '0 auto 14px', display: 'block', opacity: 0.3 }} />
|
||||
<div style={{ fontSize: 14, color: 'var(--text-2)' }}>{label}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user