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:
@@ -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 };
|
||||
}
|
||||
@@ -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)} />
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user