feat: port tauri create-service payment flow to admin session settlement

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>
This commit is contained in:
hamed
2026-07-16 13:32:06 +03:30
co-authored by Claude Opus 4.8
parent 73ae4baa66
commit 27d088c6dd
18 changed files with 1425 additions and 55 deletions
+22 -43
View File
@@ -1,6 +1,6 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useParams, useSearchParams, Link } from 'react-router-dom';
import { useParams, useSearchParams, Link, useNavigate } from 'react-router-dom';
import {
ChevronRightIcon, PencilIcon, ClipboardDocumentCheckIcon, DocumentTextIcon,
CalendarDaysIcon, CreditCardIcon, BanknotesIcon, ChatBubbleLeftRightIcon,
@@ -13,7 +13,7 @@ import { api } from '../lib/api';
import type { ApiResponse } from '../lib/api';
import type { PatientRecord } from '../types';
import { useAuthStore } from '../stores/authStore';
import { formatDate, formatRial, formatTime, formatNumber } from '../lib/utils';
import { formatDate, formatDateTime, formatRial, formatTime, formatNumber } from '../lib/utils';
import DataTable, { type Column } from '../components/ui/DataTable';
import type { WalletTxn } from '../hooks/usePatientWallet';
import type { WalletModalSubmit } from '../components/WalletTransactionModal';
@@ -78,8 +78,8 @@ export default function PatientDetailPage() {
const record = data?.data;
const qc = useQueryClient();
const nav = useNavigate();
const [invoiceUuid, setInvoiceUuid] = useState<string | null>(null);
const [settleTarget, setSettleTarget] = useState<string | null>(null);
// ── فرم «اطلاعات پرونده» (اینلاین، معادل tauri FileInfoSection) ──────────────
// استان/شهر (Location) و بیمهٔ پایه (insurance-pricing) برای گزینه‌های فرم.
@@ -143,19 +143,6 @@ export default function PatientDetailPage() {
.filter((a: any) => (a.starts_at ?? 0) >= nowSec && !String(a.status).startsWith('cancelled'))
.sort((a: any, b: any) => a.starts_at - b.starts_at)[0]?.starts_at ?? null;
const settle = useMutation({
mutationFn: ({ sessionUuid, method }: { sessionUuid: string; method: string }) =>
api.patch(`/api/v1/session/${sessionUuid}`, { payment_method: method }),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['patient-sessions', uuid] });
// پرداخت از کیف پول موجودی را کم می‌کند → دفتر کیف پول را هم تازه کن.
qc.invalidateQueries({ queryKey: ['patient-wallet', uuid] });
toast.success('پرداخت ثبت شد');
setSettleTarget(null);
},
onError: (e: any) => toast.error(e?.message || 'خطا در ثبت پرداخت'),
});
return (
<div className="fade-in">
{/* breadcrumb + patient banner (tauri BreadcrumbHeader + FileServicesHeader) */}
@@ -230,7 +217,7 @@ export default function PatientDetailPage() {
) : (
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(240px, 1fr))', columnGap: 6, rowGap: 10, alignItems: 'stretch' }}>
{sessions.map((s) => (
<SessionServiceCard key={s.uuid} session={s} settling={settle.isPending} onSettle={(u) => setSettleTarget(u)} onViewInvoice={(iv) => setInvoiceUuid(iv)} />
<SessionServiceCard key={s.uuid} session={s} onSettle={(u) => nav(`/admin/patients/${uuid}/session/${u}/pay`)} onViewInvoice={(iv) => setInvoiceUuid(iv)} />
))}
</div>
)}
@@ -254,28 +241,6 @@ export default function PatientDetailPage() {
)}
<InvoiceSummaryModal invoiceUuid={invoiceUuid} onClose={() => setInvoiceUuid(null)} />
{/* انتخاب روش پرداختِ مراجعه (نقدی / کارت / کیف پول) */}
<Modal open={settleTarget !== null} title="روش پرداخت مراجعه" onClose={() => setSettleTarget(null)}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
<p style={{ fontSize: 13, color: 'var(--text-3)', margin: 0 }}>روش تسویهٔ این مراجعه را انتخاب کنید:</p>
{[
{ method: 'cash', label: 'نقدی' },
{ method: 'card', label: 'کارت به کارت' },
{ method: 'wallet', label: 'کیف پول بیمار' },
].map((m) => (
<button
key={m.method}
className="btn"
style={{ justifyContent: 'flex-start' }}
disabled={settle.isPending}
onClick={() => settleTarget && settle.mutate({ sessionUuid: settleTarget, method: m.method })}
>
{m.label}
</button>
))}
</div>
</Modal>
</div>
);
}
@@ -663,6 +628,20 @@ function CallCenterTab({ uuid }: { uuid: string }) {
const [summary, setSummary] = useState('');
const [outcome, setOutcome] = useState<'success' | 'missed'>('success');
// Keep تاریخ/ساعت تماس live while the user hasn't manually edited them, so a
// long-open tab doesn't submit a stale time without a page refresh.
const dateTouched = useRef(false);
const timeTouched = useRef(false);
useEffect(() => {
const sync = () => {
if (!dateTouched.current) setDate(nowDate());
if (!timeTouched.current) setTime(nowTime());
};
const id = window.setInterval(sync, 30_000);
window.addEventListener('focus', sync);
return () => { window.clearInterval(id); window.removeEventListener('focus', sync); };
}, []);
const { data, isLoading } = useQuery<ApiResponse<Call[]>>({
queryKey: ['patient-calls', uuid],
queryFn: () => api.get(`/api/v1/patient/${uuid}/calls`),
@@ -679,7 +658,7 @@ function CallCenterTab({ uuid }: { uuid: string }) {
const calledAt = iso ? Math.floor(new Date(iso).getTime() / 1000) : Math.floor(Date.now() / 1000);
return api.post(`/api/v1/patient/${uuid}/call`, { subject, summary, outcome, called_at: calledAt, personnel: userName });
},
onSuccess: () => { invalidate(); setDate(nowDate()); setTime(nowTime()); setSubject(''); setSummary(''); setOutcome('success'); toast.success('تماس ثبت شد'); },
onSuccess: () => { invalidate(); dateTouched.current = false; timeTouched.current = false; setDate(nowDate()); setTime(nowTime()); setSubject(''); setSummary(''); setOutcome('success'); toast.success('تماس ثبت شد'); },
onError: (e: any) => toast.error(e.message),
});
const del = useMutation({
@@ -703,9 +682,9 @@ function CallCenterTab({ uuid }: { uuid: string }) {
<div style={{ width: 320, background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 'var(--r-lg)', padding: 18 }}>
<div style={{ fontSize: 14, fontWeight: 700, textAlign: 'center', marginBottom: 16 }}>ثبت تماس جدید</div>
<label style={{ fontSize: 12.5, color: 'var(--text-3)' }}>تاریخ تماس</label>
<div style={{ margin: '6px 0 12px' }}><PersianDateInput value={date} onChange={setDate} /></div>
<div style={{ margin: '6px 0 12px' }}><PersianDateInput value={date} onChange={(v) => { dateTouched.current = true; setDate(v); }} /></div>
<label style={{ fontSize: 12.5, color: 'var(--text-3)' }}>ساعت تماس</label>
<div className="field" style={{ margin: '6px 0 12px' }}><input type="time" value={time} onChange={(e) => setTime(e.target.value)} dir="ltr" /></div>
<div className="field" style={{ margin: '6px 0 12px' }}><input type="time" value={time} onChange={(e) => { timeTouched.current = true; setTime(e.target.value); }} dir="ltr" /></div>
<label style={{ fontSize: 12.5, color: 'var(--text-3)' }}>موضوع تماس</label>
<div className="field" style={{ margin: '6px 0 12px' }}><input value={subject} onChange={(e) => setSubject(e.target.value)} placeholder="موضوع تماس" /></div>
<label style={{ fontSize: 12.5, color: 'var(--text-3)' }}>خلاصه تماس</label>
@@ -746,7 +725,7 @@ function CallCenterTab({ uuid }: { uuid: string }) {
{c.summary && <div style={{ fontSize: 12.5, color: 'var(--text-2)', marginTop: 4 }}>{c.summary}</div>}
</div>
<div style={{ textAlign: 'end', minWidth: 120 }}>
<div style={{ fontSize: 12, color: 'var(--text-3)' }}>{formatDate(c.called_at)}</div>
<div style={{ fontSize: 12, color: 'var(--text-3)' }}>{formatDateTime(c.called_at)}</div>
{c.personnel && <div style={{ fontSize: 12, color: 'var(--text-2)', marginTop: 2 }}>{c.personnel}</div>}
<button className="btn sm ghost" aria-label="حذف" style={{ color: 'var(--danger)', marginTop: 4 }} onClick={() => del.mutate(c.uuid)}><TrashIcon style={{ width: 14 }} /></button>
</div>