feat(treatment): search and edit for treatment cases

The list had no way to tell two cases apart. TreatmentCase::toArray() carried
no patient, so four cases of the same service rendered as four identical
cards — same service, same supervisor, same date, same areas. Search would have
been meaningless without fixing that first, so the payload now carries the
patient (name, mobile, record number) and the card leads with the name.

Search: `?q=` on the list endpoint, matching patient name, mobile, national
code, record number and service name — the same keys a secretary already types
into the booking form. It lives in the URL via useUrlState, debounced, so back
and refresh keep the view.

Edit: PATCH /api/v1/treatment-case/{uuid} covering status, supervising doctor,
areas and session count, driven from a modal on the list. Rules live in
TreatmentCaseEditor, not the controller, around one boundary: no edit may
overwrite work already done. An area with session records cannot be removed, and
the session count cannot drop below the sessions that are booked or finished —
both 409, both tested. Reopening a closed case clears closed_at.

`areas[]` now also exposes `category_uuid`; the edit form selects catalog
categories, while `uuid` identifies the snapshot row.

Page fixes from the redesign checklist: the status filter was a hand-rolled
primary/secondary button pair, now `.seg` with `.on`; the raw `<progress>` bar
took the browser's own appearance and ignored the theme tokens, now a token-
styled bar with an explicit progressbar role; session counts go through
formatNumber; a failed request rendered as "no cases found", which reads as an
empty clinic rather than a broken one, and an empty search now says so in its
own words.

