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 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,
|
||||
|
||||
@@ -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;
|
||||
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,
|
||||
|
||||
Reference in New Issue
Block a user