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,191 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import { Routes, Route } from 'react-router-dom';
|
||||
import { renderWithProviders } from '../test/utils';
|
||||
|
||||
vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
|
||||
vi.mock('../lib/api', () => ({
|
||||
api: { get: vi.fn(), post: vi.fn(), patch: vi.fn(), put: vi.fn(), delete: vi.fn() },
|
||||
ApiError: class extends Error {},
|
||||
}));
|
||||
|
||||
import { api } from '../lib/api';
|
||||
import { formatRial } from '../lib/utils';
|
||||
import NewSessionPage from './NewSessionPage';
|
||||
|
||||
const get = api.get as ReturnType<typeof vi.fn>;
|
||||
const post = api.post as ReturnType<typeof vi.fn>;
|
||||
|
||||
const record = (profileOver: object = {}) => ({
|
||||
uuid: 'r1',
|
||||
user_name: 'ساغر صابری',
|
||||
profile: { full_name: 'ساغر صابری', basic_insurance_id: null, supplementary_insurance_id: null, ...profileOver },
|
||||
});
|
||||
|
||||
function mockGets(over: Partial<Record<string, unknown>> = {}) {
|
||||
get.mockImplementation((url: string) => {
|
||||
if (url === '/api/v1/patient/r1') return Promise.resolve({ success: true, data: over.record ?? record() });
|
||||
if (url === '/api/v1/service-sections') {
|
||||
return Promise.resolve({ success: true, data: over.sections ?? [{ uuid: 'sec1', name: 'بخش زیبایی' }] });
|
||||
}
|
||||
if (url === '/api/v1/service-items/sec1') {
|
||||
return Promise.resolve({
|
||||
success: true,
|
||||
data: over.items ?? [
|
||||
{ uuid: 'svc1', name: 'کاشت مو', price_rials: 2_400_000, active: true, insurance_covered: false, staff: null },
|
||||
],
|
||||
});
|
||||
}
|
||||
if (url === '/api/v1/staff') {
|
||||
return Promise.resolve({ success: true, data: over.staff ?? [{ uuid: 'st1', full_name: 'علی رضایی', active: true }] });
|
||||
}
|
||||
if (url === '/api/v1/inventory-items') {
|
||||
return Promise.resolve({
|
||||
success: true,
|
||||
data: { items: over.inventory ?? [{ uuid: 'inv1', name: 'عینک', unit: 'عدد', price: 1_200_000, stock: 10, status: 'in_stock' }], stats: {} },
|
||||
});
|
||||
}
|
||||
if (url === '/api/v1/inventory-packages') {
|
||||
return Promise.resolve({ success: true, data: over.packages ?? [{ uuid: 'pkg1', title: 'پکیج شماره یک', total: 2_400_000, available: true }] });
|
||||
}
|
||||
if (url === '/api/v1/billing/tenant-insurances') {
|
||||
return Promise.resolve({ success: true, data: { data: over.contracts ?? [] } });
|
||||
}
|
||||
if (url === '/api/v1/insurance-pricing') {
|
||||
return Promise.resolve({ success: true, data: { free_visit_price_rials: 500_000 } });
|
||||
}
|
||||
if (url === '/api/v1/patient/r1/sessions') {
|
||||
return Promise.resolve({ success: true, data: over.sessions ?? [] });
|
||||
}
|
||||
if (url === '/api/v1/patient/r1/wallet') {
|
||||
return Promise.resolve({ success: true, data: { balance_rials: 300_000, recent_transactions: [] } });
|
||||
}
|
||||
return Promise.resolve({ success: true, data: [] });
|
||||
});
|
||||
}
|
||||
|
||||
function renderPage() {
|
||||
return renderWithProviders(
|
||||
<Routes>
|
||||
<Route path="/admin/patients/:recordUuid/session/new" element={<NewSessionPage />} />
|
||||
</Routes>,
|
||||
{ route: '/admin/patients/r1/session/new' },
|
||||
);
|
||||
}
|
||||
|
||||
/** انتخاب گزینه از SearchableSelect (react-select) با inputId */
|
||||
async function pickOption(inputId: string, option: string) {
|
||||
const input = document.getElementById(inputId) as HTMLInputElement;
|
||||
fireEvent.focus(input);
|
||||
fireEvent.keyDown(input, { key: 'ArrowDown' });
|
||||
fireEvent.click(await screen.findByText(option));
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
get.mockReset();
|
||||
post.mockReset();
|
||||
});
|
||||
|
||||
describe('NewSessionPage (ویزارد ثبت مراجعه — پورت tauri create-service)', () => {
|
||||
it('renders the 3-step stepper and step-1 fields (tauri Step1Details)', async () => {
|
||||
mockGets();
|
||||
renderPage();
|
||||
|
||||
// استپر سه گام («ایجاد سرویس» هم برچسب گام است هم دکمه ثبت)
|
||||
expect((await screen.findAllByText('ایجاد سرویس')).length).toBeGreaterThanOrEqual(2);
|
||||
expect(screen.getByText('پرداخت')).toBeInTheDocument();
|
||||
expect(screen.getByText('جزییات')).toBeInTheDocument();
|
||||
// فیلدهای گام ۱
|
||||
expect(screen.getByText('تاریخ پذیرش')).toBeInTheDocument();
|
||||
expect(screen.getByText('ساعت پذیرش')).toBeInTheDocument();
|
||||
expect(screen.getByText('انتخاب بخش')).toBeInTheDocument();
|
||||
expect(screen.getByText('انتخاب سرویس')).toBeInTheDocument();
|
||||
expect(screen.getByText('پرسنل')).toBeInTheDocument();
|
||||
expect(screen.getByText('انتخاب کالای مصرفی')).toBeInTheDocument();
|
||||
expect(screen.getByText('انتخاب پکیج')).toBeInTheDocument();
|
||||
// پذیرشکننده + breadcrumb بیمار
|
||||
expect(screen.getByText(/پذیرش کننده:/)).toBeInTheDocument();
|
||||
expect((await screen.findAllByText('ساغر صابری')).length).toBeGreaterThan(0);
|
||||
// بدون سرویس بیمهدار و بدون بیمهی پروفایل → بلوک بیمه پنهان
|
||||
expect(screen.queryByText('بیمه پایه')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows the insurance block when patient profile has insurance', async () => {
|
||||
mockGets({ record: record({ basic_insurance_id: 7 }) });
|
||||
renderPage();
|
||||
|
||||
expect(await screen.findByText('بیمه پایه')).toBeInTheDocument();
|
||||
expect(screen.getByText('بیمه تکمیلی')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('creates the session with session_at, consumables, package and staff then moves to payment step', async () => {
|
||||
mockGets({
|
||||
sessions: [{
|
||||
uuid: 's-new', services: [], visit_price_rials: 500_000, final_price_rials: 1_700_000,
|
||||
patient_debt_rials: 1_700_000, discount_rials: 0, payments: [], is_paid: false,
|
||||
}],
|
||||
});
|
||||
post.mockResolvedValue({ success: true, data: { uuid: 's-new' } });
|
||||
renderPage();
|
||||
|
||||
await screen.findByText('انتخاب بخش');
|
||||
|
||||
// بخش → سرویس → افزودن
|
||||
await pickOption('section-select', 'بخش زیبایی');
|
||||
await waitFor(() => expect(get).toHaveBeenCalledWith('/api/v1/service-items/sec1'));
|
||||
await pickOption('service-select', 'کاشت مو');
|
||||
fireEvent.click(screen.getByLabelText('افزودن سرویس'));
|
||||
expect(screen.getByText('خدمات انتخاب شده')).toBeInTheDocument();
|
||||
|
||||
// کالای مصرفی: عینک
|
||||
await pickOption('consumable-select', 'عینک');
|
||||
fireEvent.click(screen.getByLabelText('افزودن کالا'));
|
||||
expect(screen.getByText(`مجموع قیمت کالاها: ${formatRial(1_200_000)}`)).toBeInTheDocument();
|
||||
|
||||
// ثبت
|
||||
fireEvent.click(screen.getByText('ایجاد سرویس', { selector: 'button' }));
|
||||
await waitFor(() => expect(post).toHaveBeenCalledTimes(1));
|
||||
|
||||
const [url, body] = post.mock.calls[0] as [string, any];
|
||||
expect(url).toBe('/api/v1/patient/r1/session');
|
||||
expect(typeof body.session_at).toBe('number');
|
||||
expect(body.payment_method).toBe('pending');
|
||||
expect(body.services).toEqual([{ service_item_uuid: 'svc1', quantity: 1 }]);
|
||||
expect(body.consumables).toEqual([{ inventory_item_uuid: 'inv1', quantity: 1 }]);
|
||||
// بدون بیمه → درصدها صفر و id ارسال نمیشود
|
||||
expect(body.base_insurance_discount_percent).toBe(0);
|
||||
expect(body.insurance_base_id).toBeUndefined();
|
||||
|
||||
// ورود به گام پرداخت با دیتای واقعی session ساختهشده
|
||||
expect(await screen.findByText('هزینه سرویس:')).toBeInTheDocument();
|
||||
expect(screen.getByText('موجودی کیف پول:')).toBeInTheDocument();
|
||||
expect(screen.getAllByText(formatRial(1_700_000)).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('increments consumable quantity via the counter and reflects the total', async () => {
|
||||
mockGets();
|
||||
renderPage();
|
||||
|
||||
await screen.findByText('انتخاب کالای مصرفی');
|
||||
await pickOption('consumable-select', 'عینک');
|
||||
fireEvent.click(screen.getByLabelText('افزودن کالا'));
|
||||
|
||||
fireEvent.click(screen.getByLabelText('افزایش'));
|
||||
expect(screen.getByText(`مجموع قیمت کالاها: ${formatRial(2_400_000)}`)).toBeInTheDocument();
|
||||
|
||||
// کاهش تا صفر → حذف
|
||||
fireEvent.click(screen.getByLabelText('کاهش'));
|
||||
fireEvent.click(screen.getByLabelText('کاهش'));
|
||||
expect(screen.getByText('کالایی انتخاب نشده است')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders empty option lists without crashing (بدون بخش/کالا/پکیج)', async () => {
|
||||
mockGets({ sections: [], inventory: [], packages: [], staff: [] });
|
||||
renderPage();
|
||||
|
||||
expect(await screen.findByText('انتخاب بخش')).toBeInTheDocument();
|
||||
expect(screen.getByText('کالایی انتخاب نشده است')).toBeInTheDocument();
|
||||
// مجموع صفر
|
||||
expect(screen.getByText(`مجموع قیمت کالاها: ${formatRial(0)}`)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,320 +1,119 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useState } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { z } from 'zod';
|
||||
import { toast } from 'sonner';
|
||||
import { ChevronRightIcon, PlusIcon, XMarkIcon } from '@heroicons/react/24/outline';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { api } from '../lib/api';
|
||||
import type { ApiResponse } from '../lib/api';
|
||||
import type { PatientRecord, ServiceSection, ServiceItem } from '../types';
|
||||
import { formatRial } from '../lib/utils';
|
||||
import SearchableSelect from '../components/ui/SearchableSelect';
|
||||
|
||||
const schema = z.object({
|
||||
visit_price_rials: z.coerce.number().min(0),
|
||||
base_insurance_discount_percent: z.coerce.number().min(0).max(100),
|
||||
supplementary_discount_percent: z.coerce.number().min(0).max(100),
|
||||
insurance_base_id: z.coerce.number().optional(),
|
||||
insurance_supplementary_id: z.coerce.number().optional(),
|
||||
payment_method: z.enum(['cash', 'card', 'insurance', 'online', 'pending']),
|
||||
notes: z.string().optional(),
|
||||
});
|
||||
type FormData = z.infer<typeof schema>;
|
||||
|
||||
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 }
|
||||
|
||||
const PAYMENT_LABELS: Record<string, string> = {
|
||||
cash: 'نقدی', card: 'کارت', insurance: 'بیمه', online: 'آنلاین', pending: 'در انتظار',
|
||||
};
|
||||
|
||||
interface Rule { covered: boolean; percent: number; franchise: number; ceiling: number | null }
|
||||
|
||||
// (calcFinal قدیمی حذف شد — حالا سهم بیمار خدمات با patientShareOf و قاعدهی هر بیمهگر محاسبه میشود)
|
||||
|
||||
// آینهی 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 sectionTitle: React.CSSProperties = { fontWeight: 700, fontSize: 13.5, color: 'var(--text-2)', margin: '0 0 12px' };
|
||||
const fieldLabel: React.CSSProperties = { fontSize: 12.5, fontWeight: 600, marginBottom: 5, display: 'block' };
|
||||
import type { PatientRecord } from '../types';
|
||||
import type { SessionCardData } from '../components/SessionServiceCard';
|
||||
import SessionStepper from '../components/SessionStepper';
|
||||
import CreateStep from '../components/session/CreateStep';
|
||||
import PaymentStep from '../components/session/PaymentStep';
|
||||
import DetailsStep from '../components/session/DetailsStep';
|
||||
import { ArrowLeftPH, ArrowLeftD, CloseModalD } from '../components/icons/FilesServiceIcons';
|
||||
|
||||
/**
|
||||
* ثبت مراجعه جدید — پورت کامل tauri /files/create-service (حالت ایجاد):
|
||||
* ویزارد سهگامی «ایجاد سرویس ← پرداخت ← جزییات». گام ۱ session را میسازد
|
||||
* (تاریخ/ساعت پذیرش، خدمات + پرسنل، کالای مصرفی، پکیج، بیمهی شرطی)؛
|
||||
* گامهای ۲ و ۳ همان کامپوننتهای مشترک SessionPaymentPage هستند.
|
||||
*/
|
||||
export default function NewSessionPage() {
|
||||
const { recordUuid = '' } = useParams();
|
||||
const nav = useNavigate();
|
||||
const qc = useQueryClient();
|
||||
|
||||
const [selectedServices, setSelectedServices] = useState<{ uuid: string; name: string; price: number; qty: number; insured: boolean }[]>([]);
|
||||
const [sectionUuid, setSectionUuid] = useState('');
|
||||
const [itemUuid, setItemUuid] = useState('');
|
||||
const [baseId, setBaseId] = useState('');
|
||||
const [suppId, setSuppId] = useState('');
|
||||
|
||||
const form = useForm<FormData>({
|
||||
resolver: zodResolver(schema),
|
||||
defaultValues: { visit_price_rials: 0, base_insurance_discount_percent: 0, supplementary_discount_percent: 0, payment_method: 'cash' },
|
||||
});
|
||||
const steps = ['ایجاد سرویس', 'پرداخت', 'جزییات'];
|
||||
const [activeStep, setActiveStep] = useState(0);
|
||||
const [createdUuid, setCreatedUuid] = useState('');
|
||||
|
||||
const recordQ = useQuery<ApiResponse<PatientRecord>>({
|
||||
queryKey: ['patient-detail', recordUuid],
|
||||
queryFn: () => api.get(`/api/v1/patient/${recordUuid}`),
|
||||
enabled: !!recordUuid,
|
||||
});
|
||||
const record = (recordQ.data?.data as PatientRecord | undefined) ?? undefined;
|
||||
const record = recordQ.data?.data as PatientRecord | undefined;
|
||||
const patientName = record?.user_name || record?.profile?.full_name || '—';
|
||||
|
||||
const { data: sectionsData } = useQuery<ApiResponse<ServiceSection[]>>({
|
||||
queryKey: ['service-sections'], queryFn: () => api.get('/api/v1/service-sections'),
|
||||
// بعد از ساخت session در گام ۱، گامهای ۲/۳ از لیست sessions تغذیه میشوند.
|
||||
const sessionsQ = useQuery<ApiResponse<SessionCardData[]>>({
|
||||
queryKey: ['patient-sessions', recordUuid],
|
||||
queryFn: () => api.get(`/api/v1/patient/${recordUuid}/sessions`),
|
||||
enabled: !!recordUuid && !!createdUuid,
|
||||
});
|
||||
const { data: itemsData } = useQuery<ApiResponse<ServiceItem[]>>({
|
||||
queryKey: ['service-items-for-session', sectionUuid],
|
||||
queryFn: () => api.get(`/api/v1/service-items/${sectionUuid}`),
|
||||
enabled: !!sectionUuid,
|
||||
const session = (sessionsQ.data?.data ?? []).find((s) => s.uuid === createdUuid);
|
||||
|
||||
const walletQ = useQuery<ApiResponse<{ balance_rials: number }>>({
|
||||
queryKey: ['patient-wallet', recordUuid],
|
||||
queryFn: () => api.get(`/api/v1/patient/${recordUuid}/wallet`),
|
||||
enabled: !!recordUuid && !!createdUuid,
|
||||
});
|
||||
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;
|
||||
const walletBalance = (walletQ.data?.data as any)?.balance_rials ?? 0;
|
||||
|
||||
useEffect(() => {
|
||||
if (freeVisit > 0 && !form.getValues('visit_price_rials')) {
|
||||
form.setValue('visit_price_rials', freeVisit);
|
||||
}
|
||||
}, [freeVisit]);
|
||||
|
||||
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 itemOptions = (itemsData?.data ?? []).filter(i => i.active).map(i => ({ value: i.uuid, label: `${i.name} — ${formatRial(i.price_rials)}` }));
|
||||
|
||||
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);
|
||||
form.setValue('insurance_base_id', id ? Number(id) : undefined);
|
||||
form.setValue('base_insurance_discount_percent', id ? coverageOf(id) : 0);
|
||||
};
|
||||
const applySupp = (id: string) => {
|
||||
setSuppId(id);
|
||||
form.setValue('insurance_supplementary_id', id ? Number(id) : undefined);
|
||||
form.setValue('supplementary_discount_percent', id ? coverageOf(id) : 0);
|
||||
};
|
||||
|
||||
const addService = () => {
|
||||
if (!itemUuid) return;
|
||||
const found = itemsData?.data?.find(i => i.uuid === itemUuid);
|
||||
if (!found || selectedServices.some(s => s.uuid === found.uuid)) return;
|
||||
setSelectedServices(p => [...p, { uuid: found.uuid, name: found.name, price: found.price_rials, qty: 1, insured: !!found.insurance_covered }]);
|
||||
setItemUuid('');
|
||||
};
|
||||
|
||||
const setQty = (uuid: string, qty: number) =>
|
||||
setSelectedServices(p => p.map(s => s.uuid === uuid ? { ...s, qty: Math.max(1, qty) } : s));
|
||||
|
||||
const visit = Number(form.watch('visit_price_rials')) || 0;
|
||||
const base = Number(form.watch('base_insurance_discount_percent')) || 0;
|
||||
const supp = Number(form.watch('supplementary_discount_percent')) || 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;
|
||||
// پرچم سرویس gate نهایی: اگر «شامل بیمه» نباشد، کامل سهم بیمار است.
|
||||
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],
|
||||
);
|
||||
const servicesInsured = servicesTotal - servicesPatient;
|
||||
const afterBase = Math.round(visit * (1 - base / 100));
|
||||
const afterSupp = Math.round(afterBase * (1 - supp / 100));
|
||||
const finalPrice = Math.round(afterSupp) + servicesPatient;
|
||||
|
||||
const createMut = useMutation({
|
||||
mutationFn: (body: object) => api.post(`/api/v1/patient/${recordUuid}/session`, body),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['patient-sessions', recordUuid] });
|
||||
toast.success('مراجعه ثبت شد');
|
||||
nav(-1);
|
||||
},
|
||||
onError: (e: Error) => toast.error(e.message),
|
||||
});
|
||||
|
||||
const submit = form.handleSubmit((d) => {
|
||||
createMut.mutate({ ...d, services: selectedServices.map(s => ({ service_item_uuid: s.uuid, quantity: s.qty })) });
|
||||
});
|
||||
|
||||
const summaryRow = (label: string, val: number, strong = false) => (
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: strong ? 15 : 13, fontWeight: strong ? 700 : 400, color: strong ? 'var(--text)' : 'var(--text-3)' }}>
|
||||
<span>{label}</span>
|
||||
<span style={strong ? { color: 'var(--primary)' } : undefined}>{formatRial(val)}</span>
|
||||
</div>
|
||||
);
|
||||
const finish = () => nav(`/admin/patients/${recordUuid}?tab=services`);
|
||||
|
||||
return (
|
||||
<div className="fade-in" style={{ maxWidth: 1100, margin: '0 auto' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 16 }}>
|
||||
<button className="btn ghost sm" onClick={() => nav(-1)}>
|
||||
<ChevronRightIcon style={{ width: 15 }} /> بازگشت
|
||||
</button>
|
||||
<div>
|
||||
<h1 className="section-title" style={{ margin: 0 }}>ثبت مراجعه جدید</h1>
|
||||
<div className="muted" style={{ fontSize: 13, marginTop: 2 }}>بیمار: {patientName}</div>
|
||||
<div className="fade-in" style={{ width: '100%' }}>
|
||||
{/* breadcrumb — tauri AddService header */}
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-start', marginBottom: 30 }}>
|
||||
<div className="dark:text-[#A1A1A1]" style={{ display: 'flex', alignItems: 'center', gap: 8, color: '#6B7280', fontSize: 12, padding: '0 16px' }}>
|
||||
<div
|
||||
onClick={() => nav(-1)}
|
||||
className="bg-white dark:bg-[#222433]"
|
||||
style={{ display: 'flex', alignItems: 'center', gap: 4, padding: '6px 8px', borderRadius: 12, cursor: 'pointer' }}
|
||||
>
|
||||
<ArrowLeftPH style={{ width: 18, height: 18, rotate: '180deg' }} />
|
||||
<span>بازگشت</span>
|
||||
</div>
|
||||
<ArrowLeftD />
|
||||
<span>پرونده</span>
|
||||
<ArrowLeftD />
|
||||
<span className="dark:text-[#D7D8ED]" style={{ color: '#111827' }}>{patientName}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form onSubmit={submit} style={{ display: 'grid', gridTemplateColumns: 'minmax(0,1fr) 320px', gap: 16, alignItems: 'start' }}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||
<div className="card" style={{ padding: 20 }}>
|
||||
<h3 style={sectionTitle}>بیمه و مبلغ ویزیت</h3>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12, marginBottom: 12 }}>
|
||||
<div>
|
||||
<label style={fieldLabel}>بیمه پایه</label>
|
||||
<SearchableSelect options={baseOpts} value={baseId} onChange={v => applyBase(v ? String(v) : '')} placeholder="بدون بیمه پایه" isClearable />
|
||||
</div>
|
||||
<div>
|
||||
<label style={fieldLabel}>بیمه تکمیلی</label>
|
||||
<SearchableSelect options={suppOpts} value={suppId} onChange={v => applySupp(v ? String(v) : '')} placeholder="بدون بیمه تکمیلی" isClearable />
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: 12 }}>
|
||||
<div>
|
||||
<label style={fieldLabel}>قیمت ویزیت (تومان)</label>
|
||||
<input className="input" type="number" min={0} dir="ltr" {...form.register('visit_price_rials')} />
|
||||
</div>
|
||||
<div>
|
||||
<label style={fieldLabel}>تخفیف بیمه پایه (%)</label>
|
||||
<input className="input" type="number" min={0} max={100} dir="ltr" {...form.register('base_insurance_discount_percent')} />
|
||||
</div>
|
||||
<div>
|
||||
<label style={fieldLabel}>تخفیف تکمیلی (%)</label>
|
||||
<input className="input" type="number" min={0} max={100} dir="ltr" {...form.register('supplementary_discount_percent')} />
|
||||
</div>
|
||||
</div>
|
||||
{/* card — tauri width 748 centered */}
|
||||
<div style={{ width: '100%', display: 'flex', justifyContent: 'center' }}>
|
||||
<div className="bg-white dark:bg-[#222433]" style={{ width: 748, maxWidth: '100%', padding: 24 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', marginBottom: 8 }}>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="بستن"
|
||||
onClick={() => nav(-1)}
|
||||
style={{ minWidth: 40, height: 40, marginBottom: 8, borderRadius: '50%', border: 'none', background: 'transparent', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center' }}
|
||||
>
|
||||
<CloseModalD />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="card" style={{ padding: 20 }}>
|
||||
<h3 style={sectionTitle}>خدمات</h3>
|
||||
<div style={{ display: 'flex', gap: 8, marginBottom: selectedServices.length ? 12 : 0 }}>
|
||||
<div style={{ flex: 1 }}>
|
||||
<SearchableSelect options={sectionOptions} value={sectionUuid} onChange={v => { setSectionUuid(v ? String(v) : ''); setItemUuid(''); }} placeholder="انتخاب بخش..." />
|
||||
</div>
|
||||
<div style={{ flex: 1 }}>
|
||||
<SearchableSelect options={itemOptions} value={itemUuid} onChange={v => setItemUuid(v ? String(v) : '')} placeholder="انتخاب سرویس..." isDisabled={!sectionUuid} />
|
||||
</div>
|
||||
<button type="button" className="btn primary sm" onClick={addService} disabled={!itemUuid}>
|
||||
<PlusIcon style={{ width: 14 }} />
|
||||
</button>
|
||||
</div>
|
||||
{selectedServices.length > 0 && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{selectedServices.map(svc => (
|
||||
<div key={svc.uuid} style={{ display: 'flex', alignItems: 'center', gap: 12, padding: '8px 12px', borderRadius: 10, border: '1px solid var(--border)' }}>
|
||||
<span style={{ flex: 1, fontWeight: 500, fontSize: 13 }}>{svc.name}</span>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||
<button type="button" className="mini-btn" onClick={() => setQty(svc.uuid, svc.qty - 1)} disabled={svc.qty <= 1}>−</button>
|
||||
<span style={{ minWidth: 26, textAlign: 'center', fontWeight: 600, fontSize: 13 }}>{svc.qty}</span>
|
||||
<button type="button" className="mini-btn" onClick={() => setQty(svc.uuid, svc.qty + 1)}>+</button>
|
||||
</div>
|
||||
<span style={{ color: 'var(--primary)', fontWeight: 600, fontSize: 13, minWidth: 90, textAlign: 'left' }} dir="ltr">{formatRial(svc.price * svc.qty)}</span>
|
||||
<button type="button" style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--danger)', padding: 0, display: 'flex' }}
|
||||
onClick={() => setSelectedServices(p => p.filter(s => s.uuid !== svc.uuid))}>
|
||||
<XMarkIcon style={{ width: 15 }} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<SessionStepper activeStep={activeStep} steps={steps} />
|
||||
|
||||
<div className="card" style={{ padding: 20 }}>
|
||||
<h3 style={sectionTitle}>پرداخت و یادداشت</h3>
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<label style={fieldLabel}>روش پرداخت</label>
|
||||
<SearchableSelect
|
||||
options={Object.entries(PAYMENT_LABELS).map(([v, l]) => ({ value: v, label: l }))}
|
||||
value={form.watch('payment_method')}
|
||||
onChange={v => form.setValue('payment_method', (v as FormData['payment_method']) || 'cash')}
|
||||
placeholder="روش پرداخت"
|
||||
{activeStep === 0 && (
|
||||
<CreateStep
|
||||
recordUuid={recordUuid}
|
||||
profile={record?.profile}
|
||||
onCreated={(uuid) => { setCreatedUuid(uuid); setActiveStep(1); }}
|
||||
onCancel={() => nav(-1)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeStep === 1 && (
|
||||
session ? (
|
||||
<PaymentStep
|
||||
recordUuid={recordUuid}
|
||||
session={session}
|
||||
walletBalance={walletBalance}
|
||||
onContinue={() => setActiveStep(2)}
|
||||
onCancel={finish}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label style={fieldLabel}>یادداشت</label>
|
||||
<textarea className="input" rows={3} dir="rtl" placeholder="یادداشت پزشک..." {...form.register('notes')} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ padding: '40px 0', textAlign: 'center', color: 'var(--text-3)', fontSize: 14 }}>در حال بارگذاری...</div>
|
||||
)
|
||||
)}
|
||||
|
||||
<div className="card" style={{ padding: 20, position: 'sticky', top: 16, display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
<h3 style={sectionTitle}>خلاصه</h3>
|
||||
{summaryRow('ویزیت آزاد', visit)}
|
||||
{base > 0 && summaryRow('پس از بیمه پایه', afterBase)}
|
||||
{supp > 0 && summaryRow('پس از بیمه تکمیلی', afterSupp)}
|
||||
{servicesTotal > 0 && summaryRow('جمع خدمات', servicesTotal)}
|
||||
{servicesInsured > 0 && summaryRow('سهم بیمه از خدمات', servicesInsured)}
|
||||
{servicesInsured > 0 && summaryRow('سهم بیمار از خدمات', servicesTotal - servicesInsured)}
|
||||
<div style={{ borderTop: '1px solid var(--border)', paddingTop: 8, marginTop: 2 }}>
|
||||
{summaryRow('مبلغ نهایی (سهم بیمار)', finalPrice, true)}
|
||||
</div>
|
||||
<button type="submit" className="btn primary block" style={{ marginTop: 8 }} disabled={createMut.isPending}>
|
||||
{createMut.isPending ? 'در حال ذخیره...' : 'ثبت مراجعه'}
|
||||
</button>
|
||||
<button type="button" className="btn ghost block" onClick={() => nav(-1)}>انصراف</button>
|
||||
{activeStep === 2 && session && (
|
||||
<DetailsStep session={session} onBack={() => setActiveStep(1)} onFinish={finish} />
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,52 +1,26 @@
|
||||
import { useState } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { ChevronDownIcon } from '@heroicons/react/24/outline';
|
||||
import { toast } from 'sonner';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { api } from '../lib/api';
|
||||
import type { ApiResponse } from '../lib/api';
|
||||
import type { PatientRecord } from '../types';
|
||||
import { formatRial } from '../lib/utils';
|
||||
import type { SessionCardData } from '../components/SessionServiceCard';
|
||||
import SessionStepper from '../components/SessionStepper';
|
||||
import SearchableSelect from '../components/ui/SearchableSelect';
|
||||
import PersianDateInput from '../components/ui/PersianDateInput';
|
||||
import {
|
||||
ArrowLeftPH, ArrowLeftD, CloseModalD, Step2PaymentCard,
|
||||
FilesServiceBalanceWallet, TrashRed,
|
||||
} from '../components/icons/FilesServiceIcons';
|
||||
|
||||
/** روشهای پرداخت — همان چهار گزینهی آکاردئون tauri Step2Payment. */
|
||||
const METHODS: { key: string; label: string }[] = [
|
||||
{ key: 'wallet', label: 'پرداخت از طریق کیف پول' },
|
||||
{ key: 'pos', label: 'پرداخت از طریق کارت خوان' },
|
||||
{ key: 'cash', label: 'پرداخت نقدی' },
|
||||
{ key: 'card', label: 'کارت به کارت' },
|
||||
];
|
||||
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);
|
||||
import PaymentStep from '../components/session/PaymentStep';
|
||||
import DetailsStep from '../components/session/DetailsStep';
|
||||
import { ArrowLeftPH, ArrowLeftD, CloseModalD } from '../components/icons/FilesServiceIcons';
|
||||
|
||||
/**
|
||||
* تکمیل پرداخت مراجعه — پورت صفحهی tauri /files/create-service در حالت payment:
|
||||
* استپر دوگامی «پرداخت ← جزییات» با تخفیف تسویه، پرداخت چندتکه و تاریخ پرداخت.
|
||||
* بدنهی گامها در components/session/{PaymentStep,DetailsStep} مشترک با NewSessionPage است.
|
||||
*/
|
||||
export default function SessionPaymentPage() {
|
||||
const { recordUuid = '', sessionUuid = '' } = useParams();
|
||||
const nav = useNavigate();
|
||||
const qc = useQueryClient();
|
||||
|
||||
const steps = ['پرداخت', 'جزییات'];
|
||||
const [activeStep, setActiveStep] = useState(0);
|
||||
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 recordQ = useQuery<ApiResponse<PatientRecord>>({
|
||||
queryKey: ['patient-detail', recordUuid],
|
||||
@@ -70,43 +44,6 @@ export default function SessionPaymentPage() {
|
||||
});
|
||||
const walletBalance = (walletQ.data?.data as any)?.balance_rials ?? 0;
|
||||
|
||||
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 ?? [];
|
||||
const serviceNames = (session?.services ?? []).map((s) => s.service_name || s.name).filter(Boolean) as string[];
|
||||
if ((session?.visit_price_rials ?? 0) > 0) serviceNames.unshift('ویزیت');
|
||||
|
||||
const finish = () => nav(`/admin/patients/${recordUuid}?tab=services`);
|
||||
|
||||
if (sessionsQ.isLoading) {
|
||||
@@ -116,10 +53,6 @@ export default function SessionPaymentPage() {
|
||||
return <div style={{ padding: '40px 0', textAlign: 'center', color: 'var(--text-3)', fontSize: 14 }}>مراجعه یافت نشد</div>;
|
||||
}
|
||||
|
||||
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' };
|
||||
|
||||
return (
|
||||
<div className="fade-in" style={{ width: '100%' }}>
|
||||
{/* breadcrumb — tauri AddService header */}
|
||||
@@ -157,196 +90,15 @@ export default function SessionPaymentPage() {
|
||||
<SessionStepper activeStep={activeStep} steps={steps} />
|
||||
|
||||
{activeStep === 0 ? (
|
||||
<>
|
||||
{/* هزینه سرویس */}
|
||||
<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={() => nav(-1)}>انصراف</button>
|
||||
<button type="button" style={{ ...primaryBtn, flex: 1 }} onClick={() => setActiveStep(1)}>ثبت و ادامه</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
<PaymentStep
|
||||
recordUuid={recordUuid}
|
||||
session={session}
|
||||
walletBalance={walletBalance}
|
||||
onContinue={() => setActiveStep(1)}
|
||||
onCancel={() => nav(-1)}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
{/* گام جزییات — پورت 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={() => setActiveStep(0)}>انصراف</button>
|
||||
<button type="button" style={{ ...primaryBtn, flex: 1 }} onClick={finish}>صدور فاکتور</button>
|
||||
</div>
|
||||
</>
|
||||
<DetailsStep session={session} onBack={() => setActiveStep(0)} onFinish={finish} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user