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
@@ -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 ? (
<button
type="button"
disabled={!session.invoice_uuid}
onClick={() => session.invoice_uuid && onViewInvoice(session.invoice_uuid)}
disabled={issuing}
onClick={() => onViewInvoice(session)}
style={{ marginTop: 8, height: 40, width: '100%', borderRadius: 4, fontSize: 13, fontWeight: 500, cursor: 'pointer', color: '#4F46E5', background: 'transparent', border: '1px solid #5559ce' }}
>
مشاهده فاکتور
{issuing ? 'در حال صدور…' : 'مشاهده فاکتور'}
</button>
) : (
<button
@@ -0,0 +1,84 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { screen, fireEvent, waitFor } from '@testing-library/react';
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 { toast } from 'sonner';
import DetailsStep from './DetailsStep';
import type { SessionCardData } from '../SessionServiceCard';
const post = api.post as ReturnType<typeof vi.fn>;
const session: SessionCardData = {
uuid: 's1',
services: [{ service_name: 'لیزر' }],
doctor_name: 'دکتر فتحی',
final_price_rials: 1_000_000,
patient_debt_rials: 0,
discount_rials: 0,
visit_price_rials: 0,
payments: [],
};
beforeEach(() => {
post.mockReset();
(toast.error as ReturnType<typeof vi.fn>).mockReset();
});
describe('DetailsStep — صدور فاکتور', () => {
it('فاکتور را می‌سازد، draft را finalize می‌کند و بعد onFinish', async () => {
post.mockImplementation((url: string) =>
url === '/api/v1/billing/invoices'
? Promise.resolve({ success: true, data: { data: { uuid: 'iv1', status: 'draft' } } })
: Promise.resolve({ success: true, data: {} }),
);
const onFinish = vi.fn();
renderWithProviders(<DetailsStep session={session} recordUuid="r1" onBack={() => {}} onFinish={onFinish} />);
fireEvent.click(screen.getByText('صدور فاکتور'));
await waitFor(() => expect(onFinish).toHaveBeenCalled());
expect(post).toHaveBeenCalledWith('/api/v1/billing/invoices', { session_uuid: 's1' });
expect(post).toHaveBeenCalledWith('/api/v1/billing/invoices/iv1/finalize', {});
});
it('فاکتور غیر draft (idempotent create) دیگر finalize نمی‌شود', async () => {
post.mockResolvedValue({ success: true, data: { data: { uuid: 'iv1', status: 'finalized' } } });
const onFinish = vi.fn();
renderWithProviders(<DetailsStep session={session} onBack={() => {}} onFinish={onFinish} />);
fireEvent.click(screen.getByText('صدور فاکتور'));
await waitFor(() => expect(onFinish).toHaveBeenCalled());
expect(post).toHaveBeenCalledTimes(1);
});
it('session با invoice_uuid موجود: بدون درخواست، مستقیم onFinish', async () => {
const onFinish = vi.fn();
renderWithProviders(
<DetailsStep session={{ ...session, invoice_uuid: 'iv0' }} onBack={() => {}} onFinish={onFinish} />,
);
fireEvent.click(screen.getByText('صدور فاکتور'));
await waitFor(() => expect(onFinish).toHaveBeenCalled());
expect(post).not.toHaveBeenCalled();
});
it('خطای صدور: toast خطا و onFinish صدا زده نمی‌شود', async () => {
post.mockRejectedValue(new Error('boom'));
const onFinish = vi.fn();
renderWithProviders(<DetailsStep session={session} onBack={() => {}} onFinish={onFinish} />);
fireEvent.click(screen.getByText('صدور فاکتور'));
await waitFor(() => expect(toast.error).toHaveBeenCalledWith('صدور فاکتور ناموفق بود'));
expect(onFinish).not.toHaveBeenCalled();
});
});
@@ -1,12 +1,16 @@
import { toast } from 'sonner';
import { formatRial } from '../../lib/utils';
import type { SessionCardData } from '../SessionServiceCard';
import { METHOD_LABELS } from './PaymentStep';
import { useIssueInvoice } from '../../hooks/useIssueInvoice';
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;
/** برای invalidate شدن لیست sessions بعد از صدور فاکتور. */
recordUuid?: string;
onBack: () => void;
onFinish: () => void;
}
@@ -15,8 +19,17 @@ interface Props {
* گام «جزییات» — پورت tauri Step3Final با دیتای واقعی:
* خلاصه‌ی سرویس/هزینه/تخفیف + لیست پرداخت‌شده‌ها + مانده. مشترک بین
* NewSessionPage (ویزارد سه‌گامه) و SessionPaymentPage (دوگامه).
* «صدور فاکتور» فاکتور را واقعاً صادر می‌کند (create + finalize) و بعد onFinish.
*/
export default function DetailsStep({ session, onBack, onFinish }: Props) {
export default function DetailsStep({ session, recordUuid, onBack, onFinish }: Props) {
const issueMut = useIssueInvoice(recordUuid);
const issue = () => {
if (session.invoice_uuid) { onFinish(); return; }
issueMut.mutate(session.uuid, {
onSuccess: () => onFinish(),
onError: () => toast.error('صدور فاکتور ناموفق بود'),
});
};
const finalPrice = session.final_price_rials ?? 0;
const discountRials = session.discount_rials ?? 0;
const debt = session.patient_debt_rials ?? 0;
@@ -71,7 +84,9 @@ export default function DetailsStep({ session, onBack, onFinish }: Props) {
<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>
<button type="button" disabled={issueMut.isPending} style={{ ...primaryBtn, flex: 1 }} onClick={issue}>
{issueMut.isPending ? 'در حال صدور…' : 'صدور فاکتور'}
</button>
</div>
</>
);