feat: add doctor invitation modal and appointment creation API
- Implemented InviteDoctorModal component for inviting doctors to clinics. - Updated ClinicDashboard to include a button for inviting doctors and handle modal state. - Added createAppointment API endpoint in AdminApiController for scheduling appointments. - Enhanced ClinicInvitationController to check user access when inviting doctors. - Updated MyAppointmentsController to ensure unique appointment records. - Added seed_test_data.php for populating test data including doctors, clinics, and appointments. - Refactored styles to include new appointment status badges and updated font imports.
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import { Routes, Route, Navigate } from 'react-router-dom';
|
||||
import { Routes, Route, Navigate, useLocation } from 'react-router-dom';
|
||||
import { useAuthStore } from './stores/authStore';
|
||||
import AdminLayout from './components/layout/AdminLayout';
|
||||
import LoginPage from './pages/LoginPage';
|
||||
@@ -33,6 +33,7 @@ import SettingsPage from './pages/SettingsPage';
|
||||
|
||||
function PrivateRoute({ children }: { children: React.ReactNode }) {
|
||||
const { isAuthenticated, primaryRole, availableContexts, dbUuid, fetchMe } = useAuthStore();
|
||||
const location = useLocation();
|
||||
|
||||
useEffect(() => {
|
||||
if (isAuthenticated && !primaryRole) {
|
||||
@@ -47,8 +48,8 @@ function PrivateRoute({ children }: { children: React.ReactNode }) {
|
||||
return <div style={{ padding: 40, textAlign: 'center' }}>در حال بارگذاری...</div>;
|
||||
}
|
||||
|
||||
// اگر چند context دارد و هنوز انتخاب نشده — به صفحه انتخاب برو
|
||||
if (availableContexts.length > 1 && !dbUuid) {
|
||||
// اگر چند context دارد و هنوز انتخاب نشده و روی صفحه انتخاب نیستیم
|
||||
if (availableContexts.length > 1 && !dbUuid && location.pathname !== '/admin/select-context') {
|
||||
return <Navigate to="/admin/select-context" replace />;
|
||||
}
|
||||
|
||||
@@ -126,9 +127,11 @@ export default function App() {
|
||||
|
||||
{/* ادمین + کلینیک */}
|
||||
<Route path="clinics/:uuid" element={<RoleRoute roles={['admin', 'clinic']}><ClinicDetailPage /></RoleRoute>} />
|
||||
<Route path="doctors" element={<RoleRoute roles={['admin', 'clinic']}><DoctorsPage /></RoleRoute>} />
|
||||
<Route path="doctors/new" element={<RoleRoute roles={['admin', 'clinic']}><DoctorFormPage /></RoleRoute>} />
|
||||
<Route path="doctors/:uuid" element={<RoleRoute roles={['admin', 'clinic', 'doctor']}><DoctorDetailPage /></RoleRoute>} />
|
||||
|
||||
{/* فقط ادمین — کلینیک از طریق دعوتنامه در صفحه کلینیک خود دکتر اضافه میکند */}
|
||||
<Route path="doctors" element={<RoleRoute roles={['admin']}><DoctorsPage /></RoleRoute>} />
|
||||
<Route path="doctors/new" element={<RoleRoute roles={['admin']}><DoctorFormPage /></RoleRoute>} />
|
||||
<Route path="doctors/:uuid" element={<RoleRoute roles={['admin', 'doctor']}><DoctorDetailPage /></RoleRoute>} />
|
||||
</Route>
|
||||
|
||||
<Route path="*" element={<Navigate to="/admin/dashboard" replace />} />
|
||||
|
||||
@@ -73,7 +73,6 @@ function buildSections(primaryRole: string | null, dbUuid: string | null): Secti
|
||||
items: [
|
||||
{ to: '/admin/dashboard', icon: ChartBarIcon, label: 'داشبورد' },
|
||||
{ to: clinicTo, icon: BuildingOffice2Icon, label: 'کلینیک من' },
|
||||
{ to: '/admin/doctors', icon: HeartIcon, label: 'پزشکان' },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { z } from 'zod';
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import { XMarkIcon } from '@heroicons/react/24/outline';
|
||||
import { toast } from 'sonner';
|
||||
import { api } from '../../lib/api';
|
||||
import type { ApiResponse } from '../../lib/api';
|
||||
|
||||
const inviteSchema = z.object({
|
||||
mobile: z.string().regex(/^09\d{9}$/, 'شماره موبایل ۱۱ رقمی با 09 شروع میشود'),
|
||||
name: z.string().optional(),
|
||||
specialty: z.string().optional(),
|
||||
});
|
||||
type InviteForm = z.infer<typeof inviteSchema>;
|
||||
|
||||
interface Props {
|
||||
clinicUuid: string;
|
||||
onClose: () => void;
|
||||
onInvited?: () => void;
|
||||
}
|
||||
|
||||
export default function InviteDoctorModal({ clinicUuid, onClose, onInvited }: Props) {
|
||||
const { register, handleSubmit, formState: { errors } } = useForm<InviteForm>({
|
||||
resolver: zodResolver(inviteSchema),
|
||||
});
|
||||
|
||||
const inviteMut = useMutation({
|
||||
mutationFn: (d: InviteForm) =>
|
||||
api.post<ApiResponse<unknown>>(`/api/v1/admin/clinic/${clinicUuid}/invite-doctor`, d),
|
||||
onSuccess: () => {
|
||||
toast.success('دعوتنامه ارسال شد');
|
||||
onInvited?.();
|
||||
onClose();
|
||||
},
|
||||
onError: (e: Error) => toast.error(e.message),
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="overlay" onClick={onClose}>
|
||||
<div className="modal" style={{ maxWidth: 420 }} onClick={e => e.stopPropagation()}>
|
||||
<div className="modal-head">
|
||||
<b>دعوت پزشک به کلینیک</b>
|
||||
<button className="mini-btn" onClick={onClose}><XMarkIcon style={{ width: 16, height: 16 }} /></button>
|
||||
</div>
|
||||
<form onSubmit={handleSubmit(d => inviteMut.mutate(d))}>
|
||||
<div className="modal-body" style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
|
||||
<div>
|
||||
<label style={{ fontSize: 13, fontWeight: 600, display: 'block', marginBottom: 6 }}>شماره موبایل پزشک *</label>
|
||||
<input className="input" dir="ltr" placeholder="09xxxxxxxxx" {...register('mobile')} />
|
||||
{errors.mobile && <div className="err-text">{errors.mobile.message}</div>}
|
||||
</div>
|
||||
<div>
|
||||
<label style={{ fontSize: 13, fontWeight: 600, display: 'block', marginBottom: 6 }}>نام پزشک (اختیاری)</label>
|
||||
<input className="input" placeholder="دکتر نام و نام خانوادگی" {...register('name')} />
|
||||
</div>
|
||||
<div>
|
||||
<label style={{ fontSize: 13, fontWeight: 600, display: 'block', marginBottom: 6 }}>تخصص (اختیاری)</label>
|
||||
<input className="input" placeholder="مثال: قلب و عروق" {...register('specialty')} />
|
||||
</div>
|
||||
<p className="muted" style={{ fontSize: 12 }}>پیامک دعوتنامه با لینک ۷۲ ساعته ارسال میشود</p>
|
||||
</div>
|
||||
<div className="modal-foot">
|
||||
<button type="button" className="btn ghost sm" onClick={onClose}>انصراف</button>
|
||||
<button type="submit" className="btn primary sm" disabled={inviteMut.isPending}>
|
||||
{inviteMut.isPending ? 'در حال ارسال...' : 'ارسال دعوتنامه'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import React, { useRef } from 'react';
|
||||
import { CalendarDaysIcon, XMarkIcon } from '@heroicons/react/24/outline';
|
||||
import { formatDate } from '../../lib/utils';
|
||||
|
||||
interface Props {
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
placeholder?: string;
|
||||
min?: string;
|
||||
max?: string;
|
||||
style?: React.CSSProperties;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function PersianDateInput({ value, onChange, placeholder = 'انتخاب تاریخ', min, max, style, className }: Props) {
|
||||
const hiddenRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const open = () => {
|
||||
const el = hiddenRef.current;
|
||||
if (!el) return;
|
||||
if (typeof el.showPicker === 'function') {
|
||||
try { el.showPicker(); } catch { el.focus(); }
|
||||
} else {
|
||||
el.focus();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ position: 'relative', display: 'inline-flex', alignItems: 'center', ...style }} className={className}>
|
||||
{/* visible text layer */}
|
||||
<div
|
||||
onClick={open}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 7,
|
||||
height: 36, padding: '0 10px', borderRadius: 'var(--r-sm)',
|
||||
border: '1px solid var(--border)', background: 'var(--surface)',
|
||||
cursor: 'pointer', fontSize: 13, color: value ? 'var(--text)' : 'var(--text-3)',
|
||||
userSelect: 'none', minWidth: 148, whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
<CalendarDaysIcon style={{ width: 15, height: 15, color: 'var(--text-3)', flexShrink: 0 }} />
|
||||
<span style={{ flex: 1 }}>{value ? formatDate(value) : placeholder}</span>
|
||||
{value && (
|
||||
<span
|
||||
onClick={e => { e.stopPropagation(); onChange(''); }}
|
||||
style={{ display: 'flex', alignItems: 'center', cursor: 'pointer', color: 'var(--text-3)' }}
|
||||
>
|
||||
<XMarkIcon style={{ width: 13, height: 13 }} />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* hidden native input — opens picker on click */}
|
||||
<input
|
||||
ref={hiddenRef}
|
||||
type="date"
|
||||
value={value}
|
||||
min={min}
|
||||
max={max}
|
||||
onChange={e => onChange(e.target.value)}
|
||||
style={{
|
||||
position: 'absolute', opacity: 0, pointerEvents: 'none',
|
||||
width: 1, height: 1, top: 0, left: 0,
|
||||
}}
|
||||
tabIndex={-1}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+28
-24
@@ -6,32 +6,36 @@ export function formatNumber(n: number): string {
|
||||
return new Intl.NumberFormat('fa-IR').format(n);
|
||||
}
|
||||
|
||||
export function formatDate(dateStr: string | null | undefined): string {
|
||||
if (!dateStr) return '—';
|
||||
try {
|
||||
return new Intl.DateTimeFormat('fa-IR', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
}).format(new Date(dateStr));
|
||||
} catch {
|
||||
return dateStr;
|
||||
}
|
||||
export function toDate(val: string | number | null | undefined): Date | null {
|
||||
if (val == null || val === '') return null;
|
||||
if (typeof val === 'number') return new Date(val * 1000);
|
||||
// Y-m-d → treat as local noon to avoid UTC-off-by-one
|
||||
if (/^\d{4}-\d{2}-\d{2}$/.test(val)) return new Date(`${val}T12:00:00`);
|
||||
return new Date(val);
|
||||
}
|
||||
|
||||
export function formatDateTime(dateStr: string | null | undefined): string {
|
||||
if (!dateStr) return '—';
|
||||
try {
|
||||
return new Intl.DateTimeFormat('fa-IR', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
}).format(new Date(dateStr));
|
||||
} catch {
|
||||
return dateStr;
|
||||
}
|
||||
export function formatDate(val: string | number | null | undefined): string {
|
||||
const d = toDate(val);
|
||||
if (!d || isNaN(d.getTime())) return '—';
|
||||
return new Intl.DateTimeFormat('fa-IR-u-ca-persian', {
|
||||
year: 'numeric', month: '2-digit', day: '2-digit',
|
||||
}).format(d);
|
||||
}
|
||||
|
||||
export function formatDateTime(val: string | number | null | undefined): string {
|
||||
const d = toDate(val);
|
||||
if (!d || isNaN(d.getTime())) return '—';
|
||||
return new Intl.DateTimeFormat('fa-IR-u-ca-persian', {
|
||||
year: 'numeric', month: '2-digit', day: '2-digit',
|
||||
hour: '2-digit', minute: '2-digit',
|
||||
}).format(d);
|
||||
}
|
||||
|
||||
export function toGregorianDate(d: Date): string {
|
||||
const y = d.getFullYear();
|
||||
const m = String(d.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(d.getDate()).padStart(2, '0');
|
||||
return `${y}-${m}-${day}`;
|
||||
}
|
||||
|
||||
export function maskMobile(mobile: string): string {
|
||||
|
||||
@@ -1,46 +1,61 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { useQuery, useMutation } from '@tanstack/react-query';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
MagnifyingGlassIcon, EyeIcon, TableCellsIcon, CalendarDaysIcon as CalendarViewIcon,
|
||||
CalendarIcon, ClockIcon, CheckCircleIcon, XCircleIcon, FunnelIcon,
|
||||
CalendarIcon, ClockIcon, CheckCircleIcon, XCircleIcon,
|
||||
PlusIcon, XMarkIcon, ChevronRightIcon, ChevronLeftIcon, UserCircleIcon,
|
||||
} from '@heroicons/react/24/outline';
|
||||
import { toast } from 'sonner';
|
||||
import { api } from '../lib/api';
|
||||
import type { PaginatedResponse } from '../lib/api';
|
||||
import type { PaginatedResponse, ApiResponse } from '../lib/api';
|
||||
import type { Appointment } from '../types';
|
||||
import { formatDate, formatRial, maskMobile } from '../lib/utils';
|
||||
import { formatDate, maskMobile, toGregorianDate } from '../lib/utils';
|
||||
import DataTable, { Column } from '../components/ui/DataTable';
|
||||
import StatusBadge from '../components/ui/StatusBadge';
|
||||
import Pagination from '../components/ui/Pagination';
|
||||
import PersianDateInput from '../components/ui/PersianDateInput';
|
||||
import { useAuthStore } from '../stores/authStore';
|
||||
|
||||
// ── Status helpers ────────────────────────────────────────────────────────
|
||||
// ── Status config ──────────────────────────────────────────────────────────
|
||||
|
||||
const STATUS_META: Record<string, { label: string; cls: string }> = {
|
||||
waiting_for_payment: { label: 'انتظار پرداخت', cls: 'status-amber' },
|
||||
reserved: { label: 'رزرو شده', cls: 'status-blue' },
|
||||
checked_in: { label: 'ورود به مطب', cls: 'status-violet' },
|
||||
waiting: { label: 'صف انتظار', cls: 'status-amber' },
|
||||
in_progress: { label: 'در حال ویزیت', cls: 'status-violet' },
|
||||
visited: { label: 'ویزیت شده', cls: 'status-green' },
|
||||
completed: { label: 'تکمیل شده', cls: 'status-green' },
|
||||
cancelled_by_user: { label: 'لغو توسط بیمار', cls: 'status-red' },
|
||||
cancelled_by_doctor: { label: 'لغو توسط پزشک', cls: 'status-red' },
|
||||
cancelled_by_admin: { label: 'لغو توسط ادمین', cls: 'status-red' },
|
||||
auto_cancel_unpaid: { label: 'لغو خودکار', cls: 'status-gray' },
|
||||
no_show: { label: 'غیبت', cls: 'status-gray' },
|
||||
};
|
||||
|
||||
const STATUS_FILTERS = [
|
||||
{ value: '', label: 'همه' },
|
||||
{ value: '', label: 'همه وضعیتها' },
|
||||
{ value: 'reserved', label: 'رزرو شده' },
|
||||
{ value: 'waiting_for_payment', label: 'در انتظار پرداخت' },
|
||||
{ value: 'reserved', label: 'رزرو شده' },
|
||||
{ value: 'checked_in', label: 'ورود به مطب' },
|
||||
{ value: 'waiting', label: 'صف انتظار' },
|
||||
{ value: 'in_progress', label: 'در حال ویزیت' },
|
||||
{ value: 'visited', label: 'ویزیت شده' },
|
||||
{ value: 'completed', label: 'تکمیل شده' },
|
||||
{ value: 'checked_in', label: 'ورود به مطب' },
|
||||
{ value: 'waiting', label: 'صف انتظار' },
|
||||
{ value: 'in_progress', label: 'در حال ویزیت' },
|
||||
{ value: 'visited', label: 'ویزیت شده' },
|
||||
{ value: 'completed', label: 'تکمیل شده' },
|
||||
{ value: 'cancelled_by_doctor', label: 'لغو پزشک' },
|
||||
{ value: 'cancelled_by_user', label: 'لغو بیمار' },
|
||||
{ value: 'no_show', label: 'غیبت' },
|
||||
{ value: 'cancelled_by_user', label: 'لغو بیمار' },
|
||||
{ value: 'no_show', label: 'غیبت' },
|
||||
];
|
||||
|
||||
const APPT_CLS: Record<string, string> = {
|
||||
waiting_for_payment: 'amber', reserved: 'blue', checked_in: 'violet',
|
||||
waiting: 'amber', in_progress: 'violet', visited: 'green', completed: 'green',
|
||||
cancelled_by_doctor: 'red', cancelled_by_user: 'red', auto_cancel_unpaid: 'gray', no_show: 'gray',
|
||||
};
|
||||
const APPT_LABEL: Record<string, string> = {
|
||||
waiting_for_payment: 'انتظار پرداخت', reserved: 'رزرو شده', checked_in: 'ورود به مطب',
|
||||
waiting: 'صف انتظار', in_progress: 'در حال ویزیت', visited: 'ویزیت شده',
|
||||
completed: 'تکمیل شده', cancelled_by_doctor: 'لغو پزشک', cancelled_by_user: 'لغو بیمار',
|
||||
auto_cancel_unpaid: 'لغو خودکار', no_show: 'غیبت',
|
||||
};
|
||||
function ApptStatus({ status }: { status: string }) {
|
||||
const m = STATUS_META[status] ?? { label: status, cls: 'status-gray' };
|
||||
return (
|
||||
<span className={`appt-status ${m.cls}`}>
|
||||
<span className="appt-status-dot" />
|
||||
{m.label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Timeline View ─────────────────────────────────────────────────────────
|
||||
|
||||
@@ -48,18 +63,68 @@ interface TimelineProps {
|
||||
items: Appointment[];
|
||||
loading: boolean;
|
||||
onView: (uuid: string) => void;
|
||||
groupByDoctor?: boolean;
|
||||
}
|
||||
|
||||
function TimelineView({ items, loading, onView }: TimelineProps) {
|
||||
function DayGroup({ date, appts, onView }: { date: string; appts: Appointment[]; onView: (u: string) => void }) {
|
||||
return (
|
||||
<div style={{ marginBottom: '1.25rem' }}>
|
||||
<div style={{
|
||||
fontSize: 12, fontWeight: 600, color: 'var(--text-3)', marginBottom: '0.6rem',
|
||||
display: 'flex', alignItems: 'center', gap: 8,
|
||||
}}>
|
||||
<span style={{ width: 24, height: 1, background: 'var(--border)', display: 'inline-block' }} />
|
||||
{formatDate(date)}
|
||||
<span style={{ flex: 1, height: 1, background: 'var(--border)', display: 'inline-block' }} />
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{appts.map((a) => (
|
||||
<div
|
||||
key={a.uuid}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 14, padding: '0.7rem 1rem',
|
||||
background: 'var(--surface-alt, #f8fafc)', borderRadius: 10,
|
||||
border: '1px solid var(--border)', cursor: 'pointer', transition: 'box-shadow .15s',
|
||||
}}
|
||||
onClick={() => onView(a.uuid)}
|
||||
onMouseEnter={e => (e.currentTarget.style.boxShadow = '0 2px 8px rgba(0,0,0,.07)')}
|
||||
onMouseLeave={e => (e.currentTarget.style.boxShadow = 'none')}
|
||||
>
|
||||
<div style={{
|
||||
width: 50, height: 50, borderRadius: 10, flexShrink: 0,
|
||||
background: 'var(--primary-soft, #eef2ff)', color: 'var(--primary)',
|
||||
display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center',
|
||||
fontWeight: 700, fontSize: 14, lineHeight: 1.2,
|
||||
}}>
|
||||
{a.appointment_time}
|
||||
</div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ fontWeight: 600, fontSize: 13.5, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
||||
{a.patient_name || maskMobile(a.patient_mobile)}
|
||||
</div>
|
||||
<div className="muted" style={{ fontSize: 12, marginTop: 2 }}>
|
||||
دکتر {a.doctor_name}{a.clinic_name ? ` · ${a.clinic_name}` : ''}
|
||||
</div>
|
||||
</div>
|
||||
<ApptStatus status={a.status} />
|
||||
<EyeIcon style={{ width: 15, height: 15, color: 'var(--text-3)', flexShrink: 0 }} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TimelineView({ items, loading, onView, groupByDoctor }: TimelineProps) {
|
||||
if (loading) {
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12, padding: '1rem' }}>
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<div key={i} style={{ display: 'flex', gap: 12, alignItems: 'flex-start' }}>
|
||||
<div className="skeleton" style={{ width: 48, height: 48, borderRadius: 10, flexShrink: 0 }} />
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<div key={i} style={{ display: 'flex', gap: 12, alignItems: 'center' }}>
|
||||
<div className="skeleton" style={{ width: 50, height: 50, borderRadius: 10, flexShrink: 0 }} />
|
||||
<div style={{ flex: 1 }}>
|
||||
<div className="skeleton" style={{ height: 14, borderRadius: 5, width: '55%', marginBottom: 6 }} />
|
||||
<div className="skeleton" style={{ height: 12, borderRadius: 5, width: '35%' }} />
|
||||
<div className="skeleton" style={{ height: 13, borderRadius: 4, width: '50%', marginBottom: 7 }} />
|
||||
<div className="skeleton" style={{ height: 11, borderRadius: 4, width: '30%' }} />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
@@ -71,97 +136,339 @@ function TimelineView({ items, loading, onView }: TimelineProps) {
|
||||
return <p className="muted" style={{ textAlign: 'center', padding: '3rem 0', fontSize: 13.5 }}>هیچ نوبتی یافت نشد</p>;
|
||||
}
|
||||
|
||||
// گروهبندی بر اساس تاریخ
|
||||
const grouped = items.reduce<Record<string, Appointment[]>>((acc, a) => {
|
||||
const key = a.appointment_date;
|
||||
if (!acc[key]) acc[key] = [];
|
||||
acc[key].push(a);
|
||||
if (groupByDoctor) {
|
||||
// group by doctor → date
|
||||
const byDoctor = items.reduce<Record<string, Appointment[]>>((acc, a) => {
|
||||
(acc[a.doctor_name] ??= []).push(a);
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
return (
|
||||
<div style={{ padding: '0 1rem 1rem' }}>
|
||||
{Object.entries(byDoctor).map(([docName, docAppts]) => {
|
||||
const byDate = docAppts.reduce<Record<string, Appointment[]>>((acc, a) => {
|
||||
(acc[a.appointment_date] ??= []).push(a);
|
||||
return acc;
|
||||
}, {});
|
||||
return (
|
||||
<div key={docName} style={{ marginBottom: '1.5rem' }}>
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', gap: 8, marginBottom: '0.75rem',
|
||||
padding: '6px 10px', borderRadius: 8,
|
||||
background: 'var(--primary-soft, #eef2ff)', border: '1px solid color-mix(in srgb, var(--primary) 20%, transparent)',
|
||||
}}>
|
||||
<UserCircleIcon style={{ width: 16, height: 16, color: 'var(--primary)' }} />
|
||||
<span style={{ fontWeight: 700, fontSize: 13.5, color: 'var(--primary)' }}>دکتر {docName}</span>
|
||||
<span className="muted" style={{ fontSize: 12, marginRight: 'auto' }}>{docAppts.length} نوبت</span>
|
||||
</div>
|
||||
{Object.entries(byDate).map(([date, appts]) => (
|
||||
<DayGroup key={date} date={date} appts={appts} onView={onView} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// default: group by date only
|
||||
const byDate = items.reduce<Record<string, Appointment[]>>((acc, a) => {
|
||||
(acc[a.appointment_date] ??= []).push(a);
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
return (
|
||||
<div style={{ padding: '0 1rem 1rem' }}>
|
||||
{Object.entries(grouped).map(([date, appts]) => (
|
||||
<div key={date} style={{ marginBottom: '1.5rem' }}>
|
||||
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text-3)', marginBottom: '0.75rem', display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<span style={{ width: 24, height: 1, background: 'var(--border)', display: 'inline-block' }} />
|
||||
{new Date(date).toLocaleDateString('fa-IR', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' })}
|
||||
<span style={{ flex: 1, height: 1, background: 'var(--border)', display: 'inline-block' }} />
|
||||
{Object.entries(byDate).map(([date, appts]) => (
|
||||
<DayGroup key={date} date={date} appts={appts} onView={onView} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Doctor-grouped table ──────────────────────────────────────────────────
|
||||
|
||||
interface DoctorGroupedTableProps {
|
||||
items: Appointment[];
|
||||
loading: boolean;
|
||||
onView: (uuid: string) => void;
|
||||
columns: Column<Appointment>[];
|
||||
}
|
||||
|
||||
function DoctorGroupedTable({ items, loading, onView, columns }: DoctorGroupedTableProps) {
|
||||
if (loading) {
|
||||
return (
|
||||
<div style={{ padding: '1rem' }}>
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<div key={i} className="skeleton" style={{ height: 44, borderRadius: 6, marginBottom: 8 }} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (!items.length) {
|
||||
return <p className="muted" style={{ textAlign: 'center', padding: '3rem 0', fontSize: 13.5 }}>هیچ نوبتی یافت نشد</p>;
|
||||
}
|
||||
|
||||
const byDoctor = items.reduce<Record<string, Appointment[]>>((acc, a) => {
|
||||
(acc[a.doctor_name] ??= []).push(a);
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
return (
|
||||
<div>
|
||||
{Object.entries(byDoctor).map(([docName, docAppts]) => (
|
||||
<div key={docName}>
|
||||
{/* Doctor header row */}
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', gap: 8,
|
||||
padding: '8px 16px', margin: '0',
|
||||
background: 'color-mix(in srgb, var(--primary) 7%, var(--surface))',
|
||||
borderBottom: '1px solid var(--border)', borderTop: '1px solid var(--border)',
|
||||
}}>
|
||||
<UserCircleIcon style={{ width: 16, height: 16, color: 'var(--primary)' }} />
|
||||
<span style={{ fontWeight: 700, fontSize: 13.5, color: 'var(--primary)' }}>دکتر {docName}</span>
|
||||
<span className="muted" style={{ fontSize: 12 }}>{docAppts.length} نوبت</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{appts.map((a) => (
|
||||
<div
|
||||
key={a.uuid}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 14, padding: '0.75rem 1rem',
|
||||
background: 'var(--surface-alt, #f8fafc)', borderRadius: 10,
|
||||
border: '1px solid var(--border)', cursor: 'pointer', transition: 'box-shadow .15s',
|
||||
}}
|
||||
onClick={() => onView(a.uuid)}
|
||||
onMouseEnter={e => (e.currentTarget.style.boxShadow = 'var(--shadow-sm, 0 2px 8px rgba(0,0,0,.08))')}
|
||||
onMouseLeave={e => (e.currentTarget.style.boxShadow = 'none')}
|
||||
>
|
||||
{/* ساعت */}
|
||||
<div style={{
|
||||
width: 52, height: 52, borderRadius: 10, flexShrink: 0,
|
||||
background: 'var(--primary-soft, #eef2ff)', color: 'var(--primary)',
|
||||
display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center',
|
||||
fontWeight: 700, fontSize: 15, lineHeight: 1.2,
|
||||
}}>
|
||||
{a.appointment_time}
|
||||
</div>
|
||||
|
||||
{/* اطلاعات */}
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ fontWeight: 600, fontSize: 14, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
||||
{a.patient_name || maskMobile(a.patient_mobile)}
|
||||
</div>
|
||||
<div className="muted" style={{ fontSize: 12, marginTop: 2 }}>
|
||||
دکتر {a.doctor_name}{a.clinic_name ? ` · ${a.clinic_name}` : ''}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* وضعیت */}
|
||||
<span className={`badge ${APPT_CLS[a.status] ?? 'gray'}`}>
|
||||
<span className="bdot" />{APPT_LABEL[a.status] ?? a.status}
|
||||
</span>
|
||||
|
||||
<EyeIcon style={{ width: 16, height: 16, color: 'var(--text-3)', flexShrink: 0 }} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{/* Appointments table for this doctor */}
|
||||
<DataTable<Appointment>
|
||||
columns={columns}
|
||||
data={docAppts}
|
||||
loading={false}
|
||||
emptyMessage=""
|
||||
actions={(appt) => (
|
||||
<button className="mini-btn" onClick={() => onView(appt.uuid)} title="مشاهده">
|
||||
<EyeIcon style={{ width: 16, height: 16 }} />
|
||||
</button>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── New Appointment Modal ─────────────────────────────────────────────────
|
||||
|
||||
interface NewApptModalProps {
|
||||
onClose: () => void;
|
||||
onCreated: () => void;
|
||||
defaultDoctorUuid?: string;
|
||||
}
|
||||
|
||||
function NewAppointmentModal({ onClose, onCreated, defaultDoctorUuid }: NewApptModalProps) {
|
||||
const { primaryRole } = useAuthStore();
|
||||
const isAdmin = primaryRole === 'admin';
|
||||
|
||||
const [step, setStep] = useState<1 | 2 | 3>(1);
|
||||
const [doctorUuid, setDoctorUuid] = useState(defaultDoctorUuid ?? '');
|
||||
const [patientMobile, setPatientMobile] = useState('');
|
||||
const [dateStr, setDateStr] = useState(toGregorianDate(new Date()));
|
||||
const [slots, setSlots] = useState<Array<{ start: number; end: number; label: string }>>([]);
|
||||
const [selectedSlot, setSelectedSlot] = useState<{ start: number; end: number; label: string } | null>(null);
|
||||
const [note, setNote] = useState('');
|
||||
const [loadingSlots, setLoadingSlots] = useState(false);
|
||||
|
||||
const fetchSlots = async () => {
|
||||
if (!doctorUuid.trim() || !dateStr) { toast.error('UUID پزشک و تاریخ را وارد کنید'); return; }
|
||||
setLoadingSlots(true);
|
||||
try {
|
||||
const res = await api.get<ApiResponse<{ slots: Array<{ start: number; end: number; label: string }> }>>(
|
||||
`/api/v1/appointment-slots?doctor_uuid=${doctorUuid.trim()}&date=${dateStr}`
|
||||
);
|
||||
const raw = (res as any)?.data?.slots ?? [];
|
||||
setSlots(raw);
|
||||
setSelectedSlot(null);
|
||||
setStep(2);
|
||||
if (!raw.length) toast.info('هیچ نوبت خالی در این تاریخ وجود ندارد');
|
||||
} catch (e: any) {
|
||||
toast.error(e.message ?? 'خطا در دریافت نوبتها');
|
||||
} finally {
|
||||
setLoadingSlots(false);
|
||||
}
|
||||
};
|
||||
|
||||
const createMut = useMutation({
|
||||
mutationFn: () => {
|
||||
if (!selectedSlot) throw new Error('نوبت را انتخاب کنید');
|
||||
const body: Record<string, unknown> = {
|
||||
doctor_uuid: doctorUuid.trim(),
|
||||
slot_start: selectedSlot.start,
|
||||
slot_end: selectedSlot.end,
|
||||
note: note || undefined,
|
||||
};
|
||||
if (isAdmin) {
|
||||
body.patient_mobile = patientMobile.trim();
|
||||
return api.post('/api/v1/admin/appointment', body);
|
||||
}
|
||||
return api.post('/api/v1/appointment', body);
|
||||
},
|
||||
onSuccess: () => { toast.success('نوبت با موفقیت ثبت شد'); onCreated(); onClose(); },
|
||||
onError: (e: Error) => toast.error(e.message),
|
||||
});
|
||||
|
||||
const handleDateChange = (v: string) => {
|
||||
setDateStr(v); setSlots([]); setSelectedSlot(null); setStep(1);
|
||||
};
|
||||
|
||||
const changeDate = (delta: number) => {
|
||||
const d = new Date(dateStr + 'T12:00:00');
|
||||
d.setDate(d.getDate() + delta);
|
||||
handleDateChange(toGregorianDate(d));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="overlay" onClick={onClose}>
|
||||
<div className="modal" style={{ maxWidth: 500 }} onClick={e => e.stopPropagation()}>
|
||||
<div className="modal-head">
|
||||
<b>ثبت نوبت جدید</b>
|
||||
<button className="mini-btn" onClick={onClose}><XMarkIcon style={{ width: 16, height: 16 }} /></button>
|
||||
</div>
|
||||
|
||||
<div className="modal-body" style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
|
||||
<div>
|
||||
<label className="field-label">UUID پزشک *</label>
|
||||
<input
|
||||
className="input" dir="ltr" placeholder="xxxxxxxx-xxxx-..."
|
||||
value={doctorUuid}
|
||||
readOnly={!!defaultDoctorUuid}
|
||||
onChange={e => { setDoctorUuid(e.target.value); setStep(1); setSlots([]); setSelectedSlot(null); }}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="field-label">تاریخ *</label>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||
<button className="mini-btn" onClick={() => changeDate(-1)} title="روز قبل">
|
||||
<ChevronRightIcon style={{ width: 14, height: 14 }} />
|
||||
</button>
|
||||
<PersianDateInput
|
||||
value={dateStr}
|
||||
onChange={handleDateChange}
|
||||
min={toGregorianDate(new Date())}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<button className="mini-btn" onClick={() => changeDate(1)} title="روز بعد">
|
||||
<ChevronLeftIcon style={{ width: 14, height: 14 }} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isAdmin && (
|
||||
<div>
|
||||
<label className="field-label">موبایل بیمار *</label>
|
||||
<input className="input" dir="ltr" placeholder="09xxxxxxxxx" value={patientMobile} onChange={e => setPatientMobile(e.target.value)} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
className="btn outline sm"
|
||||
onClick={fetchSlots}
|
||||
disabled={loadingSlots || !doctorUuid.trim() || !dateStr}
|
||||
style={{ alignSelf: 'flex-start' }}
|
||||
>
|
||||
{loadingSlots ? 'در حال دریافت...' : 'دریافت نوبتهای خالی'}
|
||||
</button>
|
||||
|
||||
{step >= 2 && slots.length > 0 && (
|
||||
<div>
|
||||
<label className="field-label">انتخاب نوبت — {formatDate(dateStr)}</label>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: 8, marginTop: 6 }}>
|
||||
{slots.map((s) => (
|
||||
<button
|
||||
key={s.start}
|
||||
onClick={() => { setSelectedSlot(s); setStep(3); }}
|
||||
style={{
|
||||
padding: '8px 4px', borderRadius: 8, fontSize: 13, fontWeight: 600, cursor: 'pointer',
|
||||
border: selectedSlot?.start === s.start ? '2px solid var(--primary)' : '1px solid var(--border)',
|
||||
background: selectedSlot?.start === s.start ? 'var(--primary-soft, #eef2ff)' : 'var(--surface)',
|
||||
color: selectedSlot?.start === s.start ? 'var(--primary)' : 'var(--text)',
|
||||
transition: 'all .15s',
|
||||
}}
|
||||
>
|
||||
{s.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step >= 2 && slots.length === 0 && (
|
||||
<p className="muted" style={{ textAlign: 'center', fontSize: 13 }}>نوبت خالی در این تاریخ وجود ندارد</p>
|
||||
)}
|
||||
|
||||
{step >= 3 && (
|
||||
<div>
|
||||
<label className="field-label">یادداشت (اختیاری)</label>
|
||||
<textarea className="input" rows={2} style={{ resize: 'vertical' }} value={note} onChange={e => setNote(e.target.value)} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="modal-foot">
|
||||
<button className="btn ghost sm" onClick={onClose}>انصراف</button>
|
||||
<button
|
||||
className="btn primary sm"
|
||||
disabled={!selectedSlot || createMut.isPending || (isAdmin && !patientMobile.trim())}
|
||||
onClick={() => createMut.mutate()}
|
||||
>
|
||||
{createMut.isPending ? 'در حال ثبت...' : 'ثبت نوبت'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Main Component ────────────────────────────────────────────────────────
|
||||
|
||||
export default function AppointmentsPage() {
|
||||
const navigate = useNavigate();
|
||||
const primaryRole = useAuthStore(s => s.primaryRole);
|
||||
const [page, setPage] = useState(1);
|
||||
const [search, setSearch] = useState('');
|
||||
const navigate = useNavigate();
|
||||
const { primaryRole, dbUuid } = useAuthStore();
|
||||
|
||||
const [page, setPage] = useState(1);
|
||||
const [search, setSearch] = useState('');
|
||||
const [statusFilter, setStatusFilter] = useState('');
|
||||
const [dateFilter, setDateFilter] = useState('');
|
||||
const [viewMode, setViewMode] = useState<'table' | 'timeline'>('table');
|
||||
const [dateFilter, setDateFilter] = useState('');
|
||||
const [doctorFilter, setDoctorFilter] = useState('');
|
||||
const [viewMode, setViewMode] = useState<'table' | 'timeline'>('table');
|
||||
const [newApptOpen, setNewApptOpen] = useState(false);
|
||||
const limit = 15;
|
||||
|
||||
const isAdmin = primaryRole === 'admin';
|
||||
const isAdmin = primaryRole === 'admin';
|
||||
const isDoctor = primaryRole === 'doctor';
|
||||
const isClinic = primaryRole === 'clinic';
|
||||
const endpoint = isAdmin ? '/api/v1/admin/appointments' : '/api/v1/my/appointments';
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
const { data, isLoading, refetch } = useQuery({
|
||||
queryKey: ['appointments', endpoint, page, search, statusFilter, dateFilter],
|
||||
queryFn: () => {
|
||||
const params = new URLSearchParams({ page: String(page), limit: String(limit) });
|
||||
if (search) params.set('search', search);
|
||||
if (search) params.set('search', search);
|
||||
if (statusFilter) params.set('status', statusFilter);
|
||||
if (dateFilter) params.set('date', dateFilter);
|
||||
if (dateFilter) params.set('date', dateFilter);
|
||||
return api.get<PaginatedResponse<Appointment>>(`${endpoint}?${params}`);
|
||||
},
|
||||
});
|
||||
|
||||
const allItems = data?.data ?? [];
|
||||
const total = data?.meta?.totalRecords ?? 0;
|
||||
|
||||
// unique doctor names for clinic filter tab
|
||||
const doctorNames = useMemo(() => {
|
||||
const names = [...new Set(allItems.map(a => a.doctor_name))];
|
||||
return names.sort();
|
||||
}, [allItems]);
|
||||
|
||||
const items = useMemo(() => {
|
||||
if (!doctorFilter) return allItems;
|
||||
return allItems.filter(a => a.doctor_name === doctorFilter);
|
||||
}, [allItems, doctorFilter]);
|
||||
|
||||
// Show doctor grouping in clinic panel when multiple doctors exist
|
||||
const showDoctorGroup = isClinic && doctorNames.length > 1 && !doctorFilter;
|
||||
|
||||
const columns: Column<Appointment>[] = [
|
||||
{
|
||||
key: 'patient',
|
||||
@@ -169,7 +476,7 @@ export default function AppointmentsPage() {
|
||||
render: (a) => (
|
||||
<div className="cell-user">
|
||||
<div className="avatar sm" style={{
|
||||
background: `linear-gradient(145deg, oklch(0.62 0.15 222), oklch(0.48 0.16 222))`,
|
||||
background: 'linear-gradient(145deg, oklch(0.62 0.15 222), oklch(0.48 0.16 222))',
|
||||
}}>
|
||||
{(a.patient_name ?? '؟').slice(0, 2)}
|
||||
</div>
|
||||
@@ -180,41 +487,41 @@ export default function AppointmentsPage() {
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{ key: 'doctor_name', header: 'پزشک', render: (a) => `دکتر ${a.doctor_name}` },
|
||||
...(!showDoctorGroup ? [{ key: 'doctor_name' as keyof Appointment, header: 'پزشک', render: (a: Appointment) => `دکتر ${a.doctor_name}` }] : []),
|
||||
{ key: 'clinic_name', header: 'کلینیک', render: (a) => <span className="muted">{a.clinic_name ?? '—'}</span> },
|
||||
{
|
||||
key: 'appointment_date',
|
||||
header: 'تاریخ نوبت',
|
||||
header: 'تاریخ و ساعت',
|
||||
render: (a) => (
|
||||
<div>
|
||||
<span>{formatDate(a.appointment_date)}</span>
|
||||
<br /><small className="muted">{a.appointment_time}</small>
|
||||
<span style={{ fontWeight: 600 }}>{formatDate(a.appointment_date)}</span>
|
||||
<br />
|
||||
<small className="muted" style={{ direction: 'ltr', display: 'inline-block' }}>{a.appointment_time}</small>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
header: 'وضعیت',
|
||||
render: (a) => <StatusBadge type="appointment" value={a.status} />,
|
||||
render: (a) => <ApptStatus status={a.status} />,
|
||||
},
|
||||
{
|
||||
key: 'amount',
|
||||
header: 'مبلغ',
|
||||
render: (a) => <><b>{formatRial(a.amount)}</b> <span className="muted" style={{ fontSize: 11 }}>تومان</span></>,
|
||||
key: 'created_at',
|
||||
header: 'ثبت در',
|
||||
render: (a) => <span className="muted" style={{ fontSize: 12 }}>{formatDate(a.created_at)}</span>,
|
||||
},
|
||||
{ key: 'created_at', header: 'تاریخ ثبت', render: (a) => <span className="muted">{formatDate(a.created_at)}</span> },
|
||||
];
|
||||
|
||||
const items = data?.data ?? [];
|
||||
const total = data?.meta?.totalRecords ?? 0;
|
||||
|
||||
const pageTitle = isAdmin ? 'نوبتها' : (primaryRole === 'doctor' ? 'نوبتهای من' : primaryRole === 'secretary' ? 'نوبتهای پزشک' : 'نوبتهای کلینیک');
|
||||
const pageTitle = isAdmin ? 'نوبتها'
|
||||
: isDoctor ? 'نوبتهای من'
|
||||
: primaryRole === 'secretary' ? 'نوبتهای پزشک'
|
||||
: 'نوبتهای کلینیک';
|
||||
|
||||
const statCards = [
|
||||
{ label: 'کل نوبتها', value: total > 0 ? String(total) : null, bg: 'var(--info-bg)', color: 'var(--info)', Icon: CalendarIcon },
|
||||
{ label: 'در انتظار', value: null, bg: 'var(--warning-bg)', color: 'var(--warning)', Icon: ClockIcon },
|
||||
{ label: 'ویزیت شده', value: null, bg: 'var(--success-bg)', color: 'var(--success)', Icon: CheckCircleIcon },
|
||||
{ label: 'لغو شده', value: null, bg: 'var(--danger-bg)', color: 'var(--danger)', Icon: XCircleIcon },
|
||||
{ label: 'در انتظار', value: null, bg: 'var(--warning-bg)', color: 'var(--warning)', Icon: ClockIcon },
|
||||
{ label: 'ویزیت شده', value: null, bg: 'var(--success-bg)', color: 'var(--success)', Icon: CheckCircleIcon },
|
||||
{ label: 'لغو شده', value: null, bg: 'var(--danger-bg)', color: 'var(--danger)', Icon: XCircleIcon },
|
||||
];
|
||||
|
||||
return (
|
||||
@@ -225,8 +532,8 @@ export default function AppointmentsPage() {
|
||||
<h1 className="section-title">{pageTitle}</h1>
|
||||
<div className="muted" style={{ fontSize: 13, marginTop: 3 }}>مدیریت و پیگیری نوبتهای درمانی</div>
|
||||
</div>
|
||||
<button className="btn primary sm">
|
||||
<CalendarIcon style={{ width: 15, height: 15 }} />
|
||||
<button className="btn primary sm" onClick={() => setNewApptOpen(true)}>
|
||||
<PlusIcon style={{ width: 15, height: 15 }} />
|
||||
ثبت نوبت جدید
|
||||
</button>
|
||||
</div>
|
||||
@@ -251,8 +558,34 @@ export default function AppointmentsPage() {
|
||||
{/* Main card */}
|
||||
<div className="card">
|
||||
<div className="card-pad" style={{ paddingBottom: 0 }}>
|
||||
|
||||
{/* Doctor filter tabs — only for clinic with multiple doctors */}
|
||||
{isClinic && doctorNames.length > 1 && (
|
||||
<div style={{
|
||||
display: 'flex', gap: 6, flexWrap: 'wrap',
|
||||
marginBottom: 14, paddingBottom: 14, borderBottom: '1px solid var(--border)',
|
||||
}}>
|
||||
<button
|
||||
onClick={() => setDoctorFilter('')}
|
||||
className={`btn sm ${!doctorFilter ? 'primary' : 'ghost'}`}
|
||||
style={{ fontSize: 12 }}
|
||||
>
|
||||
همه پزشکان
|
||||
</button>
|
||||
{doctorNames.map(name => (
|
||||
<button
|
||||
key={name}
|
||||
onClick={() => setDoctorFilter(name)}
|
||||
className={`btn sm ${doctorFilter === name ? 'primary' : 'ghost'}`}
|
||||
style={{ fontSize: 12 }}
|
||||
>
|
||||
دکتر {name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="toolbar" style={{ flexWrap: 'wrap', gap: 10 }}>
|
||||
{/* جستجو */}
|
||||
<div className="field" style={{ minWidth: 220 }}>
|
||||
<MagnifyingGlassIcon style={{ width: 17, height: 17 }} />
|
||||
<input
|
||||
@@ -262,18 +595,12 @@ export default function AppointmentsPage() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* فیلتر تاریخ */}
|
||||
<div className="field" style={{ minWidth: 160 }}>
|
||||
<FunnelIcon style={{ width: 15, height: 15 }} />
|
||||
<input
|
||||
type="date"
|
||||
value={dateFilter}
|
||||
onChange={(e) => { setDateFilter(e.target.value); setPage(1); }}
|
||||
style={{ direction: 'ltr' }}
|
||||
/>
|
||||
</div>
|
||||
<PersianDateInput
|
||||
value={dateFilter}
|
||||
onChange={(v) => { setDateFilter(v); setPage(1); }}
|
||||
placeholder="فیلتر تاریخ"
|
||||
/>
|
||||
|
||||
{/* فیلتر وضعیت */}
|
||||
<select
|
||||
value={statusFilter}
|
||||
onChange={(e) => { setStatusFilter(e.target.value); setPage(1); }}
|
||||
@@ -289,20 +616,11 @@ export default function AppointmentsPage() {
|
||||
|
||||
<div style={{ marginRight: 'auto' }} />
|
||||
|
||||
{/* تغییر نما */}
|
||||
<div className="seg">
|
||||
<button
|
||||
className={viewMode === 'table' ? 'on' : ''}
|
||||
onClick={() => setViewMode('table')}
|
||||
title="نمای جدول"
|
||||
>
|
||||
<button className={viewMode === 'table' ? 'on' : ''} onClick={() => setViewMode('table')} title="نمای جدول">
|
||||
<TableCellsIcon style={{ width: 15, height: 15 }} />
|
||||
</button>
|
||||
<button
|
||||
className={viewMode === 'timeline' ? 'on' : ''}
|
||||
onClick={() => setViewMode('timeline')}
|
||||
title="نمای زمانی"
|
||||
>
|
||||
<button className={viewMode === 'timeline' ? 'on' : ''} onClick={() => setViewMode('timeline')} title="نمای زمانی">
|
||||
<CalendarViewIcon style={{ width: 15, height: 15 }} />
|
||||
</button>
|
||||
</div>
|
||||
@@ -311,21 +629,26 @@ export default function AppointmentsPage() {
|
||||
|
||||
{viewMode === 'table' ? (
|
||||
<>
|
||||
<DataTable<Appointment>
|
||||
columns={columns}
|
||||
data={items}
|
||||
loading={isLoading}
|
||||
emptyMessage="هیچ نوبتی یافت نشد"
|
||||
actions={(appt) => (
|
||||
<button
|
||||
className="mini-btn"
|
||||
onClick={() => navigate(`/admin/appointments/${appt.uuid}`)}
|
||||
title="مشاهده"
|
||||
>
|
||||
<EyeIcon style={{ width: 16, height: 16 }} />
|
||||
</button>
|
||||
)}
|
||||
/>
|
||||
{showDoctorGroup ? (
|
||||
<DoctorGroupedTable
|
||||
items={items}
|
||||
loading={isLoading}
|
||||
onView={(uuid) => navigate(`/admin/appointments/${uuid}`)}
|
||||
columns={columns}
|
||||
/>
|
||||
) : (
|
||||
<DataTable<Appointment>
|
||||
columns={columns}
|
||||
data={items}
|
||||
loading={isLoading}
|
||||
emptyMessage="هیچ نوبتی یافت نشد"
|
||||
actions={(appt) => (
|
||||
<button className="mini-btn" onClick={() => navigate(`/admin/appointments/${appt.uuid}`)} title="مشاهده">
|
||||
<EyeIcon style={{ width: 16, height: 16 }} />
|
||||
</button>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
<Pagination page={page} total={total} limit={limit} onPageChange={setPage} />
|
||||
</>
|
||||
) : (
|
||||
@@ -334,11 +657,20 @@ export default function AppointmentsPage() {
|
||||
items={items}
|
||||
loading={isLoading}
|
||||
onView={(uuid) => navigate(`/admin/appointments/${uuid}`)}
|
||||
groupByDoctor={showDoctorGroup}
|
||||
/>
|
||||
<Pagination page={page} total={total} limit={limit} onPageChange={setPage} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{newApptOpen && (
|
||||
<NewAppointmentModal
|
||||
onClose={() => setNewApptOpen(false)}
|
||||
onCreated={refetch}
|
||||
defaultDoctorUuid={isDoctor ? (dbUuid ?? '') : ''}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ import type { ClinicDetail } from '../types';
|
||||
import { formatNumber } from '../lib/utils';
|
||||
import ConfirmDialog from '../components/ui/ConfirmDialog';
|
||||
import NotificationMobileCard from '../components/ui/NotificationMobileCard';
|
||||
import InviteDoctorModal from '../components/ui/InviteDoctorModal';
|
||||
import { useAuthStore } from '../stores/authStore';
|
||||
|
||||
// Fix leaflet icons
|
||||
@@ -505,64 +506,6 @@ const INV_STATUS_MAP: Record<string, { label: string; cls: string }> = {
|
||||
removed: { label: 'حذفشده', cls: 'gray' },
|
||||
};
|
||||
|
||||
// ── Invite modal ───────────────────────────────────────────────────────────
|
||||
|
||||
const inviteSchema = z.object({
|
||||
mobile: z.string().regex(/^09\d{9}$/, 'شماره موبایل ۱۱ رقمی با 09 شروع میشود'),
|
||||
name: z.string().optional(),
|
||||
specialty: z.string().optional(),
|
||||
});
|
||||
type InviteForm = z.infer<typeof inviteSchema>;
|
||||
|
||||
function InviteModal({ clinicUuid, onClose, onInvited }: {
|
||||
clinicUuid: string; onClose: () => void; onInvited: () => void;
|
||||
}) {
|
||||
const { register, handleSubmit, formState: { errors } } = useForm<InviteForm>({
|
||||
resolver: zodResolver(inviteSchema),
|
||||
});
|
||||
|
||||
const inviteMut = useMutation({
|
||||
mutationFn: (d: InviteForm) =>
|
||||
api.post<ApiResponse<ClinicInvitation>>(`/api/v1/admin/clinic/${clinicUuid}/invite-doctor`, d),
|
||||
onSuccess: () => { toast.success('دعوتنامه ارسال شد'); onInvited(); onClose(); },
|
||||
onError: (e: Error) => toast.error(e.message),
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="overlay" onClick={onClose}>
|
||||
<div className="modal" style={{ maxWidth: 420 }} onClick={e => e.stopPropagation()}>
|
||||
<div className="modal-head">
|
||||
<b>دعوت پزشک به کلینیک</b>
|
||||
<button className="mini-btn" onClick={onClose}><XMarkIcon style={{ width: 16, height: 16 }} /></button>
|
||||
</div>
|
||||
<form onSubmit={handleSubmit(d => inviteMut.mutate(d))}>
|
||||
<div className="modal-body" style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
|
||||
<div>
|
||||
<label style={{ fontSize: 13, fontWeight: 600, display: 'block', marginBottom: 6 }}>شماره موبایل پزشک *</label>
|
||||
<input className="input" dir="ltr" placeholder="09xxxxxxxxx" {...register('mobile')} />
|
||||
{errors.mobile && <div className="err-text">{errors.mobile.message}</div>}
|
||||
</div>
|
||||
<div>
|
||||
<label style={{ fontSize: 13, fontWeight: 600, display: 'block', marginBottom: 6 }}>نام پزشک (اختیاری)</label>
|
||||
<input className="input" placeholder="دکتر نام و نام خانوادگی" {...register('name')} />
|
||||
</div>
|
||||
<div>
|
||||
<label style={{ fontSize: 13, fontWeight: 600, display: 'block', marginBottom: 6 }}>تخصص (اختیاری)</label>
|
||||
<input className="input" placeholder="مثال: قلب و عروق" {...register('specialty')} />
|
||||
</div>
|
||||
<p className="muted" style={{ fontSize: 12 }}>پیامک دعوتنامه با لینک ۷۲ ساعته ارسال میشود</p>
|
||||
</div>
|
||||
<div className="modal-foot">
|
||||
<button type="button" className="btn ghost sm" onClick={onClose}>انصراف</button>
|
||||
<button type="submit" className="btn primary sm" disabled={inviteMut.isPending}>
|
||||
{inviteMut.isPending ? 'در حال ارسال...' : 'ارسال دعوتنامه'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Main Page ──────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -748,13 +691,17 @@ export default function ClinicDetailPage() {
|
||||
<button className="btn ghost sm" onClick={() => openEdit('basic')}>
|
||||
<PencilIcon style={{ width: 15, height: 15 }} /> ویرایش
|
||||
</button>
|
||||
<button className={`btn sm ${clinic.is_active ? 'soft' : 'primary'}`}
|
||||
onClick={() => toggleMut.mutate()} disabled={toggleMut.isPending}>
|
||||
{clinic.is_active ? 'غیرفعال کردن' : 'فعال کردن'}
|
||||
</button>
|
||||
<button className="btn danger sm" onClick={() => setDeleteOpen(true)}>
|
||||
<TrashIcon style={{ width: 15, height: 15 }} /> حذف
|
||||
</button>
|
||||
{primaryRole === 'admin' && (
|
||||
<>
|
||||
<button className={`btn sm ${clinic.is_active ? 'soft' : 'primary'}`}
|
||||
onClick={() => toggleMut.mutate()} disabled={toggleMut.isPending}>
|
||||
{clinic.is_active ? 'غیرفعال کردن' : 'فعال کردن'}
|
||||
</button>
|
||||
<button className="btn danger sm" onClick={() => setDeleteOpen(true)}>
|
||||
<TrashIcon style={{ width: 15, height: 15 }} /> حذف
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1054,7 +1001,7 @@ export default function ClinicDetailPage() {
|
||||
|
||||
{/* Invite doctor modal */}
|
||||
{inviteOpen && uuid && createPortal(
|
||||
<InviteModal
|
||||
<InviteDoctorModal
|
||||
clinicUuid={uuid}
|
||||
onClose={() => setInviteOpen(false)}
|
||||
onInvited={() => { qc.invalidateQueries({ queryKey: ['clinic-invitations', uuid] }); setDoctorsTab('invitations'); }}
|
||||
|
||||
@@ -10,6 +10,7 @@ import { api } from '../lib/api';
|
||||
import type { ApiResponse } from '../lib/api';
|
||||
import { formatNumber, formatRial, formatDateTime } from '../lib/utils';
|
||||
import { useAuthStore } from '../stores/authStore';
|
||||
import InviteDoctorModal from '../components/ui/InviteDoctorModal';
|
||||
|
||||
// ── Shared Status Maps ────────────────────────────────────────────────────
|
||||
|
||||
@@ -514,7 +515,8 @@ interface ClinicDashboardData {
|
||||
}
|
||||
|
||||
function ClinicDashboard() {
|
||||
const { context } = useAuthStore();
|
||||
const { context, dbUuid } = useAuthStore();
|
||||
const [inviteOpen, setInviteOpen] = useState(false);
|
||||
const q = useQuery({
|
||||
queryKey: ['dashboard-clinic'],
|
||||
queryFn: () => api.get<ApiResponse<ClinicDashboardData>>('/api/v1/dashboard/clinic'),
|
||||
@@ -524,6 +526,7 @@ function ClinicDashboard() {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const d = useMemo<ClinicDashboardData | undefined>(() => (q.data?.data as any)?.data ?? q.data?.data, [q.data]);
|
||||
const today = new Date().toLocaleDateString('fa-IR', { year: 'numeric', month: 'long', day: 'numeric' });
|
||||
const clinicUuid = d?.clinic.uuid ?? dbUuid ?? '';
|
||||
|
||||
if (q.isLoading) return <LoadingSkeleton />;
|
||||
|
||||
@@ -541,10 +544,16 @@ function ClinicDashboard() {
|
||||
<h1 className="section-title">داشبورد کلینیک</h1>
|
||||
<div className="muted" style={{ fontSize: 13, marginTop: 2 }}>{today} · {d?.clinic.name ?? context?.name ?? ''}</div>
|
||||
</div>
|
||||
<button className="btn ghost sm" onClick={() => q.refetch()}>
|
||||
<ArrowPathIcon style={{ width: 14, height: 14 }} />
|
||||
بهروزرسانی
|
||||
</button>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<button className="btn primary sm" onClick={() => setInviteOpen(true)}>
|
||||
<UserIcon style={{ width: 14, height: 14 }} />
|
||||
دعوت پزشک
|
||||
</button>
|
||||
<button className="btn ghost sm" onClick={() => q.refetch()}>
|
||||
<ArrowPathIcon style={{ width: 14, height: 14 }} />
|
||||
بهروزرسانی
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="stat-grid">
|
||||
@@ -571,29 +580,48 @@ function ClinicDashboard() {
|
||||
<div className="card card-pad">
|
||||
<div className="card-title-row">
|
||||
<h3 style={{ fontSize: 16 }}>پزشکان کلینیک</h3>
|
||||
<Link to="/admin/doctors" className="link">همه</Link>
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
||||
<button className="btn ghost sm" style={{ fontSize: 11, padding: '3px 10px' }} onClick={() => setInviteOpen(true)}>
|
||||
+ دعوت جدید
|
||||
</button>
|
||||
{clinicUuid && (
|
||||
<Link to={`/admin/clinics/${clinicUuid}`} className="link">مدیریت</Link>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{!d?.doctors.length ? (
|
||||
<p className="muted" style={{ textAlign: 'center', padding: '32px 0', fontSize: 13.5 }}>پزشکی ثبت نشده</p>
|
||||
<div style={{ textAlign: 'center', padding: '24px 0' }}>
|
||||
<p className="muted" style={{ fontSize: 13.5, marginBottom: 12 }}>هنوز پزشکی دعوت نشده</p>
|
||||
<button className="btn primary sm" onClick={() => setInviteOpen(true)}>
|
||||
دعوت اولین پزشک
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column' }}>
|
||||
{d.doctors.map((doc, i) => (
|
||||
<Link
|
||||
<div
|
||||
key={doc.uuid}
|
||||
to={`/admin/doctors/${doc.uuid}`}
|
||||
style={{ display: 'flex', alignItems: 'center', gap: 11, padding: '10px 0', borderBottom: i < d.doctors.length - 1 ? '1px solid var(--border)' : 'none', textDecoration: 'none', color: 'inherit' }}
|
||||
style={{ display: 'flex', alignItems: 'center', gap: 11, padding: '10px 0', borderBottom: i < d.doctors.length - 1 ? '1px solid var(--border)' : 'none' }}
|
||||
>
|
||||
<AvatarEl initials={doc.name.slice(0, 1)} hue={162} size="sm" />
|
||||
<div style={{ flex: 1 }}>
|
||||
<b style={{ fontSize: 13.5 }}>دکتر {doc.name}</b>
|
||||
</div>
|
||||
<span className="badge blue"><span className="bdot" />{formatNumber(doc.today_count)} امروز</span>
|
||||
</Link>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{inviteOpen && clinicUuid && (
|
||||
<InviteDoctorModal
|
||||
clinicUuid={clinicUuid}
|
||||
onClose={() => setInviteOpen(false)}
|
||||
onInvited={() => q.refetch()}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
+23
-1
@@ -1,5 +1,10 @@
|
||||
@import "tailwindcss";
|
||||
@import url('https://fonts.googleapis.com/css2?family=Vazirmatn:wght@300;400;500;600;700;800&display=swap');
|
||||
@import "@fontsource/vazirmatn/300.css";
|
||||
@import "@fontsource/vazirmatn/400.css";
|
||||
@import "@fontsource/vazirmatn/500.css";
|
||||
@import "@fontsource/vazirmatn/600.css";
|
||||
@import "@fontsource/vazirmatn/700.css";
|
||||
@import "@fontsource/vazirmatn/800.css";
|
||||
|
||||
/* Class-based dark mode for Tailwind v4 */
|
||||
@custom-variant dark (&:where(.dark, .dark *));
|
||||
@@ -429,6 +434,23 @@ body {
|
||||
.badge.violet { color: var(--violet); background: var(--violet-bg); }
|
||||
.badge.gray { color: var(--text-2); background: var(--surface-3); }
|
||||
|
||||
/* ── Appointment status badges ───────────────────────────────── */
|
||||
.appt-status {
|
||||
display: inline-flex; align-items: center; gap: 6px;
|
||||
font-size: 12px; font-weight: 700; padding: 4px 12px; border-radius: 99px;
|
||||
white-space: nowrap; border: 1.5px solid transparent; letter-spacing: 0.01em;
|
||||
}
|
||||
.appt-status-dot { width: 7px; height: 7px; border-radius: 50%; background: currentColor; flex-shrink: 0; }
|
||||
.appt-status.status-green { color: var(--success); background: var(--success-bg); border-color: color-mix(in srgb, var(--success) 25%, transparent); }
|
||||
.appt-status.status-amber { color: var(--warning); background: var(--warning-bg); border-color: color-mix(in srgb, var(--warning) 25%, transparent); }
|
||||
.appt-status.status-red { color: var(--danger); background: var(--danger-bg); border-color: color-mix(in srgb, var(--danger) 25%, transparent); }
|
||||
.appt-status.status-blue { color: var(--info); background: var(--info-bg); border-color: color-mix(in srgb, var(--info) 25%, transparent); }
|
||||
.appt-status.status-violet { color: var(--violet); background: var(--violet-bg); border-color: color-mix(in srgb, var(--violet) 25%, transparent); }
|
||||
.appt-status.status-gray { color: var(--text-2); background: var(--surface-3); border-color: var(--border); }
|
||||
|
||||
/* field-label utility */
|
||||
.field-label { display: block; font-size: 12.5px; font-weight: 600; color: var(--text-2); margin-bottom: 5px; }
|
||||
|
||||
/* ── Buttons ─────────────────────────────────────────────────── */
|
||||
.btn {
|
||||
display: inline-flex; align-items: center; justify-content: center; gap: 8px;
|
||||
|
||||
Generated
+10
@@ -6,6 +6,7 @@
|
||||
"": {
|
||||
"license": "UNLICENSED",
|
||||
"dependencies": {
|
||||
"@fontsource/vazirmatn": "^5.2.8",
|
||||
"@heroicons/react": "^2.0.0",
|
||||
"@hookform/resolvers": "^5.4.0",
|
||||
"@tanstack/react-query": "^5.0.0",
|
||||
@@ -2131,6 +2132,15 @@
|
||||
"integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@fontsource/vazirmatn": {
|
||||
"version": "5.2.8",
|
||||
"resolved": "https://registry.npmjs.org/@fontsource/vazirmatn/-/vazirmatn-5.2.8.tgz",
|
||||
"integrity": "sha512-WoDgv8R/y1gwgTS8Q2uL8d2ayeSGNv2IrYQ4wHmJkVwYPb8KVODrqbbvEjBqLOo5WP5kLJIrO06FRDMnSFuCaA==",
|
||||
"license": "OFL-1.1",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ayuhito"
|
||||
}
|
||||
},
|
||||
"node_modules/@heroicons/react": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@heroicons/react/-/react-2.2.0.tgz",
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
"webpack-cli": "^6.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fontsource/vazirmatn": "^5.2.8",
|
||||
"@heroicons/react": "^2.0.0",
|
||||
"@hookform/resolvers": "^5.4.0",
|
||||
"@tanstack/react-query": "^5.0.0",
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 188 B |
Binary file not shown.
|
After Width: | Height: | Size: 166 B |
@@ -0,0 +1,261 @@
|
||||
<?php
|
||||
require __DIR__ . '/vendor/autoload.php';
|
||||
(new Symfony\Component\Dotenv\Dotenv())->bootEnv(__DIR__ . '/.env');
|
||||
$kernel = new App\Kernel($_SERVER['APP_ENV'], (bool)$_SERVER['APP_DEBUG']);
|
||||
$kernel->boot();
|
||||
$em = $kernel->getContainer()->get('doctrine')->getManager();
|
||||
|
||||
$conn = $em->getConnection();
|
||||
$now = time();
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function slot(string $date, int $hour, int $minute = 0): int {
|
||||
return mktime($hour, $minute, 0, (int)date('m', strtotime($date)), (int)date('d', strtotime($date)), (int)date('Y', strtotime($date)));
|
||||
}
|
||||
|
||||
function today(int $daysOffset = 0): string {
|
||||
return date('Y-m-d', strtotime(($daysOffset >= 0 ? "+$daysOffset" : "$daysOffset") . ' days'));
|
||||
}
|
||||
|
||||
// ── Load entities ────────────────────────────────────────────────────────────
|
||||
|
||||
$doctor1 = $em->getRepository(\App\Doctor\Entity\Doctor::class)->find(1184); // آرمان رضایی
|
||||
$doctor4 = $em->getRepository(\App\Doctor\Entity\Doctor::class)->find(1185); // سامان علوی
|
||||
$clinic2 = $em->getRepository(\App\Clinic\Entity\Clinic::class)->find(203); // کلینیک تست پگاه
|
||||
$clinic4 = $em->getRepository(\App\Clinic\Entity\Clinic::class)->find(204); // کلینیک تخصصی علوی
|
||||
|
||||
$spec3 = $em->getRepository(\App\Specialty\Entity\Specialty::class)->find(3); // قلب و عروق
|
||||
$spec10 = $em->getRepository(\App\Specialty\Entity\Specialty::class)->find(10); // نورولوژی
|
||||
$spec110= $em->getRepository(\App\Specialty\Entity\Specialty::class)->find(110); // ارتوپدی
|
||||
$spec111= $em->getRepository(\App\Specialty\Entity\Specialty::class)->find(111); // ارتوپدی عمومی
|
||||
|
||||
// ── کاربر ۱: دکتر آرمان رضایی ───────────────────────────────────────────────
|
||||
|
||||
$doctor1->setGender('man')
|
||||
->setDegree('specialist')
|
||||
->setMedicalSystemCode('IR-12345')
|
||||
->setMobileNumber('09100000011')
|
||||
->setActivityTime(mktime(0,0,0,1,1,2010))
|
||||
->setInfo('متخصص قلب و عروق با بیش از ۱۴ سال سابقه بالینی. فارغالتحصیل دانشگاه علوم پزشکی تهران. عضو انجمن قلب ایران.')
|
||||
->setDoctorRate(4.5)
|
||||
->setDoctorRatePercentage(90.0)
|
||||
->setActiveDoctorAppointment(true);
|
||||
|
||||
// تخصصها
|
||||
$doctor1->getSpecialties()->clear();
|
||||
if ($spec3) $doctor1->getSpecialties()->add($spec3);
|
||||
if ($spec10) $doctor1->getSpecialties()->add($spec10);
|
||||
|
||||
$em->flush();
|
||||
|
||||
// خدمات
|
||||
$conn->executeStatement('DELETE FROM doctor_expertise WHERE doctor_id = ?', [1184]);
|
||||
foreach ([101, 102, 103, 104, 105] as $svcId) {
|
||||
$conn->executeStatement('INSERT IGNORE INTO doctor_expertise (doctor_id, service_id) VALUES (?, ?)', [1184, $svcId]);
|
||||
}
|
||||
|
||||
echo "✅ دکتر آرمان رضایی بروزرسانی شد\n";
|
||||
|
||||
// ── کاربر ۴: دکتر سامان علوی ────────────────────────────────────────────────
|
||||
|
||||
$doctor4->setGender('man')
|
||||
->setDegree('specialist')
|
||||
->setMedicalSystemCode('IR-67890')
|
||||
->setMobileNumber('09100000044')
|
||||
->setActivityTime(mktime(0,0,0,1,1,2012))
|
||||
->setInfo('متخصص ارتوپدی و جراحی مفاصل. فارغالتحصیل دانشگاه شهید بهشتی. تخصص ویژه در جراحی آرتروسکوپی زانو و تعویض مفصل.')
|
||||
->setDoctorRate(4.7)
|
||||
->setDoctorRatePercentage(94.0)
|
||||
->setActiveDoctorAppointment(true);
|
||||
|
||||
$doctor4->getSpecialties()->clear();
|
||||
if ($spec110) $doctor4->getSpecialties()->add($spec110);
|
||||
if ($spec111) $doctor4->getSpecialties()->add($spec111);
|
||||
|
||||
$em->flush();
|
||||
|
||||
$conn->executeStatement('DELETE FROM doctor_expertise WHERE doctor_id = ?', [1185]);
|
||||
foreach ([11101, 11105, 11201, 11401] as $svcId) {
|
||||
$conn->executeStatement('INSERT IGNORE INTO doctor_expertise (doctor_id, service_id) VALUES (?, ?)', [1185, $svcId]);
|
||||
}
|
||||
|
||||
echo "✅ دکتر سامان علوی بروزرسانی شد\n";
|
||||
|
||||
// ── کلینیک تست پگاه (clinic_id=203) ────────────────────────────────────────
|
||||
|
||||
$clinic2->setName('کلینیک تخصصی قلب پگاه')
|
||||
->setInfo('کلینیک تخصصی قلب و عروق با مجهزترین تجهیزات تشخیصی. ارائه خدمات نوار قلب، اکو، تست ورزش و مشاوره تخصصی.')
|
||||
->setAddress('تهران، خیابان ولیعصر، بالاتر از پارک ساعی، پلاک ۱۲۰')
|
||||
->setTelephone('02188001234')
|
||||
->setCityId(108)
|
||||
->setProvinceId(8)
|
||||
->setLatitude(35.7219)
|
||||
->setLongitude(51.3347)
|
||||
->setIsActive(true)
|
||||
->setIs247(false)
|
||||
->setWorkingDays('شنبه تا چهارشنبه ۸-۱۶، پنجشنبه ۸-۱۲');
|
||||
|
||||
$em->flush();
|
||||
|
||||
// تخصص کلینیک
|
||||
$conn->executeStatement('DELETE FROM clinic_specialties WHERE clinic_id = ?', [203]);
|
||||
foreach ([3, 10] as $sId) {
|
||||
$conn->executeStatement('INSERT IGNORE INTO clinic_specialties (clinic_id, specialty_id) VALUES (?, ?)', [203, $sId]);
|
||||
}
|
||||
|
||||
// دکتر آرمان را به این کلینیک اضافه کن (علاوه بر clinic id=1,2 که از قبل هست)
|
||||
$conn->executeStatement('INSERT IGNORE INTO clinic_doctors (clinic_id, doctor_id) VALUES (?, ?)', [203, 1184]);
|
||||
|
||||
echo "✅ کلینیک تخصصی قلب پگاه بروزرسانی شد\n";
|
||||
|
||||
// ── کلینیک تخصصی علوی (clinic_id=204) ─────────────────────────────────────
|
||||
|
||||
$clinic4->setName('کلینیک تخصصی ارتوپدی علوی')
|
||||
->setInfo('کلینیک تخصصی ارتوپدی با امکانات جراحی آرتروسکوپی. ویزیت، رادیولوژی دیجیتال، MRI، فیزیوتراپی.')
|
||||
->setAddress('تهران، سعادتآباد، میدان کاج، خیابان ۲۴ متری، پلاک ۸۵')
|
||||
->setTelephone('02122345678')
|
||||
->setCityId(108)
|
||||
->setProvinceId(8)
|
||||
->setLatitude(35.8065)
|
||||
->setLongitude(51.3822)
|
||||
->setIsActive(true)
|
||||
->setIs247(false)
|
||||
->setWorkingDays('شنبه تا پنجشنبه ۸-۱۸');
|
||||
|
||||
$em->flush();
|
||||
|
||||
$conn->executeStatement('DELETE FROM clinic_specialties WHERE clinic_id = ?', [204]);
|
||||
foreach ([110, 111] as $sId) {
|
||||
$conn->executeStatement('INSERT IGNORE INTO clinic_specialties (clinic_id, specialty_id) VALUES (?, ?)', [204, $sId]);
|
||||
}
|
||||
|
||||
// دکتر سامان را به کلینیک خودش اضافه کن
|
||||
$conn->executeStatement('INSERT IGNORE INTO clinic_doctors (clinic_id, doctor_id) VALUES (?, ?)', [204, 1185]);
|
||||
|
||||
echo "✅ کلینیک تخصصی ارتوپدی علوی بروزرسانی شد\n";
|
||||
|
||||
// ── نوبتها ─────────────────────────────────────────────────────────────────
|
||||
|
||||
// بیمارانی که از قبل وجود دارن
|
||||
$patientIds = [789, 790, 791, 792, 793, 794, 795, 796, 797, 798];
|
||||
|
||||
$uuid = fn() => sprintf('%04x%04x-%04x-%04x-%04x-%04x%04x%04x',
|
||||
mt_rand(0, 0xffff), mt_rand(0, 0xffff),
|
||||
mt_rand(0, 0xffff),
|
||||
mt_rand(0, 0x0fff) | 0x4000,
|
||||
mt_rand(0, 0x3fff) | 0x8000,
|
||||
mt_rand(0, 0xffff), mt_rand(0, 0xffff), mt_rand(0, 0xffff)
|
||||
);
|
||||
|
||||
$insertAppt = function(int $doctorId, int $userId, int $slotStart, int $slotEnd, string $status) use ($conn, $uuid, $now): void {
|
||||
$conn->executeStatement(
|
||||
'INSERT INTO appointments (uuid, slot_start, slot_end, status, doctor_id, user_id, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
|
||||
[$uuid(), $slotStart, $slotEnd, $status, $doctorId, $userId, $now, $now]
|
||||
);
|
||||
};
|
||||
|
||||
// ── نوبتهای گذشته — دکتر آرمان رضایی (doctor_id=1184) ──────────────────
|
||||
|
||||
$pastAppts = [
|
||||
// [-7 تا -1 روز گذشته]
|
||||
[today(-7), 9, 0, 'visited', 789],
|
||||
[today(-7), 10, 0, 'visited', 790],
|
||||
[today(-6), 9, 0, 'cancelled_by_user', 791],
|
||||
[today(-6), 11, 0, 'visited', 792],
|
||||
[today(-5), 9, 0, 'visited', 793],
|
||||
[today(-5), 14, 0, 'no_show', 794],
|
||||
[today(-4), 9, 0, 'visited', 795],
|
||||
[today(-4), 10, 0, 'visited', 796],
|
||||
[today(-3), 9, 0, 'cancelled_by_doctor', 797],
|
||||
[today(-3), 11, 0, 'visited', 798],
|
||||
[today(-2), 9, 0, 'visited', 789],
|
||||
[today(-2), 10, 0, 'visited', 790],
|
||||
[today(-1), 9, 0, 'visited', 791],
|
||||
[today(-1), 11, 0, 'no_show', 792],
|
||||
];
|
||||
|
||||
foreach ($pastAppts as [$date, $h, $m, $status, $patId]) {
|
||||
$start = slot($date, $h, $m);
|
||||
$insertAppt(1184, $patId, $start, $start + 1800, $status);
|
||||
}
|
||||
|
||||
echo "✅ نوبتهای گذشته دکتر آرمان ساخته شد (" . count($pastAppts) . " نوبت)\n";
|
||||
|
||||
// ── نوبتهای آینده — دکتر آرمان رضایی (doctor_id=1184) ───────────────────
|
||||
|
||||
$futureAppts = [
|
||||
[today(1), 9, 0, 'reserved', 793],
|
||||
[today(1), 10, 0, 'reserved', 794],
|
||||
[today(1), 11, 0, 'reserved', 795],
|
||||
[today(2), 9, 0, 'reserved', 796],
|
||||
[today(2), 10, 0, 'reserved', 797],
|
||||
[today(3), 9, 0, 'reserved', 798],
|
||||
[today(3), 14, 0, 'reserved', 789],
|
||||
[today(5), 9, 0, 'reserved', 790],
|
||||
[today(7), 10, 0, 'reserved', 791],
|
||||
[today(10),9, 0, 'reserved', 792],
|
||||
];
|
||||
|
||||
foreach ($futureAppts as [$date, $h, $m, $status, $patId]) {
|
||||
$start = slot($date, $h, $m);
|
||||
$insertAppt(1184, $patId, $start, $start + 1800, $status);
|
||||
}
|
||||
|
||||
echo "✅ نوبتهای آینده دکتر آرمان ساخته شد (" . count($futureAppts) . " نوبت)\n";
|
||||
|
||||
// ── نوبتهای گذشته — دکتر سامان علوی (doctor_id=1185) ───────────────────
|
||||
|
||||
$pastAppts4 = [
|
||||
[today(-10), 10, 0, 'visited', 793],
|
||||
[today(-10), 11, 0, 'visited', 794],
|
||||
[today(-8), 9, 0, 'visited', 795],
|
||||
[today(-8), 14, 0, 'cancelled_by_user', 796],
|
||||
[today(-6), 9, 0, 'visited', 797],
|
||||
[today(-6), 10, 0, 'visited', 798],
|
||||
[today(-4), 9, 0, 'no_show', 789],
|
||||
[today(-4), 11, 0, 'visited', 790],
|
||||
[today(-2), 10, 0, 'visited', 791],
|
||||
[today(-1), 9, 0, 'visited', 792],
|
||||
];
|
||||
|
||||
foreach ($pastAppts4 as [$date, $h, $m, $status, $patId]) {
|
||||
$start = slot($date, $h, $m);
|
||||
$insertAppt(1185, $patId, $start, $start + 2700, $status); // 45 دقیقه برای ارتوپد
|
||||
}
|
||||
|
||||
echo "✅ نوبتهای گذشته دکتر سامان ساخته شد (" . count($pastAppts4) . " نوبت)\n";
|
||||
|
||||
// ── نوبتهای آینده — دکتر سامان علوی (doctor_id=1185) ────────────────────
|
||||
|
||||
$futureAppts4 = [
|
||||
[today(1), 10, 0, 'reserved', 793],
|
||||
[today(1), 11, 0, 'reserved', 794],
|
||||
[today(2), 9, 0, 'reserved', 795],
|
||||
[today(2), 14, 0, 'reserved', 796],
|
||||
[today(3), 10, 0, 'reserved', 797],
|
||||
[today(4), 9, 0, 'reserved', 798],
|
||||
[today(6), 11, 0, 'reserved', 789],
|
||||
[today(8), 9, 0, 'reserved', 790],
|
||||
];
|
||||
|
||||
foreach ($futureAppts4 as [$date, $h, $m, $status, $patId]) {
|
||||
$start = slot($date, $h, $m);
|
||||
$insertAppt(1185, $patId, $start, $start + 2700, $status);
|
||||
}
|
||||
|
||||
echo "✅ نوبتهای آینده دکتر سامان ساخته شد (" . count($futureAppts4) . " نوبت)\n";
|
||||
|
||||
echo "\n=== همه دادهها با موفقیت ساخته شدند ===\n\n";
|
||||
|
||||
// ── خلاصه نهایی ──────────────────────────────────────────────────────────────
|
||||
|
||||
echo "┌─────────────────────────────────────────────────────────────────────┐\n";
|
||||
echo "│ خلاصه کاربران تستی │\n";
|
||||
echo "├─────────────────────────────────────────────────────────────────────┤\n";
|
||||
echo "│ ادمین: 09120671713 / admin123 │\n";
|
||||
echo "│ دکتر چند کلینیک: 09100000011 / Test1234 (دکتر آرمان رضایی) │\n";
|
||||
echo "│ صاحب کلینیک: 09100000022 / Test1234 (کلینیک قلب پگاه) │\n";
|
||||
echo "│ منشی: 09100000033 / Test1234 (منشی دکتر مهناز) │\n";
|
||||
echo "│ دکتر+کلینیک: 09100000044 / Test1234 (دکتر سامان + کلینیک علوی)│\n";
|
||||
echo "└─────────────────────────────────────────────────────────────────────┘\n";
|
||||
@@ -641,6 +641,52 @@ class AdminApiController extends BaseController
|
||||
new OA\Response(response: 403, description: 'Forbidden – ROLE_ADMIN required'),
|
||||
]
|
||||
)]
|
||||
#[Route('/api/v1/admin/appointment', methods: ['POST'])]
|
||||
public function createAppointment(Request $request): JsonResponse
|
||||
{
|
||||
$data = json_decode($request->getContent(), true) ?? [];
|
||||
$doctorUuid = trim($data['doctor_uuid'] ?? '');
|
||||
$slotStart = (int) ($data['slot_start'] ?? 0);
|
||||
$slotEnd = (int) ($data['slot_end'] ?? 0);
|
||||
$mobile = trim($data['patient_mobile'] ?? '');
|
||||
|
||||
if (empty($doctorUuid) || $slotStart <= 0 || $slotEnd <= $slotStart || empty($mobile)) {
|
||||
return $this->error('VALIDATION', 'doctor_uuid، slot_start، slot_end و patient_mobile الزامی است', 422);
|
||||
}
|
||||
|
||||
$doctor = $this->em->getRepository(Doctor::class)->findOneBy(['uuid' => $doctorUuid]);
|
||||
if (!$doctor) return $this->error('DOCTOR_NOT_FOUND', 'پزشک یافت نشد', 404);
|
||||
|
||||
$patient = $this->em->getRepository(User::class)->findOneBy(['mobileNumber' => $mobile]);
|
||||
if (!$patient) return $this->error('USER_NOT_FOUND', 'بیمار با این شماره یافت نشد', 404);
|
||||
|
||||
$conflict = $this->em->createQueryBuilder()
|
||||
->select('COUNT(a.id)')
|
||||
->from(Appointment::class, 'a')
|
||||
->where('a.doctor = :doctor')
|
||||
->andWhere('a.slotStart < :end AND a.slotEnd > :start')
|
||||
->andWhere("a.status NOT IN ('cancelled_by_doctor','cancelled_by_user','cancelled_by_admin','auto_cancel_unpaid')")
|
||||
->setParameter('doctor', $doctor)
|
||||
->setParameter('start', $slotStart)
|
||||
->setParameter('end', $slotEnd)
|
||||
->getQuery()->getSingleScalarResult();
|
||||
|
||||
if ($conflict > 0) return $this->error('SLOT_TAKEN', 'این نوبت قبلاً رزرو شده است', 409);
|
||||
|
||||
$appointment = new Appointment($doctor, $patient, $slotStart, $slotEnd);
|
||||
if (!empty($data['note'])) $appointment->setNote($data['note']);
|
||||
|
||||
$this->em->persist($appointment);
|
||||
$this->em->flush();
|
||||
|
||||
return $this->success([
|
||||
'uuid' => $appointment->getUuid(),
|
||||
'slot_start' => $slotStart,
|
||||
'slot_end' => $slotEnd,
|
||||
'status' => $appointment->getStatus(),
|
||||
], 201);
|
||||
}
|
||||
|
||||
#[Route('/api/v1/admin/payments', methods: ['GET'])]
|
||||
public function payments(Request $request): JsonResponse
|
||||
{
|
||||
|
||||
@@ -36,7 +36,7 @@ class MyAppointmentsController extends BaseController
|
||||
|
||||
$qb = $this->em->createQueryBuilder()
|
||||
->select(
|
||||
'a.uuid, a.slotStart, a.slotEnd, a.status, a.createdAt',
|
||||
'DISTINCT a.uuid, a.slotStart, a.slotEnd, a.status, a.createdAt',
|
||||
'd.uuid as doctor_uuid, d.name as doctor_name',
|
||||
'u.mobileNumber as patient_mobile, u.realName as patient_name',
|
||||
'c.name as clinic_name'
|
||||
@@ -56,7 +56,7 @@ class MyAppointmentsController extends BaseController
|
||||
if ($clinic === null) {
|
||||
return $this->paginated([], 0, $page, $limit);
|
||||
}
|
||||
$qb->andWhere(':clinic MEMBER OF d.clinics')
|
||||
$qb->andWhere('c = :clinic')
|
||||
->setParameter('clinic', $clinic);
|
||||
} elseif (in_array('ROLE_DOCTOR', $roles, true)) {
|
||||
$doctor = $this->doctorRepo->findByUser($user);
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\ClinicInvitation\Controller;
|
||||
|
||||
use App\Auth\Entity\User;
|
||||
use App\ClinicInvitation\Repository\ClinicDoctorInvitationRepository;
|
||||
use App\ClinicInvitation\Service\ClinicInvitationService;
|
||||
use App\Clinic\Repository\ClinicRepository;
|
||||
@@ -10,6 +11,7 @@ use App\Shared\Exception\AppException;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Security\Http\Attribute\CurrentUser;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
|
||||
class ClinicInvitationController extends BaseController
|
||||
@@ -23,14 +25,16 @@ class ClinicInvitationController extends BaseController
|
||||
// ── Admin endpoints ──────────────────────────────────────────────────────
|
||||
|
||||
#[Route('/api/v1/admin/clinic/{uuid}/invite-doctor', methods: ['POST'])]
|
||||
#[IsGranted('ROLE_ADMIN')]
|
||||
public function inviteDoctor(string $uuid, Request $request): JsonResponse
|
||||
#[IsGranted('IS_AUTHENTICATED_FULLY')]
|
||||
public function inviteDoctor(string $uuid, Request $request, #[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
$clinic = $this->clinicRepo->findByUuid($uuid);
|
||||
if (!$clinic) {
|
||||
throw new AppException('ERR_NOT_FOUND_001', 'کلینیک یافت نشد', 404);
|
||||
}
|
||||
|
||||
$this->assertClinicAccess($clinic, $user);
|
||||
|
||||
$body = json_decode($request->getContent(), true) ?? [];
|
||||
$mobile = trim($body['mobile'] ?? '');
|
||||
$name = !empty($body['name']) ? trim($body['name']) : null;
|
||||
@@ -40,20 +44,22 @@ class ClinicInvitationController extends BaseController
|
||||
throw new AppException('ERR_VALIDATION_001', 'شماره موبایل نامعتبر است', 422, 'mobile');
|
||||
}
|
||||
|
||||
$inv = $this->invitationService->invite($clinic, $this->getUser(), $mobile, $name, $specialty);
|
||||
$inv = $this->invitationService->invite($clinic, $user, $mobile, $name, $specialty);
|
||||
|
||||
return $this->success($inv->toArray(), 201);
|
||||
}
|
||||
|
||||
#[Route('/api/v1/admin/clinic/{uuid}/invitations', methods: ['GET'])]
|
||||
#[IsGranted('ROLE_ADMIN')]
|
||||
public function listInvitations(string $uuid, Request $request): JsonResponse
|
||||
#[IsGranted('IS_AUTHENTICATED_FULLY')]
|
||||
public function listInvitations(string $uuid, Request $request, #[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
$clinic = $this->clinicRepo->findByUuid($uuid);
|
||||
if (!$clinic) {
|
||||
throw new AppException('ERR_NOT_FOUND_001', 'کلینیک یافت نشد', 404);
|
||||
}
|
||||
|
||||
$this->assertClinicAccess($clinic, $user);
|
||||
|
||||
$page = max(1, (int) $request->query->get('page', 1));
|
||||
$limit = min(50, max(1, (int) $request->query->get('limit', 20)));
|
||||
|
||||
@@ -76,28 +82,31 @@ class ClinicInvitationController extends BaseController
|
||||
}
|
||||
|
||||
#[Route('/api/v1/admin/clinic/invitation/{invUuid}/resend', methods: ['POST'])]
|
||||
#[IsGranted('ROLE_ADMIN')]
|
||||
public function resendInvitation(string $invUuid): JsonResponse
|
||||
#[IsGranted('IS_AUTHENTICATED_FULLY')]
|
||||
public function resendInvitation(string $invUuid, #[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
$inv = $this->invRepo->findOneBy(['uuid' => $invUuid]);
|
||||
if (!$inv) {
|
||||
throw new AppException('ERR_NOT_FOUND_001', 'دعوتنامه یافت نشد', 404);
|
||||
}
|
||||
|
||||
$this->assertClinicAccess($inv->getClinic(), $user);
|
||||
$this->invitationService->resend($inv);
|
||||
|
||||
return $this->success(['message' => 'پیامک مجدداً ارسال شد']);
|
||||
}
|
||||
|
||||
#[Route('/api/v1/admin/clinic/invitation/{invUuid}/status', methods: ['PATCH'])]
|
||||
#[IsGranted('ROLE_ADMIN')]
|
||||
public function changeInvitationStatus(string $invUuid, Request $request): JsonResponse
|
||||
#[IsGranted('IS_AUTHENTICATED_FULLY')]
|
||||
public function changeInvitationStatus(string $invUuid, Request $request, #[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
$inv = $this->invRepo->findOneBy(['uuid' => $invUuid]);
|
||||
if (!$inv) {
|
||||
throw new AppException('ERR_NOT_FOUND_001', 'دعوتنامه یافت نشد', 404);
|
||||
}
|
||||
|
||||
$this->assertClinicAccess($inv->getClinic(), $user);
|
||||
|
||||
$body = json_decode($request->getContent(), true) ?? [];
|
||||
$status = $body['status'] ?? '';
|
||||
|
||||
@@ -107,19 +116,31 @@ class ClinicInvitationController extends BaseController
|
||||
}
|
||||
|
||||
#[Route('/api/v1/admin/clinic/invitation/{invUuid}', methods: ['DELETE'])]
|
||||
#[IsGranted('ROLE_ADMIN')]
|
||||
public function deleteInvitation(string $invUuid): JsonResponse
|
||||
#[IsGranted('IS_AUTHENTICATED_FULLY')]
|
||||
public function deleteInvitation(string $invUuid, #[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
$inv = $this->invRepo->findOneBy(['uuid' => $invUuid]);
|
||||
if (!$inv) {
|
||||
throw new AppException('ERR_NOT_FOUND_001', 'دعوتنامه یافت نشد', 404);
|
||||
}
|
||||
|
||||
$this->assertClinicAccess($inv->getClinic(), $user);
|
||||
$this->invitationService->delete($inv);
|
||||
|
||||
return $this->success(null, 204);
|
||||
}
|
||||
|
||||
private function assertClinicAccess(\App\Clinic\Entity\Clinic $clinic, User $user): void
|
||||
{
|
||||
if ($user->hasRole('ROLE_ADMIN')) {
|
||||
return;
|
||||
}
|
||||
if ($user->hasRole('ROLE_CLINIC') && $clinic->getUser()->getId() === $user->getId()) {
|
||||
return;
|
||||
}
|
||||
throw new AppException('ERR_ACCESS_DENIED', 'دسترسی ندارید', 403);
|
||||
}
|
||||
|
||||
// ── Public endpoints ──────────────────────────────────────────────────────
|
||||
|
||||
#[Route('/api/v1/clinic-invitation/{token}', methods: ['GET'])]
|
||||
|
||||
@@ -1082,6 +1082,11 @@
|
||||
resolved "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.11.tgz"
|
||||
integrity sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==
|
||||
|
||||
"@fontsource/vazirmatn@^5.2.8":
|
||||
version "5.2.8"
|
||||
resolved "https://registry.npmjs.org/@fontsource/vazirmatn/-/vazirmatn-5.2.8.tgz"
|
||||
integrity sha512-WoDgv8R/y1gwgTS8Q2uL8d2ayeSGNv2IrYQ4wHmJkVwYPb8KVODrqbbvEjBqLOo5WP5kLJIrO06FRDMnSFuCaA==
|
||||
|
||||
"@heroicons/react@^2.0.0":
|
||||
version "2.2.0"
|
||||
resolved "https://registry.npmjs.org/@heroicons/react/-/react-2.2.0.tgz"
|
||||
|
||||
Reference in New Issue
Block a user