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:
@@ -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() {
|
||||
<Route path="subscription" element={<RoleRoute roles={['doctor', 'clinic']} blockClinicScope><SubscriptionPage /></RoleRoute>} />
|
||||
<Route path="subscription/success" element={<RoleRoute roles={['doctor', 'clinic']} blockClinicScope><PaymentSuccessPage /></RoleRoute>} />
|
||||
<Route path="clinic-services" element={<RoleRoute roles={['doctor', 'clinic']} blockClinicScope><ClinicServicesPage /></RoleRoute>} />
|
||||
<Route path="inventory" element={<RoleRoute roles={['doctor', 'clinic']} blockClinicScope><InventoryPage /></RoleRoute>} />
|
||||
<Route path="sms-wallet" element={<RoleRoute roles={['doctor', 'clinic']} blockClinicScope><SmsWalletPage /></RoleRoute>} />
|
||||
<Route path="my-secretaries" element={<RoleRoute roles={['doctor', 'clinic']} blockClinicScope><MySecretariesPage /></RoleRoute>} />
|
||||
<Route path="admin-subscription" element={<RoleRoute roles={['admin']}><AdminSubscriptionPage /></RoleRoute>} />
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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<Line[]>([]);
|
||||
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 = (
|
||||
<div style={{ display: 'flex', gap: 16, justifyContent: 'flex-end', width: '100%' }}>
|
||||
<button className="btn ghost" style={{ height: 48, width: 160, justifyContent: 'center' }} onClick={onClose}>انصراف</button>
|
||||
<button className="btn primary" style={{ height: 48, width: 160, justifyContent: 'center' }} onClick={submit} disabled={saving}>
|
||||
{editing ? 'ذخیره تغییرات' : 'افزودن پکیج'}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<Modal open={open} onClose={onClose} title={editing ? 'ویرایش پکیج' : 'افزودن پکیج'} size="xl" footer={footer}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 32 }}>
|
||||
{/* Left — builder */}
|
||||
<div style={{ flex: '1 1 300px', display: 'flex', flexDirection: 'column', gap: 20 }}>
|
||||
<div>
|
||||
<label className="field-label">نام پکیج</label>
|
||||
<div className="field"><input value={title} onChange={(e) => setTitle(e.target.value)} placeholder="نام پکیج" /></div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="field-label">اجزای پکیج</label>
|
||||
<div className="field">
|
||||
<select value={pickUuid} onChange={(e) => setPickUuid(e.target.value)} disabled={items.length === 0}>
|
||||
{items.length === 0 && <option value="">ابتدا کالا اضافه کنید</option>}
|
||||
{items.map((i) => <option key={i.uuid} value={i.uuid}>{i.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="field-label">مقدار</label>
|
||||
<div className="field">
|
||||
<input value={amount} inputMode="numeric" onChange={(e) => setAmount(e.target.value.replace(/[^0-9]/g, ''))} placeholder="1" />
|
||||
</div>
|
||||
</div>
|
||||
<button className="btn ghost" style={{ justifyContent: 'center', height: 46 }} onClick={addLine} disabled={items.length === 0}>
|
||||
افزودن کالا
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Right — selected items */}
|
||||
<div
|
||||
style={{
|
||||
flex: '1 1 300px', minHeight: 320, borderRadius: 8, padding: 24,
|
||||
border: '1px dashed #636ed0', display: 'flex', flexDirection: 'column', justifyContent: 'space-between',
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<div style={{ fontSize: 16, fontWeight: 600, color: 'var(--text)', marginBottom: 16 }}>کالاهای انتخاب شده:</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||
{lines.length === 0 ? (
|
||||
<span style={{ fontSize: 14, color: 'var(--text-3)' }}>هنوز کالایی به پکیج اضافه نشده است.</span>
|
||||
) : (
|
||||
lines.map((l, idx) => (
|
||||
<div key={idx} style={{ display: 'flex', alignItems: 'center', gap: 16 }}>
|
||||
<span style={{ flex: 1, minWidth: 0, fontSize: 14, fontWeight: 600, color: 'var(--text-2)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{l.name}</span>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', width: 97, height: 40, borderRadius: 4, border: '1px solid var(--border-2)', padding: '0 8px' }}>
|
||||
<button type="button" onClick={() => changeAmount(idx, 1)} style={{ background: 'none', border: 'none', cursor: 'pointer', color: '#f0753b', fontSize: 18, fontWeight: 600 }}>+</button>
|
||||
<span style={{ color: '#f0753b', fontSize: 14, fontWeight: 500 }}>{l.amount}</span>
|
||||
<button type="button" onClick={() => changeAmount(idx, -1)} style={{ background: 'none', border: 'none', cursor: 'pointer', color: '#f0753b', fontSize: 18, fontWeight: 600 }}>-</button>
|
||||
</div>
|
||||
<span style={{ fontSize: 14, color: 'var(--text-2)', whiteSpace: 'nowrap' }}>{formatRial(l.price)}</span>
|
||||
<button type="button" onClick={() => removeLine(idx)} aria-label="حذف" style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--danger)' }}>
|
||||
<TrashIcon style={{ width: 18 }} />
|
||||
</button>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ marginTop: 24, paddingTop: 16, borderTop: '1px solid var(--border)' }}>
|
||||
<span style={{ fontSize: 14, fontWeight: 600, color: 'var(--text)' }}>قیمت کل پکیج: {formatRial(total)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{error && <span className="field-error">{error}</span>}
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<div style={{ position: 'relative', display: 'inline-block' }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 4, cursor: 'pointer',
|
||||
borderRadius: 6, padding: '4px 8px', background: 'none',
|
||||
color: '#5559ce', fontSize: 12, fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
<EllipsisHorizontalCircleIcon style={{ width: 18, height: 18 }} />
|
||||
عملیات
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<>
|
||||
<div
|
||||
onClick={() => setOpen(false)}
|
||||
style={{ position: 'fixed', inset: 0, zIndex: 30 }}
|
||||
/>
|
||||
<div
|
||||
role="menu"
|
||||
style={{
|
||||
position: 'absolute', top: '100%', insetInlineStart: 0, marginTop: 4, zIndex: 31,
|
||||
width: 160, background: 'var(--surface)', border: '1px solid var(--border)',
|
||||
borderRadius: 12, boxShadow: 'var(--shadow-lg)', overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
onClick={() => { setOpen(false); onEdit(item); }}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 8, width: '100%',
|
||||
padding: '12px 16px', background: 'var(--primary-soft)', border: 'none',
|
||||
cursor: 'pointer', fontSize: 14, fontWeight: 600, color: 'var(--text)',
|
||||
}}
|
||||
>
|
||||
<PencilSquareIcon style={{ width: 20, height: 20 }} />
|
||||
ویرایش
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
onClick={() => { setOpen(false); onDelete(item); }}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 8, width: '100%',
|
||||
padding: '12px 16px', background: 'transparent', border: 'none',
|
||||
cursor: 'pointer', fontSize: 14, color: 'var(--danger)',
|
||||
}}
|
||||
>
|
||||
<TrashIcon style={{ width: 20, height: 20 }} />
|
||||
حذف
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<div style={{ width: '100%' }}>
|
||||
{/* Desktop table */}
|
||||
<div
|
||||
className="inv-desktop"
|
||||
style={{ border: '1px solid #E7E7E7', borderRadius: 8, overflow: 'hidden' }}
|
||||
>
|
||||
<table className="inv-table">
|
||||
<thead>
|
||||
<tr>{HEAD.map((h) => <th key={h}>{h}</th>)}</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.map((item) => (
|
||||
<tr key={item.uuid}>
|
||||
<td style={{ color: 'var(--text-2)' }}>{item.name}</td>
|
||||
<td>{formatNumber(item.stock)}</td>
|
||||
<td>{item.unit}</td>
|
||||
<td>{formatRial(item.price)}</td>
|
||||
<td><InventoryStatusBadge status={item.status} /></td>
|
||||
<td><InventoryActionsMenu item={item} onEdit={onEdit} onDelete={onDelete} /></td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Mobile cards */}
|
||||
<ul
|
||||
className="inv-mobile"
|
||||
style={{ gridTemplateColumns: 'repeat(auto-fill, minmax(260px, 1fr))', gap: 16, margin: 0, padding: 0, listStyle: 'none' }}
|
||||
>
|
||||
{items.map((item) => (
|
||||
<li
|
||||
key={item.uuid}
|
||||
style={{
|
||||
padding: 12, background: 'var(--surface)', border: '1px solid var(--border)',
|
||||
borderRadius: 8, boxShadow: 'var(--shadow-sm)',
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 12 }}>
|
||||
<span style={{ color: 'var(--text)', fontSize: 14, fontWeight: 600 }}>{item.name}</span>
|
||||
<InventoryStatusBadge status={item.status} />
|
||||
</div>
|
||||
{[
|
||||
['موجودی:', formatNumber(item.stock)],
|
||||
['واحد:', item.unit],
|
||||
['قیمت:', formatRial(item.price)],
|
||||
].map(([label, value], i, arr) => (
|
||||
<div key={label}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12 }}>
|
||||
<span style={{ color: 'var(--text-3)', fontSize: 14 }}>{label}</span>
|
||||
<span style={{ color: 'var(--text-2)', fontSize: 14, fontWeight: 500 }}>{value}</span>
|
||||
</div>
|
||||
{i < arr.length - 1 && (
|
||||
<div style={{ height: 1, background: 'var(--border)', margin: '8px auto', width: 'calc(100% - 20px)' }} />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8, marginTop: 12 }}>
|
||||
<button className="btn sm ghost" aria-label="ویرایش" onClick={() => onEdit(item)} style={{ color: 'var(--text-2)' }}>
|
||||
<PencilSquareIcon style={{ width: 16 }} />
|
||||
</button>
|
||||
<button className="btn sm ghost" aria-label="حذف" onClick={() => onDelete(item)} style={{ color: 'var(--danger)' }}>
|
||||
<TrashIcon style={{ width: 16 }} />
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 = <ArchiveBoxIcon style={{ width: 20, height: 20 }} />;
|
||||
|
||||
/** Four headline counters — order/colors mirror the tauri InventoryCards. */
|
||||
export default function InventoryStatCards({ stats }: { stats: InventoryStats }) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fit, minmax(220px, 1fr))',
|
||||
gap: 20,
|
||||
padding: '20px 0',
|
||||
}}
|
||||
>
|
||||
<StatCard tone="violet" icon={ICON} label="تعداد کل کالاها" value={formatNumber(stats.total)} />
|
||||
<StatCard tone="amber" icon={ICON} label="کالاهای کم موجود" value={formatNumber(stats.low)} />
|
||||
<StatCard tone="green" icon={ICON} label="کالاهای موجود" value={formatNumber(stats.inStock)} />
|
||||
<StatCard tone="pink" icon={ICON} label="کالاهای اتمام یافته" value={formatNumber(stats.outOfStock)} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import React from 'react';
|
||||
import type { InventoryStatus } from '../../hooks/useInventory';
|
||||
|
||||
const CONFIG: Record<InventoryStatus, { label: string; cls: string }> = {
|
||||
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 <span className={`inv-badge ${c.cls}`}>{c.label}</span>;
|
||||
}
|
||||
@@ -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 (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||
{packages.map((pkg) => (
|
||||
<PackageCard key={pkg.uuid} pkg={pkg} onEdit={onEdit} onDelete={onDelete} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PackageCard({ pkg, onEdit, onDelete }: { pkg: InventoryPackage } & Pick<Props, 'onEdit' | 'onDelete'>) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const divider = <div style={{ height: 1, background: '#d7d7d7', margin: '8px 0' }} className="inv-divider" />;
|
||||
|
||||
return (
|
||||
<div style={{ background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 8, padding: 16 }}>
|
||||
{/* Header */}
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<span style={{ width: 20, height: 20, background: '#f0753b', borderRadius: '50%', flexShrink: 0 }} />
|
||||
<span style={{ fontWeight: 500, fontSize: 16, color: 'var(--text)' }}>{pkg.title}</span>
|
||||
</div>
|
||||
<span
|
||||
style={{
|
||||
width: 122, height: 34, display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
|
||||
borderRadius: 4, fontWeight: 600, fontSize: 16,
|
||||
color: pkg.available ? '#3f9954' : '#d32f2f',
|
||||
background: pkg.available ? '#f0faf2' : '#fdebed',
|
||||
}}
|
||||
>
|
||||
{pkg.available ? 'موجودی کافی' : 'موجودی ناکافی'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{divider}
|
||||
|
||||
{/* Accordion */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpanded((v) => !v)}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 12, width: '100%', padding: '8px 0',
|
||||
background: 'none', border: 'none', cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
<ChevronDownIcon
|
||||
style={{ width: 20, height: 20, color: 'var(--text-2)', transition: 'transform .2s', transform: expanded ? 'rotate(180deg)' : 'none' }}
|
||||
/>
|
||||
<span style={{ fontWeight: 500, fontSize: 14, color: 'var(--text)' }}>اجزای پکیج:</span>
|
||||
</button>
|
||||
|
||||
{expanded && (
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 40, padding: '4px 0 16px' }}>
|
||||
{pkg.items.length === 0 ? (
|
||||
<span style={{ fontSize: 14, color: 'var(--text-3)' }}>این پکیج کالایی ندارد.</span>
|
||||
) : (
|
||||
pkg.items.map((it) => (
|
||||
<div key={it.itemUuid} style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 4 }}>
|
||||
<span style={{ color: 'var(--text)', fontWeight: 700, fontSize: 16 }}>{it.name}</span>
|
||||
<span
|
||||
style={{
|
||||
height: 24, display: 'inline-flex', alignItems: 'center', padding: '0 10px',
|
||||
background: 'var(--surface-3)', color: 'var(--text-2)', borderRadius: 6, fontWeight: 500, fontSize: 14,
|
||||
}}
|
||||
>
|
||||
{it.amount} {it.unit}
|
||||
</span>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{divider}
|
||||
|
||||
{/* Footer */}
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<span style={{ fontWeight: 700, fontSize: 14, color: 'var(--text)' }}>
|
||||
قیمت پکیج: {formatRial(pkg.total)}
|
||||
</span>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<button
|
||||
className="btn sm ghost"
|
||||
onClick={() => onDelete(pkg)}
|
||||
style={{ height: 40, width: 78, border: '1px solid var(--border)', color: 'var(--danger)', gap: 6 }}
|
||||
>
|
||||
<TrashIcon style={{ width: 16 }} /> حذف
|
||||
</button>
|
||||
<button
|
||||
className="btn sm ghost"
|
||||
onClick={() => onEdit(pkg)}
|
||||
style={{ height: 40, width: 78, border: '1px solid var(--border)', color: 'var(--text-2)', gap: 6 }}
|
||||
>
|
||||
<PencilSquareIcon style={{ width: 16 }} /> ویرایش
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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: "انبارداری",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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<ApiResponse<{ items: InventoryItem[]; stats: InventoryStats }>>({
|
||||
queryKey: ['inventory-items'],
|
||||
queryFn: () => api.get('/api/v1/inventory-items'),
|
||||
});
|
||||
|
||||
const packagesQuery = useQuery<ApiResponse<InventoryPackage[]>>({
|
||||
queryKey: ['inventory-packages'],
|
||||
queryFn: () => api.get('/api/v1/inventory-packages'),
|
||||
});
|
||||
|
||||
const categoriesQuery = useQuery<ApiResponse<string[]>>({
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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); }
|
||||
|
||||
Reference in New Issue
Block a user