- Updated DoctorDetailPage, MySecretariesPage, RecordNumberSettingsPage, RepresentationsPage, ResourcePoolsPage, ResourceTypesPage, SecretariesPage, SecretaryDetailPage, SettingsPage, SkillsPage, SmsWalletPage, and TagsSettingsPage to use the new Switch component instead of native checkboxes. - Enhanced accessibility by ensuring the Switch component uses appropriate roles and labels. - Added tests for the new Switch component to ensure functionality and accessibility compliance. - Updated styles to accommodate the new Switch component design.
253 lines
11 KiB
TypeScript
253 lines
11 KiB
TypeScript
import React, { useEffect, useState } from 'react';
|
|
import { XMarkIcon } from '@heroicons/react/24/outline';
|
|
import Modal from '../ui/Modal';
|
|
import { useQuery } from '@tanstack/react-query';
|
|
import SearchableSelect from '../ui/SearchableSelect';
|
|
import Field from '../ui/Field';
|
|
import Input from '../ui/Input';
|
|
import Switch from '../ui/Switch';
|
|
import { api, type ApiResponse } from '../../lib/api';
|
|
import type { ClinicResource, ResourcePayload, ResourceType } from '../../types';
|
|
import { useResourceDetail } from '../../hooks/useResources';
|
|
import { formatNumber } from '../../lib/utils';
|
|
|
|
/** کلیدهای شناختهشدهٔ ویژگی — قرارداد است نه اجبار؛ سرور هر کلید snake_case را میپذیرد. */
|
|
const KNOWN_ATTRIBUTES = ['gender', 'device_model', 'floor', 'brand'];
|
|
|
|
type AttributeRow = { key: string; value: string };
|
|
|
|
interface Props {
|
|
open: boolean;
|
|
resource: ClinicResource | null;
|
|
types: ResourceType[];
|
|
saving: boolean;
|
|
onClose: () => void;
|
|
onSave: (payload: ResourcePayload) => void;
|
|
}
|
|
|
|
export default function ResourceFormModal({
|
|
open, resource, types, saving, onClose, onSave,
|
|
}: Props) {
|
|
// شمار نوبتهای آینده فقط برای منبعِ موجود معنا دارد و فقط وقتی مودال باز است.
|
|
const { upcomingAppointments: upcoming } = useResourceDetail(open ? resource?.uuid : undefined);
|
|
// پزشکان محیط جاری برای انتخاب ناظر — همان اندپوینت احرازشدهای که صفحهٔ نوبتها
|
|
// استفاده میکند، تا منشی فقط پزشکان مجازش را ببیند.
|
|
const doctorsQuery = useQuery<ApiResponse<{ data: { uuid: string; name: string }[] }>>({
|
|
queryKey: ['booking-doctors'],
|
|
queryFn: () => api.get('/api/v1/my/clinic-doctors'),
|
|
enabled: open,
|
|
staleTime: 60_000,
|
|
});
|
|
const doctors = doctorsQuery.data?.data?.data ?? [];
|
|
const doctorsLoading = doctorsQuery.isLoading;
|
|
|
|
const [name, setName] = useState('');
|
|
const [typeUuid, setTypeUuid] = useState<string | null>(null);
|
|
const [supervisorUuid, setSupervisorUuid] = useState<string | null>(null);
|
|
const [capacity, setCapacity] = useState('1');
|
|
const [setupMinutes, setSetupMinutes] = useState('0');
|
|
const [cleanupMinutes, setCleanupMinutes] = useState('0');
|
|
const [active, setActive] = useState(true);
|
|
const [attributes, setAttributes] = useState<AttributeRow[]>([]);
|
|
|
|
useEffect(() => {
|
|
if (!open) return;
|
|
setName(resource?.name ?? '');
|
|
setTypeUuid(resource?.type_uuid ?? null);
|
|
setSupervisorUuid(resource?.supervisor?.uuid ?? null);
|
|
setCapacity(String(resource?.capacity ?? 1));
|
|
setSetupMinutes(String(resource?.setup_minutes ?? 0));
|
|
setCleanupMinutes(String(resource?.cleanup_minutes ?? 0));
|
|
setActive(resource?.active ?? true);
|
|
setAttributes(
|
|
Object.entries(resource?.attributes ?? {}).map(([key, value]) => ({ key, value: String(value) })),
|
|
);
|
|
}, [open, resource]);
|
|
|
|
const isEdit = resource !== null;
|
|
const parsedCapacity = Number(capacity);
|
|
const invalid =
|
|
name.trim() === '' ||
|
|
!supervisorUuid ||
|
|
(!isEdit && !typeUuid) ||
|
|
!Number.isFinite(parsedCapacity) ||
|
|
parsedCapacity < 1;
|
|
|
|
const submit = () => {
|
|
const attrs: Record<string, string> = {};
|
|
attributes.forEach(({ key, value }) => {
|
|
if (key.trim() !== '') attrs[key.trim()] = value;
|
|
});
|
|
|
|
const payload: ResourcePayload = {
|
|
name: name.trim(),
|
|
capacity: parsedCapacity,
|
|
setup_minutes: Number(setupMinutes) || 0,
|
|
cleanup_minutes: Number(cleanupMinutes) || 0,
|
|
attributes: attrs,
|
|
active,
|
|
supervisor_doctor_uuid: supervisorUuid!,
|
|
};
|
|
|
|
// نوع فقط هنگام ساخت فرستاده میشود؛ جفت محیطِ منبع از آدرسِ محیط مشتق میشود و
|
|
// جابهجا کردنش یعنی همان منبع در محیط دیگری ظاهر شود. آدرس پرسیده نمیشود:
|
|
// منابع دامنهٔ شعبه ندارند و سرور آدرسِ خودِ کلینیک را برمیدارد.
|
|
if (!isEdit) {
|
|
payload.type_uuid = typeUuid!;
|
|
}
|
|
|
|
onSave(payload);
|
|
};
|
|
|
|
return (
|
|
<Modal open={open} onClose={onClose} title={isEdit ? 'ویرایش منبع' : 'افزودن منبع'} size="lg">
|
|
<div style={{ display: 'grid', gap: 14 }}>
|
|
<Field label="نام منبع" htmlFor="resource-name">
|
|
<Input
|
|
id="resource-name"
|
|
value={name}
|
|
autoFocus
|
|
onChange={(e) => setName(e.target.value)}
|
|
placeholder="لیزر آلکساندرایت ۱"
|
|
/>
|
|
</Field>
|
|
|
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))', gap: 12 }}>
|
|
<Field label="نوع منبع">
|
|
<SearchableSelect
|
|
options={types.map((t) => ({ value: t.uuid, label: t.name }))}
|
|
value={typeUuid}
|
|
onChange={(v) => setTypeUuid(v ? String(v) : null)}
|
|
placeholder="نوع را انتخاب کنید"
|
|
isDisabled={isEdit}
|
|
height={38}
|
|
/>
|
|
</Field>
|
|
{/* ناظر برخلاف نوع در ویرایش هم قابل تغییر است: پزشکِ مسئولِ یک دستگاه
|
|
عوض میشود، ولی محیطِ منبع نه. */}
|
|
<Field label="پزشک ناظر">
|
|
<SearchableSelect
|
|
options={doctors.map((d) => ({ value: d.uuid, label: d.name }))}
|
|
value={supervisorUuid}
|
|
onChange={(v) => setSupervisorUuid(v ? String(v) : null)}
|
|
placeholder="پزشک ناظر را انتخاب کنید"
|
|
isLoading={doctorsLoading}
|
|
height={38}
|
|
/>
|
|
</Field>
|
|
</div>
|
|
|
|
{isEdit && (
|
|
<p style={{ fontSize: 12, color: 'var(--text-3)', margin: 0 }}>
|
|
نوع منبع پس از ساخت تغییر نمیکند؛ برای تغییرش، منبع تازه بسازید.
|
|
</p>
|
|
)}
|
|
|
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(160px, 1fr))', gap: 12 }}>
|
|
{/* `numeric` بهجای `type="number"`: ورودیِ عددیِ فارسی در `type="number"`
|
|
نامعتبر است و مرورگر رشتهٔ خالی میدهد — یعنی «۳» تایپشده به ۰ میرسید.
|
|
`Input numeric` ارقام را زنده به لاتین برمیگرداند. */}
|
|
<Field label="ظرفیت همزمان" htmlFor="resource-capacity">
|
|
<Input id="resource-capacity" numeric value={capacity} onChange={(e) => setCapacity(e.target.value)} />
|
|
</Field>
|
|
<Field label="آمادهسازی (دقیقه)" htmlFor="resource-setup">
|
|
<Input id="resource-setup" numeric value={setupMinutes} onChange={(e) => setSetupMinutes(e.target.value)} />
|
|
</Field>
|
|
<Field label="تمیزکاری (دقیقه)" htmlFor="resource-cleanup">
|
|
<Input id="resource-cleanup" numeric value={cleanupMinutes} onChange={(e) => setCleanupMinutes(e.target.value)} />
|
|
</Field>
|
|
</div>
|
|
|
|
<p style={{ fontSize: 12, color: 'var(--text-3)', margin: 0 }}>
|
|
ظرفیت یعنی چند بیمار همزمان — اتاق تزریق سهتخته یک منبع با ظرفیت ۳ است، نه سه منبع.
|
|
آمادهسازی و تمیزکاری جزو نوبت بیمار نیستند ولی منبع را اشغال میکنند.
|
|
</p>
|
|
|
|
<div style={{ display: 'grid', gap: 8 }}>
|
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
|
<span className="cp-label">ویژگیها (اختیاری)</span>
|
|
<button
|
|
type="button"
|
|
className="btn secondary sm"
|
|
onClick={() => setAttributes((a) => [...a, { key: '', value: '' }])}
|
|
>
|
|
افزودن ویژگی
|
|
</button>
|
|
</div>
|
|
|
|
{attributes.map((row, index) => (
|
|
<div key={index} style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
|
<Input
|
|
list="resource-attribute-keys"
|
|
value={row.key}
|
|
placeholder="gender"
|
|
aria-label={`کلید ویژگی ${formatNumber(index + 1)}`}
|
|
onChange={(e) =>
|
|
setAttributes((a) => a.map((r, i) => (i === index ? { ...r, key: e.target.value } : r)))
|
|
}
|
|
style={{ flex: 1 }}
|
|
/>
|
|
<Input
|
|
value={row.value}
|
|
placeholder="female"
|
|
aria-label={`مقدار ویژگی ${formatNumber(index + 1)}`}
|
|
onChange={(e) =>
|
|
setAttributes((a) => a.map((r, i) => (i === index ? { ...r, value: e.target.value } : r)))
|
|
}
|
|
style={{ flex: 1 }}
|
|
/>
|
|
{/* دکمهٔ فقط-آیکون → `mini-btn`، نه `btn secondary sm` با یک ✕ متنی */}
|
|
<button
|
|
type="button"
|
|
className="mini-btn danger"
|
|
onClick={() => setAttributes((a) => a.filter((_, i) => i !== index))}
|
|
aria-label={`حذف ویژگی ${formatNumber(index + 1)}`}
|
|
>
|
|
<XMarkIcon style={{ width: 16 }} />
|
|
</button>
|
|
</div>
|
|
))}
|
|
|
|
<datalist id="resource-attribute-keys">
|
|
{KNOWN_ATTRIBUTES.map((k) => <option key={k} value={k} />)}
|
|
</datalist>
|
|
</div>
|
|
|
|
<Switch
|
|
id="resource-active"
|
|
checked={active}
|
|
onChange={setActive}
|
|
label="منبع فعال است"
|
|
hint="منبع غیرفعال در جستجوی وقت و رزرو نوبت نمیآید."
|
|
/>
|
|
|
|
{/* غیرفعالکردن نوبتهای ثبتشده را لغو نمیکند؛ فقط از جستجوی وقتِ بعدی حذف
|
|
میشود. پس این هشدار است نه مانع — ولی اپراتور باید بداند چند بیمار روی
|
|
منبعی نوبت دارند که دارد خاموش میشود. */}
|
|
{!active && upcoming > 0 && (
|
|
<span
|
|
style={{
|
|
fontSize: 12,
|
|
lineHeight: 1.8,
|
|
color: 'var(--warning)',
|
|
background: 'var(--warning-bg)',
|
|
borderRadius: 'var(--r-sm)',
|
|
padding: '8px 10px',
|
|
}}
|
|
>
|
|
این منبع {formatNumber(upcoming)} نوبت آیندهٔ فعال دارد. غیرفعالکردن آنها را
|
|
لغو نمیکند؛ فقط این منبع دیگر در جستجوی وقت نمیآید.
|
|
</span>
|
|
)}
|
|
|
|
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8, marginTop: 4 }}>
|
|
<button type="button" className="btn secondary" onClick={onClose}>انصراف</button>
|
|
<button type="button" className="btn primary" disabled={saving || invalid} onClick={submit}>
|
|
{saving ? 'در حال ذخیره...' : 'ذخیره'}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</Modal>
|
|
);
|
|
}
|