Adds the test files neither the page nor the case editor had.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
hamed
2026-08-07 13:48:57 +03:30
co-authored by Claude Opus 5
parent 00349cdb44
commit 952e09bd6a
11 changed files with 1120 additions and 58 deletions
@@ -3,6 +3,7 @@
namespace App\Treatment\Controller;
use App\Auth\Entity\User;
use App\ClinicService\Entity\CatalogCategory;
use App\Doctor\Service\AddressResolver;
use App\Resource\Repository\ClinicResourceRepository;
use App\Shared\Constant\ErrorCodes;
@@ -14,6 +15,8 @@ use App\Treatment\Entity\TreatmentSession;
use App\Treatment\Repository\TreatmentCaseRepository;
use App\Treatment\Repository\TreatmentSessionRepository;
use App\Treatment\Service\NextSessionSlotFinder;
use App\Treatment\Service\TreatmentCaseEditor;
use App\Treatment\Service\TreatmentCaseOpener;
use OpenApi\Attributes as OA;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
@@ -32,6 +35,8 @@ class TreatmentCaseController extends BaseController
private readonly NextSessionSlotFinder $slotFinder,
private readonly TenantOwnershipChecker $ownership,
private readonly AddressResolver $branches,
private readonly TreatmentCaseOpener $opener,
private readonly TreatmentCaseEditor $editor,
) {}
#[Route('/api/v1/treatment-cases', name: 'treatment_case_list', methods: ['GET'])]
@@ -42,16 +47,50 @@ class TreatmentCaseController extends BaseController
$status = $request->query->get('status');
$status = is_string($status) && $status !== '' ? $status : null;
$q = $request->query->get('q');
$q = is_string($q) ? trim($q) : '';
return $this->success(array_map(
static fn (TreatmentCase $c): array => $c->toArray(),
$this->cases->findForTenant($entityType, $entityId, $status),
$this->cases->findForTenant($entityType, $entityId, $status, $q !== '' ? $q : null),
));
}
#[Route('/api/v1/treatment-case/{uuid}', name: 'treatment_case_show', methods: ['GET'])]
public function show(#[CurrentUser] User $user, string $uuid): JsonResponse
{
return $this->success($this->requireCase($user, $uuid)->toArray(withSessions: true));
$case = $this->requireCase($user, $uuid);
/**
* نواحیِ قابل انتخاب کنار خودِ پرونده می‌آید، وگرنه فرم ویرایش باید حدس بزند
* کدام دسته‌ها مجازند یا اندپوینت دومی برای همان یک سؤال ساخته شود.
*/
return $this->success($case->toArray(withSessions: true) + [
'available_areas' => array_map(
static fn (CatalogCategory $c): array => ['uuid' => $c->getUuid(), 'name' => $c->getName()],
$this->opener->resolveAreas($case->getServiceItem()),
),
]);
}
/**
* ویرایش پروندهٔ باز — وضعیت، پزشک ناظر، نواحی و تعداد جلسات.
*
* قواعدش در {@see TreatmentCaseEditor} است نه اینجا: هیچ ویرایشی نباید سابقهٔ
* انجام‌شده را بازنویسی کند و آن تصمیم جای کنترلر نیست.
*/
#[Route('/api/v1/treatment-case/{uuid}', name: 'treatment_case_update', methods: ['PATCH'])]
public function update(#[CurrentUser] User $user, string $uuid, Request $request): JsonResponse
{
$data = json_decode($request->getContent(), true);
if (!is_array($data)) {
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'بدنهٔ درخواست نامعتبر است', 422);
}
$case = $this->editor->update($this->requireCase($user, $uuid), $data);
return $this->success($case->toArray(withSessions: true));
}
/**
+58
View File
@@ -155,6 +155,54 @@ class TreatmentCase
return $this;
}
/** پرونده‌ای که اشتباه بسته شده دوباره باز می‌شود؛ `closedAt` باید پاک شود وگرنه «بستهٔ فعال» می‌ماند. */
public function reopen(): self
{
$this->status = self::STATUS_ACTIVE;
$this->closedAt = null;
$this->touch();
return $this;
}
public function setSupervisorDoctor(?Doctor $doctor): self
{
$this->supervisorDoctor = $doctor;
$this->touch();
return $this;
}
/**
* تعداد جلساتِ همین پرونده، مستقل از پروتکل سرویس.
*
* پروتکل الگوی پیش‌فرض است نه قرارداد: بیمار ممکن است به جلسهٔ کمتر یا بیشتر
* نیاز داشته باشد بدون اینکه سرویس برای بقیه عوض شود.
*/
public function setTotalSessions(int $total): self
{
$this->totalSessions = $total;
$this->touch();
return $this;
}
public function removeArea(TreatmentCaseArea $area): self
{
$this->areas->removeElement($area);
$this->touch();
return $this;
}
public function removeSession(TreatmentSession $session): self
{
$this->sessions->removeElement($session);
$this->touch();
return $this;
}
public function toArray(bool $withSessions = false): array
{
$data = [
@@ -168,6 +216,16 @@ class TreatmentCase
'uuid' => $this->serviceItem->getUuid(),
'name' => $this->serviceItem->getName(),
],
/**
* بدون بیمار، دو پروندهٔ یک سرویس از هم قابل تشخیص نیستند — فهرست
* پرونده‌ها بدونش چند کارتِ یکسان است.
*/
'patient' => [
'record_uuid' => $this->patientRecord->getUuid(),
'name' => $this->patientRecord->getUser()->getRealName(),
'mobile' => $this->patientRecord->getUser()->getMobileNumber(),
'record_number' => $this->patientRecord->getRecordNumber(),
],
'supervisor' => $this->supervisorDoctor === null ? null : [
'uuid' => $this->supervisorDoctor->getUuid(),
'name' => $this->supervisorDoctor->getName(),
@@ -62,6 +62,11 @@ class TreatmentCaseArea
return [
'uuid' => $this->uuid,
'name' => $this->nameSnapshot,
/**
* فرم ویرایش با دستهٔ کاتالوگ کار می‌کند نه با این ردیف. `null` یعنی دسته
* حذف شده — ناحیه هنوز در سابقه هست ولی دیگر قابل انتخاب نیست.
*/
'category_uuid' => $this->category?->getUuid(),
];
}
}
@@ -39,8 +39,17 @@ class TreatmentCaseRepository extends ServiceEntityRepository
}
/** @return TreatmentCase[] */
public function findForTenant(string $entityType, int $entityId, ?string $status = null): array
{
/**
* @param ?string $q جستجو روی نام بیمار، موبایل، کد ملی، شمارهٔ پرونده و نام سرویس.
* منشی همان کلیدی را می‌زند که در فرم نوبت می‌زند، پس هر چهار
* شناسهٔ بیمار باید بگیرد نه فقط نام.
*/
public function findForTenant(
string $entityType,
int $entityId,
?string $status = null,
?string $q = null,
): array {
$qb = $this->createQueryBuilder('c')
->where('c.entityType = :type')
->andWhere('c.entityId = :id')
@@ -52,6 +61,17 @@ class TreatmentCaseRepository extends ServiceEntityRepository
$qb->andWhere('c.status = :status')->setParameter('status', $status);
}
if ($q !== null && $q !== '') {
$qb->join('c.patientRecord', 'pr')
->join('pr.user', 'u')
->join('c.serviceItem', 'si')
->andWhere(
'u.realName LIKE :q OR u.mobileNumber LIKE :q OR u.nationalCode LIKE :q'
. ' OR pr.recordNumber LIKE :q OR si.name LIKE :q',
)
->setParameter('q', '%' . $q . '%');
}
return $qb->getQuery()->getResult();
}
}
@@ -0,0 +1,237 @@
<?php
namespace App\Treatment\Service;
use App\ClinicService\Entity\CatalogCategory;
use App\ClinicService\Repository\CatalogCategoryRepository;
use App\Doctor\Repository\DoctorRepository;
use App\Shared\Constant\ErrorCodes;
use App\Shared\Exception\AppException;
use App\Treatment\Entity\TreatmentCase;
use App\Treatment\Entity\TreatmentCaseArea;
use App\Treatment\Entity\TreatmentProtocol;
use App\Treatment\Entity\TreatmentSession;
use Doctrine\ORM\EntityManagerInterface;
/**
* ویرایش پروندهٔ درمانِ باز.
*
* پرونده بعد از باز شدن سند است نه فرم: بیمار وسط دوره پزشک ناظرش عوض می‌شود، ناحیه
* اضافه می‌کند، یا جلسه کم و زیاد می‌شود. ولی هیچ ویرایشی نباید سابقهٔ انجام‌شده را
* بازنویسی کند — همان قاعده‌ای که `TreatmentCaseArea::$nameSnapshot` را ساخت.
*
* پس همهٔ قواعد اینجا جمع‌اند و کنترلر فقط ورودی را عبور می‌دهد.
*/
final class TreatmentCaseEditor
{
public function __construct(
private readonly DoctorRepository $doctors,
private readonly CatalogCategoryRepository $categories,
private readonly EntityManagerInterface $em,
) {}
/**
* @param array<string, mixed> $data
*/
public function update(TreatmentCase $case, array $data): TreatmentCase
{
if (array_key_exists('status', $data)) {
$this->applyStatus($case, (string) $data['status']);
}
if (array_key_exists('supervisor_doctor_uuid', $data)) {
$this->applySupervisor($case, $data['supervisor_doctor_uuid']);
}
if (array_key_exists('area_uuids', $data)) {
$this->applyAreas($case, $data['area_uuids']);
}
if (array_key_exists('total_sessions', $data)) {
$this->applyTotalSessions($case, (int) $data['total_sessions']);
}
$this->em->flush();
return $case;
}
private function applyStatus(TreatmentCase $case, string $status): void
{
$allowed = [TreatmentCase::STATUS_ACTIVE, TreatmentCase::STATUS_COMPLETED, TreatmentCase::STATUS_ABANDONED];
if (!in_array($status, $allowed, true)) {
throw new AppException(ErrorCodes::ERR_VALIDATION_001, 'وضعیت پرونده نامعتبر است', 422, 'status');
}
if ($status === $case->getStatus()) {
return;
}
$status === TreatmentCase::STATUS_ACTIVE ? $case->reopen() : $case->close($status);
}
private function applySupervisor(TreatmentCase $case, mixed $uuid): void
{
if ($uuid === null || $uuid === '') {
$case->setSupervisorDoctor(null);
return;
}
$doctor = $this->doctors->findByUuid((string) $uuid);
if ($doctor === null) {
throw new AppException(ErrorCodes::ERR_NOT_FOUND_001, 'پزشک یافت نشد', 404, 'supervisor_doctor_uuid');
}
$case->setSupervisorDoctor($doctor);
}
/**
* نواحی جایگزین می‌شوند، ولی ناحیه‌ای که جلسه‌ای رویش ثبت شده حذف نمی‌شود.
*
* حذفش یعنی پاک کردن سابقهٔ درمان — `SessionAreaRecord` به همان ردیف اشاره دارد و
* پرونده باید بگوید جلسهٔ قبل روی چه ناحیه‌ای انجام شد.
*
* @param mixed $uuids فهرست uuid دسته‌های کاتالوگ
*/
private function applyAreas(TreatmentCase $case, mixed $uuids): void
{
if (!is_array($uuids)) {
throw new AppException(ErrorCodes::ERR_VALIDATION_001, 'فهرست نواحی نامعتبر است', 422, 'area_uuids');
}
$wanted = array_values(array_unique(array_map('strval', $uuids)));
if ($wanted === []) {
throw new AppException(ErrorCodes::ERR_VALIDATION_001, 'حداقل یک ناحیه لازم است', 422, 'area_uuids');
}
/** @var array<string, CatalogCategory> $categories */
$categories = [];
foreach ($wanted as $uuid) {
$category = $this->categories->findByUuid($uuid);
if ($category === null) {
throw new AppException(ErrorCodes::ERR_NOT_FOUND_001, 'ناحیه یافت نشد', 404, 'area_uuids');
}
$categories[$uuid] = $category;
}
$existing = [];
foreach ($case->getAreas() as $area) {
$key = $area->getCategory()?->getUuid();
if ($key !== null) {
$existing[$key] = $area;
}
}
foreach ($case->getAreas()->toArray() as $area) {
$key = $area->getCategory()?->getUuid();
if ($key !== null && in_array($key, $wanted, true)) {
continue;
}
if ($this->areaHasRecords($area)) {
throw new AppException(
ErrorCodes::ERR_CONFLICT_001,
sprintf('ناحیهٔ «%s» در جلسه‌ای ثبت شده و حذف نمی‌شود', $area->getName()),
409,
'area_uuids',
);
}
$case->removeArea($area);
$this->em->remove($area);
}
$order = 0;
foreach ($wanted as $uuid) {
if (!isset($existing[$uuid])) {
$case->addArea(new TreatmentCaseArea($case, $categories[$uuid], $order));
}
++$order;
}
}
/**
* جلسه اضافه می‌شود یا از انتها کم — ولی هرگز جلسه‌ای که نوبت گرفته یا انجام شده.
*
* کفِ مجاز تعداد جلساتی است که دیگر دست‌نخوردنی‌اند، نه عددی ثابت.
*/
private function applyTotalSessions(TreatmentCase $case, int $total): void
{
if ($total < TreatmentProtocol::MIN_STEPS || $total > TreatmentProtocol::MAX_STEPS) {
throw new AppException(
ErrorCodes::ERR_VALIDATION_001,
sprintf('تعداد جلسات باید بین %d و %d باشد', TreatmentProtocol::MIN_STEPS, TreatmentProtocol::MAX_STEPS),
422,
'total_sessions',
);
}
$sessions = $case->getSessions()->toArray();
usort($sessions, static fn (TreatmentSession $a, TreatmentSession $b): int
=> $a->getSessionNumber() <=> $b->getSessionNumber());
$locked = 0;
foreach ($sessions as $session) {
if ($this->isRemovable($session)) {
continue;
}
$locked = max($locked, $session->getSessionNumber());
}
if ($total < $locked) {
throw new AppException(
ErrorCodes::ERR_CONFLICT_001,
sprintf('%d جلسه انجام شده یا نوبت دارد؛ تعداد کمتر از آن ممکن نیست', $locked),
409,
'total_sessions',
);
}
for ($i = count($sessions) - 1; $i >= 0 && count($sessions) > $total; --$i) {
$session = $sessions[$i];
if ($session->getSessionNumber() <= $total || !$this->isRemovable($session)) {
continue;
}
$case->removeSession($session);
$this->em->remove($session);
array_splice($sessions, $i, 1);
}
for ($number = count($sessions) + 1; $number <= $total; ++$number) {
$case->addSession(new TreatmentSession($case, $number));
}
$case->setTotalSessions($total);
}
private function areaHasRecords(TreatmentCaseArea $area): bool
{
return (int) $this->em->createQuery(
'SELECT COUNT(r.id) FROM App\Treatment\Entity\SessionAreaRecord r WHERE r.caseArea = :area',
)->setParameter('area', $area)->getSingleScalarResult() > 0;
}
/** جلسه‌ای که نه نوبت دارد نه شروع شده، هنوز فقط یک برنامه است. */
private function isRemovable(TreatmentSession $session): bool
{
return $session->getAppointment() === null
&& $session->getStartedAt() === null
&& in_array($session->getStatus(), [TreatmentSession::STATUS_PLANNED, TreatmentSession::STATUS_CANCELLED], true);
}
}