Files
clinicpro/assets/admin/components/inventory/AddPackageModal.tsx
T
hamedandClaude Opus 4.8 519bc8d7f6 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>
2026-07-15 13:48:29 +03:30

137 lines
7.1 KiB
TypeScript

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>
);
}