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();
});
});
+92 -42
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,10 +386,14 @@ 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
{(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>
@@ -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 (
<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;
+11 -2
View File
@@ -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` نیز اعمال می‌شود.
---
+35
View File
@@ -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,16 +201,12 @@ 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')) {
+57 -4
View File
@@ -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,
+12 -2
View File
@@ -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']);
}
}