feat: port tauri create-service page as 3-step new-session wizard
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>
This commit is contained in:
@@ -0,0 +1,425 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useQuery, useMutation } from '@tanstack/react-query';
|
||||
import { toast } from 'sonner';
|
||||
import { PlusIcon, MinusIcon } from '@heroicons/react/24/outline';
|
||||
import { api } from '../../lib/api';
|
||||
import type { ApiResponse } from '../../lib/api';
|
||||
import type { PatientProfile, ServiceSection, ServiceItem } from '../../types';
|
||||
import { formatRial } from '../../lib/utils';
|
||||
import SearchableSelect from '../ui/SearchableSelect';
|
||||
import PersianDateInput from '../ui/PersianDateInput';
|
||||
import { UserTick, FilesServiceAddCard, ClockP, TrashRed } from '../icons/FilesServiceIcons';
|
||||
import { useAuthStore } from '../../stores/authStore';
|
||||
|
||||
interface Contract { uuid: string; insurance_id: number; insurance_name: string | null; insurance_kind: string | null; coverage_percent: number; franchise_rials: number; annual_ceiling_rials: number | null }
|
||||
interface CoverageRow { service_item_uuid: string | null; covered: boolean; coverage_percent: number | null; franchise_rials: number | null; ceiling_rials: number | null }
|
||||
interface Rule { covered: boolean; percent: number; franchise: number; ceiling: number | null }
|
||||
interface InventoryItemRow { uuid: string; name: string; unit: string; price: number; stock: number; status: string }
|
||||
interface PackageRow { uuid: string; title: string; total: number; available: boolean }
|
||||
interface StaffRow { uuid: string; full_name: string; active?: boolean }
|
||||
|
||||
// آینهی BillingCalculator سمت سرور: سهم بیمار یک خدمت با پوشش پایه/مکمل.
|
||||
function patientShareOf(total: number, base: Rule | null, supp: Rule | null): number {
|
||||
let baseShare = 0;
|
||||
let remaining = total;
|
||||
if (base && base.covered) {
|
||||
baseShare = Math.round(total * (base.percent / 100));
|
||||
if (base.ceiling !== null) baseShare = Math.min(baseShare, base.ceiling);
|
||||
remaining = total - baseShare;
|
||||
}
|
||||
let suppShare = 0;
|
||||
if (supp && supp.covered) {
|
||||
suppShare = Math.round(remaining * (supp.percent / 100));
|
||||
if (supp.ceiling !== null) suppShare = Math.min(suppShare, supp.ceiling);
|
||||
remaining = remaining - suppShare;
|
||||
}
|
||||
const franchise = (base?.franchise ?? 0) + (supp?.franchise ?? 0);
|
||||
return Math.min(remaining + franchise, total);
|
||||
}
|
||||
|
||||
const todayISO = () => new Date().toISOString().slice(0, 10);
|
||||
const nowHHMM = () => {
|
||||
const d = new Date();
|
||||
return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
|
||||
};
|
||||
/** تاریخ میلادی + ساعت → unix زمان پذیرش */
|
||||
const toSessionAt = (iso: string, time: string) => Math.floor(new Date(`${iso}T${time || '12:00'}:00`).getTime() / 1000);
|
||||
|
||||
// برچسب فیلد — tauri Step1Details Typography (fontWeight 500, 0.875rem, #6B7280)
|
||||
const fieldLabel: React.CSSProperties = { fontWeight: 500, lineHeight: '130%', fontSize: '0.875rem', marginBottom: 8, color: '#6B7280', 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;
|
||||
profile?: PatientProfile | null;
|
||||
onCreated: (sessionUuid: string) => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* گام «ایجاد سرویس» — پورت tauri Step1Details با دیتای واقعی:
|
||||
* تاریخ/ساعت پذیرش، بخش/سرویس/پرسنل، کالای مصرفی با شمارنده، پکیج، و بلوک بیمهی
|
||||
* موجودِ NewSessionPage (نمایش شرطی: سرویسِ تحت پوشش بیمه یا بیمه در پروفایل بیمار).
|
||||
*/
|
||||
export default function CreateStep({ recordUuid, profile, onCreated, onCancel }: Props) {
|
||||
const userName = useAuthStore((s) => s.userName);
|
||||
|
||||
// ── state گام ایجاد ──────────────────────────────────────────────────────
|
||||
const [dateISO, setDateISO] = useState(todayISO());
|
||||
const [time, setTime] = useState(nowHHMM());
|
||||
const [sectionUuid, setSectionUuid] = useState('');
|
||||
const [itemUuid, setItemUuid] = useState('');
|
||||
const [staffUuid, setStaffUuid] = useState('');
|
||||
const [selectedServices, setSelectedServices] = useState<{ uuid: string; name: string; price: number; qty: number; insured: boolean }[]>([]);
|
||||
const [consumableUuid, setConsumableUuid] = useState('');
|
||||
const [selectedConsumables, setSelectedConsumables] = useState<{ uuid: string; name: string; price: number; qty: number }[]>([]);
|
||||
const [packageUuid, setPackageUuid] = useState('');
|
||||
const [visitPrice, setVisitPrice] = useState('0');
|
||||
const [baseId, setBaseId] = useState('');
|
||||
const [suppId, setSuppId] = useState('');
|
||||
const [basePercent, setBasePercent] = useState('0');
|
||||
const [suppPercent, setSuppPercent] = useState('0');
|
||||
const [notes, setNotes] = useState('');
|
||||
|
||||
// ── دادهها ──────────────────────────────────────────────────────────────
|
||||
const { data: sectionsData } = useQuery<ApiResponse<ServiceSection[]>>({
|
||||
queryKey: ['service-sections'], queryFn: () => api.get('/api/v1/service-sections'),
|
||||
});
|
||||
const { data: itemsData } = useQuery<ApiResponse<ServiceItem[]>>({
|
||||
queryKey: ['service-items-for-session', sectionUuid],
|
||||
queryFn: () => api.get(`/api/v1/service-items/${sectionUuid}`),
|
||||
enabled: !!sectionUuid,
|
||||
});
|
||||
const { data: staffData } = useQuery<ApiResponse<StaffRow[]>>({
|
||||
queryKey: ['staff'], queryFn: () => api.get('/api/v1/staff'),
|
||||
});
|
||||
const { data: inventoryData } = useQuery<ApiResponse<{ items: InventoryItemRow[] }>>({
|
||||
queryKey: ['inventory-items'], queryFn: () => api.get('/api/v1/inventory-items'),
|
||||
});
|
||||
const { data: packagesData } = useQuery<ApiResponse<PackageRow[]>>({
|
||||
queryKey: ['inventory-packages'], queryFn: () => api.get('/api/v1/inventory-packages'),
|
||||
});
|
||||
const { data: contractsData } = useQuery<{ data: { data: Contract[] } }>({
|
||||
queryKey: ['tenant-insurances'], queryFn: () => api.get('/api/v1/billing/tenant-insurances'),
|
||||
});
|
||||
const { data: pricingData } = useQuery<{ data: { free_visit_price_rials: number } }>({
|
||||
queryKey: ['insurance-pricing'], queryFn: () => api.get('/api/v1/insurance-pricing'),
|
||||
});
|
||||
const freeVisit = (pricingData as any)?.data?.free_visit_price_rials ?? 0;
|
||||
|
||||
useEffect(() => {
|
||||
if (freeVisit > 0 && (!visitPrice || visitPrice === '0')) setVisitPrice(String(freeVisit));
|
||||
}, [freeVisit]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const contracts = (contractsData as any)?.data?.data as Contract[] | undefined ?? [];
|
||||
const baseOpts = contracts.filter(c => c.insurance_kind === 'basic').map(c => ({ value: String(c.insurance_id), label: c.insurance_name ?? `#${c.insurance_id}` }));
|
||||
const suppOpts = contracts.filter(c => c.insurance_kind === 'supplementary').map(c => ({ value: String(c.insurance_id), label: c.insurance_name ?? `#${c.insurance_id}` }));
|
||||
|
||||
const sectionOptions = (sectionsData?.data ?? []).map(s => ({ value: s.uuid, label: s.name }));
|
||||
const serviceItems = (itemsData?.data ?? []).filter(i => i.active);
|
||||
const itemOptions = serviceItems.map(i => ({ value: i.uuid, label: i.name }));
|
||||
const currentItem = serviceItems.find(i => i.uuid === itemUuid);
|
||||
|
||||
const staffOptions = ((staffData?.data as StaffRow[] | undefined) ?? []).filter(s => s.active !== false).map(s => ({ value: s.uuid, label: s.full_name }));
|
||||
|
||||
const inventoryItems = ((inventoryData?.data as any)?.items as InventoryItemRow[] | undefined) ?? [];
|
||||
const consumableOptions = inventoryItems.map(i => ({ value: i.uuid, label: i.name }));
|
||||
const currentConsumable = inventoryItems.find(i => i.uuid === consumableUuid);
|
||||
|
||||
const packageOptions = ((packagesData?.data as PackageRow[] | undefined) ?? []).map(p => ({ value: p.uuid, label: `${p.title} — ${formatRial(p.total)}` }));
|
||||
|
||||
const baseContract = contracts.find(c => String(c.insurance_id) === baseId) ?? null;
|
||||
const suppContract = contracts.find(c => String(c.insurance_id) === suppId) ?? null;
|
||||
|
||||
const baseCoverageQ = useQuery<{ data: { data: CoverageRow[] } }>({
|
||||
queryKey: ['service-coverage', baseContract?.uuid],
|
||||
queryFn: () => api.get(`/api/v1/billing/tenant-insurances/${baseContract!.uuid}/service-coverage`),
|
||||
enabled: !!baseContract,
|
||||
});
|
||||
const suppCoverageQ = useQuery<{ data: { data: CoverageRow[] } }>({
|
||||
queryKey: ['service-coverage', suppContract?.uuid],
|
||||
queryFn: () => api.get(`/api/v1/billing/tenant-insurances/${suppContract!.uuid}/service-coverage`),
|
||||
enabled: !!suppContract,
|
||||
});
|
||||
const baseCoverage = (baseCoverageQ.data as any)?.data?.data as CoverageRow[] | undefined ?? [];
|
||||
const suppCoverage = (suppCoverageQ.data as any)?.data?.data as CoverageRow[] | undefined ?? [];
|
||||
|
||||
// قاعدهی پوشش یک خدمت تحت یک قرارداد: override خدمت اگر باشد، وگرنه پیشفرض قرارداد.
|
||||
const ruleFor = (contract: Contract | null, coverage: CoverageRow[], serviceUuid: string): Rule | null => {
|
||||
if (!contract) return null;
|
||||
const ov = coverage.find(r => r.service_item_uuid === serviceUuid);
|
||||
if (ov && !ov.covered) return { covered: false, percent: 0, franchise: 0, ceiling: null };
|
||||
return {
|
||||
covered: true,
|
||||
percent: ov?.coverage_percent ?? contract.coverage_percent,
|
||||
franchise: ov?.franchise_rials ?? contract.franchise_rials,
|
||||
ceiling: ov?.ceiling_rials ?? contract.annual_ceiling_rials,
|
||||
};
|
||||
};
|
||||
|
||||
const coverageOf = (id: string): number => contracts.find(c => String(c.insurance_id) === id)?.coverage_percent ?? 0;
|
||||
const applyBase = (id: string) => { setBaseId(id); setBasePercent(id ? String(coverageOf(id)) : '0'); };
|
||||
const applySupp = (id: string) => { setSuppId(id); setSuppPercent(id ? String(coverageOf(id)) : '0'); };
|
||||
|
||||
// نمایش شرطی بلوک بیمه: سرویسِ تحت پوشش بیمه انتخاب شده یا پروفایل بیمار بیمه دارد.
|
||||
const showInsurance = selectedServices.some(s => s.insured)
|
||||
|| !!profile?.basic_insurance_id
|
||||
|| !!profile?.supplementary_insurance_id;
|
||||
|
||||
// ── خدمات ────────────────────────────────────────────────────────────────
|
||||
const addService = () => {
|
||||
if (!currentItem || selectedServices.some(s => s.uuid === currentItem.uuid)) return;
|
||||
setSelectedServices(p => [...p, { uuid: currentItem.uuid, name: currentItem.name, price: currentItem.price_rials, qty: 1, insured: !!currentItem.insurance_covered }]);
|
||||
setItemUuid('');
|
||||
};
|
||||
const setServiceQty = (uuid: string, qty: number) => {
|
||||
if (qty <= 0) { setSelectedServices(p => p.filter(s => s.uuid !== uuid)); return; }
|
||||
setSelectedServices(p => p.map(s => s.uuid === uuid ? { ...s, qty } : s));
|
||||
};
|
||||
|
||||
// ── کالای مصرفی (tauri addConsumable/increase/decrease) ─────────────────
|
||||
const addConsumable = () => {
|
||||
if (!currentConsumable) return;
|
||||
setSelectedConsumables(p => {
|
||||
const found = p.find(c => c.uuid === currentConsumable.uuid);
|
||||
if (found) return p.map(c => c.uuid === found.uuid ? { ...c, qty: c.qty + 1 } : c);
|
||||
return [...p, { uuid: currentConsumable.uuid, name: currentConsumable.name, price: currentConsumable.price, qty: 1 }];
|
||||
});
|
||||
setConsumableUuid('');
|
||||
};
|
||||
const setConsumableQty = (uuid: string, qty: number) => {
|
||||
if (qty <= 0) { setSelectedConsumables(p => p.filter(c => c.uuid !== uuid)); return; }
|
||||
setSelectedConsumables(p => p.map(c => c.uuid === uuid ? { ...c, qty } : c));
|
||||
};
|
||||
|
||||
// ── قیمتها (آینهی سرور) ────────────────────────────────────────────────
|
||||
const visit = Number(visitPrice) || 0;
|
||||
const base = Number(basePercent) || 0;
|
||||
const supp = Number(suppPercent) || 0;
|
||||
const servicesTotal = useMemo(() => selectedServices.reduce((s, x) => s + x.price * x.qty, 0), [selectedServices]);
|
||||
const servicesPatient = useMemo(
|
||||
() => selectedServices.reduce((sum, x) => {
|
||||
const total = x.price * x.qty;
|
||||
if (!x.insured) return sum + total;
|
||||
return sum + patientShareOf(total, ruleFor(baseContract, baseCoverage, x.uuid), ruleFor(suppContract, suppCoverage, x.uuid));
|
||||
}, 0),
|
||||
[selectedServices, baseContract, suppContract, baseCoverage, suppCoverage], // eslint-disable-line react-hooks/exhaustive-deps
|
||||
);
|
||||
const consumablesTotal = useMemo(() => selectedConsumables.reduce((s, c) => s + c.price * c.qty, 0), [selectedConsumables]);
|
||||
const afterBase = Math.round(visit * (1 - base / 100));
|
||||
const afterSupp = Math.round(afterBase * (1 - supp / 100));
|
||||
const finalPrice = afterSupp + servicesPatient + consumablesTotal;
|
||||
|
||||
// ── ثبت ──────────────────────────────────────────────────────────────────
|
||||
const createMut = useMutation({
|
||||
mutationFn: (body: object) => api.post(`/api/v1/patient/${recordUuid}/session`, body),
|
||||
onSuccess: (res: any) => {
|
||||
toast.success('مراجعه ثبت شد');
|
||||
onCreated(res?.data?.uuid as string);
|
||||
},
|
||||
onError: (e: Error) => toast.error(e.message),
|
||||
});
|
||||
|
||||
const submit = () => {
|
||||
createMut.mutate({
|
||||
visit_price_rials: visit,
|
||||
base_insurance_discount_percent: showInsurance ? base : 0,
|
||||
supplementary_discount_percent: showInsurance ? supp : 0,
|
||||
...(showInsurance && baseId ? { insurance_base_id: Number(baseId) } : {}),
|
||||
...(showInsurance && suppId ? { insurance_supplementary_id: Number(suppId) } : {}),
|
||||
payment_method: 'pending',
|
||||
...(notes ? { notes } : {}),
|
||||
session_at: toSessionAt(dateISO, time),
|
||||
...(packageUuid ? { inventory_package_uuid: packageUuid } : {}),
|
||||
services: selectedServices.map(s => ({ service_item_uuid: s.uuid, quantity: s.qty, ...(staffUuid ? { staff_uuid: staffUuid } : {}) })),
|
||||
consumables: selectedConsumables.map(c => ({ inventory_item_uuid: c.uuid, quantity: c.qty })),
|
||||
});
|
||||
};
|
||||
|
||||
/** ردیف شمارندهی tauri (باکس ۷۴px، +/− نارنجی #FF7A45، سطل روی تعداد ۱) */
|
||||
const counter = (qty: number, set: (q: number) => void) => (
|
||||
<div className="dark:border-[#404040]" style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', width: 74, height: 27, border: '1px solid #d1d1d1', borderRadius: 8, padding: '0 4px', gap: 4 }}>
|
||||
<button type="button" aria-label="افزایش" onClick={() => set(qty + 1)} style={{ border: 'none', background: 'transparent', color: '#FF7A45', cursor: 'pointer', display: 'flex' }}>
|
||||
<PlusIcon style={{ width: 16 }} />
|
||||
</button>
|
||||
<span className="dark:text-[#D7D8ED]" style={{ fontSize: 13, color: '#525252', minWidth: 20, textAlign: 'center' }}>{qty}</span>
|
||||
<button type="button" aria-label="کاهش" onClick={() => set(qty - 1)} style={{ border: 'none', background: 'transparent', color: '#FF7A45', cursor: 'pointer', display: 'flex' }}>
|
||||
{qty > 1 ? <MinusIcon style={{ width: 16 }} /> : <TrashRed size={16} color="#f17732" />}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div style={{ marginTop: 16 }}>
|
||||
{/* پذیرش کننده — tauri UserTick + کاربر لاگینشده */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 4, marginBottom: 28 }}>
|
||||
<UserTick color="#6B7280" size={18} />
|
||||
<span className="dark:text-[#D7D8ED]" style={{ fontSize: 14, color: '#616161' }}>پذیرش کننده: {userName || '—'}</span>
|
||||
</div>
|
||||
|
||||
{/* تاریخ و ساعت پذیرش */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12, marginBottom: 24 }}>
|
||||
<div>
|
||||
<span style={fieldLabel}>تاریخ پذیرش</span>
|
||||
<PersianDateInput value={dateISO} onChange={setDateISO} />
|
||||
</div>
|
||||
<div>
|
||||
<span style={fieldLabel}>ساعت پذیرش</span>
|
||||
<div className="bg-white dark:bg-[#222433] dark:border-[#404040]" style={{ display: 'flex', alignItems: 'center', gap: 8, height: 48, border: '1px solid #e1e1e1', borderRadius: 4, padding: '0 8px' }}>
|
||||
<ClockP color="#6B7280" size={18} />
|
||||
<input
|
||||
type="time"
|
||||
aria-label="ساعت پذیرش"
|
||||
value={time}
|
||||
onChange={(e) => setTime(e.target.value)}
|
||||
style={{ border: 'none', outline: 'none', background: 'transparent', fontSize: '0.875rem', color: 'inherit', flex: 1 }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* انتخاب بخش */}
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<span style={fieldLabel}>انتخاب بخش</span>
|
||||
<SearchableSelect inputId="section-select" options={sectionOptions} value={sectionUuid} onChange={v => { setSectionUuid(v ? String(v) : ''); setItemUuid(''); }} placeholder="انتخاب کنید..." />
|
||||
</div>
|
||||
|
||||
{/* انتخاب سرویس + قیمت + افزودن */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8 }}>
|
||||
<div style={{ width: '50%' }}>
|
||||
<span style={fieldLabel}>انتخاب سرویس</span>
|
||||
<SearchableSelect inputId="service-select" options={itemOptions} value={itemUuid} onChange={v => setItemUuid(v ? String(v) : '')} placeholder="انتخاب کنید..." isDisabled={!sectionUuid} />
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 4, marginTop: 20, flexShrink: 0 }}>
|
||||
<FilesServiceAddCard color="#6B7280" />
|
||||
<span className="dark:text-[#D7D8ED]" style={{ fontSize: 13, color: '#3b3b3b' }}>
|
||||
قیمت: {currentItem ? formatRial(currentItem.price_rials) : '—'}
|
||||
</span>
|
||||
</div>
|
||||
<button type="button" aria-label="افزودن سرویس" onClick={addService} disabled={!itemUuid} style={{ minWidth: 40, height: 40, borderRadius: 8, background: '#5559CE', border: 'none', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center', marginTop: 20 }}>
|
||||
<PlusIcon style={{ width: 18, color: '#fff' }} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* خدمات انتخاب شده */}
|
||||
{selectedServices.length > 0 && (
|
||||
<div style={{ border: '1px dashed #C7C9F4', borderRadius: 8, padding: 16, marginBottom: 16 }}>
|
||||
<span className="dark:text-[#D7D8ED]" style={{ fontSize: 13, fontWeight: 600, color: '#525252', display: 'block', marginBottom: 16 }}>خدمات انتخاب شده</span>
|
||||
{selectedServices.map(item => (
|
||||
<div key={item.uuid} style={{ display: 'flex', alignItems: 'center', gap: 24, padding: '8px 0' }}>
|
||||
<span className="dark:text-[#D7D8ED]" style={{ fontSize: 14, color: '#525252', minWidth: 120 }}>{item.name}</span>
|
||||
{counter(item.qty, (q) => setServiceQty(item.uuid, q))}
|
||||
<span className="dark:text-[#A1A1A1]" style={{ fontSize: 12, fontWeight: 500, color: '#3b3b3b' }}>قیمت: {formatRial(item.price * item.qty)}</span>
|
||||
</div>
|
||||
))}
|
||||
<span className="dark:text-[#D7D8ED]" style={{ fontSize: 13, fontWeight: 600, color: '#525252', display: 'block', marginTop: 16 }}>مجموع قیمت خدمات: {formatRial(servicesTotal)}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* پرسنل */}
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<span style={fieldLabel}>پرسنل</span>
|
||||
<SearchableSelect inputId="staff-select" options={staffOptions} value={staffUuid} onChange={v => setStaffUuid(v ? String(v) : '')} placeholder="انتخاب کنید..." isClearable />
|
||||
</div>
|
||||
|
||||
{/* انتخاب کالای مصرفی */}
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<span className="dark:text-[#D7D8ED]" style={{ fontSize: 14, color: '#525252', display: 'block', marginBottom: 8 }}>انتخاب کالای مصرفی</span>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<div style={{ width: 313, maxWidth: '60%' }}>
|
||||
<SearchableSelect inputId="consumable-select" options={consumableOptions} value={consumableUuid} onChange={v => setConsumableUuid(v ? String(v) : '')} placeholder="انتخاب کنید..." />
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 4, flexShrink: 0 }}>
|
||||
<FilesServiceAddCard color="#6B7280" />
|
||||
<span className="dark:text-[#A1A1A1]" style={{ fontSize: 13, color: '#6B7280' }}>
|
||||
قیمت: {currentConsumable ? formatRial(currentConsumable.price) : '—'}
|
||||
</span>
|
||||
</div>
|
||||
<button type="button" aria-label="افزودن کالا" onClick={addConsumable} disabled={!consumableUuid} style={{ minWidth: 40, height: 40, borderRadius: 8, background: '#5559CE', border: 'none', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<PlusIcon style={{ width: 18, color: '#fff' }} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* کالاهای انتخاب شده — باکس خطچین tauri */}
|
||||
<div style={{ border: '1px dashed #C7C9F4', borderRadius: 8, padding: 16, marginBottom: 16 }}>
|
||||
<span className="dark:text-[#D7D8ED]" style={{ fontSize: 13, fontWeight: 600, color: '#525252', display: 'block', marginBottom: 16 }}>کالاهای انتخاب شده</span>
|
||||
{selectedConsumables.length === 0 ? (
|
||||
<span className="dark:text-[#A1A1A1]" style={{ fontSize: 13, color: '#6B7280' }}>کالایی انتخاب نشده است</span>
|
||||
) : selectedConsumables.map(item => (
|
||||
<div key={item.uuid} style={{ display: 'flex', alignItems: 'center', gap: 24, padding: '8px 0' }}>
|
||||
<span className="dark:text-[#D7D8ED]" style={{ fontSize: 14, color: '#525252', minWidth: 120 }}>{item.name}</span>
|
||||
{counter(item.qty, (q) => setConsumableQty(item.uuid, q))}
|
||||
<span className="dark:text-[#A1A1A1]" style={{ fontSize: 12, fontWeight: 500, color: '#3b3b3b' }}>قیمت: {formatRial(item.price * item.qty)}</span>
|
||||
</div>
|
||||
))}
|
||||
<span className="dark:text-[#D7D8ED]" style={{ fontSize: 13, fontWeight: 600, color: '#525252', display: 'block', marginTop: 16 }}>مجموع قیمت کالاها: {formatRial(consumablesTotal)}</span>
|
||||
</div>
|
||||
|
||||
{/* انتخاب پکیج */}
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<span style={fieldLabel}>انتخاب پکیج</span>
|
||||
<SearchableSelect inputId="package-select" options={packageOptions} value={packageUuid} onChange={v => setPackageUuid(v ? String(v) : '')} placeholder="انتخاب کنید..." isClearable />
|
||||
</div>
|
||||
|
||||
{/* قیمت ویزیت */}
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<span style={fieldLabel}>قیمت ویزیت (تومان)</span>
|
||||
<input className="input" type="number" min={0} dir="ltr" aria-label="قیمت ویزیت" value={visitPrice} onChange={(e) => setVisitPrice(e.target.value)} />
|
||||
</div>
|
||||
|
||||
{/* بیمه — منطق موجود NewSessionPage؛ فقط وقتی سرویس تحت پوشش یا بیمار بیمه دارد */}
|
||||
{showInsurance && (
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<span style={fieldLabel}>بیمه</span>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12, marginBottom: 12 }}>
|
||||
<div>
|
||||
<span style={fieldLabel}>بیمه پایه</span>
|
||||
<SearchableSelect inputId="base-insurance-select" options={baseOpts} value={baseId} onChange={v => applyBase(v ? String(v) : '')} placeholder="بدون بیمه پایه" isClearable />
|
||||
</div>
|
||||
<div>
|
||||
<span style={fieldLabel}>بیمه تکمیلی</span>
|
||||
<SearchableSelect inputId="supp-insurance-select" options={suppOpts} value={suppId} onChange={v => applySupp(v ? String(v) : '')} placeholder="بدون بیمه تکمیلی" isClearable />
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
|
||||
<div>
|
||||
<span style={fieldLabel}>تخفیف بیمه پایه (%)</span>
|
||||
<input className="input" type="number" min={0} max={100} dir="ltr" aria-label="تخفیف بیمه پایه" value={basePercent} onChange={(e) => setBasePercent(e.target.value)} />
|
||||
</div>
|
||||
<div>
|
||||
<span style={fieldLabel}>تخفیف تکمیلی (%)</span>
|
||||
<input className="input" type="number" min={0} max={100} dir="ltr" aria-label="تخفیف تکمیلی" value={suppPercent} onChange={(e) => setSuppPercent(e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* یادداشت */}
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<span style={fieldLabel}>یادداشت</span>
|
||||
<textarea className="input" rows={3} dir="rtl" placeholder="یادداشت پزشک..." value={notes} onChange={(e) => setNotes(e.target.value)} />
|
||||
</div>
|
||||
|
||||
{/* خلاصه مبلغ */}
|
||||
<div className="dark:border-[#404040]" style={{ borderTop: '1px solid var(--border)', paddingTop: 12, marginBottom: 16, display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 13, color: 'var(--text-3)' }}><span>ویزیت</span><span>{formatRial(afterSupp)}</span></div>
|
||||
{servicesTotal > 0 && <div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 13, color: 'var(--text-3)' }}><span>سهم بیمار خدمات</span><span>{formatRial(servicesPatient)}</span></div>}
|
||||
{consumablesTotal > 0 && <div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 13, color: 'var(--text-3)' }}><span>کالاهای مصرفی</span><span>{formatRial(consumablesTotal)}</span></div>}
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 15, fontWeight: 700 }}>
|
||||
<span>مبلغ نهایی (سهم بیمار)</span>
|
||||
<span style={{ color: 'var(--primary)' }}>{formatRial(finalPrice)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ACTIONS — tauri: انصراف / ایجاد سرویس */}
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 8, marginTop: 16, width: '100%', maxWidth: 340, margin: '16px auto 0' }}>
|
||||
<button type="button" style={{ ...ghostBtn, width: 164 }} onClick={onCancel}>انصراف</button>
|
||||
<button type="button" style={{ ...primaryBtn, width: 164 }} onClick={submit} disabled={createMut.isPending}>
|
||||
{createMut.isPending ? 'در حال ذخیره...' : 'ایجاد سرویس'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { formatRial } from '../../lib/utils';
|
||||
import type { SessionCardData } from '../SessionServiceCard';
|
||||
import { METHOD_LABELS } from './PaymentStep';
|
||||
|
||||
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 {
|
||||
session: SessionCardData;
|
||||
onBack: () => void;
|
||||
onFinish: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* گام «جزییات» — پورت tauri Step3Final با دیتای واقعی:
|
||||
* خلاصهی سرویس/هزینه/تخفیف + لیست پرداختشدهها + مانده. مشترک بین
|
||||
* NewSessionPage (ویزارد سهگامه) و SessionPaymentPage (دوگامه).
|
||||
*/
|
||||
export default function DetailsStep({ session, onBack, onFinish }: Props) {
|
||||
const finalPrice = session.final_price_rials ?? 0;
|
||||
const discountRials = session.discount_rials ?? 0;
|
||||
const debt = session.patient_debt_rials ?? 0;
|
||||
const payments = session.payments ?? [];
|
||||
const serviceNames = (session.services ?? []).map((s) => s.service_name || s.name).filter(Boolean) as string[];
|
||||
if ((session.visit_price_rials ?? 0) > 0) serviceNames.unshift('ویزیت');
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* گام جزییات — پورت tauri Step3Final با دیتای واقعی */}
|
||||
<div dir="rtl" className="dark:border-[#404040] dark:bg-[#222433]" style={{ padding: 16, border: '1px dashed #A7A7E0' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 32, marginBottom: 16 }}>
|
||||
<span className="dark:text-[#D7D8ED]" style={{ fontSize: 14, fontWeight: 500, color: '#2f2f2f' }}>
|
||||
{serviceNames.length ? serviceNames.join(' - ') : 'ویزیت'}
|
||||
</span>
|
||||
<span className="dark:bg-[#404040]" style={{ width: 1, height: 38, background: '#E0E0E0' }} />
|
||||
<span className="dark:text-[#D7D8ED]" style={{ fontSize: 14, fontWeight: 500, color: '#525252' }}>{session.doctor_name || '—'}</span>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', gap: 72, marginBottom: 16 }}>
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<span className="dark:text-[#D7D8ED]" style={{ fontSize: 14, fontWeight: 500, color: '#525252' }}>هزینه سرویس:</span>
|
||||
<span className="dark:text-[#D7D8ED]" style={{ fontSize: 14, fontWeight: 500, color: '#525252' }}>{formatRial(finalPrice)}</span>
|
||||
</span>
|
||||
<span className="dark:bg-[#404040]" style={{ width: 1, height: 38, background: '#E0E0E0' }} />
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<span className="dark:text-[#D7D8ED]" style={{ fontSize: 14, fontWeight: 500, color: '#525252' }}>تخفیف:</span>
|
||||
<span className="dark:text-[#D7D8ED]" style={{ fontSize: 14, fontWeight: 500, color: '#525252' }}>{formatRial(discountRials)}</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<span className="dark:text-[#6A6AD9]" style={{ fontSize: 13, fontWeight: 600, color: '#5559CE', display: 'block', marginBottom: 12 }}>پرداخت شده ها:</span>
|
||||
{payments.length === 0 ? (
|
||||
<span className="dark:text-[#A1A1A1]" style={{ fontSize: 13, color: '#6B7280' }}>پرداختی ثبت نشده است</span>
|
||||
) : payments.map((p) => (
|
||||
<div key={p.uuid} className="dark:border-[#404040]" style={{ display: 'flex', alignItems: 'center', gap: 56, padding: '8px 0', borderBottom: '1px solid #EEE' }}>
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<span className="dark:bg-[#6A6AD9]" style={{ width: 6, height: 6, borderRadius: '50%', background: '#5559CE', flexShrink: 0 }} />
|
||||
<span className="dark:text-[#D7D8ED]" style={{ fontSize: 14, fontWeight: 600, color: '#525252' }}>{METHOD_LABELS[p.method] ?? p.method}</span>
|
||||
</span>
|
||||
<span className="dark:text-[#D7D8ED]" style={{ fontSize: 14, fontWeight: 600, color: '#111827' }}>مبلغ: {formatRial(p.amount_rials)}</span>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div style={{ display: 'flex', width: '100%', justifyContent: 'flex-end', alignItems: 'center' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginTop: 16 }}>
|
||||
<span className="dark:text-[#D7D8ED]" style={{ fontSize: 14, fontWeight: 600, color: '#525252' }}>مبلغ باقی مانده:</span>
|
||||
<span className="dark:text-[#FF5252]" style={{ fontSize: 14, fontWeight: 600, color: '#d32f2f' }}>{formatRial(debt)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: 8, marginTop: 16, width: '100%', maxWidth: 320, margin: '16px auto 0' }}>
|
||||
<button type="button" style={{ ...ghostBtn, flex: 1 }} onClick={onBack}>انصراف</button>
|
||||
<button type="button" style={{ ...primaryBtn, flex: 1 }} onClick={onFinish}>صدور فاکتور</button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user