Manage a resource's services from the panel

A "سرویس‌ها" action on each resource row opens a modal listing what that
resource performs, with its own duration and price. It follows the skills modal
exactly — same PUT-replaces-everything contract, same components, no new page
and no new route.

Leaving a cell empty means inherit, so the effective value is shown as the
placeholder along with where it came from: "40 — service default", "1,800,000
toman — branch". Without that the user cannot tell an unset field from a zero,
which is the one thing this screen has to communicate.

Two backend adjustments came out of wiring it up:

- the offering filter in findEligible is now scoped to the requirement's
  resource type. Registering lasers for a service was making the room in the
  same plan ineligible and breaking the whole booking — "who performs this" is
  about the performing role, not about rooms and support resources. The seeder
  caught this immediately.
- the scenario seeder now creates offerings and resource categories, so the
  demo data exercises this model instead of leaving every resource empty.

Three vitest tests: inherited value with its source, saving an override, and
clearing back to inheritance. Verified in the browser at 1440 dark, 1440
compact and 390 mobile — the last with no horizontal scroll.

Panel suite 648 green across 98 files, tsc clean, encore build succeeds.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
hamed
2026-08-01 22:57:55 +03:30
co-authored by Claude Opus 5
parent a6acf3bfe2
commit 26425bec31
10 changed files with 463 additions and 35 deletions
@@ -5,6 +5,7 @@ namespace App\Resource\Repository;
use App\ClinicService\Entity\ServiceItem;
use App\Resource\Entity\ClinicResource;
use App\Resource\Entity\ResourceServiceOffering;
use App\Resource\Entity\ResourceType;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
@@ -43,27 +44,42 @@ class ResourceServiceOfferingRepository extends ServiceEntityRepository
* رابطه‌ها را پر نکرده نباید یک‌شبه بدون وقت آزاد شود، پس فیلتر فقط وقتی اعمال
* می‌شود که کلینیک دست‌کم یک ردیف برای آن سرویس ساخته باشد.
*/
public function hasAnyFor(ServiceItem $item): bool
public function hasAnyFor(ServiceItem $item, ?ResourceType $type = null): bool
{
return (bool) $this->createQueryBuilder('o')
$qb = $this->createQueryBuilder('o')
->select('1')
->where('o.serviceItem = :item')
->setParameter('item', $item)
->setMaxResults(1)
->getQuery()
->getOneOrNullResult();
->setMaxResults(1);
/**
* محدود به نوعِ منبع، وقتی نوع داده شده.
*
* «کدام منبع این سرویس را می‌دهد» دربارهٔ نقشِ انجام‌دهنده است، نه دربارهٔ اتاق و
* پشتیبان‌ها. بدون این قید، ثبت رابطه برای دستگاه‌ها باعث می‌شد اتاقِ همان
* برنامه ناگهان «واجد شرایط نباشد» و کل برنامه بشکند.
*/
if ($type !== null) {
$qb->join('o.resource', 'r')->andWhere('r.type = :type')->setParameter('type', $type);
}
return (bool) $qb->getQuery()->getOneOrNullResult();
}
/** @return int[] شناسهٔ منابعی که این سرویس را فعال ارائه می‌دهند */
public function activeResourceIdsFor(ServiceItem $item): array
public function activeResourceIdsFor(ServiceItem $item, ?ResourceType $type = null): array
{
$rows = $this->createQueryBuilder('o')
$qb = $this->createQueryBuilder('o')
->select('IDENTITY(o.resource) AS resource_id')
->where('o.serviceItem = :item')
->andWhere('o.active = true')
->setParameter('item', $item)
->getQuery()
->getArrayResult();
->setParameter('item', $item);
if ($type !== null) {
$qb->join('o.resource', 'r')->andWhere('r.type = :type')->setParameter('type', $type);
}
$rows = $qb->getQuery()->getArrayResult();
return array_map(static fn (array $row): int => (int) $row['resource_id'], $rows);
}