fix(services): restore separate insurance modal + fix ⋮ menu position
- revert the previous merge of insurance coverage into the service edit modal (per user): 'پوشش بیمه' is again a ⋮ menu action opening its own ServiceInsuranceModal, kept within the services page - delete the inline ServiceInsuranceSection; restore ServiceInsuranceModal - fix ⋮ dropdown position: left:8 → insetInlineStart:8 so the menu anchors under the ⋮ button (which sits at inline-start/right in RTL) instead of the opposite side - keep the --primary-subtle → --primary-soft token fix in the modal - test: assert the ⋮ menu exposes ویرایش / تعرفههای سالانه / پوشش بیمه Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+29
-34
@@ -3,6 +3,7 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
|||||||
import { ShieldCheckIcon, CheckIcon } from '@heroicons/react/24/outline';
|
import { ShieldCheckIcon, CheckIcon } from '@heroicons/react/24/outline';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { api } from '../lib/api';
|
import { api } from '../lib/api';
|
||||||
|
import Modal from './ui/Modal';
|
||||||
import PriceInput from './ui/PriceInput';
|
import PriceInput from './ui/PriceInput';
|
||||||
import type { ServiceItem } from '../types';
|
import type { ServiceItem } from '../types';
|
||||||
|
|
||||||
@@ -129,7 +130,7 @@ function ContractCard({ contract, item }: { contract: TenantInsurance; item: Ser
|
|||||||
type="number" min={0} max={100} dir="ltr" className="input"
|
type="number" min={0} max={100} dir="ltr" className="input"
|
||||||
style={{ height: 40, textAlign: 'left' }}
|
style={{ height: 40, textAlign: 'left' }}
|
||||||
value={draft.coverage_percent ?? ''}
|
value={draft.coverage_percent ?? ''}
|
||||||
placeholder="ارثی"
|
placeholder="ارث"
|
||||||
onChange={(e) => setDraft((d) => ({ ...d, coverage_percent: e.target.value === '' ? null : Number(e.target.value) }))}
|
onChange={(e) => setDraft((d) => ({ ...d, coverage_percent: e.target.value === '' ? null : Number(e.target.value) }))}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -158,7 +159,7 @@ function ContractCard({ contract, item }: { contract: TenantInsurance; item: Ser
|
|||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 10, marginTop: 12 }}>
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 10, marginTop: 12 }}>
|
||||||
<span style={{ fontSize: 11, color: 'var(--text-3)' }}>مقدار خالی از قرارداد بیمه ارث میبرد</span>
|
<span style={{ fontSize: 11, color: 'var(--text-3)' }}>مقدار خالی از قرارداد بیمه ارث میبرد</span>
|
||||||
<button type="button" className="btn primary sm" disabled={saveMut.isPending} onClick={() => saveMut.mutate()}>
|
<button className="btn primary sm" disabled={saveMut.isPending} onClick={() => saveMut.mutate()}>
|
||||||
{saveMut.isPending ? '...' : 'ذخیره'}
|
{saveMut.isPending ? '...' : 'ذخیره'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -171,7 +172,7 @@ function ContractCard({ contract, item }: { contract: TenantInsurance; item: Ser
|
|||||||
{!draft.covered && !isLoading && (
|
{!draft.covered && !isLoading && (
|
||||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 10, padding: '12px 14px' }}>
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 10, padding: '12px 14px' }}>
|
||||||
<span style={{ fontSize: 12, color: 'var(--text-3)' }}>این خدمت تحت این بیمه پوشش ندارد</span>
|
<span style={{ fontSize: 12, color: 'var(--text-3)' }}>این خدمت تحت این بیمه پوشش ندارد</span>
|
||||||
<button type="button" className="btn sm" disabled={saveMut.isPending} onClick={() => saveMut.mutate()}>
|
<button className="btn sm" disabled={saveMut.isPending} onClick={() => saveMut.mutate()}>
|
||||||
{saveMut.isPending ? '...' : 'ذخیره'}
|
{saveMut.isPending ? '...' : 'ذخیره'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -180,47 +181,41 @@ function ContractCard({ contract, item }: { contract: TenantInsurance; item: Ser
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
export default function ServiceInsuranceModal({ item, onClose }: { item: ServiceItem | null; onClose: () => void }) {
|
||||||
* Per-insurer coverage editor for a single saved service item, rendered inline
|
|
||||||
* inside the service edit modal (no dialog wrapper). Each contract saves on its
|
|
||||||
* own; independent of the service item's own save button.
|
|
||||||
*/
|
|
||||||
export default function ServiceInsuranceSection({ item }: { item: ServiceItem }) {
|
|
||||||
const { data, isLoading } = useQuery<{ data: { data: TenantInsurance[] } }>({
|
const { data, isLoading } = useQuery<{ data: { data: TenantInsurance[] } }>({
|
||||||
queryKey: ['tenant-insurances'],
|
queryKey: ['tenant-insurances'],
|
||||||
queryFn: () => api.get('/api/v1/billing/tenant-insurances'),
|
queryFn: () => api.get('/api/v1/billing/tenant-insurances'),
|
||||||
|
enabled: !!item,
|
||||||
});
|
});
|
||||||
|
|
||||||
const contracts = ((data as any)?.data?.data as TenantInsurance[] | undefined) ?? [];
|
const contracts = ((data as any)?.data?.data as TenantInsurance[] | undefined) ?? [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
|
<Modal open={!!item} onClose={onClose} title={`پوشش بیمه — ${item?.name ?? ''}`} size="md">
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
|
||||||
<ShieldCheckIcon style={{ width: 17, color: 'var(--primary)' }} />
|
|
||||||
<b style={{ fontSize: 14 }}>پوشش بیمه</b>
|
|
||||||
</div>
|
|
||||||
<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={{
|
<div style={{
|
||||||
border: '2px dashed var(--border)', borderRadius: 'var(--r)',
|
display: 'flex', gap: 8, padding: '11px 13px', borderRadius: 'var(--r-sm)',
|
||||||
padding: '28px 16px', textAlign: 'center',
|
background: 'var(--primary-soft)', fontSize: 12, color: 'var(--text-2)', lineHeight: 1.7,
|
||||||
}}>
|
}}>
|
||||||
<ShieldCheckIcon style={{ width: 32, margin: '0 auto 10px', display: 'block', opacity: 0.35 }} />
|
<ShieldCheckIcon style={{ width: 16, flexShrink: 0, marginTop: 1, color: 'var(--primary)' }} />
|
||||||
<div style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-2)' }}>قرارداد بیمهی فعالی ندارید</div>
|
<span>درصد پوشش، فرانشیز و سقف هر بیمهگر برای این خدمت تعیین میشود. این تنظیمات مبنای محاسبهی سهم بیمار و ساخت مطالبات بیمه است.</span>
|
||||||
<div style={{ fontSize: 12, color: 'var(--text-3)', marginTop: 4 }}>ابتدا از بخش بیمهها یک بیمه را فعال کنید.</div>
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
|
||||||
contracts.map((c) => <ContractCard key={c.uuid} contract={c} item={item} />)
|
{isLoading ? (
|
||||||
)}
|
<div className="muted" style={{ fontSize: 13, padding: '8px 0' }}>در حال بارگذاری...</div>
|
||||||
</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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -36,11 +36,6 @@ beforeEach(() => {
|
|||||||
{ uuid: 'st1', full_name: 'مریم امینی', active: true },
|
{ uuid: 'st1', full_name: 'مریم امینی', active: true },
|
||||||
{ uuid: 'st2', full_name: 'سحر رحمانی', active: true },
|
{ uuid: 'st2', full_name: 'سحر رحمانی', active: true },
|
||||||
] });
|
] });
|
||||||
// coverage before tenant-insurances (coverage URL also contains "tenant-insurances")
|
|
||||||
if (url.includes('/service-coverage')) return Promise.resolve({ success: true, data: { data: [] } });
|
|
||||||
if (url.includes('/tenant-insurances')) return Promise.resolve({ success: true, data: { data: [
|
|
||||||
{ uuid: 'ins1', insurance_name: 'تأمین اجتماعی', insurance_kind: 'basic', coverage_percent: 70 },
|
|
||||||
] } });
|
|
||||||
return Promise.resolve({ success: true, data: [] });
|
return Promise.resolve({ success: true, data: [] });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -64,15 +59,13 @@ describe('ClinicServicesPage (خدمات)', () => {
|
|||||||
expect(screen.getByText('سرویس جدید')).toBeInTheDocument();
|
expect(screen.getByText('سرویس جدید')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('embeds per-insurer coverage inline inside the service edit modal', async () => {
|
it('opens the ⋮ actions menu with insurance and tariff entries for a service', async () => {
|
||||||
renderWithProviders(<ClinicServicesPage />, { route: '/admin/clinic-services' });
|
renderWithProviders(<ClinicServicesPage />, { route: '/admin/clinic-services' });
|
||||||
fireEvent.click(await screen.findByText('کندلا ۲۰۲۱'));
|
fireEvent.click(await screen.findByText('کندلا ۲۰۲۱'));
|
||||||
// open the ⋮ actions menu, then ویرایش
|
|
||||||
fireEvent.click(await screen.findByTitle('عملیات'));
|
fireEvent.click(await screen.findByTitle('عملیات'));
|
||||||
fireEvent.click(await screen.findByText('ویرایش'));
|
|
||||||
|
|
||||||
// insurance section + contract render inside the same (edit) modal
|
expect(await screen.findByText('ویرایش')).toBeInTheDocument();
|
||||||
expect(await screen.findByText('پوشش بیمه')).toBeInTheDocument();
|
expect(screen.getByText('تعرفههای سالانه')).toBeInTheDocument();
|
||||||
expect(await screen.findByText('تأمین اجتماعی')).toBeInTheDocument();
|
expect(screen.getByText('پوشش بیمه')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ import Modal from '../components/ui/Modal';
|
|||||||
import PriceInput from '../components/ui/PriceInput';
|
import PriceInput from '../components/ui/PriceInput';
|
||||||
import ConfirmDialog from '../components/ui/ConfirmDialog';
|
import ConfirmDialog from '../components/ui/ConfirmDialog';
|
||||||
import ServiceTariffModal from '../components/ServiceTariffModal';
|
import ServiceTariffModal from '../components/ServiceTariffModal';
|
||||||
import ServiceInsuranceSection from '../components/ServiceInsuranceSection';
|
import ServiceInsuranceModal from '../components/ServiceInsuranceModal';
|
||||||
import SearchableSelect from '../components/ui/SearchableSelect';
|
import SearchableSelect from '../components/ui/SearchableSelect';
|
||||||
import FeatureGate from '../components/ui/FeatureGate';
|
import FeatureGate from '../components/ui/FeatureGate';
|
||||||
|
|
||||||
@@ -78,6 +78,7 @@ function ClinicServicesPageInner() {
|
|||||||
const [itemModal, setItemModal] = useState<'create' | ServiceItem | null>(null);
|
const [itemModal, setItemModal] = useState<'create' | ServiceItem | null>(null);
|
||||||
const [toggleItem, setToggleItem] = useState<ServiceItem | null>(null);
|
const [toggleItem, setToggleItem] = useState<ServiceItem | null>(null);
|
||||||
const [tariffItem, setTariffItem] = useState<ServiceItem | null>(null);
|
const [tariffItem, setTariffItem] = useState<ServiceItem | null>(null);
|
||||||
|
const [insuranceItem, setInsuranceItem] = useState<ServiceItem | null>(null);
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
const [showInactive, setShowInactive] = useState(true);
|
const [showInactive, setShowInactive] = useState(true);
|
||||||
const [menuOpen, setMenuOpen] = useState<string | null>(null);
|
const [menuOpen, setMenuOpen] = useState<string | null>(null);
|
||||||
@@ -356,9 +357,10 @@ function ClinicServicesPageInner() {
|
|||||||
{menuOpen === item.uuid && (
|
{menuOpen === item.uuid && (
|
||||||
<>
|
<>
|
||||||
<div style={{ position: 'fixed', inset: 0, zIndex: 40 }} onClick={() => setMenuOpen(null)} />
|
<div style={{ position: 'fixed', inset: 0, zIndex: 40 }} onClick={() => setMenuOpen(null)} />
|
||||||
<div style={{ position: 'absolute', top: 40, left: 8, zIndex: 41, background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 'var(--r-sm)', boxShadow: 'var(--shadow)', padding: 6, minWidth: 176, display: 'flex', flexDirection: 'column', gap: 2 }}>
|
<div style={{ position: 'absolute', top: 40, insetInlineStart: 8, zIndex: 41, background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 'var(--r-sm)', boxShadow: 'var(--shadow)', padding: 6, minWidth: 176, display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||||
<button style={menuItemStyle} onClick={() => { setMenuOpen(null); openEditItem(item); }}><PencilIcon style={{ width: 15 }} /> ویرایش</button>
|
<button style={menuItemStyle} onClick={() => { setMenuOpen(null); openEditItem(item); }}><PencilIcon style={{ width: 15 }} /> ویرایش</button>
|
||||||
<button style={menuItemStyle} onClick={() => { setMenuOpen(null); setTariffItem(item); }}><BanknotesIcon style={{ width: 15 }} /> تعرفههای سالانه</button>
|
<button style={menuItemStyle} onClick={() => { setMenuOpen(null); setTariffItem(item); }}><BanknotesIcon style={{ width: 15 }} /> تعرفههای سالانه</button>
|
||||||
|
<button style={menuItemStyle} onClick={() => { setMenuOpen(null); setInsuranceItem(item); }}><ShieldCheckIcon style={{ width: 15 }} /> پوشش بیمه</button>
|
||||||
<button style={menuItemStyle} onClick={() => { setMenuOpen(null); setToggleItem(item); }}>{item.active ? <EyeSlashIcon style={{ width: 15 }} /> : <EyeIcon style={{ width: 15 }} />}{item.active ? ' غیرفعالکردن' : ' فعالکردن'}</button>
|
<button style={menuItemStyle} onClick={() => { setMenuOpen(null); setToggleItem(item); }}>{item.active ? <EyeSlashIcon style={{ width: 15 }} /> : <EyeIcon style={{ width: 15 }} />}{item.active ? ' غیرفعالکردن' : ' فعالکردن'}</button>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
@@ -572,25 +574,12 @@ function ClinicServicesPageInner() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
{/* پوشش بیمه — پیوسته داخل همان مودال، فقط برای سرویس ذخیرهشده */}
|
|
||||||
{itemModal !== null && typeof itemModal === 'object' ? (
|
|
||||||
<div style={{ marginTop: 22, paddingTop: 20, borderTop: '1px solid var(--border)' }}>
|
|
||||||
<ServiceInsuranceSection item={itemModal} />
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div style={{
|
|
||||||
marginTop: 22, paddingTop: 20, borderTop: '1px solid var(--border)',
|
|
||||||
fontSize: 12.5, color: 'var(--text-3)', display: 'flex', alignItems: 'center', gap: 8,
|
|
||||||
}}>
|
|
||||||
<ShieldCheckIcon style={{ width: 16, flexShrink: 0, color: 'var(--text-3)' }} />
|
|
||||||
<span>برای تنظیم پوشش بیمه، ابتدا سرویس را ذخیره کنید و سپس آن را ویرایش کنید.</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
<ServiceTariffModal item={tariffItem} onClose={() => setTariffItem(null)} />
|
<ServiceTariffModal item={tariffItem} onClose={() => setTariffItem(null)} />
|
||||||
|
|
||||||
|
<ServiceInsuranceModal item={insuranceItem} onClose={() => setInsuranceItem(null)} />
|
||||||
|
|
||||||
{/* Confirm حذف بخش */}
|
{/* Confirm حذف بخش */}
|
||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
open={!!deleteSection}
|
open={!!deleteSection}
|
||||||
|
|||||||
Reference in New Issue
Block a user