Port the tauri /files/create-service page (payment mode) to the admin SPA
and back it with real multi-part session settlement:
Backend:
- New SessionPayment entity (session_payments table): partial payments
per session with method (wallet/pos/cash/card), amount, paid_at, actor
- PatientSession: settlement discount (percent/fixed), discount_rials,
paid_at, payments relation; remaining debt derived from
final - discount - paid total
- POST /api/v1/session/{uuid}/payments: register a partial payment;
wallet method debits the patient wallet; zero remaining marks paid
- PATCH /api/v1/session/{uuid}: accepts discount_type/discount_value
(null removes) and paid_at, backward compatible
- New error codes: ERR_SESSION_PAYMENT_INVALID/_EXCEEDS,
ERR_SESSION_DISCOUNT_INVALID
- Migration + 14 functional tests (partial/full/wallet/exceed/discount)
Frontend (admin):
- SessionPaymentPage: two-step stepper (پرداخت ← جزییات) ported from
tauri AddService payment mode — service cost, settlement discount
input, Jalali payment date, wallet balance, 4-method payment accordion,
paid-list box, details summary
- SessionStepper + stepper/payment icons ported verbatim from tauri SVGs
- «تکمیل پرداخت» on SessionServiceCard now navigates to the payment page
(replaces the small settle modal on PatientDetailPage)
- Routes for patients/ and my-patients/ variants; vitest coverage
- docs/api/patient.md updated
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
117 lines
5.4 KiB
TypeScript
117 lines
5.4 KiB
TypeScript
import 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_by_name?: string | null;
|
|
}
|
|
|
|
export interface SessionCardData {
|
|
uuid: string;
|
|
services?: Array<{ service_name?: string; name?: string }>;
|
|
visit_price_rials?: number;
|
|
doctor_name?: string | null;
|
|
final_price_rials?: number;
|
|
patient_debt_rials?: number;
|
|
is_paid?: boolean;
|
|
invoice_uuid?: string | null;
|
|
notes?: string | null;
|
|
created_at?: number;
|
|
// تسویه چندتکه + تخفیف (backend session settlement fields)
|
|
discount_type?: 'percent' | 'fixed' | null;
|
|
discount_value?: number;
|
|
discount_rials?: number;
|
|
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, settling }: {
|
|
session: SessionCardData;
|
|
onSettle: (uuid: string) => void;
|
|
onViewInvoice: (invoiceUuid: string) => void;
|
|
settling?: boolean;
|
|
}) {
|
|
const paid = !!session.is_paid;
|
|
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={{ fontSize: 14, fontWeight: 600, color: '#525252', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', maxWidth: 180 }}>{title}</span>
|
|
<span className="dark:text-[#A1A1A1]" style={{ fontSize: 12, color: '#6B7280', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', maxWidth: 200 }}>{subtitle}</span>
|
|
</div>
|
|
</div>
|
|
<FilesServiceMore style={{ color: '#9CA3AF', flexShrink: 0 }} />
|
|
</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={!session.invoice_uuid}
|
|
onClick={() => session.invoice_uuid && onViewInvoice(session.invoice_uuid)}
|
|
style={{ marginTop: 8, height: 40, width: '100%', borderRadius: 4, fontSize: 13, fontWeight: 500, cursor: 'pointer', color: '#4F46E5', background: 'transparent', border: '1px solid #5559ce' }}
|
|
>
|
|
مشاهده فاکتور
|
|
</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>
|
|
);
|
|
}
|