Files
clinicpro/assets/admin/components/SessionServiceCard.tsx
T
hamed 773f9d4d16 feat: update session payment logic to ensure accurate payable amounts and reflect consumables in cost breakdown
- Adjusted the calculation of payable amounts in PaymentStep to align with server logic, ensuring overpayments are handled correctly.
- Enhanced DetailsStep to include consumables in the itemized cost breakdown, ensuring consistency with patient share calculations.
- Updated tests for SessionPaymentPage to validate new behavior regarding overpayments and consumable listings.
- Modified PatientController to register SessionPayment correctly when settling sessions via wallet, preventing double charges.
- Refactored WalletService to remove outdated methods and ensure wallet transactions reflect the correct amounts after discounts.
- Improved accessibility in SearchableSelect component by adding aria labels and ensuring proper role attributes for screen readers.
- Updated styles to ensure minimum touch targets meet WCAG guidelines for mobile usability.
2026-07-19 13:29:46 +03:30

185 lines
10 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;
base_insurance_discount_percent?: number;
supplementary_discount_percent?: number;
doctor_name?: 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: '#616161' }} className="dark:text-[#A1A1A1]">{label}:</span>
<span style={{ fontSize: 14, color: '#525252', textAlign: 'left', ...valueStyle }} className="dark:text-[#D7D8ED]">{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-white dark:bg-[#222433] border border-[#EDEDED] dark:border-[#35343D]"
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 className="dark:text-[#D7D8ED]" style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 14, fontWeight: 600, color: '#525252', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', maxWidth: 180 }}>
{title}
{session.archived && <span className="badge" style={{ fontSize: 10, color: '#9CA3AF', border: '1px solid #E0E0E0', borderRadius: 4, padding: '1px 6px' }}>آرشیو</span>}
</span>
<span className="dark:text-[#A1A1A1]" style={{ fontSize: 12, color: '#6B7280', 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: '#9CA3AF' }} />
</button>
{menuOpen && (
<div className="card dark:bg-[#222433] dark:border-[#35343D]" style={{ position: 'absolute', insetInlineEnd: 0, top: '100%', marginTop: 4, minWidth: 150, zIndex: 20, padding: 4, background: '#fff', 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)' : '#EF4444', fontFamily: 'inherit' }}>
{session.archived ? 'خروج از آرشیو' : 'آرشیو'}
</button>
)}
</div>
)}
</div>
</div>
<div className="dark:border-[#35343D]" style={{ borderTop: '1px solid #F1F1F1' }} />
<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 className="dark:border-[#35343D]" style={{ borderTop: '1px solid #F1F1F1' }} />
<div style={{ display: 'flex', flexDirection: 'column', width: '100%' }}>
<Row label="هزینه" value={formatRial(session.final_price_rials ?? 0)} valueStyle={{ fontWeight: 500 }} />
{!paid && <Row label="مانده بدهی" value={formatRial(debt)} valueStyle={{ color: '#EF4444', 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: '#4F46E5', background: 'transparent', border: '1px solid #5559ce' }}
>
{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: '#fff', background: '#5559ce', border: '1px solid #5559ce' }}
>
تکمیل پرداخت
</button>
)}
</div>
</div>
);
}