Files
clinicpro/assets/admin/pages/ClinicFormPage.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

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>
);
}