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:
@@ -39,6 +39,7 @@ import MyPatientsPage from './pages/MyPatientsPage';
|
|||||||
import NewSessionPage from './pages/NewSessionPage';
|
import NewSessionPage from './pages/NewSessionPage';
|
||||||
import InsurancePricingPage from './pages/InsurancePricingPage';
|
import InsurancePricingPage from './pages/InsurancePricingPage';
|
||||||
import ClaimsPage from './pages/ClaimsPage';
|
import ClaimsPage from './pages/ClaimsPage';
|
||||||
|
import DoctorClaimsPage from './pages/DoctorClaimsPage';
|
||||||
import MyFinancialPage from './pages/MyFinancialPage';
|
import MyFinancialPage from './pages/MyFinancialPage';
|
||||||
import ClinicFormPage from './pages/ClinicFormPage';
|
import ClinicFormPage from './pages/ClinicFormPage';
|
||||||
import PreRegistrationsPage from './pages/PreRegistrationsPage';
|
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-finance" element={<RoleRoute roles={['representation']}><RepresentationFinancePage /></RoleRoute>} />
|
||||||
<Route path="representation-profile" element={<RoleRoute roles={['representation']}><RepresentationProfilePage /></RoleRoute>} />
|
<Route path="representation-profile" element={<RoleRoute roles={['representation']}><RepresentationProfilePage /></RoleRoute>} />
|
||||||
<Route path="doctors" element={<RoleRoute roles={['admin', 'representation']}><DoctorsPage /></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/new" element={<RoleRoute roles={['admin', 'representation']}><DoctorFormPage /></RoleRoute>} />
|
||||||
<Route path="doctors/:uuid" element={<RoleRoute roles={['admin', 'doctor', 'clinic', 'representation']}><DoctorDetailPage /></RoleRoute>} />
|
<Route path="doctors/:uuid" element={<RoleRoute roles={['admin', 'doctor', 'clinic', 'representation']}><DoctorDetailPage /></RoleRoute>} />
|
||||||
<Route path="profile" element={<RoleRoute roles={['doctor']} blockClinicScope><DoctorProfilePage /></RoleRoute>} />
|
<Route path="profile" element={<RoleRoute roles={['doctor']} blockClinicScope><DoctorProfilePage /></RoleRoute>} />
|
||||||
|
|||||||
@@ -73,6 +73,7 @@ function buildSections(
|
|||||||
label: "کاربران",
|
label: "کاربران",
|
||||||
},
|
},
|
||||||
{ to: "/admin/doctors", icon: HeartIcon, label: "پزشکان" },
|
{ to: "/admin/doctors", icon: HeartIcon, label: "پزشکان" },
|
||||||
|
{ to: "/admin/doctor-claims", icon: HeartIcon, label: "تصاحب پروفایل" },
|
||||||
{
|
{
|
||||||
to: "/admin/clinics",
|
to: "/admin/clinics",
|
||||||
icon: BuildingOffice2Icon,
|
icon: BuildingOffice2Icon,
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -27,6 +27,8 @@ interface AdminDoctor {
|
|||||||
mobile: string | null;
|
mobile: string | null;
|
||||||
email: string | null;
|
email: string | null;
|
||||||
is_active: boolean;
|
is_active: boolean;
|
||||||
|
owner_status?: string;
|
||||||
|
source?: string;
|
||||||
rate: number;
|
rate: number;
|
||||||
specialties: { id: number; name: string }[];
|
specialties: { id: number; name: string }[];
|
||||||
profile_image: string | null;
|
profile_image: string | null;
|
||||||
@@ -100,6 +102,7 @@ export default function DoctorsPage() {
|
|||||||
const [searchInput, setSearchInput] = useState('');
|
const [searchInput, setSearchInput] = useState('');
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
const [status, setStatus] = useState('');
|
const [status, setStatus] = useState('');
|
||||||
|
const [ownerStatus, setOwnerStatus] = useState('');
|
||||||
const [specialtyId, setSpecialty] = useState('');
|
const [specialtyId, setSpecialty] = useState('');
|
||||||
const [view, setView] = useState<'table' | 'grid'>('table');
|
const [view, setView] = useState<'table' | 'grid'>('table');
|
||||||
const [deleteTarget, setDeleteTarget] = useState<AdminDoctor | null>(null);
|
const [deleteTarget, setDeleteTarget] = useState<AdminDoctor | null>(null);
|
||||||
@@ -109,7 +112,7 @@ export default function DoctorsPage() {
|
|||||||
return () => clearTimeout(t);
|
return () => clearTimeout(t);
|
||||||
}, [searchInput]);
|
}, [searchInput]);
|
||||||
|
|
||||||
useEffect(() => { setPage(1); }, [status, specialtyId]);
|
useEffect(() => { setPage(1); }, [status, ownerStatus, specialtyId]);
|
||||||
|
|
||||||
// ── Queries ──
|
// ── Queries ──
|
||||||
|
|
||||||
@@ -131,11 +134,12 @@ export default function DoctorsPage() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const doctorsQ = useQuery({
|
const doctorsQ = useQuery({
|
||||||
queryKey: ['admin-doctors', page, limit, search, status, specialtyId],
|
queryKey: ['admin-doctors', page, limit, search, status, ownerStatus, specialtyId],
|
||||||
queryFn: () => {
|
queryFn: () => {
|
||||||
const p = new URLSearchParams({ page: String(page), limit: String(limit) });
|
const p = new URLSearchParams({ page: String(page), limit: String(limit) });
|
||||||
if (search) p.set('search', search);
|
if (search) p.set('search', search);
|
||||||
if (status) p.set('status', status);
|
if (status) p.set('status', status);
|
||||||
|
if (ownerStatus && !isRepresentation) p.set('owner_status', ownerStatus);
|
||||||
if (specialtyId) p.set('specialty_id', specialtyId);
|
if (specialtyId) p.set('specialty_id', specialtyId);
|
||||||
const base = isRepresentation ? '/api/v1/representation/doctors' : '/api/v1/admin/doctors';
|
const base = isRepresentation ? '/api/v1/representation/doctors' : '/api/v1/admin/doctors';
|
||||||
return api.get<PaginatedResponse<AdminDoctor>>(`${base}?${p}`);
|
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 === 'active' ? 'on' : ''} onClick={() => setStatus('active')}>فعال</button>
|
||||||
<button className={status === 'inactive' ? 'on' : ''} onClick={() => setStatus('inactive')}>غیرفعال</button>
|
<button className={status === 'inactive' ? 'on' : ''} onClick={() => setStatus('inactive')}>غیرفعال</button>
|
||||||
</div>
|
</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="spacer" />
|
||||||
<div className="seg">
|
<div className="seg">
|
||||||
<button className={view === 'table' ? 'on' : ''} onClick={() => setView('table')} title="جدول">
|
<button className={view === 'table' ? 'on' : ''} onClick={() => setView('table')} title="جدول">
|
||||||
@@ -338,6 +349,8 @@ export default function DoctorsPage() {
|
|||||||
{doc.mobile ?? '—'}
|
{doc.mobile ?? '—'}
|
||||||
</td>
|
</td>
|
||||||
<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={`badge ${doc.is_active ? 'green' : 'gray'}`}>
|
||||||
<span className="bdot" />
|
<span className="bdot" />
|
||||||
{doc.is_active ? 'فعال' : 'غیرفعال'}
|
{doc.is_active ? 'فعال' : 'غیرفعال'}
|
||||||
|
|||||||
@@ -383,6 +383,8 @@ class AdminApiController extends BaseController
|
|||||||
'mobile' => $d['doctor_mobile'] ?: $d['user_mobile'],
|
'mobile' => $d['doctor_mobile'] ?: $d['user_mobile'],
|
||||||
'email' => $d['email'],
|
'email' => $d['email'],
|
||||||
'is_active' => (bool) $d['active_doctor_appointment'],
|
'is_active' => (bool) $d['active_doctor_appointment'],
|
||||||
|
'owner_status' => $d['owner_status'],
|
||||||
|
'source' => $d['source'],
|
||||||
'rate' => (float) $d['doctor_rate'],
|
'rate' => (float) $d['doctor_rate'],
|
||||||
'specialties' => $specMap[(int) $d['id']] ?? [],
|
'specialties' => $specMap[(int) $d['id']] ?? [],
|
||||||
'profile_image' => !empty($images) ? ($images[0]['url'] ?? null) : null,
|
'profile_image' => !empty($images) ? ($images[0]['url'] ?? null) : null,
|
||||||
|
|||||||
Reference in New Issue
Block a user