feat(representation): add representation management for doctors, including attach and update functionality
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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: 'بیمار' },
|
||||
];
|
||||
|
||||
|
||||
+97
-2
@@ -129,7 +129,7 @@ List all users with pagination and filters.
|
||||
| `page` | integer | ❌ | Default: 1 |
|
||||
| `limit` | integer | ❌ | Default: 20 |
|
||||
| `search` | string | ❌ | Search by name or mobile |
|
||||
| `role` | string | ❌ | Filter: `ROLE_USER`, `ROLE_DOCTOR`, `ROLE_ADMIN`, etc. |
|
||||
| `role` | string | ❌ | فیلتر نقش: `admin` \| `doctor` \| `secretary` \| `clinic` \| `representation` (کاربران دارای `ROLE_REPRESENTATION`) \| `patient` |
|
||||
| `status` | string | ❌ | `"active"` or `"inactive"` |
|
||||
| `sort` | string | ❌ | `"created_at"` (default desc) |
|
||||
|
||||
@@ -344,6 +344,7 @@ List all doctors with pagination.
|
||||
| `gender` | string | ❌ | `"male"` or `"female"` |
|
||||
| `specialty_id` | integer | ❌ | Filter by specialty |
|
||||
| `owner_status` | string | ❌ | `claimed` \| `unclaimed` \| `pending_transfer` — پروفایلهای ایمپورت IRIMC (خروجی هم `owner_status` و `source` دارد) |
|
||||
| `unassigned` | string | ❌ | `1` → فقط پزشکانِ بدون نماینده (`representation_id IS NULL`) — برای انتخاب و اتصال به نماینده |
|
||||
| `sort` | string | ❌ | Sort field |
|
||||
|
||||
### Response `200`
|
||||
@@ -407,6 +408,38 @@ Toggle doctor active status.
|
||||
|
||||
---
|
||||
|
||||
### PUT `/api/v1/admin/doctors/{uuid}/representation`
|
||||
|
||||
ست کردن یا حذف نمایندهی یک پزشک (از صفحهی پروفایل پزشک در پنل ادمین).
|
||||
|
||||
**Permission:** `ROLE_ADMIN`
|
||||
|
||||
### Request Body
|
||||
```json
|
||||
{ "representation_id": 12 }
|
||||
```
|
||||
> `representation_id: null` (یا حذفشده/خالی) → نمایندهی پزشک حذف میشود (`representation_id = NULL`). بر خلاف `POST /representations/{uuid}/doctors`، این endpoint اجازهی **تغییر** نمایندهی پزشکی که از قبل نماینده دارد را هم میدهد.
|
||||
|
||||
### Response `200`
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"message": "نماینده ثبت شد",
|
||||
"representation": { "id": 12, "uuid": "9c1...", "full_name": "علی محمدی" }
|
||||
}
|
||||
}
|
||||
```
|
||||
> برای حذف، `representation` برابر `null` برمیگردد.
|
||||
|
||||
### Errors
|
||||
| Code | HTTP | Description |
|
||||
|------|------|-------------|
|
||||
| `DOCTOR_NOT_FOUND` | 404 | پزشک یافت نشد |
|
||||
| `ERR_NOT_FOUND_001` | 404 | نماینده یافت نشد |
|
||||
|
||||
---
|
||||
|
||||
## Clinic Management
|
||||
|
||||
### GET `/api/v1/admin/clinics`
|
||||
@@ -805,10 +838,72 @@ Paginated. Each item:
|
||||
"medical_code": "12345",
|
||||
"is_active": true,
|
||||
"owner_status": "claimed",
|
||||
"appointment_count": 17,
|
||||
"past_count": 12,
|
||||
"upcoming_count": 5,
|
||||
"created_at": "2026-06-18T..."
|
||||
}
|
||||
```
|
||||
> `past_count` = نوبتهای `slot_start < now`؛ `upcoming_count` = `slot_start >= now`.
|
||||
|
||||
### Errors
|
||||
| Code | HTTP | Description |
|
||||
|------|------|-------------|
|
||||
| `ERR_NOT_FOUND_001` | 404 | نماینده یافت نشد |
|
||||
|
||||
---
|
||||
|
||||
### POST `/api/v1/admin/representations/{uuid}/doctors`
|
||||
|
||||
اتصال یک پزشکِ موجودِ **بدون نماینده** به این نماینده (`representation_id` ست میشود).
|
||||
|
||||
**Permission:** `ROLE_ADMIN`
|
||||
|
||||
### Request Body
|
||||
```json
|
||||
{ "doctor_uuid": "550e8400-..." }
|
||||
```
|
||||
|
||||
### Response `200`
|
||||
```json
|
||||
{ "success": true, "data": { "message": "پزشک به نماینده متصل شد", "doctor_uuid": "550e8400-...", "representation_id": 12 } }
|
||||
```
|
||||
|
||||
### Errors
|
||||
| Code | HTTP | Description |
|
||||
|------|------|-------------|
|
||||
| `ERR_VALIDATION_001` | 422 | `doctor_uuid` ارسال نشده |
|
||||
| `ERR_NOT_FOUND_001` | 404 | نماینده یا پزشک یافت نشد |
|
||||
| `ERR_CONFLICT_001` | 409 | پزشک از قبل به یک نماینده متصل است |
|
||||
|
||||
---
|
||||
|
||||
### GET `/api/v1/admin/representations/{uuid}/appointments`
|
||||
|
||||
نوبتهای پزشکانِ زیرمجموعهی یک نماینده (paginated).
|
||||
|
||||
**Permission:** `ROLE_ADMIN`
|
||||
|
||||
### Query Parameters
|
||||
| Param | Type | Required | Description |
|
||||
|-------|------|----------|-------------|
|
||||
| `scope` | string | ❌ | `upcoming` (پیشفرض، `slot_start >= now`، صعودی) یا `past` (`slot_start < now`، نزولی) |
|
||||
| `page` | integer | ❌ | Default: 1 |
|
||||
| `limit` | integer | ❌ | Default: 15 (max 100) |
|
||||
|
||||
### Response `200`
|
||||
Paginated. Each item:
|
||||
```json
|
||||
{
|
||||
"uuid": "...",
|
||||
"slot_start": 1750000000,
|
||||
"slot_end": 1750001800,
|
||||
"status": "confirmed",
|
||||
"patient_name": "علی رضایی",
|
||||
"doctor_uuid": "...",
|
||||
"doctor_name": "دکتر علی احمدی"
|
||||
}
|
||||
```
|
||||
> `slot_start`/`slot_end` Unix timestamp (ثانیه).
|
||||
|
||||
### Errors
|
||||
| Code | HTTP | Description |
|
||||
|
||||
@@ -228,6 +228,7 @@ class AdminApiController extends BaseController
|
||||
'doctor' => $qb->andWhere("u.roles LIKE :role AND u.roles NOT LIKE :notRole")
|
||||
->setParameter('role', '%ROLE_DOCTOR%')->setParameter('notRole', '%ROLE_ADMIN%'),
|
||||
'secretary' => $qb->andWhere("u.roles LIKE :role")->setParameter('role', '%ROLE_SECRETARY%'),
|
||||
'representation' => $qb->andWhere("u.roles LIKE :role")->setParameter('role', '%ROLE_REPRESENTATION%'),
|
||||
'clinic' => $qb->andWhere("u.roles LIKE :role AND u.roles NOT LIKE :notRole")
|
||||
->setParameter('role', '%ROLE_CLINIC%')->setParameter('notRole', '%ROLE_ADMIN%'),
|
||||
'patient' => $qb->andWhere("u.roles NOT LIKE :d AND u.roles NOT LIKE :a")
|
||||
@@ -297,6 +298,37 @@ class AdminApiController extends BaseController
|
||||
return $this->success(['is_active' => $doctor->isActiveDoctorAppointment()]);
|
||||
}
|
||||
|
||||
#[Route('/api/v1/admin/doctors/{uuid}/representation', methods: ['PUT'])]
|
||||
public function setDoctorRepresentation(string $uuid, Request $request, RepresentationRepository $repRepo): JsonResponse
|
||||
{
|
||||
$doctor = $this->em->getRepository(Doctor::class)->findOneBy(['uuid' => $uuid]);
|
||||
if ($doctor === null) {
|
||||
return $this->error(ErrorCodes::DOCTOR_NOT_FOUND, 'پزشک یافت نشد', 404);
|
||||
}
|
||||
|
||||
$body = json_decode($request->getContent(), true) ?? [];
|
||||
$repId = $body['representation_id'] ?? null;
|
||||
|
||||
if ($repId === null || $repId === '') {
|
||||
$doctor->setRepresentationId(null);
|
||||
$this->em->flush();
|
||||
return $this->success(['message' => 'نماینده حذف شد', 'representation' => null]);
|
||||
}
|
||||
|
||||
$rep = $repRepo->find((int) $repId);
|
||||
if ($rep === null) {
|
||||
return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'نماینده یافت نشد', 404);
|
||||
}
|
||||
|
||||
$doctor->setRepresentationId($rep->getId());
|
||||
$this->em->flush();
|
||||
|
||||
return $this->success([
|
||||
'message' => 'نماینده ثبت شد',
|
||||
'representation' => ['id' => $rep->getId(), 'uuid' => $rep->getUuid(), 'full_name' => $rep->getFullName()],
|
||||
]);
|
||||
}
|
||||
|
||||
#[Route('/api/v1/admin/doctors', methods: ['GET'])]
|
||||
public function doctorsList(Request $request): JsonResponse
|
||||
{
|
||||
@@ -1130,26 +1162,104 @@ class AdminApiController extends BaseController
|
||||
$conn = $this->em->getConnection();
|
||||
$total = (int) $conn->fetchOne('SELECT COUNT(*) FROM doctors WHERE representation_id = ?', [$repId]);
|
||||
|
||||
$now = time();
|
||||
$offset = ($page - 1) * $limit;
|
||||
$rows = $conn->fetchAllAssociative(
|
||||
"SELECT d.id, d.uuid, d.name, d.gender, d.medical_system_code,
|
||||
d.active_doctor_appointment, d.owner_status, d.created_at,
|
||||
(SELECT COUNT(*) FROM appointments a WHERE a.doctor_id = d.id) AS appointment_count
|
||||
(SELECT COUNT(*) FROM appointments a WHERE a.doctor_id = d.id AND a.slot_start < $now) AS past_count,
|
||||
(SELECT COUNT(*) FROM appointments a WHERE a.doctor_id = d.id AND a.slot_start >= $now) AS upcoming_count
|
||||
FROM doctors d
|
||||
WHERE d.representation_id = ? ORDER BY d.created_at DESC LIMIT $limit OFFSET $offset",
|
||||
[$repId]
|
||||
);
|
||||
|
||||
$items = array_map(fn(array $d): array => [
|
||||
'uuid' => $d['uuid'],
|
||||
'id' => (int) $d['id'],
|
||||
'name' => $d['name'],
|
||||
'gender' => $d['gender'],
|
||||
'medical_code' => $d['medical_system_code'],
|
||||
'is_active' => (bool) $d['active_doctor_appointment'],
|
||||
'owner_status' => $d['owner_status'],
|
||||
'appointment_count' => (int) $d['appointment_count'],
|
||||
'created_at' => date('c', (int) $d['created_at']),
|
||||
'uuid' => $d['uuid'],
|
||||
'id' => (int) $d['id'],
|
||||
'name' => $d['name'],
|
||||
'gender' => $d['gender'],
|
||||
'medical_code' => $d['medical_system_code'],
|
||||
'is_active' => (bool) $d['active_doctor_appointment'],
|
||||
'owner_status' => $d['owner_status'],
|
||||
'past_count' => (int) $d['past_count'],
|
||||
'upcoming_count' => (int) $d['upcoming_count'],
|
||||
'created_at' => date('c', (int) $d['created_at']),
|
||||
], $rows);
|
||||
|
||||
return $this->paginated($items, $total, $page, $limit);
|
||||
}
|
||||
|
||||
#[Route('/api/v1/admin/representations/{uuid}/doctors', methods: ['POST'])]
|
||||
public function attachDoctorToRepresentation(string $uuid, Request $request, RepresentationRepository $repRepo): JsonResponse
|
||||
{
|
||||
$rep = $repRepo->findByUuid($uuid);
|
||||
if ($rep === null) {
|
||||
return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'نماینده یافت نشد', 404);
|
||||
}
|
||||
|
||||
$body = json_decode($request->getContent(), true) ?? [];
|
||||
$doctorUuid = trim((string) ($body['doctor_uuid'] ?? ''));
|
||||
if ($doctorUuid === '') {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'شناسهی پزشک الزامی است', 422);
|
||||
}
|
||||
|
||||
$doctor = $this->em->getRepository(Doctor::class)->findOneBy(['uuid' => $doctorUuid]);
|
||||
if ($doctor === null) {
|
||||
return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'پزشک یافت نشد', 404);
|
||||
}
|
||||
if ($doctor->getRepresentationId() !== null) {
|
||||
return $this->error(ErrorCodes::ERR_CONFLICT_001, 'این پزشک از قبل به یک نماینده متصل است', 409);
|
||||
}
|
||||
|
||||
$doctor->setRepresentationId($rep->getId());
|
||||
$this->em->flush();
|
||||
|
||||
return $this->success(['message' => 'پزشک به نماینده متصل شد', 'doctor_uuid' => $doctorUuid, 'representation_id' => $rep->getId()]);
|
||||
}
|
||||
|
||||
#[Route('/api/v1/admin/representations/{uuid}/appointments', methods: ['GET'])]
|
||||
public function representationAppointments(string $uuid, Request $request, RepresentationRepository $repRepo): JsonResponse
|
||||
{
|
||||
$rep = $repRepo->findByUuid($uuid);
|
||||
if ($rep === null) {
|
||||
return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'نماینده یافت نشد', 404);
|
||||
}
|
||||
|
||||
$page = max(1, (int) $request->query->get('page', 1));
|
||||
$limit = min(100, max(1, (int) $request->query->get('limit', 15)));
|
||||
$scope = $request->query->get('scope') === 'past' ? 'past' : 'upcoming';
|
||||
$repId = $rep->getId();
|
||||
$now = time();
|
||||
|
||||
$conn = $this->em->getConnection();
|
||||
$cmp = $scope === 'past' ? '<' : '>=';
|
||||
$order = $scope === 'past' ? 'DESC' : 'ASC';
|
||||
|
||||
$total = (int) $conn->fetchOne(
|
||||
"SELECT COUNT(*) FROM appointments a JOIN doctors d ON d.id = a.doctor_id
|
||||
WHERE d.representation_id = ? AND a.slot_start $cmp ?",
|
||||
[$repId, $now]
|
||||
);
|
||||
|
||||
$offset = ($page - 1) * $limit;
|
||||
$rows = $conn->fetchAllAssociative(
|
||||
"SELECT a.uuid, a.slot_start, a.slot_end, a.status, a.patient_name,
|
||||
d.uuid AS doctor_uuid, d.name AS doctor_name
|
||||
FROM appointments a JOIN doctors d ON d.id = a.doctor_id
|
||||
WHERE d.representation_id = ? AND a.slot_start $cmp ?
|
||||
ORDER BY a.slot_start $order LIMIT $limit OFFSET $offset",
|
||||
[$repId, $now]
|
||||
);
|
||||
|
||||
$items = array_map(fn(array $a): array => [
|
||||
'uuid' => $a['uuid'],
|
||||
'slot_start' => (int) $a['slot_start'],
|
||||
'slot_end' => (int) $a['slot_end'],
|
||||
'status' => $a['status'],
|
||||
'patient_name' => $a['patient_name'],
|
||||
'doctor_uuid' => $a['doctor_uuid'],
|
||||
'doctor_name' => $a['doctor_name'],
|
||||
], $rows);
|
||||
|
||||
return $this->paginated($items, $total, $page, $limit);
|
||||
|
||||
Reference in New Issue
Block a user