Users typing on a Persian keyboard produced two distinct failures. Fields with type="number" silently returned an empty string — the browser rejects Persian digits, so the value was lost and saved as empty or zero. Text fields passed the Persian characters straight through to the database, where a mobile stored as ۰۹۱۲… never matches 09… again. The secretary form hit the second case with no validation at all. Frontend: - Adds digitsOnly() and the national-code schemas to lib/utils, plus lib/forms with numericField()/latinDigitsField() wrappers for React Hook Form fields. - Converts every type="number" input to type="text" inputMode="numeric" with digit normalization; none remain. Fields that legitimately carry non-digits (sheba, landline) only get the digits translated, keeping IR and separators. - Points the patient national-code and mobile schemas at the shared normalizing schemas, which accept Persian input instead of rejecting it. - Drops two duplicate local digit converters in favour of the shared helper. Backend: - Adds NumericFieldNormalizerSubscriber, translating digits in whitelisted numeric keys of JSON request bodies under /api/v1/ before controllers run, so nobat724_front and clinic-pro-tauri are covered too. Translation only — no characters are stripped, non-string values and other keys are untouched. Three component tests asserted on role="spinbutton" and numeric input values; both are properties of type="number", so they were updated to match the new text inputs. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
164 lines
8.2 KiB
TypeScript
164 lines
8.2 KiB
TypeScript
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 { iranNationalCodeSchema, iranMobileSchema } 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) => (ts ? new Date(ts * 1000).toISOString().slice(0, 10) : '');
|
||
|
||
/** تشکیل/ویرایش پرونده — 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 }}>
|
||
<Link to="/admin/patients" className="btn sm ghost" style={{ color: 'var(--text-2)' }}><ChevronRightIcon style={{ width: 16 }} /> بازگشت</Link>
|
||
<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>
|
||
);
|
||
}
|