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>
This commit is contained in:
hamed
2026-08-03 12:15:42 +03:30
co-authored by Claude Opus 5
parent b03ae95bf8
commit ab4974d174
10 changed files with 318 additions and 10 deletions
@@ -1,6 +1,8 @@
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';
@@ -25,9 +27,21 @@ export default function ResourceFormModal({
}: 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');
@@ -39,6 +53,7 @@ export default function ResourceFormModal({
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));
@@ -52,6 +67,7 @@ export default function ResourceFormModal({
const parsedCapacity = Number(capacity);
const invalid =
name.trim() === '' ||
!supervisorUuid ||
(!isEdit && (!addressUuid || !typeUuid)) ||
!Number.isFinite(parsedCapacity) ||
parsedCapacity < 1;
@@ -69,6 +85,7 @@ export default function ResourceFormModal({
cleanup_minutes: Number(cleanupMinutes) || 0,
attributes: attrs,
active,
supervisor_doctor_uuid: supervisorUuid!,
};
// شعبه و نوع فقط هنگام ساخت فرستاده می‌شوند؛ جفت محیطِ منبع از آدرس مشتق شده و
@@ -109,6 +126,18 @@ export default function ResourceFormModal({
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 && (