Backend: - Add session_at and inventory_package_id to patient_sessions, new session_consumables table (migration Version20260716102537) - New SessionConsumable entity/repository mirroring SessionService; price snapshot, quantity >= 1, tenant-scoped silent skip - PatientService::createSession accepts session_at, consumables[] and inventory_package_uuid; consumables are fully patient-paid (no insurance coverage) and added to final_price_rials - Functional tests: success, foreign-tenant/unknown skip, empty and zero-quantity edges (tests/Patient/SessionConsumableTest.php) - docs/api/patient.md updated for the new Create Session fields Frontend (admin): - NewSessionPage rewritten as the tauri /files/create-service 3-step wizard (ایجاد سرویس ← پرداخت ← جزییات) using SessionStepper - New CreateStep: acceptance date/time (Jalali), section/service/staff, consumables with counters, package select, conditional insurance block (insured service or insured patient profile), price summary - PaymentStep/DetailsStep extracted from SessionPaymentPage and shared between both pages (behavior unchanged, tests still green) - UserTick and FilesServiceAddCard icons ported verbatim from tauri - Vitest coverage for the wizard incl. empty-data states Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
232 lines
12 KiB
TypeScript
232 lines
12 KiB
TypeScript
import { useState } from 'react';
|
|
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
|
import { ChevronDownIcon } from '@heroicons/react/24/outline';
|
|
import { toast } from 'sonner';
|
|
import { api } from '../../lib/api';
|
|
import { formatRial } from '../../lib/utils';
|
|
import type { SessionCardData } from '../SessionServiceCard';
|
|
import SearchableSelect from '../ui/SearchableSelect';
|
|
import PersianDateInput from '../ui/PersianDateInput';
|
|
import { Step2PaymentCard, FilesServiceBalanceWallet, TrashRed } from '../icons/FilesServiceIcons';
|
|
|
|
/** روشهای پرداخت — همان چهار گزینهی آکاردئون tauri Step2Payment. */
|
|
const METHODS: { key: string; label: string }[] = [
|
|
{ key: 'wallet', label: 'پرداخت از طریق کیف پول' },
|
|
{ key: 'pos', label: 'پرداخت از طریق کارت خوان' },
|
|
{ key: 'cash', label: 'پرداخت نقدی' },
|
|
{ key: 'card', label: 'کارت به کارت' },
|
|
];
|
|
export const METHOD_LABELS: Record<string, string> = {
|
|
wallet: 'پرداخت از کیف پول', pos: 'پرداخت کارتخوان', cash: 'پرداخت نقدی', card: 'کارت به کارت',
|
|
};
|
|
|
|
const todayISO = () => new Date().toISOString().slice(0, 10);
|
|
/** YYYY-MM-DD → unix (ظهر همان روز تا با هر timezone یک روز بماند) */
|
|
const isoToUnix = (iso: string) => Math.floor(new Date(`${iso}T12:00:00`).getTime() / 1000);
|
|
|
|
const fieldLabel: React.CSSProperties = { fontSize: 14, color: '#6B7280', marginBottom: 8, display: 'block' };
|
|
const primaryBtn: React.CSSProperties = { background: '#5559CE', color: '#fff', border: 'none', borderRadius: 4, height: 46, fontSize: 14, fontWeight: 500, cursor: 'pointer' };
|
|
const ghostBtn: React.CSSProperties = { background: 'transparent', color: '#5559CE', border: '1px solid #5559CE', borderRadius: 4, height: 46, fontSize: 14, fontWeight: 500, cursor: 'pointer' };
|
|
|
|
interface Props {
|
|
recordUuid: string;
|
|
session: SessionCardData;
|
|
walletBalance: number;
|
|
onContinue: () => void;
|
|
onCancel: () => void;
|
|
}
|
|
|
|
/**
|
|
* گام «پرداخت» — پورت tauri Step2Payment با دیتای واقعی:
|
|
* تخفیف تسویه (PATCH /session/{uuid}) + پرداخت چندتکه (POST /session/{uuid}/payments).
|
|
* بین NewSessionPage (ویزارد سهگامه) و SessionPaymentPage (دوگامه) مشترک است.
|
|
*/
|
|
export default function PaymentStep({ recordUuid, session, walletBalance, onContinue, onCancel }: Props) {
|
|
const qc = useQueryClient();
|
|
const sessionUuid = session.uuid;
|
|
|
|
const [discountType, setDiscountType] = useState('');
|
|
const [discountValue, setDiscountValue] = useState('');
|
|
const [paymentDate, setPaymentDate] = useState(todayISO());
|
|
const [expanded, setExpanded] = useState<string | null>(null);
|
|
const [amount, setAmount] = useState('');
|
|
|
|
const invalidate = () => {
|
|
qc.invalidateQueries({ queryKey: ['patient-sessions', recordUuid] });
|
|
qc.invalidateQueries({ queryKey: ['patient-wallet', recordUuid] });
|
|
};
|
|
|
|
const discountMut = useMutation({
|
|
mutationFn: (body: object) => api.patch(`/api/v1/session/${sessionUuid}`, body),
|
|
onSuccess: () => { invalidate(); toast.success('تخفیف بهروزرسانی شد'); },
|
|
onError: (e: Error) => toast.error(e.message),
|
|
});
|
|
|
|
const payMut = useMutation({
|
|
mutationFn: (body: object) => api.post(`/api/v1/session/${sessionUuid}/payments`, body),
|
|
onSuccess: () => { invalidate(); setAmount(''); toast.success('پرداخت ثبت شد'); },
|
|
onError: (e: Error) => toast.error(e.message),
|
|
});
|
|
|
|
const applyDiscount = () => {
|
|
if (!discountType || !discountValue) return;
|
|
discountMut.mutate({ discount_type: discountType, discount_value: Number(discountValue) });
|
|
};
|
|
const removeDiscount = () => {
|
|
setDiscountType(''); setDiscountValue('');
|
|
discountMut.mutate({ discount_type: null });
|
|
};
|
|
const submitPayment = (method: string) => {
|
|
if (!amount) return;
|
|
payMut.mutate({ method, amount_rials: Number(amount), paid_at: isoToUnix(paymentDate) });
|
|
};
|
|
|
|
const finalPrice = session.final_price_rials ?? 0;
|
|
const discountRials = session.discount_rials ?? 0;
|
|
const debt = session.patient_debt_rials ?? 0;
|
|
const payments = session.payments ?? [];
|
|
|
|
return (
|
|
<>
|
|
{/* هزینه سرویس */}
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: 12, margin: '12px 0 16px' }}>
|
|
<Step2PaymentCard />
|
|
<span style={{ fontSize: 14, color: '#F97316', fontWeight: 700 }}>هزینه سرویس:</span>
|
|
<span className="dark:text-[#D7D8ED]" style={{ fontSize: 14, fontWeight: 700, color: '#525252' }}>{formatRial(finalPrice)}</span>
|
|
</div>
|
|
|
|
{/* تخفیف — port of tauri DiscountInput (نوع + مقدار + ثبت) */}
|
|
<span style={fieldLabel}>تخفیف:</span>
|
|
<div style={{ display: 'flex', alignItems: 'stretch', gap: 8 }}>
|
|
<div className="bg-white dark:bg-[#222433] dark:border-[#35343D]" style={{ flex: 1, minWidth: 0, display: 'flex', alignItems: 'center', border: '1px solid #e0e0e0', borderRadius: 8, marginBottom: 16 }}>
|
|
<div style={{ minWidth: 150, borderLeft: '1px solid #e0e0e0', padding: '0 4px' }}>
|
|
<SearchableSelect
|
|
options={[{ value: 'percent', label: 'درصدی' }, { value: 'fixed', label: 'مبلغ ثابت' }]}
|
|
value={discountType}
|
|
onChange={(v) => setDiscountType(v ? String(v) : '')}
|
|
placeholder="مبلغ تخفیف"
|
|
/>
|
|
</div>
|
|
<input
|
|
type="number"
|
|
min={0}
|
|
dir="ltr"
|
|
placeholder="مقدار تخفیف را وارد نمایید"
|
|
value={discountValue}
|
|
onChange={(e) => setDiscountValue(e.target.value)}
|
|
style={{ flex: 1, minWidth: 0, height: 40, padding: '0 12px', fontSize: 13, border: 'none', outline: 'none', background: 'transparent', color: 'inherit' }}
|
|
/>
|
|
<button
|
|
type="button"
|
|
onClick={applyDiscount}
|
|
disabled={discountMut.isPending || !discountType || !discountValue}
|
|
style={{ height: 40, minWidth: 72, border: 'none', borderRadius: '0 4px 4px 0', background: '#E8EBFF', color: '#3B3F9F', fontSize: 13, fontWeight: 600, cursor: 'pointer' }}
|
|
>
|
|
ثبت
|
|
</button>
|
|
</div>
|
|
<div
|
|
onClick={removeDiscount}
|
|
style={{ display: 'flex', alignItems: 'center', gap: 4, flexShrink: 0, cursor: 'pointer', marginBottom: 16, minWidth: 120, justifyContent: 'center' }}
|
|
>
|
|
<TrashRed color="#EF4444" size={18} />
|
|
<span className="dark:text-[#A1A1A1]" style={{ fontSize: 13, color: '#6B7280' }}>حذف تخفیف</span>
|
|
</div>
|
|
</div>
|
|
|
|
<p style={{ fontSize: 13, marginBottom: 16 }}>
|
|
<span className="dark:text-[#D7D8ED]" style={{ color: '#2f2f2f' }}>مبلغ تخفیف:</span>{' '}
|
|
<span style={{ color: '#D32F2F' }}>{formatRial(discountRials)}</span>
|
|
</p>
|
|
|
|
{/* تاریخ پرداخت */}
|
|
<span className="dark:text-[#A1A1A1]" style={{ ...fieldLabel, color: '#3b3b3b' }}>تاریخ پرداخت</span>
|
|
<PersianDateInput value={paymentDate} onChange={setPaymentDate} />
|
|
|
|
{/* روشهای پرداخت */}
|
|
<span className="dark:text-[#A1A1A1]" style={{ ...fieldLabel, color: '#3b3b3b', marginTop: 24 }}>انتخاب روش های پرداخت:</span>
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
|
<div style={{ display: 'flex', gap: 4 }}>
|
|
<FilesServiceBalanceWallet />
|
|
<span style={{ fontSize: 14, color: '#F97316', marginBottom: 8, fontWeight: 600 }}>موجودی کیف پول:</span>
|
|
</div>
|
|
<span className="dark:text-[#D7D8ED]" style={{ fontSize: 14, fontWeight: 600, color: '#525252', marginBottom: 4 }}>{formatRial(walletBalance)}</span>
|
|
</div>
|
|
|
|
{/* آکاردئون چهار روش — باز شدن هر روش: مبلغ + ثبت پرداخت */}
|
|
<div style={{ marginTop: 8 }}>
|
|
{METHODS.map((m) => {
|
|
const open = expanded === m.key;
|
|
return (
|
|
<div key={m.key} className="dark:border-[#35343D]" style={{ borderBottom: '1px solid #e0e0e0' }}>
|
|
<button
|
|
type="button"
|
|
aria-expanded={open}
|
|
onClick={() => { setExpanded(open ? null : m.key); setAmount(''); }}
|
|
style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', width: '100%', padding: '14px 4px', background: 'transparent', border: 'none', cursor: 'pointer' }}
|
|
>
|
|
<span className="dark:text-[#D7D8ED]" style={{ fontSize: 14, fontWeight: 600, color: '#111827' }}>{m.label}</span>
|
|
<ChevronDownIcon style={{ width: 16, color: '#6B7280', transform: open ? 'rotate(180deg)' : undefined, transition: 'transform .15s' }} />
|
|
</button>
|
|
{open && (
|
|
<div style={{ display: 'flex', gap: 8, padding: '0 4px 14px' }}>
|
|
<input
|
|
type="number"
|
|
min={1}
|
|
dir="ltr"
|
|
className="input"
|
|
placeholder="مبلغ (تومان)"
|
|
value={amount}
|
|
onChange={(e) => setAmount(e.target.value)}
|
|
style={{ flex: 1, height: 40 }}
|
|
/>
|
|
<button
|
|
type="button"
|
|
onClick={() => submitPayment(m.key)}
|
|
disabled={payMut.isPending || !amount}
|
|
style={{ ...primaryBtn, height: 40, padding: '0 20px', borderRadius: 8 }}
|
|
>
|
|
ثبت پرداخت
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
|
|
{/* پرداخت شدهها — باکس خطچین tauri */}
|
|
<div className="dark:border-[#404040] dark:bg-[#222433]" style={{ border: '1px dashed #C7C9F4', borderRadius: 8, padding: 16, marginTop: 16, background: '#fff' }}>
|
|
<span className="dark:text-[#6A6AD9]" style={{ fontSize: 16, fontWeight: 500, color: '#636bd4', display: 'block', marginBottom: 16 }}>پرداخت شده ها:</span>
|
|
{payments.length === 0 ? (
|
|
<span className="dark:text-[#A1A1A1]" style={{ fontSize: 13, color: '#6B7280' }}>پرداختی ثبت نشده است</span>
|
|
) : (
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
|
{payments.map((p) => (
|
|
<div key={p.uuid} className="dark:border-[#404040]" style={{ display: 'flex', alignItems: 'center', gap: 32, padding: '4px 0', borderBottom: '1px solid #e0e0e0' }}>
|
|
<span style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
|
<span className="dark:bg-[#6A6AD9]" style={{ width: 8, height: 8, borderRadius: '50%', background: '#636bd4' }} />
|
|
<span className="dark:text-[#D7D8ED]" style={{ fontSize: 14, color: '#111827' }}>{METHOD_LABELS[p.method] ?? p.method}</span>
|
|
</span>
|
|
<span className="dark:text-[#D7D8ED]" style={{ flex: 1, textAlign: 'left', fontSize: 14, color: '#111827' }}>مبلغ : {formatRial(p.amount_rials)}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'flex-end', gap: 8, marginTop: 16 }}>
|
|
<span className="dark:text-[#D7D8ED]" style={{ fontSize: 14, fontWeight: 500, color: '#111827' }}>مبلغ باقیمانده :</span>
|
|
<span className="dark:text-[#FF5252]" style={{ fontSize: 14, fontWeight: 600, color: '#d32f2f' }}>{formatRial(debt)}</span>
|
|
</div>
|
|
</div>
|
|
|
|
{/* ACTIONS */}
|
|
<div style={{ display: 'flex', gap: 8, marginTop: 16, width: '100%', justifyContent: 'flex-end' }}>
|
|
<div style={{ width: '50%', display: 'flex', gap: 4 }}>
|
|
<button type="button" style={{ ...ghostBtn, flex: 1 }} onClick={onCancel}>انصراف</button>
|
|
<button type="button" style={{ ...primaryBtn, flex: 1 }} onClick={onContinue}>ثبت و ادامه</button>
|
|
</div>
|
|
</div>
|
|
</>
|
|
);
|
|
}
|