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>
87 lines
4.8 KiB
TypeScript
87 lines
4.8 KiB
TypeScript
import React from 'react';
|
|
import { useNavigate } from 'react-router-dom';
|
|
import { useMutation } from '@tanstack/react-query';
|
|
import { useForm } from 'react-hook-form';
|
|
import { zodResolver } from '@hookform/resolvers/zod';
|
|
import { z } from 'zod';
|
|
import { ArrowRightIcon, BuildingOffice2Icon } from '@heroicons/react/24/outline';
|
|
import { toast } from 'sonner';
|
|
import { api } from '../lib/api';
|
|
import type { ApiResponse } from '../lib/api';
|
|
import MobileInput from '../components/ui/MobileInput';
|
|
import { iranMobileSchema } from '../lib/utils';
|
|
import { latinDigitsField } from '../lib/forms';
|
|
|
|
const schema = z.object({
|
|
owner_mobile: iranMobileSchema,
|
|
name: z.string().min(2, 'نام کلینیک حداقل ۲ کاراکتر'),
|
|
telephone: z.string().max(20).optional().or(z.literal('')),
|
|
address: z.string().max(500).optional().or(z.literal('')),
|
|
info: z.string().max(2000).optional().or(z.literal('')),
|
|
});
|
|
type FormValues = z.infer<typeof schema>;
|
|
|
|
interface ClinicCreated { uuid: string; name: string; is_active: boolean }
|
|
|
|
export default function ClinicFormPage() {
|
|
const navigate = useNavigate();
|
|
const { register, handleSubmit, formState: { errors, isSubmitting } } = useForm<FormValues>({ resolver: zodResolver(schema) });
|
|
|
|
const mutation = useMutation<ApiResponse<ClinicCreated>, Error, FormValues>({
|
|
mutationFn: (body) => api.post('/api/v1/admin/clinic', body),
|
|
onSuccess: (res) => {
|
|
toast.success(`کلینیک "${res?.data?.name}" ایجاد شد`);
|
|
navigate('/admin/clinics');
|
|
},
|
|
onError: (err) => toast.error(err.message ?? 'خطا در ایجاد کلینیک'),
|
|
});
|
|
|
|
return (
|
|
<div className="page" style={{ maxWidth: 640 }}>
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 28 }}>
|
|
<button className="btn ghost sm" onClick={() => navigate('/admin/clinics')}>
|
|
<ArrowRightIcon style={{ width: 16, height: 16 }} />بازگشت
|
|
</button>
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
|
<BuildingOffice2Icon style={{ width: 22, height: 22, color: 'var(--primary)' }} />
|
|
<h1 style={{ margin: 0, fontSize: 20, fontWeight: 700 }}>افزودن کلینیک جدید</h1>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="card">
|
|
<form onSubmit={handleSubmit((v) => mutation.mutate(v))} style={{ display: 'flex', flexDirection: 'column', gap: 20 }}>
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
|
<label style={{ fontSize: 13, fontWeight: 600 }}>شماره موبایل صاحب کلینیک <span style={{ color: 'var(--red)' }}>*</span></label>
|
|
<MobileInput className="field" hasError={!!errors.owner_mobile} {...register('owner_mobile')} />
|
|
{errors.owner_mobile && <span style={{ color: 'var(--red)', fontSize: 12 }}>{errors.owner_mobile.message}</span>}
|
|
<span style={{ fontSize: 11, color: 'var(--text-3)' }}>اگر این موبایل در سیستم نباشد، کاربر جدید ساخته میشود</span>
|
|
</div>
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
|
<label style={{ fontSize: 13, fontWeight: 600 }}>نام کلینیک <span style={{ color: 'var(--red)' }}>*</span></label>
|
|
<input className="field" placeholder="مثال: کلینیک تخصصی پارسیان" {...register('name')} />
|
|
{errors.name && <span style={{ color: 'var(--red)', fontSize: 12 }}>{errors.name.message}</span>}
|
|
</div>
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
|
<label style={{ fontSize: 13, fontWeight: 600 }}>تلفن ثابت</label>
|
|
<input className="field" placeholder="02xxxxxxxx" {...latinDigitsField(register('telephone'))} />
|
|
</div>
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
|
<label style={{ fontSize: 13, fontWeight: 600 }}>آدرس</label>
|
|
<input className="field" placeholder="آدرس کلینیک" {...register('address')} />
|
|
</div>
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
|
<label style={{ fontSize: 13, fontWeight: 600 }}>توضیحات</label>
|
|
<textarea className="field" rows={3} placeholder="درباره کلینیک..." {...register('info')} style={{ resize: 'vertical' }} />
|
|
</div>
|
|
<div style={{ display: 'flex', gap: 12, justifyContent: 'flex-end', paddingTop: 8 }}>
|
|
<button type="button" className="btn ghost" onClick={() => navigate('/admin/clinics')}>انصراف</button>
|
|
<button type="submit" className="btn primary" disabled={isSubmitting || mutation.isPending}>
|
|
{mutation.isPending ? 'در حال ذخیره...' : 'ایجاد کلینیک'}
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|