A clinic owner configures insurance on the doctor (`doctor_uuid`), but an
appointment booked at the clinic belongs to the clinic — so at confirm time
the engine looked for contracts under the clinic, found none, and the operator
had no insurance to pick and no way to save one ("this insurance has no active
contract"). The two sides were writing and reading different tenants.
Contracts, service kinds and the visit price now resolve doctor-first with the
appointment's clinic as fallback, each judged separately: a doctor who holds
their own contracts but leaves the visit price to the clinic gets each from the
right place. The confirm modal asks the same question the engine answers, via
`inherit=1` on the two read endpoints; the settings pages deliberately do not
send it, since editing must target the doctor's own row.
Two further things came out of the same sweep. The service-kind settings
repository had the tenant-filter blindness already fixed for contracts and
pricing — reads pinned to the caller's environment while the target is another
tenant — so it is now exempted the same way. And a coverage percentage of zero
is accepted as a real choice meaning "this contract does not cover that service
kind"; what is still rejected is leaving an enabled kind with no percentage at
all, inheriting a central default of zero included.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
670 lines
30 KiB
TypeScript
670 lines
30 KiB
TypeScript
import { useEffect, useMemo, useRef, useState } from 'react';
|
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
|
import { toast } from 'sonner';
|
|
import { UserCircleIcon, PlusIcon, TrashIcon } from '@heroicons/react/24/outline';
|
|
import { api } from '../../lib/api';
|
|
import type { ApiResponse } from '../../lib/api';
|
|
import type { BankAccount, Pos } from '../../hooks/usePaymentMethods';
|
|
import { formatNumber, formatRial, rialToToman, tomanToRial } from '../../lib/utils';
|
|
import { DEFAULT_SERVICE_CATEGORY } from '../../lib/insuranceShares';
|
|
import { useAppointmentInsurance } from '../../hooks/useAppointmentInsurance';
|
|
import Modal from '../ui/Modal';
|
|
import PriceInput from '../ui/PriceInput';
|
|
import SearchableSelect from '../ui/SearchableSelect';
|
|
import Stepper from '../ui/Stepper';
|
|
|
|
/** میانبُرهای مبلغ روی هر ردیف پرداخت — درصدی از سهم بیمار. */
|
|
const PAYMENT_PERCENTS = [20, 50, 70, 100];
|
|
|
|
/** همان چهار روشِ SessionPayment::METHODS در بکاند. */
|
|
const METHOD_OPTIONS = [
|
|
{ value: 'cash', label: 'پرداخت نقدی' },
|
|
{ value: 'pos', label: 'پرداخت از طریق کارت خوان' },
|
|
{ value: 'card', label: 'کارت به کارت' },
|
|
{ value: 'wallet', label: 'پرداخت از طریق کیف پول' },
|
|
];
|
|
|
|
interface ServiceItem {
|
|
uuid: string;
|
|
name: string;
|
|
price_rials?: number | null;
|
|
service_category?: string | null;
|
|
insurance_covered?: boolean;
|
|
}
|
|
|
|
interface AppointmentLike {
|
|
uuid: string;
|
|
version?: number;
|
|
visit_price_rials?: number | null;
|
|
service_items?: ServiceItem[] | null;
|
|
patient_name?: string | null;
|
|
insurance_service_category?: string | null;
|
|
insurance_base_id?: number | null;
|
|
insurance_supplementary_id?: number | null;
|
|
doctor?: { uuid?: string | null } | null;
|
|
}
|
|
|
|
interface Props {
|
|
open: boolean;
|
|
appointmentUuid: string;
|
|
/** اگر صفحه از قبل نوبت را دارد، پاس بده تا درخواست اضافه نرود. */
|
|
appointment?: AppointmentLike | null;
|
|
onClose: () => void;
|
|
/** کلید کوئریِ لیستی که بعد از قطعیشدن باید invalidate شود. */
|
|
queryKey?: unknown[];
|
|
}
|
|
|
|
/**
|
|
* مراحلِ قطعی کردن: اول «چقدر»، بعد «چطور»، آخر «تأیید».
|
|
*
|
|
* فرم قبلاً یک صفحهٔ بلند بود — بیمه، جدول هزینه، ردیفهای پرداخت و خلاصه با هم — و
|
|
* با دو روش پرداخت از ارتفاع صفحه بلندتر میشد.
|
|
*/
|
|
const CONFIRM_STEPS = [
|
|
{ key: 'cost', title: 'بیمه و هزینه' },
|
|
{ key: 'payment', title: 'پرداخت' },
|
|
{ key: 'review', title: 'تأیید' },
|
|
] as const;
|
|
|
|
type ConfirmStepKey = (typeof CONFIRM_STEPS)[number]['key'];
|
|
|
|
/** یک ردیفِ پرداخت در تسویهٔ چندروشی. */
|
|
interface PaymentRow {
|
|
id: number;
|
|
method: string;
|
|
amountToman: number;
|
|
/** uuid کارتخوان (pos) یا حساب بانکیِ (card) ثبتشده. */
|
|
methodUuid: string;
|
|
/** شناسه تراکنش / شماره پیگیری. */
|
|
reference: string;
|
|
}
|
|
|
|
const rowStyle: React.CSSProperties = {
|
|
display: 'flex',
|
|
justifyContent: 'space-between',
|
|
alignItems: 'center',
|
|
gap: 12,
|
|
padding: '9px 0',
|
|
fontSize: 13.5,
|
|
color: 'var(--text-2)',
|
|
};
|
|
|
|
/** رنگ چیپِ «وضعیت پرداخت» بر اساس نسبت پرداخت به جمع کل. */
|
|
const STATE_TONE: Record<string, { fg: string; bg: string }> = {
|
|
'بدون پرداخت': { fg: 'var(--text-2)', bg: 'var(--surface-3)' },
|
|
'پرداخت جزئی': { fg: 'var(--warning)', bg: 'var(--warning-bg)' },
|
|
'تسویه کامل': { fg: 'var(--success)', bg: 'var(--success-bg)' },
|
|
};
|
|
|
|
/**
|
|
* «قطعی کردن نوبت» — هزینههای نوبت را نشان میدهد، پرداخت را بین چند روش
|
|
* (نقدی/کارتخوان/کارتبهکارت/کیف پول) تقسیم میکند و نوبت را «قطعی» میکند.
|
|
*
|
|
* سرور همین یک درخواست را اتمیک انجام میدهد: وضعیت + پرونده/مراجعه + پرداختها.
|
|
* مبلغ پرداختی میتواند کمتر از جمع کل باشد (پرداخت جزئی)، ولی نباید بیشتر شود.
|
|
*/
|
|
export default function ConfirmAppointmentModal({
|
|
open,
|
|
appointmentUuid,
|
|
appointment,
|
|
onClose,
|
|
queryKey,
|
|
}: Props) {
|
|
const qc = useQueryClient();
|
|
const nextId = useRef(1);
|
|
const makeRow = (over: Partial<PaymentRow> = {}): PaymentRow => ({
|
|
id: nextId.current++, method: 'cash', amountToman: 0, methodUuid: '', reference: '', ...over,
|
|
});
|
|
const [rows, setRows] = useState<PaymentRow[]>([makeRow()]);
|
|
/** تا وقتی کاربر مبلغ را دست نزده، ردیفِ اول با کل مبلغ پر میماند. */
|
|
const [touched, setTouched] = useState(false);
|
|
const [stepIdx, setStepIdx] = useState(0);
|
|
|
|
// جزئیات همیشه گرفته میشود، حتی وقتی صفحهٔ میزبان نوبتی پاس داده است: ردیفِ
|
|
// فهرست نوبتها `visit_price_rials` و بیمه را ندارد، و مودال با اعتماد به همان
|
|
// ردیف، هزینهٔ ویزیتِ ثبتشده را صفر نشان میداد و همان صفر را هم ثبت میکرد.
|
|
// مبلغ چیزی نیست که از یک payload ناقص حدس زده شود.
|
|
const detailQuery = useQuery({
|
|
queryKey: ['appointment', appointmentUuid],
|
|
queryFn: () => api.get<ApiResponse<AppointmentLike>>(`/api/v1/appointment/${appointmentUuid}`),
|
|
enabled: open,
|
|
});
|
|
|
|
// روشهای پرداختِ ثبتشده — فقط وقتی مودال باز است.
|
|
const posQuery = useQuery<ApiResponse<Pos[]>>({
|
|
queryKey: ['payment-methods', 'pos'],
|
|
queryFn: () => api.get('/api/v1/my/payment-methods/pos'),
|
|
enabled: open,
|
|
});
|
|
const bankQuery = useQuery<ApiResponse<BankAccount[]>>({
|
|
queryKey: ['payment-methods', 'bank-accounts'],
|
|
queryFn: () => api.get('/api/v1/my/payment-methods/bank-accounts'),
|
|
enabled: open,
|
|
});
|
|
const posOptions = useMemo(
|
|
() => ((posQuery.data?.data ?? []) as Pos[])
|
|
.filter(p => p.is_active)
|
|
.map(p => ({ value: p.uuid, label: `${p.bank_name} — ${p.terminal_number}` })),
|
|
[posQuery.data],
|
|
);
|
|
const bankOptions = useMemo(
|
|
() => ((bankQuery.data?.data ?? []) as BankAccount[])
|
|
.filter(b => b.is_active)
|
|
.map(b => ({ value: b.uuid, label: `${b.bank_name}${b.card_number ? ` — ${b.card_number}` : ''}` })),
|
|
[bankQuery.data],
|
|
);
|
|
|
|
// پاسخ سرور مرجع است؛ نوبتِ پاسدادهشده فقط تا رسیدنِ آن، صفحه را خالی نگه نمیدارد.
|
|
const detail: AppointmentLike | null =
|
|
(detailQuery.data?.data as any)?.data ?? detailQuery.data?.data ?? null;
|
|
const appt: AppointmentLike | null = detail ?? appointment ?? null;
|
|
|
|
// ── بیمه: نوع خدمت + بیمهٔ پایهٔ نوبت ──────────────────────────────────────
|
|
const insurance = useAppointmentInsurance(open, appt?.doctor?.uuid ?? null);
|
|
|
|
// نوبتِ بدون هزینهٔ ویزیت، سرِ ساختِ مراجعه «قیمت ویزیت آزاد» تنظیمات را میگیرد؛
|
|
// مودال هم باید همان را نشان دهد، وگرنه صفر نشان میدهد و مبلغ ثبتشده فرق میکند.
|
|
const visitPrice = insurance.visitPriceOf(appt?.visit_price_rials);
|
|
const services = appt?.service_items ?? [];
|
|
const servicesTotal = useMemo(
|
|
() => services.reduce((sum, s) => sum + Number(s.price_rials ?? 0), 0),
|
|
[services],
|
|
);
|
|
const total = visitPrice + servicesTotal;
|
|
const [serviceCategory, setServiceCategory] = useState<string>('');
|
|
const [insuranceId, setInsuranceId] = useState<string>('');
|
|
const [supplementaryId, setSupplementaryId] = useState<string>('');
|
|
|
|
// مقدارِ نوبت مبنا است؛ در نبودش نوع پیشفرضِ tenant.
|
|
useEffect(() => {
|
|
if (!open) return;
|
|
setServiceCategory(appt?.insurance_service_category ?? insurance.defaultCategory ?? '');
|
|
setInsuranceId(appt?.insurance_base_id ? String(appt.insurance_base_id) : '');
|
|
setSupplementaryId(appt?.insurance_supplementary_id ? String(appt.insurance_supplementary_id) : '');
|
|
}, [open, appt?.uuid, insurance.defaultCategory]);
|
|
|
|
const effectiveCategory = serviceCategory || insurance.defaultCategory || DEFAULT_SERVICE_CATEGORY;
|
|
|
|
// آینهٔ سرور: ویزیت با نوع انتخابی، هر خدمت با نوع خودش.
|
|
const shares = useMemo(() => insurance.breakdown([
|
|
{ total: visitPrice, category: effectiveCategory, insured: true },
|
|
...services.map(s => ({
|
|
total: Number(s.price_rials ?? 0),
|
|
category: s.service_category ?? DEFAULT_SERVICE_CATEGORY,
|
|
insured: s.insurance_covered !== false,
|
|
})),
|
|
], insuranceId, supplementaryId), [visitPrice, services, effectiveCategory, insuranceId, supplementaryId, insurance.breakdown]);
|
|
|
|
const hasInsurance = !!insuranceId || !!supplementaryId;
|
|
const payable = hasInsurance ? shares.patient : total;
|
|
|
|
// ردیفِ اول تا لحظهای که کاربر مبلغ را دستی تغییر ندهد پیشفرضِ «پرداخت کامل» است؛
|
|
// نوبت هنوز session ندارد، پس باقیماندهاش برابر مبلغِ قابل پرداخت است.
|
|
useEffect(() => {
|
|
if (!open || touched || payable <= 0) return;
|
|
setRows(prev => prev.map((r, i) => (i === 0 ? { ...r, amountToman: rialToToman(payable) } : r)));
|
|
}, [open, touched, payable]);
|
|
|
|
const paidRials = useMemo(
|
|
() => rows.reduce((sum, r) => sum + tomanToRial(r.amountToman), 0),
|
|
[rows],
|
|
);
|
|
const remaining = Math.max(0, payable - paidRials);
|
|
const overpaid = paidRials > payable;
|
|
|
|
const paymentState = paidRials === 0
|
|
? 'بدون پرداخت'
|
|
: remaining === 0
|
|
? 'تسویه کامل'
|
|
: 'پرداخت جزئی';
|
|
|
|
const confirmMut = useMutation({
|
|
mutationFn: () =>
|
|
api.post<ApiResponse<unknown>>(`/api/v1/appointment/${appointmentUuid}/confirm`, {
|
|
version: appt?.version,
|
|
...(serviceCategory ? { insurance_service_category: serviceCategory } : {}),
|
|
...(insuranceId ? { insurance_base_id: Number(insuranceId) } : {}),
|
|
...(supplementaryId ? { insurance_supplementary_id: Number(supplementaryId) } : {}),
|
|
payments: rows
|
|
.filter(r => tomanToRial(r.amountToman) > 0)
|
|
.map(r => ({
|
|
method: r.method,
|
|
amount_rials: tomanToRial(r.amountToman),
|
|
...(r.methodUuid ? { payment_method_uuid: r.methodUuid } : {}),
|
|
...(r.reference.trim() ? { reference: r.reference.trim() } : {}),
|
|
})),
|
|
}),
|
|
onSuccess: () => {
|
|
toast.success('نوبت قطعی شد');
|
|
if (queryKey) qc.invalidateQueries({ queryKey });
|
|
qc.invalidateQueries({ queryKey: ['appointment', appointmentUuid] });
|
|
qc.invalidateQueries({ queryKey: ['appointment-events', appointmentUuid] });
|
|
reset();
|
|
onClose();
|
|
},
|
|
onError: (e: any) => toast.error(e?.message || 'قطعی کردن نوبت ناموفق بود'),
|
|
});
|
|
|
|
function reset() {
|
|
nextId.current = 1;
|
|
setRows([makeRow()]);
|
|
setTouched(false);
|
|
setStepIdx(0);
|
|
}
|
|
|
|
function patchRow(id: number, patch: Partial<PaymentRow>) {
|
|
setTouched(true);
|
|
setRows(prev => prev.map(r => (r.id === id ? { ...r, ...patch } : r)));
|
|
}
|
|
|
|
/** روش که عوض شد، جزئیاتِ مخصوصِ روشِ قبلی بیمعنا میشود. */
|
|
function changeMethod(id: number, method: string) {
|
|
patchRow(id, { method, methodUuid: '', reference: '' });
|
|
}
|
|
|
|
/** درصدِ سهم بیمار روی همان ردیف مینشیند؛ ردیفهای دیگر دستنخورده میمانند. */
|
|
function applyPercent(id: number, percent: number) {
|
|
patchRow(id, { amountToman: rialToToman(Math.round((payable * percent) / 100)) });
|
|
}
|
|
|
|
function addRow() {
|
|
setTouched(true);
|
|
// ردیفِ جدید پیشفرض با باقیمانده پر میشود تا تسویه سریعتر باشد.
|
|
setRows(prev => [...prev, makeRow({ amountToman: rialToToman(remaining) })]);
|
|
}
|
|
|
|
function removeRow(id: number) {
|
|
setTouched(true);
|
|
setRows(prev => (prev.length > 1 ? prev.filter(r => r.id !== id) : prev));
|
|
}
|
|
|
|
function handleClose() {
|
|
reset();
|
|
onClose();
|
|
}
|
|
|
|
// تا رسیدنِ جزئیات، دکمهها قفلاند: کاربر نباید روی مبلغی که هنوز از سرور نیامده
|
|
// «مرحلهٔ بعد» بزند و پرداختِ صفر ثبت کند.
|
|
const loading = detailQuery.isLoading;
|
|
const currentStep: ConfirmStepKey = CONFIRM_STEPS[Math.min(stepIdx, CONFIRM_STEPS.length - 1)].key;
|
|
const isLastStep = currentStep === 'review';
|
|
|
|
return (
|
|
<Modal
|
|
open={open}
|
|
title="قطعی کردن نوبت"
|
|
size="md"
|
|
onClose={handleClose}
|
|
footer={
|
|
<>
|
|
{/* بستنِ فرم همیشه یک کلیک است؛ «مرحلهٔ قبل» جایش را نمیگیرد. */}
|
|
<button type="button" className="btn ghost" onClick={handleClose}>
|
|
انصراف
|
|
</button>
|
|
{stepIdx > 0 && (
|
|
<button type="button" className="btn secondary" onClick={() => setStepIdx(i => i - 1)}>
|
|
مرحلهٔ قبل
|
|
</button>
|
|
)}
|
|
{isLastStep ? (
|
|
<button
|
|
type="button"
|
|
className="btn primary"
|
|
disabled={loading || overpaid || confirmMut.isPending}
|
|
onClick={() => confirmMut.mutate()}
|
|
>
|
|
{confirmMut.isPending ? 'در حال ثبت…' : 'تأیید و قطعی کردن'}
|
|
</button>
|
|
) : (
|
|
<button
|
|
type="button"
|
|
className="btn primary"
|
|
disabled={loading || overpaid}
|
|
onClick={() => setStepIdx(i => i + 1)}
|
|
>
|
|
مرحلهٔ بعد
|
|
</button>
|
|
)}
|
|
</>
|
|
}
|
|
>
|
|
{loading ? (
|
|
<p style={{ color: 'var(--text-2)' }}>در حال دریافت اطلاعات نوبت…</p>
|
|
) : (
|
|
<>
|
|
{appt?.patient_name && (
|
|
<div
|
|
style={{
|
|
display: 'flex', alignItems: 'center', gap: 10, marginBottom: 16,
|
|
padding: '10px 14px', borderRadius: 'var(--r-sm)',
|
|
background: 'var(--primary-soft)',
|
|
}}
|
|
>
|
|
<UserCircleIcon style={{ width: 20, height: 20, color: 'var(--primary-700)', flexShrink: 0 }} />
|
|
<span style={{ fontSize: 13.5, color: 'var(--text-2)' }}>بیمار</span>
|
|
<strong style={{ fontSize: 14, color: 'var(--primary-700)' }}>{appt.patient_name}</strong>
|
|
</div>
|
|
)}
|
|
|
|
<Stepper
|
|
steps={CONFIRM_STEPS.map(st => ({ key: st.key, title: st.title }))}
|
|
current={currentStep}
|
|
ariaLabel="مراحل قطعی کردن نوبت"
|
|
/>
|
|
|
|
{currentStep === 'cost' && (
|
|
<>
|
|
{/* بیمه — نوع خدمت فقط وقتی چند نوع فعال است پرسیده میشود. */}
|
|
<div style={{ display: 'flex', gap: 10, marginBottom: 16, flexWrap: 'wrap' }}>
|
|
{insurance.needsCategoryChoice && (
|
|
<div className="field-block" style={{ flex: 1, minWidth: 180 }}>
|
|
<label>نوع خدمت</label>
|
|
<SearchableSelect
|
|
value={serviceCategory}
|
|
onChange={(v) => setServiceCategory(v == null ? '' : String(v))}
|
|
options={insurance.categoryOptions}
|
|
placeholder="انتخاب نوع خدمت"
|
|
height={40}
|
|
/>
|
|
</div>
|
|
)}
|
|
<div className="field-block" style={{ flex: 1, minWidth: 180 }}>
|
|
<label>بیمه پایه</label>
|
|
<SearchableSelect
|
|
value={insuranceId}
|
|
onChange={(v) => setInsuranceId(v == null ? '' : String(v))}
|
|
options={insurance.insuranceOptions}
|
|
placeholder="بدون بیمه"
|
|
noOptionsMessage="قرارداد بیمهٔ فعالی ندارید"
|
|
isClearable
|
|
height={40}
|
|
/>
|
|
</div>
|
|
{insurance.hasSupplementary && (
|
|
<div className="field-block" style={{ flex: 1, minWidth: 180 }}>
|
|
<label>بیمه تکمیلی</label>
|
|
<SearchableSelect
|
|
value={supplementaryId}
|
|
onChange={(v) => setSupplementaryId(v == null ? '' : String(v))}
|
|
options={insurance.supplementaryOptions}
|
|
placeholder="بدون بیمه تکمیلی"
|
|
isClearable
|
|
height={40}
|
|
/>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* هزینهها */}
|
|
<div
|
|
style={{
|
|
border: '1px solid var(--border)', borderRadius: 'var(--r)',
|
|
padding: '4px 14px 10px', marginBottom: 20,
|
|
}}
|
|
>
|
|
<div style={rowStyle}>
|
|
<span>ویزیت</span>
|
|
<strong style={{ color: 'var(--text)' }}>{formatRial(visitPrice)}</strong>
|
|
</div>
|
|
{services.map((s) => (
|
|
<div key={s.uuid} style={{ ...rowStyle, borderTop: '1px solid var(--border)' }}>
|
|
<span>{s.name}</span>
|
|
<strong style={{ color: 'var(--text)' }}>{formatRial(Number(s.price_rials ?? 0))}</strong>
|
|
</div>
|
|
))}
|
|
<div style={{ ...rowStyle, borderTop: '1px solid var(--border)' }}>
|
|
<span>جمع کل</span>
|
|
<strong style={{ color: 'var(--text)' }}>{formatRial(total)}</strong>
|
|
</div>
|
|
{/* سهمها زنجیرهایاند: پایه روی کل، تکمیلی روی باقیمانده — جدا نشان داده
|
|
میشوند تا معلوم باشد هرکدام چقدر برداشتهاند. */}
|
|
{insuranceId !== '' && (
|
|
<div style={{ ...rowStyle, borderTop: '1px solid var(--border)' }}>
|
|
<span>
|
|
سهم بیمه پایه
|
|
{insurance.insuranceNameOf(insuranceId) ? ` — ${insurance.insuranceNameOf(insuranceId)}` : ''}
|
|
{insurance.categoryLabelOf(effectiveCategory) ? ` (${insurance.categoryLabelOf(effectiveCategory)})` : ''}
|
|
</span>
|
|
<strong style={{ color: 'var(--success)' }}>{formatRial(shares.base)}</strong>
|
|
</div>
|
|
)}
|
|
{supplementaryId !== '' && (
|
|
<div style={{ ...rowStyle, borderTop: '1px solid var(--border)' }}>
|
|
<span>
|
|
سهم بیمه تکمیلی
|
|
{insurance.insuranceNameOf(supplementaryId) ? ` — ${insurance.insuranceNameOf(supplementaryId)}` : ''}
|
|
<span style={{ color: 'var(--text-3)', fontSize: 11.5 }}> (روی باقیمانده)</span>
|
|
</span>
|
|
<strong style={{ color: 'var(--success)' }}>{formatRial(shares.supplementary)}</strong>
|
|
</div>
|
|
)}
|
|
<div
|
|
style={{
|
|
...rowStyle, borderTop: '1px solid var(--border)', marginTop: 2,
|
|
paddingTop: 12, fontSize: 14, fontWeight: 700, color: 'var(--text)',
|
|
}}
|
|
>
|
|
<span>{hasInsurance ? 'سهم بیمار (قابل پرداخت)' : 'مبلغ قابل پرداخت'}</span>
|
|
<strong style={{ fontSize: 16, color: 'var(--primary)' }}>{formatRial(payable)}</strong>
|
|
</div>
|
|
</div>
|
|
|
|
</>
|
|
)}
|
|
|
|
{currentStep === 'payment' && (
|
|
<>
|
|
{/* پرداختها — تقسیم بین چند روش */}
|
|
<div style={{ marginBottom: 12 }}>
|
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}>
|
|
<label style={{ fontSize: 13.5, fontWeight: 700, color: 'var(--text)' }}>روشهای پرداخت</label>
|
|
<button type="button" className="btn ghost sm" onClick={addRow}>
|
|
<PlusIcon style={{ width: 15, height: 15 }} />
|
|
افزودن روش
|
|
</button>
|
|
</div>
|
|
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
|
{rows.map((r) => (
|
|
<div
|
|
key={r.id}
|
|
style={{
|
|
border: '1px solid var(--border)', borderRadius: 'var(--r-sm)',
|
|
padding: 12, display: 'flex', flexDirection: 'column', gap: 10,
|
|
}}
|
|
>
|
|
<div style={{ display: 'flex', gap: 10, alignItems: 'flex-end' }}>
|
|
<div className="field-block" style={{ flex: 1 }}>
|
|
<label>روش پرداخت</label>
|
|
<SearchableSelect
|
|
value={r.method}
|
|
onChange={(v) => changeMethod(r.id, String(v ?? 'cash'))}
|
|
options={METHOD_OPTIONS}
|
|
placeholder="روش پرداخت"
|
|
height={40}
|
|
/>
|
|
</div>
|
|
<div className="field-block" style={{ flex: 1 }}>
|
|
<label>مبلغ (تومان)</label>
|
|
<div className="field">
|
|
<PriceInput
|
|
value={r.amountToman}
|
|
onChange={(v) => patchRow(r.id, { amountToman: v })}
|
|
suffix="تومان"
|
|
/>
|
|
</div>
|
|
</div>
|
|
{rows.length > 1 && (
|
|
<button
|
|
type="button"
|
|
className="btn ghost sm"
|
|
aria-label="حذف روش پرداخت"
|
|
onClick={() => removeRow(r.id)}
|
|
style={{ marginBottom: 2, color: 'var(--danger)' }}
|
|
>
|
|
<TrashIcon style={{ width: 16, height: 16 }} />
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
{/* میانبُرهای درصدی — پرداخت جزئی رایج است و تایپ دستیِ مبلغ خطا میآورد. */}
|
|
{payable > 0 && (
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: 6, flexWrap: 'wrap' }}>
|
|
{PAYMENT_PERCENTS.map((percent) => {
|
|
const active = tomanToRial(r.amountToman) === Math.round((payable * percent) / 100);
|
|
return (
|
|
<button
|
|
key={percent}
|
|
type="button"
|
|
className="btn sm"
|
|
onClick={() => applyPercent(r.id, percent)}
|
|
title={formatRial(Math.round((payable * percent) / 100))}
|
|
style={{
|
|
height: 30,
|
|
padding: '0 12px',
|
|
fontSize: 12,
|
|
borderRadius: 'var(--r-pill)',
|
|
border: `1px solid ${active ? 'var(--primary)' : 'var(--border)'}`,
|
|
background: active ? 'var(--primary-soft)' : 'var(--surface)',
|
|
color: active ? 'var(--primary)' : 'var(--text-2)',
|
|
fontWeight: active ? 700 : 500,
|
|
}}
|
|
>
|
|
{formatNumber(percent)}٪
|
|
</button>
|
|
);
|
|
})}
|
|
<span style={{ fontSize: 12, color: 'var(--text-3)' }}>
|
|
از {formatRial(payable)}
|
|
</span>
|
|
</div>
|
|
)}
|
|
|
|
{/* جزئیاتِ کارتخوان: انتخاب دستگاهِ ثبتشده + شناسه تراکنش */}
|
|
{r.method === 'pos' && (
|
|
<div style={{ display: 'flex', gap: 10 }}>
|
|
<div className="field-block" style={{ flex: 1 }}>
|
|
<label>کارتخوان</label>
|
|
<SearchableSelect
|
|
value={r.methodUuid}
|
|
onChange={(v) => patchRow(r.id, { methodUuid: String(v ?? '') })}
|
|
options={posOptions}
|
|
placeholder={posOptions.length ? 'انتخاب کارتخوان' : 'کارتخوانی ثبت نشده'}
|
|
height={40}
|
|
/>
|
|
</div>
|
|
<div className="field-block" style={{ flex: 1 }}>
|
|
<label>شناسه تراکنش (اختیاری)</label>
|
|
<div className="field">
|
|
<input
|
|
type="text"
|
|
value={r.reference}
|
|
onChange={(e) => patchRow(r.id, { reference: e.target.value })}
|
|
placeholder="شماره پیگیری"
|
|
style={{ direction: 'ltr' }}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* جزئیاتِ کارتبهکارت: انتخاب حساب بانکیِ ثبتشده + شناسه تراکنش */}
|
|
{r.method === 'card' && (
|
|
<div style={{ display: 'flex', gap: 10 }}>
|
|
<div className="field-block" style={{ flex: 1 }}>
|
|
<label>حساب بانکی (اختیاری)</label>
|
|
<SearchableSelect
|
|
value={r.methodUuid}
|
|
onChange={(v) => patchRow(r.id, { methodUuid: String(v ?? '') })}
|
|
options={bankOptions}
|
|
placeholder={bankOptions.length ? 'انتخاب حساب' : 'حسابی ثبت نشده'}
|
|
height={40}
|
|
/>
|
|
</div>
|
|
<div className="field-block" style={{ flex: 1 }}>
|
|
<label>شناسه تراکنش (اختیاری)</label>
|
|
<div className="field">
|
|
<input
|
|
type="text"
|
|
value={r.reference}
|
|
onChange={(e) => patchRow(r.id, { reference: e.target.value })}
|
|
placeholder="شماره پیگیری"
|
|
style={{ direction: 'ltr' }}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{overpaid && (
|
|
<p className="field-err" style={{ marginBottom: 14 }}>
|
|
مجموع پرداختها از مبلغ قابل پرداخت بیشتر است.
|
|
</p>
|
|
)}
|
|
</>
|
|
)}
|
|
|
|
|
|
{currentStep === 'review' && (
|
|
<>
|
|
{/* خلاصهٔ همان چیزی که ثبت میشود */}
|
|
<div
|
|
style={{
|
|
border: '1px solid var(--border)', borderRadius: 'var(--r)',
|
|
padding: '4px 14px 10px', marginBottom: 16,
|
|
}}
|
|
>
|
|
<div style={rowStyle}>
|
|
<span>مبلغ قابل پرداخت</span>
|
|
<strong style={{ color: 'var(--text)' }}>{formatRial(payable)}</strong>
|
|
</div>
|
|
{rows.filter(r => tomanToRial(r.amountToman) > 0).map(r => (
|
|
<div key={r.id} style={{ ...rowStyle, borderTop: '1px solid var(--border)' }}>
|
|
<span>{METHOD_OPTIONS.find(m => m.value === r.method)?.label ?? r.method}</span>
|
|
<strong style={{ color: 'var(--text)' }}>{formatRial(tomanToRial(r.amountToman))}</strong>
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
<div
|
|
style={{
|
|
background: 'var(--surface-2)', border: '1px solid var(--border)',
|
|
borderRadius: 'var(--r)', padding: '4px 14px 10px',
|
|
}}
|
|
>
|
|
<div style={rowStyle}>
|
|
<span>پرداختشده</span>
|
|
<strong style={{ color: 'var(--text)' }}>{formatRial(Math.min(paidRials, payable))}</strong>
|
|
</div>
|
|
<div style={{ ...rowStyle, borderTop: '1px solid var(--border)' }}>
|
|
<span>باقیمانده</span>
|
|
<strong style={{ color: remaining > 0 ? 'var(--danger)' : 'var(--success)' }}>
|
|
{formatRial(remaining)}
|
|
</strong>
|
|
</div>
|
|
<div style={{ ...rowStyle, borderTop: '1px solid var(--border)' }}>
|
|
<span>وضعیت پرداخت</span>
|
|
<span
|
|
style={{
|
|
padding: '4px 12px', borderRadius: 'var(--r-pill)',
|
|
fontSize: 12.5, fontWeight: 700,
|
|
color: STATE_TONE[paymentState].fg,
|
|
background: STATE_TONE[paymentState].bg,
|
|
}}
|
|
>
|
|
{paymentState}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</>
|
|
)}
|
|
</>
|
|
)}
|
|
</Modal>
|
|
);
|
|
}
|