Files
clinicpro/assets/admin/components/resources/ResourceFormModal.tsx
T
hamedandClaude Opus 5 581a553516 refactor(resource): drop the branch domain from resources
Resources never needed a branch: devices and rooms belong to the clinic
itself, and the picker always had exactly one option — a mandatory click
that decided nothing.

- `address_uuid` is now optional on resource and pool creation; when it is
  missing the environment's own address is used. Clients still sending it
  keep working.
- The panel no longer asks for or displays a branch anywhere: resource
  form, list column and filter, pool form and column, detail row, and the
  resource-first booking page.
- Availability no longer gates on `doctor_addresses.active`. That gate shut
  down every device of a clinic whose address row happened to be inactive,
  with a message no page in the panel could act on — no endpoint writes
  that column at all.

`address_id` stays on the resource: the timezone and the tenant pair are
derived from it. It is simply no longer the user's decision.

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

245 lines
10 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 { 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="نام منبع">
<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={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>
);
}