Anything that belongs to a service is defined on the service itself, so the two tabs that managed selection groups and appointment segments come off the service page. Only the UI goes. SegmentTemplate is what makes a service occupy a room and a device at the same time — it is the input to AppointmentPlanBuilder and the reason the resource timeline has anything to draw — and a service without a template already books through singleSegment(). Removing the model would change booking; removing the tabs does not, which tests/Appointment confirms at 314 green. The active tab moved into the query string on the way past. That is what makes the old ?tab=segments link land on the info tab instead of rendering nothing, and it lets back and refresh return to the same tab. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
589 lines
26 KiB
TypeScript
589 lines
26 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 { 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;
|
|
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: 'categories',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 [urlState, setUrlState] = useUrlState({ tab: 'info' });
|
|
// تبِ ناشناخته (لینک قدیمی به گروهها/بخشها) به اطلاعات برمیگردد، نه صفحهٔ خالی.
|
|
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);
|
|
|
|
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 === 'categories' && (
|
|
<ServiceCategoryTab
|
|
serviceUuid={item.uuid}
|
|
categoryUuid={item.catalog_category_uuid ?? null}
|
|
canEdit={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>
|
|
);
|
|
}
|