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,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<FormState>(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<HTMLInputElement>) =>
|
||||
setForm((f) => ({ ...f, [k]: e.target.value }));
|
||||
const setNum = (k: keyof FormState) => (e: React.ChangeEvent<HTMLInputElement>) =>
|
||||
setForm((f) => ({ ...f, [k]: digits(e.target.value) }));
|
||||
|
||||
const 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 (
|
||||
<Modal open={open} onClose={onClose} title={editing ? 'ویرایش کالا' : 'افزودن کالای جدید'} size="lg">
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 20 }}>
|
||||
{fields.map((f) => (
|
||||
<div key={f.key}>
|
||||
<label className="field-label">{f.label}</label>
|
||||
<div className="field">
|
||||
<input
|
||||
value={form[f.key]}
|
||||
onChange={f.numeric ? setNum(f.key) : set(f.key)}
|
||||
placeholder={f.placeholder}
|
||||
inputMode={f.numeric ? 'numeric' : undefined}
|
||||
autoFocus={f.key === 'name'}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{error && <span className="field-error">{error}</span>}
|
||||
<button
|
||||
className="btn primary"
|
||||
style={{ width: '100%', justifyContent: 'center', height: 46 }}
|
||||
onClick={submit}
|
||||
disabled={saving}
|
||||
>
|
||||
{editing ? 'ذخیره تغییرات' : 'اضافه کردن کالا'}
|
||||
</button>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user