feat(representation): add representation management for doctors, including attach and update functionality
This commit is contained in:
@@ -6,7 +6,7 @@ import { toast } from 'sonner';
|
||||
import { api } from '../lib/api';
|
||||
import type { ApiResponse, PaginatedResponse } from '../lib/api';
|
||||
import type { Representation, City } from '../types';
|
||||
import { formatDate, formatNumber } from '../lib/utils';
|
||||
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';
|
||||
@@ -21,10 +21,28 @@ interface RepDoctor {
|
||||
medical_code: string | null;
|
||||
is_active: boolean;
|
||||
owner_status?: string;
|
||||
appointment_count: number;
|
||||
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">
|
||||
@@ -41,6 +59,9 @@ export default function RepresentationDetailPage() {
|
||||
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'],
|
||||
@@ -65,6 +86,34 @@ export default function RepresentationDetailPage() {
|
||||
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),
|
||||
@@ -237,8 +286,11 @@ export default function RepresentationDetailPage() {
|
||||
{/* Doctors 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>
|
||||
<span className="badge blue">{formatNumber(repDoctorsTotal)} پزشک</span>
|
||||
<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>
|
||||
@@ -251,7 +303,8 @@ export default function RepresentationDetailPage() {
|
||||
<tr>
|
||||
<th>نام</th>
|
||||
<th>کد نظام</th>
|
||||
<th>تعداد نوبت</th>
|
||||
<th>نوبت گذشته</th>
|
||||
<th>نوبت آینده</th>
|
||||
<th>وضعیت</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -260,7 +313,8 @@ export default function RepresentationDetailPage() {
|
||||
<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.appointment_count)}</td>
|
||||
<td>{formatNumber(d.past_count)}</td>
|
||||
<td>{formatNumber(d.upcoming_count)}</td>
|
||||
<td><ActiveBadge active={d.is_active} /></td>
|
||||
</tr>
|
||||
))}
|
||||
@@ -270,6 +324,82 @@ export default function RepresentationDetailPage() {
|
||||
)}
|
||||
</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)}
|
||||
|
||||
Reference in New Issue
Block a user