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
+44
View File
@@ -4,6 +4,7 @@ namespace App\Patient\Entity;
use App\Auth\Entity\User;
use App\Patient\Repository\PatientRecordRepository;
use App\Tag\Entity\TenantTag;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
@@ -41,6 +42,20 @@ class PatientRecord
#[ORM\Column(name: 'created_at', type: 'integer')]
private int $createdAt;
// Clinic-scoped case-file number. Patient identity/demographics (gender,
// date_of_birth, referral_source, description, insurance, …) live on the
// patient's UserProfile and are set via PATCH /patient/{uuid}.
#[ORM\Column(name: 'record_number', type: 'string', length: 40, nullable: true)]
private ?string $recordNumber = null;
/**
* @var Collection<int, TenantTag> record labels. EAGER so the typed
* collection is always hydrated (see ServiceItem for the same pitfall).
*/
#[ORM\ManyToMany(targetEntity: TenantTag::class, fetch: 'EAGER')]
#[ORM\JoinTable(name: 'patient_record_tags')]
private Collection $tags;
#[ORM\OneToMany(targetEntity: PatientSession::class, mappedBy: 'record', cascade: ['remove'])]
private Collection $sessions;
@@ -54,6 +69,7 @@ class PatientRecord
$this->createdById = $createdById;
$this->createdAt = time();
$this->sessions = new ArrayCollection();
$this->tags = new ArrayCollection();
}
public function getId(): ?int { return $this->id; }
@@ -65,6 +81,29 @@ class PatientRecord
public function getCreatedById(): int { return $this->createdById; }
public function getCreatedAt(): int { return $this->createdAt; }
public function getRecordNumber(): ?string { return $this->recordNumber; }
public function setRecordNumber(?string $v): self { $this->recordNumber = $v; return $this; }
/** @return Collection<int, TenantTag> */
public function getTags(): Collection
{
// Doctrine hydrates without the constructor; guard the typed property.
return $this->tags ??= new ArrayCollection();
}
/** @param TenantTag[] $tags */
public function setTags(array $tags): self
{
$collection = $this->getTags();
$collection->clear();
foreach ($tags as $t) {
if (!$collection->contains($t)) {
$collection->add($t);
}
}
return $this;
}
public function toArray(): array
{
return [
@@ -75,6 +114,11 @@ class PatientRecord
'user_name' => $this->user->getRealName(),
'user_mobile' => $this->user->getMobileNumber(),
'user_national_code' => $this->user->getNationalCode(),
'record_number' => $this->recordNumber,
'tags' => array_map(
fn(TenantTag $t) => $t->toArray(),
array_values($this->getTags()->toArray())
),
'created_by_type' => $this->createdByType,
'created_at' => $this->createdAt,
];