An appointment can now carry the insurance it is billed with: the service kind (outpatient/inpatient) and the basic insurance. Confirming it no longer hands the whole amount to the patient — the visit is split through BillingCalculator with the coverage percent of that service kind, and the choice travels to the encounter and the invoice built from it. The enabled service kinds are a tenant-wide setting (all of that tenant's insurances share it), so a tenant covering only one kind is never asked which one: the panel resolves it the same way the server does. - add tenant_service_category_settings + TenantServiceCategoryService, exposed on the existing insurance-pricing endpoint (service_categories, default_service_category); at least one kind must stay enabled - add appointments.insurance_service_category / insurance_base_id with AppointmentInsuranceService validating them against the tenant's own settings and active contracts (basic only), accepted by PATCH and by confirm - snapshot the kind on patient_sessions and invoices; the visit's coverage rule is resolved per kind (services keep using their own ServiceItem.service_category) - lib/insuranceShares becomes the single client-side mirror of BillingCalculator, shared by the confirm modal, the appointment edit page and the session form - surface the selection: confirm modal (with live shares), turns timeline chip, appointment edit page, patient record service card and invoice summary - the session form shows the insurance block whenever the tenant has an active contract and prefills the patient's own insurance, so it can be changed - fix: the confirm modal showed a zero visit price when the appointment had none — it now falls back to the tenant's free-visit price like the server - fix: useServiceCategories read one level too shallow, so Persian labels never arrived and raw enum keys leaked into the contract summary - fix: BlogsPage test asserted the public blogs endpoint after the page moved to the admin one Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
299 lines
14 KiB
TypeScript
299 lines
14 KiB
TypeScript
import { useEffect, useState } from 'react';
|
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
|
import { useNavigate, useParams, Link } from 'react-router-dom';
|
|
import { ChevronRightIcon } from '@heroicons/react/24/outline';
|
|
import { toast } from 'sonner';
|
|
import { api } from '../lib/api';
|
|
import type { ApiResponse } from '../lib/api';
|
|
import PersianDateInput from '../components/ui/PersianDateInput';
|
|
import PriceInput from '../components/ui/PriceInput';
|
|
import SearchableSelect from '../components/ui/SearchableSelect';
|
|
import { WalletChargeLink } from '../components/AppointmentActions';
|
|
import { formatRial, rialToToman, tomanToRial } from '../lib/utils';
|
|
import { DEFAULT_SERVICE_CATEGORY } from '../lib/insuranceShares';
|
|
import { useAppointmentInsurance } from '../hooks/useAppointmentInsurance';
|
|
|
|
interface Option { uuid: string; name?: string; full_name?: string }
|
|
|
|
interface AppointmentDetail {
|
|
uuid: string; slot_start: number; slot_end: number; status: string; version: number;
|
|
note?: string | null;
|
|
patient_mobile?: string | null;
|
|
user?: { uuid: string; mobile: string } | null;
|
|
deposit_required?: boolean; deposit_amount_rials?: number | null;
|
|
service_section?: Option | null; service_item?: Option | null; staff?: Option | null;
|
|
visit_price_rials?: number | null;
|
|
service_items?: { uuid: string; price_rials?: number | null; service_category?: string | null; insurance_covered?: boolean }[] | null;
|
|
insurance_service_category?: string | null;
|
|
insurance_base_id?: number | null;
|
|
}
|
|
|
|
const isoDate = (ts: number) => {
|
|
const d = new Date(ts * 1000);
|
|
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
|
};
|
|
const isoTime = (ts: number) => {
|
|
const d = new Date(ts * 1000);
|
|
return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
|
|
};
|
|
const toEpoch = (date: string, time: string) => Math.floor(new Date(`${date}T${time || '00:00'}`).getTime() / 1000);
|
|
|
|
/** ویرایش نوبت (Figma edit.pdf) — full-page edit of service specs, timing, deposit, status and notes. */
|
|
export default function AppointmentEditPage() {
|
|
const { uuid = '' } = useParams();
|
|
const navigate = useNavigate();
|
|
const qc = useQueryClient();
|
|
|
|
const { data, isLoading } = useQuery<ApiResponse<{ data: AppointmentDetail }>>({
|
|
queryKey: ['appointment-edit', uuid],
|
|
queryFn: () => api.get(`/api/v1/appointment/${uuid}`),
|
|
enabled: !!uuid,
|
|
});
|
|
const a = data?.data?.data;
|
|
|
|
const [sectionUuid, setSectionUuid] = useState('');
|
|
const [itemUuid, setItemUuid] = useState('');
|
|
const [staffUuid, setStaffUuid] = useState('');
|
|
const [date, setDate] = useState('');
|
|
const [start, setStart] = useState('');
|
|
const [end, setEnd] = useState('');
|
|
const [depositRequired, setDepositRequired] = useState(false);
|
|
const [depositToman, setDepositToman] = useState(0);
|
|
const [status, setStatus] = useState('');
|
|
const [note, setNote] = useState('');
|
|
const [serviceCategory, setServiceCategory] = useState('');
|
|
const [insuranceId, setInsuranceId] = useState('');
|
|
|
|
// hydrate once the appointment arrives
|
|
useEffect(() => {
|
|
if (!a) return;
|
|
setSectionUuid(a.service_section?.uuid ?? '');
|
|
setItemUuid(a.service_item?.uuid ?? '');
|
|
setStaffUuid(a.staff?.uuid ?? '');
|
|
setDate(isoDate(a.slot_start));
|
|
setStart(isoTime(a.slot_start));
|
|
setEnd(isoTime(a.slot_end));
|
|
setDepositRequired(!!a.deposit_required);
|
|
setDepositToman(rialToToman(a.deposit_amount_rials ?? 0));
|
|
setStatus(a.status);
|
|
setNote(a.note ?? '');
|
|
setServiceCategory(a.insurance_service_category ?? '');
|
|
setInsuranceId(a.insurance_base_id ? String(a.insurance_base_id) : '');
|
|
}, [a]);
|
|
|
|
const insurance = useAppointmentInsurance(!!uuid);
|
|
|
|
// نوع خدمتِ مؤثر: انتخاب نوبت، وگرنه تنها نوع فعالِ tenant (همان قاعدهٔ سرور).
|
|
const effectiveCategory = serviceCategory || insurance.defaultCategory || DEFAULT_SERVICE_CATEGORY;
|
|
|
|
const shares = insurance.breakdown([
|
|
// نوبتِ بدون هزینهٔ ویزیت، «قیمت ویزیت آزاد» تنظیمات را میگیرد (مثل سرور).
|
|
{ total: insurance.visitPriceOf(a?.visit_price_rials), category: effectiveCategory, insured: true },
|
|
...(a?.service_items ?? []).map((s) => ({
|
|
total: Number(s.price_rials ?? 0),
|
|
category: s.service_category ?? DEFAULT_SERVICE_CATEGORY,
|
|
insured: s.insurance_covered !== false,
|
|
})),
|
|
], insuranceId);
|
|
|
|
const sectionsQ = useQuery<ApiResponse<Option[]>>({ queryKey: ['service-sections'], queryFn: () => api.get('/api/v1/service-sections') });
|
|
const itemsQ = useQuery<ApiResponse<Option[]>>({
|
|
queryKey: ['service-items', sectionUuid],
|
|
queryFn: () => api.get(`/api/v1/service-items/${sectionUuid}`),
|
|
enabled: !!sectionUuid,
|
|
});
|
|
const staffQ = useQuery<ApiResponse<Option[]>>({ queryKey: ['staff-list'], queryFn: () => api.get('/api/v1/staff') });
|
|
|
|
const save = useMutation({
|
|
mutationFn: () => api.patch(`/api/v1/appointment/${uuid}`, {
|
|
slot_start: toEpoch(date, start),
|
|
slot_end: toEpoch(date, end),
|
|
service_section_uuid: sectionUuid,
|
|
service_item_uuid: itemUuid,
|
|
staff_uuid: staffUuid,
|
|
deposit_required: depositRequired,
|
|
deposit_amount_rials: depositRequired ? tomanToRial(depositToman) : null,
|
|
note,
|
|
insurance_service_category: serviceCategory || null,
|
|
insurance_base_id: insuranceId ? Number(insuranceId) : null,
|
|
...(status !== a?.status ? { status } : {}),
|
|
version: a?.version,
|
|
}),
|
|
onSuccess: () => {
|
|
qc.invalidateQueries({ queryKey: ['appointments'] });
|
|
toast.success('نوبت بهروزرسانی شد');
|
|
// به همان روزِ نوبت برگرد، نه امروز.
|
|
navigate(`/admin/appointments?date=${date}`);
|
|
},
|
|
onError: (e: any) => toast.error(e.message || 'خطا در ذخیره اطلاعات'),
|
|
});
|
|
|
|
const label = { fontSize: 12.5, color: 'var(--text-3)' } as const;
|
|
|
|
if (isLoading || !a) return <div style={{ padding: 24, color: 'var(--text-3)', fontSize: 13 }}>در حال بارگذاری...</div>;
|
|
|
|
const statusOptions: [string, string][] = [
|
|
['pending', 'ثبت شده'], ['confirmed', 'قطعی شده'], ['following_up', 'در حال پیگیری'],
|
|
['salon', 'سالن'], ['completed', 'ویزیت شده'], ['cancelled_by_doctor', 'لغو شده'],
|
|
];
|
|
|
|
return (
|
|
<div className="fade-in" style={{ maxWidth: 860, margin: '0 auto' }}>
|
|
<div style={{ display: 'flex', justifyContent: 'flex-end', marginBottom: 14 }}>
|
|
<Link to={`/admin/appointments?date=${date}`} className="btn sm ghost" style={{ color: 'var(--text-2)' }}>
|
|
<ChevronRightIcon style={{ width: 16 }} /> بازگشت
|
|
</Link>
|
|
</div>
|
|
|
|
<div style={{ background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 'var(--r-lg)', padding: 22 }}>
|
|
<div style={{ fontSize: 14, fontWeight: 700, marginBottom: 12 }}>مشخصات سرویس:</div>
|
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))', gap: 12, marginBottom: 18 }}>
|
|
<div>
|
|
<label style={label}>بخش</label>
|
|
<div style={{ marginTop: 6 }}>
|
|
<SearchableSelect
|
|
options={(sectionsQ.data?.data ?? []).map(o => ({ value: o.uuid, label: o.name ?? '' }))}
|
|
value={sectionUuid || null}
|
|
onChange={v => { setSectionUuid(v ? String(v) : ''); setItemUuid(''); }}
|
|
placeholder="انتخاب بخش"
|
|
isLoading={sectionsQ.isLoading}
|
|
isClearable
|
|
height={38}
|
|
/>
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<label style={label}>سرویس</label>
|
|
<div style={{ marginTop: 6 }}>
|
|
<SearchableSelect
|
|
options={(itemsQ.data?.data ?? []).map(o => ({ value: o.uuid, label: o.name ?? '' }))}
|
|
value={itemUuid || null}
|
|
onChange={v => setItemUuid(v ? String(v) : '')}
|
|
placeholder="انتخاب سرویس"
|
|
isDisabled={!sectionUuid}
|
|
isLoading={itemsQ.isLoading}
|
|
isClearable
|
|
height={38}
|
|
/>
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<label style={label}>پرسنل</label>
|
|
<div style={{ marginTop: 6 }}>
|
|
<SearchableSelect
|
|
options={(staffQ.data?.data ?? []).map(o => ({ value: o.uuid, label: o.full_name ?? '' }))}
|
|
value={staffUuid || null}
|
|
onChange={v => setStaffUuid(v ? String(v) : '')}
|
|
placeholder="انتخاب پرسنل"
|
|
isLoading={staffQ.isLoading}
|
|
isClearable
|
|
height={38}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div style={{ fontSize: 14, fontWeight: 700, marginBottom: 12 }}>بیمه:</div>
|
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))', gap: 12, marginBottom: 18 }}>
|
|
{/* نوع خدمت فقط وقتی چند نوع فعال است پرسیده میشود. */}
|
|
{insurance.needsCategoryChoice && (
|
|
<div>
|
|
<label style={label}>نوع خدمت</label>
|
|
<div style={{ marginTop: 6 }}>
|
|
<SearchableSelect
|
|
options={insurance.categoryOptions}
|
|
value={serviceCategory || null}
|
|
onChange={v => setServiceCategory(v ? String(v) : '')}
|
|
placeholder="انتخاب نوع خدمت"
|
|
isClearable
|
|
height={38}
|
|
/>
|
|
</div>
|
|
</div>
|
|
)}
|
|
<div>
|
|
<label style={label}>بیمه</label>
|
|
<div style={{ marginTop: 6 }}>
|
|
<SearchableSelect
|
|
options={insurance.insuranceOptions}
|
|
value={insuranceId || null}
|
|
onChange={v => setInsuranceId(v ? String(v) : '')}
|
|
placeholder="بدون بیمه"
|
|
noOptionsMessage="قرارداد بیمهٔ فعالی ندارید"
|
|
isClearable
|
|
height={38}
|
|
/>
|
|
</div>
|
|
</div>
|
|
{insuranceId !== '' && (
|
|
<div>
|
|
<label style={label}>سهم بیمه / سهم بیمار</label>
|
|
<div style={{ marginTop: 6, display: 'flex', alignItems: 'center', gap: 8, minHeight: 38, fontSize: 13 }}>
|
|
<span style={{ color: 'var(--success)', fontWeight: 700 }}>{formatRial(shares.insurance)}</span>
|
|
<span style={{ color: 'var(--text-3)' }}>/</span>
|
|
<span style={{ color: 'var(--primary)', fontWeight: 700 }}>{formatRial(shares.patient)}</span>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div style={{ fontSize: 14, fontWeight: 700, marginBottom: 12 }}>زمان نوبت:</div>
|
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(160px, 1fr))', gap: 12, marginBottom: 18 }}>
|
|
<div>
|
|
<label style={label}>انتخاب تاریخ</label>
|
|
<div style={{ marginTop: 6 }}><PersianDateInput value={date} onChange={setDate} /></div>
|
|
</div>
|
|
<div>
|
|
<label style={label}>ساعت شروع</label>
|
|
<div className="field" style={{ marginTop: 6 }}><input aria-label="ساعت شروع" type="time" value={start} onChange={e => setStart(e.target.value)} dir="ltr" /></div>
|
|
</div>
|
|
<div>
|
|
<label style={label}>ساعت پایان</label>
|
|
<div className="field" style={{ marginTop: 6 }}><input aria-label="ساعت پایان" type="time" value={end} onChange={e => setEnd(e.target.value)} dir="ltr" /></div>
|
|
</div>
|
|
</div>
|
|
|
|
<div style={{ fontSize: 14, fontWeight: 700, marginBottom: 12 }}>بیعانه:</div>
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: 16, flexWrap: 'wrap', marginBottom: 18 }}>
|
|
<label style={{ display: 'inline-flex', alignItems: 'center', gap: 8, fontSize: 13, cursor: 'pointer' }}>
|
|
<input type="checkbox" checked={depositRequired} onChange={e => setDepositRequired(e.target.checked)} />
|
|
بیعانه مورد نیاز است.
|
|
</label>
|
|
{depositRequired && (
|
|
<>
|
|
<div style={{ minWidth: 220 }}>
|
|
<label style={label}>مبلغ بیعانه (تومان)</label>
|
|
<div style={{ marginTop: 6 }}><PriceInput value={depositToman} onChange={setDepositToman} /></div>
|
|
</div>
|
|
<WalletChargeLink mobile={a.patient_mobile || a.user?.mobile || ''} />
|
|
</>
|
|
)}
|
|
</div>
|
|
|
|
<div style={{ maxWidth: 320, marginBottom: 18 }}>
|
|
<label style={label}>انتخاب وضعیت</label>
|
|
<div style={{ marginTop: 6 }}>
|
|
<SearchableSelect
|
|
options={statusOptions.map(([v, l]) => ({ value: v, label: l }))}
|
|
value={status || null}
|
|
onChange={v => setStatus(v ? String(v) : '')}
|
|
placeholder="انتخاب وضعیت"
|
|
height={38}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<label style={label}>توضیحات</label>
|
|
<div className="field" style={{ height: 'auto', margin: '6px 0 18px' }}>
|
|
<textarea value={note} onChange={e => setNote(e.target.value)} rows={4} placeholder="توضیحات"
|
|
style={{ width: '100%', border: 'none', background: 'transparent', fontFamily: 'inherit', resize: 'vertical' }} />
|
|
</div>
|
|
|
|
<button className="btn primary" disabled={!date || !start || !end || save.isPending} onClick={() => save.mutate()}>
|
|
ثبت اطلاعات
|
|
</button>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|