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