feat: implement per-doctor insurance settings in multi-doctor clinics

- Updated InsuranceModal to include doctorUuid in the payload for insurance contracts.
- Enhanced TenantInsuranceContracts to allow selection of doctors and pass doctorUuid in API requests.
- Modified InsuranceController to handle doctorUuid for tenant insurance endpoints, ensuring contracts are stored per doctor.
- Updated API documentation to reflect the new optional doctor_uuid parameter for tenant insurance endpoints.
- Added tests to verify the functionality of per-doctor insurance contracts and ensure isolation of contracts between doctors.
This commit is contained in:
hamed
2026-07-21 19:21:55 +03:30
parent 7e847b62c4
commit 5507b42fd8
6 changed files with 478 additions and 25 deletions
+11 -4
View File
@@ -59,8 +59,12 @@ export function contractToForm(c: Contract): InsuranceFormValues {
};
}
/** Build the API payload from form values (toman → rials, Y-m-d → unix). */
export function buildInsurancePayload(v: InsuranceFormValues) {
/**
* Build the API payload from form values (toman → rials, Y-m-d → unix).
* When `doctorUuid` is set, the contract is targeted at that doctor (multi-doctor
* clinic); otherwise it falls back to the caller's own tenant on the backend.
*/
export function buildInsurancePayload(v: InsuranceFormValues, doctorUuid?: string | null) {
return {
insurance_id: Number(v.insuranceId),
kind: v.kind || null,
@@ -69,6 +73,7 @@ export function buildInsurancePayload(v: InsuranceFormValues) {
annual_ceiling_rials: v.ceiling === '' ? null : tomanToRial(Number(v.ceiling)),
effective_from: isoToUnix(v.effectiveFrom),
effective_to: isoToUnix(v.effectiveTo),
...(doctorUuid ? { doctor_uuid: doctorUuid } : {}),
};
}
@@ -79,6 +84,8 @@ interface Props {
options: InsuranceOption[];
/** Insurance kind of the active tab ('basic'|'supplementary'); assigned to new contracts, not user-editable. */
kind: string;
/** Target doctor in a multi-doctor clinic; threaded into the payload as `doctor_uuid`. */
doctorUuid?: string | null;
onClose: () => void;
onSubmit: (payload: ReturnType<typeof buildInsurancePayload>) => void;
isPending?: boolean;
@@ -89,7 +96,7 @@ interface Props {
* state, emits the built payload via onSubmit. Fields mirror the Figma "افزودن بیمه"
* modal plus the injected coverage/franchise/ceiling controls.
*/
export default function InsuranceModal({ open, editContract, options, kind, onClose, onSubmit, isPending }: Props) {
export default function InsuranceModal({ open, editContract, options, kind, doctorUuid, onClose, onSubmit, isPending }: Props) {
const [form, setForm] = useState<InsuranceFormValues>(EMPTY_FORM);
useEffect(() => {
@@ -102,7 +109,7 @@ export default function InsuranceModal({ open, editContract, options, kind, onCl
const submit = () => {
if (!form.insuranceId) return;
onSubmit(buildInsurancePayload(form));
onSubmit(buildInsurancePayload(form, doctorUuid));
};
const field = { display: 'flex', flexDirection: 'column' as const, gap: 6 };
@@ -3,7 +3,11 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { PlusIcon, PencilIcon, MagnifyingGlassIcon, ChevronDownIcon } from '@heroicons/react/24/outline';
import { toast } from 'sonner';
import { api } from '../lib/api';
import type { ApiResponse } from '../lib/api';
import { formatRial, formatNumber, formatDate } from '../lib/utils';
import { useAuthStore } from '../stores/authStore';
import SearchableSelect from './ui/SearchableSelect';
import type { ClinicDoctorItem } from './ClinicDoctorsManager';
import InsuranceModal, { Contract, InsuranceOption, KIND_LABEL, buildInsurancePayload } from './InsuranceModal';
type Kind = 'basic' | 'supplementary';
@@ -37,20 +41,52 @@ export function contractSummary(c: Contract): string {
export default function TenantInsuranceContracts() {
const qc = useQueryClient();
const { dbUuid, context, availableContexts } = useAuthStore();
const [tab, setTab] = useState<Kind>('basic');
const [modalOpen, setModalOpen] = useState(false);
const [editContract, setEditContract] = useState<Contract | null>(null);
const [search, setSearch] = useState('');
const [expanded, setExpanded] = useState<string | null>(null);
const [pickedDoctorUuid, setPickedDoctorUuid] = useState<string | null>(null);
// A user who is both a doctor and a clinic owner may have a doctor db_uuid active;
// fall back to the clinic context so the roster query targets the clinic. Same
// resolution as ClinicAppointmentSettingsPage.
const clinicUuid = useMemo(() => {
if (context?.type === 'clinic') return dbUuid;
return availableContexts.find((c) => c.type === 'clinic')?.db_uuid ?? null;
}, [context, dbUuid, availableContexts]);
const doctorsQuery = useQuery({
queryKey: ['clinic-doctors', clinicUuid],
queryFn: () => api.get<ApiResponse<{ data: ClinicDoctorItem[] }>>(`/api/v1/clinic/doctor-list/${clinicUuid}`),
enabled: !!clinicUuid,
});
const doctorList: ClinicDoctorItem[] = useMemo(() => {
const raw = doctorsQuery.data?.data;
return (raw as any)?.data ?? raw ?? [];
}, [doctorsQuery.data]);
// In a clinic, insurance is per-doctor: default to the first doctor. Solo doctors /
// personal offices have no clinic context → doctorUuid stays null → backend keeps the
// legacy tenant-scoped behavior.
const isClinic = !!clinicUuid;
const doctorUuid = useMemo(
() => (isClinic ? pickedDoctorUuid ?? doctorList[0]?.uuid ?? null : null),
[isClinic, pickedDoctorUuid, doctorList],
);
const showDoctorPicker = isClinic && doctorList.length > 1;
const dq = doctorUuid ? `?doctor_uuid=${encodeURIComponent(doctorUuid)}` : '';
const contractsQuery = useQuery({
queryKey: ['tenant-insurances'],
queryFn: () => api.get('/api/v1/billing/tenant-insurances'),
queryKey: ['tenant-insurances', doctorUuid],
queryFn: () => api.get(`/api/v1/billing/tenant-insurances${dq}`),
});
const pricingQuery = useQuery({
queryKey: ['insurance-pricing'],
queryFn: () => api.get('/api/v1/insurance-pricing'),
queryKey: ['insurance-pricing', doctorUuid],
queryFn: () => api.get(`/api/v1/insurance-pricing${dq}`),
});
const contracts: Contract[] = (contractsQuery.data as any)?.data?.data ?? [];
@@ -76,7 +112,7 @@ export default function TenantInsuranceContracts() {
supplementary: contracts.filter((c) => kindOf(c) === 'supplementary').length,
}), [contracts, catalogTypeById]);
const invalidate = () => qc.invalidateQueries({ queryKey: ['tenant-insurances'] });
const invalidate = () => qc.invalidateQueries({ queryKey: ['tenant-insurances', doctorUuid] });
const toggleRow = (uuid: string) => setExpanded((p) => (p === uuid ? null : uuid));
const saveMut = useMutation({
@@ -94,7 +130,10 @@ export default function TenantInsuranceContracts() {
const toggleMut = useMutation({
mutationFn: (c: Contract) =>
api.patch(`/api/v1/billing/tenant-insurances/${c.uuid}`, { is_active: !c.is_active }),
api.patch(`/api/v1/billing/tenant-insurances/${c.uuid}`, {
is_active: !c.is_active,
...(doctorUuid ? { doctor_uuid: doctorUuid } : {}),
}),
onSuccess: () => invalidate(),
onError: (e: Error) => toast.error(e.message),
});
@@ -114,6 +153,21 @@ export default function TenantInsuranceContracts() {
</button>
</div>
{showDoctorPicker && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, marginBottom: 16, maxWidth: 320 }}>
<label style={{ fontSize: 12, fontWeight: 600, color: 'var(--text-2)' }}>پزشک</label>
<SearchableSelect
options={doctorList.map((d) => ({ value: d.uuid, label: d.name }))}
value={doctorUuid ?? ''}
onChange={(v) => { setPickedDoctorUuid(v ? String(v) : null); setExpanded(null); }}
placeholder="انتخاب پزشک..."
/>
<span style={{ fontSize: 11, color: 'var(--text-3)' }}>
تنظیمات بیمه برای هر پزشک جداگانه ذخیره میشود.
</span>
</div>
)}
<div role="tablist" style={{ display: 'flex', gap: 4, borderBottom: '1px solid var(--border)', marginBottom: 16 }}>
{KINDS.map((k) => {
const active = k.key === tab;
@@ -181,6 +235,7 @@ export default function TenantInsuranceContracts() {
editContract={editContract}
options={editContract ? allInsurances : available}
kind={editContract ? kindOf(editContract) : tab}
doctorUuid={doctorUuid}
onClose={closeModal}
onSubmit={(payload) => saveMut.mutate(payload)}
isPending={saveMut.isPending}