feat: add national code to patient records and user entity, update related functionality

This commit is contained in:
hamed
2026-06-22 19:44:17 +03:30
parent 3c103bc51e
commit 5b1dfe9b40
7 changed files with 252 additions and 130 deletions
+200 -120
View File
@@ -2,6 +2,7 @@ import {
CheckCircleIcon,
ChevronRightIcon,
FolderOpenIcon,
IdentificationIcon,
MagnifyingGlassIcon,
PencilIcon,
PhoneIcon,
@@ -15,7 +16,6 @@ import React, { useCallback, useRef, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
import DataTable, { type Column } from "../components/ui/DataTable";
import FeatureGate from "../components/ui/FeatureGate";
import Modal from "../components/ui/Modal";
import PageHeader from "../components/ui/PageHeader";
@@ -65,13 +65,15 @@ const EMPTY_RECORDS: PatientRecord[] = [];
const EMPTY_SESSIONS: PatientSession[] = [];
function getPatientName(record?: PatientRecord | null) {
const user = record?.user;
return user?.fullName || user?.name || record?.user_name || "—";
return record?.user_name || "—";
}
function getPatientPhone(record?: PatientRecord | null) {
const user = record?.user;
return user?.phone || record?.user_mobile || "—";
return record?.user_mobile || "—";
}
function getPatientNationalCode(record?: PatientRecord | null) {
return record?.user_national_code || null;
}
function calcFinalPrice(
@@ -121,7 +123,9 @@ function MyPatientsPageInner() {
uuid: string;
name: string | null;
mobile: string;
national_code?: string | null;
} | null>(null);
const [recordNationalCode, setRecordNationalCode] = useState("");
const [searchError, setSearchError] = useState("");
const mobileInputRef = useRef<HTMLInputElement>(null);
@@ -296,72 +300,6 @@ function MyPatientsPageInner() {
setPage(1);
}, []);
const recordColumns: Column<PatientRecord>[] = [
{
key: "user",
header: "بیمار",
render: (r) => (
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
<div
className="avatar sm"
style={{
background:
"linear-gradient(145deg, oklch(0.62 0.15 256), oklch(0.48 0.16 256))",
flexShrink: 0,
fontWeight: 700,
}}
>
{(r.user?.fullName ?? "?").charAt(0)}
</div>
<div>
<div style={{ fontWeight: 600, fontSize: 14 }}>
{r.user?.fullName ?? "—"}
</div>
<div
style={{
fontSize: 12,
color: "var(--text-3)",
display: "flex",
alignItems: "center",
gap: 4,
marginTop: 2,
}}
>
<PhoneIcon style={{ width: 11 }} />
<span dir="ltr">{r.user?.phone ?? "—"}</span>
</div>
</div>
</div>
),
},
{
key: "created_at",
header: "تاریخ ثبت",
render: (r) => (
<span style={{ fontSize: 13, color: "var(--text-3)" }}>
{formatDate(r.created_at)}
</span>
),
},
{
key: "uuid",
header: "",
render: (r) => (
<button
className="btn primary sm"
style={{ display: "flex", alignItems: "center", gap: 5 }}
onClick={() => {
setSelectedRecord(r);
setSessionPage(1);
}}
>
<FolderOpenIcon style={{ width: 14 }} />
مشاهده پرونده
</button>
),
},
];
if (!selectedRecord) {
return (
<>
@@ -408,65 +346,77 @@ function MyPatientsPageInner() {
}}
value={search}
onChange={(e) => handleSearch(e.target.value)}
placeholder="جستجو بر اساس نام یا تلفن..."
placeholder="جستجو بر اساس نام، شماره موبایل یا کد ملی..."
/>
</div>
</div>
<div className="card">
{records.length === 0 && !isLoading ? (
{records.length === 0 && !isLoading ? (
<div
className="card"
style={{
textAlign: "center",
padding: "60px 24px",
color: "var(--text-3)",
}}
>
<UsersIcon
style={{
width: 48,
margin: "0 auto 16px",
display: "block",
opacity: 0.4,
}}
/>
<div
style={{
textAlign: "center",
padding: "60px 24px",
color: "var(--text-3)",
fontWeight: 600,
fontSize: 15,
marginBottom: 8,
color: "var(--text-2)",
}}
>
<UsersIcon
style={{
width: 48,
margin: "0 auto 16px",
display: "block",
opacity: 0.4,
}}
/>
<div
style={{
fontWeight: 600,
fontSize: 15,
marginBottom: 8,
color: "var(--text-2)",
}}
>
{search
? "بیماری یافت نشد"
: "هنوز بیماری ثبت نشده است"}
</div>
<div style={{ fontSize: 13 }}>
{search
? "عبارت جستجو را تغییر دهید"
: "پس از ثبت نوبت، پرونده بیمار به طور خودکار ایجاد می‌شود"}
</div>
{search
? "بیماری یافت نشد"
: "هنوز بیماری ثبت نشده است"}
</div>
) : (
<>
<DataTable
columns={recordColumns}
data={records}
loading={isLoading}
emptyMessage="بیماری ثبت نشده است"
/>
<div style={{ padding: "0 16px 12px" }}>
<Pagination
page={page}
total={totalRec}
limit={limit}
onPageChange={setPage}
<div style={{ fontSize: 13 }}>
{search
? "عبارت جستجو را تغییر دهید"
: "پس از ثبت نوبت، پرونده بیمار به طور خودکار ایجاد می‌شود"}
</div>
</div>
) : (
<>
<div
style={{
display: "grid",
gridTemplateColumns:
"repeat(auto-fill, minmax(280px, 1fr))",
gap: 14,
}}
>
{records.map((r) => (
<PatientCard
key={r.uuid}
record={r}
onOpen={() => {
setSelectedRecord(r);
setSessionPage(1);
}}
/>
</div>
</>
)}
</div>
))}
</div>
<div style={{ marginTop: 16 }}>
<Pagination
page={page}
total={totalRec}
limit={limit}
onPageChange={setPage}
/>
</div>
</>
)}
{/* Modal ایجاد پرونده دستی */}
<Modal
@@ -1115,6 +1065,136 @@ function MyPatientsPageInner() {
);
}
function PatientCard({
record,
onOpen,
}: {
record: PatientRecord;
onOpen: () => void;
}) {
const name = getPatientName(record);
const phone = getPatientPhone(record);
const nationalCode = getPatientNationalCode(record);
return (
<div
className="card"
style={{
padding: 16,
display: "flex",
flexDirection: "column",
gap: 14,
cursor: "pointer",
transition: "border-color .15s, box-shadow .15s",
}}
onClick={onOpen}
>
<div style={{ display: "flex", alignItems: "center", gap: 12 }}>
<div
className="avatar"
style={{
width: 44,
height: 44,
borderRadius: "50%",
background:
"linear-gradient(145deg, oklch(0.62 0.15 256), oklch(0.48 0.16 256))",
color: "#fff",
display: "flex",
alignItems: "center",
justifyContent: "center",
fontWeight: 700,
fontSize: 17,
flexShrink: 0,
}}
>
{(name === "—" ? "؟" : name).charAt(0)}
</div>
<div style={{ minWidth: 0, flex: 1 }}>
<div
style={{
fontWeight: 600,
fontSize: 15,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{name}
</div>
<div
style={{
fontSize: 12,
color: "var(--text-3)",
marginTop: 2,
}}
>
{formatDate(record.created_at)}
</div>
</div>
</div>
<div
style={{
display: "flex",
flexDirection: "column",
gap: 7,
fontSize: 13,
color: "var(--text-2)",
}}
>
<div
style={{ display: "flex", alignItems: "center", gap: 7 }}
>
<PhoneIcon
style={{
width: 14,
color: "var(--text-3)",
flexShrink: 0,
}}
/>
<span dir="ltr">{phone}</span>
</div>
<div
style={{ display: "flex", alignItems: "center", gap: 7 }}
>
<IdentificationIcon
style={{
width: 14,
color: "var(--text-3)",
flexShrink: 0,
}}
/>
<span dir="ltr">
{nationalCode ?? (
<span style={{ color: "var(--text-3)" }}>
کد ملی ثبت نشده
</span>
)}
</span>
</div>
</div>
<button
className="btn primary sm"
style={{
width: "100%",
justifyContent: "center",
display: "flex",
alignItems: "center",
gap: 5,
}}
onClick={(e) => {
e.stopPropagation();
onOpen();
}}
>
<FolderOpenIcon style={{ width: 14 }} />
مشاهده پرونده
</button>
</div>
);
}
function SessionRow({
session,
onEdit,
+1 -6
View File
@@ -426,15 +426,10 @@ export interface PatientRecord {
uuid: string;
entity_type: string;
entity_id: number;
user?: {
uuid?: string;
fullName?: string;
name?: string;
phone?: string;
} | null;
user_uuid?: string;
user_name?: string | null;
user_mobile?: string | null;
user_national_code?: string | null;
created_at: number;
}
+31
View File
@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20260622160119 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE users ADD national_code VARCHAR(10) DEFAULT NULL');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE users DROP national_code');
}
}
+5
View File
@@ -32,6 +32,9 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
#[ORM\Column(name: 'real_name', type: 'string', length: 100, nullable: true)]
private ?string $realName = null;
#[ORM\Column(name: 'national_code', type: 'string', length: 10, nullable: true)]
private ?string $nationalCode = null;
#[ORM\Column(type: 'json')]
private array $roles = ['ROLE_USER'];
@@ -57,6 +60,7 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
public function getMobileNumber(): string { return $this->mobileNumber; }
public function getEmail(): ?string { return $this->email; }
public function getRealName(): ?string { return $this->realName; }
public function getNationalCode(): ?string { return $this->nationalCode; }
public function getStatus(): int { return $this->status; }
public function getCreatedAt(): int { return $this->createdAt; }
@@ -77,6 +81,7 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
public function setEmail(?string $email): self { $this->email = $email; return $this; }
public function setRealName(?string $name): self { $this->realName = $name; $this->updatedAt = time(); return $this; }
public function setNationalCode(?string $code): self { $this->nationalCode = $code !== null && $code !== '' ? $code : null; $this->updatedAt = time(); return $this; }
public function setPasswordHash(?string $hash): self { $this->passwordHash = $hash; $this->updatedAt = time(); return $this; }
public function setRoles(array $roles): self { $this->roles = $roles; $this->updatedAt = time(); return $this; }
public function setStatus(int $status): self { $this->status = $status; $this->updatedAt = time(); return $this; }
@@ -59,6 +59,7 @@ class PatientController extends BaseController
'uuid' => $patient->getUuid(),
'name' => $patient->getRealName(),
'mobile' => $patient->getMobileNumber(),
'national_code' => $patient->getNationalCode(),
]);
}
@@ -101,6 +102,15 @@ class PatientController extends BaseController
return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'کاربر یافت نشد', 404);
}
$nationalCode = trim((string) ($data['national_code'] ?? ''));
if ($nationalCode !== '' && $patient->getNationalCode() === null) {
if (!preg_match('/^\d{10}$/', $nationalCode)) {
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'کد ملی باید ۱۰ رقم باشد', 422);
}
$patient->setNationalCode($nationalCode);
$this->userRepo->save($patient);
}
$existing = $this->recordRepo->findByEntityAndUser($entityType, $entityId, $patient);
if ($existing !== null) {
return $this->success($existing->toArray());
+3 -2
View File
@@ -72,8 +72,9 @@ class PatientRecord
'entity_type' => $this->entityType,
'entity_id' => $this->entityId,
'user_uuid' => $this->user->getUuid(),
'user_name' => $this->user->getRealName(),
'user_mobile' => $this->user->getMobileNumber(),
'user_name' => $this->user->getRealName(),
'user_mobile' => $this->user->getMobileNumber(),
'user_national_code' => $this->user->getNationalCode(),
'created_by_type' => $this->createdByType,
'created_at' => $this->createdAt,
];
@@ -41,7 +41,7 @@ class PatientRecordRepository extends ServiceEntityRepository
->setMaxResults($limit);
if ($search !== null && $search !== '') {
$qb->andWhere('u.realName LIKE :search OR u.mobileNumber LIKE :search')
$qb->andWhere('u.realName LIKE :search OR u.mobileNumber LIKE :search OR u.nationalCode LIKE :search')
->setParameter('search', '%' . $search . '%');
}
@@ -59,7 +59,7 @@ class PatientRecordRepository extends ServiceEntityRepository
->setParameter('id', $entityId);
if ($search !== null && $search !== '') {
$qb->andWhere('u.realName LIKE :search OR u.mobileNumber LIKE :search')
$qb->andWhere('u.realName LIKE :search OR u.mobileNumber LIKE :search OR u.nationalCode LIKE :search')
->setParameter('search', '%' . $search . '%');
}