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:
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user