Files
clinicpro/assets/admin/components/resources/ResourceFormModal.tsx
T
hamed 0e7970d6e0 feat: replace checkboxes with Switch component for better UI consistency
- 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.
2026-08-05 16:09:46 +03:30

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>
);
}