- Updated DoctorDetailPage, MySecretariesPage, RecordNumberSettingsPage, RepresentationsPage, ResourcePoolsPage, ResourceTypesPage, SecretariesPage, SecretaryDetailPage, SettingsPage, SkillsPage, SmsWalletPage, and TagsSettingsPage to use the new Switch component instead of native checkboxes. - Enhanced accessibility by ensuring the Switch component uses appropriate roles and labels. - Added tests for the new Switch component to ensure functionality and accessibility compliance. - Updated styles to accommodate the new Switch component design.
236 lines
11 KiB
TypeScript
236 lines
11 KiB
TypeScript
import { useEffect, useState } from 'react';
|
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
|
import { ShieldCheckIcon, CheckIcon } from '@heroicons/react/24/outline';
|
|
import { toast } from 'sonner';
|
|
import { api } from '../lib/api';
|
|
import { digitsOnly, parseUserNumberClamped } from '../lib/utils';
|
|
import Modal from './ui/Modal';
|
|
import PriceInput from './ui/PriceInput';
|
|
import type { ServiceItem } from '../types';
|
|
import Switch from './ui/Switch';
|
|
|
|
interface TenantInsurance {
|
|
uuid: string;
|
|
insurance_name: string | null;
|
|
insurance_kind: 'basic' | 'supplementary' | null;
|
|
coverage_percent: number;
|
|
}
|
|
|
|
interface CoverageRow {
|
|
service_item_uuid: string | null;
|
|
covered: boolean;
|
|
coverage_percent: number | null;
|
|
franchise_percent: number | null;
|
|
ceiling_rials: number | null;
|
|
}
|
|
|
|
interface Draft {
|
|
covered: boolean;
|
|
coverage_percent: number | null;
|
|
franchise_percent: number | null;
|
|
ceiling_rials: number | null;
|
|
}
|
|
|
|
const KIND = {
|
|
basic: { label: 'پایه', cls: 'blue' },
|
|
supplementary: { label: 'تکمیلی', cls: 'violet' },
|
|
} as const;
|
|
|
|
function ContractCard({ contract, item }: { contract: TenantInsurance; item: ServiceItem }) {
|
|
const qc = useQueryClient();
|
|
|
|
const { data, isLoading } = useQuery<{ data: { data: CoverageRow[] } }>({
|
|
queryKey: ['service-coverage', contract.uuid],
|
|
queryFn: () => api.get(`/api/v1/billing/tenant-insurances/${contract.uuid}/service-coverage`),
|
|
});
|
|
|
|
const rows = (data as any)?.data?.data as CoverageRow[] | undefined;
|
|
const existing = rows?.find((r) => r.service_item_uuid === item.uuid);
|
|
|
|
const [draft, setDraft] = useState<Draft>({ covered: true, coverage_percent: null, franchise_percent: null, ceiling_rials: null });
|
|
// متن خام فیلدهای درصد جدا از مقدار عددی نگه داشته میشود تا کاربر بتواند فیلد را خالی کند.
|
|
const [percentText, setPercentText] = useState('');
|
|
const [franchiseText, setFranchiseText] = useState('');
|
|
|
|
useEffect(() => {
|
|
setDraft(existing
|
|
? {
|
|
covered: existing.covered,
|
|
coverage_percent: existing.coverage_percent,
|
|
franchise_percent: existing.franchise_percent,
|
|
ceiling_rials: existing.ceiling_rials,
|
|
}
|
|
: { covered: true, coverage_percent: null, franchise_percent: null, ceiling_rials: null });
|
|
setPercentText(existing?.coverage_percent == null ? '' : String(existing.coverage_percent));
|
|
setFranchiseText(existing?.franchise_percent == null ? '' : String(existing.franchise_percent));
|
|
}, [existing]);
|
|
|
|
const saveMut = useMutation({
|
|
mutationFn: () =>
|
|
api.put(`/api/v1/billing/tenant-insurances/${contract.uuid}/service-coverage`, {
|
|
service_item_uuid: item.uuid,
|
|
covered: draft.covered,
|
|
coverage_percent: draft.coverage_percent,
|
|
franchise_percent: draft.franchise_percent,
|
|
ceiling_rials: draft.ceiling_rials,
|
|
}),
|
|
onSuccess: () => {
|
|
toast.success('پوشش بیمه ذخیره شد');
|
|
qc.invalidateQueries({ queryKey: ['service-coverage', contract.uuid] });
|
|
},
|
|
onError: (e: Error) => toast.error(e.message),
|
|
});
|
|
|
|
const kind = contract.insurance_kind ? KIND[contract.insurance_kind] : null;
|
|
|
|
return (
|
|
<div style={{ border: '1px solid var(--border)', borderRadius: 'var(--r)', overflow: 'hidden' }}>
|
|
{/* سربرگ کارت */}
|
|
<div style={{
|
|
display: 'flex', alignItems: 'center', gap: 10, padding: '12px 14px',
|
|
background: 'var(--surface-2)', borderBottom: draft.covered ? '1px solid var(--border)' : 'none',
|
|
}}>
|
|
<div style={{
|
|
width: 32, height: 32, borderRadius: 9, flexShrink: 0,
|
|
display: 'grid', placeItems: 'center', background: 'var(--primary-soft)',
|
|
}}>
|
|
<ShieldCheckIcon style={{ width: 18, color: 'var(--primary)' }} />
|
|
</div>
|
|
<div style={{ flex: 1, minWidth: 0 }}>
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
|
<b style={{ fontSize: 13.5, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
|
{contract.insurance_name ?? 'بیمه'}
|
|
</b>
|
|
{kind && <span className={`badge ${kind.cls}`} style={{ fontSize: 10 }}>{kind.label}</span>}
|
|
{existing && (
|
|
<span className="badge green" style={{ fontSize: 10 }}>
|
|
<CheckIcon style={{ width: 10 }} /> تنظیمشده
|
|
</span>
|
|
)}
|
|
</div>
|
|
<div style={{ fontSize: 11.5, color: 'var(--text-3)', marginTop: 2 }}>
|
|
پوشش پیشفرض قرارداد: {contract.coverage_percent}٪
|
|
</div>
|
|
</div>
|
|
<Switch
|
|
checked={draft.covered}
|
|
disabled={isLoading}
|
|
onChange={(v) => setDraft((d) => ({ ...d, covered: v }))}
|
|
ariaLabel="پوشش بیمه برای این سرویس"
|
|
/>
|
|
</div>
|
|
|
|
{/* بدنه — فقط وقتی پوشش فعال است */}
|
|
{draft.covered && (
|
|
<div style={{ padding: 14 }}>
|
|
{isLoading ? (
|
|
<div className="muted" style={{ fontSize: 12.5 }}>در حال بارگذاری...</div>
|
|
) : (
|
|
<>
|
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: 10 }}>
|
|
<div style={{ minWidth: 0 }}>
|
|
<label className="field-label">درصد پوشش</label>
|
|
<input
|
|
type="text" inputMode="numeric" dir="ltr" className="input"
|
|
style={{ height: 40, textAlign: 'left' }}
|
|
value={percentText}
|
|
placeholder="ارث از قرارداد"
|
|
onChange={(e) => {
|
|
const digits = digitsOnly(e.target.value, 3);
|
|
setPercentText(digits);
|
|
setDraft((d) => ({ ...d, coverage_percent: parseUserNumberClamped(digits, 0, 100) }));
|
|
}}
|
|
onBlur={() => setPercentText(draft.coverage_percent == null ? '' : String(draft.coverage_percent))}
|
|
/>
|
|
</div>
|
|
<div style={{ minWidth: 0 }}>
|
|
<label className="field-label">فرانشیز (درصد)</label>
|
|
<input
|
|
type="text" inputMode="numeric" dir="ltr" className="input"
|
|
style={{ height: 40, textAlign: 'left' }}
|
|
aria-label="فرانشیز درصد خدمت"
|
|
value={franchiseText}
|
|
placeholder="بدون فرانشیز"
|
|
onChange={(e) => {
|
|
const digits = digitsOnly(e.target.value, 3);
|
|
setFranchiseText(digits);
|
|
setDraft((d) => ({ ...d, franchise_percent: parseUserNumberClamped(digits, 0, 100) }));
|
|
}}
|
|
onBlur={() => setFranchiseText(draft.franchise_percent == null ? '' : String(draft.franchise_percent))}
|
|
/>
|
|
</div>
|
|
<div style={{ minWidth: 0 }}>
|
|
<label className="field-label">سقف پوشش</label>
|
|
<PriceInput
|
|
className="input"
|
|
style={{ height: 40 }}
|
|
value={draft.ceiling_rials ?? 0}
|
|
onChange={(v) => setDraft((d) => ({ ...d, ceiling_rials: v || null }))}
|
|
placeholder="بدون سقف"
|
|
min={0}
|
|
/>
|
|
</div>
|
|
</div>
|
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 10, marginTop: 12 }}>
|
|
<span style={{ fontSize: 11, color: 'var(--text-3)' }}>مقدار خالی از قرارداد بیمه ارث میبرد</span>
|
|
<button className="btn primary sm" disabled={saveMut.isPending} onClick={() => saveMut.mutate()}>
|
|
{saveMut.isPending ? '...' : 'ذخیره'}
|
|
</button>
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* وقتی پوشش غیرفعال است — یک خط ذخیره */}
|
|
{!draft.covered && !isLoading && (
|
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 10, padding: '12px 14px' }}>
|
|
<span style={{ fontSize: 12, color: 'var(--text-3)' }}>این خدمت تحت این بیمه پوشش ندارد</span>
|
|
<button className="btn primary sm" disabled={saveMut.isPending} onClick={() => saveMut.mutate()}>
|
|
{saveMut.isPending ? '...' : 'ذخیره'}
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default function ServiceInsuranceModal({ item, onClose }: { item: ServiceItem | null; onClose: () => void }) {
|
|
const { data, isLoading } = useQuery<{ data: { data: TenantInsurance[] } }>({
|
|
queryKey: ['tenant-insurances'],
|
|
queryFn: () => api.get('/api/v1/billing/tenant-insurances'),
|
|
enabled: !!item,
|
|
});
|
|
|
|
const contracts = ((data as any)?.data?.data as TenantInsurance[] | undefined) ?? [];
|
|
|
|
return (
|
|
<Modal open={!!item} onClose={onClose} title={`پوشش بیمه — ${item?.name ?? ''}`} size="md">
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
|
|
<div style={{
|
|
display: 'flex', gap: 8, padding: '11px 13px', borderRadius: 'var(--r-sm)',
|
|
background: 'var(--primary-soft)', fontSize: 12, color: 'var(--text-2)', lineHeight: 1.7,
|
|
}}>
|
|
<ShieldCheckIcon style={{ width: 16, flexShrink: 0, marginTop: 1, color: 'var(--primary)' }} />
|
|
<span>درصد پوشش، فرانشیز و سقف هر بیمهگر برای این خدمت تعیین میشود. این تنظیمات مبنای محاسبهی سهم بیمار و ساخت مطالبات بیمه است.</span>
|
|
</div>
|
|
|
|
{isLoading ? (
|
|
<div className="muted" style={{ fontSize: 13, padding: '8px 0' }}>در حال بارگذاری...</div>
|
|
) : contracts.length === 0 ? (
|
|
<div style={{
|
|
border: '2px dashed var(--border)', borderRadius: 'var(--r)',
|
|
padding: '36px 16px', textAlign: 'center',
|
|
}}>
|
|
<ShieldCheckIcon style={{ width: 34, margin: '0 auto 10px', display: 'block', opacity: 0.35 }} />
|
|
<div style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-2)' }}>قرارداد بیمهی فعالی ندارید</div>
|
|
<div style={{ fontSize: 12, color: 'var(--text-3)', marginTop: 4 }}>ابتدا از بخش بیمهها یک بیمه را فعال کنید.</div>
|
|
</div>
|
|
) : (
|
|
item && contracts.map((c) => <ContractCard key={c.uuid} contract={c} item={item} />)
|
|
)}
|
|
</div>
|
|
</Modal>
|
|
);
|
|
}
|