- Added BackButton component to standardize back navigation across pages. - Integrated BackButton into various pages, replacing custom back buttons for consistency. - Updated PageHeader to accept backTo prop for displaying BackButton when navigating from subpages. - Created useGoBack hook to handle navigation logic, determining whether to go back in history or redirect to a fallback page. - Added tests for BackButton and its integration with PageHeader to ensure expected behavior.
576 lines
25 KiB
TypeScript
576 lines
25 KiB
TypeScript
import { useState } from 'react';
|
|
import { useParams, useNavigate, Link } from 'react-router-dom';
|
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
|
import {
|
|
BanknotesIcon, ShieldCheckIcon, ClockIcon, UsersIcon, PencilIcon,
|
|
CalendarDaysIcon, WrenchScrewdriverIcon, CubeIcon,
|
|
} from '@heroicons/react/24/outline';
|
|
import { toast } from 'sonner';
|
|
import { api } from '../lib/api';
|
|
import type { ApiResponse } from '../lib/api';
|
|
import type { ServiceItem } from '../types';
|
|
import type { InventoryPackage } from '../hooks/useInventory';
|
|
import { formatRial, formatNumber, formatYear, formatDateTime } from '../lib/utils';
|
|
import PageHeader from '../components/ui/PageHeader';
|
|
import FeatureGate from '../components/ui/FeatureGate';
|
|
import ConfirmDialog from '../components/ui/ConfirmDialog';
|
|
import { usePermissions } from '../hooks/usePermissions';
|
|
import ServiceTariffModal from '../components/ServiceTariffModal';
|
|
import ServiceInsuranceModal from '../components/ServiceInsuranceModal';
|
|
import ServiceItemFormModal from '../components/ServiceItemFormModal';
|
|
|
|
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;
|
|
insurance_kind: 'basic' | 'supplementary' | null;
|
|
coverage_percent: number;
|
|
}
|
|
|
|
interface CoverageRow {
|
|
service_item_uuid: string | null;
|
|
covered: boolean;
|
|
coverage_percent: number | null;
|
|
franchise_percent: number | null;
|
|
ceiling_rials: number | null;
|
|
}
|
|
|
|
const TABS = [
|
|
{ id: 'info', label: 'اطلاعات سرویس' },
|
|
{ id: 'tariffs', label: 'تعرفهها' },
|
|
{ id: 'insurance', label: 'بیمهها' },
|
|
{ id: 'goods', label: 'کالاهای مرتبط' },
|
|
{ id: 'history', label: 'لاگ تغییرات' },
|
|
] as const;
|
|
type TabId = typeof TABS[number]['id'];
|
|
|
|
interface AuditLog {
|
|
uuid: string;
|
|
field: string;
|
|
operation: 'create' | 'update';
|
|
old_value: string | null;
|
|
new_value: string | null;
|
|
actor_name: string | null;
|
|
created_at: number;
|
|
}
|
|
|
|
/** برچسب فارسی فیلدهای لاگ — باید با ServiceItemAuditService::TRACKED همتراز بماند. */
|
|
const FIELD_LABELS: Record<string, string> = {
|
|
name: 'نام سرویس',
|
|
price_rials: 'قیمت پایه',
|
|
active: 'وضعیت',
|
|
duration_minutes: 'زمان متوسط',
|
|
bookable: 'نمایش در نوبتدهی',
|
|
insurance_covered: 'پوشش بیمه',
|
|
inventory_package: 'پکیج کالا',
|
|
};
|
|
|
|
/** مقدار خام لاگ را برای نمایش قابلفهم میکند (بولین، مبلغ، خالی). */
|
|
function auditValue(field: string, raw: string | null): string {
|
|
if (raw === null || raw === '') return '—';
|
|
if (['active', 'bookable', 'insurance_covered'].includes(field)) return raw === '1' ? 'بله' : 'خیر';
|
|
if (field === 'price_rials') return formatRial(Number(raw));
|
|
if (field === 'duration_minutes') return `${formatNumber(Number(raw))} دقیقه`;
|
|
if (field === 'inventory_package') return 'پکیج متصل';
|
|
return raw;
|
|
}
|
|
|
|
const KIND = {
|
|
basic: { label: 'پایه', cls: 'blue' },
|
|
supplementary: { label: 'تکمیلی', cls: 'violet' },
|
|
} as const;
|
|
|
|
function Row({ icon, label, children }: { icon: React.ReactNode; label: string; children: React.ReactNode }) {
|
|
return (
|
|
<div style={{
|
|
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
|
gap: 12, padding: '11px 0', borderBottom: '1px solid var(--border)',
|
|
}}>
|
|
<span style={{ fontSize: 12.5, color: 'var(--text-3)', display: 'inline-flex', alignItems: 'center', gap: 6 }}>
|
|
{icon} {label}
|
|
</span>
|
|
<span style={{ fontSize: 13.5, color: 'var(--text-2)', textAlign: 'left', minWidth: 0 }}>{children}</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function InfoTab({ item }: { item: ServiceItem }) {
|
|
const members = item.staff_members ?? (item.staff ? [item.staff] : []);
|
|
|
|
return (
|
|
<div className="card card-pad">
|
|
<Row icon={<BanknotesIcon style={{ width: 15 }} />} label="قیمت پایه">
|
|
<b style={{ color: 'var(--primary)', fontSize: 14 }}>{formatRial(item.price_rials)}</b>
|
|
</Row>
|
|
<Row icon={<WrenchScrewdriverIcon style={{ width: 15 }} />} label="بخش">
|
|
{item.section_name ?? '—'}
|
|
</Row>
|
|
<Row icon={<ClockIcon style={{ width: 15 }} />} label="زمان متوسط">
|
|
{item.duration_minutes
|
|
? <span className="badge blue" style={{ fontSize: 11 }}>{formatNumber(Number(item.duration_minutes))} دقیقه</span>
|
|
: '—'}
|
|
</Row>
|
|
<Row icon={<CalendarDaysIcon style={{ width: 15 }} />} label="نمایش در نوبتدهی">
|
|
{item.bookable
|
|
? <span className="badge green" style={{ fontSize: 11 }}>فعال</span>
|
|
: <span className="badge gray" style={{ fontSize: 11 }}>غیرفعال</span>}
|
|
</Row>
|
|
<Row icon={<ShieldCheckIcon style={{ width: 15 }} />} label="پوشش بیمه">
|
|
{item.insurance_covered
|
|
? <span className="badge green" style={{ fontSize: 11 }}>تحت پوشش</span>
|
|
: '—'}
|
|
</Row>
|
|
<Row icon={<UsersIcon style={{ width: 15 }} />} label="پرسنل مسئول">
|
|
{members.length > 0 ? (
|
|
<span style={{ display: 'flex', flexWrap: 'wrap', gap: 4, justifyContent: 'flex-end' }}>
|
|
{members.map((m) => (
|
|
<span key={m.uuid} style={{
|
|
fontSize: 12, color: 'var(--text-2)', background: 'var(--surface-2)',
|
|
border: '1px solid var(--border)', borderRadius: 999, padding: '3px 10px', whiteSpace: 'nowrap',
|
|
}}>{m.full_name}</span>
|
|
))}
|
|
</span>
|
|
) : '—'}
|
|
</Row>
|
|
<Row icon={<CalendarDaysIcon style={{ width: 15 }} />} label="تاریخ ایجاد">
|
|
{item.created_at ? formatDateTime(item.created_at) : '—'}
|
|
</Row>
|
|
<div style={{
|
|
display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, padding: '11px 0',
|
|
}}>
|
|
<span style={{ fontSize: 12.5, color: 'var(--text-3)', display: 'inline-flex', alignItems: 'center', gap: 6 }}>
|
|
<CalendarDaysIcon style={{ width: 15 }} /> آخرین ویرایش
|
|
</span>
|
|
<span style={{ fontSize: 13.5, color: 'var(--text-2)' }}>
|
|
{item.updated_at ? formatDateTime(item.updated_at) : '—'}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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'],
|
|
queryFn: () => api.get('/api/v1/billing/tenant-insurances'),
|
|
});
|
|
|
|
const contracts = (contractsData as any)?.data?.data as TenantInsurance[] | undefined ?? [];
|
|
|
|
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>
|
|
) : contracts.length === 0 ? (
|
|
<div className="empty" style={{ padding: '28px 0' }}>
|
|
<ShieldCheckIcon style={{ width: 30, height: 30 }} />
|
|
<p className="muted">قرارداد بیمهی فعالی ندارید</p>
|
|
</div>
|
|
) : (
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, marginTop: 12 }}>
|
|
{contracts.map((c) => (
|
|
<ContractCoverageRow key={c.uuid} contract={c} itemUuid={item.uuid} />
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function ContractCoverageRow({ contract, itemUuid }: { contract: TenantInsurance; itemUuid: string }) {
|
|
const { data } = useQuery<{ data: { data: CoverageRow[] } }>({
|
|
queryKey: ['service-coverage', contract.uuid],
|
|
queryFn: () => api.get(`/api/v1/billing/tenant-insurances/${contract.uuid}/service-coverage`),
|
|
});
|
|
|
|
const row = ((data as any)?.data?.data as CoverageRow[] | undefined)
|
|
?.find((r) => r.service_item_uuid === itemUuid);
|
|
const kind = contract.insurance_kind ? KIND[contract.insurance_kind] : null;
|
|
const percent = row?.coverage_percent ?? contract.coverage_percent;
|
|
|
|
return (
|
|
<div style={{
|
|
display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 10,
|
|
padding: '10px 12px', borderRadius: 'var(--r-sm)', border: '1px solid var(--border)',
|
|
}}>
|
|
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 8, fontSize: 13, minWidth: 0 }}>
|
|
<ShieldCheckIcon style={{ width: 15, color: 'var(--primary)', flexShrink: 0 }} />
|
|
<b style={{ whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
|
{contract.insurance_name ?? 'بیمه'}
|
|
</b>
|
|
{kind && <span className={`badge ${kind.cls}`} style={{ fontSize: 10 }}>{kind.label}</span>}
|
|
</span>
|
|
{row && !row.covered ? (
|
|
<span style={{ fontSize: 12, color: 'var(--text-3)' }}>بدون پوشش</span>
|
|
) : (
|
|
<span style={{ fontSize: 12.5, color: 'var(--text-2)', display: 'inline-flex', gap: 10, whiteSpace: 'nowrap' }}>
|
|
<span>پوشش {formatNumber(percent)}٪</span>
|
|
{row?.franchise_percent ? <span>فرانشیز {formatNumber(row.franchise_percent)}٪</span> : null}
|
|
{row?.ceiling_rials ? <span>سقف {formatRial(row.ceiling_rials)}</span> : null}
|
|
{!row && <span className="muted">(ارث از قرارداد)</span>}
|
|
</span>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function GoodsTab({ item, onEdit, canUpdate }: { item: ServiceItem; onEdit: () => void; canUpdate: boolean }) {
|
|
const { data, isLoading } = useQuery<ApiResponse<InventoryPackage[]>>({
|
|
queryKey: ['inventory-packages'],
|
|
queryFn: () => api.get('/api/v1/inventory-packages'),
|
|
});
|
|
|
|
const linked = (data?.data ?? []).find((p) => p.uuid === item.inventory_package_uuid);
|
|
const consumables = item.consumables ?? [];
|
|
const nothingLinked = !item.inventory_package_uuid && consumables.length === 0;
|
|
|
|
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={onEdit}>ویرایش کالاها</button>}
|
|
</div>
|
|
|
|
{isLoading ? (
|
|
<div className="muted" style={{ fontSize: 13, padding: '12px 0' }}>در حال بارگذاری...</div>
|
|
) : nothingLinked ? (
|
|
<div className="empty" style={{ padding: '28px 0' }}>
|
|
<CubeIcon style={{ width: 30, height: 30 }} />
|
|
<p className="muted">کالایی به این سرویس متصل نیست</p>
|
|
<Link className="btn sm" to="/admin/inventory">مدیریت انبار</Link>
|
|
</div>
|
|
) : (
|
|
<div style={{ marginTop: 12, display: 'flex', flexDirection: 'column', gap: 16 }}>
|
|
{item.inventory_package_uuid && (
|
|
<div>
|
|
<div className="muted" style={{ fontSize: 12, marginBottom: 8 }}>پکیج</div>
|
|
{!linked ? (
|
|
<div className="muted" style={{ fontSize: 13 }}>
|
|
{item.inventory_package_title ?? 'پکیج متصل'} — جزئیات در دسترس نیست
|
|
</div>
|
|
) : (
|
|
<>
|
|
<div style={{
|
|
display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 10,
|
|
padding: '10px 12px', borderRadius: 'var(--r-sm)', background: 'var(--primary-soft)', marginBottom: 8,
|
|
}}>
|
|
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 8, fontSize: 13 }}>
|
|
<CubeIcon style={{ width: 15, color: 'var(--primary)' }} />
|
|
<b>{linked.title}</b>
|
|
{!linked.available && <span className="badge gray" style={{ fontSize: 10 }}>موجودی ناکافی</span>}
|
|
</span>
|
|
<b style={{ fontSize: 13.5, color: 'var(--primary)' }}>{formatRial(linked.total)}</b>
|
|
</div>
|
|
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
|
{linked.items.map((line) => (
|
|
<div key={line.itemUuid} style={{
|
|
display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 10,
|
|
padding: '9px 12px', borderRadius: 'var(--r-sm)', border: '1px solid var(--border)',
|
|
}}>
|
|
<span style={{ fontSize: 13 }}>{line.name}</span>
|
|
<span style={{ fontSize: 12.5, color: 'var(--text-3)', display: 'inline-flex', gap: 10 }}>
|
|
<span>{formatNumber(line.amount)} {line.unit}</span>
|
|
<span>{formatRial(line.price * line.amount)}</span>
|
|
</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{consumables.length > 0 && (
|
|
<div>
|
|
<div className="muted" style={{ fontSize: 12, marginBottom: 8 }}>کالاهای تکی</div>
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
|
{consumables.map((line) => (
|
|
<div key={line.item_uuid} style={{
|
|
display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 10,
|
|
padding: '9px 12px', borderRadius: 'var(--r-sm)', border: '1px solid var(--border)',
|
|
}}>
|
|
<span style={{ fontSize: 13, display: 'inline-flex', alignItems: 'center', gap: 8 }}>
|
|
{line.name}
|
|
{line.stock < line.amount && <span className="badge gray" style={{ fontSize: 10 }}>موجودی ناکافی</span>}
|
|
</span>
|
|
<span style={{ fontSize: 12.5, color: 'var(--text-3)', display: 'inline-flex', gap: 10 }}>
|
|
<span>{formatNumber(line.amount)} {line.unit}</span>
|
|
<span>{formatRial(line.price * line.amount)}</span>
|
|
</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function HistoryTab({ item }: { item: ServiceItem }) {
|
|
const { data, isLoading } = useQuery<ApiResponse<AuditLog[]>>({
|
|
queryKey: ['service-item-audit', item.uuid],
|
|
queryFn: () => api.get(`/api/v1/service-item/${item.uuid}/audit-logs`),
|
|
});
|
|
|
|
const logs = data?.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>
|
|
</div>
|
|
|
|
{isLoading ? (
|
|
<div className="muted" style={{ fontSize: 13, padding: '12px 0' }}>در حال بارگذاری...</div>
|
|
) : logs.length === 0 ? (
|
|
<div className="empty" style={{ padding: '28px 0' }}>
|
|
<ClockIcon style={{ width: 30, height: 30 }} />
|
|
<p className="muted">تغییری ثبت نشده است</p>
|
|
</div>
|
|
) : (
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, marginTop: 12 }}>
|
|
{logs.map((log) => (
|
|
<div key={log.uuid} style={{
|
|
padding: '10px 12px', borderRadius: 'var(--r-sm)', border: '1px solid var(--border)',
|
|
}}>
|
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 10, marginBottom: 6 }}>
|
|
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 8, fontSize: 13 }}>
|
|
<b>{FIELD_LABELS[log.field] ?? log.field}</b>
|
|
{log.operation === 'create' && <span className="badge green" style={{ fontSize: 10 }}>ایجاد</span>}
|
|
</span>
|
|
<span style={{ fontSize: 11.5, color: 'var(--text-3)' }}>{formatDateTime(log.created_at)}</span>
|
|
</div>
|
|
{log.operation === 'update' && (
|
|
<div style={{ fontSize: 12.5, color: 'var(--text-2)', display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
|
|
<span style={{ color: 'var(--text-3)', textDecoration: 'line-through' }}>{auditValue(log.field, log.old_value)}</span>
|
|
<span style={{ color: 'var(--text-3)' }}>←</span>
|
|
<span style={{ fontWeight: 600 }}>{auditValue(log.field, log.new_value)}</span>
|
|
</div>
|
|
)}
|
|
{log.actor_name && (
|
|
<div style={{ fontSize: 11.5, color: 'var(--text-3)', marginTop: 4 }}>توسط {log.actor_name}</div>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function ServiceDetailPageInner() {
|
|
const { uuid } = useParams<{ uuid: string }>();
|
|
const navigate = useNavigate();
|
|
const qc = useQueryClient();
|
|
// مجوز منشی؛ برای owner/پزشک همیشه true (usePermissions بدون context آزاد است).
|
|
const { can } = usePermissions();
|
|
const canUpdate = can('services', 'update');
|
|
|
|
const [tab, setTab] = useState<TabId>('info');
|
|
const [editOpen, setEditOpen] = useState(false);
|
|
const [tariffOpen, setTariffOpen] = useState(false);
|
|
const [insuranceOpen, setInsuranceOpen] = useState(false);
|
|
const [toggleOpen, setToggleOpen] = useState(false);
|
|
|
|
const { data, isLoading, isError } = useQuery<ApiResponse<ServiceItem>>({
|
|
queryKey: ['service-item', uuid],
|
|
queryFn: () => api.get(`/api/v1/service-item/${uuid}`),
|
|
enabled: !!uuid,
|
|
});
|
|
|
|
const item = data?.data;
|
|
|
|
const toggleActive = useMutation({
|
|
mutationFn: () => api.patch(`/api/v1/service-item/${uuid}`, { active: !item!.active }),
|
|
onSuccess: () => {
|
|
qc.invalidateQueries({ queryKey: ['service-item', uuid] });
|
|
qc.invalidateQueries({ queryKey: ['service-items'] });
|
|
setToggleOpen(false);
|
|
toast.success(item!.active ? 'سرویس غیرفعال شد' : 'سرویس فعال شد');
|
|
},
|
|
onError: (e: Error) => { toast.error(e.message); setToggleOpen(false); },
|
|
});
|
|
|
|
if (isLoading) {
|
|
return <div style={{ color: 'var(--text-3)', fontSize: 13, padding: 16 }}>در حال بارگذاری...</div>;
|
|
}
|
|
|
|
if (isError || !item) {
|
|
return (
|
|
<div className="card" style={{ padding: '52px 0', textAlign: 'center' }}>
|
|
<CubeIcon style={{ width: 34, margin: '0 auto 12px', display: 'block', opacity: 0.35 }} />
|
|
<div style={{ fontWeight: 600, color: 'var(--text-2)', marginBottom: 12 }}>سرویس یافت نشد</div>
|
|
<button className="btn primary sm" onClick={() => navigate('/admin/clinic-services')}>بازگشت به سرویسها</button>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="fade-in">
|
|
<PageHeader
|
|
backTo="/admin/clinic-services"
|
|
title={item.name}
|
|
breadcrumbs={[
|
|
{ label: 'سرویسها', to: '/admin/clinic-services' },
|
|
...(item.section_name ? [{ label: item.section_name }] : []),
|
|
{ label: item.name },
|
|
]}
|
|
action={
|
|
canUpdate ? (
|
|
<div style={{ display: 'flex', gap: 8 }}>
|
|
<button className="btn sm" onClick={() => setToggleOpen(true)}>
|
|
{item.active ? 'غیرفعالکردن' : 'فعالکردن'}
|
|
</button>
|
|
<button className="btn primary sm" onClick={() => setEditOpen(true)}>
|
|
<PencilIcon style={{ width: 15 }} /> ویرایش
|
|
</button>
|
|
</div>
|
|
) : undefined
|
|
}
|
|
/>
|
|
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 'var(--gap)' }}>
|
|
<span className={`badge ${item.active ? 'green' : 'gray'}`} style={{ fontSize: 11 }}>
|
|
<span className="bdot" />{item.active ? 'فعال' : 'غیرفعال'}
|
|
</span>
|
|
</div>
|
|
|
|
<div className="seg" style={{ marginBottom: 'var(--gap)', overflowX: 'auto', flexWrap: 'nowrap' }}>
|
|
{TABS.map((t) => (
|
|
<button
|
|
key={t.id}
|
|
className={tab === t.id ? 'active' : ''}
|
|
style={{ whiteSpace: 'nowrap' }}
|
|
onClick={() => setTab(t.id)}
|
|
>
|
|
{t.label}
|
|
</button>
|
|
))}
|
|
</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 === 'goods' && <GoodsTab item={item} onEdit={() => setEditOpen(true)} canUpdate={canUpdate} />}
|
|
{tab === 'history' && <HistoryTab item={item} />}
|
|
|
|
<ServiceItemFormModal
|
|
item={editOpen ? item : null}
|
|
sectionUuid={item.section_uuid ?? null}
|
|
onClose={() => setEditOpen(false)}
|
|
onManageInsurance={() => setInsuranceOpen(true)}
|
|
/>
|
|
<ServiceTariffModal item={tariffOpen ? item : null} onClose={() => setTariffOpen(false)} />
|
|
<ServiceInsuranceModal item={insuranceOpen ? item : null} onClose={() => setInsuranceOpen(false)} />
|
|
|
|
<ConfirmDialog
|
|
open={toggleOpen}
|
|
title={item.active ? 'غیرفعالکردن سرویس' : 'فعالکردن سرویس'}
|
|
message={
|
|
item.active
|
|
? `سرویس «${item.name}» غیرفعال میشود و در پذیرش جدید نمایش داده نمیشود. سوابق قبلی حفظ میمانند.`
|
|
: `سرویس «${item.name}» دوباره فعال و قابل انتخاب میشود.`
|
|
}
|
|
confirmLabel={item.active ? 'غیرفعال کن' : 'فعال کن'}
|
|
onConfirm={() => toggleActive.mutate()}
|
|
onCancel={() => setToggleOpen(false)}
|
|
loading={toggleActive.isPending}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default function ServiceDetailPage() {
|
|
return (
|
|
<FeatureGate feature="services">
|
|
<ServiceDetailPageInner />
|
|
</FeatureGate>
|
|
);
|
|
}
|