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>
221 lines
8.5 KiB
TypeScript
221 lines
8.5 KiB
TypeScript
import React, { useEffect, useState } from 'react';
|
|
import SearchableSelect from '../ui/SearchableSelect';
|
|
import { formatRial } from '../../lib/utils';
|
|
import type { ClinicResource, ResourceServiceOffering, ServiceItemOption } from '../../types';
|
|
|
|
type Line = {
|
|
service_uuid: string;
|
|
service_name: string;
|
|
duration_minutes: string;
|
|
price_rials: string;
|
|
active: boolean;
|
|
effective_duration_minutes: number | null;
|
|
effective_price_rials: number;
|
|
duration_source: string | null;
|
|
price_source: string;
|
|
};
|
|
|
|
export type ServiceOfferingLine = {
|
|
service_uuid: string;
|
|
duration_minutes: string;
|
|
price_rials: string;
|
|
active: boolean;
|
|
};
|
|
|
|
interface Props {
|
|
resource: ClinicResource | null;
|
|
offerings: ResourceServiceOffering[];
|
|
services: ServiceItemOption[];
|
|
saving: boolean;
|
|
/** وقتی داده نشود دکمهٔ «انصراف» نمایش داده نمیشود — حالت تب. */
|
|
onCancel?: () => void;
|
|
onSave: (lines: ServiceOfferingLine[]) => void;
|
|
}
|
|
|
|
/** برچسب فارسیِ سطحی که مقدار مؤثر از آن آمده. */
|
|
const SOURCE_LABELS: Record<string, string> = {
|
|
resource_option: 'همین منبع',
|
|
resource_service: 'منبع، روی سرویس والد',
|
|
branch: 'تنظیم محل نوبتدهی',
|
|
service_default: 'پیشفرض سرویس',
|
|
};
|
|
|
|
/**
|
|
* سرویسهایی که یک منبع ارائه میدهد، با مدت و قیمت اختصاصی.
|
|
*
|
|
* خالیگذاشتن مدت یا قیمت یعنی «ارث از سطح بالاتر»، نه صفر — به همین دلیل مقدار مؤثر
|
|
* بهصورت placeholder با برچسب منبعش نشان داده میشود، وگرنه کاربر نمیفهمد خانهٔ خالی
|
|
* یعنی «تنظیم نشده» یا «رایگان».
|
|
*
|
|
* ذخیره یک PUT است و **جایگزینی کامل**، همان قرارداد پنل مهارتها.
|
|
*/
|
|
export default function ResourceServicesPanel({
|
|
resource, offerings, services, saving, onCancel, onSave,
|
|
}: Props) {
|
|
const [lines, setLines] = useState<Line[]>([]);
|
|
|
|
useEffect(() => {
|
|
if (!resource) return;
|
|
|
|
setLines(
|
|
offerings.map((o) => ({
|
|
service_uuid: o.service_uuid,
|
|
service_name: o.service_name,
|
|
duration_minutes: o.duration_minutes === null ? '' : String(o.duration_minutes),
|
|
price_rials: o.price_rials === null ? '' : String(o.price_rials),
|
|
active: o.active,
|
|
effective_duration_minutes: o.effective_duration_minutes,
|
|
effective_price_rials: o.effective_price_rials,
|
|
duration_source: o.duration_source,
|
|
price_source: o.price_source,
|
|
})),
|
|
);
|
|
}, [resource, offerings]);
|
|
|
|
const chosen = new Set(lines.map((l) => l.service_uuid));
|
|
const available = services.filter((s) => !chosen.has(s.uuid));
|
|
|
|
const patch = (index: number, changes: Partial<Line>) =>
|
|
setLines((l) => l.map((x, i) => (i === index ? { ...x, ...changes } : x)));
|
|
|
|
return (
|
|
<div style={{ display: 'grid', gap: 14 }}>
|
|
{services.length === 0 && (
|
|
<p style={{ fontSize: 13, color: 'var(--text-3)', margin: 0 }}>
|
|
هنوز هیچ سرویسی تعریف نشده است. اول از صفحهٔ «سرویسها» یکی بسازید.
|
|
</p>
|
|
)}
|
|
|
|
{lines.length === 0 ? (
|
|
<p style={{ fontSize: 13, color: 'var(--text-3)', margin: 0 }}>
|
|
این منبع هیچ سرویسی ارائه نمیدهد.
|
|
</p>
|
|
) : (
|
|
<div style={{ display: 'grid', gap: 10 }}>
|
|
{lines.map((line, index) => (
|
|
<div
|
|
key={line.service_uuid}
|
|
style={{
|
|
display: 'grid',
|
|
gap: 8,
|
|
padding: 10,
|
|
borderRadius: 'var(--r-sm)',
|
|
background: 'var(--surface-2)',
|
|
}}
|
|
>
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
|
<span style={{ flex: 1, fontSize: 13, fontWeight: 600 }}>{line.service_name}</span>
|
|
|
|
<label style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 12, color: 'var(--text-2)' }}>
|
|
<input
|
|
type="checkbox"
|
|
checked={line.active}
|
|
onChange={(e) => patch(index, { active: e.target.checked })}
|
|
/>
|
|
فعال
|
|
</label>
|
|
|
|
<button
|
|
type="button"
|
|
className="btn secondary sm"
|
|
onClick={() => setLines((l) => l.filter((_, i) => i !== index))}
|
|
aria-label={`حذف ${line.service_name}`}
|
|
>
|
|
✕
|
|
</button>
|
|
</div>
|
|
|
|
<div style={{ display: 'flex', gap: 10 }}>
|
|
<div className="field-block" style={{ flex: 1 }}>
|
|
<label style={{ fontSize: 12, color: 'var(--text-2)' }}>مدت (دقیقه)</label>
|
|
<input
|
|
className="field"
|
|
inputMode="numeric"
|
|
value={line.duration_minutes}
|
|
onChange={(e) => patch(index, { duration_minutes: e.target.value })}
|
|
placeholder={
|
|
line.effective_duration_minutes === null
|
|
? 'تعیین نشده'
|
|
: `${line.effective_duration_minutes} — ${SOURCE_LABELS[line.duration_source ?? ''] ?? '—'}`
|
|
}
|
|
/>
|
|
</div>
|
|
|
|
<div className="field-block" style={{ flex: 1 }}>
|
|
<label style={{ fontSize: 12, color: 'var(--text-2)' }}>قیمت (ریال)</label>
|
|
<input
|
|
className="field"
|
|
inputMode="numeric"
|
|
value={line.price_rials}
|
|
onChange={(e) => patch(index, { price_rials: e.target.value })}
|
|
placeholder={`${formatRial(line.effective_price_rials)} — ${SOURCE_LABELS[line.price_source] ?? '—'}`}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{available.length > 0 && (
|
|
<div style={{ display: 'grid', gap: 6 }}>
|
|
<label style={{ fontSize: 12, color: 'var(--text-2)' }}>افزودن سرویس</label>
|
|
<SearchableSelect
|
|
options={available.map((s) => ({ value: s.uuid, label: s.name }))}
|
|
value={null}
|
|
onChange={(v) => {
|
|
const picked = services.find((s) => s.uuid === String(v));
|
|
if (!picked) return;
|
|
|
|
setLines((l) => [
|
|
...l,
|
|
{
|
|
service_uuid: picked.uuid,
|
|
service_name: picked.name,
|
|
duration_minutes: '',
|
|
price_rials: '',
|
|
active: true,
|
|
effective_duration_minutes: picked.duration_minutes ?? null,
|
|
effective_price_rials: picked.price_rials ?? 0,
|
|
duration_source: 'service_default',
|
|
price_source: 'service_default',
|
|
},
|
|
]);
|
|
}}
|
|
placeholder="یک سرویس انتخاب کنید"
|
|
height={38}
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
<p style={{ fontSize: 12, color: 'var(--text-3)', margin: 0 }}>
|
|
خالی گذاشتن مدت یا قیمت یعنی همان مقدارِ سطح بالاتر استفاده شود. ذخیره کل فهرست را
|
|
جایگزین میکند؛ سرویسی که اینجا نباشد از این منبع برداشته میشود.
|
|
</p>
|
|
|
|
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8 }}>
|
|
{onCancel && (
|
|
<button type="button" className="btn secondary" onClick={onCancel}>انصراف</button>
|
|
)}
|
|
<button
|
|
type="button"
|
|
className="btn primary"
|
|
disabled={saving}
|
|
onClick={() =>
|
|
onSave(
|
|
lines.map((l) => ({
|
|
service_uuid: l.service_uuid,
|
|
duration_minutes: l.duration_minutes.trim(),
|
|
price_rials: l.price_rials.trim(),
|
|
active: l.active,
|
|
})),
|
|
)
|
|
}
|
|
>
|
|
{saving ? 'در حال ذخیره...' : 'ذخیره'}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|