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:
@@ -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() } }));
|
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;
|
const resource = { uuid: 'r-1', name: 'دستگاه شمارهٔ ۲' } as never;
|
||||||
|
|
||||||
@@ -35,15 +35,19 @@ function props(overrides: Record<string, unknown> = {}) {
|
|||||||
offerings: offerings as never,
|
offerings: offerings as never,
|
||||||
services: services as never,
|
services: services as never,
|
||||||
saving: false,
|
saving: false,
|
||||||
onClose: vi.fn(),
|
onCancel: vi.fn(),
|
||||||
onSave: vi.fn(),
|
onSave: vi.fn(),
|
||||||
...overrides,
|
...overrides,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('ResourceServicesModal', () => {
|
/**
|
||||||
|
* پنل مستقیماً تست میشود، نه از راه مودال: مودالِ فهرست منابع حذف شد چون همان کار
|
||||||
|
* را تبِ «سرویسها»ی خودِ منبع میکند. منطقِ ارثبری/override همان است و باید بماند.
|
||||||
|
*/
|
||||||
|
describe('ResourceServicesPanel', () => {
|
||||||
it('نشان میدهد مقدار ارثبریشده از کجا آمده', () => {
|
it('نشان میدهد مقدار ارثبریشده از کجا آمده', () => {
|
||||||
renderWithProviders(<ResourceServicesModal {...props()} />);
|
renderWithProviders(<ResourceServicesPanel {...props()} />);
|
||||||
|
|
||||||
// مدت را خودِ منبع گفته، پس در خانه است.
|
// مدت را خودِ منبع گفته، پس در خانه است.
|
||||||
expect(screen.getByDisplayValue('15')).toBeInTheDocument();
|
expect(screen.getByDisplayValue('15')).toBeInTheDocument();
|
||||||
@@ -55,7 +59,7 @@ describe('ResourceServicesModal', () => {
|
|||||||
|
|
||||||
it('override را ذخیره میکند', async () => {
|
it('override را ذخیره میکند', async () => {
|
||||||
const onSave = vi.fn();
|
const onSave = vi.fn();
|
||||||
renderWithProviders(<ResourceServicesModal {...props({ onSave })} />);
|
renderWithProviders(<ResourceServicesPanel {...props({ onSave })} />);
|
||||||
|
|
||||||
const price = screen.getByPlaceholderText(/پیشفرض سرویس/);
|
const price = screen.getByPlaceholderText(/پیشفرض سرویس/);
|
||||||
await userEvent.type(price, '9500000');
|
await userEvent.type(price, '9500000');
|
||||||
@@ -68,7 +72,7 @@ describe('ResourceServicesModal', () => {
|
|||||||
|
|
||||||
it('پاککردن مقدار یعنی بازگشت به ارث، نه صفر', async () => {
|
it('پاککردن مقدار یعنی بازگشت به ارث، نه صفر', async () => {
|
||||||
const onSave = vi.fn();
|
const onSave = vi.fn();
|
||||||
renderWithProviders(<ResourceServicesModal {...props({ onSave })} />);
|
renderWithProviders(<ResourceServicesPanel {...props({ onSave })} />);
|
||||||
|
|
||||||
await userEvent.clear(screen.getByDisplayValue('15'));
|
await userEvent.clear(screen.getByDisplayValue('15'));
|
||||||
await userEvent.click(screen.getByRole('button', { name: 'ذخیره' }));
|
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -49,8 +49,11 @@ export function useResources(filters: ResourceFilters = {}) {
|
|||||||
onError: (e) => fail(e, 'افزودن منبع ناموفق بود'),
|
onError: (e) => fail(e, 'افزودن منبع ناموفق بود'),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// PATCH است و سرور هم واقعاً جزئی رفتار میکند (`array_key_exists` روی هر فیلد)،
|
||||||
|
// پس `Partial`: تغییر فقط `active` نباید کلاینت را مجبور کند `name` را هم بفرستد
|
||||||
|
// — فرستادنِ نامِ کهنه، تغییرِ همزمانِ نام را بازمیگرداند.
|
||||||
const update = useMutation({
|
const update = useMutation({
|
||||||
mutationFn: ({ uuid, d }: { uuid: string; d: ResourcePayload }) =>
|
mutationFn: ({ uuid, d }: { uuid: string; d: Partial<ResourcePayload> }) =>
|
||||||
api.patch<ApiResponse<ClinicResource>>(`/api/v1/resource/${uuid}`, d),
|
api.patch<ApiResponse<ClinicResource>>(`/api/v1/resource/${uuid}`, d),
|
||||||
onSuccess: () => { toast.success('منبع بهروزرسانی شد'); invalidate(); },
|
onSuccess: () => { toast.success('منبع بهروزرسانی شد'); invalidate(); },
|
||||||
onError: (e) => fail(e, 'بهروزرسانی منبع ناموفق بود'),
|
onError: (e) => fail(e, 'بهروزرسانی منبع ناموفق بود'),
|
||||||
|
|||||||
@@ -247,4 +247,48 @@ describe('ResourceDetailPage', () => {
|
|||||||
expect(screen.queryByRole('button', { name: /افزودن دستهبندی جدید/ })).not.toBeInTheDocument();
|
expect(screen.queryByRole('button', { name: /افزودن دستهبندی جدید/ })).not.toBeInTheDocument();
|
||||||
expect(screen.getByText('تنظیمات ← دستهبندیها')).toHaveAttribute('href', '/admin/service-categories');
|
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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import React, { useState } from 'react';
|
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 { PencilIcon } from '@heroicons/react/24/outline';
|
||||||
import PageHeader from '../components/ui/PageHeader';
|
import PageHeader from '../components/ui/PageHeader';
|
||||||
|
import ConfirmDialog from '../components/ui/ConfirmDialog';
|
||||||
import { ActiveBadge } from '../components/ui/StatusBadge';
|
import { ActiveBadge } from '../components/ui/StatusBadge';
|
||||||
import ResourceFormModal from '../components/resources/ResourceFormModal';
|
import ResourceFormModal from '../components/resources/ResourceFormModal';
|
||||||
import ResourceWorkingHoursPanel from '../components/resources/ResourceWorkingHoursPanel';
|
import ResourceWorkingHoursPanel from '../components/resources/ResourceWorkingHoursPanel';
|
||||||
@@ -9,6 +10,7 @@ import ResourceExceptionsPanel from '../components/resources/ResourceExceptionsP
|
|||||||
import ResourceServicesPanel from '../components/resources/ResourceServicesPanel';
|
import ResourceServicesPanel from '../components/resources/ResourceServicesPanel';
|
||||||
import ResourceSkillsPanel from '../components/resources/ResourceSkillsPanel';
|
import ResourceSkillsPanel from '../components/resources/ResourceSkillsPanel';
|
||||||
import ResourceCategoriesPanel from '../components/resources/ResourceCategoriesPanel';
|
import ResourceCategoriesPanel from '../components/resources/ResourceCategoriesPanel';
|
||||||
|
import ResourceBlocksPanel from '../components/resources/ResourceBlocksPanel';
|
||||||
import { useUrlState } from '../hooks/useUrlState';
|
import { useUrlState } from '../hooks/useUrlState';
|
||||||
import { usePermissions } from '../hooks/usePermissions';
|
import { usePermissions } from '../hooks/usePermissions';
|
||||||
import { useAddresses } from '../hooks/useAddresses';
|
import { useAddresses } from '../hooks/useAddresses';
|
||||||
@@ -23,6 +25,7 @@ const TABS = [
|
|||||||
{ id: 'services', label: 'سرویسها' },
|
{ id: 'services', label: 'سرویسها' },
|
||||||
{ id: 'skills', label: 'مهارتها' },
|
{ id: 'skills', label: 'مهارتها' },
|
||||||
{ id: 'categories', label: 'دستهبندیها' },
|
{ id: 'categories', label: 'دستهبندیها' },
|
||||||
|
{ id: 'blocks', label: 'غیرفعالسازی موقت' },
|
||||||
] as const;
|
] as const;
|
||||||
type TabId = typeof TABS[number]['id'];
|
type TabId = typeof TABS[number]['id'];
|
||||||
|
|
||||||
@@ -41,6 +44,7 @@ const SUBJECT_LABEL: Record<string, string> = {
|
|||||||
*/
|
*/
|
||||||
export default function ResourceDetailPage() {
|
export default function ResourceDetailPage() {
|
||||||
const { resourceUuid } = useParams<{ resourceUuid: string }>();
|
const { resourceUuid } = useParams<{ resourceUuid: string }>();
|
||||||
|
const navigate = useNavigate();
|
||||||
const [urlState, setUrlState] = useUrlState({ tab: 'info' });
|
const [urlState, setUrlState] = useUrlState({ tab: 'info' });
|
||||||
const tab = (TABS.some((t) => t.id === urlState.tab) ? urlState.tab : 'info') as TabId;
|
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 { addresses } = useAddresses();
|
||||||
const { types } = useResourceTypes();
|
const { types } = useResourceTypes();
|
||||||
const { skills } = useSkills();
|
const { skills } = useSkills();
|
||||||
const { update, setSkills, setCategories } = useResources();
|
const { update, remove, setSkills, setCategories } = useResources();
|
||||||
const { offerings, save: saveServices } = useResourceServices(resourceUuid);
|
const { offerings, save: saveServices } = useResourceServices(resourceUuid);
|
||||||
const { items: serviceOptions } = useAllServiceItems();
|
const { items: serviceOptions } = useAllServiceItems();
|
||||||
const { can } = usePermissions();
|
const { can } = usePermissions();
|
||||||
const canUpdate = can('appointment_settings', 'update');
|
const canUpdate = can('appointment_settings', 'update');
|
||||||
|
|
||||||
const [editOpen, setEditOpen] = useState(false);
|
const [editOpen, setEditOpen] = useState(false);
|
||||||
|
const [confirmDelete, setConfirmDelete] = useState(false);
|
||||||
|
|
||||||
// حالتهای بارگذاری و نبودِ منبع هم داخل پوستهٔ تنظیمات میمانند، وگرنه منوی کناری
|
// حالتهای بارگذاری و نبودِ منبع هم داخل پوستهٔ تنظیمات میمانند، وگرنه منوی کناری
|
||||||
// یک لحظه میپرد و دوباره برمیگردد.
|
// یک لحظه میپرد و دوباره برمیگردد.
|
||||||
@@ -86,11 +91,12 @@ export default function ResourceDetailPage() {
|
|||||||
<ActiveBadge active={resource.active} />
|
<ActiveBadge active={resource.active} />
|
||||||
</div>
|
</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) => (
|
{TABS.map((t) => (
|
||||||
<button
|
<button
|
||||||
key={t.id}
|
key={t.id}
|
||||||
className={tab === t.id ? 'active' : ''}
|
className={tab === t.id ? 'active' : ''}
|
||||||
|
aria-pressed={tab === t.id}
|
||||||
style={{ whiteSpace: 'nowrap' }}
|
style={{ whiteSpace: 'nowrap' }}
|
||||||
onClick={() => setUrlState({ tab: t.id })}
|
onClick={() => setUrlState({ tab: t.id })}
|
||||||
>
|
>
|
||||||
@@ -99,7 +105,15 @@ export default function ResourceDetailPage() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</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} />}
|
{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
|
<ResourceFormModal
|
||||||
open={editOpen}
|
open={editOpen}
|
||||||
resource={resource}
|
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 ?? {});
|
const attributes = Object.entries(resource.attributes ?? {});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<div style={{ display: 'grid', gap: 'var(--gap)' }}>
|
||||||
<div className="card card-pad">
|
<div className="card card-pad">
|
||||||
<Row label="شعبه">{resource.address_name || '—'}</Row>
|
<Row label="شعبه">{resource.address_name || '—'}</Row>
|
||||||
<Row label="نوع منبع">{resource.type_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>
|
<Row key={key} label={key}>{String(value)}</Row>
|
||||||
))}
|
))}
|
||||||
</div>
|
</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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -107,4 +107,22 @@ describe('ResourcesPage', () => {
|
|||||||
// شعبه و نوع هنوز انتخاب نشدهاند → ذخیره غیرفعال است.
|
// شعبه و نوع هنوز انتخاب نشدهاند → ذخیره غیرفعال است.
|
||||||
expect(screen.getByText('ذخیره').closest('button')).toBeDisabled();
|
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();
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,18 +3,13 @@ import { Link } from 'react-router-dom';
|
|||||||
import { PlusIcon } from '@heroicons/react/24/outline';
|
import { PlusIcon } from '@heroicons/react/24/outline';
|
||||||
import PageHeader from '../components/ui/PageHeader';
|
import PageHeader from '../components/ui/PageHeader';
|
||||||
import DataTable, { type Column } from '../components/ui/DataTable';
|
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 SearchableSelect from '../components/ui/SearchableSelect';
|
||||||
import { ActiveBadge } from '../components/ui/StatusBadge';
|
import { ActiveBadge } from '../components/ui/StatusBadge';
|
||||||
import { useUrlState } from '../hooks/useUrlState';
|
import { useUrlState } from '../hooks/useUrlState';
|
||||||
import { usePermissions } from '../hooks/usePermissions';
|
import { usePermissions } from '../hooks/usePermissions';
|
||||||
import { useAddresses } from '../hooks/useAddresses';
|
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 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 type { ClinicResource } from '../types';
|
||||||
import ResourcesSubNav from '../components/resources/ResourcesSubNav';
|
import ResourcesSubNav from '../components/resources/ResourcesSubNav';
|
||||||
|
|
||||||
@@ -43,21 +38,15 @@ export default function ResourcesPage() {
|
|||||||
const { can } = usePermissions();
|
const { can } = usePermissions();
|
||||||
const canUpdate = can('appointment_settings', 'update');
|
const canUpdate = can('appointment_settings', 'update');
|
||||||
|
|
||||||
const { resources, loading, create, update, remove, setSkills } = useResources({
|
const { resources, loading, create } = useResources({
|
||||||
address_uuid: urlState.address || undefined,
|
address_uuid: urlState.address || undefined,
|
||||||
type_uuid: urlState.type || undefined,
|
type_uuid: urlState.type || undefined,
|
||||||
skill_uuid: urlState.skill || undefined,
|
skill_uuid: urlState.skill || undefined,
|
||||||
active: urlState.status || 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 [createOpen, setCreateOpen] = useState(false);
|
||||||
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 rows = useMemo(() => {
|
const rows = useMemo(() => {
|
||||||
const q = urlState.search.trim();
|
const q = urlState.search.trim();
|
||||||
@@ -119,7 +108,7 @@ export default function ResourcesPage() {
|
|||||||
description="هر چیزی که ممکن است اشغال باشد: پزشک، اپراتور، اتاق، دستگاه. ظرفیت یعنی تعداد بیمار همزمان."
|
description="هر چیزی که ممکن است اشغال باشد: پزشک، اپراتور، اتاق، دستگاه. ظرفیت یعنی تعداد بیمار همزمان."
|
||||||
action={
|
action={
|
||||||
canUpdate ? (
|
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 }} /> افزودن منبع
|
<PlusIcon style={{ width: 16 }} /> افزودن منبع
|
||||||
</button>
|
</button>
|
||||||
) : undefined
|
) : undefined
|
||||||
@@ -183,90 +172,24 @@ export default function ResourcesPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
actions={
|
/* یک اقدام، نه ششتا: ویرایش/مهارتها/سرویسها/مسدودسازی/حذف همگی تبهای
|
||||||
canUpdate
|
خودِ منبعاند. شش دکمه در هر ردیف، جدول را از لبه بیرون میزد و همان کار
|
||||||
? (r) => (
|
را دو جای مختلف (مودالِ ردیف و تبِ منبع) قابل انجام میکرد. */
|
||||||
<div style={{ display: 'flex', gap: 6 }}>
|
actions={(r) => (
|
||||||
<button type="button" className="btn secondary sm" onClick={() => setEditing({ open: true, resource: r })}>
|
<Link className="btn secondary sm" to={`/admin/resources/${r.uuid}`}>
|
||||||
ویرایش
|
مدیریت
|
||||||
</button>
|
</Link>
|
||||||
<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)}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<ResourceFormModal
|
<ResourceFormModal
|
||||||
open={editing.open}
|
open={createOpen}
|
||||||
resource={editing.resource}
|
resource={null}
|
||||||
addresses={addresses}
|
addresses={addresses}
|
||||||
types={types}
|
types={types}
|
||||||
saving={create.isPending || update.isPending}
|
saving={create.isPending}
|
||||||
onClose={() => setEditing({ open: false, resource: null })}
|
onClose={() => setCreateOpen(false)}
|
||||||
onSave={(payload) => {
|
onSave={(payload) => create.mutate(payload, { onSuccess: () => setCreateOpen(false) })}
|
||||||
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)}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user