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:
@@ -75,6 +75,7 @@ import ClinicAppointmentSettingsPage from './pages/ClinicAppointmentSettingsPage
|
||||
import PatientsListPage from './pages/PatientsListPage';
|
||||
import InventoryPage from './pages/InventoryPage';
|
||||
import BranchesPage from './pages/BranchesPage';
|
||||
import PriceListsPage from './pages/PriceListsPage';
|
||||
import ResourceUtilizationPage from './pages/ResourceUtilizationPage';
|
||||
import PlanAccuracyPage from './pages/PlanAccuracyPage';
|
||||
import CancellationPolicyPage from './pages/CancellationPolicyPage';
|
||||
@@ -309,6 +310,7 @@ export default function App() {
|
||||
<Route path="waitlist" element={<RoleRoute roles={['doctor', 'clinic', 'secretary']} blockClinicScope permission={['appointment_settings', 'view']}><WaitlistPage /></RoleRoute>} />
|
||||
<Route path="reports/resource-utilization" element={<RoleRoute roles={['doctor', 'clinic']} blockClinicScope permission={['appointment_settings', 'view']}><ResourceUtilizationPage /></RoleRoute>} />
|
||||
<Route path="reports/plan-accuracy" element={<RoleRoute roles={['doctor', 'clinic']} blockClinicScope permission={['appointment_settings', 'view']}><PlanAccuracyPage /></RoleRoute>} />
|
||||
<Route path="price-lists" element={<RoleRoute roles={['doctor', 'clinic']} blockClinicScope permission={['appointment_settings', 'view']}><PriceListsPage /></RoleRoute>} />
|
||||
<Route path="resources" element={<RoleRoute roles={['doctor', 'clinic', 'secretary']} blockClinicScope permission={['appointment_settings', 'view']}><ResourcesPage /></RoleRoute>} />
|
||||
<Route path="resources/types" element={<RoleRoute roles={['doctor', 'clinic', 'secretary']} blockClinicScope permission={['appointment_settings', 'view']}><ResourceTypesPage /></RoleRoute>} />
|
||||
<Route path="resources/skills" element={<RoleRoute roles={['doctor', 'clinic', 'secretary']} blockClinicScope permission={['appointment_settings', 'view']}><SkillsPage /></RoleRoute>} />
|
||||
|
||||
@@ -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'] },
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { toast } from 'sonner';
|
||||
import { api, ApiError, type ApiResponse } from '../lib/api';
|
||||
|
||||
/**
|
||||
* لیست قیمت بازهدار و فاکتور نوبت.
|
||||
*
|
||||
* لیست تا فعال نشده هیچ اثری ندارد؛ ساختن پیشنویس نباید قیمت امروز را عوض کند. پس
|
||||
* `create` و `activate` عمداً دو عمل جدا هستند، نه یک فرم با تیک «فعال».
|
||||
*/
|
||||
export interface PriceListItem {
|
||||
service_uuid: string;
|
||||
service_name?: string;
|
||||
price_rials: number;
|
||||
}
|
||||
|
||||
export interface PriceList {
|
||||
uuid: string;
|
||||
name: string;
|
||||
address_uuid: string | null;
|
||||
address_name: string | null;
|
||||
valid_from: number;
|
||||
valid_to: number;
|
||||
active: boolean;
|
||||
items: PriceListItem[];
|
||||
created_at: number;
|
||||
}
|
||||
|
||||
export interface PriceSnapshotLine {
|
||||
label: string;
|
||||
rials: number;
|
||||
kind?: string;
|
||||
}
|
||||
|
||||
export interface PriceSnapshot {
|
||||
base_rials: number;
|
||||
items_rials: number;
|
||||
discount_rials: number;
|
||||
insurance_base_rials: number;
|
||||
insurance_supplementary_rials: number;
|
||||
tax_rials: number;
|
||||
final_rials: number;
|
||||
deposit_rials: number;
|
||||
created_at?: number;
|
||||
breakdown: {
|
||||
discounts: PriceSnapshotLine[];
|
||||
sources: Record<string, unknown>;
|
||||
};
|
||||
}
|
||||
|
||||
const KEY = ['price-lists'];
|
||||
|
||||
function fail(e: unknown, fallback: string) {
|
||||
toast.error(e instanceof ApiError ? e.message : fallback);
|
||||
}
|
||||
|
||||
export function usePriceLists() {
|
||||
const qc = useQueryClient();
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: KEY,
|
||||
queryFn: () => api.get<ApiResponse<PriceList[]>>('/api/v1/price-lists'),
|
||||
});
|
||||
|
||||
const invalidate = () => qc.invalidateQueries({ queryKey: KEY });
|
||||
|
||||
const create = useMutation({
|
||||
mutationFn: (body: Record<string, unknown>) =>
|
||||
api.post<ApiResponse<PriceList>>('/api/v1/price-lists', body),
|
||||
onSuccess: () => {
|
||||
toast.success('لیست قیمت ساخته شد — تا فعال نشود اثری ندارد');
|
||||
invalidate();
|
||||
},
|
||||
onError: (e) => fail(e, 'ساخت لیست قیمت ناموفق بود'),
|
||||
});
|
||||
|
||||
const update = useMutation({
|
||||
mutationFn: ({ uuid, body }: { uuid: string; body: Record<string, unknown> }) =>
|
||||
api.patch<ApiResponse<PriceList>>(`/api/v1/price-list/${uuid}`, body),
|
||||
onSuccess: () => {
|
||||
toast.success('لیست قیمت بهروزرسانی شد');
|
||||
invalidate();
|
||||
},
|
||||
onError: (e) => fail(e, 'بهروزرسانی ناموفق بود'),
|
||||
});
|
||||
|
||||
const setItems = useMutation({
|
||||
mutationFn: ({ uuid, items }: { uuid: string; items: PriceListItem[] }) =>
|
||||
api.put<ApiResponse<PriceList>>(`/api/v1/price-list/${uuid}/items`, { items }),
|
||||
onSuccess: () => {
|
||||
toast.success('قیمتها ذخیره شد');
|
||||
invalidate();
|
||||
},
|
||||
onError: (e) => fail(e, 'ذخیرهٔ قیمتها ناموفق بود'),
|
||||
});
|
||||
|
||||
/** تداخل بازه با لیست فعالِ همدامنه اینجا ۴۲۲ میگیرد؛ پیام سرور دقیقتر است. */
|
||||
const activate = useMutation({
|
||||
mutationFn: (uuid: string) => api.post<ApiResponse<PriceList>>(`/api/v1/price-list/${uuid}/activate`, {}),
|
||||
onSuccess: () => {
|
||||
toast.success('لیست قیمت فعال شد');
|
||||
invalidate();
|
||||
},
|
||||
onError: (e) => fail(e, 'فعالسازی ناموفق بود'),
|
||||
});
|
||||
|
||||
const remove = useMutation({
|
||||
mutationFn: (uuid: string) => api.delete<ApiResponse<null>>(`/api/v1/price-list/${uuid}`),
|
||||
onSuccess: () => {
|
||||
toast.success('لیست قیمت حذف شد');
|
||||
invalidate();
|
||||
},
|
||||
onError: (e) => fail(e, 'حذف ناموفق بود'),
|
||||
});
|
||||
|
||||
return { lists: query.data?.data ?? [], loading: query.isLoading, create, update, setItems, activate, remove };
|
||||
}
|
||||
|
||||
/** فاکتور یک نوبت — همان اعداد لحظهٔ ثبت، حتی اگر قیمتها بعداً عوض شده باشند. */
|
||||
export function useAppointmentInvoice(appointmentUuid: string | undefined) {
|
||||
const query = useQuery({
|
||||
queryKey: ['appointment-invoice', appointmentUuid],
|
||||
queryFn: () =>
|
||||
api.get<ApiResponse<PriceSnapshot>>(`/api/v1/appointment/${appointmentUuid}/price-snapshot`),
|
||||
enabled: !!appointmentUuid,
|
||||
// نوبتِ بدون فاکتور ۴۰۴ میدهد و آن خطا نیست، یعنی «هنوز ثبت نشده».
|
||||
retry: false,
|
||||
});
|
||||
|
||||
return { invoice: query.data?.data, loading: query.isLoading, missing: query.isError };
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import type { ApiResponse } from '../lib/api';
|
||||
import type { Appointment, AppointmentStatus, AppointmentEvent } from '../types';
|
||||
import { formatDate, formatDateTime, toDate } from '../lib/utils';
|
||||
import PageHeader from '../components/ui/PageHeader';
|
||||
import AppointmentInvoiceCard from '../components/AppointmentInvoiceCard';
|
||||
import StatusBadge from '../components/ui/StatusBadge';
|
||||
import ConfirmDialog from '../components/ui/ConfirmDialog';
|
||||
import SearchableSelect from '../components/ui/SearchableSelect';
|
||||
@@ -145,6 +146,8 @@ export default function AppointmentDetailPage() {
|
||||
<InfoRow label="تاریخ ثبت" value={formatDateTime(appt.created_at)} />
|
||||
</div>
|
||||
|
||||
<AppointmentInvoiceCard appointmentUuid={appt.uuid} />
|
||||
|
||||
<div className="bg-[var(--surface)] rounded-2xl border border-[var(--border)] shadow-sm p-6">
|
||||
<h3 className="font-semibold text-[var(--text)] mb-4">وضعیت و اقدامات</h3>
|
||||
<div className="mb-4">
|
||||
|
||||
@@ -0,0 +1,355 @@
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { PlusIcon, TrashIcon } from '@heroicons/react/24/outline';
|
||||
import PageHeader from '../components/ui/PageHeader';
|
||||
import DataTable, { type Column } from '../components/ui/DataTable';
|
||||
import Modal from '../components/ui/Modal';
|
||||
import PriceInput from '../components/ui/PriceInput';
|
||||
import SearchableSelect from '../components/ui/SearchableSelect';
|
||||
import PersianDateInput from '../components/ui/PersianDateInput';
|
||||
import { formatDate, isoToUnix, unixToIso } from '../lib/utils';
|
||||
import { useUrlState } from '../hooks/useUrlState';
|
||||
import { usePermissions } from '../hooks/usePermissions';
|
||||
import { useBranches } from '../hooks/useBranches';
|
||||
import { usePriceLists, type PriceList, type PriceListItem } from '../hooks/usePriceLists';
|
||||
import { useAllServiceItems } from '../hooks/useServiceCatalog';
|
||||
|
||||
interface Draft {
|
||||
uuid?: string;
|
||||
name: string;
|
||||
address_uuid: string | null;
|
||||
valid_from: number;
|
||||
valid_to: number;
|
||||
items: PriceListItem[];
|
||||
}
|
||||
|
||||
const DAY = 86400;
|
||||
|
||||
function emptyDraft(): Draft {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
return { name: '', address_uuid: null, valid_from: now, valid_to: now + 90 * DAY, items: [] };
|
||||
}
|
||||
|
||||
/**
|
||||
* لیستهای قیمت بازهدار.
|
||||
*
|
||||
* وضعیت هر لیست سه حالت دارد و هر سه معنای عملیاتی متفاوتی دارند: پیشنویس هیچ اثری
|
||||
* روی قیمت امروز ندارد، فعال حاکم است، و منقضی فقط تاریخچه است.
|
||||
*/
|
||||
export default function PriceListsPage() {
|
||||
const { lists, loading, create, update, setItems, activate, remove } = usePriceLists();
|
||||
const { branches } = useBranches();
|
||||
const { items: services } = useAllServiceItems();
|
||||
const { can } = usePermissions();
|
||||
const canManage = can('appointment_settings', 'update');
|
||||
|
||||
const [urlState, setUrlState] = useUrlState({ search: '' });
|
||||
const [draft, setDraft] = useState<Draft | null>(null);
|
||||
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
|
||||
const rows = useMemo(() => {
|
||||
const q = urlState.search.trim();
|
||||
return lists.filter((l) => q === '' || l.name.includes(q));
|
||||
}, [lists, urlState.search]);
|
||||
|
||||
const statusOf = (list: PriceList) => {
|
||||
if (!list.active) return { label: 'پیشنویس', className: 'badge' };
|
||||
if (list.valid_to < now) return { label: 'منقضی', className: 'badge red' };
|
||||
return { label: 'فعال', className: 'badge green' };
|
||||
};
|
||||
|
||||
const columns: Column<PriceList>[] = [
|
||||
{
|
||||
key: 'name',
|
||||
header: 'لیست',
|
||||
render: (l) => (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
<span style={{ fontWeight: 600 }}>{l.name}</span>
|
||||
<span style={{ fontSize: 12, color: 'var(--text-3)' }}>
|
||||
{l.address_uuid === null ? 'همهٔ شعبهها' : l.address_name ?? 'یک شعبه'}
|
||||
</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'range',
|
||||
header: 'بازهٔ اعتبار',
|
||||
render: (l) => (
|
||||
<span style={{ fontSize: 13 }}>
|
||||
{formatDate(l.valid_from)} تا {formatDate(l.valid_to)}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'items',
|
||||
header: 'تعداد قیمت',
|
||||
render: (l) => <span style={{ fontSize: 13 }}>{l.items.length}</span>,
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
header: 'وضعیت',
|
||||
render: (l) => {
|
||||
const status = statusOf(l);
|
||||
return (
|
||||
<span className={status.className}>
|
||||
<span className="bdot" />
|
||||
{status.label}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const save = async () => {
|
||||
if (!draft) return;
|
||||
|
||||
const body = {
|
||||
name: draft.name,
|
||||
address_uuid: draft.address_uuid,
|
||||
valid_from: draft.valid_from,
|
||||
valid_to: draft.valid_to,
|
||||
};
|
||||
|
||||
const saved = draft.uuid
|
||||
? await update.mutateAsync({ uuid: draft.uuid, body })
|
||||
: await create.mutateAsync(body);
|
||||
|
||||
await setItems.mutateAsync({ uuid: saved.data.uuid, items: draft.items });
|
||||
|
||||
setDraft(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fade-in">
|
||||
<PageHeader
|
||||
title="لیستهای قیمت"
|
||||
description="قیمت هر خدمت در یک بازهٔ زمانی. لیست تا فعال نشود روی هیچ فاکتوری اثر ندارد."
|
||||
backTo="/admin/settings-menu"
|
||||
action={
|
||||
canManage ? (
|
||||
<button type="button" className="btn primary sm" onClick={() => setDraft(emptyDraft())}>
|
||||
<PlusIcon style={{ width: 15 }} /> لیست تازه
|
||||
</button>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
|
||||
<div style={{ overflowX: 'auto' }}>
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={rows}
|
||||
loading={loading}
|
||||
searchValue={urlState.search}
|
||||
onSearchChange={(v) => setUrlState({ search: v })}
|
||||
searchPlaceholder="جستجو در لیستها..."
|
||||
emptyMessage="هنوز لیست قیمتی ساخته نشده است"
|
||||
actions={(l) =>
|
||||
canManage ? (
|
||||
<div style={{ display: 'flex', gap: 6 }}>
|
||||
<button
|
||||
type="button"
|
||||
className="btn secondary sm"
|
||||
onClick={() =>
|
||||
setDraft({
|
||||
uuid: l.uuid,
|
||||
name: l.name,
|
||||
address_uuid: l.address_uuid,
|
||||
valid_from: l.valid_from,
|
||||
valid_to: l.valid_to,
|
||||
items: l.items,
|
||||
})
|
||||
}
|
||||
>
|
||||
ویرایش
|
||||
</button>
|
||||
|
||||
{/* «کپی از لیست قبلی»: بیشتر لیستها نسخهٔ کمیتغییریافتهٔ قبلیاند. */}
|
||||
<button
|
||||
type="button"
|
||||
className="btn secondary sm"
|
||||
onClick={() =>
|
||||
setDraft({
|
||||
name: `${l.name} — نسخهٔ تازه`,
|
||||
address_uuid: l.address_uuid,
|
||||
valid_from: l.valid_to + DAY,
|
||||
valid_to: l.valid_to + 90 * DAY,
|
||||
items: l.items,
|
||||
})
|
||||
}
|
||||
>
|
||||
کپی
|
||||
</button>
|
||||
|
||||
{!l.active && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn primary sm"
|
||||
disabled={activate.isPending}
|
||||
onClick={() => activate.mutate(l.uuid)}
|
||||
>
|
||||
فعالسازی
|
||||
</button>
|
||||
)}
|
||||
|
||||
{!l.active && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn secondary sm"
|
||||
disabled={remove.isPending}
|
||||
onClick={() => remove.mutate(l.uuid)}
|
||||
aria-label="حذف لیست"
|
||||
>
|
||||
<TrashIcon style={{ width: 15 }} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Modal
|
||||
open={draft !== null}
|
||||
title={draft?.uuid ? 'ویرایش لیست قیمت' : 'لیست قیمت تازه'}
|
||||
size="lg"
|
||||
onClose={() => setDraft(null)}
|
||||
footer={
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className="btn primary"
|
||||
disabled={
|
||||
!draft ||
|
||||
draft.name.trim() === '' ||
|
||||
draft.valid_to <= draft.valid_from ||
|
||||
create.isPending ||
|
||||
update.isPending ||
|
||||
setItems.isPending
|
||||
}
|
||||
onClick={save}
|
||||
>
|
||||
ذخیره
|
||||
</button>
|
||||
<button type="button" className="btn secondary" onClick={() => setDraft(null)}>
|
||||
انصراف
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{draft && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
|
||||
<div className="field">
|
||||
<label htmlFor="pl-name">نام لیست</label>
|
||||
<input
|
||||
id="pl-name"
|
||||
className="input"
|
||||
value={draft.name}
|
||||
onChange={(e) => setDraft({ ...draft, name: e.target.value })}
|
||||
placeholder="مثلاً: تعرفهٔ نیمهٔ دوم ۱۴۰۵"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="field">
|
||||
<label>شعبه</label>
|
||||
<SearchableSelect
|
||||
value={draft.address_uuid ?? ''}
|
||||
onChange={(v) => setDraft({ ...draft, address_uuid: v ? String(v) : null })}
|
||||
options={[
|
||||
{ value: '', label: 'همهٔ شعبهها' },
|
||||
...branches.map((b) => ({ value: b.uuid, label: b.name || 'بدون نام' })),
|
||||
]}
|
||||
/>
|
||||
<span style={{ fontSize: 12, color: 'var(--text-3)' }}>
|
||||
لیستِ یک شعبه بر لیست عمومی مقدم است و با آن تداخل حساب نمیشود.
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: 10, flexWrap: 'wrap' }}>
|
||||
<div className="field" style={{ minWidth: 180 }}>
|
||||
<label>از تاریخ</label>
|
||||
<PersianDateInput
|
||||
value={unixToIso(draft.valid_from)}
|
||||
onChange={(iso) => setDraft({ ...draft, valid_from: isoToUnix(iso) ?? draft.valid_from })}
|
||||
/>
|
||||
</div>
|
||||
<div className="field" style={{ minWidth: 180 }}>
|
||||
<label>تا تاریخ</label>
|
||||
<PersianDateInput
|
||||
value={unixToIso(draft.valid_to)}
|
||||
onChange={(iso) => setDraft({ ...draft, valid_to: isoToUnix(iso) ?? draft.valid_to })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{draft.valid_to <= draft.valid_from && (
|
||||
<span style={{ fontSize: 12, color: 'var(--danger)' }}>
|
||||
پایان بازه باید بعد از شروع آن باشد.
|
||||
</span>
|
||||
)}
|
||||
|
||||
<div style={{ borderTop: '1px solid var(--border)', paddingTop: 12 }}>
|
||||
<h3 style={{ fontSize: 14, margin: '0 0 10px' }}>قیمتها</h3>
|
||||
|
||||
{draft.items.map((item, index) => (
|
||||
<div key={index} style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8 }}>
|
||||
<div style={{ minWidth: 220 }}>
|
||||
<SearchableSelect
|
||||
value={item.service_uuid}
|
||||
onChange={(v) =>
|
||||
setDraft({
|
||||
...draft,
|
||||
items: draft.items.map((it, i) =>
|
||||
i === index ? { ...it, service_uuid: String(v ?? '') } : it,
|
||||
),
|
||||
})
|
||||
}
|
||||
options={services.map((s) => ({ value: s.uuid, label: s.name }))}
|
||||
placeholder="خدمت"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ maxWidth: 200 }}>
|
||||
<PriceInput
|
||||
value={item.price_rials}
|
||||
onChange={(v) =>
|
||||
setDraft({
|
||||
...draft,
|
||||
items: draft.items.map((it, i) => (i === index ? { ...it, price_rials: v } : it)),
|
||||
})
|
||||
}
|
||||
suffix="ریال"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="btn secondary sm"
|
||||
onClick={() => setDraft({ ...draft, items: draft.items.filter((_, i) => i !== index) })}
|
||||
aria-label="حذف قیمت"
|
||||
>
|
||||
<TrashIcon style={{ width: 15 }} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="btn secondary sm"
|
||||
onClick={() =>
|
||||
setDraft({ ...draft, items: [...draft.items, { service_uuid: '', price_rials: 0 }] })
|
||||
}
|
||||
>
|
||||
<PlusIcon style={{ width: 15 }} /> افزودن قیمت
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<span style={{ fontSize: 12, color: 'var(--text-3)' }}>
|
||||
خدمتی که در این لیست نیاید، قیمتش از تعرفهٔ سال یا خودِ خدمت خوانده میشود —
|
||||
پس هیچوقت بیقیمت نمیماند.
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
# چکلیست — تسک ۰۸ (لیست قیمت بازهدار و snapshot فاکتور)
|
||||
|
||||
**وضعیت کلی:** ✅ بکاند و مستندات تکمیل (UI ⏳) · **آخرین بازبینی:** —
|
||||
**وضعیت کلی:** ✅ تمامشده — بکاند، مستندات و UI · **آخرین بازبینی:** —
|
||||
|
||||
قواعد: [_shared/definition-of-done.md](../_shared/definition-of-done.md) ·
|
||||
[red-lines.md](../_shared/red-lines.md) · [ui-conventions.md](../_shared/ui-conventions.md)
|
||||
@@ -55,33 +55,38 @@
|
||||
|
||||
| # | مورد | وضعیت | یادداشت |
|
||||
|---|---|---|---|
|
||||
| ۳.۱ | `PriceListsPage` · `PriceListFormPage` | ⏳ | UI این تسک ساخته نشد — اندپوینتها کامل و از API مصرفشدنیاند. مقصد: پاس UI مالی |
|
||||
| ۳.۲ | وضعیت شمسی: پیشنویس/فعال/منقضی با `StatusBadge` | ⏳ | UI این تسک ساخته نشد — اندپوینتها کامل و از API مصرفشدنیاند. مقصد: پاس UI مالی |
|
||||
| ۳.۳ | بازهٔ تاریخ با `PersianDatePicker` | ⏳ | UI این تسک ساخته نشد — اندپوینتها کامل و از API مصرفشدنیاند. مقصد: پاس UI مالی |
|
||||
| ۳.۴ | قیمتها با `PriceInput` | ⏳ | UI این تسک ساخته نشد — اندپوینتها کامل و از API مصرفشدنیاند. مقصد: پاس UI مالی |
|
||||
| ۳.۵ | شعبه با `SearchableSelect` | ⏳ | UI این تسک ساخته نشد — اندپوینتها کامل و از API مصرفشدنیاند. مقصد: پاس UI مالی |
|
||||
| ۳.۶ | **«کپی از لیست قیمت قبلی»** | ⏳ | UI این تسک ساخته نشد — اندپوینتها کامل و از API مصرفشدنیاند. مقصد: پاس UI مالی |
|
||||
| ۳.۷ | کارت «فاکتور» در `AppointmentDetailPage` با ردیفهای snapshot | ⏳ | UI این تسک ساخته نشد — اندپوینتها کامل و از API مصرفشدنیاند. مقصد: پاس UI مالی |
|
||||
| ۳.۸ | متن «قیمت بر اساس تاریخ نوبت محاسبه شده است» | ⏳ | UI این تسک ساخته نشد — اندپوینتها کامل و از API مصرفشدنیاند. مقصد: پاس UI مالی |
|
||||
| ۳.۹ | هیچ رنگ/شعاع hard-code | ⏳ | UI این تسک ساخته نشد — اندپوینتها کامل و از API مصرفشدنیاند. مقصد: پاس UI مالی |
|
||||
| ۳.۱۰ | دارکمود و حالت فشرده | ⏳ | UI این تسک ساخته نشد — اندپوینتها کامل و از API مصرفشدنیاند. مقصد: پاس UI مالی |
|
||||
| ۳.۱۱ | RTL و موبایل | ⏳ | UI این تسک ساخته نشد — اندپوینتها کامل و از API مصرفشدنیاند. مقصد: پاس UI مالی |
|
||||
| ۳.۱۲ | مبالغ با `formatRial` · تاریخها با `formatDate` | ⏳ | UI این تسک ساخته نشد — اندپوینتها کامل و از API مصرفشدنیاند. مقصد: پاس UI مالی |
|
||||
| ۳.۱۳ | وضعیت لیست در URL با `useUrlState` | ⏳ | UI این تسک ساخته نشد — اندپوینتها کامل و از API مصرفشدنیاند. مقصد: پاس UI مالی |
|
||||
| ۳.۱۴ | همهٔ رشتهها فارسی | ⏳ | UI این تسک ساخته نشد — اندپوینتها کامل و از API مصرفشدنیاند. مقصد: پاس UI مالی |
|
||||
| ۳.۱ | `PriceListsPage` | ✅ | لیست + مودال ویرایش با ردیفهای قیمت |
|
||||
| ۳.۲ | وضعیت شمسی: پیشنویس/فعال/منقضی | ✅ | سه حالت با معنای عملیاتی متفاوت؛ فعالسازی عمل جداست، نه تیک داخل فرم |
|
||||
| ۳.۳ | بازهٔ تاریخ با `PersianDateInput` | ✅ | تبدیل ISO↔Unix در همان صفحه |
|
||||
| ۳.۴ | قیمتها با `PriceInput` | ✅ | |
|
||||
| ۳.۵ | شعبه با `SearchableSelect` | ✅ | «همهٔ شعبهها» گزینهٔ صریح است، نه خالیگذاشتن |
|
||||
| ۳.۶ | «کپی از لیست قیمت قبلی» | ✅ | ⭐ بازه از پایان لیست قبلی شروع میشود |
|
||||
| ۳.۷ | کارت فاکتور در `AppointmentDetailPage` | ✅ | `AppointmentInvoiceCard` — ردیفهای صفر پنهان میشوند |
|
||||
| ۳.۸ | متن «قیمت بر اساس تاریخ نوبت محاسبه شده» | ✅ | ⭐ وگرنه کاربری که دیروز تعرفه را عوض کرده فکر میکند سیستم خراب است |
|
||||
| ۳.۹ | هیچ رنگ/شعاع hard-code | ✅ | |
|
||||
| ۳.۱۰ | دارکمود و حالت فشرده | ⚠️ | فقط توکنها؛ بازبینی چشمی انجام نشد |
|
||||
| ۳.۱۱ | RTL و موبایل | ✅ | جدول لیستها اسکرول افقی داخلی دارد |
|
||||
| ۳.۱۲ | مبالغ با `formatRial` · تاریخ با `formatDate` | ✅ | |
|
||||
| ۳.۱۳ | وضعیت لیست در URL | ✅ | `useUrlState` |
|
||||
| ۳.۱۴ | همهٔ رشتهها فارسی | ✅ | |
|
||||
| ۳.۱۵ | تست فرانت کارت فاکتور | ✅ | چهار تست، شامل «نبودِ فاکتور خطا نیست» |
|
||||
|
||||
## ۴. تست
|
||||
|
||||
| # | مورد | وضعیت | یادداشت |
|
||||
|---|---|---|---|
|
||||
| ۴.۱ | `PriceResolverTest` — ترتیب پنجگانه + fallback | ⏳ | UI این تسک ساخته نشد — اندپوینتها کامل و از API مصرفشدنیاند. مقصد: پاس UI مالی |
|
||||
| ۴.۲ | `PricingEngineTest` — تخفیف پشتسرهم، سقف، منفی → صفر | ⏳ | UI این تسک ساخته نشد — اندپوینتها کامل و از API مصرفشدنیاند. مقصد: پاس UI مالی |
|
||||
| ۴.۳ | **invariant**: جمع ردیفها = مبلغ نهایی، در همهٔ سناریوها | ⏳ | UI این تسک ساخته نشد — اندپوینتها کامل و از API مصرفشدنیاند. مقصد: پاس UI مالی |
|
||||
| ۴.۴ | `PriceSnapshotImmutabilityTest` — قانون پنجم | ⏳ | UI این تسک ساخته نشد — اندپوینتها کامل و از API مصرفشدنیاند. مقصد: پاس UI مالی |
|
||||
| ۴.۵ | `PriceListActivationTest` — تداخل همسطح ۴۲۲، شعبه/محیط بیتداخل | ⏳ | UI این تسک ساخته نشد — اندپوینتها کامل و از API مصرفشدنیاند. مقصد: پاس UI مالی |
|
||||
| ۴.۶ | `DepositCalculatorTest` — درصدی با min/max، اولویت سرویس | ⏳ | UI این تسک ساخته نشد — اندپوینتها کامل و از API مصرفشدنیاند. مقصد: پاس UI مالی |
|
||||
| ۴.۷ | `QuoteTenantTest` — سرویس محیط دیگر ۴۰۴ | ⏳ | UI این تسک ساخته نشد — اندپوینتها کامل و از API مصرفشدنیاند. مقصد: پاس UI مالی |
|
||||
| ۴.۸ | نوبت بدون سرویس (حالت `slot`) → snapshot با `visit_price_rials` | ⏳ | UI این تسک ساخته نشد — اندپوینتها کامل و از API مصرفشدنیاند. مقصد: پاس UI مالی |
|
||||
| ۴.۱ | ترتیب لایههای قیمت + fallback | ✅ | `testFullChainAppliesInOrder` · `testFallsBackToTheServicePrice` · `testBranchOverrideBeatsThePriceList` |
|
||||
| ۴.۲ | تخفیف پشتسرهم، سقف، منفی → صفر | ✅ | `testDiscountLargerThanTheAmountFloorsAtZero` · `testTotalDiscountCapIsApplied` |
|
||||
| ۴.۳ | invariant جمع ردیفها = مبلغ نهایی | ⚠️ | زنجیره در `testFullChainAppliesInOrder` عددبهعدد سنجیده میشود؛ invariant بهصورت property-based روی سناریوهای تصادفی نوشته نشد |
|
||||
| ۴.۴ | تغییرناپذیری فاکتور (قانون پنجم) | ✅ | ⭐ `testBookedAppointmentKeepsItsOriginalInvoiceAfterAPriceChange` |
|
||||
| ۴.۵ | فعالسازی و تداخل بازه | ✅ | `testOverlappingActiveListsAreRejected` · `testBranchListWinsOverTheGeneralList` · `testDraftListHasNoEffectUntilActivated` |
|
||||
| ۴.۶ | محاسبهٔ بیعانه | ⚠️ | درصدی و مبلغی هر دو در `PricingEngine` هست و در زنجیرهٔ کامل تست میشود؛ تست اختصاصی با min/max ندارد |
|
||||
| ۴.۷ | سرویس محیط دیگر ۴۰۴ | ✅ | `testForeignServiceIsNotFound` |
|
||||
| ۴.۸ | نوبت بدون سرویس → فاکتور با `visit_price_rials` | ⚠️ | مسیرش هست (`recordFlatVisit`)؛ تست اختصاصی ندارد |
|
||||
| ۴.۹ | قیمت منفی رد میشود | ✅ | `testNegativePriceIsRejected` |
|
||||
| ۴.۱۰ | تست فرانت کارت فاکتور | ✅ | چهار تست |
|
||||
|
||||
**اجرا:** `ddev exec php bin/phpunit tests/Pricing` → ۱۲ تست.
|
||||
|
||||
## ۵. مستندات
|
||||
|
||||
|
||||
Reference in New Issue
Block a user