Files
clinicpro/assets/admin/pages/PatientRecordFormPage.tsx
T
hamed e0e8fbd1e4 feat: implement BackButton component for consistent navigation
- Added BackButton component to standardize back navigation across pages.
- Integrated BackButton into various pages, replacing custom back buttons for consistency.
- Updated PageHeader to accept backTo prop for displaying BackButton when navigating from subpages.
- Created useGoBack hook to handle navigation logic, determining whether to go back in history or redirect to a fallback page.
- Added tests for BackButton and its integration with PageHeader to ensure expected behavior.
2026-07-29 20:26:51 +03:30

165 lines
8.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect } from 'react';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useParams, useNavigate, Link } from 'react-router-dom';
import { ChevronRightIcon } from '@heroicons/react/24/outline';
import { toast } from 'sonner';
import { api } from '../lib/api';
import type { ApiResponse } from '../lib/api';
import type { PatientRecord } from '../types';
import PersianDateInput from '../components/ui/PersianDateInput';
import SearchableSelect from '../components/ui/SearchableSelect';
import { numericField } from '../lib/forms';
import BackButton from '../components/ui/BackButton';
import { iranNationalCodeSchema, iranMobileSchema, unixToIso } from '../lib/utils';
const REFERRAL_OPTIONS = ['اینستاگرام', 'معرفی دوستان و آشنایان', 'جستجوی اینترنتی', 'تابلو مطب', 'سایر'];
const schema = z.object({
name: z.string().min(1, 'نام و نام خانوادگی الزامی است'),
record_number: z.string().min(1, 'شماره پرونده الزامی است'),
gender: z.enum(['male', 'female'], { errorMap: () => ({ message: 'جنسیت را انتخاب کنید' }) }),
national_code: iranNationalCodeSchema,
mobile: iranMobileSchema,
birth_date: z.string().optional(),
referral_source: z.string().optional(),
description: z.string().optional(),
});
type Form = z.infer<typeof schema>;
const toEpoch = (iso?: string) => (iso ? Math.floor(new Date(iso).getTime() / 1000) : null);
const fromEpoch = (ts?: number | null) => unixToIso(ts);
/** تشکیل/ویرایش پرونده — patient record create & edit form (Figma "تشکیل پرونده"). */
export default function PatientRecordFormPage() {
const { uuid } = useParams<{ uuid: string }>();
const isEdit = !!uuid;
const navigate = useNavigate();
const qc = useQueryClient();
const form = useForm<Form>({
resolver: zodResolver(schema),
defaultValues: { name: '', record_number: '', gender: undefined as any, national_code: '', mobile: '', birth_date: '', referral_source: '', description: '' },
});
const { data: recordData } = useQuery<ApiResponse<PatientRecord>>({
queryKey: ['patient', uuid],
queryFn: () => api.get(`/api/v1/patient/${uuid}`),
enabled: isEdit,
});
useEffect(() => {
const r = recordData?.data;
if (!r) return;
const p: any = r.profile ?? {};
form.reset({
name: r.user_name ?? '',
record_number: r.record_number ?? '',
gender: (p.gender as 'male' | 'female') ?? undefined,
national_code: r.user_national_code ?? '',
mobile: r.user_mobile ?? '',
birth_date: fromEpoch(p.date_of_birth),
referral_source: p.referral_source ?? '',
description: p.description ?? '',
});
}, [recordData]); // eslint-disable-line react-hooks/exhaustive-deps
const save = useMutation({
mutationFn: async (d: Form) => {
const profilePayload = {
gender: d.gender,
date_of_birth: toEpoch(d.birth_date),
referral_source: d.referral_source || null,
description: d.description || null,
};
if (isEdit) {
return api.patch(`/api/v1/patient/${uuid}`, {
name: d.name, national_code: d.national_code, mobile: d.mobile, record_number: d.record_number, ...profilePayload,
});
}
// create: POST creates the record + identity, then PATCH applies the profile demographics
const created = await api.post<ApiResponse<PatientRecord>>('/api/v1/patient', {
name: d.name, mobile: d.mobile, national_code: d.national_code, record_number: d.record_number,
});
const newUuid = (created as any)?.data?.uuid;
if (newUuid) await api.patch(`/api/v1/patient/${newUuid}`, profilePayload);
return created;
},
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['patients'] });
toast.success(isEdit ? 'پرونده ویرایش شد' : 'پرونده تشکیل شد');
navigate('/admin/patients');
},
onError: (e: any) => toast.error(e.message),
});
const Field = ({ label, required, error, children }: { label: string; required?: boolean; error?: string; children: React.ReactNode }) => (
<div>
<label className="field-label">{label} {required && <span style={{ color: 'var(--danger)' }}>*</span>}</label>
{children}
{error && <span className="field-error">{error}</span>}
</div>
);
return (
<div className="fade-in" style={{ maxWidth: 1000, margin: '0 auto' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 18 }}>
<BackButton fallback="/admin/patients" />
<div style={{ fontSize: 14, color: 'var(--text-3)' }}>پرونده <b style={{ color: 'var(--text)' }}>{isEdit ? 'ویرایش پرونده' : 'تشکیل پرونده'}</b></div>
</div>
<form onSubmit={form.handleSubmit((d) => save.mutate(d))}
style={{ background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 'var(--r-lg)', padding: 24 }}>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(240px, 1fr))', gap: 18 }}>
<Field label="نام و نام خانوادگی مراجعه کننده" required error={form.formState.errors.name?.message}>
<div className="field"><input {...form.register('name')} placeholder="نام و نام خانوادگی را وارد نمایید" /></div>
</Field>
<Field label="شماره پرونده" required error={form.formState.errors.record_number?.message}>
<div className="field"><input {...form.register('record_number')} placeholder="شماره پرونده" /></div>
</Field>
<Field label="جنسیت" required error={form.formState.errors.gender?.message}>
<SearchableSelect
options={[{ value: 'female', label: 'زن' }, { value: 'male', label: 'مرد' }]}
value={form.watch('gender') ?? null}
onChange={(v) => form.setValue('gender', v as Form['gender'], { shouldValidate: true, shouldDirty: true })}
placeholder="انتخاب..."
height={38}
/>
</Field>
<Field label="کد ملی" required error={form.formState.errors.national_code?.message}>
<div className="field"><input {...numericField(form.register('national_code'), 10)} placeholder="کد ملی را وارد نمایید" /></div>
</Field>
<Field label="شماره تماس" required error={form.formState.errors.mobile?.message}>
<div className="field"><input {...numericField(form.register('mobile'), 11)} placeholder="شماره تماس را وارد نمایید" /></div>
</Field>
<Field label="تاریخ تولد">
<PersianDateInput value={form.watch('birth_date') ?? ''} onChange={(v) => form.setValue('birth_date', v)} enableYearPicker />
</Field>
<Field label="نحوه آشنایی">
<SearchableSelect
options={REFERRAL_OPTIONS.map((o) => ({ value: o, label: o }))}
value={form.watch('referral_source') || null}
onChange={(v) => form.setValue('referral_source', v ? String(v) : '', { shouldDirty: true })}
placeholder="انتخاب کنید..."
isClearable
height={38}
/>
</Field>
</div>
<div style={{ marginTop: 18 }}>
<Field label="توضیحات">
<div className="field" style={{ height: 'auto' }}><textarea {...form.register('description')} rows={4} placeholder="توضیحات" style={{ width: '100%', border: 'none', background: 'transparent', fontFamily: 'inherit', resize: 'vertical' }} /></div>
</Field>
</div>
<button type="submit" className="btn primary" style={{ marginTop: 22, height: 44, padding: '0 24px' }} disabled={save.isPending}>
{save.isPending ? 'در حال ذخیره...' : 'ثبت اطلاعات'}
</button>
</form>
</div>
);
}