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;
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
"entity_id": 5,
|
||||
"name": "آزمایشگاه",
|
||||
"active": true,
|
||||
"items_count": 10,
|
||||
"created_at": 1718000000,
|
||||
"updated_at": 1718000000
|
||||
}
|
||||
@@ -30,6 +31,8 @@
|
||||
}
|
||||
```
|
||||
|
||||
> `items_count` تعداد سرویسهای همان بخش است (فقط در این endpoint لیستی برگردانده میشود).
|
||||
|
||||
---
|
||||
|
||||
## POST /api/v1/service-section
|
||||
@@ -87,6 +90,11 @@
|
||||
"section_uuid": "...",
|
||||
"staff_uuid": "...",
|
||||
"staff_name": "علی محمدی",
|
||||
"staff": { "uuid": "...", "full_name": "علی محمدی" },
|
||||
"staff_members": [
|
||||
{ "uuid": "...", "full_name": "علی محمدی" },
|
||||
{ "uuid": "...", "full_name": "سحر رحمانی" }
|
||||
],
|
||||
"name": "رادیوگرافی مستقیم",
|
||||
"price_rials": 500000,
|
||||
"active": true,
|
||||
@@ -126,7 +134,8 @@
|
||||
| section_uuid | UUID | ✅ |
|
||||
| name | string | ✅ |
|
||||
| price_rials | integer | ❌ (پیشفرض 0) — «قیمت پایه» |
|
||||
| staff_uuid | UUID | ❌ — پرسنل مسئول |
|
||||
| staff_uuids | UUID[] | ❌ — پرسنل مسئول (چند نفر). ترجیح داده میشود |
|
||||
| staff_uuid | UUID | ❌ — legacy تکپرسنل (اگر `staff_uuids` نباشد استفاده میشود) |
|
||||
| insurance_covered | boolean | ❌ (پیشفرض false) — آیا خدمت شامل بیمه میشود |
|
||||
| insurance_price_rials | integer\|null | ❌ — سهم/قیمت بیمار با بیمه |
|
||||
| duration_minutes | integer\|null | ❌ — «زمان متوسط» انجام خدمت به دقیقه (`""`/`null` = بدون مقدار) |
|
||||
@@ -155,7 +164,7 @@
|
||||
|
||||
> فیلد `duration_minutes` (زمان متوسط، دقیقه) در پاسخِ `toArray` و در ساخت/ویرایش پشتیبانی میشود؛ `""`/`null` آن را پاک میکند.
|
||||
|
||||
> `staff_uuid` باید به پرسنل متعلق به همان tenant (`entity_type`/`entity_id` کاربر) اشاره کند؛ ربطدادن پرسنل tenant دیگر → `422 ERR_VALIDATION_001` (`field: staff_uuid`). همین قید روی `POST /service-item` نیز اعمال میشود.
|
||||
> **پرسنل چندنفره:** یک سرویس میتواند چند پرسنل داشته باشد. `staff_uuids` (آرایه) ترجیح داده میشود؛ در نبود آن، `staff_uuid` تکنفره بهصورت backward-compatible پذیرفته میشود. پاسخ همیشه `staff_members[]` (کامل) و `staff`/`staff_uuid`/`staff_name` (نفر اول، برای سازگاری) را برمیگرداند. هر پرسنل باید متعلق به همان tenant (`entity_type`/`entity_id`) باشد؛ در غیر این صورت → `422 ERR_VALIDATION_001` (`field: staff_uuids`). همین قید روی `POST /service-item` نیز اعمال میشود.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
/**
|
||||
* Auto-generated Migration: Please modify to your needs!
|
||||
*/
|
||||
final class Version20260713093327 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return '';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
// this up() migration is auto-generated, please modify it to your needs
|
||||
$this->addSql('CREATE TABLE service_item_staff (service_item_id INT NOT NULL, clinic_staff_id INT NOT NULL, INDEX IDX_FE98D1F6DDEB00C2 (service_item_id), INDEX IDX_FE98D1F6BE704C14 (clinic_staff_id), PRIMARY KEY (service_item_id, clinic_staff_id)) DEFAULT CHARACTER SET utf8mb4');
|
||||
$this->addSql('ALTER TABLE service_item_staff ADD CONSTRAINT FK_FE98D1F6DDEB00C2 FOREIGN KEY (service_item_id) REFERENCES service_items (id) ON DELETE CASCADE');
|
||||
$this->addSql('ALTER TABLE service_item_staff ADD CONSTRAINT FK_FE98D1F6BE704C14 FOREIGN KEY (clinic_staff_id) REFERENCES clinic_staff (id) ON DELETE CASCADE');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
// this down() migration is auto-generated, please modify it to your needs
|
||||
$this->addSql('ALTER TABLE service_item_staff DROP FOREIGN KEY FK_FE98D1F6DDEB00C2');
|
||||
$this->addSql('ALTER TABLE service_item_staff DROP FOREIGN KEY FK_FE98D1F6BE704C14');
|
||||
$this->addSql('DROP TABLE service_item_staff');
|
||||
}
|
||||
}
|
||||
@@ -50,9 +50,12 @@ class ClinicServiceController extends BaseController
|
||||
[$entityType, $entityId] = $this->resolveEntity($user);
|
||||
$this->assertServicesGate($entityType, $entityId);
|
||||
|
||||
$sectionEntities = $this->sectionRepo->findByEntity($entityType, $entityId);
|
||||
$counts = $this->itemRepo->countBySections($sectionEntities);
|
||||
|
||||
$sections = array_map(
|
||||
fn(ServiceSection $s) => $s->toArray(),
|
||||
$this->sectionRepo->findByEntity($entityType, $entityId)
|
||||
fn(ServiceSection $s) => $s->toArray($counts[$s->getUuid()] ?? 0),
|
||||
$sectionEntities
|
||||
);
|
||||
|
||||
return $this->success($sections);
|
||||
@@ -158,12 +161,9 @@ class ClinicServiceController extends BaseController
|
||||
|
||||
$item = new ServiceItem($section, $name, (int) ($data['price_rials'] ?? 0));
|
||||
|
||||
if (!empty($data['staff_uuid'])) {
|
||||
$staff = $this->staffRepo->findByUuid($data['staff_uuid']);
|
||||
if ($staff === null || $staff->getEntityType() !== $entityType || $staff->getEntityId() !== $entityId) {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'پرسنل انتخابشده متعلق به شما نیست', 422, 'staff_uuid');
|
||||
}
|
||||
$item->setStaff($staff);
|
||||
$staffError = $this->applyStaffMembers($item, $data, $entityType, $entityId);
|
||||
if ($staffError !== null) {
|
||||
return $staffError;
|
||||
}
|
||||
|
||||
if (isset($data['insurance_covered'])) {
|
||||
@@ -201,15 +201,11 @@ class ClinicServiceController extends BaseController
|
||||
if (isset($data['name']) && trim($data['name']) !== '') { $item->setName(trim($data['name'])); }
|
||||
if (isset($data['price_rials'])) { $item->setPriceRials((int) $data['price_rials']); $priceChanged = true; }
|
||||
if (isset($data['active'])) { $item->setActive((bool) $data['active']); }
|
||||
if (array_key_exists('staff_uuid', $data)) {
|
||||
$staff = null;
|
||||
if ($data['staff_uuid']) {
|
||||
$staff = $this->staffRepo->findByUuid($data['staff_uuid']);
|
||||
if ($staff === null || $staff->getEntityType() !== $entityType || $staff->getEntityId() !== $entityId) {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'پرسنل انتخابشده متعلق به شما نیست', 422, 'staff_uuid');
|
||||
}
|
||||
if (array_key_exists('staff_uuids', $data) || array_key_exists('staff_uuid', $data)) {
|
||||
$staffError = $this->applyStaffMembers($item, $data, $entityType, $entityId);
|
||||
if ($staffError !== null) {
|
||||
return $staffError;
|
||||
}
|
||||
$item->setStaff($staff);
|
||||
}
|
||||
if (isset($data['insurance_covered'])) {
|
||||
$item->setInsuranceCovered((bool) $data['insurance_covered']);
|
||||
@@ -312,6 +308,34 @@ class ClinicServiceController extends BaseController
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Resolve and assign the service's personnel from the payload, scoped to the
|
||||
* tenant. Accepts `staff_uuids` (array, preferred) or the legacy single
|
||||
* `staff_uuid`. Returns a 422 JsonResponse if any staff is missing or not
|
||||
* owned by the tenant, otherwise null.
|
||||
*/
|
||||
private function applyStaffMembers(ServiceItem $item, array $data, string $entityType, int $entityId): ?JsonResponse
|
||||
{
|
||||
$uuids = [];
|
||||
if (array_key_exists('staff_uuids', $data) && is_array($data['staff_uuids'])) {
|
||||
$uuids = $data['staff_uuids'];
|
||||
} elseif (!empty($data['staff_uuid'])) {
|
||||
$uuids = [$data['staff_uuid']];
|
||||
}
|
||||
|
||||
$members = [];
|
||||
foreach (array_values(array_unique(array_filter($uuids))) as $uuid) {
|
||||
$staff = $this->staffRepo->findByUuid((string) $uuid);
|
||||
if ($staff === null || $staff->getEntityType() !== $entityType || $staff->getEntityId() !== $entityId) {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'پرسنل انتخابشده متعلق به شما نیست', 422, 'staff_uuids');
|
||||
}
|
||||
$members[] = $staff;
|
||||
}
|
||||
|
||||
$item->setStaffMembers($members);
|
||||
return null;
|
||||
}
|
||||
|
||||
private function resolveEntity(User $user): array
|
||||
{
|
||||
if ($user->hasRole('ROLE_DOCTOR')) {
|
||||
|
||||
@@ -4,6 +4,8 @@ namespace App\ClinicService\Entity;
|
||||
|
||||
use App\ClinicService\Repository\ServiceItemRepository;
|
||||
use App\Staff\Entity\ClinicStaff;
|
||||
use Doctrine\Common\Collections\ArrayCollection;
|
||||
use Doctrine\Common\Collections\Collection;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Component\Uid\Uuid;
|
||||
|
||||
@@ -23,10 +25,21 @@ class ServiceItem
|
||||
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
|
||||
private ServiceSection $section;
|
||||
|
||||
// Legacy single-staff column, kept for backward compatibility with existing
|
||||
// consumers (reception/session). Mirrors the first entry of $staffMembers.
|
||||
#[ORM\ManyToOne(targetEntity: ClinicStaff::class)]
|
||||
#[ORM\JoinColumn(nullable: true, onDelete: 'SET NULL')]
|
||||
private ?ClinicStaff $staff = null;
|
||||
|
||||
/**
|
||||
* @var Collection<int, ClinicStaff> personnel assigned to this service.
|
||||
* EAGER so hydration always populates the typed property (avoids the
|
||||
* "accessed before initialization" pitfall on lazy typed collections).
|
||||
*/
|
||||
#[ORM\ManyToMany(targetEntity: ClinicStaff::class, fetch: 'EAGER')]
|
||||
#[ORM\JoinTable(name: 'service_item_staff')]
|
||||
private Collection $staffMembers;
|
||||
|
||||
#[ORM\Column(type: 'string', length: 200)]
|
||||
private string $name;
|
||||
|
||||
@@ -59,6 +72,7 @@ class ServiceItem
|
||||
$this->priceRials = $priceRials;
|
||||
$this->createdAt = time();
|
||||
$this->updatedAt = time();
|
||||
$this->staffMembers = new ArrayCollection();
|
||||
}
|
||||
|
||||
public function getId(): ?int { return $this->id; }
|
||||
@@ -75,6 +89,33 @@ class ServiceItem
|
||||
public function getUpdatedAt(): int { return $this->updatedAt; }
|
||||
|
||||
public function setStaff(?ClinicStaff $staff): self { $this->staff = $staff; $this->updatedAt = time(); return $this; }
|
||||
|
||||
/** @return Collection<int, ClinicStaff> */
|
||||
public function getStaffMembers(): Collection
|
||||
{
|
||||
// Doctrine hydrates without the constructor; guard the typed property.
|
||||
return $this->staffMembers ??= new ArrayCollection();
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace the assigned personnel. Also mirrors the first member into the
|
||||
* legacy single {@see $staff} column so back-compat consumers keep working.
|
||||
*
|
||||
* @param ClinicStaff[] $members
|
||||
*/
|
||||
public function setStaffMembers(array $members): self
|
||||
{
|
||||
$collection = $this->getStaffMembers();
|
||||
$collection->clear();
|
||||
foreach ($members as $m) {
|
||||
if (!$collection->contains($m)) {
|
||||
$collection->add($m);
|
||||
}
|
||||
}
|
||||
$this->staff = $members[0] ?? null;
|
||||
$this->updatedAt = time();
|
||||
return $this;
|
||||
}
|
||||
public function setName(string $name): self { $this->name = $name; $this->updatedAt = time(); return $this; }
|
||||
public function setPriceRials(int $price): self { $this->priceRials = $price; $this->updatedAt = time(); return $this; }
|
||||
public function setActive(bool $active): self { $this->active = $active; $this->updatedAt = time(); return $this; }
|
||||
@@ -84,14 +125,26 @@ class ServiceItem
|
||||
|
||||
public function toArray(): array
|
||||
{
|
||||
// Prefer the multi-staff collection; fall back to the legacy single
|
||||
// staff so rows created before the migration still expose personnel.
|
||||
$members = array_values($this->getStaffMembers()->toArray());
|
||||
if (empty($members) && $this->staff !== null) {
|
||||
$members = [$this->staff];
|
||||
}
|
||||
$primary = $members[0] ?? null;
|
||||
|
||||
return [
|
||||
'uuid' => $this->uuid,
|
||||
'section_uuid' => $this->section->getUuid(),
|
||||
'staff_uuid' => $this->staff?->getUuid(),
|
||||
'staff_name' => $this->staff?->getFullName(),
|
||||
'staff' => $this->staff !== null
|
||||
? ['uuid' => $this->staff->getUuid(), 'full_name' => $this->staff->getFullName()]
|
||||
'staff_uuid' => $primary?->getUuid(),
|
||||
'staff_name' => $primary?->getFullName(),
|
||||
'staff' => $primary !== null
|
||||
? ['uuid' => $primary->getUuid(), 'full_name' => $primary->getFullName()]
|
||||
: null,
|
||||
'staff_members' => array_map(
|
||||
fn(ClinicStaff $s) => ['uuid' => $s->getUuid(), 'full_name' => $s->getFullName()],
|
||||
$members
|
||||
),
|
||||
'name' => $this->name,
|
||||
'price_rials' => $this->priceRials,
|
||||
'active' => $this->active,
|
||||
|
||||
@@ -65,9 +65,13 @@ class ServiceSection
|
||||
public function setName(string $name): self { $this->name = $name; $this->updatedAt = time(); return $this; }
|
||||
public function setActive(bool $active): self { $this->active = $active; $this->updatedAt = time(); return $this; }
|
||||
|
||||
public function toArray(): array
|
||||
/**
|
||||
* @param int|null $itemsCount when provided, added as `items_count`
|
||||
* (number of services in this section)
|
||||
*/
|
||||
public function toArray(?int $itemsCount = null): array
|
||||
{
|
||||
return [
|
||||
$data = [
|
||||
'uuid' => $this->uuid,
|
||||
'entity_type' => $this->entityType,
|
||||
'entity_id' => $this->entityId,
|
||||
@@ -76,5 +80,11 @@ class ServiceSection
|
||||
'created_at' => $this->createdAt,
|
||||
'updated_at' => $this->updatedAt,
|
||||
];
|
||||
|
||||
if ($itemsCount !== null) {
|
||||
$data['items_count'] = $itemsCount;
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,6 +29,34 @@ class ServiceItemRepository extends ServiceEntityRepository
|
||||
->getResult();
|
||||
}
|
||||
|
||||
/**
|
||||
* Count services per section in a single query (avoids N+1 in the section list).
|
||||
*
|
||||
* @param ServiceSection[] $sections
|
||||
* @return array<string,int> section uuid → number of services
|
||||
*/
|
||||
public function countBySections(array $sections): array
|
||||
{
|
||||
if (empty($sections)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$rows = $this->createQueryBuilder('i')
|
||||
->select('s.uuid AS uuid, COUNT(i.id) AS cnt')
|
||||
->join('i.section', 's')
|
||||
->where('i.section IN (:sections)')
|
||||
->setParameter('sections', $sections)
|
||||
->groupBy('s.uuid')
|
||||
->getQuery()
|
||||
->getScalarResult();
|
||||
|
||||
$counts = [];
|
||||
foreach ($rows as $row) {
|
||||
$counts[$row['uuid']] = (int) $row['cnt'];
|
||||
}
|
||||
return $counts;
|
||||
}
|
||||
|
||||
public function save(ServiceItem $item): void
|
||||
{
|
||||
$this->getEntityManager()->persist($item);
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tests\ClinicService;
|
||||
|
||||
use App\ClinicService\Entity\ServiceItem;
|
||||
use App\ClinicService\Entity\ServiceSection;
|
||||
use App\ClinicService\Repository\ServiceItemRepository;
|
||||
use App\Doctor\Entity\Doctor;
|
||||
use App\Staff\Entity\ClinicStaff;
|
||||
use App\Tests\ApiTestCase;
|
||||
|
||||
/**
|
||||
* A service can carry multiple personnel (staffMembers). toArray must expose
|
||||
* them as `staff_members`, keep the legacy single `staff` (first member) for
|
||||
* back-compat, and the section list must report `items_count`.
|
||||
*/
|
||||
class ServiceItemMultiStaffTest extends ApiTestCase
|
||||
{
|
||||
public function testMultiStaffToArrayAndSectionCount(): void
|
||||
{
|
||||
$owner = $this->createUser(['ROLE_DOCTOR']);
|
||||
$doctor = new Doctor($owner, 'دکتر');
|
||||
$this->em->persist($doctor);
|
||||
$this->em->flush();
|
||||
|
||||
$s1 = new ClinicStaff('doctor', $doctor->getId(), 'مریم امینی');
|
||||
$s2 = new ClinicStaff('doctor', $doctor->getId(), 'سحر رحمانی');
|
||||
$this->em->persist($s1);
|
||||
$this->em->persist($s2);
|
||||
$this->em->flush();
|
||||
|
||||
$section = new ServiceSection('doctor', $doctor->getId(), 'کندلا');
|
||||
$item = new ServiceItem($section, 'فول بادی', 3_500_000);
|
||||
$item->setStaffMembers([$s1, $s2]); // staff already managed (as in the controller)
|
||||
$this->em->persist($section);
|
||||
$this->em->persist($item);
|
||||
$this->em->flush();
|
||||
$sectionUuid = $section->getUuid();
|
||||
$itemUuid = $item->getUuid();
|
||||
$this->em->clear();
|
||||
|
||||
/** @var ServiceItemRepository $repo */
|
||||
$repo = static::getContainer()->get(ServiceItemRepository::class);
|
||||
$reloaded = $repo->findByUuid($itemUuid); // uuid — db_test is never reset
|
||||
self::assertNotNull($reloaded);
|
||||
self::assertCount(2, $reloaded->getStaffMembers());
|
||||
|
||||
$arr = $reloaded->toArray();
|
||||
self::assertCount(2, $arr['staff_members']);
|
||||
// primary (legacy single) mirrors the first member
|
||||
self::assertSame('مریم امینی', $arr['staff']['full_name']);
|
||||
|
||||
// batch count
|
||||
$sectionEntity = $reloaded->getSection();
|
||||
$counts = $repo->countBySections([$sectionEntity]);
|
||||
self::assertSame(1, $counts[$sectionUuid]);
|
||||
|
||||
// endpoint exposes items_count
|
||||
$resp = $this->authJson('GET', '/api/v1/service-sections', $owner);
|
||||
self::assertSame(200, $this->responseCode());
|
||||
$row = $resp['data'][0] ?? [];
|
||||
self::assertSame(1, $row['items_count'] ?? null);
|
||||
}
|
||||
|
||||
public function testCreateAndUpdateItemWithMultipleStaffViaApi(): void
|
||||
{
|
||||
$owner = $this->createUser(['ROLE_DOCTOR']);
|
||||
$doctor = new Doctor($owner, 'دکتر');
|
||||
$this->em->persist($doctor);
|
||||
$this->em->flush();
|
||||
|
||||
$a = new ClinicStaff('doctor', $doctor->getId(), 'الف');
|
||||
$b = new ClinicStaff('doctor', $doctor->getId(), 'ب');
|
||||
$c = new ClinicStaff('doctor', $doctor->getId(), 'ج');
|
||||
$section = new ServiceSection('doctor', $doctor->getId(), 'بخش');
|
||||
foreach ([$a, $b, $c, $section] as $e) { $this->em->persist($e); }
|
||||
$this->em->flush();
|
||||
|
||||
// create with two staff members
|
||||
$created = $this->authJson('POST', '/api/v1/service-item', $owner, [
|
||||
'section_uuid' => $section->getUuid(),
|
||||
'name' => 'سرویس چندنفره',
|
||||
'price_rials' => 1_000,
|
||||
'staff_uuids' => [$a->getUuid(), $b->getUuid()],
|
||||
]);
|
||||
self::assertSame(201, $this->responseCode());
|
||||
self::assertCount(2, $created['data']['staff_members']);
|
||||
|
||||
// update: replace with a single different staff
|
||||
$itemUuid = $created['data']['uuid'];
|
||||
$updated = $this->authJson('PATCH', '/api/v1/service-item/' . $itemUuid, $owner, [
|
||||
'staff_uuids' => [$c->getUuid()],
|
||||
]);
|
||||
self::assertSame(200, $this->responseCode());
|
||||
self::assertCount(1, $updated['data']['staff_members']);
|
||||
self::assertSame('ج', $updated['data']['staff_members'][0]['full_name']);
|
||||
}
|
||||
|
||||
public function testLegacySingleStaffFallsBackInToArray(): void
|
||||
{
|
||||
$doctor = new Doctor($this->createUser(['ROLE_DOCTOR']), 'دکتر');
|
||||
$this->em->persist($doctor);
|
||||
$this->em->flush();
|
||||
|
||||
$staff = new ClinicStaff('doctor', $doctor->getId(), 'کاربر قدیمی');
|
||||
$section = new ServiceSection('doctor', $doctor->getId(), 'بخش');
|
||||
$item = new ServiceItem($section, 'خدمت قدیمی');
|
||||
$item->setStaff($staff); // legacy single-staff path, no members
|
||||
$this->em->persist($staff);
|
||||
$this->em->persist($section);
|
||||
$this->em->persist($item);
|
||||
$this->em->flush();
|
||||
|
||||
$arr = $item->toArray();
|
||||
self::assertCount(1, $arr['staff_members']);
|
||||
self::assertSame('کاربر قدیمی', $arr['staff_members'][0]['full_name']);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user