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

510 lines
21 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 { useParams, useNavigate } from 'react-router-dom';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { PencilIcon, TrashIcon, CheckCircleIcon, XCircleIcon, UserGroupIcon, CalendarDaysIcon, ClockIcon, ReceiptPercentIcon, PlusIcon } from '@heroicons/react/24/outline';
import { toast } from 'sonner';
import { api } from '../lib/api';
import type { ApiResponse, PaginatedResponse } from '../lib/api';
import type { Representation, City } from '../types';
import { formatDate, formatDateTime, formatNumber } from '../lib/utils';
import PageHeader from '../components/ui/PageHeader';
import { ActiveBadge } from '../components/ui/StatusBadge';
import ConfirmDialog from '../components/ui/ConfirmDialog';
import Modal from '../components/ui/Modal';
import SearchableSelect from '../components/ui/SearchableSelect';
import { digitsOnly } from '../lib/utils';
interface RepDoctor {
uuid: string;
id: number;
name: string;
gender: string | null;
medical_code: string | null;
is_active: boolean;
owner_status?: string;
past_count: number;
upcoming_count: number;
created_at: string;
}
interface RepAppointment {
uuid: string;
slot_start: number;
slot_end: number;
status: string;
patient_name: string | null;
doctor_uuid: string;
doctor_name: string;
}
interface UnassignedDoctor {
uuid: string;
name: string;
medical_code: string | null;
mobile: string | null;
}
function DetailRow({ label, value }: { label: string; value: React.ReactNode }) {
return (
<div className="cp-info-row items-start gap-4">
<span className="text-sm text-gray-500 w-40 shrink-0">{label}</span>
<span className="cp-info-value">{value ?? '—'}</span>
</div>
);
}
function StatTile({ icon: Icon, label, value, tone }: {
icon: React.ComponentType<{ className?: string }>;
label: string; value: React.ReactNode; tone: 'violet' | 'green' | 'amber' | 'pink';
}) {
return (
<div className="cp-stat">
<div className="cp-stat-icon" style={{ background: `var(--stat-${tone}-bg)`, color: `var(--stat-${tone}-fg)` }}>
<Icon className="w-5 h-5" />
</div>
<div className="min-w-0">
<div className="text-xs text-[var(--text-3)] mb-1">{label}</div>
<div className="text-xl font-bold text-[var(--text)] leading-none">{value}</div>
</div>
</div>
);
}
export default function RepresentationDetailPage() {
const { uuid } = useParams<{ uuid: string }>();
const navigate = useNavigate();
const qc = useQueryClient();
const [deleteOpen, setDeleteOpen] = useState(false);
const [editOpen, setEditOpen] = useState(false);
const [formData, setFormData] = useState({ full_name: '', city_id: '', mobile_number: '', commission_percent: '' });
const [attachOpen, setAttachOpen] = useState(false);
const [attachSearch, setAttachSearch] = useState('');
const [apptScope, setApptScope] = useState<'upcoming' | 'past'>('upcoming');
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 { data, isLoading } = useQuery({
queryKey: ['representation', uuid],
queryFn: () => api.get<ApiResponse<{ data: Representation }>>(`/api/v1/representation/${uuid}`),
enabled: !!uuid,
});
const rep: Representation | undefined = (data?.data as any)?.data ?? data?.data;
const doctorsQuery = useQuery({
queryKey: ['representation-doctors', uuid],
queryFn: () => api.get<PaginatedResponse<RepDoctor>>(`/api/v1/admin/representations/${uuid}/doctors?limit=100`),
enabled: !!uuid,
});
const repDoctors: RepDoctor[] = doctorsQuery.data?.data ?? [];
const repDoctorsTotal = doctorsQuery.data?.meta?.totalRecords ?? repDoctors.length;
const upcomingTotal = repDoctors.reduce((s, d) => s + (d.upcoming_count ?? 0), 0);
const pastTotal = repDoctors.reduce((s, d) => s + (d.past_count ?? 0), 0);
const appointmentsQuery = useQuery({
queryKey: ['representation-appointments', uuid, apptScope],
queryFn: () => api.get<PaginatedResponse<RepAppointment>>(`/api/v1/admin/representations/${uuid}/appointments?scope=${apptScope}&limit=50`),
enabled: !!uuid,
});
const repAppointments: RepAppointment[] = appointmentsQuery.data?.data ?? [];
const repAppointmentsTotal = appointmentsQuery.data?.meta?.totalRecords ?? repAppointments.length;
const unassignedQuery = useQuery({
queryKey: ['unassigned-doctors', attachSearch],
queryFn: () => api.get<PaginatedResponse<UnassignedDoctor>>(`/api/v1/admin/doctors?unassigned=1&limit=20${attachSearch ? `&search=${encodeURIComponent(attachSearch)}` : ''}`),
enabled: attachOpen,
});
const unassignedDoctors: UnassignedDoctor[] = unassignedQuery.data?.data ?? [];
const attachMutation = useMutation({
mutationFn: (doctorUuid: string) =>
api.post<ApiResponse<unknown>>(`/api/v1/admin/representations/${uuid}/doctors`, { doctor_uuid: doctorUuid }),
onSuccess: () => {
toast.success('پزشک به نماینده متصل شد');
setAttachOpen(false);
setAttachSearch('');
qc.invalidateQueries({ queryKey: ['representation-doctors', uuid] });
qc.invalidateQueries({ queryKey: ['representations'] });
},
onError: (err: Error) => toast.error(err.message),
});
const updateMutation = useMutation({
mutationFn: (d: Partial<Representation>) =>
api.patch<ApiResponse<Representation>>(`/api/v1/representation/${uuid}`, d),
onSuccess: () => {
toast.success('اطلاعات بروزرسانی شد');
setEditOpen(false);
qc.invalidateQueries({ queryKey: ['representation', uuid] });
qc.invalidateQueries({ queryKey: ['representations'] });
},
onError: (err: Error) => toast.error(err.message),
});
const toggleActiveMutation = useMutation({
mutationFn: () =>
api.patch<ApiResponse<Representation>>(`/api/v1/representation/${uuid}`, {
active: !(rep?.active ?? rep?.is_active),
}),
onSuccess: () => {
toast.success('وضعیت تغییر کرد');
qc.invalidateQueries({ queryKey: ['representation', uuid] });
},
onError: (err: Error) => toast.error(err.message),
});
const deleteMutation = useMutation({
mutationFn: () => api.delete<ApiResponse<null>>(`/api/v1/representation/${uuid}`),
onSuccess: () => {
toast.success('نماینده حذف شد');
navigate('/admin/representations');
},
onError: (err: Error) => toast.error(err.message),
});
const openEdit = () => {
if (!rep) return;
setFormData({
full_name: rep.full_name ?? rep.domain ?? '',
city_id: rep.city_id ? String(rep.city_id) : '',
mobile_number: rep.mobile_number ?? '',
commission_percent: String(rep.commission_percent),
});
setEditOpen(true);
};
const isActive = rep?.active ?? rep?.is_active ?? false;
if (isLoading) {
return (
<div>
<PageHeader
title="جزئیات نماینده"
breadcrumbs={[
{ label: 'داشبورد', to: '/admin/dashboard' },
{ label: 'نمایندگان', to: '/admin/representations' },
{ label: 'جزئیات' },
]}
/>
<div className="bg-white rounded-2xl shadow-sm border border-gray-100 p-6 animate-pulse">
<div className="space-y-4">
{[1, 2, 3, 4, 5].map((i) => (
<div key={i} className="h-8 bg-gray-100 rounded" />
))}
</div>
</div>
</div>
);
}
if (!rep) {
return (
<div>
<PageHeader
title="نماینده یافت نشد"
breadcrumbs={[
{ label: 'داشبورد', to: '/admin/dashboard' },
{ label: 'نمایندگان', to: '/admin/representations' },
]}
/>
<div className="cp-card p-12 text-center text-slate-400 dark:text-slate-500">
نماینده‌ای با این شناسه یافت نشد.
</div>
</div>
);
}
const name = rep.full_name ?? rep.domain ?? '—';
return (
<div>
<PageHeader
title={name}
breadcrumbs={[
{ label: 'داشبورد', to: '/admin/dashboard' },
{ label: 'نمایندگان', to: '/admin/representations' },
{ label: name },
]}
action={
<div className="flex items-center gap-2">
<button
onClick={() => toggleActiveMutation.mutate()}
disabled={toggleActiveMutation.isPending}
className={`flex items-center gap-1.5 px-3 py-2 text-sm rounded-[10px] border transition-colors disabled:opacity-50 ${
isActive
? 'border-red-300 text-red-600 hover:bg-red-50'
: 'border-green-300 text-green-600 hover:bg-green-50'
}`}
>
{isActive
? <><XCircleIcon className="w-4 h-4" /> غیرفعال کردن</>
: <><CheckCircleIcon className="w-4 h-4" /> فعال کردن</>
}
</button>
<button
onClick={openEdit}
className="flex items-center gap-1.5 px-3 py-2 text-sm rounded-[10px] border border-gray-300 text-gray-700 hover:bg-gray-50 transition-colors"
>
<PencilIcon className="w-4 h-4" />
ویرایش
</button>
<button
onClick={() => setDeleteOpen(true)}
className="flex items-center gap-1.5 px-3 py-2 text-sm rounded-[10px] border border-red-300 text-red-600 hover:bg-red-50 transition-colors"
>
<TrashIcon className="w-4 h-4" />
حذف
</button>
</div>
}
/>
{/* KPI tiles */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
<StatTile icon={UserGroupIcon} tone="violet" label="تعداد پزشکان" value={formatNumber(repDoctorsTotal)} />
<StatTile icon={CalendarDaysIcon} tone="green" label="نوبت آینده" value={formatNumber(upcomingTotal)} />
<StatTile icon={ClockIcon} tone="amber" label="نوبت گذشته" value={formatNumber(pastTotal)} />
<StatTile icon={ReceiptPercentIcon} tone="pink" label="درصد کمیسیون" value={`${formatNumber(rep.commission_percent)}٪`} />
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Basic Info */}
<div className="cp-card p-6">
<h2 className="text-sm font-semibold text-gray-700 mb-4">اطلاعات پایه</h2>
<DetailRow label="نام کامل" value={rep.full_name} />
<DetailRow label="موبایل" value={
rep.mobile_number
? <span dir="ltr">{rep.mobile_number}</span>
: null
} />
<DetailRow label="شهر" value={rep.city ?? (rep.city_id ? `شناسه ${rep.city_id}` : null)} />
<DetailRow label="درصد کمیسیون" value={`${formatNumber(rep.commission_percent)}٪`} />
<DetailRow label="وضعیت" value={<ActiveBadge active={isActive} />} />
<DetailRow label="تاریخ ثبت" value={formatDate(rep.created_at)} />
</div>
{/* Bank Account */}
<div className="cp-card p-6">
<h2 className="text-sm font-semibold text-gray-700 mb-4">اطلاعات بانکی</h2>
{rep.bank_account ? (
<>
<DetailRow label="شماره کارت" value={
rep.bank_account.card
? <span dir="ltr" className="font-mono tracking-widest">{rep.bank_account.card}</span>
: null
} />
<DetailRow label="نام بانک" value={rep.bank_account.bank_name} />
<DetailRow label="شماره شبا" value={
rep.bank_account.iban
? <span dir="ltr" className="font-mono text-xs">{rep.bank_account.iban}</span>
: null
} />
</>
) : (
<p className="text-sm text-gray-400 text-center py-6">اطلاعات بانکی ثبت نشده</p>
)}
</div>
</div>
{/* Doctors of this representation */}
<div className="cp-card p-6 mt-6">
<div className="flex items-center justify-between mb-4 flex-wrap gap-2">
<div className="flex items-center gap-2">
<UserGroupIcon className="w-5 h-5 text-[var(--text-3)]" />
<h2 className="text-sm font-semibold text-gray-700">پزشکان این نماینده</h2>
<span className="badge violet">{formatNumber(repDoctorsTotal)}</span>
</div>
<button className="btn primary sm inline-flex items-center gap-1" onClick={() => setAttachOpen(true)}>
<PlusIcon className="w-4 h-4" /> افزودن پزشک
</button>
</div>
{doctorsQuery.isLoading ? (
<p className="text-sm text-gray-400 text-center py-8">در حال بارگذاری…</p>
) : repDoctors.length === 0 ? (
<div className="text-center py-10">
<UserGroupIcon className="w-10 h-10 mx-auto text-gray-300 mb-2" />
<p className="text-sm text-gray-400">این نماینده هنوز پزشکی ندارد</p>
<button className="btn primary sm mt-3 inline-flex items-center gap-1" onClick={() => setAttachOpen(true)}>
<PlusIcon className="w-4 h-4" /> افزودن اولین پزشک
</button>
</div>
) : (
<div className="table-wrap">
<table className="t">
<thead>
<tr>
<th>نام پزشک</th>
<th>کد نظام</th>
<th>نوبت گذشته</th>
<th>نوبت آینده</th>
<th>وضعیت</th>
</tr>
</thead>
<tbody>
{repDoctors.map((d) => (
<tr key={d.uuid} style={{ cursor: 'pointer' }} onClick={() => navigate(`/admin/doctors/${d.uuid}`)}>
<td><b>{d.name}</b></td>
<td style={{ fontFamily: 'monospace' }} className="text-[var(--text-3)]"><span dir="ltr">{d.medical_code ?? '—'}</span></td>
<td>{formatNumber(d.past_count)}</td>
<td>{d.upcoming_count > 0 ? <span className="badge green">{formatNumber(d.upcoming_count)}</span> : <span className="text-[var(--text-3)]">۰</span>}</td>
<td><ActiveBadge active={d.is_active} /></td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
{/* Appointments of this representation */}
<div className="cp-card p-6 mt-6">
<div className="flex items-center justify-between mb-4 flex-wrap gap-2">
<div className="flex items-center gap-2">
<CalendarDaysIcon className="w-5 h-5 text-[var(--text-3)]" />
<h2 className="text-sm font-semibold text-gray-700">نوبت‌های این نماینده</h2>
{!appointmentsQuery.isLoading && <span className="badge blue">{formatNumber(repAppointmentsTotal)}</span>}
</div>
<div className="seg">
<button className={apptScope === 'upcoming' ? 'on' : ''} onClick={() => setApptScope('upcoming')}>آینده</button>
<button className={apptScope === 'past' ? 'on' : ''} onClick={() => setApptScope('past')}>گذشته</button>
</div>
</div>
{appointmentsQuery.isLoading ? (
<p className="text-sm text-gray-400 text-center py-8">در حال بارگذاری…</p>
) : repAppointments.length === 0 ? (
<div className="text-center py-10">
<CalendarDaysIcon className="w-10 h-10 mx-auto text-gray-300 mb-2" />
<p className="text-sm text-gray-400">
{apptScope === 'upcoming' ? 'نوبت آینده‌ای ثبت نشده است' : 'نوبت گذشته‌ای وجود ندارد'}
</p>
</div>
) : (
<div className="table-wrap">
<table className="t">
<thead>
<tr>
<th>پزشک</th>
<th>بیمار</th>
<th>زمان</th>
<th>وضعیت</th>
</tr>
</thead>
<tbody>
{repAppointments.map((a) => (
<tr key={a.uuid} style={{ cursor: 'pointer' }} onClick={() => navigate(`/admin/doctors/${a.doctor_uuid}`)}>
<td><b>{a.doctor_name}</b></td>
<td>{a.patient_name ?? '—'}</td>
<td><span dir="ltr">{formatDateTime(a.slot_start)}</span></td>
<td><span className="badge gray">{a.status}</span></td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
{/* Attach Doctor Modal */}
<Modal open={attachOpen} title="افزودن پزشک به نماینده"
onClose={() => { setAttachOpen(false); setAttachSearch(''); }}
footer={<button onClick={() => { setAttachOpen(false); setAttachSearch(''); }} className="btn ghost sm">بستن</button>}
>
<input
type="text"
className="input w-full mb-3"
placeholder="جستجوی پزشک (نام یا موبایل)…"
value={attachSearch}
onChange={(e) => setAttachSearch(e.target.value)}
/>
{unassignedQuery.isLoading ? (
<p className="text-sm text-gray-400 text-center py-6">در حال بارگذاری…</p>
) : unassignedDoctors.length === 0 ? (
<p className="text-sm text-gray-400 text-center py-6">پزشکِ بدون نماینده‌ای یافت نشد</p>
) : (
<div style={{ maxHeight: 360, overflowY: 'auto' }}>
{unassignedDoctors.map((d) => (
<div key={d.uuid} className="flex items-center justify-between py-2 border-b border-gray-100">
<div>
<b className="text-sm">{d.name}</b>
<div className="text-xs text-gray-400" dir="ltr">{d.medical_code ?? d.mobile ?? '—'}</div>
</div>
<button className="btn primary sm" disabled={attachMutation.isPending}
onClick={() => attachMutation.mutate(d.uuid)}>افزودن</button>
</div>
))}
</div>
)}
</Modal>
{/* Edit Modal */}
<Modal open={editOpen} title="ویرایش نماینده"
onClose={() => setEditOpen(false)}
footer={
<>
<button onClick={() => setEditOpen(false)}
className="btn ghost sm">
لغو
</button>
<button
onClick={() => updateMutation.mutate({
full_name: formData.full_name,
city_id: formData.city_id ? parseInt(formData.city_id) : null,
mobile_number: formData.mobile_number || null,
commission_percent: parseFloat(formData.commission_percent) || rep.commission_percent,
} as any)}
disabled={updateMutation.isPending}
className="btn primary sm">
{updateMutation.isPending ? 'در حال ذخیره...' : 'ذخیره'}
</button>
</>
}
>
<div className="space-y-4">
<div>
<label className="">نام کامل</label>
<input value={formData.full_name} onChange={(e) => setFormData((p) => ({ ...p, full_name: e.target.value }))}
className="cp-input h-11" />
</div>
<div>
<label className="">شهر</label>
<SearchableSelect
options={cities.map(c => ({ value: String(c.id), label: c.name }))}
value={formData.city_id || null}
onChange={(v) => setFormData(p => ({ ...p, city_id: v ? String(v) : '' }))}
placeholder="انتخاب شهر"
isClearable
/>
</div>
<div>
<label className="">موبایل</label>
<input value={formData.mobile_number} onChange={(e) => setFormData((p) => ({ ...p, mobile_number: e.target.value }))}
dir="ltr" className="cp-input h-11" />
</div>
<div>
<label className="">درصد کمیسیون</label>
<input value={formData.commission_percent} onChange={(e) => setFormData((p) => ({ ...p, commission_percent: digitsOnly(e.target.value, 3) }))}
type="text" inputMode="numeric" dir="ltr"
className="cp-input h-11" />
</div>
</div>
</Modal>
<ConfirmDialog
open={deleteOpen}
title="حذف نماینده"
message={`آیا از حذف نماینده "${name}" اطمینان دارید؟`}
confirmLabel="حذف"
danger
loading={deleteMutation.isPending}
onConfirm={() => deleteMutation.mutate()}
onCancel={() => setDeleteOpen(false)}
/>
</div>
);
}