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