Files
clinicpro/assets/admin/components/resources/ResourceFormModal.tsx
T
hamedandClaude Opus 5 ab4974d174 feat(resource): every resource is supervised by a doctor
Supervision now lives on the resource itself instead of being asked for again at
booking time, so one relation answers it everywhere.

The column is deliberately separate from the existing doctor_id bridge. That
bridge means "this resource IS this doctor" and isPerson() uses it to pin
capacity at 1; a supervised three-seat device must not become a person resource.
The FK is SET NULL rather than CASCADE because deleting a doctor should not take
the clinic's laser with it.

Required on create and non-clearable on update, enforced in the API where it can
give a Persian message. Ownership is checked through Clinic::hasDoctor so a
secretary cannot put their device under a doctor of another clinic; that returns
404, not 403, keeping foreign data invisible.

The 13 existing resources are backfilled deterministically: a practice resource
gets its own doctor, a clinic resource gets that clinic's first doctor. Both are
editable from the resource form.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 12:15:42 +03:30

258 lines
11 KiB
TypeScript

import React, { useEffect, useState } from 'react';
import Modal from '../ui/Modal';
import { useQuery } from '@tanstack/react-query';
import SearchableSelect from '../ui/SearchableSelect';
import { api, type ApiResponse } from '../../lib/api';
import type { Branch, 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;
addresses: Branch[];
types: ResourceType[];
saving: boolean;
onClose: () => void;
onSave: (payload: ResourcePayload) => void;
}
export default function ResourceFormModal({
open, resource, addresses, 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 [addressUuid, setAddressUuid] = useState<string | null>(null);
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 ?? '');
setAddressUuid(resource?.address_uuid ?? null);
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 && (!addressUuid || !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.address_uuid = addressUuid!;
payload.type_uuid = typeUuid!;
}
onSave(payload);
};
return (
<Modal open={open} onClose={onClose} title={isEdit ? 'ویرایش منبع' : 'افزودن منبع'} size="lg">
<div style={{ display: 'grid', gap: 14 }}>
<Field label="نام منبع">
<input className="field" value={name} 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={addresses.map((b) => ({ value: b.uuid, label: b.name || 'بدون نام' }))}
value={addressUuid}
onChange={(v) => setAddressUuid(v ? String(v) : null)}
placeholder="شعبه را انتخاب کنید"
isDisabled={isEdit}
height={38}
/>
</Field>
<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 }}>
<Field label="ظرفیت هم‌زمان">
<input className="field" type="number" min={1} value={capacity} onChange={(e) => setCapacity(e.target.value)} />
</Field>
<Field label="آماده‌سازی (دقیقه)">
<input className="field" type="number" min={0} value={setupMinutes} onChange={(e) => setSetupMinutes(e.target.value)} />
</Field>
<Field label="تمیزکاری (دقیقه)">
<input className="field" type="number" min={0} 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' }}>
<label style={{ fontSize: 12, color: 'var(--text-2)' }}>ویژگی‌ها (اختیاری)</label>
<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
className="field"
list="resource-attribute-keys"
value={row.key}
placeholder="gender"
onChange={(e) =>
setAttributes((a) => a.map((r, i) => (i === index ? { ...r, key: e.target.value } : r)))
}
style={{ flex: 1 }}
/>
<input
className="field"
value={row.value}
placeholder="female"
onChange={(e) =>
setAttributes((a) => a.map((r, i) => (i === index ? { ...r, value: e.target.value } : r)))
}
style={{ flex: 1 }}
/>
<button
type="button"
className="btn secondary sm"
onClick={() => setAttributes((a) => a.filter((_, i) => i !== index))}
aria-label="حذف ویژگی"
>
</button>
</div>
))}
<datalist id="resource-attribute-keys">
{KNOWN_ATTRIBUTES.map((k) => <option key={k} value={k} />)}
</datalist>
</div>
<label style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 13 }}>
<input type="checkbox" checked={active} onChange={(e) => setActive(e.target.checked)} />
منبع فعال است
</label>
{/* غیرفعال‌کردن نوبت‌های ثبت‌شده را لغو نمی‌کند؛ فقط از جستجوی وقتِ بعدی حذف
می‌شود. پس این هشدار است نه مانع — ولی اپراتور باید بداند چند بیمار روی
منبعی نوبت دارند که دارد خاموش می‌شود. */}
{!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>
);
}
function Field({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div style={{ display: 'grid', gap: 6 }}>
<label style={{ fontSize: 12, color: 'var(--text-2)' }}>{label}</label>
{children}
</div>
);
}