Files
clinicpro/assets/admin/pages/ServiceDetailPage.tsx
T
hamedandClaude Opus 5 d8aabe5d0a feat(admin): treatment plan tab, staff session screens and the device form editor
Three screens, each reusing what already exists rather than inventing a parallel
look. The treatment plan lives as a tab on the service page next to categories,
because the course belongs to the service; the switch is the protocol's existence
rather than a separate boolean that could disagree with the step list. Each step
asks for days since the previous session, which is how the interval actually
works and what the form should therefore say.

The staff screens are the flow from the reference screenshots: today's sessions,
then a session where each area is started, recorded and closed on its own. Field
inputs are built from the schema the server sends per resource type, so a clinic
adding an RF device sees its own form here without a code change.

StatusBadge gains treatment session and area states rather than a second badge
component sitting beside it, and the resource type modal grows a field editor so
the operator form is configured where the device is defined.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 18:29:49 +03:30

523 lines
23 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 ServiceInsuranceModal from '../components/ServiceInsuranceModal';
import ServiceItemFormModal from '../components/ServiceItemFormModal';
import ServiceCategoryTab from '../components/ServiceCategoryTab';
import TreatmentProtocolTab from '../components/TreatmentProtocolTab';
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: 'insurance', label: 'بیمه‌ها' },
{ id: 'categories',label: 'دسته‌بندی‌ها' },
{ id: 'treatment', 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 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 [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 === 'insurance' && <InsuranceTab item={item} onManage={() => setInsuranceOpen(true)} canUpdate={canUpdate} />}
{tab === 'categories' && (
<ServiceCategoryTab
serviceUuid={item.uuid}
categoryUuid={item.catalog_category_uuid ?? null}
canEdit={canUpdate}
/>
)}
{tab === 'treatment' && <TreatmentProtocolTab serviceUuid={item.uuid} 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)}
/>
<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>
);
}