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
@@ -1,197 +0,0 @@
import React, { useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
import { TrashIcon } from '@heroicons/react/24/outline';
import Modal from './ui/Modal';
import PersianDateInput from './ui/PersianDateInput';
import { api, ApiError, type ApiResponse } from '../lib/api';
import { formatDate, isoToUnix, unixToIso } from '../lib/utils';
interface Block {
uuid: string;
starts_at: number;
ends_at: number;
segment_name: string | null;
}
interface Props {
resourceUuid: string | null;
resourceName: string;
onClose: () => void;
}
const HOUR = 3600;
/**
* مسدودسازی موردی یک منبع.
*
* عمداً از «استثنای تقویم» جداست و همین‌جا هم گفته می‌شود: آن الگوی کاری منبع را عوض
* می‌کند و ماندگار است، این فقط یک بازهٔ مشخص را می‌بندد. اپراتوری که این تفاوت را
* نداند، تعطیلی یک بعدازظهر را برای همیشه در تقویم ثبت می‌کند.
*/
export default function ResourceBlocksModal({ resourceUuid, resourceName, onClose }: Props) {
const qc = useQueryClient();
const key = ['resource-blocks', resourceUuid];
const [day, setDay] = useState(() => unixToIso(Math.floor(Date.now() / 1000) + 86400));
const [fromHour, setFromHour] = useState(9);
const [toHour, setToHour] = useState(13);
const [reason, setReason] = useState('');
const { data, isLoading } = useQuery({
queryKey: key,
queryFn: () => api.get<ApiResponse<Block[]>>(`/api/v1/resource/${resourceUuid}/blocks`),
enabled: !!resourceUuid,
});
const blocks = data?.data ?? [];
const create = useMutation({
mutationFn: (body: { starts_at: number; ends_at: number; reason: string }) =>
api.post<ApiResponse<Block>>(`/api/v1/resource/${resourceUuid}/blocks`, body),
onSuccess: () => {
toast.success('بازه مسدود شد');
qc.invalidateQueries({ queryKey: key });
setReason('');
},
// ۴۰۹ یعنی آن بازه نوبت دارد؛ پیام سرور دقیقاً می‌گوید اول چه باید کرد.
onError: (e) => toast.error(e instanceof ApiError ? e.message : 'مسدودسازی ناموفق بود'),
});
const remove = useMutation({
mutationFn: (uuid: string) => api.delete<ApiResponse<null>>(`/api/v1/resource-block/${uuid}`),
onSuccess: () => {
toast.success('مسدودسازی برداشته شد');
qc.invalidateQueries({ queryKey: key });
},
onError: (e) => toast.error(e instanceof ApiError ? e.message : 'حذف ناموفق بود'),
});
const dayStart = isoToUnix(day) ?? 0;
const rangeInvalid = toHour <= fromHour;
return (
<Modal
open={resourceUuid !== null}
title={`مسدودسازی موردی — ${resourceName}`}
onClose={onClose}
footer={
<button type="button" className="btn secondary" onClick={onClose}>
بستن
</button>
}
>
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
<p style={{ fontSize: 12, color: 'var(--text-3)', margin: 0, lineHeight: 1.7 }}>
این یک بازهٔ مشخص را میبندد و تا وقتی حذفش نکنید میماند. برای تغییر
<strong> الگوی کاری </strong>
منبع (مثلاً «پنجشنبهها تعطیل») از استثنای تقویم استفاده کنید، نه از اینجا.
</p>
<div style={{ display: 'flex', gap: 10, flexWrap: 'wrap', alignItems: 'flex-end' }}>
<div className="field-block" style={{ minWidth: 170, margin: 0 }}>
<label>روز</label>
<PersianDateInput value={day} onChange={setDay} />
</div>
<label style={{ fontSize: 12, display: 'flex', alignItems: 'center', gap: 4 }}>
از ساعت
<input
className="input"
style={{ width: 70 }}
type="number"
min={0}
max={23}
value={fromHour}
onChange={(e) => setFromHour(Number(e.target.value))}
/>
</label>
<label style={{ fontSize: 12, display: 'flex', alignItems: 'center', gap: 4 }}>
تا ساعت
<input
className="input"
style={{ width: 70 }}
type="number"
min={1}
max={24}
value={toHour}
onChange={(e) => setToHour(Number(e.target.value))}
/>
</label>
</div>
<div className="field-block" style={{ margin: 0 }}>
<label htmlFor="block-reason">دلیل</label>
<input
id="block-reason"
className="input"
value={reason}
onChange={(e) => setReason(e.target.value)}
placeholder="مثلاً: سرویس دوره‌ای دستگاه"
/>
</div>
{rangeInvalid && (
<span style={{ fontSize: 12, color: 'var(--danger)' }}>
ساعت پایان باید بعد از ساعت شروع باشد.
</span>
)}
<div>
<button
type="button"
className="btn primary sm"
disabled={rangeInvalid || dayStart === 0 || create.isPending}
onClick={() =>
create.mutate({
starts_at: dayStart + fromHour * HOUR,
ends_at: dayStart + toHour * HOUR,
reason,
})
}
>
مسدود کن
</button>
</div>
<div style={{ borderTop: '1px solid var(--border)', paddingTop: 12 }}>
<h3 style={{ fontSize: 14, margin: '0 0 10px' }}>مسدودسازیهای فعلی</h3>
{isLoading ? (
<span style={{ fontSize: 13, color: 'var(--text-3)' }}>در حال بارگذاری</span>
) : blocks.length === 0 ? (
<span style={{ fontSize: 13, color: 'var(--text-3)' }}>
این منبع مسدودسازی موردی ندارد.
</span>
) : (
blocks.map((block) => (
<div
key={block.uuid}
style={{ display: 'flex', alignItems: 'center', gap: 10, fontSize: 13, marginBottom: 6 }}
>
<span>{formatDate(block.starts_at)}</span>
<span dir="ltr" style={{ color: 'var(--text-2)' }}>
{new Date(block.starts_at * 1000).toLocaleTimeString('fa-IR', { hour: '2-digit', minute: '2-digit' })}
{' '}
{new Date(block.ends_at * 1000).toLocaleTimeString('fa-IR', { hour: '2-digit', minute: '2-digit' })}
</span>
<span style={{ color: 'var(--text-3)', fontSize: 12 }}>{block.segment_name ?? '—'}</span>
<button
type="button"
className="btn secondary sm"
style={{ marginRight: 'auto' }}
disabled={remove.isPending}
onClick={() => remove.mutate(block.uuid)}
aria-label="برداشتن مسدودسازی"
>
<TrashIcon style={{ width: 15 }} />
</button>
</div>
))
)}
</div>
</div>
</Modal>
);
}
@@ -0,0 +1,201 @@
import React, { useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
import { TrashIcon } from '@heroicons/react/24/outline';
import PersianDateInput from '../ui/PersianDateInput';
import { api, ApiError, type ApiResponse } from '../../lib/api';
import { formatDate, isoToUnix, unixToIso } from '../../lib/utils';
interface Block {
uuid: string;
starts_at: number;
ends_at: number;
segment_name: string | null;
}
const HOUR = 3600;
/**
* غیرفعال‌سازی موقتِ یک منبع — بستن یک بازهٔ زمانی مشخص.
*
* عمداً از «استثنای تقویم» جداست و همین‌جا هم گفته می‌شود: آن الگوی کاری منبع را عوض
* می‌کند و ماندگار است، این فقط یک بازهٔ مشخص را می‌بندد. اپراتوری که این تفاوت را
* نداند، تعطیلی یک بعدازظهر را برای همیشه در تقویم ثبت می‌کند.
*
* پیش‌تر مودالی بود که از ردیف جدولِ منابع باز می‌شد؛ حالا یکی از تب‌های خودِ منبع
* است، کنار بقیهٔ تنظیماتش.
*/
export default function ResourceBlocksPanel({ resourceUuid, canUpdate }: {
resourceUuid?: string;
canUpdate: boolean;
}) {
const qc = useQueryClient();
const key = ['resource-blocks', resourceUuid];
const [day, setDay] = useState(() => unixToIso(Math.floor(Date.now() / 1000) + 86400));
const [fromHour, setFromHour] = useState(9);
const [toHour, setToHour] = useState(13);
const [reason, setReason] = useState('');
const { data, isLoading } = useQuery({
queryKey: key,
queryFn: () => api.get<ApiResponse<Block[]>>(`/api/v1/resource/${resourceUuid}/blocks`),
enabled: !!resourceUuid,
});
const blocks = data?.data ?? [];
const create = useMutation({
mutationFn: (body: { starts_at: number; ends_at: number; reason: string }) =>
api.post<ApiResponse<Block>>(`/api/v1/resource/${resourceUuid}/blocks`, body),
onSuccess: () => {
toast.success('بازه بسته شد');
qc.invalidateQueries({ queryKey: key });
setReason('');
},
// ۴۰۹ یعنی آن بازه نوبت دارد؛ پیام سرور دقیقاً می‌گوید اول چه باید کرد.
onError: (e) => toast.error(e instanceof ApiError ? e.message : 'بستن بازه ناموفق بود'),
});
const remove = useMutation({
mutationFn: (uuid: string) => api.delete<ApiResponse<null>>(`/api/v1/resource-block/${uuid}`),
onSuccess: () => {
toast.success('بازه دوباره باز شد');
qc.invalidateQueries({ queryKey: key });
},
onError: (e) => toast.error(e instanceof ApiError ? e.message : 'حذف ناموفق بود'),
});
const dayStart = isoToUnix(day) ?? 0;
const rangeInvalid = toHour <= fromHour;
return (
<div className="card card-pad">
<h2 style={{ fontSize: 16, fontWeight: 700, color: 'var(--text)' }}>غیرفعالسازی موقت</h2>
<p className="field-hint" style={{ marginTop: 4, marginBottom: 16 }}>
یک بازهٔ مشخص را میبندد و تا وقتی حذفش نکنید میماند. برای تغییر
<strong> الگوی کاری </strong>
منبع (مثلاً «پنجشنبهها تعطیل») از تب «تعطیلات و استثنا» استفاده کنید، نه از اینجا.
</p>
{canUpdate && (
<div style={{ display: 'grid', gap: 12, marginBottom: 18 }}>
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap', alignItems: 'flex-end' }}>
<div className="field-block" style={{ minWidth: 180 }}>
<label>روز</label>
<PersianDateInput value={day} onChange={setDay} ariaLabel="روزِ بازهٔ بسته‌شده" />
</div>
<div className="field-block" style={{ width: 110 }}>
<label htmlFor="block-from">از ساعت</label>
<label className="field">
<input
id="block-from"
type="number"
min={0}
max={23}
value={fromHour}
onChange={(e) => setFromHour(Number(e.target.value))}
/>
</label>
</div>
<div className="field-block" style={{ width: 110 }}>
<label htmlFor="block-to">تا ساعت</label>
<label className="field">
<input
id="block-to"
type="number"
min={1}
max={24}
value={toHour}
onChange={(e) => setToHour(Number(e.target.value))}
/>
</label>
</div>
</div>
<div className="field-block">
<label htmlFor="block-reason">دلیل <span className="opt">(اختیاری)</span></label>
<label className="field">
<input
id="block-reason"
value={reason}
onChange={(e) => setReason(e.target.value)}
placeholder="مثلاً سرویس دوره‌ای دستگاه"
/>
</label>
</div>
{rangeInvalid && (
<p className="field-err" role="alert" style={{ marginTop: 0 }}>
ساعت پایان باید بعد از ساعت شروع باشد.
</p>
)}
<div>
<button
type="button"
className="btn primary"
disabled={rangeInvalid || dayStart === 0 || create.isPending}
onClick={() =>
create.mutate({
starts_at: dayStart + fromHour * HOUR,
ends_at: dayStart + toHour * HOUR,
reason,
})
}
>
{create.isPending ? 'در حال ثبت...' : 'بستن این بازه'}
</button>
</div>
</div>
)}
<div style={{ borderTop: '1px solid var(--border)', paddingTop: 14 }}>
<h3 style={{ fontSize: 14, fontWeight: 700, margin: '0 0 10px' }}>بازههای بستهشده</h3>
{isLoading ? (
<div className="skeleton" style={{ height: 60, borderRadius: 'var(--r-sm)' }} />
) : blocks.length === 0 ? (
<p style={{ fontSize: 13, color: 'var(--text-3)', margin: 0 }}>
این منبع هیچ بازهٔ بستهشدهای ندارد.
</p>
) : (
<div style={{ display: 'grid', gap: 8 }}>
{blocks.map((block) => (
<div
key={block.uuid}
style={{
display: 'flex', alignItems: 'center', gap: 10, fontSize: 13,
padding: '8px 10px', borderRadius: 'var(--r-sm)', background: 'var(--surface-2)',
}}
>
<span style={{ fontWeight: 600 }}>{formatDate(block.starts_at)}</span>
<span dir="ltr" style={{ color: 'var(--text-2)' }}>
{new Date(block.starts_at * 1000).toLocaleTimeString('fa-IR', { hour: '2-digit', minute: '2-digit' })}
{' '}
{new Date(block.ends_at * 1000).toLocaleTimeString('fa-IR', { hour: '2-digit', minute: '2-digit' })}
</span>
<span style={{ color: 'var(--text-3)', fontSize: 12, flex: 1, minWidth: 0 }}>
{block.segment_name ?? '—'}
</span>
{canUpdate && (
<button
type="button"
className="mini-btn danger"
disabled={remove.isPending}
onClick={() => remove.mutate(block.uuid)}
aria-label={`باز کردن بازهٔ ${formatDate(block.starts_at)}`}
>
<TrashIcon style={{ width: 16 }} />
</button>
)}
</div>
))}
</div>
)}
</div>
</div>
);
}
@@ -1,31 +0,0 @@
import React from 'react';
import Modal from '../ui/Modal';
import ResourceServicesPanel, { type ServiceOfferingLine } from './ResourceServicesPanel';
import type { ClinicResource, ResourceServiceOffering, ServiceItemOption } from '../../types';
interface Props {
resource: ClinicResource | null;
offerings: ResourceServiceOffering[];
services: ServiceItemOption[];
saving: boolean;
onClose: () => void;
onSave: (lines: ServiceOfferingLine[]) => void;
}
/** همان پنل سرویس‌ها، در قاب مودالِ فهرست منابع. */
export default function ResourceServicesModal({
resource, offerings, services, saving, onClose, onSave,
}: Props) {
return (
<Modal open={resource !== null} onClose={onClose} title={`سرویس‌های ${resource?.name ?? 'منبع'}`}>
<ResourceServicesPanel
resource={resource}
offerings={offerings}
services={services}
saving={saving}
onCancel={onClose}
onSave={onSave}
/>
</Modal>
);
}
@@ -5,7 +5,7 @@ import { renderWithProviders } from '../../test/utils';
vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
import ResourceServicesModal from './ResourceServicesModal';
import ResourceServicesPanel from './ResourceServicesPanel';
const resource = { uuid: 'r-1', name: 'دستگاه شمارهٔ ۲' } as never;
@@ -35,15 +35,19 @@ function props(overrides: Record<string, unknown> = {}) {
offerings: offerings as never,
services: services as never,
saving: false,
onClose: vi.fn(),
onCancel: vi.fn(),
onSave: vi.fn(),
...overrides,
};
}
describe('ResourceServicesModal', () => {
/**
* پنل مستقیماً تست می‌شود، نه از راه مودال: مودالِ فهرست منابع حذف شد چون همان کار
* را تبِ «سرویس‌ها»ی خودِ منبع می‌کند. منطقِ ارث‌بری/override همان است و باید بماند.
*/
describe('ResourceServicesPanel', () => {
it('نشان می‌دهد مقدار ارث‌بری‌شده از کجا آمده', () => {
renderWithProviders(<ResourceServicesModal {...props()} />);
renderWithProviders(<ResourceServicesPanel {...props()} />);
// مدت را خودِ منبع گفته، پس در خانه است.
expect(screen.getByDisplayValue('15')).toBeInTheDocument();
@@ -55,7 +59,7 @@ describe('ResourceServicesModal', () => {
it('override را ذخیره می‌کند', async () => {
const onSave = vi.fn();
renderWithProviders(<ResourceServicesModal {...props({ onSave })} />);
renderWithProviders(<ResourceServicesPanel {...props({ onSave })} />);
const price = screen.getByPlaceholderText(/پیش‌فرض سرویس/);
await userEvent.type(price, '9500000');
@@ -68,7 +72,7 @@ describe('ResourceServicesModal', () => {
it('پاک‌کردن مقدار یعنی بازگشت به ارث، نه صفر', async () => {
const onSave = vi.fn();
renderWithProviders(<ResourceServicesModal {...props({ onSave })} />);
renderWithProviders(<ResourceServicesPanel {...props({ onSave })} />);
await userEvent.clear(screen.getByDisplayValue('15'));
await userEvent.click(screen.getByRole('button', { name: 'ذخیره' }));
@@ -1,27 +0,0 @@
import React from 'react';
import Modal from '../ui/Modal';
import ResourceSkillsPanel, { type SkillLine } from './ResourceSkillsPanel';
import type { ClinicResource, Skill } from '../../types';
interface Props {
resource: ClinicResource | null;
skills: Skill[];
saving: boolean;
onClose: () => void;
onSave: (lines: SkillLine[]) => void;
}
/** همان پنل مهارت‌ها، در قاب مودالِ فهرست منابع. */
export default function ResourceSkillsModal({ resource, skills, saving, onClose, onSave }: Props) {
return (
<Modal open={resource !== null} onClose={onClose} title={`مهارت‌های ${resource?.name ?? 'منبع'}`}>
<ResourceSkillsPanel
resource={resource}
skills={skills}
saving={saving}
onCancel={onClose}
onSave={onSave}
/>
</Modal>
);
}