feat(patients): phase A — records list + create/edit form (Figma)

Rebuild the patient records (پرونده‌ها) area, phase A of the Figma redesign:

- BE: add a clinic-scoped `record_number` and a TenantTag `tags` M2M to
  PatientRecord (migration + EAGER-hydrated collection). POST /patient and
  PATCH /patient/{uuid} now accept `record_number` and tenant-scoped `tags`
  (foreign tag → 422); demographic fields (gender, date_of_birth,
  referral_source, description) continue to live on UserProfile via PATCH.
- FE: new PatientsListPage (table + card views, search, pagination, tags
  column, "تشکیل پرونده") at /admin/patients, and PatientRecordFormPage
  (create/edit) that POSTs the record then PATCHes the demographics. Point
  the sidebar "پرونده" entry to the new list.

Phases B–E (tabbed patient file, service stepper, invoice, payments/wallet,
call-center) follow. Backend covered by PHPUnit, FE by Vitest.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
hamed
2026-07-13 14:54:38 +03:30
co-authored by Claude Opus 4.8
parent c46800610f
commit 15c6f5dce7
13 changed files with 671 additions and 4 deletions
@@ -49,9 +49,33 @@ class PatientController extends BaseController
private readonly InvoiceRepository $invoiceRepo,
private readonly \App\Appointment\Repository\AppointmentRepository $appointmentRepo,
private readonly \App\ClinicInvitation\Repository\ClinicDoctorInvitationRepository $invitationRepo,
private readonly \App\Tag\Repository\TenantTagRepository $tenantTagRepo,
private readonly LoggerInterface $logger,
) {}
/**
* Assign record labels from the payload (`tags` = array of TenantTag uuids),
* scoped to the caller's entity. Returns a 422 response on a foreign tag,
* otherwise null. Does nothing when `tags` is absent.
*/
private function applyRecordTags(PatientRecord $record, array $data, string $entityType, int $entityId): ?JsonResponse
{
if (!array_key_exists('tags', $data)) {
return null;
}
$uuids = is_array($data['tags']) ? $data['tags'] : [];
$tags = [];
foreach (array_values(array_unique(array_filter($uuids))) as $uuid) {
$tag = $this->tenantTagRepo->findByUuid((string) $uuid);
if ($tag === null || $tag->getEntityType() !== $entityType || $tag->getEntityId() !== $entityId) {
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'برچسب انتخاب‌شده متعلق به شما نیست', 422, 'tags');
}
$tags[] = $tag;
}
$record->setTags($tags);
return null;
}
private function buildPatientProfile(User $patient): array
{
$p = $this->profileRepo->findByUser($patient);
@@ -196,6 +220,15 @@ class PatientController extends BaseController
}
$record = new PatientRecord($entityType, $entityId, $patient, $user->hasRole('ROLE_DOCTOR') ? 'doctor' : 'clinic', $entityId);
if (($rn = trim((string) ($data['record_number'] ?? ''))) !== '') {
$record->setRecordNumber($rn);
}
$tagError = $this->applyRecordTags($record, $data, $entityType, $entityId);
if ($tagError !== null) {
return $tagError;
}
$this->recordRepo->save($record);
return $this->success($record->toArray(), 201);
@@ -335,6 +368,16 @@ class PatientController extends BaseController
}
$this->profileRepo->save($profile);
if (array_key_exists('record_number', $data)) {
$rn = trim((string) ($data['record_number'] ?? ''));
$record->setRecordNumber($rn === '' ? null : $rn);
}
$tagError = $this->applyRecordTags($record, $data, $entityType, $entityId);
if ($tagError !== null) {
return $tagError;
}
$this->recordRepo->save($record);
$out = $record->toArray();
$out['profile'] = $this->buildPatientProfile($patient);