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:
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user