Files
clinicpro/assets/admin/pages/RepresentationDetailPage.tsx
T

469 lines
19 KiB
TypeScript

import React, { useState } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { PencilIcon, TrashIcon, CheckCircleIcon, XCircleIcon } from '@heroicons/react/24/outline';
import { toast } from 'sonner';
import { api } from '../lib/api';
import type { ApiResponse, PaginatedResponse } from '../lib/api';
import type { Representation, City } from '../types';
import { formatDate, formatDateTime, formatNumber } from '../lib/utils';
import PageHeader from '../components/ui/PageHeader';
import { ActiveBadge } from '../components/ui/StatusBadge';
import ConfirmDialog from '../components/ui/ConfirmDialog';
import Modal from '../components/ui/Modal';
import SearchableSelect from '../components/ui/SearchableSelect';
interface RepDoctor {
uuid: string;
id: number;
name: string;
gender: string | null;
medical_code: string | null;
is_active: boolean;
owner_status?: string;
past_count: number;
upcoming_count: number;
created_at: string;
}
interface RepAppointment {
uuid: string;
slot_start: number;
slot_end: number;
status: string;
patient_name: string | null;
doctor_uuid: string;
doctor_name: string;
}
interface UnassignedDoctor {
uuid: string;
name: string;
medical_code: string | null;
mobile: string | null;
}
function DetailRow({ label, value }: { label: string; value: React.ReactNode }) {
return (
<div className="cp-info-row items-start gap-4">
<span className="text-sm text-gray-500 w-40 shrink-0">{label}</span>
<span className="cp-info-value">{value ?? '—'}</span>
</div>
);
}
export default function RepresentationDetailPage() {
const { uuid } = useParams<{ uuid: string }>();
const navigate = useNavigate();
const qc = useQueryClient();
const [deleteOpen, setDeleteOpen] = useState(false);
const [editOpen, setEditOpen] = useState(false);
const [formData, setFormData] = useState({ full_name: '', city_id: '', mobile_number: '', commission_percent: '' });
const [attachOpen, setAttachOpen] = useState(false);
const [attachSearch, setAttachSearch] = useState('');
const [apptScope, setApptScope] = useState<'upcoming' | 'past'>('upcoming');
const citiesQuery = useQuery({
queryKey: ['cities-select'],
queryFn: () => api.get<PaginatedResponse<City>>('/api/v1/admin/cities?limit=200'),
staleTime: 5 * 60_000,
});
const cities: City[] = citiesQuery.data?.data ?? [];
const { data, isLoading } = useQuery({
queryKey: ['representation', uuid],
queryFn: () => api.get<ApiResponse<{ data: Representation }>>(`/api/v1/representation/${uuid}`),
enabled: !!uuid,
});
const rep: Representation | undefined = (data?.data as any)?.data ?? data?.data;
const doctorsQuery = useQuery({
queryKey: ['representation-doctors', uuid],
queryFn: () => api.get<PaginatedResponse<RepDoctor>>(`/api/v1/admin/representations/${uuid}/doctors?limit=100`),
enabled: !!uuid,
});
const repDoctors: RepDoctor[] = doctorsQuery.data?.data ?? [];
const repDoctorsTotal = doctorsQuery.data?.meta?.totalRecords ?? repDoctors.length;
const appointmentsQuery = useQuery({
queryKey: ['representation-appointments', uuid, apptScope],
queryFn: () => api.get<PaginatedResponse<RepAppointment>>(`/api/v1/admin/representations/${uuid}/appointments?scope=${apptScope}&limit=50`),
enabled: !!uuid,
});
const repAppointments: RepAppointment[] = appointmentsQuery.data?.data ?? [];
const repAppointmentsTotal = appointmentsQuery.data?.meta?.totalRecords ?? repAppointments.length;
const unassignedQuery = useQuery({
queryKey: ['unassigned-doctors', attachSearch],
queryFn: () => api.get<PaginatedResponse<UnassignedDoctor>>(`/api/v1/admin/doctors?unassigned=1&limit=20${attachSearch ? `&search=${encodeURIComponent(attachSearch)}` : ''}`),
enabled: attachOpen,
});
const unassignedDoctors: UnassignedDoctor[] = unassignedQuery.data?.data ?? [];
const attachMutation = useMutation({
mutationFn: (doctorUuid: string) =>
api.post<ApiResponse<unknown>>(`/api/v1/admin/representations/${uuid}/doctors`, { doctor_uuid: doctorUuid }),
onSuccess: () => {
toast.success('پزشک به نماینده متصل شد');
setAttachOpen(false);
setAttachSearch('');
qc.invalidateQueries({ queryKey: ['representation-doctors', uuid] });
qc.invalidateQueries({ queryKey: ['representations'] });
},
onError: (err: Error) => toast.error(err.message),
});
const updateMutation = useMutation({
mutationFn: (d: Partial<Representation>) =>
api.patch<ApiResponse<Representation>>(`/api/v1/representation/${uuid}`, d),
onSuccess: () => {
toast.success('اطلاعات بروزرسانی شد');
setEditOpen(false);
qc.invalidateQueries({ queryKey: ['representation', uuid] });
qc.invalidateQueries({ queryKey: ['representations'] });
},
onError: (err: Error) => toast.error(err.message),
});
const toggleActiveMutation = useMutation({
mutationFn: () =>
api.patch<ApiResponse<Representation>>(`/api/v1/representation/${uuid}`, {
active: !(rep?.active ?? rep?.is_active),
}),
onSuccess: () => {
toast.success('وضعیت تغییر کرد');
qc.invalidateQueries({ queryKey: ['representation', uuid] });
},
onError: (err: Error) => toast.error(err.message),
});
const deleteMutation = useMutation({
mutationFn: () => api.delete<ApiResponse<null>>(`/api/v1/representation/${uuid}`),
onSuccess: () => {
toast.success('نماینده حذف شد');
navigate('/admin/representations');
},
onError: (err: Error) => toast.error(err.message),
});
const openEdit = () => {
if (!rep) return;
setFormData({
full_name: rep.full_name ?? rep.domain ?? '',
city_id: rep.city_id ? String(rep.city_id) : '',
mobile_number: rep.mobile_number ?? '',
commission_percent: String(rep.commission_percent),
});
setEditOpen(true);
};
const isActive = rep?.active ?? rep?.is_active ?? false;
if (isLoading) {
return (
<div>
<PageHeader
title="جزئیات نماینده"
breadcrumbs={[
{ label: 'داشبورد', to: '/admin/dashboard' },
{ label: 'نمایندگان', to: '/admin/representations' },
{ label: 'جزئیات' },
]}
/>
<div className="bg-white rounded-2xl shadow-sm border border-gray-100 p-6 animate-pulse">
<div className="space-y-4">
{[1, 2, 3, 4, 5].map((i) => (
<div key={i} className="h-8 bg-gray-100 rounded" />
))}
</div>
</div>
</div>
);
}
if (!rep) {
return (
<div>
<PageHeader
title="نماینده یافت نشد"
breadcrumbs={[
{ label: 'داشبورد', to: '/admin/dashboard' },
{ label: 'نمایندگان', to: '/admin/representations' },
]}
/>
<div className="cp-card p-12 text-center text-slate-400 dark:text-slate-500">
نماینده‌ای با این شناسه یافت نشد.
</div>
</div>
);
}
const name = rep.full_name ?? rep.domain ?? '—';
return (
<div>
<PageHeader
title={name}
breadcrumbs={[
{ label: 'داشبورد', to: '/admin/dashboard' },
{ label: 'نمایندگان', to: '/admin/representations' },
{ label: name },
]}
action={
<div className="flex items-center gap-2">
<button
onClick={() => toggleActiveMutation.mutate()}
disabled={toggleActiveMutation.isPending}
className={`flex items-center gap-1.5 px-3 py-2 text-sm rounded-[10px] border transition-colors disabled:opacity-50 ${
isActive
? 'border-red-300 text-red-600 hover:bg-red-50'
: 'border-green-300 text-green-600 hover:bg-green-50'
}`}
>
{isActive
? <><XCircleIcon className="w-4 h-4" /> غیرفعال کردن</>
: <><CheckCircleIcon className="w-4 h-4" /> فعال کردن</>
}
</button>
<button
onClick={openEdit}
className="flex items-center gap-1.5 px-3 py-2 text-sm rounded-[10px] border border-gray-300 text-gray-700 hover:bg-gray-50 transition-colors"
>
<PencilIcon className="w-4 h-4" />
ویرایش
</button>
<button
onClick={() => setDeleteOpen(true)}
className="flex items-center gap-1.5 px-3 py-2 text-sm rounded-[10px] border border-red-300 text-red-600 hover:bg-red-50 transition-colors"
>
<TrashIcon className="w-4 h-4" />
حذف
</button>
</div>
}
/>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Basic Info */}
<div className="cp-card p-6">
<h2 className="text-sm font-semibold text-gray-700 mb-4">اطلاعات پایه</h2>
<DetailRow label="نام کامل" value={rep.full_name} />
<DetailRow label="موبایل" value={
rep.mobile_number
? <span dir="ltr">{rep.mobile_number}</span>
: null
} />
<DetailRow label="شهر" value={rep.city ?? (rep.city_id ? `شناسه ${rep.city_id}` : null)} />
<DetailRow label="درصد کمیسیون" value={`${formatNumber(rep.commission_percent)}٪`} />
<DetailRow label="وضعیت" value={<ActiveBadge active={isActive} />} />
<DetailRow label="تاریخ ثبت" value={formatDate(rep.created_at)} />
</div>
{/* Bank Account */}
<div className="cp-card p-6">
<h2 className="text-sm font-semibold text-gray-700 mb-4">اطلاعات بانکی</h2>
{rep.bank_account ? (
<>
<DetailRow label="شماره کارت" value={
rep.bank_account.card
? <span dir="ltr" className="font-mono tracking-widest">{rep.bank_account.card}</span>
: null
} />
<DetailRow label="نام بانک" value={rep.bank_account.bank_name} />
<DetailRow label="شماره شبا" value={
rep.bank_account.iban
? <span dir="ltr" className="font-mono text-xs">{rep.bank_account.iban}</span>
: null
} />
</>
) : (
<p className="text-sm text-gray-400 text-center py-6">اطلاعات بانکی ثبت نشده</p>
)}
</div>
</div>
{/* Doctors of this representation */}
<div className="cp-card p-6 mt-5">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<h2 className="text-sm font-semibold text-gray-700">پزشکان این نماینده</h2>
<span className="badge blue">{formatNumber(repDoctorsTotal)} پزشک</span>
</div>
<button className="btn primary sm" onClick={() => setAttachOpen(true)}>افزودن پزشک</button>
</div>
{doctorsQuery.isLoading ? (
<p className="text-sm text-gray-400 text-center py-6">در حال بارگذاری…</p>
) : repDoctors.length === 0 ? (
<p className="text-sm text-gray-400 text-center py-6">این نماینده پزشکی ندارد</p>
) : (
<div style={{ overflowX: 'auto' }}>
<table style={{ width: '100%' }}>
<thead>
<tr>
<th>نام</th>
<th>کد نظام</th>
<th>نوبت گذشته</th>
<th>نوبت آینده</th>
<th>وضعیت</th>
</tr>
</thead>
<tbody>
{repDoctors.map((d) => (
<tr key={d.uuid} style={{ cursor: 'pointer' }} onClick={() => navigate(`/admin/doctors/${d.uuid}`)}>
<td><b>{d.name}</b></td>
<td dir="ltr" style={{ fontFamily: 'monospace' }}>{d.medical_code ?? '—'}</td>
<td>{formatNumber(d.past_count)}</td>
<td>{formatNumber(d.upcoming_count)}</td>
<td><ActiveBadge active={d.is_active} /></td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
{/* Appointments of this representation */}
<div className="cp-card p-6 mt-5">
<div className="flex items-center justify-between mb-4">
<h2 className="text-sm font-semibold text-gray-700">نوبت‌های این نماینده</h2>
<div className="flex items-center gap-1">
<button className={`btn sm ${apptScope === 'upcoming' ? 'primary' : 'ghost'}`} onClick={() => setApptScope('upcoming')}>آینده</button>
<button className={`btn sm ${apptScope === 'past' ? 'primary' : 'ghost'}`} onClick={() => setApptScope('past')}>گذشته</button>
</div>
</div>
{appointmentsQuery.isLoading ? (
<p className="text-sm text-gray-400 text-center py-6">در حال بارگذاری…</p>
) : repAppointments.length === 0 ? (
<p className="text-sm text-gray-400 text-center py-6">
{apptScope === 'upcoming' ? 'نوبت آینده‌ای ثبت نشده' : 'نوبت گذشته‌ای وجود ندارد'}
</p>
) : (
<>
<div className="mb-2"><span className="badge blue">{formatNumber(repAppointmentsTotal)} نوبت</span></div>
<div style={{ overflowX: 'auto' }}>
<table style={{ width: '100%' }}>
<thead>
<tr>
<th>پزشک</th>
<th>بیمار</th>
<th>زمان</th>
<th>وضعیت</th>
</tr>
</thead>
<tbody>
{repAppointments.map((a) => (
<tr key={a.uuid} style={{ cursor: 'pointer' }} onClick={() => navigate(`/admin/doctors/${a.doctor_uuid}`)}>
<td><b>{a.doctor_name}</b></td>
<td>{a.patient_name ?? '—'}</td>
<td>{formatDateTime(a.slot_start)}</td>
<td><span className="badge gray">{a.status}</span></td>
</tr>
))}
</tbody>
</table>
</div>
</>
)}
</div>
{/* Attach Doctor Modal */}
<Modal open={attachOpen} title="افزودن پزشک به نماینده"
onClose={() => { setAttachOpen(false); setAttachSearch(''); }}
footer={<button onClick={() => { setAttachOpen(false); setAttachSearch(''); }} className="btn ghost sm">بستن</button>}
>
<input
type="text"
className="input w-full mb-3"
placeholder="جستجوی پزشک (نام یا موبایل)…"
value={attachSearch}
onChange={(e) => setAttachSearch(e.target.value)}
/>
{unassignedQuery.isLoading ? (
<p className="text-sm text-gray-400 text-center py-6">در حال بارگذاری…</p>
) : unassignedDoctors.length === 0 ? (
<p className="text-sm text-gray-400 text-center py-6">پزشکِ بدون نماینده‌ای یافت نشد</p>
) : (
<div style={{ maxHeight: 360, overflowY: 'auto' }}>
{unassignedDoctors.map((d) => (
<div key={d.uuid} className="flex items-center justify-between py-2 border-b border-gray-100">
<div>
<b className="text-sm">{d.name}</b>
<div className="text-xs text-gray-400" dir="ltr">{d.medical_code ?? d.mobile ?? '—'}</div>
</div>
<button className="btn primary sm" disabled={attachMutation.isPending}
onClick={() => attachMutation.mutate(d.uuid)}>افزودن</button>
</div>
))}
</div>
)}
</Modal>
{/* Edit Modal */}
<Modal open={editOpen} title="ویرایش نماینده"
onClose={() => setEditOpen(false)}
footer={
<>
<button onClick={() => setEditOpen(false)}
className="btn ghost sm">
لغو
</button>
<button
onClick={() => updateMutation.mutate({
full_name: formData.full_name,
city_id: formData.city_id ? parseInt(formData.city_id) : null,
mobile_number: formData.mobile_number || null,
commission_percent: parseFloat(formData.commission_percent) || rep.commission_percent,
} as any)}
disabled={updateMutation.isPending}
className="btn primary sm">
{updateMutation.isPending ? 'در حال ذخیره...' : 'ذخیره'}
</button>
</>
}
>
<div className="space-y-4">
<div>
<label className="">نام کامل</label>
<input value={formData.full_name} onChange={(e) => setFormData((p) => ({ ...p, full_name: e.target.value }))}
className="cp-input h-11" />
</div>
<div>
<label className="">شهر</label>
<SearchableSelect
options={cities.map(c => ({ value: String(c.id), label: c.name }))}
value={formData.city_id || null}
onChange={(v) => setFormData(p => ({ ...p, city_id: v ? String(v) : '' }))}
placeholder="انتخاب شهر"
isClearable
/>
</div>
<div>
<label className="">موبایل</label>
<input value={formData.mobile_number} onChange={(e) => setFormData((p) => ({ ...p, mobile_number: e.target.value }))}
dir="ltr" className="cp-input h-11" />
</div>
<div>
<label className="">درصد کمیسیون</label>
<input value={formData.commission_percent} onChange={(e) => setFormData((p) => ({ ...p, commission_percent: e.target.value }))}
type="number" min="0" max="100" dir="ltr"
className="cp-input h-11" />
</div>
</div>
</Modal>
<ConfirmDialog
open={deleteOpen}
title="حذف نماینده"
message={`آیا از حذف نماینده "${name}" اطمینان دارید؟`}
confirmLabel="حذف"
danger
loading={deleteMutation.isPending}
onConfirm={() => deleteMutation.mutate()}
onCancel={() => setDeleteOpen(false)}
/>
</div>
);
}