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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user