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

272 lines
13 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 React, { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useNavigate } from 'react-router-dom';
import { EyeIcon, NoSymbolIcon, PlusIcon, MagnifyingGlassIcon } from '@heroicons/react/24/outline';
import { useForm, Controller } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { toast } from 'sonner';
import { api } from '../lib/api';
import type { ApiResponse, PaginatedResponse } from '../lib/api';
import MobileInput from '../components/ui/MobileInput';
import { iranMobileSchema } from '../lib/utils';
import type { Representation, City } from '../types';
import { formatDate, formatRial, formatNumber } from '../lib/utils';
import DataTable, { Column } from '../components/ui/DataTable';
import { ActiveBadge } from '../components/ui/StatusBadge';
import Pagination from '../components/ui/Pagination';
import ConfirmDialog from '../components/ui/ConfirmDialog';
import Modal from '../components/ui/Modal';
import SearchableSelect from '../components/ui/SearchableSelect';
import { numericField } from '../lib/forms';
const schema = z.object({
full_name: z.string().min(2, 'نام الزامی است'),
mobile_number: iranMobileSchema,
city_ids: z.array(z.number()).optional(),
domain: z.string().optional(),
is_global: z.boolean().optional(),
commission_percent: z.coerce.number().min(0).max(100),
});
type FormData = z.infer<typeof schema>;
export default function RepresentationsPage() {
const navigate = useNavigate();
const qc = useQueryClient();
const [page, setPage] = useState(1);
const [search, setSearch] = useState('');
const [cityFilter, setCityFilter] = useState<number | null>(null);
const [addOpen, setAddOpen] = useState(false);
const [deleteTarget, setDeleteTarget] = useState<Representation | null>(null);
const limit = 15;
const citiesQuery = useQuery({
queryKey: ['cities-select'],
queryFn: () => api.get<PaginatedResponse<City>>('/api/v1/admin/cities?limit=200'),
staleTime: 5 * 60_000,
});
const cities: City[] = citiesQuery.data?.data ?? [];
const cityOptions = cities.map((c) => ({ value: c.id, label: c.name }));
const { data, isLoading } = useQuery({
queryKey: ['representations', page, search, cityFilter],
queryFn: () => {
const params = new URLSearchParams({ page: String(page), limit: String(limit) });
if (search) params.set('search', search);
if (cityFilter !== null) params.set('city_id', String(cityFilter));
return api.get<PaginatedResponse<Representation>>(`/api/v1/admin/representations?${params}`);
},
});
const { register, handleSubmit, reset, control, watch, formState: { errors, isSubmitting } } = useForm<FormData>({
resolver: zodResolver(schema),
defaultValues: { commission_percent: 10, city_ids: [], is_global: false },
});
const isGlobal = watch('is_global') ?? false;
const createMutation = useMutation({
mutationFn: (d: FormData) =>
api.post<ApiResponse<Representation>>('/api/v1/representation', {
full_name: d.full_name,
mobile_number: d.mobile_number,
city_ids: d.city_ids ?? [],
is_global: d.is_global ?? false,
...(d.domain?.trim() && { domain: d.domain.trim() }),
commission_percent: d.commission_percent,
}),
onSuccess: () => {
toast.success('نماینده اضافه شد');
setAddOpen(false);
reset();
qc.invalidateQueries({ queryKey: ['representations'] });
},
onError: (err: Error) => toast.error(err.message),
});
const deactivateMutation = useMutation({
mutationFn: (r: Representation) =>
api.patch<ApiResponse<Representation>>(`/api/v1/representation/${r.uuid}`, { active: false }),
onSuccess: () => {
toast.success('نماینده غیرفعال شد');
setDeleteTarget(null);
qc.invalidateQueries({ queryKey: ['representations'] });
},
onError: (err: Error) => toast.error(err.message),
});
const columns: Column<Representation>[] = [
{ key: 'full_name', header: 'نام', render: (r) => (
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}>
<b>{r.full_name}</b>
{r.is_global && <span className="badge green" title="نماینده سراسری">سراسری </span>}
</span>
) },
{ key: 'mobile_number', header: 'موبایل', render: (r) => <span dir="ltr">{r.mobile_number ?? '—'}</span> },
{ key: 'domain', header: 'دامنه', render: (r) => r.domain ? <span dir="ltr">{r.domain}</span> : '—' },
{ key: 'city', header: 'شهرها', render: (r) => r.city ?? '—' },
{ key: 'commission_percent', header: 'کمیسیون', render: (r) => `${formatNumber(r.commission_percent)}٪` },
{ key: 'doctor_count', header: 'تعداد پزشکان', render: (r) => formatNumber(r.doctor_count ?? 0) },
{ key: 'appointment_count', header: 'تعداد نوبت', render: (r) => formatNumber(r.appointment_count ?? 0) },
{ key: 'wallet_balance', header: 'موجودی کیف‌پول', render: (r) => formatRial(r.wallet_balance ?? 0) },
{ key: 'is_active', header: 'وضعیت', render: (r) => <ActiveBadge active={r.is_active ?? r.active ?? false} /> },
{ key: 'created_at', header: 'تاریخ ثبت', render: (r) => formatDate(r.created_at) },
];
const items = data?.data ?? [];
const total = data?.meta?.totalRecords ?? 0;
return (
<div className="fade-in">
<div className="card-title-row" style={{ marginBottom: 'var(--gap)' }}>
<div>
<h1 className="section-title">نمایندگان</h1>
<div className="muted">{total} نماینده ثبت‌شده</div>
</div>
<button onClick={() => setAddOpen(true)} className="btn primary sm">
<PlusIcon style={{ width: 16, height: 16 }} />
افزودن نماینده
</button>
</div>
<div className="card">
<div className="card-pad" style={{ paddingBottom: 0 }}>
<div className="toolbar">
<div className="field" style={{ minWidth: 240 }}>
<MagnifyingGlassIcon style={{ width: 17, height: 17 }} />
<input
value={search}
onChange={(e) => { setSearch(e.target.value); setPage(1); }}
placeholder="جستجو بر اساس نام یا موبایل..."
/>
</div>
<div style={{ minWidth: 200 }}>
<SearchableSelect
options={cityOptions}
value={cityFilter}
onChange={(val) => { setCityFilter(val as number | null); setPage(1); }}
placeholder="فیلتر شهر..."
isClearable
isLoading={citiesQuery.isLoading}
noOptionsMessage="هیچ شهری یافت نشد"
/>
</div>
</div>
</div>
<DataTable<Representation>
columns={columns}
data={items}
loading={isLoading}
emptyMessage="هیچ نماینده‌ای یافت نشد"
actions={(rep) => (
<>
<button onClick={() => navigate(`/admin/representations/${rep.uuid}`)}
className="mini-btn" title="مشاهده">
<EyeIcon style={{ width: 15, height: 15 }} />
</button>
{(rep.is_active ?? rep.active) && (
<button onClick={() => setDeleteTarget(rep)}
className="mini-btn danger" title="غیرفعال‌سازی">
<NoSymbolIcon style={{ width: 15, height: 15 }} />
</button>
)}
</>
)}
/>
<Pagination page={page} total={total} limit={limit} onPageChange={setPage} />
</div>
<Modal open={addOpen} title="افزودن نماینده" onClose={() => { setAddOpen(false); reset(); }}
footer={
<>
<button onClick={() => { setAddOpen(false); reset(); }} className="btn ghost sm">لغو</button>
<button form="add-rep-form" type="submit" disabled={isSubmitting} className="btn primary sm">
{isSubmitting ? 'در حال ذخیره...' : 'ذخیره'}
</button>
</>
}
>
<form id="add-rep-form" onSubmit={handleSubmit((d) => createMutation.mutate(d))}>
<div className="form-row">
<label>نام کامل</label>
<input {...register('full_name')} placeholder="علی محمدی" className="input" />
{errors.full_name && <p className="err-text">{errors.full_name.message}</p>}
</div>
<div className="form-row" style={{ marginTop: 12 }}>
<label>شماره موبایل</label>
<MobileInput className="input" hasError={!!errors.mobile_number} {...register('mobile_number')} />
{errors.mobile_number && <p className="err-text">{errors.mobile_number.message}</p>}
</div>
<div className="form-row" style={{ marginTop: 12 }}>
<label style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<input type="checkbox" {...register('is_global')} style={{ width: 16, height: 16 }} />
نماینده سراسری (دامنه اختصاصی فقط پزشکان/کلینیک‌های خودش نمایش داده می‌شوند)
</label>
</div>
<div className="form-row" style={{ marginTop: 12 }}>
<label>دامنه</label>
<input {...register('domain')} placeholder="example-nobat.ir" dir="ltr" className="input" />
{errors.domain && <p className="err-text">{errors.domain.message}</p>}
</div>
<div className="form-row" style={{ marginTop: 12 }}>
<label>شهرها {isGlobal && <span className="muted">(برای نماینده سراسری اختیاری)</span>}</label>
<Controller
name="city_ids"
control={control}
render={({ field }) => {
const selected: number[] = field.value ?? [];
return (
<div>
<SearchableSelect
options={cityOptions.filter((o) => !selected.includes(o.value))}
value={null}
onChange={(val) => {
if (val !== null && !selected.includes(val as number)) {
field.onChange([...selected, val as number]);
}
}}
placeholder={isGlobal ? 'اختیاری — افزودن شهر...' : 'افزودن شهر...'}
isClearable
isLoading={citiesQuery.isLoading}
noOptionsMessage="هیچ شهری یافت نشد"
/>
{selected.length > 0 && (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, marginTop: 8 }}>
{selected.map((id) => (
<span key={id} className="badge" style={{ display: 'inline-flex', alignItems: 'center', gap: 4 }}>
{cityOptions.find((o) => o.value === id)?.label ?? id}
<button type="button" className="mini-btn" style={{ padding: 0, width: 16, height: 16 }}
onClick={() => field.onChange(selected.filter((v) => v !== id))}>
×
</button>
</span>
))}
</div>
)}
</div>
);
}}
/>
</div>
<div className="form-row" style={{ marginTop: 12 }}>
<label>درصد کمیسیون</label>
<input {...numericField(register('commission_percent'), 3)} placeholder="10" dir="ltr"
className="input" />
{errors.commission_percent && <p className="err-text">{errors.commission_percent.message}</p>}
</div>
</form>
</Modal>
<ConfirmDialog
open={!!deleteTarget}
title="غیرفعال‌سازی نماینده"
message={`آیا از غیرفعال‌سازی نماینده "${deleteTarget?.full_name}" اطمینان دارید؟ این نماینده دیگر فعال نخواهد بود.`}
confirmLabel="غیرفعال‌سازی"
danger
loading={deactivateMutation.isPending}
onConfirm={() => deleteTarget && deactivateMutation.mutate(deleteTarget)}
onCancel={() => setDeleteTarget(null)}
/>
</div>
);
}