Files
clinicpro/assets/admin/pages/PatientRecordFormPage.tsx
T
hamedandClaude Opus 4.8 00cb9aaa1a feat(admin): normalize Persian/Arabic digits in every numeric field
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>
2026-07-18 10:38:56 +03:30

164 lines
8.2 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 { 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>
);
}