Files
clinicpro/assets/admin/pages/AppointmentEditPage.tsx
T
hamedandClaude Opus 5 1f58b1b9b3 feat(insurance): bill an appointment with a chosen service kind and insurance
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>
2026-07-25 17:50:14 +03:30

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>
);
}