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
@@ -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')) {