refactor(pricing): make the service the only price source

Price lists, annual tariffs and per-branch price overrides each answered
"what does this service cost?" differently, so a single date could carry
several answers and nobody could say which one was right. Price now lives
only on ServiceItem.price_rials, edited from the services page.

- drop PriceList/PriceListItem, their repositories and the seven
  /api/v1/price-list(s) endpoints; PricingController keeps only quote and
  the appointment price snapshot
- drop Tariff, TariffRepository, TariffService and the two
  /service-items/{uuid}/tariffs endpoints; creating or repricing a service
  no longer upserts a current-year tariff
- drop price_rials from ServiceBranchOverride; the entity stays for its
  duration columns, which DurationCalculator and ServiceSelectionValidator
  still read
- InvoiceService reads the item price directly
- PricingEngine collapses to a single source; breakdown.sources always
  reports service_item, keeping the response contract intact
- remove the price-lists admin page, its route and settings-menu entry, the
  tariff modal and the service detail tariffs tab; useAppointmentInvoice
  moves to its own hook file

Migration drops price_lists, price_list_items, service_tariffs and the
override price column.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
hamed
2026-08-02 18:00:48 +03:30
co-authored by Claude Opus 5
parent f06efe26c0
commit 4fe0c4f9bf
41 changed files with 208 additions and 1835 deletions
-2
View File
@@ -75,7 +75,6 @@ import ClinicAppointmentSettingsPage from './pages/ClinicAppointmentSettingsPage
import PatientsListPage from './pages/PatientsListPage';
import InventoryPage from './pages/InventoryPage';
import ResourceBookingPage from './pages/ResourceBookingPage';
import PriceListsPage from './pages/PriceListsPage';
import ResourcesPage from './pages/ResourcesPage';
import ResourceTypesPage from './pages/ResourceTypesPage';
import CatalogCategoriesPage from './pages/CatalogCategoriesPage';
@@ -292,7 +291,6 @@ export default function App() {
<Route path="clinic-services/:uuid" element={<RoleRoute roles={['doctor', 'clinic', 'secretary']} blockClinicScope permission={['services', 'view']}><ServiceDetailPage /></RoleRoute>} />
<Route path="inventory" element={<RoleRoute roles={['doctor', 'clinic', 'secretary']} blockClinicScope permission={['inventory', 'view']}><InventoryPage /></RoleRoute>} />
<Route path="service-categories" element={<RoleRoute roles={['doctor', 'clinic', 'secretary']} blockClinicScope permission={['appointment_settings', 'view']}><CatalogCategoriesPage /></RoleRoute>} />
<Route path="price-lists" element={<RoleRoute roles={['doctor', 'clinic']} blockClinicScope permission={['appointment_settings', 'view']}><PriceListsPage /></RoleRoute>} />
<Route path="resource-booking" element={<RoleRoute roles={['doctor', 'clinic', 'secretary']} blockClinicScope permission={['appointments', 'create']}><ResourceBookingPage /></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>} />
@@ -1,6 +1,6 @@
import React from 'react';
import { formatDate, formatRial } from '../lib/utils';
import { useAppointmentInvoice } from '../hooks/usePriceLists';
import { useAppointmentInvoice } from '../hooks/useAppointmentInvoice';
interface Props {
appointmentUuid: string;
@@ -1,166 +0,0 @@
import { useMemo, useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { PencilIcon, CheckIcon, XMarkIcon } from '@heroicons/react/24/outline';
import { toast } from 'sonner';
import { api } from '../lib/api';
import { formatRial, formatYear, rialToToman, tomanToRial } from '../lib/utils';
import Modal from './ui/Modal';
import PriceInput from './ui/PriceInput';
import SearchableSelect from './ui/SearchableSelect';
import type { ServiceItem } from '../types';
interface TariffRow {
uuid: string;
year: number;
price_rials: number;
is_active: boolean;
}
interface TariffResponse {
current_year: number;
default_price_rials: number;
data: TariffRow[];
}
export default function ServiceTariffModal({ item, onClose }: { item: ServiceItem | null; onClose: () => void }) {
const qc = useQueryClient();
const [year, setYear] = useState<number | ''>('');
const [price, setPrice] = useState(0);
const [editYear, setEditYear] = useState<number | null>(null);
const [editPrice, setEditPrice] = useState(0);
const { data, isLoading } = useQuery<{ data: TariffResponse }>({
queryKey: ['service-tariffs', item?.uuid],
queryFn: () => api.get(`/api/v1/service-items/${item!.uuid}/tariffs`),
enabled: !!item,
});
const resp = (data as any)?.data as TariffResponse | undefined;
const tariffs = useMemo(() => [...(resp?.data ?? [])].sort((a, b) => b.year - a.year), [resp]);
const currentYear = resp?.current_year;
// سال‌های قابل انتخاب: ۵ سال گذشته تا ۲ سال آینده، منهای سال‌های ثبت‌شده.
const yearOptions = useMemo(() => {
if (!currentYear) return [];
const used = new Set(tariffs.map((t) => t.year));
const opts: { value: number; label: string }[] = [];
for (let y = currentYear + 2; y >= currentYear - 5; y--) {
if (used.has(y)) continue;
opts.push({ value: y, label: y === currentYear ? `${formatYear(y)} (سال جاری)` : formatYear(y) });
}
return opts;
}, [currentYear, tariffs]);
const addMut = useMutation({
mutationFn: () => api.put(`/api/v1/service-items/${item!.uuid}/tariffs/${Number(year)}`, { price_rials: tomanToRial(price) }),
onSuccess: () => {
toast.success('تعرفه ذخیره شد');
setYear('');
setPrice(0);
qc.invalidateQueries({ queryKey: ['service-tariffs', item?.uuid] });
qc.invalidateQueries({ queryKey: ['service-items'] });
},
onError: (e: Error) => toast.error(e.message),
});
const editMut = useMutation({
mutationFn: (vars: { year: number; price: number }) =>
api.put(`/api/v1/service-items/${item!.uuid}/tariffs/${vars.year}`, { price_rials: tomanToRial(vars.price) }),
onSuccess: () => {
toast.success('تعرفه ویرایش شد');
setEditYear(null);
qc.invalidateQueries({ queryKey: ['service-tariffs', item?.uuid] });
qc.invalidateQueries({ queryKey: ['service-items'] });
},
onError: (e: Error) => toast.error(e.message),
});
const startEdit = (t: TariffRow) => { setEditYear(t.year); setEditPrice(rialToToman(t.price_rials)); };
return (
<Modal open={!!item} onClose={onClose} title={`تعرفه‌های سالانه — ${item?.name ?? ''}`} size="md">
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
{resp && (
<div style={{
display: 'flex', gap: 8, padding: '11px 13px', borderRadius: 'var(--r-sm)',
background: 'var(--primary-subtle)', fontSize: 12, color: 'var(--text-2)', lineHeight: 1.7,
}}>
<span>قیمت پایهی سرویس همان تعرفهی سال جاری ({currentYear ? formatYear(currentYear) : '—'}) است و همهجا از همین استفاده میشود. تعرفهی سالهای دیگر فقط برای صورتحساب همان سال بهکار میرود.</span>
</div>
)}
{/* افزودن تعرفه‌ی جدید */}
<div style={{ padding: 14, borderRadius: 'var(--r)', border: '1px solid var(--border)', background: 'var(--surface-2)' }}>
<div style={{ fontSize: 12.5, fontWeight: 700, marginBottom: 10 }}>افزودن تعرفهی سال</div>
<div style={{ display: 'grid', gridTemplateColumns: '150px 1fr auto', gap: 10, alignItems: 'end' }}>
<div style={{ minWidth: 0 }}>
<label className="field-label">سال (شمسی)</label>
<SearchableSelect
options={yearOptions}
value={year === '' ? '' : year}
onChange={(v) => setYear(v != null && v !== '' ? Number(v) : '')}
placeholder="انتخاب سال"
noOptionsMessage="سالی باقی نمانده"
height={40}
/>
</div>
<div style={{ minWidth: 0 }}>
<label className="field-label">تعرفه (تومان)</label>
<PriceInput className="input" style={{ height: 40 }} value={price} onChange={setPrice} placeholder="مبلغ" min={0} />
</div>
<button className="btn primary" style={{ height: 40 }} disabled={year === '' || addMut.isPending} onClick={() => addMut.mutate()}>
{addMut.isPending ? '...' : 'افزودن'}
</button>
</div>
</div>
{/* فهرست تعرفه‌ها */}
{isLoading ? (
<div className="muted" style={{ fontSize: 13 }}>در حال بارگذاری...</div>
) : tariffs.length === 0 ? (
<div style={{ fontSize: 12.5, color: 'var(--text-3)', textAlign: 'center', padding: '12px 0' }}>هنوز تعرفهای ثبت نشده است.</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
{tariffs.map((t) => {
const isCurrent = t.year === currentYear;
const isEditing = editYear === t.year;
return (
<div key={t.uuid} style={{
display: 'flex', alignItems: 'center', gap: 10, padding: '9px 12px',
borderRadius: 9, border: '1px solid var(--border)',
background: isCurrent ? 'var(--primary-subtle)' : 'var(--bg)',
}}>
<span style={{ fontWeight: 700, fontSize: 13, minWidth: 70 }}>
سال {formatYear(t.year)}
{isCurrent && <span className="badge green" style={{ fontSize: 9.5, marginInlineStart: 6 }}><span className="bdot" />جاری</span>}
</span>
{isEditing ? (
<>
<div style={{ flex: 1 }}>
<PriceInput className="input" style={{ height: 36 }} value={editPrice} onChange={setEditPrice} min={0} />
</div>
<button className="btn primary sm" disabled={editMut.isPending} onClick={() => editMut.mutate({ year: t.year, price: editPrice })} title="ذخیره">
<CheckIcon style={{ width: 14 }} />
</button>
<button className="btn sm" onClick={() => setEditYear(null)} title="انصراف">
<XMarkIcon style={{ width: 14 }} />
</button>
</>
) : (
<>
<span style={{ flex: 1, color: 'var(--primary)', fontWeight: 600, fontSize: 13 }} dir="ltr">{formatRial(t.price_rials)}</span>
<button className="btn sm ghost" onClick={() => startEdit(t)} title="ویرایش">
<PencilIcon style={{ width: 14 }} />
</button>
</>
)}
</div>
);
})}
</div>
)}
</div>
</Modal>
);
}
@@ -37,7 +37,6 @@ export const SETTINGS_MENU: SettingsMenuItem[] = [
{ key: 'resources', label: 'منابع', icon: CubeIcon, to: '/admin/resources', roles: ['doctor', 'clinic'], perm: ['appointment_settings', 'view'] },
{ key: 'service-categories', label: 'دسته‌بندی‌ها', icon: RectangleStackIcon, to: '/admin/service-categories', roles: ['doctor', 'clinic'], perm: ['appointment_settings', 'view'] },
{ key: 'holidays', label: 'تعطیلات رسمی', icon: CalendarDaysIcon, to: '/admin/holidays', 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: 'payment', label: 'مدیریت پرداخت', icon: BanknotesIcon, to: '/admin/my-financial', perm: ['payments', 'view'] },
{ key: 'secretary', label: 'مدیریت منشی', icon: UsersIcon, to: '/admin/my-secretaries' },
{ key: 'staff', label: 'پرسنل', icon: UserPlusIcon, to: '/admin/staff', perm: ['staff', 'view'] },
@@ -0,0 +1,38 @@
import { useQuery } from '@tanstack/react-query';
import { api, type ApiResponse } from '../lib/api';
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>;
};
}
/** فاکتور یک نوبت — همان اعداد لحظهٔ ثبت، حتی اگر قیمت سرویس بعداً عوض شده باشد. */
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 };
}
-131
View File
@@ -1,131 +0,0 @@
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 };
}
@@ -59,14 +59,14 @@ describe('ClinicServicesPage (خدمات)', () => {
expect(screen.getByText('سرویس جدید')).toBeInTheDocument();
});
it('opens the ⋮ actions menu with insurance and tariff entries for a service', async () => {
it('opens the ⋮ actions menu with the insurance entry and no tariff entry', async () => {
renderWithProviders(<ClinicServicesPage />, { route: '/admin/clinic-services' });
fireEvent.click(await screen.findByText('کندلا ۲۰۲۱'));
fireEvent.click(await screen.findByTitle('عملیات'));
expect(await screen.findByText('ویرایش')).toBeInTheDocument();
expect(screen.getByText('تعرفه‌های سالانه')).toBeInTheDocument();
expect(screen.getByText('پوشش بیمه')).toBeInTheDocument();
expect(screen.queryByText('تعرفه‌های سالانه')).not.toBeInTheDocument();
});
it('فرم ویرایش سرویس دیگر سوییچ بیمه ندارد و به بخش پوشش بیمه ارجاع می‌دهد', async () => {
@@ -19,7 +19,6 @@ import { usePermissions } from '../hooks/usePermissions';
import PageHeader from '../components/ui/PageHeader';
import Modal from '../components/ui/Modal';
import ConfirmDialog from '../components/ui/ConfirmDialog';
import ServiceTariffModal from '../components/ServiceTariffModal';
import ServiceInsuranceModal from '../components/ServiceInsuranceModal';
import ServiceItemFormModal from '../components/ServiceItemFormModal';
import FeatureGate from '../components/ui/FeatureGate';
@@ -55,7 +54,6 @@ function ClinicServicesPageInner() {
const [sectionModal, setSectionModal] = useState<'create' | ServiceSection | null>(null);
const [itemModal, setItemModal] = useState<'create' | ServiceItem | null>(null);
const [toggleItem, setToggleItem] = useState<ServiceItem | null>(null);
const [tariffItem, setTariffItem] = useState<ServiceItem | null>(null);
const [insuranceItem, setInsuranceItem] = useState<ServiceItem | null>(null);
const [search, setSearch] = useState('');
const [showInactive, setShowInactive] = useState(true);
@@ -282,7 +280,6 @@ function ClinicServicesPageInner() {
<div style={{ position: 'fixed', inset: 0, zIndex: 40 }} onClick={(e) => { e.stopPropagation(); setMenuOpen(null); }} />
<div onClick={(e) => e.stopPropagation()} style={{ position: 'absolute', top: 40, insetInlineStart: 8, zIndex: 41, background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 'var(--r-sm)', boxShadow: 'var(--shadow)', padding: 6, minWidth: 176, display: 'flex', flexDirection: 'column', gap: 2 }}>
<button style={menuItemStyle} onClick={() => { setMenuOpen(null); setItemModal(item); }}><PencilIcon style={{ width: 15 }} /> ویرایش</button>
<button style={menuItemStyle} onClick={() => { setMenuOpen(null); setTariffItem(item); }}><BanknotesIcon style={{ width: 15 }} /> تعرفههای سالانه</button>
<button style={menuItemStyle} onClick={() => { setMenuOpen(null); setInsuranceItem(item); }}><ShieldCheckIcon style={{ width: 15 }} /> پوشش بیمه</button>
<button style={menuItemStyle} onClick={() => { setMenuOpen(null); setToggleItem(item); }}>{item.active ? <EyeSlashIcon style={{ width: 15 }} /> : <EyeIcon style={{ width: 15 }} />}{item.active ? ' غیرفعال‌کردن' : ' فعال‌کردن'}</button>
</div>
@@ -368,7 +365,6 @@ function ClinicServicesPageInner() {
onClose={() => setItemModal(null)}
onManageInsurance={setInsuranceItem}
/>
<ServiceTariffModal item={tariffItem} onClose={() => setTariffItem(null)} />
<ServiceInsuranceModal item={insuranceItem} onClose={() => setInsuranceItem(null)} />
-358
View File
@@ -1,358 +0,0 @@
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 { useAddresses } from '../hooks/useAddresses';
import { usePriceLists, type PriceList, type PriceListItem } from '../hooks/usePriceLists';
import { useAllServiceItems } from '../hooks/useServiceCatalog';
import SettingsLayout from '../components/layout/SettingsLayout';
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 { addresses } = useAddresses();
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 (
<SettingsLayout active="price-lists">
<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-block">
<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-block">
<label>شعبه</label>
<SearchableSelect
value={draft.address_uuid ?? ''}
onChange={(v) => setDraft({ ...draft, address_uuid: v ? String(v) : null })}
options={[
{ value: '', label: 'همهٔ شعبه‌ها' },
...addresses.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-block" 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-block" 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>
</SettingsLayout>
);
}
+6 -14
View File
@@ -45,13 +45,6 @@ const mockApi = (item: unknown = ITEM, notFound = false) => {
if (url.includes('/service-item/')) {
return notFound ? Promise.reject(new Error('یافت نشد')) : Promise.resolve({ success: true, data: item });
}
if (url.includes('/tariffs')) return Promise.resolve({ success: true, data: {
current_year: 1404, default_price_rials: 8_500_000,
data: [
{ uuid: 't1', year: 1404, price_rials: 8_500_000, is_active: true },
{ uuid: 't2', year: 1403, price_rials: 7_000_000, is_active: false },
],
} });
if (url.includes('/tenant-insurances')) return Promise.resolve({ data: { data: [
{ uuid: 'ins1', insurance_name: 'بیمه ایران', insurance_kind: 'basic', coverage_percent: 70 },
] } });
@@ -104,13 +97,12 @@ describe('ServiceDetailPage (جزئیات سرویس)', () => {
expect(screen.getByText(/۱۴۰۲/)).toBeInTheDocument();
});
it('تب تعرفه‌ها فهرست سال‌ها را با نشان «سال جاری» می‌آورد', async () => {
/** تعرفهٔ سالانه حذف شد: قیمت فقط روی خودِ سرویس تعریف می‌شود. */
it('دیگر تب تعرفه‌ها ندارد', async () => {
render();
fireEvent.click(await screen.findByText('تعرفه‌ها'));
await screen.findByRole('heading', { name: 'سرم ۵۰۰cc' });
expect(await screen.findByText('سال جاری')).toBeInTheDocument();
expect(screen.getByText('۱۴۰۴')).toBeInTheDocument();
expect(screen.getByText('۱۴۰۳')).toBeInTheDocument();
expect(screen.queryByText('تعرفه‌ها')).not.toBeInTheDocument();
});
it('تب بیمه‌ها قراردادها و درصد پوشش را می‌آورد', async () => {
@@ -225,8 +217,8 @@ describe('ServiceDetailPage (جزئیات سرویس)', () => {
/** تب در URL می‌نشیند تا «بازگشت» و رفرش همان نما را بدهند. */
it('تب انتخاب‌شده از URL خوانده می‌شود', async () => {
render('/admin/clinic-services/it1?tab=tariffs');
render('/admin/clinic-services/it1?tab=insurance');
expect(await screen.findByText('سال جاری')).toBeInTheDocument();
expect(await screen.findByText('بیمه ایران')).toBeInTheDocument();
});
});
-69
View File
@@ -16,24 +16,10 @@ import FeatureGate from '../components/ui/FeatureGate';
import ConfirmDialog from '../components/ui/ConfirmDialog';
import { usePermissions } from '../hooks/usePermissions';
import { useUrlState } from '../hooks/useUrlState';
import ServiceTariffModal from '../components/ServiceTariffModal';
import ServiceInsuranceModal from '../components/ServiceInsuranceModal';
import ServiceItemFormModal from '../components/ServiceItemFormModal';
import ServiceCategoryTab from '../components/ServiceCategoryTab';
interface Tariff {
uuid: string;
year: number;
price_rials: number;
is_active: boolean;
}
interface TariffList {
current_year: number;
default_price_rials: number;
data: Tariff[];
}
interface TenantInsurance {
uuid: string;
insurance_name: string | null;
@@ -51,7 +37,6 @@ interface CoverageRow {
const TABS = [
{ id: 'info', label: 'اطلاعات سرویس' },
{ id: 'tariffs', label: 'تعرفه‌ها' },
{ id: 'insurance', label: 'بیمه‌ها' },
{ id: 'categories',label: 'دسته‌بندی‌ها' },
{ id: 'goods', label: 'کالاهای مرتبط' },
@@ -164,57 +149,6 @@ function InfoTab({ item }: { item: ServiceItem }) {
);
}
function TariffsTab({ item, onManage, canUpdate }: { item: ServiceItem; onManage: () => void; canUpdate: boolean }) {
const { data, isLoading } = useQuery<ApiResponse<TariffList>>({
queryKey: ['service-tariffs', item.uuid],
queryFn: () => api.get(`/api/v1/service-items/${item.uuid}/tariffs`),
});
const list = data?.data;
const tariffs = list?.data ?? [];
return (
<div className="card card-pad">
<div className="card-title-row">
<div>
<b style={{ fontSize: 14 }}>تعرفههای سالانه</b>
<div className="muted" style={{ fontSize: 12 }}>
قیمت هر سال شمسی؛ قیمت سال جاری مبنای صورتحساب است.
</div>
</div>
{canUpdate && <button className="btn primary sm" onClick={onManage}>مدیریت تعرفهها</button>}
</div>
{isLoading ? (
<div className="muted" style={{ fontSize: 13, padding: '12px 0' }}>در حال بارگذاری...</div>
) : tariffs.length === 0 ? (
<div className="empty" style={{ padding: '28px 0' }}>
<BanknotesIcon style={{ width: 30, height: 30 }} />
<p className="muted">تعرفهای ثبت نشده است</p>
</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, marginTop: 12 }}>
{tariffs.map((t) => (
<div key={t.uuid} style={{
display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 10,
padding: '10px 12px', borderRadius: 'var(--r-sm)',
border: '1px solid var(--border)',
background: t.year === list?.current_year ? 'var(--primary-soft)' : 'var(--surface)',
}}>
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 8, fontSize: 13 }}>
<b>{formatYear(t.year)}</b>
{t.year === list?.current_year && <span className="badge green" style={{ fontSize: 10 }}>سال جاری</span>}
{!t.is_active && <span className="badge gray" style={{ fontSize: 10 }}>غیرفعال</span>}
</span>
<b style={{ fontSize: 13.5, color: 'var(--primary)' }}>{formatRial(t.price_rials)}</b>
</div>
))}
</div>
)}
</div>
);
}
function InsuranceTab({ item, onManage, canUpdate }: { item: ServiceItem; onManage: () => void; canUpdate: boolean }) {
const { data: contractsData, isLoading } = useQuery<{ data: { data: TenantInsurance[] } }>({
queryKey: ['tenant-insurances'],
@@ -460,7 +394,6 @@ function ServiceDetailPageInner() {
const tab = (TABS.some((t) => t.id === urlState.tab) ? urlState.tab : 'info') as TabId;
const setTab = (id: TabId) => setUrlState({ tab: id });
const [editOpen, setEditOpen] = useState(false);
const [tariffOpen, setTariffOpen] = useState(false);
const [insuranceOpen, setInsuranceOpen] = useState(false);
const [toggleOpen, setToggleOpen] = useState(false);
@@ -541,7 +474,6 @@ function ServiceDetailPageInner() {
</div>
{tab === 'info' && <InfoTab item={item} />}
{tab === 'tariffs' && <TariffsTab item={item} onManage={() => setTariffOpen(true)} canUpdate={canUpdate} />}
{tab === 'insurance' && <InsuranceTab item={item} onManage={() => setInsuranceOpen(true)} canUpdate={canUpdate} />}
{tab === 'categories' && (
<ServiceCategoryTab
@@ -559,7 +491,6 @@ function ServiceDetailPageInner() {
onClose={() => setEditOpen(false)}
onManageInsurance={() => setInsuranceOpen(true)}
/>
<ServiceTariffModal item={tariffOpen ? item : null} onClose={() => setTariffOpen(false)} />
<ServiceInsuranceModal item={insuranceOpen ? item : null} onClose={() => setInsuranceOpen(false)} />
<ConfirmDialog