feat(services): match Figma خدمات page in settings shell + multi-staff

Render the clinic services page (sections → services) inside the settings
sub-navigation shell (SettingsLayout, "خدمات" active) to match the Figma
settings design. Restyle section cards to show the service count and a
status toggle with edit/delete actions, and service cards with labelled
price/duration and personnel chips.

A service can now have multiple personnel: add an additive many-to-many
ServiceItem↔ClinicStaff (staffMembers, EAGER) while keeping the legacy
single `staff` column mirrored for backward compatibility. Endpoints accept
`staff_uuids[]` (falling back to the legacy single `staff_uuid`) and return
`staff_members[]`; the section list now reports `items_count`.

Backfill-safe: pre-migration rows fall back to the single staff in toArray.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
hamed
2026-07-13 13:28:51 +03:30
co-authored by Claude Opus 4.8
parent 5e59785854
commit 9e4ee11831
10 changed files with 464 additions and 71 deletions
@@ -0,0 +1,61 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { screen, fireEvent } from '@testing-library/react';
import { renderWithProviders } from '../test/utils';
vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
vi.mock('../lib/api', () => ({
api: { get: vi.fn(), post: vi.fn(), patch: vi.fn(), put: vi.fn(), delete: vi.fn() },
ApiError: class extends Error {},
}));
import { api } from '../lib/api';
import { useAuthStore } from '../stores/authStore';
import ClinicServicesPage from './ClinicServicesPage';
const get = api.get as ReturnType<typeof vi.fn>;
beforeEach(() => {
// FeatureGate → useSubscription only fetches for doctor/clinic/secretary roles
useAuthStore.setState({ primaryRole: 'doctor' });
get.mockReset();
get.mockImplementation((url: string) => {
if (url.includes('/subscription/my')) return Promise.resolve({ success: true, data: {
subscription: null, used_trial: false,
effective_plan: { name: 'professional', level: 2, max_secretaries: 10, features: { services: true } },
} });
if (url.includes('/payment/config')) return Promise.resolve({ success: true, data: { test_mode: true, gateways: [] } });
if (url.includes('/service-sections')) return Promise.resolve({ success: true, data: [
{ uuid: 'sec1', name: 'کندلا ۲۰۲۱', active: true, items_count: 10 },
] });
if (url.includes('/service-items/sec1')) return Promise.resolve({ success: true, data: [
{ uuid: 'it1', name: 'فول بادی', price_rials: 35_000_000, active: true, duration_minutes: 50,
staff: { uuid: 'st1', full_name: 'مریم امینی' },
staff_members: [{ uuid: 'st1', full_name: 'مریم امینی' }, { uuid: 'st2', full_name: 'سحر رحمانی' }] },
] });
if (url.includes('/staff')) return Promise.resolve({ success: true, data: [
{ uuid: 'st1', full_name: 'مریم امینی', active: true },
{ uuid: 'st2', full_name: 'سحر رحمانی', active: true },
] });
return Promise.resolve({ success: true, data: [] });
});
});
describe('ClinicServicesPage (خدمات)', () => {
it('renders section cards with the service count inside the settings shell', async () => {
renderWithProviders(<ClinicServicesPage />, { route: '/admin/clinic-services' });
// settings shell menu + section card
expect(await screen.findByText('کندلا ۲۰۲۱')).toBeInTheDocument();
expect(screen.getByText(/۱۰ سرویس/)).toBeInTheDocument();
expect(screen.getByText('بخش جدید')).toBeInTheDocument();
});
it('drills into a section and shows a service with all its personnel chips', async () => {
renderWithProviders(<ClinicServicesPage />, { route: '/admin/clinic-services' });
fireEvent.click(await screen.findByText('کندلا ۲۰۲۱'));
expect(await screen.findByText('فول بادی')).toBeInTheDocument();
expect(screen.getByText('مریم امینی')).toBeInTheDocument();
expect(screen.getByText('سحر رحمانی')).toBeInTheDocument();
expect(screen.getByText('سرویس جدید')).toBeInTheDocument();
});
});
+97 -47
View File
@@ -4,7 +4,9 @@ import {
PlusIcon, PencilIcon, TrashIcon, WrenchScrewdriverIcon, BanknotesIcon,
ShieldCheckIcon, MagnifyingGlassIcon, EyeIcon, EyeSlashIcon,
EllipsisHorizontalIcon, CheckCircleIcon, XCircleIcon, ChevronRightIcon,
XMarkIcon, UsersIcon, ClockIcon,
} from '@heroicons/react/24/outline';
import SettingsLayout from '../components/layout/SettingsLayout';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
@@ -12,11 +14,10 @@ import { toast } from 'sonner';
import { api } from '../lib/api';
import type { ApiResponse } from '../lib/api';
import type { ServiceSection, ServiceItem, ClinicStaff } from '../types';
import { formatRial, rialToToman, tomanToRial } from '../lib/utils';
import { formatRial, formatNumber, rialToToman, tomanToRial } from '../lib/utils';
import Modal from '../components/ui/Modal';
import PriceInput from '../components/ui/PriceInput';
import ConfirmDialog from '../components/ui/ConfirmDialog';
import PageHeader from '../components/ui/PageHeader';
import ServiceTariffModal from '../components/ServiceTariffModal';
import ServiceInsuranceModal from '../components/ServiceInsuranceModal';
import SearchableSelect from '../components/ui/SearchableSelect';
@@ -26,7 +27,7 @@ const sectionSchema = z.object({ name: z.string().min(1, 'نام بخش الزا
const itemSchema = z.object({
name: z.string().min(1, 'نام سرویس الزامی است'),
price_rials: z.coerce.number().min(0, 'مبلغ نمی‌تواند منفی باشد'),
staff_uuid: z.string().optional(),
staff_uuids: z.array(z.string()).optional(),
insurance_covered: z.boolean().optional(),
insurance_price_rials: z.coerce.number().min(0).optional(),
duration_minutes: z.coerce.number().min(0).optional(),
@@ -102,13 +103,24 @@ function ClinicServicesPageInner() {
const allItems = itemsData?.data ?? EMPTY_ITEMS;
const allStaff = staffData?.data ?? [];
const editingStaff = itemModal && typeof itemModal === 'object' ? itemModal.staff : null;
const sectionForm = useForm<SectionForm>({ resolver: zodResolver(sectionSchema) });
const itemForm = useForm<ItemForm>({ resolver: zodResolver(itemSchema) });
const selectedStaffUuids = itemForm.watch('staff_uuids') ?? [];
const editingMembers = itemModal && typeof itemModal === 'object'
? (itemModal.staff_members ?? (itemModal.staff ? [itemModal.staff] : []))
: [];
const staffOptions = allStaff
.filter((s) => s.active || s.uuid === editingStaff?.uuid)
.filter((s) => s.active || editingMembers.some((m) => m.uuid === s.uuid))
.filter((s) => !selectedStaffUuids.includes(s.uuid))
.map((s) => ({
value: s.uuid,
label: s.active ? s.full_name : `${s.full_name} (غیرفعال)`,
}));
const staffNameOf = (uuid: string) =>
allStaff.find((s) => s.uuid === uuid)?.full_name
?? editingMembers.find((m) => m.uuid === uuid)?.full_name
?? uuid;
const items = allItems.filter((it) => {
if (!showInactive && !it.active) return false;
@@ -117,9 +129,6 @@ function ClinicServicesPageInner() {
});
const activeCount = allItems.filter((i) => i.active).length;
const sectionForm = useForm<SectionForm>({ resolver: zodResolver(sectionSchema) });
const itemForm = useForm<ItemForm>({ resolver: zodResolver(itemSchema) });
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('بخش ایجاد شد'); },
@@ -195,7 +204,7 @@ function ClinicServicesPageInner() {
itemForm.reset({
name: item.name,
price_rials: rialToToman(item.price_rials),
staff_uuid: item.staff?.uuid ?? '',
staff_uuids: (item.staff_members ?? (item.staff ? [item.staff] : [])).map((s) => s.uuid),
insurance_covered: item.insurance_covered ?? false,
insurance_price_rials: rialToToman(item.insurance_price_rials ?? 0),
duration_minutes: item.duration_minutes ?? undefined,
@@ -204,14 +213,12 @@ function ClinicServicesPageInner() {
};
const openCreateItem = () => {
itemForm.reset({ name: '', price_rials: 0, staff_uuid: '', insurance_covered: false, insurance_price_rials: 0, duration_minutes: undefined });
itemForm.reset({ name: '', price_rials: 0, staff_uuids: [], insurance_covered: false, insurance_price_rials: 0, duration_minutes: undefined });
setItemModal('create');
};
return (
<>
<PageHeader title="سرویس‌های کلینیک" description="بخش‌ها، سرویس‌ها، تعرفه و پوشش بیمه را مدیریت کنید" />
{!selectedSection ? (
/* ═════════ نمای بخش‌ها ═════════ */
<>
@@ -225,37 +232,46 @@ function ClinicServicesPageInner() {
{sectionsLoading ? (
<div style={{ color: 'var(--text-3)', fontSize: 13, padding: 16 }}>در حال بارگذاری...</div>
) : sections.length === 0 ? (
<div className="card" style={{ padding: '52px 0', textAlign: 'center', color: 'var(--text-3)' }}>
<WrenchScrewdriverIcon style={{ width: 34, margin: '0 auto 12px', display: 'block', opacity: 0.35 }} />
<div style={{ fontWeight: 600, color: 'var(--text-2)', marginBottom: 4 }}>بخشی ثبت نشده است</div>
<button className="cp-btn-primary" style={{ marginTop: 12 }} onClick={() => { sectionForm.reset({ name: '' }); setSectionModal('create'); }}>افزودن بخش</button>
<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(210px, 1fr))', gap: 14 }}>
<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: 8,
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: '18px 14px',
border: '1px solid var(--border)', padding: '16px 16px 12px',
opacity: s.active ? 1 : 0.62,
}}
>
<div style={{ fontWeight: 700, fontSize: 14, color: 'var(--text)', textAlign: 'center', marginBottom: 14 }}>{s.name}</div>
<div style={{ display: 'flex', gap: 6, justifyContent: 'center', borderTop: '1px solid var(--border)', paddingTop: 10 }} onClick={(e) => e.stopPropagation()}>
<button className="btn sm ghost" style={{ display: 'flex', alignItems: 'center', gap: 4, color: 'var(--text-2)' }} onClick={() => openEditSection(s)}>
<PencilIcon style={{ width: 14 }} /> ویرایش
<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 }}>
<label className="switch" title={s.active ? 'فعال' : 'غیرفعال'}>
<input type="checkbox" checked={s.active} onChange={() => toggleSection.mutate({ uuid: s.uuid, active: !s.active })} />
<span className="switch-track"><span className="switch-thumb" /></span>
</label>
<span style={{ fontSize: 12.5, color: s.active ? 'var(--success)' : 'var(--text-3)' }}>{s.active ? 'فعال' : 'غیرفعال'}</span>
</span>
</div>
<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>
<button
className="btn sm ghost"
style={{ display: 'flex', alignItems: 'center', gap: 4, color: s.active ? 'var(--danger)' : 'var(--success)' }}
onClick={() => toggleSection.mutate({ uuid: s.uuid, active: !s.active })}
>
{s.active
? <><XCircleIcon style={{ width: 14 }} /> غیرفعال</>
: <><CheckCircleIcon style={{ width: 14 }} /> فعال</>}
<button className="btn sm ghost" aria-label="حذف" style={{ display: 'flex', alignItems: 'center', gap: 4, color: 'var(--danger)' }} onClick={() => setDeleteSection(s)}>
<TrashIcon style={{ width: 15 }} />
</button>
</div>
</div>
@@ -350,12 +366,19 @@ function ClinicServicesPageInner() {
</>
)}
<SrvRow label="قیمت پایه" value={formatRial(item.price_rials)} strong />
<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)' }}>زمان متوسط:</span>
<span style={{ fontSize: 12, color: 'var(--text-3)', display: 'inline-flex', alignItems: 'center', gap: 5 }}>
<ClockIcon style={{ width: 14, color: 'var(--text-3)' }} /> زمان متوسط:
</span>
{item.duration_minutes
? <span className="badge blue" style={{ fontSize: 11 }}>{Number(item.duration_minutes).toLocaleString('fa-IR')} دقیقه</span>
? <span className="badge blue" style={{ fontSize: 11 }}>{formatNumber(Number(item.duration_minutes))} دقیقه</span>
: <span style={{ fontSize: 12, color: 'var(--text-3)' }}></span>}
</div>
@@ -363,12 +386,16 @@ function ClinicServicesPageInner() {
<SrvRow label="سهم بیمار (بیمه)" value={formatRial(item.insurance_price_rials)} />
)}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 8, padding: '3px 0' }}>
<span style={{ fontSize: 12, color: 'var(--text-3)', flexShrink: 0 }}>پرسنل:</span>
<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
? <span style={chipStyle}>{item.staff.full_name}</span>
: <span style={{ fontSize: 12, color: 'var(--text-3)' }}></span>}
{(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>
@@ -460,13 +487,34 @@ function ClinicServicesPageInner() {
<label className="field-label">پرسنل مسئول</label>
<SearchableSelect
options={staffOptions}
value={itemForm.watch('staff_uuid') ?? ''}
onChange={(v) => itemForm.setValue('staff_uuid', v != null ? String(v) : '')}
placeholder="انتخاب (اختیاری)"
noOptionsMessage="پرسنلی ثبت نشده"
value={''}
onChange={(v) => {
if (v != null) itemForm.setValue('staff_uuids', [...selectedStaffUuids, String(v)]);
}}
placeholder="افزودن پرسنل (اختیاری)"
noOptionsMessage="پرسنلی باقی نمانده"
height={42}
isClearable
/>
{selectedStaffUuids.length > 0 && (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, marginTop: 8 }}>
{selectedStaffUuids.map((uuid) => (
<span key={uuid} style={{
display: 'inline-flex', alignItems: 'center', gap: 6,
fontSize: 12.5, color: 'var(--text-2)', background: 'var(--surface-2)',
border: '1px solid var(--border)', borderRadius: 999, padding: '4px 6px 4px 10px',
}}>
{staffNameOf(uuid)}
<button
type="button" aria-label={`حذف ${staffNameOf(uuid)}`}
onClick={() => itemForm.setValue('staff_uuids', selectedStaffUuids.filter((u) => u !== uuid))}
style={{ display: 'grid', placeItems: 'center', width: 16, height: 16, border: 'none', cursor: 'pointer', borderRadius: '50%', background: 'var(--surface-3)', color: 'var(--text-3)' }}
>
<XMarkIcon style={{ width: 11 }} />
</button>
</span>
))}
</div>
)}
</div>
</div>
@@ -563,8 +611,10 @@ function ClinicServicesPageInner() {
export default function ClinicServicesPage() {
return (
<FeatureGate feature="services">
<ClinicServicesPageInner />
</FeatureGate>
<SettingsLayout active="services">
<FeatureGate feature="services">
<ClinicServicesPageInner />
</FeatureGate>
</SettingsLayout>
);
}
+5
View File
@@ -421,13 +421,18 @@ export interface ServiceSection {
uuid: string;
name: string;
active: boolean;
/** number of services in this section (only present in the list endpoint) */
items_count?: number;
}
export interface ServiceItem {
uuid: string;
name: string;
price_rials: number;
/** primary staff (first member) — kept for backward compatibility */
staff: { uuid: string; full_name: string } | null;
/** all personnel assigned to this service */
staff_members?: { uuid: string; full_name: string }[];
active: boolean;
insurance_covered?: boolean;
insurance_price_rials?: number | null;