feat(patient): complete "اطلاعات پرونده" demographic form
Backend:
- Add profile columns field_of_study, province_id, city_id, postal_code,
referral_source (UserProfile + migration).
- Extend PATCH /api/v1/patient/{uuid} to persist all demographic fields
and return them in the patient profile payload.
- Support editable mobile (login identifier): validation, uniqueness,
User.setMobileNumber, new ERR_PROFILE_002.
- Update docs/api/patient.md.
Frontend:
- New reusable Input, Field, and PatientRecordInfoForm (RHF + Zod).
- usePatient/useUpdatePatient hooks and patientForm mapping helpers.
- Extend the existing "info" tab in MyPatientsPage to the full field set
via the shared form (province/city/insurance options, Jalali date).
Tests: Patient entity + PATCH integration (PHPUnit); form, hooks, and
mapping helpers (Vitest).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -40,6 +40,15 @@ import Modal from "../components/ui/Modal";
|
||||
import PageHeader from "../components/ui/PageHeader";
|
||||
import Pagination from "../components/ui/Pagination";
|
||||
import SearchableSelect from "../components/ui/SearchableSelect";
|
||||
import PatientRecordInfoForm from "../components/PatientRecordInfoForm";
|
||||
import {
|
||||
GENDER_OPTS,
|
||||
MARITAL_OPTS,
|
||||
EDUCATION_OPTS,
|
||||
REFERRAL_OPTS,
|
||||
profileToFormValues,
|
||||
formValuesToPayload,
|
||||
} from "../lib/patientForm";
|
||||
import type { ApiResponse, PaginatedResponse } from "../lib/api";
|
||||
import { api } from "../lib/api";
|
||||
import {
|
||||
@@ -240,14 +249,7 @@ function MyPatientsPageInner() {
|
||||
const [newPatientName, setNewPatientName] = useState("");
|
||||
const mobileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const EMPTY_EDIT = {
|
||||
name: "", family: "", national_code: "", gender: "", blood_type: "",
|
||||
marital_status: "", job: "", home_phone: "", work_phone: "", address: "",
|
||||
basic_insurance_id: "", supplementary_insurance_id: "",
|
||||
};
|
||||
const [editOpen, setEditOpen] = useState(false);
|
||||
const [editForm, setEditForm] = useState<Record<string, string>>(EMPTY_EDIT);
|
||||
const setEditField = (k: string, v: string) => setEditForm((f) => ({ ...f, [k]: v }));
|
||||
|
||||
const servicesTotal = selectedServices.reduce(
|
||||
(sum, s) => sum + s.price_rials,
|
||||
@@ -279,6 +281,22 @@ function MyPatientsPageInner() {
|
||||
});
|
||||
const patientProfile = (recordDetail?.data as PatientRecord | undefined)?.profile ?? null;
|
||||
|
||||
// استان/شهر برای فرم «اطلاعات پرونده» (منبع: دامنهٔ Location؛ شهر وابسته به استان)
|
||||
const [editProvinceId, setEditProvinceId] = useState<number | null>(null);
|
||||
const provincesQ = useQuery({
|
||||
queryKey: ["provinces"],
|
||||
queryFn: () => api.get<any>("/api/v1/provinces"),
|
||||
staleTime: 600_000,
|
||||
});
|
||||
const citiesQ = useQuery({
|
||||
queryKey: ["cities", editProvinceId],
|
||||
queryFn: () =>
|
||||
api.get<any>(`/api/v1/cities${editProvinceId ? `?province_id=${editProvinceId}` : ""}`),
|
||||
staleTime: 300_000,
|
||||
});
|
||||
const locOpts = (raw: any) =>
|
||||
(raw?.data?.data ?? raw?.data ?? []).map((x: any) => ({ value: Number(x.id), label: x.name }));
|
||||
|
||||
const { data: sessionsData, isLoading: sessionsLoading } = useQuery<
|
||||
PaginatedResponse<PatientSession>
|
||||
>({
|
||||
@@ -385,21 +403,7 @@ function MyPatientsPageInner() {
|
||||
};
|
||||
|
||||
const openEditInfo = () => {
|
||||
const p = patientProfile;
|
||||
setEditForm({
|
||||
name: p?.name ?? "",
|
||||
family: p?.family ?? "",
|
||||
national_code: p?.national_code ?? "",
|
||||
gender: p?.gender ?? "",
|
||||
blood_type: p?.blood_type ?? "",
|
||||
marital_status: p?.marital_status ?? "",
|
||||
job: p?.job ?? "",
|
||||
home_phone: p?.home_phone ?? "",
|
||||
work_phone: p?.work_phone ?? "",
|
||||
address: p?.address ?? "",
|
||||
basic_insurance_id: p?.basic_insurance_id != null ? String(p.basic_insurance_id) : "",
|
||||
supplementary_insurance_id: p?.supplementary_insurance_id != null ? String(p.supplementary_insurance_id) : "",
|
||||
});
|
||||
setEditProvinceId(patientProfile?.province_id ?? null);
|
||||
setEditOpen(true);
|
||||
};
|
||||
|
||||
@@ -417,26 +421,6 @@ function MyPatientsPageInner() {
|
||||
},
|
||||
});
|
||||
|
||||
const submitEditInfo = () => {
|
||||
if (!editForm.name.trim()) {
|
||||
toast.error("نام بیمار الزامی است");
|
||||
return;
|
||||
}
|
||||
updatePatientMut.mutate({
|
||||
name: editForm.name.trim(),
|
||||
family: editForm.family.trim() || null,
|
||||
national_code: editForm.national_code.trim(),
|
||||
gender: editForm.gender || null,
|
||||
blood_type: editForm.blood_type.trim() || null,
|
||||
marital_status: editForm.marital_status || null,
|
||||
job: editForm.job.trim() || null,
|
||||
home_phone: editForm.home_phone.trim() || null,
|
||||
work_phone: editForm.work_phone.trim() || null,
|
||||
address: editForm.address.trim() || null,
|
||||
basic_insurance_id: editForm.basic_insurance_id ? Number(editForm.basic_insurance_id) : null,
|
||||
supplementary_insurance_id: editForm.supplementary_insurance_id ? Number(editForm.supplementary_insurance_id) : null,
|
||||
});
|
||||
};
|
||||
|
||||
const settleSessionMut = useMutation({
|
||||
mutationFn: (uuid: string) =>
|
||||
@@ -1148,17 +1132,23 @@ function MyPatientsPageInner() {
|
||||
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(180px, 1fr))", gap: 12 }}>
|
||||
{([
|
||||
["نام کامل", patientProfile.full_name],
|
||||
["نام پدر", patientProfile.fathers_name],
|
||||
["کد ملی", patientProfile.national_code],
|
||||
["جنسیت", patientProfile.gender === "male" ? "مرد" : patientProfile.gender === "female" ? "زن" : patientProfile.gender],
|
||||
["تاریخ تولد", patientProfile.date_of_birth ? formatDate(patientProfile.date_of_birth) : null],
|
||||
["گروه خونی", patientProfile.blood_type],
|
||||
["وضعیت تأهل", patientProfile.marital_status],
|
||||
["مقطع تحصیلی", patientProfile.education],
|
||||
["رشته تحصیلی", patientProfile.field_of_study],
|
||||
["شغل", patientProfile.job],
|
||||
["موبایل", patientProfile.mobile],
|
||||
["تلفن منزل", patientProfile.home_phone],
|
||||
["بیمه پایه", patientProfile.basic_insurance_name],
|
||||
["بیمه تکمیلی", patientProfile.supplementary_insurance_name],
|
||||
["کد پستی", patientProfile.postal_code],
|
||||
["نحوه آشنایی", patientProfile.referral_source],
|
||||
["آدرس", patientProfile.address],
|
||||
["توضیحات", patientProfile.description],
|
||||
] as [string, string | null | undefined][]).map(([label, value]) => (
|
||||
<div key={label}>
|
||||
<div style={{ fontSize: 11.5, color: "var(--text-3)", marginBottom: 3 }}>{label}</div>
|
||||
@@ -1173,85 +1163,26 @@ function MyPatientsPageInner() {
|
||||
|
||||
<Modal
|
||||
open={editOpen}
|
||||
title="ویرایش اطلاعات بیمار"
|
||||
title="ویرایش اطلاعات پرونده"
|
||||
size="lg"
|
||||
onClose={() => setEditOpen(false)}
|
||||
footer={
|
||||
<>
|
||||
<button className="cp-btn-ghost" onClick={() => setEditOpen(false)}>انصراف</button>
|
||||
<button className="cp-btn-primary" onClick={submitEditInfo} disabled={updatePatientMut.isPending}>
|
||||
{updatePatientMut.isPending ? "در حال ذخیره…" : "ذخیره"}
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(200px, 1fr))", gap: 12 }}>
|
||||
<div>
|
||||
<label className="cp-label">نام</label>
|
||||
<input className="cp-input" value={editForm.name} onChange={(e) => setEditField("name", e.target.value)} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="cp-label">نام خانوادگی</label>
|
||||
<input className="cp-input" value={editForm.family} onChange={(e) => setEditField("family", e.target.value)} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="cp-label">کد ملی</label>
|
||||
<input className="cp-input" dir="ltr" inputMode="numeric" maxLength={10} value={editForm.national_code} onChange={(e) => setEditField("national_code", e.target.value.replace(/\D/g, ""))} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="cp-label">جنسیت</label>
|
||||
<select className="cp-select" value={editForm.gender} onChange={(e) => setEditField("gender", e.target.value)}>
|
||||
<option value="">—</option>
|
||||
<option value="male">مرد</option>
|
||||
<option value="female">زن</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="cp-label">گروه خونی</label>
|
||||
<input className="cp-input" value={editForm.blood_type} onChange={(e) => setEditField("blood_type", e.target.value)} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="cp-label">وضعیت تأهل</label>
|
||||
<select className="cp-select" value={editForm.marital_status} onChange={(e) => setEditField("marital_status", e.target.value)}>
|
||||
<option value="">—</option>
|
||||
<option value="single">مجرد</option>
|
||||
<option value="married">متأهل</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="cp-label">شغل</label>
|
||||
<input className="cp-input" value={editForm.job} onChange={(e) => setEditField("job", e.target.value)} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="cp-label">تلفن منزل</label>
|
||||
<input className="cp-input" dir="ltr" value={editForm.home_phone} onChange={(e) => setEditField("home_phone", e.target.value)} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="cp-label">تلفن محل کار</label>
|
||||
<input className="cp-input" dir="ltr" value={editForm.work_phone} onChange={(e) => setEditField("work_phone", e.target.value)} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="cp-label">بیمه پایه</label>
|
||||
<select className="cp-select" value={editForm.basic_insurance_id} onChange={(e) => setEditField("basic_insurance_id", e.target.value)}>
|
||||
<option value="">—</option>
|
||||
{baseInsuranceOptions.map((o) => <option key={o.value} value={o.value}>{o.label}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="cp-label">بیمه تکمیلی</label>
|
||||
<select className="cp-select" value={editForm.supplementary_insurance_id} onChange={(e) => setEditField("supplementary_insurance_id", e.target.value)}>
|
||||
<option value="">—</option>
|
||||
{suppInsuranceOptions.map((o) => <option key={o.value} value={o.value}>{o.label}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ marginTop: 12 }}>
|
||||
<label className="cp-label">آدرس</label>
|
||||
<textarea className="cp-textarea" rows={2} value={editForm.address} onChange={(e) => setEditField("address", e.target.value)} />
|
||||
</div>
|
||||
<div style={{ marginTop: 12, fontSize: 12, color: "var(--text-3)" }}>
|
||||
شماره موبایل ({patientProfile?.mobile}) شناسهی حساب بیمار است و از اینجا قابل تغییر نیست.
|
||||
</div>
|
||||
<PatientRecordInfoForm
|
||||
defaultValues={profileToFormValues(patientProfile)}
|
||||
recordNumber={selectedRecord?.uuid}
|
||||
options={{
|
||||
gender: GENDER_OPTS,
|
||||
marital: MARITAL_OPTS,
|
||||
education: EDUCATION_OPTS,
|
||||
referral: REFERRAL_OPTS,
|
||||
insurance: baseInsuranceOptions,
|
||||
province: locOpts(provincesQ.data),
|
||||
city: locOpts(citiesQ.data),
|
||||
}}
|
||||
onSubmit={(v) => updatePatientMut.mutate(formValuesToPayload(v))}
|
||||
isSubmitting={updatePatientMut.isPending}
|
||||
onProvinceChange={setEditProvinceId}
|
||||
/>
|
||||
</Modal>
|
||||
|
||||
{detailTab === "visits" && (
|
||||
|
||||
Reference in New Issue
Block a user