feat(representation): add representation management for doctors, including attach and update functionality

This commit is contained in:
hamed
2026-07-12 14:20:42 +03:30
parent 1a8b3bfa53
commit c1e4cd7f94
5 changed files with 432 additions and 31 deletions
+62 -3
View File
@@ -2353,6 +2353,10 @@ export default function DoctorDetailPage({ isOwnProfile = false }: { isOwnProfil
const [deletingAddrId, setDeletingAddrId] = useState<string | null>(null);
const [editGender, setEditGender] = useState<'man' | 'woman' | ''>('');
const [editActivityDate, setEditActivityDate] = useState('');
const [repModalOpen, setRepModalOpen] = useState(false);
const [selRepId, setSelRepId] = useState<number | null>(null);
const isAdmin = primaryRole === 'admin';
// ── Queries ──
@@ -2476,6 +2480,29 @@ export default function DoctorDetailPage({ isOwnProfile = false }: { isOwnProfil
onError: (e: Error) => toast.error(e.message),
});
const repsQuery = useQuery({
queryKey: ['representations-select'],
queryFn: () => api.get<ApiResponse<any>>('/api/v1/admin/representations?limit=200'),
enabled: isAdmin && repModalOpen,
staleTime: 60_000,
});
const repOptions: { value: number; label: string }[] = useMemo(() => {
const rows = repsQuery.data?.data ?? [];
return (rows as any[]).map((r) => ({ value: r.id as number, label: `${r.full_name}${r.city ? `${r.city}` : ''}` }));
}, [repsQuery.data]);
const setRepMut = useMutation({
mutationFn: (representationId: number | null) =>
api.put<ApiResponse<any>>(`/api/v1/admin/doctors/${uuid}/representation`, { representation_id: representationId }),
onSuccess: () => {
toast.success('نماینده‌ی پزشک به‌روزرسانی شد');
setRepModalOpen(false);
qc.invalidateQueries({ queryKey: ['doctor-detail', uuid] });
qc.invalidateQueries({ queryKey: ['admin-doctors'] });
},
onError: (e: Error) => toast.error(e.message),
});
const deleteAddrMut = useMutation({
mutationFn: (id: string) => api.delete<ApiResponse<null>>(`/api/v1/clinic-pro/doctor-address/${id}`),
onSuccess: () => {
@@ -2703,9 +2730,19 @@ export default function DoctorDetailPage({ isOwnProfile = false }: { isOwnProfil
<InfoCard
icon={UserIcon}
label="نماینده"
value={doctor.representation
? <button type="button" style={{ color: '#2563eb', textDecoration: 'underline', background: 'none', border: 0, padding: 0, cursor: 'pointer', font: 'inherit' }} onClick={() => navigate(`/admin/representations/${doctor.representation!.uuid}`)}>{doctor.representation.full_name ?? 'نماینده'}</button>
: <span className="text-slate-400">بدون نماینده</span>}
value={
<span className="flex items-center gap-2">
{doctor.representation
? <button type="button" style={{ color: '#2563eb', textDecoration: 'underline', background: 'none', border: 0, padding: 0, cursor: 'pointer', font: 'inherit' }} onClick={() => navigate(`/admin/representations/${doctor.representation!.uuid}`)}>{doctor.representation.full_name ?? 'نماینده'}</button>
: <span className="text-slate-400">بدون نماینده</span>}
{isAdmin && (
<button type="button" className="btn ghost sm"
onClick={() => { setSelRepId(doctor.representation?.id ?? null); setRepModalOpen(true); }}>
{doctor.representation ? 'تغییر' : 'افزودن نماینده'}
</button>
)}
</span>
}
/>
</div>
</div>
@@ -3042,6 +3079,28 @@ export default function DoctorDetailPage({ isOwnProfile = false }: { isOwnProfil
confirmLabel="بله، حذف کن" danger loading={deleteMut.isPending}
onConfirm={() => deleteMut.mutate()} onCancel={() => setDeleteOpen(false)}
/>
<Modal open={repModalOpen} title="نماینده‌ی پزشک" size="md" onClose={() => setRepModalOpen(false)}
footer={
<>
<button className="btn ghost sm" onClick={() => setRepModalOpen(false)}>لغو</button>
{doctor.representation && (
<button className="btn danger sm" disabled={setRepMut.isPending}
onClick={() => setRepMut.mutate(null)}>حذف نماینده</button>
)}
<button className="btn primary sm" disabled={setRepMut.isPending || !selRepId}
onClick={() => setRepMut.mutate(selRepId)}>ذخیره</button>
</>
}
>
<label className="block text-sm text-slate-600 mb-2">انتخاب نماینده</label>
<GlobalSearchableSelect
options={repOptions}
value={selRepId}
onChange={(v) => setSelRepId(v === null || v === '' ? null : Number(v))}
placeholder={repsQuery.isLoading ? 'در حال بارگذاری…' : 'جستجوی نماینده…'}
/>
</Modal>
</div>
);
}
+136 -6
View File
@@ -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)}
+17 -10
View File
@@ -44,18 +44,24 @@ interface UserStats {
const HUES_LIST = [256, 205, 162, 295, 272];
const ROLE_META: Record<string, { label: string; badgeCls: string }> = {
admin: { label: 'ادمین', badgeCls: 'violet' },
doctor: { label: 'پزشک', badgeCls: 'blue' },
secretary: { label: 'منشی', badgeCls: 'amber' },
clinic: { label: 'کلینیک', badgeCls: 'green' },
patient: { label: 'بیمار', badgeCls: 'gray' },
admin: { label: 'ادمین', badgeCls: 'violet' },
doctor: { label: 'پزشک', badgeCls: 'blue' },
secretary: { label: 'منشی', badgeCls: 'amber' },
clinic: { label: 'کلینیک', badgeCls: 'green' },
representation: { label: 'نماینده', badgeCls: 'red' },
patient: { label: 'بیمار', badgeCls: 'gray' },
};
// نقش‌هایی که از این صفحه قابل تغییرِ مستقیم‌اند (backend `roleMap`). «نماینده» از این‌جا ست نمی‌شود
// چون ساخت نماینده رکورد Representation هم می‌خواهد؛ فقط برای نمایش badge/فیلتر تعریف شده است.
const ASSIGNABLE_ROLES = ['admin', 'doctor', 'secretary', 'clinic', 'patient'];
function getPrimaryRole(roles: string[]): string {
if (roles.includes('ROLE_ADMIN')) return 'admin';
if (roles.includes('ROLE_DOCTOR')) return 'doctor';
if (roles.includes('ROLE_SECRETARY')) return 'secretary';
if (roles.includes('ROLE_CLINIC')) return 'clinic';
if (roles.includes('ROLE_ADMIN')) return 'admin';
if (roles.includes('ROLE_DOCTOR')) return 'doctor';
if (roles.includes('ROLE_SECRETARY')) return 'secretary';
if (roles.includes('ROLE_CLINIC')) return 'clinic';
if (roles.includes('ROLE_REPRESENTATION')) return 'representation';
return 'patient';
}
@@ -114,7 +120,7 @@ function ChangeRoleModal({ user, onClose, onSave, loading }: {
<div className="modal-body">
<p className="muted" style={{ fontSize: 13, marginBottom: 16 }}>{user.name || user.mobile_number}</p>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{Object.entries(ROLE_META).map(([key, meta]) => (
{Object.entries(ROLE_META).filter(([key]) => ASSIGNABLE_ROLES.includes(key)).map(([key, meta]) => (
<label
key={key}
style={{
@@ -154,6 +160,7 @@ const ROLE_TABS = [
{ key: 'doctor', label: 'پزشک' },
{ key: 'secretary', label: 'منشی' },
{ key: 'clinic', label: 'کلینیک' },
{ key: 'representation', label: 'نماینده' },
{ key: 'patient', label: 'بیمار' },
];