fix: issue invoice on demand so view-invoice buttons actually work

The admin SPA never called POST /billing/invoices, so every session had
invoice_uuid = null and the view-invoice buttons on the patient services
tab (and MyPatients visit modal) were permanently disabled. Add a shared
useIssueInvoice hook (idempotent create + finalize when draft) and wire
it into:
- DetailsStep: the wizard's final step now really issues the invoice
- SessionServiceCard / PatientDetailPage: clicking view-invoice on a
  session without an invoice issues it first, then opens the summary
- MyPatientsPage visit modal: same, replacing the dead disabled button

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
hamed
2026-07-16 15:39:02 +03:30
co-authored by Claude Opus 4.8
parent 0ba4bf383b
commit 76bfa5d36f
10 changed files with 204 additions and 18 deletions
+20 -7
View File
@@ -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<string | null>(null);
const [invoiceUuid, setInvoiceUuid] = useState<string | null>(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<PatientSession | null>(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({
<PencilIcon style={{ width: 15 }} /> ویرایش
</button>
{paid ? (
session.invoice_uuid ? (
<button className="cp-btn-secondary" onClick={() => onViewInvoice(session.invoice_uuid!)}>مشاهده فاکتور</button>
) : (
<button className="cp-btn-secondary" disabled>پرداخت شده</button>
)
<button className="cp-btn-secondary" disabled={issuing} onClick={() => onViewInvoice(session)}>
{issuing ? 'در حال صدور…' : 'مشاهده فاکتور'}
</button>
) : (
<button className="cp-btn-primary" disabled={settling} onClick={() => onSettle(session.uuid)}>تکمیل پرداخت</button>
)}
+1 -1
View File
@@ -110,7 +110,7 @@ export default function NewSessionPage() {
)}
{activeStep === 2 && session && (
<DetailsStep session={session} onBack={() => setActiveStep(1)} onFinish={finish} />
<DetailsStep session={session} recordUuid={recordUuid} onBack={() => setActiveStep(1)} onFinish={finish} />
)}
</div>
</div>
@@ -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<typeof vi.fn>;
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();
+12 -1
View File
@@ -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<string | null>(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<number | null>(null);
@@ -217,7 +228,7 @@ export default function PatientDetailPage() {
) : (
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(240px, 1fr))', columnGap: 6, rowGap: 10, alignItems: 'stretch' }}>
{sessions.map((s) => (
<SessionServiceCard key={s.uuid} session={s} onSettle={(u) => nav(`/admin/patients/${uuid}/session/${u}/pay`)} onViewInvoice={(iv) => setInvoiceUuid(iv)} />
<SessionServiceCard key={s.uuid} session={s} onSettle={(u) => nav(`/admin/patients/${uuid}/session/${u}/pay`)} onViewInvoice={viewInvoice} issuing={issueInvoiceMut.isPending} />
))}
</div>
)}
+1 -1
View File
@@ -98,7 +98,7 @@ export default function SessionPaymentPage() {
onCancel={() => nav(-1)}
/>
) : (
<DetailsStep session={session} onBack={() => setActiveStep(0)} onFinish={finish} />
<DetailsStep session={session} recordUuid={recordUuid} onBack={() => setActiveStep(0)} onFinish={finish} />
)}
</div>
</div>