Files
clinicpro/assets/admin/components/SessionStepper.tsx
T
hamedandClaude Opus 4.8 27d088c6dd 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>
2026-07-16 13:32:06 +03:30

64 lines
2.9 KiB
TypeScript

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>
);
}