Files
clinicpro/assets/admin/pages/InventoryPage.tsx
T
hamedandClaude Opus 4.8 a3b29404f4 fix(secretary): gate CRUD action buttons across all panel pages by permission
Backend already returned 403 for ungranted secretary actions, but the UI still
showed the add/edit/delete buttons (e.g. clinic-services showed «بخش جدید» to a
secretary without services.create). Sweep every secretary-reachable page so each
create/edit/delete/manage control renders only when the matching
usePermissions().can(resource, action) is true. Owner/doctor/clinic are
unaffected — can() returns true when there is no permission context — so this
restricts only secretaries and mirrors the server checks.

Pages/components gated (resource):
- services: ClinicServicesPage, ServiceDetailPage (+ its tabs)
- inventory: InventoryPage, InventoryItemsTable, InventoryActionsMenu, PackagesView
- tags: TagsSettingsPage · staff: StaffPage · discounts: DiscountTab
- sms: SmsWalletPage · insurances: TenantInsuranceContracts
- clinic_doctors: ClinicDoctorsPage + ClinicDoctorsManager (props, default true)
- patients: PatientsListPage, MyPatientsPage, PatientDetailPage (records/notes/
  sessions/attachments/calls/wallet — create/update/delete split)
- appointments: AppointmentsPage (add + empty-slot booking gated by create),
  TurnsTable (status dropdown → read-only badge without update_status; actions
  menu hidden without manage/cancel)
- appointment_settings: AppointmentSettingsPage + ClinicAppointmentSettingsPage
  pass readOnly to ScheduleSection + FreeVisitPrice (new readOnly prop)

Not gated: view/read, search, filter, tabs, navigation, export, and modal
submit buttons reachable only via an already-gated trigger.

tsc clean; full frontend suite 501/501 passes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 18:39:58 +03:30

181 lines
8.0 KiB
TypeScript

import React, { useMemo, useState } from 'react';
import { PlusIcon, MagnifyingGlassIcon, ArchiveBoxIcon } from '@heroicons/react/24/outline';
import ConfirmDialog from '../components/ui/ConfirmDialog';
import SearchableSelect from '../components/ui/SearchableSelect';
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';
import { usePermissions } from '../hooks/usePermissions';
type Tab = 'stock' | 'packages';
/** انبارداری — consumable stock items + packages. Ported from clinic-pro-tauri /inventory. */
export default function InventoryPage() {
const {
items, stats, packages, categories, meta, itemsLoading, packagesLoading,
createItem, updateItem, deleteItem, createPackage, updatePackage, deletePackage,
} = useInventory();
// مجوزهای منشی؛ برای owner/پزشک همیشه true (usePermissions بدون context آزاد است).
const { can } = usePermissions();
const canCreate = can('inventory', 'create');
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.category === 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 style={{ minWidth: 200, flex: '1 1 260px', maxWidth: 397 }}>
<SearchableSelect
options={categories.map((c) => ({ value: c, label: c }))}
value={category || null}
onChange={(v) => setCategory(v ? String(v) : '')}
placeholder="دسته‌بندی کالا را انتخاب کنید..."
isClearable
height={38}
/>
</div>
</div>
{canCreate && (
<button className="btn primary" onClick={() => setItemModal({ open: true, editing: null })}>
<PlusIcon style={{ width: 16 }} /> افزودن کالا
</button>
)}
</div>
) : (
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
{canCreate && (
<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}
meta={meta}
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>
);
}