Branches and rooms are not part of the resource-first product: a room is a
resource like any other, and the only thing the branch pages still managed —
opening hours — duplicated the resource's own shift.
What could not go is the address. Every appointment carries address_id (75 of
75 rows), the public booking site reads /clinic-pro/doctor-address/{id}, and a
resource derives its tenant pair from the address it belongs to. So
DoctorAddress stays as an invisible anchor with no page and no menu entry, and
GET /api/v1/addresses replaces GET /api/v1/branches for the forms that still
need to say "where".
BranchResolver was likewise not a branch feature. doctor_addresses is a global
table, so TenantFilter does not cover it and eight callers across booking,
availability, pricing and the catalog went through this resolver to avoid
leaking another clinic's address. It moved to Doctor\Service\AddressResolver
rather than dying with the domain.
The availability engine loses one layer: a resource's real hours were the
branch hours intersected with its shift, and are now the shift alone. That is
the single behavioural change, and the three tests that asserted the old
contract are replaced by one that states the new one.
Rooms already had a resource row each; the migration drops only the bridge
back to `rooms`, and drops it before the table — that foreign key is ON DELETE
CASCADE and the other order would take the resources, and their appointments,
with it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
229 lines
9.5 KiB
TypeScript
229 lines
9.5 KiB
TypeScript
import React, { useEffect, useState } from 'react';
|
|
import Modal from '../ui/Modal';
|
|
import SearchableSelect from '../ui/SearchableSelect';
|
|
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 [name, setName] = useState('');
|
|
const [addressUuid, setAddressUuid] = useState<string | null>(null);
|
|
const [typeUuid, setTypeUuid] = 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);
|
|
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() === '' ||
|
|
(!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,
|
|
};
|
|
|
|
// شعبه و نوع فقط هنگام ساخت فرستاده میشوند؛ جفت محیطِ منبع از آدرس مشتق شده و
|
|
// جابهجا کردنش یعنی همان منبع در محیط دیگری ظاهر شود.
|
|
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>
|
|
</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>
|
|
);
|
|
}
|