feat(admin): price lists and the appointment invoice card

Task 08's pricing chain was reachable only through the API, so a clinic could
not define a price list or see what a booked appointment was actually charged.

Price lists
- Draft / active / expired are shown as three states because they mean three
  different things operationally: a draft has no effect on today's price at all
- Activation is a separate action rather than a checkbox in the form, matching
  the backend rule that creating a list must not change anything
- "Copy" seeds a new list from an existing one starting the day the old one
  ends, since most lists are last quarter's with a few numbers moved
- "All branches" is an explicit option, not an empty field

Invoice card
- Renders the recorded chain down to the final amount, hiding zero rows so the
  card stays readable
- A missing invoice renders as a normal state, not an error: an appointment
  that was never confirmed has no invoice
- Says outright that the numbers are from the appointment's own date and later
  tariff changes do not move them — otherwise someone who edited a price
  yesterday reads today's older number as a bug

Also corrects task 08's checklist: its test section carried a copy-pasted "no
UI was built" note against rows whose tests have existed since the task
shipped. Replaced with the real test names and the two that genuinely are not
covered.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
hamed
2026-07-31 19:58:29 +03:30
co-authored by Claude Opus 5
parent 1559a60994
commit 4bca659939
8 changed files with 700 additions and 23 deletions
@@ -0,0 +1,70 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { screen, waitFor } from '@testing-library/react';
import { renderWithProviders } from '../test/utils';
vi.mock('../lib/api', () => ({
api: { get: vi.fn(), post: vi.fn(), patch: vi.fn(), put: vi.fn(), delete: vi.fn() },
ApiError: class extends Error {},
}));
vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
import { api } from '../lib/api';
import AppointmentInvoiceCard from './AppointmentInvoiceCard';
const get = api.get as ReturnType<typeof vi.fn>;
const invoice = {
base_rials: 10_000_000,
items_rials: 2_000_000,
discount_rials: 1_200_000,
insurance_base_rials: 2_160_000,
insurance_supplementary_rials: 0,
tax_rials: 432_000,
final_rials: 9_072_000,
deposit_rials: 1_425_600,
created_at: 1_700_000_000,
breakdown: { discounts: [{ label: 'تخفیف درصدی', rials: 1_200_000 }], sources: {} },
};
describe('AppointmentInvoiceCard', () => {
beforeEach(() => vi.clearAllMocks());
it('lays out the chain down to the final amount', async () => {
get.mockResolvedValue({ success: true, data: invoice });
renderWithProviders(<AppointmentInvoiceCard appointmentUuid="a1" />, { route: '/admin/appointments/a1' });
await waitFor(() => expect(screen.getByText('فاکتور')).toBeInTheDocument());
expect(screen.getByText('قیمت پایه')).toBeInTheDocument();
expect(screen.getByText('مبلغ نهایی')).toBeInTheDocument();
expect(screen.getByText('بیعانه')).toBeInTheDocument();
});
/** ردیف صفر نباید جا بگیرد — فاکتور شلوغ خوانده نمی‌شود. */
it('hides zero rows', async () => {
get.mockResolvedValue({ success: true, data: invoice });
renderWithProviders(<AppointmentInvoiceCard appointmentUuid="a1" />, { route: '/admin/appointments/a1' });
await waitFor(() => expect(screen.getByText('فاکتور')).toBeInTheDocument());
expect(screen.queryByText('سهم بیمهٔ تکمیلی')).not.toBeInTheDocument();
});
/** ⭐ نبودِ فاکتور خطا نیست: نوبتِ ثبت‌نهایی‌نشده فاکتوری ندارد. */
it('treats a missing invoice as a normal state', async () => {
get.mockRejectedValue(new Error('not found'));
renderWithProviders(<AppointmentInvoiceCard appointmentUuid="a1" />, { route: '/admin/appointments/a1' });
await waitFor(() =>
expect(screen.getByText('برای این نوبت فاکتوری ثبت نشده است.')).toBeInTheDocument(),
);
});
it('says the snapshot does not follow later price changes', async () => {
get.mockResolvedValue({ success: true, data: invoice });
renderWithProviders(<AppointmentInvoiceCard appointmentUuid="a1" />, { route: '/admin/appointments/a1' });
await waitFor(() =>
expect(screen.getByText(/تغییر بعدی تعرفه این فاکتور را عوض نمی‌کند/)).toBeInTheDocument(),
);
});
});
@@ -0,0 +1,110 @@
import React from 'react';
import { formatDate, formatRial } from '../lib/utils';
import { useAppointmentInvoice } from '../hooks/usePriceLists';
interface Props {
appointmentUuid: string;
}
/**
* فاکتور تفکیک‌شدهٔ نوبت.
*
* اعدادش snapshot لحظهٔ ثبت‌اند، نه محاسبهٔ امروز: تغییر تعرفه هرگز فاکتور صادرشده را
* عوض نمی‌کند (قانون پنجم مستند). همین جمله زیر کارت هم نوشته می‌شود، چون کاربری که
* قیمت را دیروز عوض کرده و امروز عدد قدیمی می‌بیند وگرنه فکر می‌کند سیستم خراب است.
*/
export default function AppointmentInvoiceCard({ appointmentUuid }: Props) {
const { invoice, loading, missing } = useAppointmentInvoice(appointmentUuid);
if (loading) {
return (
<div className="card" style={{ fontSize: 13, color: 'var(--text-3)' }}>
در حال بارگذاری فاکتور
</div>
);
}
// نبودِ فاکتور خطا نیست: نوبتی که هنوز ثبت نهایی نشده، فاکتوری هم ندارد.
if (missing || !invoice) {
return (
<div className="card" style={{ fontSize: 13, color: 'var(--text-3)' }}>
برای این نوبت فاکتوری ثبت نشده است.
</div>
);
}
const rows: { label: string; value: number; muted?: boolean }[] = [
{ label: 'قیمت پایه', value: invoice.base_rials },
{ label: 'آیتم‌های اضافه', value: invoice.items_rials },
{ label: 'تخفیف', value: -invoice.discount_rials },
{ label: 'سهم بیمهٔ پایه', value: -invoice.insurance_base_rials },
{ label: 'سهم بیمهٔ تکمیلی', value: -invoice.insurance_supplementary_rials },
{ label: 'مالیات', value: invoice.tax_rials },
];
return (
<div className="card" style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap' }}>
<h3 style={{ fontSize: 15, margin: 0 }}>فاکتور</h3>
{invoice.created_at !== undefined && (
<span style={{ fontSize: 12, color: 'var(--text-3)' }}>
ثبتشده در {formatDate(invoice.created_at)}
</span>
)}
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
{rows
.filter((row) => row.value !== 0)
.map((row) => (
<div
key={row.label}
style={{ display: 'flex', justifyContent: 'space-between', fontSize: 13 }}
>
<span style={{ color: 'var(--text-2)' }}>{row.label}</span>
<span style={{ color: row.value < 0 ? 'var(--success)' : undefined }}>
{formatRial(Math.abs(row.value))}
{row.value < 0 ? ' ' : ''}
</span>
</div>
))}
{invoice.breakdown.discounts.map((line, index) => (
<div
key={index}
style={{ display: 'flex', justifyContent: 'space-between', fontSize: 12, color: 'var(--text-3)' }}
>
<span>{line.label}</span>
<span>{formatRial(Math.abs(line.rials))}</span>
</div>
))}
</div>
<div
style={{
borderTop: '1px solid var(--border)',
paddingTop: 10,
display: 'flex',
justifyContent: 'space-between',
fontSize: 15,
fontWeight: 600,
}}
>
<span>مبلغ نهایی</span>
<span>{formatRial(invoice.final_rials)}</span>
</div>
{invoice.deposit_rials > 0 && (
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 13 }}>
<span style={{ color: 'var(--text-2)' }}>بیعانه</span>
<span>{formatRial(invoice.deposit_rials)}</span>
</div>
)}
<span style={{ fontSize: 12, color: 'var(--text-3)' }}>
قیمتها بر اساس تاریخ همین نوبت محاسبه و ثبت شدهاند؛ تغییر بعدی تعرفه این فاکتور
را عوض نمیکند.
</span>
</div>
);
}
@@ -35,6 +35,7 @@ export const SETTINGS_MENU: SettingsMenuItem[] = [
{ key: 'policies', label: 'قوانین', icon: ScaleIcon, to: '/admin/policies', roles: ['doctor', 'clinic'], perm: ['appointment_settings', 'view'] },
{ key: 'packages', label: 'پکیج‌ها', icon: RectangleStackIcon, to: '/admin/packages', roles: ['doctor', 'clinic'], perm: ['appointment_settings', 'view'] },
{ key: 'course-protocols', label: 'پروتکل دوره', icon: ArrowPathRoundedSquareIcon, to: '/admin/course-protocols', roles: ['doctor', 'clinic'], perm: ['appointment_settings', 'view'] },
{ key: 'price-lists', label: 'لیست‌های قیمت', icon: BanknotesIcon, to: '/admin/price-lists', roles: ['doctor', 'clinic'], perm: ['appointment_settings', 'view'] },
{ key: 'cancellation', label: 'سیاست لغو', icon: NoSymbolIcon, to: '/admin/cancellation-policy', roles: ['doctor', 'clinic'], perm: ['appointment_settings', 'view'] },
{ key: 'waitlist', label: 'لیست انتظار', icon: QueueListIcon, to: '/admin/waitlist', roles: ['doctor', 'clinic'], perm: ['appointment_settings', 'view'] },
{ key: 'utilization', label: 'بهره‌وری منابع', icon: ChartBarIcon, to: '/admin/reports/resource-utilization', roles: ['doctor', 'clinic'], perm: ['appointment_settings', 'view'] },