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
@@ -16,6 +16,7 @@ export interface SessionPaymentData {
const PAYMENT_LABELS: Record<string, string> = {
cash: 'نقدی', card: 'کارت', insurance: 'بیمه', online: 'آنلاین', pending: 'در انتظار',
wallet: 'کیف پول', pos: 'کارتخوان',
};
/** vertical hairline divider between meta columns (tauri MUI vertical Divider). */
@@ -2,6 +2,14 @@ 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 }>;
@@ -13,6 +21,13 @@ export interface SessionCardData {
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). */
@@ -0,0 +1,63 @@
import {
FilesServiceAddServiceStep,
FilesAddServicePaymentStep,
FilesServiceDetailsStep,
} from './icons/FilesServiceIcons';
/**
* استپر صفحه‌ی سرویس/تسویه — پورت tauri files/services/CustomizedStepper
* (کانکتور بنفش، آیکون دایره‌ای هر گام، تیک سبز برای گام‌های کامل‌شده).
*/
// هر برچسب گام آیکون ثابت خودش را دارد تا در حالت payment-only هم درست بماند.
const ICON_BY_LABEL: Record<string, number> = {
'ایجاد سرویس': 1,
'ویرایش': 1,
'پرداخت': 2,
'جزییات': 3,
};
function StepIcon({ index, active, completed }: { index: number; active: boolean; completed: boolean }) {
if (completed && !active) {
// دایره سفید با تیک سبز (tauri ColorlibStepIconRoot completed state)
return (
<div style={{ width: 52, height: 52, borderRadius: '50%', background: '#fff', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<svg width="32" height="32" viewBox="0 0 24 24" fill="#22C55E" xmlns="http://www.w3.org/2000/svg">
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z" />
</svg>
</div>
);
}
const shadow = active ? { boxShadow: '0 4px 10px 0 rgba(85, 89, 206, 0.25)', borderRadius: '50%' } : undefined;
switch (index) {
case 1: return <div style={shadow}><FilesServiceAddServiceStep active={active} /></div>;
case 2: return <div style={shadow}><FilesAddServicePaymentStep active={active} /></div>;
default: return <div style={shadow}><FilesServiceDetailsStep active={active} /></div>;
}
}
export default function SessionStepper({ activeStep = 0, steps = [] }: { activeStep?: number; steps?: string[] }) {
return (
<div style={{ display: 'flex', alignItems: 'flex-start', width: '100%', marginBottom: 24 }}>
{steps.map((label, i) => (
<div key={label} style={{ flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', position: 'relative' }}>
{/* connector to previous step (tauri ColorlibConnector: top 22, h 3, marginX 40) */}
{i > 0 && (
<div
data-testid={`step-connector-${i}`}
style={{
position: 'absolute', top: 22, right: 'calc(50% + 40px)', left: 'calc(-50% + 40px)',
height: 3, borderRadius: 1,
background: i <= activeStep ? 'linear-gradient(95deg, #5559ce 0%, #5559ce 100%)' : '#eaeaf0',
}}
/>
)}
<StepIcon index={ICON_BY_LABEL[label] ?? i + 1} active={i === activeStep} completed={i < activeStep} />
<span className="dark:text-[#D7D8ED]" style={{ marginTop: 8, fontSize: '0.875rem', fontWeight: 600, color: '#3b3b3b' }}>
{label}
</span>
</div>
))}
</div>
);
}
@@ -249,3 +249,112 @@ export function StatusGlobe({ color = '#616161', size = 20, style }: IconProps &
</svg>
);
}
/* ── Create-service stepper (tauri files/create-service) ─────────────────── */
type StepIconProps = { size?: number; active?: boolean };
/** Step «ایجاد سرویس/ویرایش» icon — tauri FilesServiceAddServiceStep, verbatim. */
export function FilesServiceAddServiceStep({ size = 52, active = false }: StepIconProps) {
const circleStrokeColor = active ? '#5559CE' : '#E1E1E1';
const bgColor = active ? '#5559CE' : '#E1E1E1';
const iconColor = active ? '#FAFAFA' : '#9B9B9B';
return (
<svg width={size} height={size} viewBox="0 0 52 52" fill="none" xmlns="http://www.w3.org/2000/svg" style={{ display: 'block' }}>
<circle cx="26" cy="26" r="25" stroke={circleStrokeColor} strokeWidth="2" strokeDasharray="16 2" />
<rect x="6.5" y="6.5" width="39" height="39" rx="19.5" fill={bgColor} />
<rect x="6.5" y="6.5" width="39" height="39" rx="19.5" stroke={bgColor} />
<path d="M23.5605 32V27" stroke={iconColor} strokeWidth="1.5" strokeMiterlimit="10" strokeLinecap="round" strokeLinejoin="round" />
<path d="M26 29.5H21" stroke={iconColor} strokeWidth="1.5" strokeMiterlimit="10" strokeLinecap="round" strokeLinejoin="round" />
<path d="M22 16V19" stroke={iconColor} strokeWidth="1.5" strokeMiterlimit="10" strokeLinecap="round" strokeLinejoin="round" />
<path d="M30 16V19" stroke={iconColor} strokeWidth="1.5" strokeMiterlimit="10" strokeLinecap="round" strokeLinejoin="round" />
<path d="M29.8098 17.4199C33.1498 17.5399 34.8398 18.7699 34.9398 23.4699L35.0698 29.6399C35.1498 33.7599 34.1998 35.8299 29.1998 35.9399L23.1998 36.0599C18.1998 36.1599 17.1598 34.1199 17.0798 30.0099L16.9398 23.8299C16.8398 19.1299 18.4898 17.8299 21.8098 17.5799L29.8098 17.4199Z" stroke={iconColor} strokeWidth="1.5" strokeMiterlimit="10" strokeLinecap="round" strokeLinejoin="round" />
</svg>
);
}
/** Step «پرداخت» icon — tauri FilesAddServicePaymentStep, verbatim. */
export function FilesAddServicePaymentStep({ size = 52, active = false }: StepIconProps) {
const circleStrokeColor = active ? '#5559CE' : '#E1E1E1';
const bgColor = active ? '#5559CE' : '#E1E1E1';
const iconColor = active ? '#FAFAFA' : '#9B9B9B';
return (
<svg width={size} height={size} viewBox="0 0 52 52" fill="none" xmlns="http://www.w3.org/2000/svg" style={{ display: 'block' }}>
<circle cx="26" cy="26" r="25" stroke={circleStrokeColor} strokeWidth="2" strokeDasharray="16 2" />
<rect x="6.5" y="6.5" width="39" height="39" rx="19.5" fill={bgColor} />
<rect x="6.5" y="6.5" width="39" height="39" rx="19.5" stroke={bgColor} />
<path d="M17.4297 29.8792L29.3797 17.9292" stroke={iconColor} strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
<path d="M24.6016 32.2791L25.8016 31.0791" stroke={iconColor} strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
<path d="M27.293 29.5887L29.683 27.1987" stroke={iconColor} strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
<path d="M17.1013 24.239L23.7413 17.599C25.8613 15.479 26.9213 15.469 29.0213 17.569L33.9313 22.479C36.0313 24.579 36.0213 25.639 33.9013 27.759L27.2613 34.399C25.1413 36.519 24.0813 36.529 21.9813 34.429L17.0713 29.519C14.9713 27.419 14.9713 26.369 17.1013 24.239Z" stroke={iconColor} strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
<path d="M15.5 35.9985H35.5" stroke={iconColor} strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
);
}
/** Step «جزییات» icon — tauri FilesServiceDetailsStep, verbatim. */
export function FilesServiceDetailsStep({ size = 52, active = false }: StepIconProps) {
const circleStrokeColor = active ? '#5559CE' : '#E1E1E1';
const bgColor = active ? '#5559CE' : '#E1E1E1';
const iconColor = active ? '#FAFAFA' : '#9B9B9B';
return (
<svg width={size} height={size} viewBox="0 0 52 52" fill="none" xmlns="http://www.w3.org/2000/svg" style={{ display: 'block' }}>
<circle cx="26" cy="26" r="25" stroke={circleStrokeColor} strokeWidth="2" strokeDasharray="16 2" />
<rect x="6.5" y="6.5" width="39" height="39" rx="19.5" fill={bgColor} />
<rect x="6.5" y="6.5" width="39" height="39" rx="19.5" stroke={bgColor} />
<path d="M22 16V19" stroke={iconColor} strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
<path d="M30 16V19" stroke={iconColor} strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
<path d="M21 27H29" stroke={iconColor} strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
<path d="M21 31H26" stroke={iconColor} strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
<path d="M30 17.5C33.33 17.68 35 18.95 35 23.65V29.83C35 33.95 34 36.01 29 36.01H23C18 36.01 17 33.95 17 29.83V23.65C17 18.95 18.67 17.69 22 17.5H30Z" stroke={iconColor} strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
);
}
/* ── Payment step widgets (tauri Step2Payment) ───────────────────────────── */
/** Orange «هزینه سرویس» badge — tauri Step2PaymentCard, verbatim. */
export function Step2PaymentCard({ size = 40 }: { size?: number }) {
return (
<svg width={size} height={size} viewBox="0 0 40 40" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="40" height="40" rx="20" fill="#FFF4E5" />
<path d="M27 23.5C28.9339 23.5 30.5 25.0661 30.5 27C30.5 27.6563 30.316 28.2758 29.9941 28.7979L29.9902 28.8047C29.387 29.8186 28.2772 30.5 27 30.5C25.8025 30.5 24.7525 29.9008 24.1289 28.9902L24.0098 28.8047L24.0059 28.7979L23.8916 28.5977C23.6407 28.1204 23.5 27.5743 23.5 27C23.5 25.0661 25.0661 23.5 27 23.5ZM29.4756 25.1689C29.0107 24.6708 28.216 24.6333 27.7109 25.1025L26.4629 26.2559L26.3232 26.1162C25.838 25.6313 25.0418 25.6313 24.5566 26.1162C24.0714 26.6015 24.0714 27.3985 24.5566 27.8838L25.5469 28.874C25.7954 29.1223 26.1145 29.2402 26.4297 29.2402C26.7311 29.2402 27.0438 29.1255 27.2793 28.9072L29.4092 26.9375L29.418 26.9297C29.9048 26.459 29.9501 25.6774 29.4756 25.1689Z" fill="#F17732" stroke="#F17732" />
<path d="M14.1396 11.8999H25.8496C27.8635 11.8999 29.5 13.5364 29.5 15.5503V15.9995C29.5 16.2734 29.2739 16.4995 29 16.4995H11C10.7261 16.4995 10.5 16.2734 10.5 15.9995V15.5396C10.5002 13.526 12.1261 11.9001 14.1396 11.8999Z" fill="#F17732" stroke="#F17732" />
<path d="M29 18.9902C29.2739 18.9902 29.5 19.2164 29.5 19.4902V20.8799C29.5 21.2157 29.1673 21.4608 28.8301 21.3467H28.8291C27.6616 20.9539 26.3422 20.9004 24.9873 21.334C24.1629 21.5978 23.4064 22.0584 22.792 22.6611C21.4095 24.003 20.8496 25.6792 20.9111 27.2607L20.9316 27.5762C20.9516 27.831 20.7192 28.1004 20.4004 28.1006H14.1396C12.126 28.1004 10.5 26.4737 10.5 24.46V19.5C10.5 19.2239 10.7239 19 11 18.9902H29ZM14 23.25C13.3139 23.25 12.75 23.814 12.75 24.5C12.75 25.1861 13.3139 25.75 14 25.75H16C16.6861 25.75 17.25 25.1861 17.25 24.5C17.25 23.814 16.6861 23.25 16 23.25H14Z" fill="#F17732" stroke="#F17732" />
</svg>
);
}
/** «موجودی کیف پول» icon — tauri FilesServiceBalanceWallet, verbatim. */
export function FilesServiceBalanceWallet({ size = 20 }: { size?: number }) {
return (
<svg width={size} height={size} viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M10.833 9.2915H5.83301" stroke="#F17732" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
<path d="M1.66699 9.2917V5.4417C1.66699 3.7417 3.04199 2.3667 4.74199 2.3667H9.42532C11.1253 2.3667 12.5003 3.42503 12.5003 5.12503" stroke="#F17732" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
<path d="M14.567 10.1667C14.1503 10.5667 13.9503 11.1833 14.117 11.8166C14.3253 12.5916 15.092 13.0833 15.892 13.0833H16.667V14.2917C16.667 16.1333 15.1753 17.625 13.3337 17.625H5.00033C3.15866 17.625 1.66699 16.1333 1.66699 14.2917V8.45833C1.66699 6.61667 3.15866 5.125 5.00033 5.125H13.3337C15.167 5.125 16.667 6.625 16.667 8.45833V9.66663H15.767C15.3003 9.66663 14.8753 9.84999 14.567 10.1667Z" stroke="#F17732" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
<path d="M18.3338 10.5165V12.2332C18.3338 12.6999 17.9505 13.0832 17.4755 13.0832H15.8672C14.9672 13.0832 14.1422 12.4249 14.0672 11.5249C14.0172 10.9999 14.2172 10.5082 14.5672 10.1665C14.8755 9.84987 15.3005 9.6665 15.7672 9.6665H17.4755C17.9505 9.6665 18.3338 10.0499 18.3338 10.5165Z" stroke="#F17732" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
);
}
/** Red trash icon (حذف تخفیف) — tauri TrashRed, verbatim. */
export function TrashRed({ size = 20, color = '#FF5450' }: { size?: number; color?: string }) {
return (
<svg xmlns="http://www.w3.org/2000/svg" width={size} height={size} viewBox="0 0 20 20" fill="none">
<path d="M17.5 4.98356C14.725 4.70856 11.9333 4.56689 9.15 4.56689C7.5 4.56689 5.85 4.65023 4.2 4.81689L2.5 4.98356" stroke={color} strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
<path d="M7.08301 4.1415L7.26634 3.04984C7.39967 2.25817 7.49967 1.6665 8.90801 1.6665H11.0913C12.4997 1.6665 12.608 2.2915 12.733 3.05817L12.9163 4.1415" stroke={color} strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
<path d="M15.7087 7.6167L15.167 16.0084C15.0753 17.3167 15.0003 18.3334 12.6753 18.3334H7.32533C5.00033 18.3334 4.92533 17.3167 4.83366 16.0084L4.29199 7.6167" stroke={color} strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
<path d="M8.6084 13.75H11.3834" stroke={color} strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
<path d="M7.91699 10.4165H12.0837" stroke={color} strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
);
}
/** Modal close (X) — tauri CloseModalD, verbatim. */
export function CloseModalD() {
return (
<svg xmlns="http://www.w3.org/2000/svg" width="17" height="17" viewBox="0 0 17 17" fill="none">
<path className="fill-[#7E7E7E] dark:fill-[#FAFAFA]" d="M12.5947 4.20135C12.533 4.13954 12.4597 4.09051 12.3791 4.05706C12.2984 4.0236 12.212 4.00638 12.1247 4.00638C12.0374 4.00638 11.9509 4.0236 11.8703 4.05706C11.7896 4.09051 11.7164 4.13954 11.6547 4.20135L8.39468 7.45468L5.13468 4.19468C5.07296 4.13296 4.99969 4.084 4.91904 4.0506C4.8384 4.01719 4.75197 4 4.66468 4C4.57739 4 4.49096 4.01719 4.41032 4.0506C4.32968 4.084 4.2564 4.13296 4.19468 4.19468C4.13296 4.2564 4.084 4.32968 4.0506 4.41032C4.01719 4.49096 4 4.57739 4 4.66468C4 4.75197 4.01719 4.8384 4.0506 4.91904C4.084 4.99969 4.13296 5.07296 4.19468 5.13468L7.45468 8.39468L4.19468 11.6547C4.13296 11.7164 4.084 11.7897 4.0506 11.8703C4.01719 11.951 4 12.0374 4 12.1247C4 12.212 4.01719 12.2984 4.0506 12.379C4.084 12.4597 4.13296 12.533 4.19468 12.5947C4.2564 12.6564 4.32968 12.7054 4.41032 12.7388C4.49096 12.7722 4.57739 12.7894 4.66468 12.7894C4.75197 12.7894 4.8384 12.7722 4.91904 12.7388C4.99969 12.7054 5.07296 12.6564 5.13468 12.5947L8.39468 9.33468L11.6547 12.5947C11.7164 12.6564 11.7897 12.7054 11.8703 12.7388C11.951 12.7722 12.0374 12.7894 12.1247 12.7894C12.212 12.7894 12.2984 12.7722 12.379 12.7388C12.4597 12.7054 12.533 12.6564 12.5947 12.5947C12.6564 12.533 12.7054 12.4597 12.7388 12.379C12.7722 12.2984 12.7894 12.212 12.7894 12.1247C12.7894 12.0374 12.7722 11.951 12.7388 11.8703C12.7054 11.7897 12.6564 11.7164 12.5947 11.6547L9.33468 8.39468L12.5947 5.13468C12.848 4.88135 12.848 4.45468 12.5947 4.20135Z" fill="#7E7E7E" />
</svg>
);
}