diff --git a/assets/admin/components/SessionServiceCard.tsx b/assets/admin/components/SessionServiceCard.tsx index d5b59262..6c91ca8d 100644 --- a/assets/admin/components/SessionServiceCard.tsx +++ b/assets/admin/components/SessionServiceCard.tsx @@ -45,11 +45,14 @@ function Row({ label, value, valueStyle }: { label: string; value: string; value * pixel-for-pixel from tauri files/services/ServiceCard. Paid cards show * «مشاهده فاکتور», unpaid ones «تکمیل پرداخت». */ -export default function SessionServiceCard({ session, onSettle, onViewInvoice, settling }: { +export default function SessionServiceCard({ session, onSettle, onViewInvoice, settling, issuing }: { session: SessionCardData; onSettle: (uuid: string) => void; - onViewInvoice: (invoiceUuid: string) => void; + /** کل session پاس می‌شود؛ اگر invoice_uuid نداشت، caller فاکتور را می‌سازد. */ + onViewInvoice: (session: SessionCardData) => void; settling?: boolean; + /** true وقتی صدور فاکتور همین لحظه در جریان است (دکمه قفل می‌شود). */ + issuing?: boolean; }) { const paid = !!session.is_paid; const names = (session.services ?? []).map((s) => s.service_name || s.name).filter(Boolean) as string[]; @@ -94,11 +97,11 @@ export default function SessionServiceCard({ session, onSettle, onViewInvoice, s {paid ? ( ) : ( - + ); diff --git a/assets/admin/hooks/useIssueInvoice.ts b/assets/admin/hooks/useIssueInvoice.ts new file mode 100644 index 00000000..fcbd24b7 --- /dev/null +++ b/assets/admin/hooks/useIssueInvoice.ts @@ -0,0 +1,29 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { api } from '../lib/api'; + +interface InvoicePayload { uuid: string; status: string } + +/** + * صدور فاکتور یک مراجعه: ساخت idempotent (`POST /billing/invoices`) و سپس + * نهایی‌سازی draft. خروجی mutation، uuid فاکتور است تا caller مودال «خلاصه + * فاکتور» را باز کند. با recordUuid، لیست sessions همان بیمار invalidate + * می‌شود تا invoice_uuid کارت‌ها به‌روز شود. + */ +export function useIssueInvoice(recordUuid?: string) { + const qc = useQueryClient(); + + return useMutation({ + mutationFn: async (sessionUuid: string): Promise => { + const res = await api.post('/api/v1/billing/invoices', { session_uuid: sessionUuid }); + const invoice = ((res as any)?.data?.data ?? (res as any)?.data) as InvoicePayload; + if (invoice.status === 'draft') { + await api.post(`/api/v1/billing/invoices/${invoice.uuid}/finalize`, {}); + } + return invoice.uuid; + }, + onSuccess: () => { + if (recordUuid) qc.invalidateQueries({ queryKey: ['patient-sessions', recordUuid] }); + qc.invalidateQueries({ queryKey: ['invoice'] }); + }, + }); +} diff --git a/assets/admin/pages/MyPatientsPage.tsx b/assets/admin/pages/MyPatientsPage.tsx index 687d0134..886277dc 100644 --- a/assets/admin/pages/MyPatientsPage.tsx +++ b/assets/admin/pages/MyPatientsPage.tsx @@ -51,6 +51,7 @@ import { } from "../lib/patientForm"; import type { ApiResponse, PaginatedResponse } from "../lib/api"; import { api } from "../lib/api"; +import { useIssueInvoice } from "../hooks/useIssueInvoice"; import { formatDate, formatDateTime, @@ -210,6 +211,16 @@ function MyPatientsPageInner() { >("visits"); const [expandedVisit, setExpandedVisit] = useState(null); const [invoiceUuid, setInvoiceUuid] = useState(null); + + // «مشاهده فاکتور»: نبودِ invoice_uuid یعنی اول صادر شود (idempotent create + finalize). + const issueInvoiceMut = useIssueInvoice(selectedRecord?.uuid); + const viewInvoice = (s: PatientSession) => { + if (s.invoice_uuid) { setExpandedVisit(null); setInvoiceUuid(s.invoice_uuid); return; } + issueInvoiceMut.mutate(s.uuid, { + onSuccess: (iv) => { setExpandedVisit(null); setInvoiceUuid(iv); }, + onError: () => toast.error("صدور فاکتور ناموفق بود"), + }); + }; const [sessionPage, setSessionPage] = useState(1); const [sessionModal, setSessionModal] = useState(false); const [editSession, setEditSession] = useState(null); @@ -1340,7 +1351,8 @@ function MyPatientsPageInner() { onClose={() => setExpandedVisit(null)} settling={settleSessionMut.isPending} onSettle={(uuid) => settleSessionMut.mutate(uuid)} - onViewInvoice={(uuid) => { setExpandedVisit(null); setInvoiceUuid(uuid); }} + onViewInvoice={viewInvoice} + issuing={issueInvoiceMut.isPending} onEdit={(s) => { setExpandedVisit(null); setEditSession(s); }} /> @@ -1878,6 +1890,7 @@ function VisitDetailModal({ session, onClose, settling, + issuing, onSettle, onViewInvoice, onEdit, @@ -1885,8 +1898,10 @@ function VisitDetailModal({ session: PatientSession | null; onClose: () => void; settling: boolean; + issuing?: boolean; onSettle: (uuid: string) => void; - onViewInvoice: (uuid: string) => void; + /** کل session پاس می‌شود؛ نبودِ invoice_uuid یعنی caller باید فاکتور را صادر کند. */ + onViewInvoice: (session: PatientSession) => void; onEdit: (s: PatientSession) => void; }) { if (!session) return null; @@ -1916,11 +1931,9 @@ function VisitDetailModal({ ویرایش {paid ? ( - session.invoice_uuid ? ( - - ) : ( - - ) + ) : ( )} diff --git a/assets/admin/pages/NewSessionPage.tsx b/assets/admin/pages/NewSessionPage.tsx index 59900a3c..4867ad3c 100644 --- a/assets/admin/pages/NewSessionPage.tsx +++ b/assets/admin/pages/NewSessionPage.tsx @@ -110,7 +110,7 @@ export default function NewSessionPage() { )} {activeStep === 2 && session && ( - setActiveStep(1)} onFinish={finish} /> + setActiveStep(1)} onFinish={finish} /> )} diff --git a/assets/admin/pages/PatientDetailPage.test.tsx b/assets/admin/pages/PatientDetailPage.test.tsx index 4a591eb6..108b9e43 100644 --- a/assets/admin/pages/PatientDetailPage.test.tsx +++ b/assets/admin/pages/PatientDetailPage.test.tsx @@ -100,6 +100,33 @@ describe('PatientDetailPage (پرونده تب‌دار)', () => { expect(await screen.findByText('اطلاعات فاکتور')).toBeInTheDocument(); }); + it('issues the invoice first (create + finalize) when the paid session has none', async () => { + const base = get.getMockImplementation()!; + get.mockImplementation((url: string) => { + if (url === '/api/v1/patient/r1/sessions') return Promise.resolve({ success: true, data: [ + { uuid: 's3', services: [{ service_name: 'لیزر' }], doctor_name: 'دکتر فتحی', final_price_rials: 1000000, is_paid: true, patient_debt_rials: 0, created_at: 1700000000, visit_price_rials: 0 }, + ], meta: { totalRecords: 1 } }); + if (url === '/api/v1/billing/invoices/iv9') return Promise.resolve({ success: true, data: { data: { + uuid: 'iv9', status: 'finalized', issued_at: 1700000000, total_rials: 1000000, + base_insurance_rials: 0, supplementary_rials: 0, patient_rials: 1000000, items: [], + } } }); + return base(url); + }); + const post = api.post as ReturnType; + post.mockReset(); + post.mockImplementation((url: string) => { + if (url === '/api/v1/billing/invoices') return Promise.resolve({ success: true, data: { data: { uuid: 'iv9', status: 'draft' } } }); + return Promise.resolve({ success: true, data: {} }); + }); + + renderDetail(); + fireEvent.click(await screen.findByText('مشاهده فاکتور')); + + await waitFor(() => expect(post).toHaveBeenCalledWith('/api/v1/billing/invoices', { session_uuid: 's3' })); + expect(post).toHaveBeenCalledWith('/api/v1/billing/invoices/iv9/finalize', {}); + expect(await screen.findByText('خلاصه فاکتور')).toBeInTheDocument(); + }); + it('renders the editable «اطلاعات پرونده» form prefilled from the profile', async () => { renderDetail(); await loaded(); diff --git a/assets/admin/pages/PatientDetailPage.tsx b/assets/admin/pages/PatientDetailPage.tsx index 90f0195b..f3a85a0e 100644 --- a/assets/admin/pages/PatientDetailPage.tsx +++ b/assets/admin/pages/PatientDetailPage.tsx @@ -22,6 +22,7 @@ import ConfirmDialog from '../components/ui/ConfirmDialog'; import PersianDateInput from '../components/ui/PersianDateInput'; import PatientCaseBanner, { Breadcrumb } from '../components/PatientCaseBanner'; import SessionServiceCard, { type SessionCardData } from '../components/SessionServiceCard'; +import { useIssueInvoice } from '../hooks/useIssueInvoice'; import AppointmentTurnCard, { type AppointmentCardData } from '../components/AppointmentTurnCard'; import SessionPaymentAccordion, { type SessionPaymentData } from '../components/SessionPaymentAccordion'; import InvoiceSummaryModal from '../components/InvoiceSummaryModal'; @@ -81,6 +82,16 @@ export default function PatientDetailPage() { const nav = useNavigate(); const [invoiceUuid, setInvoiceUuid] = useState(null); + // «مشاهده فاکتور»: اگر session هنوز فاکتور ندارد، اول صادر می‌شود (idempotent). + const issueInvoiceMut = useIssueInvoice(uuid); + const viewInvoice = (s: SessionCardData) => { + if (s.invoice_uuid) { setInvoiceUuid(s.invoice_uuid); return; } + issueInvoiceMut.mutate(s.uuid, { + onSuccess: (iv) => setInvoiceUuid(iv), + onError: () => toast.error('صدور فاکتور ناموفق بود'), + }); + }; + // ── فرم «اطلاعات پرونده» (اینلاین، معادل tauri FileInfoSection) ────────────── // استان/شهر (Location) و بیمهٔ پایه (insurance-pricing) برای گزینه‌های فرم. const [editProvinceId, setEditProvinceId] = useState(null); @@ -217,7 +228,7 @@ export default function PatientDetailPage() { ) : (
{sessions.map((s) => ( - nav(`/admin/patients/${uuid}/session/${u}/pay`)} onViewInvoice={(iv) => setInvoiceUuid(iv)} /> + nav(`/admin/patients/${uuid}/session/${u}/pay`)} onViewInvoice={viewInvoice} issuing={issueInvoiceMut.isPending} /> ))}
)} diff --git a/assets/admin/pages/SessionPaymentPage.tsx b/assets/admin/pages/SessionPaymentPage.tsx index 92a59434..7429fbfe 100644 --- a/assets/admin/pages/SessionPaymentPage.tsx +++ b/assets/admin/pages/SessionPaymentPage.tsx @@ -98,7 +98,7 @@ export default function SessionPaymentPage() { onCancel={() => nav(-1)} /> ) : ( - setActiveStep(0)} onFinish={finish} /> + setActiveStep(0)} onFinish={finish} /> )} diff --git a/docs/api/billing.md b/docs/api/billing.md index c60592ab..6ba971ae 100644 --- a/docs/api/billing.md +++ b/docs/api/billing.md @@ -270,4 +270,8 @@ ## ارسال مطالبه (ClaimSubmitter) -عملِ `submit` از طریق interface `App\Billing\Contract\ClaimSubmitterInterface` انجام می‌شود. پیاده‌سازی پیش‌فرض `ManualClaimSubmitter` است (ارسال دستی/آفلاین — همیشه موفق). برای اتصال آینده به API شرکت‌های بیمه‌ی ایران کافی است یک پیاده‌سازی جدید از این interface ساخته و در `config/services.yaml` bind شود؛ `ClaimService` تغییر نمی‌کند (Dependency Inversion). اگر `submit` ناموفق باشد، انتقال وضعیت با `422` متوقف می‌شود. \ No newline at end of file +عملِ `submit` از طریق interface `App\Billing\Contract\ClaimSubmitterInterface` انجام می‌شود. پیاده‌سازی پیش‌فرض `ManualClaimSubmitter` است (ارسال دستی/آفلاین — همیشه موفق). برای اتصال آینده به API شرکت‌های بیمه‌ی ایران کافی است یک پیاده‌سازی جدید از این interface ساخته و در `config/services.yaml` bind شود؛ `ClaimService` تغییر نمی‌کند (Dependency Inversion). اگر `submit` ناموفق باشد، انتقال وضعیت با `422` متوقف می‌شود. + +## جریان صدور فاکتور در پنل ادمین + +«صدور فاکتور» (گام «جزییات» ویزارد مراجعه) و «مشاهده فاکتور» (کارت مراجعه / مودال جزئیات) هر دو از `useIssueInvoice` استفاده می‌کنند: `POST /billing/invoices` (idempotent) و سپس `finalize` اگر `draft` باشد. یعنی session بدون فاکتور، در اولین مشاهده صاحب فاکتور نهایی‌شده می‌شود.