Files
clinicpro/assets/admin/pages/DoctorClaimsPage.tsx
T
hamedandClaude Opus 4.8 958105694a feat(admin): doctor claims audit page + owner_status visibility in doctors list
- 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>
2026-07-11 11:49:04 +03:30

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