The encounter edit form asked for contracts in the panel's own environment, so inside a clinic it got an empty list: no insurance to choose, and the one already recorded on the encounter had no matching option to display. It now scopes the request by the encounter's doctor, the same way the confirm modal does, which is why the session payload's existing doctor_uuid is threaded through. Saving the form had the matching defect on the server. Recalculating the totals looked up the coverage percentage against the record's tenant rather than the contract's, so a single save on a correctly-billed encounter zeroed the insurance share and moved the whole amount back onto the patient. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
198 lines
11 KiB
TypeScript
198 lines
11 KiB
TypeScript
import { useEffect, useRef, useState, type CSSProperties } from 'react';
|
|
import { formatDate, formatRial } from '../lib/utils';
|
|
import { FilesServiceSuccess, FilesServiceMore } from './icons/FilesServiceIcons';
|
|
|
|
export interface SessionPaymentEntry {
|
|
uuid: string;
|
|
method: string;
|
|
amount_rials: number;
|
|
paid_at?: number;
|
|
created_at?: number;
|
|
created_by_name?: string | null;
|
|
}
|
|
|
|
export interface SessionCardData {
|
|
uuid: string;
|
|
services?: Array<{ service_item_uuid?: string; service_name?: string; name?: string; line_total_rials?: number; price_rials?: number; quantity?: number }>;
|
|
// مطابق SessionConsumable::toArray در بکاند
|
|
consumables?: Array<{ uuid?: string; inventory_item_uuid?: string; item_name?: string; unit?: string; price_rials?: number; quantity?: number; line_total_rials?: number }>;
|
|
visit_price_rials?: number;
|
|
services_total_rials?: number;
|
|
session_at?: number | null;
|
|
insurance_base_id?: number | null;
|
|
insurance_supplementary_id?: number | null;
|
|
/** نوع خدمتِ بیمهایِ این مراجعه (سرپایی/بستری) و نام بیمهٔ پایه. */
|
|
insurance_service_category?: string | null;
|
|
insurance_service_category_label?: string | null;
|
|
insurance_base_name?: string | null;
|
|
base_insurance_discount_percent?: number;
|
|
supplementary_discount_percent?: number;
|
|
doctor_name?: string | null;
|
|
/** پزشکِ نوبتِ این مراجعه — محدودهٔ قرارداد بیمه از روی او حل میشود. */
|
|
doctor_uuid?: string | null;
|
|
final_price_rials?: number;
|
|
// تفکیک بیمه — سرور محاسبه میکند (PatientSession::applyShares)
|
|
gross_total_rials?: number;
|
|
base_insurance_rials?: number;
|
|
supplementary_insurance_rials?: number;
|
|
patient_share_rials?: number;
|
|
remaining_rials?: number;
|
|
patient_debt_rials?: number;
|
|
is_paid?: boolean;
|
|
invoice_uuid?: string | null;
|
|
notes?: string | null;
|
|
archived?: boolean;
|
|
created_at?: number;
|
|
// تسویه چندتکه + تخفیف (backend session settlement fields)
|
|
discount_type?: 'percent' | 'fixed' | null;
|
|
discount_value?: number;
|
|
discount_rials?: number;
|
|
applied_discount_rule_label?: string | null;
|
|
paid_total_rials?: number;
|
|
paid_at?: number | null;
|
|
payments?: SessionPaymentEntry[];
|
|
}
|
|
|
|
/** label:value row inside the service card (mirrors tauri ServiceInfoRow). */
|
|
function Row({ label, value, valueStyle }: { label: string; value: string; valueStyle?: CSSProperties }) {
|
|
return (
|
|
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 6, margin: '6px 0' }}>
|
|
<span style={{ fontSize: 12, color: 'var(--text-2)' }}>{label}:</span>
|
|
<span style={{ fontSize: 14, color: 'var(--text)', textAlign: 'left', ...valueStyle }}>{value}</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* A patient «مراجعه» (session) card — visit + performed services — ported
|
|
* pixel-for-pixel from tauri files/services/ServiceCard. Paid cards show
|
|
* «مشاهده فاکتور», unpaid ones «تکمیل پرداخت».
|
|
*/
|
|
export default function SessionServiceCard({ session, onSettle, onViewInvoice, onArchive, onEdit, onViewAudit, settling, issuing }: {
|
|
session: SessionCardData;
|
|
onSettle: (uuid: string) => void;
|
|
/** کل session پاس میشود؛ اگر invoice_uuid نداشت، caller فاکتور را میسازد. */
|
|
onViewInvoice: (session: SessionCardData) => void;
|
|
/** آرشیو/خروج از آرشیو مراجعه. */
|
|
onArchive?: (session: SessionCardData, archived: boolean) => void;
|
|
/** ویرایش سرویسهای مراجعه. */
|
|
onEdit?: (session: SessionCardData) => void;
|
|
/** نمایش تاریخچهی تغییرات (Audit Log). */
|
|
onViewAudit?: (session: SessionCardData) => void;
|
|
settling?: boolean;
|
|
/** true وقتی صدور فاکتور همین لحظه در جریان است (دکمه قفل میشود). */
|
|
issuing?: boolean;
|
|
}) {
|
|
const paid = !!session.is_paid;
|
|
const [menuOpen, setMenuOpen] = useState(false);
|
|
const menuRef = useRef<HTMLDivElement>(null);
|
|
useEffect(() => {
|
|
if (!menuOpen) return;
|
|
const onDoc = (e: MouseEvent) => { if (menuRef.current && !menuRef.current.contains(e.target as Node)) setMenuOpen(false); };
|
|
document.addEventListener('mousedown', onDoc);
|
|
return () => document.removeEventListener('mousedown', onDoc);
|
|
}, [menuOpen]);
|
|
const names = (session.services ?? []).map((s) => s.service_name || s.name).filter(Boolean) as string[];
|
|
if ((session.visit_price_rials ?? 0) > 0) names.unshift('ویزیت');
|
|
// A «مراجعه» = the visit plus the services performed in it.
|
|
const title = 'مراجعه';
|
|
const subtitle = names.length ? names.join(' - ') : 'ویزیت';
|
|
const debt = session.patient_debt_rials ?? 0;
|
|
|
|
return (
|
|
<div
|
|
className="bg-[var(--surface)] border border-[var(--border)]"
|
|
style={{ minWidth: 277, minHeight: 331, borderRadius: 12, padding: 14, display: 'flex', flexDirection: 'column', gap: 10, overflow: 'hidden', boxShadow: '0 1px 6px rgba(15,23,42,0.06)' }}
|
|
>
|
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 2 }}>
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8, minWidth: 0 }}>
|
|
<FilesServiceSuccess />
|
|
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-start', gap: 3, minWidth: 0 }}>
|
|
<span style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 14, fontWeight: 600, color: 'var(--text)', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', maxWidth: 180 }}>
|
|
{title}
|
|
{session.archived && <span className="badge" style={{ fontSize: 10, color: 'var(--text-3)', border: '1px solid var(--border-2)', borderRadius: 4, padding: '1px 6px' }}>آرشیو</span>}
|
|
</span>
|
|
<span style={{ fontSize: 12, color: 'var(--text-2)', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', maxWidth: 200 }}>{subtitle}</span>
|
|
</div>
|
|
</div>
|
|
<div ref={menuRef} style={{ position: 'relative', flexShrink: 0 }}>
|
|
<button type="button" aria-label="عملیات" onClick={() => setMenuOpen((o) => !o)}
|
|
style={{ background: 'transparent', border: 'none', cursor: 'pointer', padding: 2, display: 'flex' }}>
|
|
<FilesServiceMore style={{ color: 'var(--text-3)' }} />
|
|
</button>
|
|
{menuOpen && (
|
|
<div className="card" style={{ position: 'absolute', insetInlineEnd: 0, top: '100%', marginTop: 4, minWidth: 150, zIndex: 20, padding: 4, background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 8, boxShadow: 'var(--shadow-lg)' }}>
|
|
<button type="button" onClick={() => { setMenuOpen(false); onViewInvoice(session); }}
|
|
style={{ display: 'block', width: '100%', textAlign: 'right', padding: '8px 10px', fontSize: 13, background: 'transparent', border: 'none', borderRadius: 6, cursor: 'pointer', color: 'var(--text)', fontFamily: 'inherit' }}>
|
|
مشاهده فاکتور
|
|
</button>
|
|
{onEdit && (
|
|
<button type="button" onClick={() => { setMenuOpen(false); onEdit(session); }}
|
|
style={{ display: 'block', width: '100%', textAlign: 'right', padding: '8px 10px', fontSize: 13, background: 'transparent', border: 'none', borderRadius: 6, cursor: 'pointer', color: 'var(--text)', fontFamily: 'inherit' }}>
|
|
ویرایش
|
|
</button>
|
|
)}
|
|
{onViewAudit && (
|
|
<button type="button" onClick={() => { setMenuOpen(false); onViewAudit(session); }}
|
|
style={{ display: 'block', width: '100%', textAlign: 'right', padding: '8px 10px', fontSize: 13, background: 'transparent', border: 'none', borderRadius: 6, cursor: 'pointer', color: 'var(--text)', fontFamily: 'inherit' }}>
|
|
تاریخچه تغییرات
|
|
</button>
|
|
)}
|
|
{onArchive && (
|
|
<button type="button" onClick={() => { setMenuOpen(false); onArchive(session, !session.archived); }}
|
|
style={{ display: 'block', width: '100%', textAlign: 'right', padding: '8px 10px', fontSize: 13, background: 'transparent', border: 'none', borderRadius: 6, cursor: 'pointer', color: session.archived ? 'var(--text)' : 'var(--danger)', fontFamily: 'inherit' }}>
|
|
{session.archived ? 'خروج از آرشیو' : 'آرشیو'}
|
|
</button>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<div style={{ borderTop: '1px solid var(--border)' }} />
|
|
|
|
<div style={{ display: 'flex', flexDirection: 'column', flex: 1, minHeight: 0, overflow: 'hidden', lineHeight: 1.9 }}>
|
|
<Row label="انجام دهنده" value={session.doctor_name || '—'} />
|
|
<Row label="تاریخ" value={session.created_at ? formatDate(session.created_at) : '—'} />
|
|
<Row label="توضیحات" value={session.notes || '—'} valueStyle={{ display: 'flex', textAlign: 'right', width: '100%' }} />
|
|
</div>
|
|
|
|
<div style={{ borderTop: '1px solid var(--border)' }} />
|
|
|
|
<div style={{ display: 'flex', flexDirection: 'column', width: '100%' }}>
|
|
{session.insurance_service_category_label && (
|
|
<Row label="نوع خدمت بیمه" value={session.insurance_service_category_label} />
|
|
)}
|
|
{session.insurance_base_name && <Row label="بیمه" value={session.insurance_base_name} />}
|
|
{(session.base_insurance_rials ?? 0) > 0 && (
|
|
<Row label="سهم بیمه" value={formatRial(session.base_insurance_rials ?? 0)} valueStyle={{ color: 'var(--success)', fontWeight: 500 }} />
|
|
)}
|
|
<Row label="هزینه" value={formatRial(session.final_price_rials ?? 0)} valueStyle={{ fontWeight: 500 }} />
|
|
{!paid && <Row label="مانده بدهی" value={formatRial(debt)} valueStyle={{ color: 'var(--danger)', fontWeight: 500 }} />}
|
|
</div>
|
|
|
|
<div style={{ marginTop: 'auto' }}>
|
|
{paid ? (
|
|
<button
|
|
type="button"
|
|
disabled={issuing}
|
|
onClick={() => onViewInvoice(session)}
|
|
style={{ marginTop: 8, height: 40, width: '100%', borderRadius: 4, fontSize: 13, fontWeight: 500, cursor: 'pointer', color: 'var(--primary)', background: 'transparent', border: '1px solid var(--primary)' }}
|
|
>
|
|
{issuing ? 'در حال صدور…' : 'مشاهده فاکتور'}
|
|
</button>
|
|
) : (
|
|
<button
|
|
type="button"
|
|
disabled={settling}
|
|
onClick={() => onSettle(session.uuid)}
|
|
style={{ marginTop: 8, height: 40, width: '100%', borderRadius: 4, fontSize: 13, fontWeight: 500, cursor: 'pointer', color: 'var(--on-primary)', background: 'var(--primary)', border: '1px solid var(--primary)' }}
|
|
>
|
|
تکمیل پرداخت
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|