Files
clinicpro/assets/admin/pages/ClinicServicesPage.tsx
T
hamed 6876135a53 feat: add BlogBodySanitizer for HTML sanitization on article save
- Implemented BlogBodySanitizer to clean HTML content before saving articles, ensuring security against XSS attacks.
- Added tests for BlogBodySanitizer to verify that unsafe tags and attributes are stripped from the content.
- Introduced ApiLeastPrivilegeTest to ensure that unauthorized users cannot access sensitive API routes, maintaining strict access control.
2026-08-07 21:13:38 +03:30

398 lines
22 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { useState } from 'react';
import { useNavigate } from 'react-router';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import {
PlusIcon, PencilIcon, WrenchScrewdriverIcon, BanknotesIcon,
ShieldCheckIcon, MagnifyingGlassIcon, EyeIcon, EyeSlashIcon,
EllipsisHorizontalIcon, CheckCircleIcon, XCircleIcon, ChevronRightIcon,
XMarkIcon, UsersIcon, ClockIcon,
} from '@heroicons/react/24/outline';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { toast } from 'sonner';
import { api } from '../lib/api';
import type { ApiResponse } from '../lib/api';
import type { ServiceSection, ServiceItem } from '../types';
import { formatRial, formatNumber } from '../lib/utils';
import { usePermissions } from '../hooks/usePermissions';
import PageHeader from '../components/ui/PageHeader';
import Modal from '../components/ui/Modal';
import ConfirmDialog from '../components/ui/ConfirmDialog';
import ServiceInsuranceModal from '../components/ServiceInsuranceModal';
import ServiceItemFormModal from '../components/ServiceItemFormModal';
import FeatureGate from '../components/ui/FeatureGate';
import Switch from '../components/ui/Switch';
const sectionSchema = z.object({ name: z.string().min(1, 'نام بخش الزامی است') });
type SectionForm = z.infer<typeof sectionSchema>;
const EMPTY_SECTIONS: ServiceSection[] = [];
const EMPTY_ITEMS: ServiceItem[] = [];
function Avatar({ name }: { name: string }) {
return (
<div style={{
width: 26, height: 26, borderRadius: '50%',
background: 'linear-gradient(145deg, oklch(0.62 0.15 162), oklch(0.48 0.16 162))',
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: 11, fontWeight: 700, color: 'var(--on-primary)', flexShrink: 0,
}}>
{name.charAt(0)}
</div>
);
}
function ClinicServicesPageInner() {
const qc = useQueryClient();
// مجوزهای منشی؛ برای owner/پزشک همیشه true (usePermissions بدون context آزاد است).
const { can } = usePermissions();
const canCreate = can('services', 'create');
const canUpdate = can('services', 'update');
const navigate = useNavigate();
const [selectedSection, setSelectedSection] = useState<ServiceSection | null>(null);
const [sectionModal, setSectionModal] = useState<'create' | ServiceSection | null>(null);
const [itemModal, setItemModal] = useState<'create' | ServiceItem | null>(null);
const [toggleItem, setToggleItem] = useState<ServiceItem | null>(null);
const [insuranceItem, setInsuranceItem] = useState<ServiceItem | null>(null);
const [search, setSearch] = useState('');
const [showInactive, setShowInactive] = useState(true);
const [menuOpen, setMenuOpen] = useState<string | null>(null);
const { data: sectionsData, isLoading: sectionsLoading } = useQuery<ApiResponse<ServiceSection[]>>({
queryKey: ['service-sections'],
queryFn: () => api.get('/api/v1/service-sections'),
});
const { data: itemsData, isLoading: itemsLoading } = useQuery<ApiResponse<ServiceItem[]>>({
queryKey: ['service-items', selectedSection?.uuid],
queryFn: () => api.get(`/api/v1/service-items/${selectedSection!.uuid}`),
enabled: !!selectedSection,
});
const sections = sectionsData?.data ?? EMPTY_SECTIONS;
const allItems = itemsData?.data ?? EMPTY_ITEMS;
const sectionForm = useForm<SectionForm>({ resolver: zodResolver(sectionSchema) });
const items = allItems.filter((it) => {
if (!showInactive && !it.active) return false;
if (search.trim() && !it.name.includes(search.trim())) return false;
return true;
});
const activeCount = allItems.filter((i) => i.active).length;
const createSection = useMutation({
mutationFn: (body: SectionForm) => api.post('/api/v1/service-section', body),
onSuccess: () => { qc.invalidateQueries({ queryKey: ['service-sections'] }); setSectionModal(null); sectionForm.reset(); toast.success('بخش ایجاد شد'); },
onError: (e: any) => toast.error(e.message),
});
const editSection = useMutation({
mutationFn: ({ uuid, body }: { uuid: string; body: SectionForm }) =>
api.patch(`/api/v1/service-section/${uuid}`, body),
onSuccess: () => { qc.invalidateQueries({ queryKey: ['service-sections'] }); setSectionModal(null); toast.success('بخش ویرایش شد'); },
onError: (e: any) => toast.error(e.message),
});
const toggleSection = useMutation({
mutationFn: ({ uuid, active }: { uuid: string; active: boolean }) =>
api.patch(`/api/v1/service-section/${uuid}`, { active }),
onSuccess: (_d, v) => {
qc.invalidateQueries({ queryKey: ['service-sections'] });
toast.success(v.active ? 'بخش فعال شد' : 'بخش غیرفعال شد');
},
onError: (e: any) => toast.error(e.message),
});
const toggleActive = useMutation({
mutationFn: ({ uuid, active }: { uuid: string; active: boolean }) =>
api.patch(`/api/v1/service-item/${uuid}`, { active }),
onSuccess: (_d, v) => {
qc.invalidateQueries({ queryKey: ['service-items', selectedSection?.uuid] });
setToggleItem(null);
toast.success(v.active ? 'سرویس فعال شد' : 'سرویس غیرفعال شد');
},
onError: (e: any) => { toast.error(e.message); setToggleItem(null); },
});
const openEditSection = (s: ServiceSection) => {
sectionForm.reset({ name: s.name });
setSectionModal(s);
};
const openCreateItem = () => setItemModal('create');
return (
<>
{!selectedSection ? (
/* ═════════ نمای بخش‌ها ═════════ */
<>
{/* تیتر دست‌ساز جای `PageHeader` بود: بدون آن این صفحه تنها فهرستِ پنل است
که نه دکمهٔ بازگشت دارد و نه می‌گوید چیست. */}
<PageHeader
title="سرویس‌ها"
description="بخش‌ها و سرویس‌های قابل رزرو. مدت و قیمت هر سرویس از همین‌جا می‌آید."
backTo="/admin/settings-menu"
action={
canCreate ? (
<button className="cp-btn-primary" onClick={() => { sectionForm.reset({ name: '' }); setSectionModal('create'); }}>
<PlusIcon style={{ width: 16 }} /> بخش جدید
</button>
) : undefined
}
/>
{sectionsLoading ? (
<div style={{ color: 'var(--text-3)', fontSize: 13, padding: 16 }}>در حال بارگذاری...</div>
) : sections.length === 0 ? (
<div className="card" style={{ padding: '60px 0', textAlign: 'center', color: 'var(--text-3)' }}>
<WrenchScrewdriverIcon style={{ width: 56, margin: '0 auto 16px', display: 'block', opacity: 0.3 }} />
<div style={{ fontSize: 14, color: 'var(--text-2)' }}>برای اضافه کردن بخش روی دکمه بخش جدید کلیک نمایید.</div>
</div>
) : (
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(240px, 1fr))', gap: 14 }}>
{sections.map((s) => (
<div
key={s.uuid}
onClick={() => setSelectedSection(s)}
style={{
cursor: 'pointer', background: 'var(--surface)', borderRadius: 'var(--r)',
boxShadow: '0 1px 24.8px rgba(204,204,204,0.18)',
border: '1px solid var(--border)', padding: '16px 16px 12px',
opacity: s.active ? 1 : 0.62,
}}
>
<div style={{ fontWeight: 700, fontSize: 15, color: 'var(--text)', marginBottom: 12 }}>{s.name}</div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '4px 0' }}>
<span style={{ fontSize: 12.5, color: 'var(--text-3)' }}>تعداد:</span>
<span style={{ fontSize: 13, color: 'var(--text-2)' }}>{formatNumber(s.items_count ?? 0)} سرویس</span>
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '4px 0' }} onClick={(e) => e.stopPropagation()}>
<span style={{ fontSize: 12.5, color: 'var(--text-3)' }}>وضعیت:</span>
<span style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<Switch
checked={s.active}
onChange={() => toggleSection.mutate({ uuid: s.uuid, active: !s.active })}
ariaLabel={`وضعیت ${s.name}`}
/>
<span style={{ fontSize: 12.5, color: s.active ? 'var(--success)' : 'var(--text-3)' }}>{s.active ? 'فعال' : 'غیرفعال'}</span>
</span>
</div>
{canUpdate && (
<div style={{ display: 'flex', gap: 6, borderTop: '1px solid var(--border)', paddingTop: 10, marginTop: 8 }} onClick={(e) => e.stopPropagation()}>
<button className="btn sm ghost" aria-label="ویرایش" style={{ display: 'flex', alignItems: 'center', gap: 4, color: 'var(--text-2)' }} onClick={() => openEditSection(s)}>
<PencilIcon style={{ width: 15 }} />
</button>
</div>
)}
</div>
))}
</div>
)}
</>
) : (
/* ═════════ نمای سرویس‌های یک بخش ═════════ */
<>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 16, flexWrap: 'wrap', gap: 12 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap' }}>
<button className="cp-btn-secondary" style={{ height: 36 }} onClick={() => setSelectedSection(null)}>
<ChevronRightIcon style={{ width: 16 }} /> بازگشت
</button>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 14, color: 'var(--text-3)' }}>
<span style={{ cursor: 'pointer' }} onClick={() => setSelectedSection(null)}>بخش‌ها</span>
<span></span>
<b style={{ color: 'var(--text)' }}>{selectedSection.name}</b>
</div>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
<div style={{ position: 'relative' }}>
<MagnifyingGlassIcon style={{ width: 15, position: 'absolute', insetInlineStart: 9, top: '50%', transform: 'translateY(-50%)', color: 'var(--text-3)', pointerEvents: 'none' }} />
<input className="cp-input" placeholder="جستجوی سرویس" value={search} onChange={(e) => setSearch(e.target.value)} style={{ width: 170, paddingInlineStart: 30, height: 36 }} />
</div>
<button className="btn sm ghost" onClick={() => setShowInactive((v) => !v)} title={showInactive ? 'پنهان‌کردن غیرفعال‌ها' : 'نمایش غیرفعال‌ها'}>
{showInactive ? <EyeIcon style={{ width: 16 }} /> : <EyeSlashIcon style={{ width: 16 }} />}
</button>
{canCreate && (
<button className="cp-btn-primary" onClick={openCreateItem}>
<PlusIcon style={{ width: 16 }} /> سرویس جدید
</button>
)}
</div>
</div>
{itemsLoading ? (
<div style={{ color: 'var(--text-3)', fontSize: 13, padding: 16 }}>در حال بارگذاری...</div>
) : items.length === 0 ? (
<div className="card" style={{ padding: '52px 0', textAlign: 'center', color: 'var(--text-3)' }}>
<PlusIcon style={{ width: 32, margin: '0 auto 12px', display: 'block', opacity: 0.35 }} />
<div style={{ fontWeight: 600, color: 'var(--text-2)', marginBottom: 4 }}>
{allItems.length === 0 ? 'سرویسی در این بخش وجود ندارد' : 'سرویسی با این فیلتر یافت نشد'}
</div>
{allItems.length === 0 && canCreate && (
<button className="cp-btn-primary" style={{ marginTop: 12 }} onClick={openCreateItem}>افزودن سرویس</button>
)}
</div>
) : (
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(300px, 1fr))', gap: 16 }}>
{items.map((item) => {
const menuItemStyle: React.CSSProperties = {
display: 'flex', alignItems: 'center', gap: 8, width: '100%',
padding: '8px 10px', borderRadius: 8, border: 'none', cursor: 'pointer',
background: 'transparent', color: 'var(--text-2)', fontSize: 13,
fontFamily: 'inherit', textAlign: 'start', whiteSpace: 'nowrap',
};
const chipStyle: React.CSSProperties = {
fontSize: 12, color: 'var(--text-2)', background: 'var(--surface-2)',
border: '1px solid var(--border)', borderRadius: 999, padding: '3px 10px', whiteSpace: 'nowrap',
};
return (
<div
key={item.uuid}
role="link"
tabIndex={0}
onClick={() => navigate(`/admin/clinic-services/${item.uuid}`)}
onKeyDown={(e) => { if (e.key === 'Enter') navigate(`/admin/clinic-services/${item.uuid}`); }}
style={{
position: 'relative', background: 'var(--surface)', borderRadius: 8,
boxShadow: '0 1px 24.8px rgba(204,204,204,0.18)',
border: '1px solid var(--border)', padding: 14, cursor: 'pointer',
opacity: item.active ? 1 : 0.7,
}}
>
{/* نوار بالا: ⋮ (چپ) + وضعیت و نام (راست) */}
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 8, marginBottom: 14 }}>
{canUpdate ? (
<button className="btn sm ghost" style={{ padding: 4 }} onClick={(e) => { e.stopPropagation(); setMenuOpen(menuOpen === item.uuid ? null : item.uuid); }} title="عملیات">
<EllipsisHorizontalIcon style={{ width: 20 }} />
</button>
) : <span />}
<div style={{ display: 'flex', alignItems: 'center', gap: 8, minWidth: 0 }}>
<span className={`badge ${item.active ? 'green' : 'gray'}`} style={{ fontSize: 11 }}>
<span className="bdot" />{item.active ? 'فعال' : 'غیرفعال'}
</span>
<span style={{ fontWeight: 700, fontSize: 14, color: 'var(--text)', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{item.name}</span>
</div>
</div>
{menuOpen === item.uuid && (
<>
<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); 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>
</>
)}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', gap: 8, padding: '3px 0' }}>
<span style={{ fontSize: 12, color: 'var(--text-3)', display: 'inline-flex', alignItems: 'center', gap: 5 }}>
<BanknotesIcon style={{ width: 14, color: 'var(--text-3)' }} /> قیمت پایه:
</span>
<span style={{ fontSize: 14, fontWeight: 800, color: 'var(--primary)' }}>{formatRial(item.price_rials)}</span>
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 8, padding: '3px 0' }}>
<span style={{ fontSize: 12, color: 'var(--text-3)', display: 'inline-flex', alignItems: 'center', gap: 5 }}>
<ClockIcon style={{ width: 14, color: 'var(--text-3)' }} /> زمان متوسط:
</span>
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}>
{item.bookable && <span className="badge green" style={{ fontSize: 11 }}>در نوبت‌دهی</span>}
{item.duration_minutes
? <span className="badge blue" style={{ fontSize: 11 }}>{formatNumber(Number(item.duration_minutes))} دقیقه</span>
: <span style={{ fontSize: 12, color: 'var(--text-3)' }}></span>}
</span>
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 8, padding: '3px 0' }}>
<span style={{ fontSize: 12, color: 'var(--text-3)', display: 'inline-flex', alignItems: 'center', gap: 5 }}>
<ShieldCheckIcon style={{ width: 14, color: 'var(--text-3)' }} /> بیمه:
</span>
{item.insurance_covered
? <span className="badge green" style={{ fontSize: 11 }}>تحت پوشش</span>
: <span style={{ fontSize: 12, color: 'var(--text-3)' }}></span>}
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 8, padding: '3px 0' }}>
<span style={{ fontSize: 12, color: 'var(--text-3)', flexShrink: 0, display: 'inline-flex', alignItems: 'center', gap: 5 }}>
<UsersIcon style={{ width: 14, color: 'var(--text-3)' }} /> پرسنل:
</span>
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap', justifyContent: 'flex-end' }}>
{(item.staff_members && item.staff_members.length > 0)
? item.staff_members.map((m) => <span key={m.uuid} style={chipStyle}>{m.full_name}</span>)
: item.staff
? <span style={chipStyle}>{item.staff.full_name}</span>
: <span style={{ fontSize: 12, color: 'var(--text-3)' }}></span>}
</div>
</div>
</div>
);
})}
</div>
)}
</>
)}
{/* ───────── Modal بخش ───────── */}
<Modal
open={sectionModal !== null}
onClose={() => setSectionModal(null)}
title={sectionModal === 'create' ? 'بخش جدید' : 'ویرایش بخش'}
>
<form onSubmit={sectionForm.handleSubmit((d) => {
if (sectionModal === 'create') createSection.mutate(d);
else if (sectionModal !== null && typeof sectionModal === 'object') editSection.mutate({ uuid: sectionModal.uuid, body: d });
})}>
<div className="field">
<label>نام بخش *</label>
<input {...sectionForm.register('name')} placeholder="مثلاً: تزریقات" autoFocus />
{sectionForm.formState.errors.name && (
<span className="field-error">{sectionForm.formState.errors.name.message}</span>
)}
</div>
<div style={{ display: 'flex', gap: 8, marginTop: 18 }}>
<button type="submit" className="btn primary" disabled={createSection.isPending || editSection.isPending}>ذخیره</button>
<button type="button" className="btn" onClick={() => setSectionModal(null)}>انصراف</button>
</div>
</form>
</Modal>
{/* ───────── Modal سرویس ───────── */}
<ServiceItemFormModal
item={itemModal}
sectionUuid={selectedSection?.uuid ?? null}
onClose={() => setItemModal(null)}
onManageInsurance={setInsuranceItem}
/>
<ServiceInsuranceModal item={insuranceItem} onClose={() => setInsuranceItem(null)} />
{/* Confirm فعال/غیرفعال سرویس */}
<ConfirmDialog
open={!!toggleItem}
title={toggleItem?.active ? 'غیرفعال‌کردن سرویس' : 'فعال‌کردن سرویس'}
message={
toggleItem?.active
? `سرویس «${toggleItem?.name}» غیرفعال می‌شود و در پذیرش جدید نمایش داده نمی‌شود. سوابق قبلی حفظ می‌مانند.`
: `سرویس «${toggleItem?.name}» دوباره فعال و قابل انتخاب می‌شود.`
}
confirmLabel={toggleItem?.active ? 'غیرفعال کن' : 'فعال کن'}
onConfirm={() => toggleItem && toggleActive.mutate({ uuid: toggleItem.uuid, active: !toggleItem.active })}
onCancel={() => setToggleItem(null)}
loading={toggleActive.isPending}
/>
</>
);
}
export default function ClinicServicesPage() {
return (
<FeatureGate feature="services">
<ClinicServicesPageInner />
</FeatureGate>
);
}