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')) {
+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);