Files
clinicpro/assets/admin/pages/RepresentationProfilePage.tsx
T
hamedandClaude Opus 4.8 1508d38daa feat(representation profile): Iranian national-code + IBAN validation, English digits
- national code: full Iranian checksum (control digit + reject all-same),
  not just length 10 → "کد ملی نامعتبر است" on failure
- IBAN (شبا): validate IR + 24 digits + mod-97; input forces uppercase,
  strips to IR/digits, maxLength 26
- Persian/Arabic digits converted to Latin on input and before validation
  so the field behaves with an English/numeric keyboard

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 15:20:08 +03:30

235 lines
10 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 { toast } from 'sonner';
import { CheckBadgeIcon, TrashIcon, PlusIcon } from '@heroicons/react/24/outline';
import { api } from '../lib/api';
import type { ApiResponse } from '../lib/api';
import PersianDatePicker from '../components/ui/PersianDatePicker';
// تبدیل تاریخ میلادی ISO (YYYY-MM-DD) به شمسی Y/m/d برای استعلام api.ir
function toJalali(iso: string): string {
if (!iso) return '';
const parts = new Intl.DateTimeFormat('en-US-u-ca-persian-nu-latn', {
year: 'numeric', month: '2-digit', day: '2-digit',
}).formatToParts(new Date(iso + 'T00:00:00'));
const get = (t: string) => parts.find(p => p.type === t)?.value ?? '';
const y = get('year'), m = get('month'), d = get('day');
return y && m && d ? `${y}/${m}/${d}` : '';
}
// ارقام فارسی/عربی → لاتین (کیبورد انگلیسی؛ ورودی چسبانده‌شده هم نرمال شود)
function toLatinDigits(s: string): string {
return s.replace(/[۰-۹٠-٩]/g, (d) =>
String('۰۱۲۳۴۵۶۷۸۹٠١٢٣٤٥٦٧٨٩'.indexOf(d) % 10),
);
}
// اعتبارسنجی کد ملی ایران (طول ۱۰ + رقم کنترلی)
function isValidIranNationalCode(code: string): boolean {
if (!/^\d{10}$/.test(code)) return false;
if (/^(\d)\1{9}$/.test(code)) return false; // ارقام یکسان نامعتبر
const check = +code[9];
let sum = 0;
for (let i = 0; i < 9; i++) sum += +code[i] * (10 - i);
const r = sum % 11;
return r < 2 ? check === r : check === 11 - r;
}
// اعتبارسنجی شبای ایران: IR + ۲۴ رقم + کنترل mod-97
function isValidIranIban(raw: string): boolean {
const iban = toLatinDigits(raw).replace(/\s/g, '').toUpperCase();
if (!/^IR\d{24}$/.test(iban)) return false;
const rearranged = iban.slice(4) + iban.slice(0, 4);
const numeric = rearranged.replace(/[A-Z]/g, (c) => String(c.charCodeAt(0) - 55));
let rem = 0;
for (const ch of numeric) rem = (rem * 10 + +ch) % 97;
return rem === 1;
}
interface IbanItem {
id: string;
iban: string;
bank_name: string | null;
owner_name: string | null;
verified: boolean;
}
interface RepProfile {
full_name: string;
mobile_number: string | null;
national_code: string | null;
national_code_verified: boolean;
bank_account: IbanItem[] | null;
}
// پاسخ تک‌منبعی ممکن است double-nested باشد
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const unwrap = (d: any): RepProfile | undefined => d?.data?.data ?? d?.data;
export default function RepresentationProfilePage() {
const qc = useQueryClient();
const [nationalCode, setNationalCode] = useState('');
const [iban, setIban] = useState('');
const [birthDate, setBirthDate] = useState('');
const meQ = useQuery({
queryKey: ['representation-me'],
queryFn: () => api.get<ApiResponse<RepProfile>>('/api/v1/representation/me'),
staleTime: 30_000,
});
const profile = unwrap(meQ.data);
const verified = profile?.national_code_verified ?? false;
const ibans = profile?.bank_account ?? [];
const verifyMut = useMutation({
mutationFn: (code: string) =>
api.post<ApiResponse<RepProfile>>('/api/v1/representation/verify-national-code', { national_code: code }),
onSuccess: () => {
toast.success('کد ملی با موفقیت تأیید شد');
setNationalCode('');
qc.invalidateQueries({ queryKey: ['representation-me'] });
},
onError: (e: Error) => toast.error(e.message),
});
const addIbanMut = useMutation({
mutationFn: (payload: { iban: string; birth_date: string }) =>
api.post<ApiResponse<RepProfile>>('/api/v1/representation/iban', payload),
onSuccess: () => {
toast.success('شماره شبا تأیید و اضافه شد');
setIban('');
setBirthDate('');
qc.invalidateQueries({ queryKey: ['representation-me'] });
},
onError: (e: Error) => toast.error(e.message),
});
const removeIbanMut = useMutation({
mutationFn: (id: string) =>
api.delete<ApiResponse<RepProfile>>(`/api/v1/representation/iban/${id}`),
onSuccess: () => {
toast.success('شماره شبا حذف شد');
qc.invalidateQueries({ queryKey: ['representation-me'] });
},
onError: (e: Error) => toast.error(e.message),
});
const submitNationalCode = () => {
const code = toLatinDigits(nationalCode).replace(/\D/g, '');
if (!isValidIranNationalCode(code)) { toast.error('کد ملی نامعتبر است'); return; }
verifyMut.mutate(code);
};
const submitIban = () => {
const clean = toLatinDigits(iban).replace(/\s/g, '').toUpperCase();
if (!isValidIranIban(clean)) { toast.error('شماره شبا نامعتبر است (IR + ۲۴ رقم)'); return; }
const jalali = toJalali(birthDate);
if (!jalali) { toast.error('تاریخ تولد را انتخاب کنید'); return; }
addIbanMut.mutate({ iban: clean, birth_date: jalali });
};
return (
<div className="fade-in">
<div className="card-title-row" style={{ marginBottom: 'var(--gap)' }}>
<div>
<h1 className="section-title">پروفایل نماینده</h1>
<div className="muted" style={{ fontSize: 13, marginTop: 2 }}>تأیید هویت و مدیریت شماره‌های شبا</div>
</div>
</div>
{/* تأیید کد ملی */}
<div className="card card-pad">
<div className="card-title-row" style={{ marginBottom: 12 }}>
<h3 style={{ fontSize: 16 }}>تأیید هویت</h3>
{verified && (
<span className="badge green" style={{ display: 'inline-flex', alignItems: 'center', gap: 4 }}>
<CheckBadgeIcon style={{ width: 15, height: 15 }} /> کد ملی تأیید شده
</span>
)}
</div>
{verified ? (
<div className="muted" style={{ fontSize: 13.5 }}>
کد ملی: <span dir="ltr" style={{ fontWeight: 600 }}>{profile?.national_code}</span>
</div>
) : (
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
<input
type="text" inputMode="numeric" dir="ltr" maxLength={10} value={nationalCode}
placeholder="کد ملی ۱۰ رقمی"
onChange={(e) => setNationalCode(toLatinDigits(e.target.value).replace(/\D/g, ''))}
style={{ width: 220, height: 38, padding: '0 12px', borderRadius: 'var(--r-sm)', border: '1px solid var(--border)', background: 'var(--surface)', color: 'var(--text)', fontSize: 13.5, boxSizing: 'border-box', textAlign: 'center' }}
/>
<button className="btn primary" onClick={submitNationalCode} disabled={verifyMut.isPending}>
{verifyMut.isPending ? 'در حال تأیید...' : 'تأیید کد ملی'}
</button>
<span className="muted" style={{ fontSize: 12 }}>با استعلام شاهکار بررسی می‌شود</span>
</div>
)}
</div>
{/* شماره‌های شبا */}
<div className="card card-pad" style={{ marginTop: 'var(--gap)' }}>
<div className="card-title-row" style={{ marginBottom: 12 }}>
<h3 style={{ fontSize: 16 }}>شماره‌های شبا</h3>
<span className="muted" style={{ fontSize: 12 }}>{ibans.length} از ۲</span>
</div>
<div style={{ overflowX: 'auto' }}>
<div className="table-wrap"><table className="tbl" style={{ width: '100%' }}>
<thead><tr><th>شماره شبا</th><th>بانک</th><th>صاحب حساب</th><th>وضعیت</th><th></th></tr></thead>
<tbody>
{ibans.length === 0 && (
<tr><td colSpan={5} className="muted" style={{ textAlign: 'center', padding: 16 }}>هنوز شبایی ثبت نشده است</td></tr>
)}
{ibans.map((b) => (
<tr key={b.id}>
<td dir="ltr" style={{ textAlign: 'left', fontFamily: 'monospace' }}>{b.iban}</td>
<td>{b.bank_name ?? '—'}</td>
<td>{b.owner_name ?? '—'}</td>
<td><span className={`badge ${b.verified ? 'green' : 'gray'}`}>{b.verified ? 'تأیید شده' : 'تأیید نشده'}</span></td>
<td style={{ textAlign: 'center' }}>
<button className="btn icon danger" title="حذف" onClick={() => removeIbanMut.mutate(b.id)} disabled={removeIbanMut.isPending}>
<TrashIcon style={{ width: 16, height: 16 }} />
</button>
</td>
</tr>
))}
</tbody>
</table></div>
</div>
{!verified && (
<div className="muted" style={{ fontSize: 12.5, marginTop: 12 }}>
برای افزودن شبا ابتدا باید کد ملی خود را تأیید کنید.
</div>
)}
{verified && ibans.length < 2 && (
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap', marginTop: 14 }}>
<input
type="text" inputMode="numeric" dir="ltr" maxLength={26} value={iban}
placeholder="IR000000000000000000000000"
onChange={(e) => setIban(toLatinDigits(e.target.value).toUpperCase().replace(/[^IR0-9]/g, ''))}
style={{ width: 320, height: 38, padding: '0 12px', borderRadius: 'var(--r-sm)', border: '1px solid var(--border)', background: 'var(--surface)', color: 'var(--text)', fontSize: 13, boxSizing: 'border-box', fontFamily: 'monospace' }}
/>
<PersianDatePicker
value={birthDate}
onChange={setBirthDate}
placeholder="تاریخ تولد"
height={38}
minWidth={170}
enableYearPicker
/>
<button className="btn primary" onClick={submitIban} disabled={addIbanMut.isPending} style={{ display: 'inline-flex', alignItems: 'center', gap: 4 }}>
<PlusIcon style={{ width: 16, height: 16 }} />
{addIbanMut.isPending ? 'در حال بررسی...' : 'افزودن شبا'}
</button>
<span className="muted" style={{ fontSize: 12, width: '100%' }}>تاریخ تولد فقط برای استعلام مالکیت شبا استفاده می‌شود و ذخیره نمی‌شود.</span>
</div>
)}
</div>
</div>
);
}