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>
211 lines
7.4 KiB
TypeScript
211 lines
7.4 KiB
TypeScript
import React from 'react';
|
|
import { Controller, useForm } from 'react-hook-form';
|
|
import { zodResolver } from '@hookform/resolvers/zod';
|
|
import { z } from 'zod';
|
|
import Field from './ui/Field';
|
|
import Input from './ui/Input';
|
|
import MobileInput from './ui/MobileInput';
|
|
import SearchableSelect from './ui/SearchableSelect';
|
|
import type { SelectOption } from './ui/SearchableSelect';
|
|
import PersianDatePicker from './ui/PersianDatePicker';
|
|
import { iranNationalCodeOptionalSchema, iranMobileOptionalSchema } from '../lib/utils';
|
|
|
|
/**
|
|
* اسکیمای فرم «اطلاعات پرونده». مطابق قواعد بکاند:
|
|
* نام الزامی؛ موبایل در صورت پرشدن باید ^09\d{9}$؛ کدملی در صورت پرشدن ۱۰ رقم.
|
|
* `birth_date` رشتهٔ میلادی YYYY-MM-DD است؛ تبدیل به timestamp در لایهٔ صفحه انجام میشود.
|
|
*/
|
|
export const patientFormSchema = z.object({
|
|
name: z.string().trim().min(1, 'نام و نام خانوادگی الزامی است'),
|
|
mobile: iranMobileOptionalSchema,
|
|
national_code: iranNationalCodeOptionalSchema,
|
|
gender: z.string().nullable(),
|
|
birth_date: z.string(),
|
|
fathers_name: z.string(),
|
|
marital_status: z.string().nullable(),
|
|
basic_insurance_id: z.union([z.number(), z.string(), z.null()]),
|
|
supplementary_insurance_id: z.union([z.number(), z.string(), z.null()]),
|
|
field_of_study: z.string(),
|
|
education: z.string().nullable(),
|
|
job: z.string(),
|
|
province_id: z.union([z.number(), z.string(), z.null()]),
|
|
city_id: z.union([z.number(), z.string(), z.null()]),
|
|
home_phone: z.string(),
|
|
address: z.string(),
|
|
postal_code: z.string(),
|
|
referral_source: z.string().nullable(),
|
|
description: z.string(),
|
|
});
|
|
|
|
export type PatientFormValues = z.infer<typeof patientFormSchema>;
|
|
|
|
export interface PatientFormOptions {
|
|
gender: SelectOption[];
|
|
marital: SelectOption[];
|
|
education: SelectOption[];
|
|
insurance: SelectOption[];
|
|
supplementary: SelectOption[];
|
|
province: SelectOption[];
|
|
city: SelectOption[];
|
|
referral: SelectOption[];
|
|
}
|
|
|
|
interface Props {
|
|
defaultValues: PatientFormValues;
|
|
recordNumber?: string | null;
|
|
options: PatientFormOptions;
|
|
onSubmit: (values: PatientFormValues) => void;
|
|
isSubmitting?: boolean;
|
|
/** برای بارگذاری وابستهٔ شهرها هنگام تغییر استان (منطق در صفحه). */
|
|
onProvinceChange?: (provinceId: number | null) => void;
|
|
}
|
|
|
|
const grid: React.CSSProperties = {
|
|
display: 'grid',
|
|
gridTemplateColumns: 'repeat(3, 1fr)',
|
|
gap: 'var(--gap)',
|
|
};
|
|
|
|
export default function PatientRecordInfoForm({
|
|
defaultValues,
|
|
recordNumber,
|
|
options,
|
|
onSubmit,
|
|
isSubmitting,
|
|
onProvinceChange,
|
|
}: Props) {
|
|
const {
|
|
register,
|
|
handleSubmit,
|
|
control,
|
|
formState: { errors },
|
|
} = useForm<PatientFormValues>({
|
|
resolver: zodResolver(patientFormSchema),
|
|
defaultValues,
|
|
});
|
|
|
|
const sel = (name: keyof PatientFormValues, label: string, opts: SelectOption[], placeholder = 'انتخاب کنید...') => (
|
|
<Field label={label} error={errors[name]?.message as string | undefined}>
|
|
<Controller
|
|
name={name}
|
|
control={control}
|
|
render={({ field }) => (
|
|
<SearchableSelect
|
|
options={opts}
|
|
value={field.value as string | number | null}
|
|
onChange={field.onChange}
|
|
placeholder={placeholder}
|
|
/>
|
|
)}
|
|
/>
|
|
</Field>
|
|
);
|
|
|
|
return (
|
|
<form onSubmit={handleSubmit(onSubmit)} noValidate>
|
|
<div style={grid}>
|
|
<Field label="نام و نام خانوادگی مراجعه کننده" error={errors.name?.message}>
|
|
<Input {...register('name')} hasError={!!errors.name} placeholder="نام و نام خانوادگی" />
|
|
</Field>
|
|
|
|
<Field label="شماره پرونده">
|
|
<Input value={recordNumber ?? '—'} readOnly dir="ltr" />
|
|
</Field>
|
|
|
|
{sel('gender', 'جنسیت', options.gender)}
|
|
|
|
<Field label="کدملی" error={errors.national_code?.message}>
|
|
<Input {...register('national_code')} numeric hasError={!!errors.national_code} maxLength={10} placeholder="کد ملی" />
|
|
</Field>
|
|
|
|
<Field label="شماره تماس" error={errors.mobile?.message}>
|
|
<Controller
|
|
name="mobile"
|
|
control={control}
|
|
render={({ field }) => (
|
|
<MobileInput value={field.value} onChange={field.onChange} hasError={!!errors.mobile} />
|
|
)}
|
|
/>
|
|
</Field>
|
|
|
|
<Field label="تاریخ تولد" error={errors.birth_date?.message}>
|
|
<Controller
|
|
name="birth_date"
|
|
control={control}
|
|
render={({ field }) => (
|
|
<PersianDatePicker value={field.value} onChange={field.onChange} placeholder="تاریخ تولد" enableYearPicker />
|
|
)}
|
|
/>
|
|
</Field>
|
|
|
|
<Field label="نام پدر">
|
|
<Input {...register('fathers_name')} placeholder="نام پدر را وارد نمایید" />
|
|
</Field>
|
|
|
|
{sel('marital_status', 'وضعیت تاهل', options.marital)}
|
|
{sel('basic_insurance_id', 'بیمه پایه', options.insurance)}
|
|
{sel('supplementary_insurance_id', 'بیمه تکمیلی', options.supplementary)}
|
|
|
|
<Field label="رشته تحصیلی">
|
|
<Input {...register('field_of_study')} placeholder="رشته تحصیلی را وارد نمایید" />
|
|
</Field>
|
|
|
|
{sel('education', 'مقطع تحصیلی', options.education)}
|
|
|
|
<Field label="شغل">
|
|
<Input {...register('job')} placeholder="شغل را وارد نمایید" />
|
|
</Field>
|
|
|
|
<Field label="استان" error={errors.province_id?.message as string | undefined}>
|
|
<Controller
|
|
name="province_id"
|
|
control={control}
|
|
render={({ field }) => (
|
|
<SearchableSelect
|
|
options={options.province}
|
|
value={field.value as string | number | null}
|
|
onChange={(v) => { field.onChange(v); onProvinceChange?.(v === null ? null : Number(v)); }}
|
|
placeholder="انتخاب کنید..."
|
|
/>
|
|
)}
|
|
/>
|
|
</Field>
|
|
|
|
{sel('city_id', 'شهر', options.city)}
|
|
|
|
<Field label="تلفن ثابت">
|
|
<Input {...register('home_phone')} dir="ltr" placeholder="تلفن ثابت را وارد نمایید..." />
|
|
</Field>
|
|
|
|
<Field label="آدرس" style={{ gridColumn: 'span 2' }}>
|
|
<Input {...register('address')} placeholder="آدرس محل سکونت را وارد نمایید..." />
|
|
</Field>
|
|
|
|
<Field label="کد پستی">
|
|
<Input {...register('postal_code')} numeric maxLength={10} placeholder="کدپستی محل سکونت را وارد نمایید..." />
|
|
</Field>
|
|
|
|
{sel('referral_source', 'نحوه آشنایی', options.referral)}
|
|
</div>
|
|
|
|
<div style={{ marginTop: 'var(--gap)' }}>
|
|
<Field label="توضیحات">
|
|
<textarea
|
|
{...register('description')}
|
|
className="cp-input"
|
|
rows={4}
|
|
style={{ resize: 'vertical', minHeight: 96 }}
|
|
placeholder="توضیحات"
|
|
/>
|
|
</Field>
|
|
</div>
|
|
|
|
<div style={{ marginTop: 'var(--gap)' }}>
|
|
<button type="submit" className="cp-btn cp-btn-primary" disabled={isSubmitting}>
|
|
{isSubmitting ? 'در حال ثبت...' : 'ثبت اطلاعات'}
|
|
</button>
|
|
</div>
|
|
</form>
|
|
);
|
|
}
|