feat: refactor resource management by removing modal components and integrating functionality into tabs; add ResourceBlocksPanel for temporary deactivation of resources

This commit is contained in:
hamed
2026-08-03 10:12:01 +03:30
parent b2b36e3eec
commit 00c275d618
10 changed files with 391 additions and 362 deletions
@@ -247,4 +247,48 @@ describe('ResourceDetailPage', () => {
expect(screen.queryByRole('button', { name: /افزودن دسته‌بندی جدید/ })).not.toBeInTheDocument();
expect(screen.getByText('تنظیمات ← دسته‌بندی‌ها')).toHaveAttribute('href', '/admin/service-categories');
});
/**
* مسدودسازی موردی از مودالِ جدول به تبِ خودِ منبع آمد و «غیرفعال‌سازی موقت» نام گرفت.
* متن تب باید تفاوتش با استثنای تقویم را بگوید، وگرنه اپراتور تعطیلیِ یک بعدازظهر را
* برای همیشه ثبت می‌کند.
*/
it('تب غیرفعال‌سازی موقت، بازه‌ها را جدا از الگوی کاری معرفی می‌کند', async () => {
mockApi();
const user = userEvent.setup();
renderPage();
await waitFor(() => expect(screen.getByText('شعبهٔ مرکزی')).toBeInTheDocument());
await user.click(screen.getByRole('button', { name: 'غیرفعال‌سازی موقت' }));
expect(await screen.findByText(/یک بازهٔ مشخص را می‌بندد/)).toBeInTheDocument();
// «الگوی کاری» فقط در همین راهنما می‌آید — نامِ تب در چند جا تکرار می‌شود.
expect(screen.getByText(/الگوی کاری/)).toBeInTheDocument();
expect(screen.getByRole('heading', { name: 'غیرفعال‌سازی موقت' })).toBeInTheDocument();
});
/** فعال/غیرفعال و حذف از ردیف جدول به تب اطلاعات آمدند. */
it('تب اطلاعات، کلید فعال/غیرفعال و حذف منبع را دارد', async () => {
mockApi();
renderPage();
await waitFor(() => expect(screen.getByText('شعبهٔ مرکزی')).toBeInTheDocument());
// منبعِ نمونه فعال است، پس اقدام «غیرفعال کردن» پیشنهاد می‌شود.
expect(screen.getByRole('button', { name: 'غیرفعال کردن' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'حذف' })).toBeInTheDocument();
expect(screen.getByText(/اگر فقط موقتاً لازمش ندارید/)).toBeInTheDocument();
});
/** حذف بدون تأیید انجام نمی‌شود — عملی است که برگشت ندارد. */
it('حذف اول تأیید می‌گیرد', async () => {
mockApi();
const user = userEvent.setup();
renderPage();
await waitFor(() => expect(screen.getByText('شعبهٔ مرکزی')).toBeInTheDocument());
await user.click(screen.getByRole('button', { name: 'حذف' }));
expect(await screen.findByText(/مطمئن هستید؟ این کار برگشت‌پذیر نیست/)).toBeInTheDocument();
});
});
+96 -5
View File
@@ -1,7 +1,8 @@
import React, { useState } from 'react';
import { useParams } from 'react-router-dom';
import { useNavigate, useParams } from 'react-router-dom';
import { PencilIcon } from '@heroicons/react/24/outline';
import PageHeader from '../components/ui/PageHeader';
import ConfirmDialog from '../components/ui/ConfirmDialog';
import { ActiveBadge } from '../components/ui/StatusBadge';
import ResourceFormModal from '../components/resources/ResourceFormModal';
import ResourceWorkingHoursPanel from '../components/resources/ResourceWorkingHoursPanel';
@@ -9,6 +10,7 @@ import ResourceExceptionsPanel from '../components/resources/ResourceExceptionsP
import ResourceServicesPanel from '../components/resources/ResourceServicesPanel';
import ResourceSkillsPanel from '../components/resources/ResourceSkillsPanel';
import ResourceCategoriesPanel from '../components/resources/ResourceCategoriesPanel';
import ResourceBlocksPanel from '../components/resources/ResourceBlocksPanel';
import { useUrlState } from '../hooks/useUrlState';
import { usePermissions } from '../hooks/usePermissions';
import { useAddresses } from '../hooks/useAddresses';
@@ -23,6 +25,7 @@ const TABS = [
{ id: 'services', label: 'سرویس‌ها' },
{ id: 'skills', label: 'مهارت‌ها' },
{ id: 'categories', label: 'دسته‌بندی‌ها' },
{ id: 'blocks', label: 'غیرفعال‌سازی موقت' },
] as const;
type TabId = typeof TABS[number]['id'];
@@ -41,6 +44,7 @@ const SUBJECT_LABEL: Record<string, string> = {
*/
export default function ResourceDetailPage() {
const { resourceUuid } = useParams<{ resourceUuid: string }>();
const navigate = useNavigate();
const [urlState, setUrlState] = useUrlState({ tab: 'info' });
const tab = (TABS.some((t) => t.id === urlState.tab) ? urlState.tab : 'info') as TabId;
@@ -48,13 +52,14 @@ export default function ResourceDetailPage() {
const { addresses } = useAddresses();
const { types } = useResourceTypes();
const { skills } = useSkills();
const { update, setSkills, setCategories } = useResources();
const { update, remove, setSkills, setCategories } = useResources();
const { offerings, save: saveServices } = useResourceServices(resourceUuid);
const { items: serviceOptions } = useAllServiceItems();
const { can } = usePermissions();
const canUpdate = can('appointment_settings', 'update');
const [editOpen, setEditOpen] = useState(false);
const [confirmDelete, setConfirmDelete] = useState(false);
// حالت‌های بارگذاری و نبودِ منبع هم داخل پوستهٔ تنظیمات می‌مانند، وگرنه منوی کناری
// یک لحظه می‌پرد و دوباره برمی‌گردد.
@@ -86,11 +91,12 @@ export default function ResourceDetailPage() {
<ActiveBadge active={resource.active} />
</div>
<div className="seg" style={{ marginBottom: 'var(--gap)', overflowX: 'auto', flexWrap: 'nowrap' }}>
<div className="seg" role="group" aria-label="تنظیمات منبع" style={{ marginBottom: 'var(--gap)', overflowX: 'auto', flexWrap: 'nowrap' }}>
{TABS.map((t) => (
<button
key={t.id}
className={tab === t.id ? 'active' : ''}
aria-pressed={tab === t.id}
style={{ whiteSpace: 'nowrap' }}
onClick={() => setUrlState({ tab: t.id })}
>
@@ -99,7 +105,15 @@ export default function ResourceDetailPage() {
))}
</div>
{tab === 'info' && <InfoTab resource={resource} />}
{tab === 'info' && (
<InfoTab
resource={resource}
canUpdate={canUpdate}
toggling={update.isPending}
onToggleActive={() => update.mutate({ uuid: resource.uuid, d: { active: !resource.active } })}
onDelete={() => setConfirmDelete(true)}
/>
)}
{tab === 'hours' && <ResourceWorkingHoursPanel resourceUuid={resourceUuid} canUpdate={canUpdate} />}
@@ -137,6 +151,27 @@ export default function ResourceDetailPage() {
/>
)}
{tab === 'blocks' && <ResourceBlocksPanel resourceUuid={resourceUuid} canUpdate={canUpdate} />}
{/* حذف موفق یعنی این صفحه دیگر منبعی ندارد؛ ماندن روی آن یک «منبع یافت نشد» است. */}
<ConfirmDialog
open={confirmDelete}
title="حذف منبع"
message={`آیا از حذف «${resource.name}» مطمئن هستید؟ این کار برگشت‌پذیر نیست.`}
confirmLabel="حذف"
danger
loading={remove.isPending}
onConfirm={() =>
remove.mutate(resource.uuid, {
onSuccess: () => {
setConfirmDelete(false);
navigate('/admin/resources');
},
})
}
onCancel={() => setConfirmDelete(false)}
/>
<ResourceFormModal
open={editOpen}
resource={resource}
@@ -164,10 +199,24 @@ function Row({ label, children }: { label: string; children: React.ReactNode })
);
}
function InfoTab({ resource }: { resource: ClinicResource }) {
/**
* اطلاعات منبع + دو اقدامِ سطح-منبع.
*
* فعال/غیرفعال و حذف پیش‌تر دکمه‌های ردیفِ جدول بودند. جدول حالا فقط فهرست است و
* هر اقدام کنارِ همان چیزی نشسته که تغییرش می‌دهد؛ حذف هم پایین و جدا از بقیه است
* تا با «ویرایش» و «غیرفعال» یک‌جا هم‌وزن دیده نشود.
*/
function InfoTab({ resource, canUpdate, toggling, onToggleActive, onDelete }: {
resource: ClinicResource;
canUpdate: boolean;
toggling: boolean;
onToggleActive: () => void;
onDelete: () => void;
}) {
const attributes = Object.entries(resource.attributes ?? {});
return (
<div style={{ display: 'grid', gap: 'var(--gap)' }}>
<div className="card card-pad">
<Row label="شعبه">{resource.address_name || '—'}</Row>
<Row label="نوع منبع">{resource.type_name}</Row>
@@ -199,5 +248,47 @@ function InfoTab({ resource }: { resource: ClinicResource }) {
<Row key={key} label={key}>{String(value)}</Row>
))}
</div>
{canUpdate && (
<div className="card card-pad">
<h2 style={{ fontSize: 16, fontWeight: 700, color: 'var(--text)', marginBottom: 4 }}>
وضعیت منبع
</h2>
<p className="field-hint" style={{ marginTop: 0, marginBottom: 14 }}>
منبع غیرفعال در نوبتدهی پیشنهاد نمیشود. برای بستن فقط یک بازهٔ مشخص، از تب
«غیرفعالسازی موقت» استفاده کنید.
</p>
<div className="toggle-row">
<div>
<div className="tr-title">{resource.active ? 'این منبع فعال است' : 'این منبع غیرفعال است'}</div>
<div className="tr-desc">
{resource.active
? 'در جستجوی وقت و رزرو نوبت در دسترس است.'
: 'در جستجوی وقت و رزرو نوبت ظاهر نمی‌شود.'}
</div>
</div>
<button
type="button"
className={resource.active ? 'btn secondary' : 'btn primary'}
disabled={toggling}
onClick={onToggleActive}
>
{resource.active ? 'غیرفعال کردن' : 'فعال کردن'}
</button>
</div>
{/* پس‌زمینه عمداً خنثی می‌ماند: `.btn.danger` خودش `--danger-bg` است و روی
ردیفِ هم‌رنگ نامرئی می‌شد. مرزِ قرمز به‌تنهایی هشدار را می‌رساند. */}
<div className="toggle-row" style={{ marginTop: 10, borderColor: 'var(--danger)' }}>
<div>
<div className="tr-title">حذف منبع</div>
<div className="tr-desc">برگشتپذیر نیست. اگر فقط موقتاً لازمش ندارید، غیرفعالش کنید.</div>
</div>
<button type="button" className="btn danger" onClick={onDelete}>حذف</button>
</div>
</div>
)}
</div>
);
}
+18
View File
@@ -107,4 +107,22 @@ describe('ResourcesPage', () => {
// شعبه و نوع هنوز انتخاب نشده‌اند → ذخیره غیرفعال است.
expect(screen.getByText('ذخیره').closest('button')).toBeDisabled();
});
/**
* جدول فقط فهرست است. هر اقدامِ سطح-منبع تبی در خودِ منبع دارد، پس ردیف یک لینک
* دارد نه شش دکمه — که هم جدول را از لبه بیرون می‌زد، هم یک کار را دو جا می‌گذاشت.
*/
it('هر ردیف فقط یک اقدام دارد: مدیریتِ همان منبع', async () => {
mockApi();
renderWithProviders(<ResourcesPage />, { route: '/admin/resources' });
await waitFor(() => expect(screen.getByText('لیزر ۱')).toBeInTheDocument());
expect(screen.getAllByRole('link', { name: 'مدیریت' })[0])
.toHaveAttribute('href', '/admin/resources/r1');
for (const gone of ['ویرایش', 'مهارت‌ها', 'سرویس‌ها', 'مسدودسازی', 'حذف', 'تنظیمات']) {
expect(screen.queryByRole('button', { name: gone })).not.toBeInTheDocument();
}
});
});
+18 -95
View File
@@ -3,18 +3,13 @@ import { Link } from 'react-router-dom';
import { PlusIcon } from '@heroicons/react/24/outline';
import PageHeader from '../components/ui/PageHeader';
import DataTable, { type Column } from '../components/ui/DataTable';
import ResourceBlocksModal from '../components/ResourceBlocksModal';
import ConfirmDialog from '../components/ui/ConfirmDialog';
import SearchableSelect from '../components/ui/SearchableSelect';
import { ActiveBadge } from '../components/ui/StatusBadge';
import { useUrlState } from '../hooks/useUrlState';
import { usePermissions } from '../hooks/usePermissions';
import { useAddresses } from '../hooks/useAddresses';
import { useResourceServices, useResources, useResourceTypes, useSkills } from '../hooks/useResources';
import { useResources, useResourceTypes, useSkills } from '../hooks/useResources';
import ResourceFormModal from '../components/resources/ResourceFormModal';
import ResourceSkillsModal from '../components/resources/ResourceSkillsModal';
import ResourceServicesModal from '../components/resources/ResourceServicesModal';
import { useAllServiceItems } from '../hooks/useServiceCatalog';
import type { ClinicResource } from '../types';
import ResourcesSubNav from '../components/resources/ResourcesSubNav';
@@ -43,21 +38,15 @@ export default function ResourcesPage() {
const { can } = usePermissions();
const canUpdate = can('appointment_settings', 'update');
const { resources, loading, create, update, remove, setSkills } = useResources({
const { resources, loading, create } = useResources({
address_uuid: urlState.address || undefined,
type_uuid: urlState.type || undefined,
skill_uuid: urlState.skill || undefined,
active: urlState.status || undefined,
});
const [editing, setEditing] = useState<{ open: boolean; resource: ClinicResource | null }>({ open: false, resource: null });
const [skillsFor, setSkillsFor] = useState<ClinicResource | null>(null);
const [servicesFor, setServicesFor] = useState<ClinicResource | null>(null);
const [blocksFor, setBlocksFor] = useState<ClinicResource | null>(null);
const [toDelete, setToDelete] = useState<ClinicResource | null>(null);
const { offerings, save: saveServices } = useResourceServices(servicesFor?.uuid);
const { items: serviceOptions } = useAllServiceItems();
// ساخت تنها کاری است که به منبعِ موجود گره نمی‌خورد، پس تنها مودالی است که می‌ماند.
const [createOpen, setCreateOpen] = useState(false);
const rows = useMemo(() => {
const q = urlState.search.trim();
@@ -119,7 +108,7 @@ export default function ResourcesPage() {
description="هر چیزی که ممکن است اشغال باشد: پزشک، اپراتور، اتاق، دستگاه. ظرفیت یعنی تعداد بیمار هم‌زمان."
action={
canUpdate ? (
<button type="button" className="btn primary" onClick={() => setEditing({ open: true, resource: null })}>
<button type="button" className="btn primary" onClick={() => setCreateOpen(true)}>
<PlusIcon style={{ width: 16 }} /> افزودن منبع
</button>
) : undefined
@@ -183,90 +172,24 @@ export default function ResourcesPage() {
</div>
</div>
}
actions={
canUpdate
? (r) => (
<div style={{ display: 'flex', gap: 6 }}>
<button type="button" className="btn secondary sm" onClick={() => setEditing({ open: true, resource: r })}>
ویرایش
</button>
<button type="button" className="btn secondary sm" onClick={() => setSkillsFor(r)}>
مهارتها
</button>
<button type="button" className="btn secondary sm" onClick={() => setServicesFor(r)}>
سرویسها
</button>
<Link className="btn secondary sm" to={`/admin/resources/${r.uuid}`}>
تنظیمات
</Link>
{/* مسدودسازی موردی از تقویم جداست: آن الگوی کاری را عوض می‌کند،
این یک بازهٔ مشخص را می‌بندد. */}
<button type="button" className="btn secondary sm" onClick={() => setBlocksFor(r)}>
مسدودسازی
</button>
<button type="button" className="btn secondary sm" onClick={() => setToDelete(r)}>
حذف
</button>
</div>
)
: undefined
}
/>
<ResourceBlocksModal
resourceUuid={blocksFor?.uuid ?? null}
resourceName={blocksFor?.name ?? ''}
onClose={() => setBlocksFor(null)}
/* یک اقدام، نه شش‌تا: ویرایش/مهارت‌ها/سرویس‌ها/مسدودسازی/حذف همگی تب‌های
خودِ منبع‌اند. شش دکمه در هر ردیف، جدول را از لبه بیرون می‌زد و همان کار
را دو جای مختلف (مودالِ ردیف و تبِ منبع) قابل انجام می‌کرد. */
actions={(r) => (
<Link className="btn secondary sm" to={`/admin/resources/${r.uuid}`}>
مدیریت
</Link>
)}
/>
<ResourceFormModal
open={editing.open}
resource={editing.resource}
open={createOpen}
resource={null}
addresses={addresses}
types={types}
saving={create.isPending || update.isPending}
onClose={() => setEditing({ open: false, resource: null })}
onSave={(payload) => {
const opts = { onSuccess: () => setEditing({ open: false, resource: null }) };
if (editing.resource) update.mutate({ uuid: editing.resource.uuid, d: payload }, opts);
else create.mutate(payload, opts);
}}
/>
<ResourceSkillsModal
resource={skillsFor}
skills={skills}
saving={setSkills.isPending}
onClose={() => setSkillsFor(null)}
onSave={(lines) =>
skillsFor &&
setSkills.mutate({ uuid: skillsFor.uuid, skills: lines }, { onSuccess: () => setSkillsFor(null) })
}
/>
<ResourceServicesModal
resource={servicesFor}
offerings={offerings}
services={serviceOptions}
saving={saveServices.isPending}
onClose={() => setServicesFor(null)}
onSave={(lines) =>
servicesFor &&
saveServices.mutate(
{ uuid: servicesFor.uuid, services: lines },
{ onSuccess: () => setServicesFor(null) },
)
}
/>
<ConfirmDialog
open={!!toDelete}
title="حذف منبع"
message={`آیا از حذف «${toDelete?.name}» مطمئن هستید؟`}
confirmLabel="حذف"
loading={remove.isPending}
onConfirm={() => toDelete && remove.mutate(toDelete.uuid, { onSuccess: () => setToDelete(null) })}
onCancel={() => setToDelete(null)}
saving={create.isPending}
onClose={() => setCreateOpen(false)}
onSave={(payload) => create.mutate(payload, { onSuccess: () => setCreateOpen(false) })}
/>
</div>
);