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>
This commit is contained in:
hamed
2026-07-11 11:49:04 +03:30
co-authored by Claude Opus 4.8
parent af125572c9
commit 958105694a
5 changed files with 206 additions and 2 deletions
+2
View File
@@ -39,6 +39,7 @@ import MyPatientsPage from './pages/MyPatientsPage';
import NewSessionPage from './pages/NewSessionPage';
import InsurancePricingPage from './pages/InsurancePricingPage';
import ClaimsPage from './pages/ClaimsPage';
import DoctorClaimsPage from './pages/DoctorClaimsPage';
import MyFinancialPage from './pages/MyFinancialPage';
import ClinicFormPage from './pages/ClinicFormPage';
import PreRegistrationsPage from './pages/PreRegistrationsPage';
@@ -177,6 +178,7 @@ export default function App() {
<Route path="representation-finance" element={<RoleRoute roles={['representation']}><RepresentationFinancePage /></RoleRoute>} />
<Route path="representation-profile" element={<RoleRoute roles={['representation']}><RepresentationProfilePage /></RoleRoute>} />
<Route path="doctors" element={<RoleRoute roles={['admin', 'representation']}><DoctorsPage /></RoleRoute>} />
<Route path="doctor-claims" element={<RoleRoute roles={['admin']}><DoctorClaimsPage /></RoleRoute>} />
<Route path="doctors/new" element={<RoleRoute roles={['admin', 'representation']}><DoctorFormPage /></RoleRoute>} />
<Route path="doctors/:uuid" element={<RoleRoute roles={['admin', 'doctor', 'clinic', 'representation']}><DoctorDetailPage /></RoleRoute>} />
<Route path="profile" element={<RoleRoute roles={['doctor']} blockClinicScope><DoctorProfilePage /></RoleRoute>} />
@@ -73,6 +73,7 @@ function buildSections(
label: "کاربران",
},
{ to: "/admin/doctors", icon: HeartIcon, label: "پزشکان" },
{ to: "/admin/doctor-claims", icon: HeartIcon, label: "تصاحب پروفایل" },
{
to: "/admin/clinics",
icon: BuildingOffice2Icon,
+186
View File
@@ -0,0 +1,186 @@
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>
);
}
+15 -2
View File
@@ -27,6 +27,8 @@ interface AdminDoctor {
mobile: string | null;
email: string | null;
is_active: boolean;
owner_status?: string;
source?: string;
rate: number;
specialties: { id: number; name: string }[];
profile_image: string | null;
@@ -100,6 +102,7 @@ export default function DoctorsPage() {
const [searchInput, setSearchInput] = useState('');
const [search, setSearch] = useState('');
const [status, setStatus] = useState('');
const [ownerStatus, setOwnerStatus] = useState('');
const [specialtyId, setSpecialty] = useState('');
const [view, setView] = useState<'table' | 'grid'>('table');
const [deleteTarget, setDeleteTarget] = useState<AdminDoctor | null>(null);
@@ -109,7 +112,7 @@ export default function DoctorsPage() {
return () => clearTimeout(t);
}, [searchInput]);
useEffect(() => { setPage(1); }, [status, specialtyId]);
useEffect(() => { setPage(1); }, [status, ownerStatus, specialtyId]);
// ── Queries ──
@@ -131,11 +134,12 @@ export default function DoctorsPage() {
});
const doctorsQ = useQuery({
queryKey: ['admin-doctors', page, limit, search, status, specialtyId],
queryKey: ['admin-doctors', page, limit, search, status, ownerStatus, specialtyId],
queryFn: () => {
const p = new URLSearchParams({ page: String(page), limit: String(limit) });
if (search) p.set('search', search);
if (status) p.set('status', status);
if (ownerStatus && !isRepresentation) p.set('owner_status', ownerStatus);
if (specialtyId) p.set('specialty_id', specialtyId);
const base = isRepresentation ? '/api/v1/representation/doctors' : '/api/v1/admin/doctors';
return api.get<PaginatedResponse<AdminDoctor>>(`${base}?${p}`);
@@ -257,6 +261,13 @@ export default function DoctorsPage() {
<button className={status === 'active' ? 'on' : ''} onClick={() => setStatus('active')}>فعال</button>
<button className={status === 'inactive' ? 'on' : ''} onClick={() => setStatus('inactive')}>غیرفعال</button>
</div>
{!isRepresentation && (
<div className="seg" title="مالکیت پروفایل (ایمپورت نظام پزشکی)">
<button className={!ownerStatus ? 'on' : ''} onClick={() => setOwnerStatus('')}>همه</button>
<button className={ownerStatus === 'unclaimed' ? 'on' : ''} onClick={() => setOwnerStatus('unclaimed')}>بدونمالک</button>
<button className={ownerStatus === 'claimed' ? 'on' : ''} onClick={() => setOwnerStatus('claimed')}>تصاحبشده</button>
</div>
)}
<div className="spacer" />
<div className="seg">
<button className={view === 'table' ? 'on' : ''} onClick={() => setView('table')} title="جدول">
@@ -338,6 +349,8 @@ export default function DoctorsPage() {
{doc.mobile ?? '—'}
</td>
<td>
{doc.owner_status === 'unclaimed' && <span className="badge amber" style={{ marginLeft: 6 }}>بدونمالک</span>}
{doc.owner_status === 'pending_transfer' && <span className="badge violet" style={{ marginLeft: 6 }}>در انتظار انتقال</span>}
<span className={`badge ${doc.is_active ? 'green' : 'gray'}`}>
<span className="bdot" />
{doc.is_active ? 'فعال' : 'غیرفعال'}
@@ -383,6 +383,8 @@ class AdminApiController extends BaseController
'mobile' => $d['doctor_mobile'] ?: $d['user_mobile'],
'email' => $d['email'],
'is_active' => (bool) $d['active_doctor_appointment'],
'owner_status' => $d['owner_status'],
'source' => $d['source'],
'rate' => (float) $d['doctor_rate'],
'specialties' => $specMap[(int) $d['id']] ?? [],
'profile_image' => !empty($images) ? ($images[0]['url'] ?? null) : null,