- DoctorClaimsPage: paginated claim requests (status filter, verification
method, masked mobile, human-readable failure reason, Jalali dates) +
manual-transfer modal calling POST /api/v1/admin/doctors/{uuid}/transfer
- DoctorsPage: owner_status filter segment (بدونمالک/تصاحبشده) + badges
for unclaimed / pending_transfer rows
- doctorsList API mapping now returns owner_status + source
- route /admin/doctor-claims (admin-only) + sidebar entry
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
187 lines
7.2 KiB
TypeScript
187 lines
7.2 KiB
TypeScript
import React, { useState } from 'react';
|
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
|
import { ArrowPathIcon, UserPlusIcon } from '@heroicons/react/24/outline';
|
|
import { toast } from 'sonner';
|
|
import { api } from '../lib/api';
|
|
import type { ApiResponse, PaginatedResponse } from '../lib/api';
|
|
import { formatDate } from '../lib/utils';
|
|
import DataTable, { Column } from '../components/ui/DataTable';
|
|
import Pagination from '../components/ui/Pagination';
|
|
import Modal from '../components/ui/Modal';
|
|
|
|
interface DoctorClaim {
|
|
uuid: string;
|
|
status: 'pending' | 'completed' | 'failed';
|
|
doctor: { uuid: string; name: string };
|
|
mobile_masked: string;
|
|
verification_method: string;
|
|
failure_reason: string | null;
|
|
created_at: number;
|
|
completed_at: number | null;
|
|
}
|
|
|
|
const FILTERS = [
|
|
{ value: '', label: 'همه' },
|
|
{ value: 'completed', label: 'موفق' },
|
|
{ value: 'failed', label: 'ناموفق' },
|
|
{ value: 'pending', label: 'در جریان' },
|
|
];
|
|
|
|
const STATUS_BADGE: Record<DoctorClaim['status'], { cls: string; label: string }> = {
|
|
completed: { cls: 'green', label: 'موفق' },
|
|
failed: { cls: 'red', label: 'ناموفق' },
|
|
pending: { cls: 'amber', label: 'در جریان' },
|
|
};
|
|
|
|
const METHOD_LABEL: Record<string, string> = {
|
|
'apiir_personinfo+shahkar': 'استعلام هویت + شاهکار',
|
|
'apiir_personinfo': 'استعلام هویت',
|
|
'admin_manual': 'انتقال دستی ادمین',
|
|
};
|
|
|
|
export default function DoctorClaimsPage() {
|
|
const qc = useQueryClient();
|
|
const [page, setPage] = useState(1);
|
|
const [status, setStatus] = useState('');
|
|
const [transferTarget, setTransferTarget] = useState<DoctorClaim | null>(null);
|
|
const [transferMobile, setTransferMobile] = useState('');
|
|
const limit = 20;
|
|
|
|
const { data, isLoading, refetch, isFetching } = useQuery({
|
|
queryKey: ['doctor-claims', page, status],
|
|
queryFn: () => {
|
|
const p = new URLSearchParams({ page: String(page), limit: String(limit) });
|
|
if (status) p.set('status', status);
|
|
return api.get<PaginatedResponse<DoctorClaim>>(`/api/v1/admin/doctor-claims?${p}`);
|
|
},
|
|
});
|
|
|
|
const transferMut = useMutation({
|
|
mutationFn: ({ doctorUuid, mobile }: { doctorUuid: string; mobile: string }) =>
|
|
api.post<ApiResponse<unknown>>(`/api/v1/admin/doctors/${doctorUuid}/transfer`, { mobile }),
|
|
onSuccess: () => {
|
|
toast.success('پروفایل با موفقیت منتقل شد');
|
|
setTransferTarget(null);
|
|
setTransferMobile('');
|
|
qc.invalidateQueries({ queryKey: ['doctor-claims'] });
|
|
qc.invalidateQueries({ queryKey: ['admin-doctors'] });
|
|
},
|
|
onError: (err: Error) => toast.error(err.message),
|
|
});
|
|
|
|
const columns: Column<DoctorClaim>[] = [
|
|
{ key: 'doctor', header: 'پزشک', render: (c) => <b>{c.doctor?.name}</b> },
|
|
{
|
|
key: 'status',
|
|
header: 'وضعیت',
|
|
render: (c) => <span className={`badge ${STATUS_BADGE[c.status].cls}`}>{STATUS_BADGE[c.status].label}</span>,
|
|
},
|
|
{ key: 'mobile_masked', header: 'موبایل', render: (c) => <span dir="ltr">{c.mobile_masked}</span> },
|
|
{
|
|
key: 'verification_method',
|
|
header: 'روش احراز',
|
|
render: (c) => METHOD_LABEL[c.verification_method] ?? c.verification_method,
|
|
},
|
|
{
|
|
key: 'failure_reason',
|
|
header: 'علت شکست',
|
|
render: (c) => c.failure_reason
|
|
? <span className="muted" style={{ fontSize: 12 }}>{c.failure_reason}</span>
|
|
: '—',
|
|
},
|
|
{ key: 'created_at', header: 'تاریخ درخواست', render: (c) => formatDate(c.created_at) },
|
|
{
|
|
key: 'completed_at',
|
|
header: 'پایان',
|
|
render: (c) => (c.completed_at ? formatDate(c.completed_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>
|
|
</div>
|
|
|
|
<div className="card">
|
|
<div className="card-pad" style={{ paddingBottom: 0 }}>
|
|
<div className="toolbar">
|
|
<div className="seg">
|
|
{FILTERS.map((f) => (
|
|
<button
|
|
key={f.value}
|
|
className={status === f.value ? 'on' : ''}
|
|
onClick={() => { setStatus(f.value); setPage(1); }}
|
|
>
|
|
{f.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
<div className="spacer" />
|
|
<button className="btn ghost sm" onClick={() => refetch()} disabled={isFetching}>
|
|
<ArrowPathIcon style={{ width: 15, height: 15, animation: isFetching ? 'spin 1s linear infinite' : undefined }} />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<DataTable<DoctorClaim>
|
|
columns={columns}
|
|
data={items}
|
|
loading={isLoading}
|
|
emptyMessage="هیچ درخواست تصاحبی ثبت نشده است"
|
|
actions={(claim) => (
|
|
claim.status !== 'completed' ? (
|
|
<button
|
|
className="mini-btn"
|
|
title="انتقال دستی به موبایل"
|
|
onClick={() => { setTransferTarget(claim); setTransferMobile(''); }}
|
|
>
|
|
<UserPlusIcon style={{ width: 15, height: 15 }} />
|
|
</button>
|
|
) : null
|
|
)}
|
|
/>
|
|
<Pagination page={page} total={total} limit={limit} onPageChange={setPage} />
|
|
</div>
|
|
|
|
<Modal
|
|
open={!!transferTarget}
|
|
onClose={() => setTransferTarget(null)}
|
|
title={`انتقال دستی پروفایل ${transferTarget?.doctor?.name ?? ''}`}
|
|
>
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
|
<p className="muted" style={{ fontSize: 13, lineHeight: 2 }}>
|
|
مالکیت پروفایل بدون استعلام هویت به کاربرِ این شماره منتقل میشود
|
|
(اگر کاربری با این موبایل نباشد، ساخته میشود). فقط برای پشتیبانی استفاده کنید.
|
|
</p>
|
|
<div className="field">
|
|
<input
|
|
dir="ltr"
|
|
value={transferMobile}
|
|
onChange={(e) => setTransferMobile(e.target.value)}
|
|
placeholder="09xxxxxxxxx"
|
|
maxLength={11}
|
|
/>
|
|
</div>
|
|
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
|
|
<button className="btn ghost sm" onClick={() => setTransferTarget(null)}>انصراف</button>
|
|
<button
|
|
className="btn primary sm"
|
|
disabled={!/^09\d{9}$/.test(transferMobile) || transferMut.isPending}
|
|
onClick={() => transferTarget && transferMut.mutate({ doctorUuid: transferTarget.doctor.uuid, mobile: transferMobile })}
|
|
>
|
|
{transferMut.isPending ? 'در حال انتقال…' : 'انتقال مالکیت'}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</Modal>
|
|
</div>
|
|
);
|
|
}
|