refactor(insurance): merge pricing into contracts (basic+supp groups)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,98 @@
|
|||||||
|
# یکپارچهسازی قراردادهای بیمه، select یکدست، و تعداد خدمت در مراجعه
|
||||||
|
|
||||||
|
## پروژه
|
||||||
|
|
||||||
|
`clinicpro` (admin frontend + کمی backend برای `quantity`)
|
||||||
|
|
||||||
|
## زمینه
|
||||||
|
|
||||||
|
چهار مورد بههممرتبط در پنل ادمین:
|
||||||
|
|
||||||
|
1. صفحهی `/admin/insurance-pricing` دو بخش دارد که همپوشانی دارند → باید یکی شود.
|
||||||
|
2. select listها در کل پنل یکشکل نیستند (بعضی `<select className="input">` بومی، بعضی `SearchableSelect`).
|
||||||
|
3. در «ثبت مراجعه جدید» هر خدمت فقط یکبار اضافه میشود؛ تعداد (مثلاً سرُم ۲ عدد) ممکن نیست.
|
||||||
|
4. select بیمهی پایه/تکمیلی در فرم مراجعه باید فقط بیمههایی را نشان دهد که tenant در `insurance-pricing` قیمتگذاری/قرارداد کرده، و درصد تخفیف خودکار اعمال شود.
|
||||||
|
|
||||||
|
## وضعیت فعلی
|
||||||
|
|
||||||
|
### صفحهی insurance-pricing (دو بخش همپوشان)
|
||||||
|
|
||||||
|
`assets/admin/pages/InsurancePricingPage.tsx`:
|
||||||
|
```tsx
|
||||||
|
<TenantInsuranceContracts /> {/* قراردادها: بیمه + درصد پوشش + فرانشیز + سقف */}
|
||||||
|
<InsurancePricingSection /> {/* قیمتگذاری: قیمت ویزیت آزاد + سهم هر بیمهی پایه */}
|
||||||
|
```
|
||||||
|
- `TenantInsuranceContracts` (`assets/admin/components/TenantInsuranceContracts.tsx`): per (tenant, insurance) قرارداد با `coverage_percent`, `franchise_rials`, `annual_ceiling_rials`؛ افزودن/ویرایش/حذف. هم basic هم supplementary. API: `/api/v1/billing/tenant-insurances` (GET/POST/PATCH/DELETE).
|
||||||
|
- `InsurancePricingSection` (`assets/admin/components/InsurancePricingSection.tsx`): «قیمت ویزیت آزاد» + سهم بیمار به ازای هر بیمهی **پایه**. API: `GET/PUT /api/v1/insurance-pricing` (جدول `entity_insurance_pricing`).
|
||||||
|
|
||||||
|
این دو منطق موازیاند و کاربر را گیج میکنند. قرارداد با `coverage_percent` کافی است؛ بخش «قیمتگذاری» زیرش بیمعنی است.
|
||||||
|
|
||||||
|
### select listها
|
||||||
|
|
||||||
|
نمونهی بومی پراکنده (`NewSessionPage.tsx`, `MyPatientsPage.tsx`, `SmsPage.tsx`, `ClaimsPage.tsx`, `SettingsPage.tsx`, …):
|
||||||
|
```tsx
|
||||||
|
<select className="input" value={...} onChange={...}>
|
||||||
|
<option value="">...</option>
|
||||||
|
{opts.map(o => <option key={o.value} value={o.value}>{o.label}</option>)}
|
||||||
|
</select>
|
||||||
|
```
|
||||||
|
و در کنارش کامپوننت `SearchableSelect` (`assets/admin/components/ui/SearchableSelect.tsx`) برای بخش خدمات استفاده میشود. ظاهر این دو فرق دارد.
|
||||||
|
|
||||||
|
### تعداد خدمت
|
||||||
|
|
||||||
|
`src/Patient/Entity/SessionService.php` — فیلد `quantity` ندارد:
|
||||||
|
```php
|
||||||
|
private ServiceItem $serviceItem;
|
||||||
|
private ?ClinicStaff $staff = null;
|
||||||
|
private int $priceRials; // = serviceItem price، بدون ضرب در تعداد
|
||||||
|
```
|
||||||
|
`PatientService::createSession` services را تکواحدی جمع میزند:
|
||||||
|
```php
|
||||||
|
$servicesTotal = array_sum(array_column($serviceItems, 'price_rials'));
|
||||||
|
```
|
||||||
|
فرانت (`NewSessionPage.tsx`) هم هر خدمت را یکبار اضافه میکند (chip بدون تعداد).
|
||||||
|
|
||||||
|
### select بیمه در فرم مراجعه
|
||||||
|
|
||||||
|
`NewSessionPage.tsx` گزینههای بیمه را از `GET /api/v1/insurance-pricing` میگیرد (`pricing.insurances`)، ولی آن endpoint **همهی بیمههای فعال** را برمیگرداند (نه فقط قراردادهای tenant). درصد هم از `patient_share_rials` همان endpoint محاسبه میشود — که بعد از حذف بخش قیمتگذاری دیگر منبع درستی نیست.
|
||||||
|
|
||||||
|
## وظایف
|
||||||
|
|
||||||
|
### ۱. ادغام صفحهی insurance-pricing
|
||||||
|
|
||||||
|
- `InsurancePricingSection` و endpoint/جدول `entity_insurance_pricing` را کنار بگذار (از `InsurancePricingPage` حذف کن). قرارداد (`TenantInsuranceContracts`) تنها منبع باشد.
|
||||||
|
- `TenantInsuranceContracts` باید واضح هم basic هم supplementary را پشتیبانی کند (الان هم میکند؛ متن/گروهبندی را شفاف کن: دو دسته «بیمه پایه» و «بیمه تکمیلی»).
|
||||||
|
- اگر «قیمت ویزیت آزاد» جایی لازم است، آن را بهصورت یک فیلد ساده در همان صفحه/پروفایل نگهدار یا حذف کن (تصمیم را در پیادهسازی مستند کن). مسیر `/admin/insurance-pricing` و آیتم سایدبار باقی بماند.
|
||||||
|
- `InsurancePricingSection.tsx` حذف یا خالی شود؛ importها پاکسازی.
|
||||||
|
|
||||||
|
### ۲. یک کامپوننت select یکدست
|
||||||
|
|
||||||
|
- یک کامپوننت `Select` در `assets/admin/components/ui/` بساز (یا `SearchableSelect` را بهعنوان استاندارد واحد بپذیر) با ظاهر یکسان: همان استایل، RTL، chevron، حالت focus/disabled، placeholder.
|
||||||
|
- همهی `<select className="input">`های پنل را با این کامپوننت جایگزین کن (صفحات بالا). رفتار/داده تغییر نکند، فقط ظاهر یکدست شود.
|
||||||
|
- نمونهی مرجع ظاهر: select خدمات (`SearchableSelect`) در فرم مراجعه.
|
||||||
|
|
||||||
|
### ۳. تعداد (quantity) برای خدمات
|
||||||
|
|
||||||
|
**Backend:**
|
||||||
|
- به `SessionService` فیلد `quantity` (int, پیشفرض 1) اضافه کن + getter/setter + در `toArray()`. migration.
|
||||||
|
- `PatientService::createSession`: body هر service میتواند `quantity` داشته باشد؛ `line_total = price × quantity`؛ `services_total_rials` و `final_price_rials` با احتساب تعداد محاسبه شوند.
|
||||||
|
- `docs/api/patient.md` (یا فایل session مربوط) بهروز شود.
|
||||||
|
|
||||||
|
**Frontend (`NewSessionPage.tsx`):**
|
||||||
|
- chip خدمت دارای ورودی/استپر تعداد باشد (۱،۲،…)؛ نمایش `price × qty`.
|
||||||
|
- body ارسالی: `services: [{ service_item_uuid, quantity }]`.
|
||||||
|
- خلاصهی قیمت با تعداد محاسبه شود.
|
||||||
|
|
||||||
|
### ۴. select بیمه فقط از قراردادها + درصد خودکار
|
||||||
|
|
||||||
|
- در `NewSessionPage.tsx`، گزینههای «بیمه پایه» و «بیمه تکمیلی» از **قراردادهای tenant** بیایند (`GET /api/v1/billing/tenant-insurances`)، نه از `insurance-pricing`.
|
||||||
|
- هر گزینه `coverage_percent` خودش را دارد؛ با انتخاب بیمه، درصد تخفیف **خودکار** از `coverage_percent` قرارداد ست شود (نه از `patient_share_rials`).
|
||||||
|
- فقط بیمههای فعال قرارداد نمایش داده شوند؛ تفکیک basic/supplementary بر اساس `insurance_kind`.
|
||||||
|
|
||||||
|
## نکات مهم
|
||||||
|
|
||||||
|
- backend فقط برای `quantity` تغییر میکند؛ بقیه frontend است.
|
||||||
|
- بعد از تغییر Entity: `doctrine:migrations:diff` + `migrate`.
|
||||||
|
- پاسخها/الگوها: `BaseController`، `formatRial`/`formatNumber`، RTL، Vazirmatn، CSS variables موجود.
|
||||||
|
- چون `insurance-pricing` (entity_insurance_pricing) حذف میشود، هر مصرفکنندهی دیگرش را پیدا و پاکسازی کن (جستجوی `insurance-pricing` و `EntityInsurancePricing` در `src/` و `assets/`). اگر `BillingCalculator`/`InvoiceService` به آن وابسته است، به `coverage_percent` قرارداد سوییچ کن.
|
||||||
|
- tsc + `yarn dev` بدون خطا؛ هر بخش جدا commit.
|
||||||
@@ -1,161 +0,0 @@
|
|||||||
import { useEffect, useState } from 'react';
|
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
|
||||||
import { toast } from 'sonner';
|
|
||||||
import { api } from '../lib/api';
|
|
||||||
import { formatRial } from '../lib/utils';
|
|
||||||
|
|
||||||
interface InsuranceRow {
|
|
||||||
insurance_id: number;
|
|
||||||
insurance_name: string;
|
|
||||||
type: string;
|
|
||||||
patient_share_rials: number | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface PricingResponse {
|
|
||||||
free_visit_price_rials: number;
|
|
||||||
insurances: InsuranceRow[];
|
|
||||||
}
|
|
||||||
|
|
||||||
const TYPE_LABEL: Record<string, string> = {
|
|
||||||
basic: 'بیمه پایه',
|
|
||||||
supplementary: 'بیمه تکمیلی',
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function InsurancePricingSection() {
|
|
||||||
const qc = useQueryClient();
|
|
||||||
const [freeVisit, setFreeVisit] = useState('');
|
|
||||||
const [shares, setShares] = useState<Record<number, string>>({});
|
|
||||||
|
|
||||||
const { data, isLoading } = useQuery<{ data: PricingResponse }>({
|
|
||||||
queryKey: ['insurance-pricing'],
|
|
||||||
queryFn: () => api.get('/api/v1/insurance-pricing'),
|
|
||||||
});
|
|
||||||
|
|
||||||
const pricing = (data as any)?.data as PricingResponse | undefined;
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!pricing) return;
|
|
||||||
setFreeVisit(String(pricing.free_visit_price_rials ?? 0));
|
|
||||||
const next: Record<number, string> = {};
|
|
||||||
pricing.insurances.forEach((i) => {
|
|
||||||
next[i.insurance_id] = i.patient_share_rials != null ? String(i.patient_share_rials) : '';
|
|
||||||
});
|
|
||||||
setShares(next);
|
|
||||||
}, [pricing]);
|
|
||||||
|
|
||||||
const saveMut = useMutation({
|
|
||||||
mutationFn: () =>
|
|
||||||
api.put('/api/v1/insurance-pricing', {
|
|
||||||
free_visit_price_rials: Number(freeVisit) || 0,
|
|
||||||
insurances: (pricing?.insurances ?? []).filter((i) => i.type === 'basic').map((i) => ({
|
|
||||||
insurance_id: i.insurance_id,
|
|
||||||
patient_share_rials:
|
|
||||||
shares[i.insurance_id] === '' || shares[i.insurance_id] == null
|
|
||||||
? null
|
|
||||||
: Number(shares[i.insurance_id]) || 0,
|
|
||||||
})),
|
|
||||||
}),
|
|
||||||
onSuccess: () => {
|
|
||||||
toast.success('قیمتگذاری بیمه ذخیره شد');
|
|
||||||
qc.invalidateQueries({ queryKey: ['insurance-pricing'] });
|
|
||||||
},
|
|
||||||
onError: (e: Error) => toast.error(e.message),
|
|
||||||
});
|
|
||||||
|
|
||||||
const basics = (pricing?.insurances ?? []).filter((i) => i.type === 'basic');
|
|
||||||
|
|
||||||
const renderRow = (i: InsuranceRow) => {
|
|
||||||
const share = shares[i.insurance_id] ?? '';
|
|
||||||
const preview =
|
|
||||||
share !== '' && freeVisit !== ''
|
|
||||||
? `سهم بیمار: ${formatRial(Number(share) || 0)}`
|
|
||||||
: 'تعیین نشده';
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={i.insurance_id}
|
|
||||||
style={{
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: 12,
|
|
||||||
padding: '10px 12px',
|
|
||||||
borderRadius: 10,
|
|
||||||
border: '1px solid var(--border)',
|
|
||||||
background: 'var(--surface)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div style={{ flex: 1, minWidth: 0 }}>
|
|
||||||
<div style={{ fontWeight: 600, fontSize: 13.5 }}>{i.insurance_name}</div>
|
|
||||||
<div style={{ fontSize: 11.5, color: 'var(--text-3)', marginTop: 2 }}>{preview}</div>
|
|
||||||
</div>
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
min={0}
|
|
||||||
dir="ltr"
|
|
||||||
className="input"
|
|
||||||
style={{ width: 150 }}
|
|
||||||
placeholder="سهم بیمار (ریال)"
|
|
||||||
value={share}
|
|
||||||
onChange={(e) =>
|
|
||||||
setShares((p) => ({ ...p, [i.insurance_id]: e.target.value }))
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="card" style={{ padding: 20 }}>
|
|
||||||
<div style={{ marginBottom: 14 }}>
|
|
||||||
<h2 style={{ fontSize: 14, fontWeight: 700, margin: 0 }}>قیمتگذاری ویزیت بر اساس بیمه</h2>
|
|
||||||
<p style={{ fontSize: 12, color: 'var(--text-3)', marginTop: 4, lineHeight: 1.7 }}>
|
|
||||||
مبلغ ویزیت آزاد و سهم بیمار به ازای هر بیمه را تعیین کنید. خالیگذاشتن یک بیمه یعنی پذیرفته نمیشود.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{isLoading ? (
|
|
||||||
<div className="muted" style={{ fontSize: 13 }}>در حال بارگذاری...</div>
|
|
||||||
) : (
|
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 18 }}>
|
|
||||||
<div className="field" style={{ flexDirection: 'column', alignItems: 'stretch', height: 'auto', gap: 6, padding: 0, border: 'none', background: 'none' }}>
|
|
||||||
<label style={{ fontSize: 12.5, fontWeight: 600 }}>مبلغ ویزیت آزاد (ریال)</label>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
min={0}
|
|
||||||
dir="ltr"
|
|
||||||
className="input"
|
|
||||||
style={{ maxWidth: 220 }}
|
|
||||||
value={freeVisit}
|
|
||||||
onChange={(e) => setFreeVisit(e.target.value)}
|
|
||||||
/>
|
|
||||||
<span style={{ fontSize: 11.5, color: 'var(--text-3)' }}>
|
|
||||||
{freeVisit !== '' ? formatRial(Number(freeVisit) || 0) : '—'}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{basics.length > 0 && (
|
|
||||||
<div>
|
|
||||||
<div style={{ fontSize: 12.5, fontWeight: 600, marginBottom: 8, color: 'var(--text-2)' }}>{TYPE_LABEL.basic}</div>
|
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>{basics.map(renderRow)}</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{basics.length === 0 && (
|
|
||||||
<div style={{ fontSize: 12.5, color: 'var(--text-3)' }}>بیمه پایهای در سیستم تعریف نشده است.</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<button
|
|
||||||
className="btn primary sm"
|
|
||||||
disabled={saveMut.isPending}
|
|
||||||
onClick={() => saveMut.mutate()}
|
|
||||||
>
|
|
||||||
{saveMut.isPending ? 'در حال ذخیره...' : 'ذخیره قیمتگذاری'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -168,28 +168,38 @@ export default function TenantInsuranceContracts() {
|
|||||||
هنوز با هیچ بیمهای قرارداد فعال ندارید.
|
هنوز با هیچ بیمهای قرارداد فعال ندارید.
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 18 }}>
|
||||||
{contracts.map((c: Contract) => (
|
{(['basic', 'supplementary'] as const).map((kind) => {
|
||||||
<div key={c.uuid} style={{ display: 'flex', alignItems: 'center', gap: 12, padding: '10px 12px', borderRadius: 10, border: '1px solid var(--border)' }}>
|
const group = contracts.filter((c: Contract) => c.insurance_kind === kind);
|
||||||
<div style={{ flex: 1, minWidth: 0 }}>
|
if (group.length === 0) return null;
|
||||||
<div style={{ fontWeight: 600, fontSize: 13.5 }}>
|
return (
|
||||||
{c.insurance_name ?? `#${c.insurance_id}`}
|
<div key={kind}>
|
||||||
{c.insurance_kind && <span className="badge gray" style={{ fontSize: 10, marginInlineStart: 6 }}>{KIND_LABEL[c.insurance_kind] ?? c.insurance_kind}</span>}
|
<div style={{ fontSize: 12.5, fontWeight: 700, color: 'var(--text-2)', marginBottom: 8 }}>
|
||||||
|
بیمه {KIND_LABEL[kind]}
|
||||||
</div>
|
</div>
|
||||||
<div style={{ fontSize: 11.5, color: 'var(--text-3)', marginTop: 2 }}>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||||
پوشش {c.coverage_percent}٪
|
{group.map((c: Contract) => (
|
||||||
{c.franchise_rials > 0 && ` · فرانشیز ${formatRial(c.franchise_rials)}`}
|
<div key={c.uuid} style={{ display: 'flex', alignItems: 'center', gap: 12, padding: '10px 12px', borderRadius: 10, border: '1px solid var(--border)' }}>
|
||||||
{c.annual_ceiling_rials != null && ` · سقف ${formatRial(c.annual_ceiling_rials)}`}
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<div style={{ fontWeight: 600, fontSize: 13.5 }}>{c.insurance_name ?? `#${c.insurance_id}`}</div>
|
||||||
|
<div style={{ fontSize: 11.5, color: 'var(--text-3)', marginTop: 2 }}>
|
||||||
|
پوشش {c.coverage_percent}٪
|
||||||
|
{c.franchise_rials > 0 && ` · فرانشیز ${formatRial(c.franchise_rials)}`}
|
||||||
|
{c.annual_ceiling_rials != null && ` · سقف ${formatRial(c.annual_ceiling_rials)}`}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button className="mini-btn" title="ویرایش" onClick={() => openEdit(c)}>
|
||||||
|
<PencilIcon style={{ width: 15 }} />
|
||||||
|
</button>
|
||||||
|
<button className="mini-btn danger" title="غیرفعالسازی" disabled={delMut.isPending} onClick={() => delMut.mutate(c.uuid)}>
|
||||||
|
<TrashIcon style={{ width: 15 }} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button className="mini-btn" title="ویرایش" onClick={() => openEdit(c)}>
|
);
|
||||||
<PencilIcon style={{ width: 15 }} />
|
})}
|
||||||
</button>
|
|
||||||
<button className="mini-btn danger" title="غیرفعالسازی" disabled={delMut.isPending} onClick={() => delMut.mutate(c.uuid)}>
|
|
||||||
<TrashIcon style={{ width: 15 }} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,16 +1,14 @@
|
|||||||
import PageHeader from '../components/ui/PageHeader';
|
import PageHeader from '../components/ui/PageHeader';
|
||||||
import InsurancePricingSection from '../components/InsurancePricingSection';
|
|
||||||
import TenantInsuranceContracts from '../components/TenantInsuranceContracts';
|
import TenantInsuranceContracts from '../components/TenantInsuranceContracts';
|
||||||
|
|
||||||
export default function InsurancePricingPage() {
|
export default function InsurancePricingPage() {
|
||||||
return (
|
return (
|
||||||
<div className="fade-in">
|
<div className="fade-in">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="بیمه و قیمتگذاری"
|
title="قراردادهای بیمه"
|
||||||
description="قراردادهای بیمه، درصد پوشش و مبلغ ویزیت"
|
description="بیمههای پایه و تکمیلی، درصد پوشش، فرانشیز و سقف تعهد"
|
||||||
/>
|
/>
|
||||||
<TenantInsuranceContracts />
|
<TenantInsuranceContracts />
|
||||||
<InsurancePricingSection />
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user