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, CheckCircleIcon,
ChevronRightIcon, ChevronRightIcon,
FolderOpenIcon, FolderOpenIcon,
IdentificationIcon,
MagnifyingGlassIcon, MagnifyingGlassIcon,
PencilIcon, PencilIcon,
PhoneIcon, PhoneIcon,
@@ -15,7 +16,6 @@ import React, { useCallback, useRef, useState } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { toast } from "sonner"; import { toast } from "sonner";
import { z } from "zod"; import { z } from "zod";
import DataTable, { type Column } from "../components/ui/DataTable";
import FeatureGate from "../components/ui/FeatureGate"; import FeatureGate from "../components/ui/FeatureGate";
import Modal from "../components/ui/Modal"; import Modal from "../components/ui/Modal";
import PageHeader from "../components/ui/PageHeader"; import PageHeader from "../components/ui/PageHeader";
@@ -65,13 +65,15 @@ const EMPTY_RECORDS: PatientRecord[] = [];
const EMPTY_SESSIONS: PatientSession[] = []; const EMPTY_SESSIONS: PatientSession[] = [];
function getPatientName(record?: PatientRecord | null) { function getPatientName(record?: PatientRecord | null) {
const user = record?.user; return record?.user_name || "—";
return user?.fullName || user?.name || record?.user_name || "—";
} }
function getPatientPhone(record?: PatientRecord | null) { function getPatientPhone(record?: PatientRecord | null) {
const user = record?.user; return record?.user_mobile || "—";
return user?.phone || record?.user_mobile || "—"; }
function getPatientNationalCode(record?: PatientRecord | null) {
return record?.user_national_code || null;
} }
function calcFinalPrice( function calcFinalPrice(
@@ -121,7 +123,9 @@ function MyPatientsPageInner() {
uuid: string; uuid: string;
name: string | null; name: string | null;
mobile: string; mobile: string;
national_code?: string | null;
} | null>(null); } | null>(null);
const [recordNationalCode, setRecordNationalCode] = useState("");
const [searchError, setSearchError] = useState(""); const [searchError, setSearchError] = useState("");
const mobileInputRef = useRef<HTMLInputElement>(null); const mobileInputRef = useRef<HTMLInputElement>(null);
@@ -296,72 +300,6 @@ function MyPatientsPageInner() {
setPage(1); 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) { if (!selectedRecord) {
return ( return (
<> <>
@@ -408,65 +346,77 @@ function MyPatientsPageInner() {
}} }}
value={search} value={search}
onChange={(e) => handleSearch(e.target.value)} onChange={(e) => handleSearch(e.target.value)}
placeholder="جستجو بر اساس نام یا تلفن..." placeholder="جستجو بر اساس نام، شماره موبایل یا کد ملی..."
/> />
</div> </div>
</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 <div
style={{ style={{
textAlign: "center", fontWeight: 600,
padding: "60px 24px", fontSize: 15,
color: "var(--text-3)", marginBottom: 8,
color: "var(--text-2)",
}} }}
> >
<UsersIcon {search
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>
</div> </div>
) : ( <div style={{ fontSize: 13 }}>
<> {search
<DataTable ? "عبارت جستجو را تغییر دهید"
columns={recordColumns} : "پس از ثبت نوبت، پرونده بیمار به طور خودکار ایجاد می‌شود"}
data={records} </div>
loading={isLoading} </div>
emptyMessage="بیماری ثبت نشده است" ) : (
/> <>
<div style={{ padding: "0 16px 12px" }}> <div
<Pagination style={{
page={page} display: "grid",
total={totalRec} gridTemplateColumns:
limit={limit} "repeat(auto-fill, minmax(280px, 1fr))",
onPageChange={setPage} gap: 14,
}}
>
{records.map((r) => (
<PatientCard
key={r.uuid}
record={r}
onOpen={() => {
setSelectedRecord(r);
setSessionPage(1);
}}
/> />
</div> ))}
</> </div>
)} <div style={{ marginTop: 16 }}>
</div> <Pagination
page={page}
total={totalRec}
limit={limit}
onPageChange={setPage}
/>
</div>
</>
)}
{/* Modal ایجاد پرونده دستی */} {/* Modal ایجاد پرونده دستی */}
<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({ function SessionRow({
session, session,
onEdit, onEdit,
+1 -6
View File
@@ -426,15 +426,10 @@ export interface PatientRecord {
uuid: string; uuid: string;
entity_type: string; entity_type: string;
entity_id: number; entity_id: number;
user?: {
uuid?: string;
fullName?: string;
name?: string;
phone?: string;
} | null;
user_uuid?: string; user_uuid?: string;
user_name?: string | null; user_name?: string | null;
user_mobile?: string | null; user_mobile?: string | null;
user_national_code?: string | null;
created_at: number; 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)] #[ORM\Column(name: 'real_name', type: 'string', length: 100, nullable: true)]
private ?string $realName = null; private ?string $realName = null;
#[ORM\Column(name: 'national_code', type: 'string', length: 10, nullable: true)]
private ?string $nationalCode = null;
#[ORM\Column(type: 'json')] #[ORM\Column(type: 'json')]
private array $roles = ['ROLE_USER']; private array $roles = ['ROLE_USER'];
@@ -57,6 +60,7 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
public function getMobileNumber(): string { return $this->mobileNumber; } public function getMobileNumber(): string { return $this->mobileNumber; }
public function getEmail(): ?string { return $this->email; } public function getEmail(): ?string { return $this->email; }
public function getRealName(): ?string { return $this->realName; } public function getRealName(): ?string { return $this->realName; }
public function getNationalCode(): ?string { return $this->nationalCode; }
public function getStatus(): int { return $this->status; } public function getStatus(): int { return $this->status; }
public function getCreatedAt(): int { return $this->createdAt; } 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 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 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 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 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; } 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(), 'uuid' => $patient->getUuid(),
'name' => $patient->getRealName(), 'name' => $patient->getRealName(),
'mobile' => $patient->getMobileNumber(), 'mobile' => $patient->getMobileNumber(),
'national_code' => $patient->getNationalCode(),
]); ]);
} }
@@ -101,6 +102,15 @@ class PatientController extends BaseController
return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'کاربر یافت نشد', 404); 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); $existing = $this->recordRepo->findByEntityAndUser($entityType, $entityId, $patient);
if ($existing !== null) { if ($existing !== null) {
return $this->success($existing->toArray()); return $this->success($existing->toArray());
+3 -2
View File
@@ -72,8 +72,9 @@ class PatientRecord
'entity_type' => $this->entityType, 'entity_type' => $this->entityType,
'entity_id' => $this->entityId, 'entity_id' => $this->entityId,
'user_uuid' => $this->user->getUuid(), 'user_uuid' => $this->user->getUuid(),
'user_name' => $this->user->getRealName(), 'user_name' => $this->user->getRealName(),
'user_mobile' => $this->user->getMobileNumber(), 'user_mobile' => $this->user->getMobileNumber(),
'user_national_code' => $this->user->getNationalCode(),
'created_by_type' => $this->createdByType, 'created_by_type' => $this->createdByType,
'created_at' => $this->createdAt, 'created_at' => $this->createdAt,
]; ];
@@ -41,7 +41,7 @@ class PatientRecordRepository extends ServiceEntityRepository
->setMaxResults($limit); ->setMaxResults($limit);
if ($search !== null && $search !== '') { 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 . '%'); ->setParameter('search', '%' . $search . '%');
} }
@@ -59,7 +59,7 @@ class PatientRecordRepository extends ServiceEntityRepository
->setParameter('id', $entityId); ->setParameter('id', $entityId);
if ($search !== null && $search !== '') { 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 . '%'); ->setParameter('search', '%' . $search . '%');
} }