From 82e1c264a12ce5e63062aedc2745e3670594e310 Mon Sep 17 00:00:00 2001
From: hamed <15238-genius.ha@users.noreply.drupalcode.org>
Date: Thu, 11 Jun 2026 13:36:54 +0330
Subject: [PATCH] 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.
---
assets/admin/App.tsx | 15 +-
assets/admin/components/layout/Sidebar.tsx | 1 -
.../admin/components/ui/InviteDoctorModal.tsx | 73 ++
.../admin/components/ui/PersianDateInput.tsx | 69 ++
assets/admin/lib/utils.ts | 52 +-
assets/admin/pages/AppointmentsPage.tsx | 636 +++++++++++++-----
assets/admin/pages/ClinicDetailPage.tsx | 79 +--
assets/admin/pages/DashboardPage.tsx | 50 +-
assets/admin/styles.css | 24 +-
package-lock.json | 10 +
package.json | 1 +
public/favicon.ico | Bin 0 -> 188 bytes
public/favicon.png | Bin 0 -> 166 bytes
seed_test_data.php | 261 +++++++
src/Admin/Controller/AdminApiController.php | 46 ++
.../Controller/MyAppointmentsController.php | 4 +-
.../Controller/ClinicInvitationController.php | 43 +-
yarn.lock | 5 +
18 files changed, 1095 insertions(+), 274 deletions(-)
create mode 100644 assets/admin/components/ui/InviteDoctorModal.tsx
create mode 100644 assets/admin/components/ui/PersianDateInput.tsx
create mode 100644 public/favicon.ico
create mode 100644 public/favicon.png
create mode 100644 seed_test_data.php
diff --git a/assets/admin/App.tsx b/assets/admin/App.tsx
index d7b74e4b..5af887e8 100644
--- a/assets/admin/App.tsx
+++ b/assets/admin/App.tsx
@@ -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
در حال بارگذاری...
;
}
- // اگر چند context دارد و هنوز انتخاب نشده — به صفحه انتخاب برو
- if (availableContexts.length > 1 && !dbUuid) {
+ // اگر چند context دارد و هنوز انتخاب نشده و روی صفحه انتخاب نیستیم
+ if (availableContexts.length > 1 && !dbUuid && location.pathname !== '/admin/select-context') {
return ;
}
@@ -126,9 +127,11 @@ export default function App() {
{/* ادمین + کلینیک */}
} />
- } />
- } />
- } />
+
+ {/* فقط ادمین — کلینیک از طریق دعوتنامه در صفحه کلینیک خود دکتر اضافه میکند */}
+ } />
+ } />
+ } />
} />
diff --git a/assets/admin/components/layout/Sidebar.tsx b/assets/admin/components/layout/Sidebar.tsx
index 3007d09b..60704f58 100644
--- a/assets/admin/components/layout/Sidebar.tsx
+++ b/assets/admin/components/layout/Sidebar.tsx
@@ -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: 'پزشکان' },
],
},
{
diff --git a/assets/admin/components/ui/InviteDoctorModal.tsx b/assets/admin/components/ui/InviteDoctorModal.tsx
new file mode 100644
index 00000000..6b92aa13
--- /dev/null
+++ b/assets/admin/components/ui/InviteDoctorModal.tsx
@@ -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;
+
+interface Props {
+ clinicUuid: string;
+ onClose: () => void;
+ onInvited?: () => void;
+}
+
+export default function InviteDoctorModal({ clinicUuid, onClose, onInvited }: Props) {
+ const { register, handleSubmit, formState: { errors } } = useForm({
+ resolver: zodResolver(inviteSchema),
+ });
+
+ const inviteMut = useMutation({
+ mutationFn: (d: InviteForm) =>
+ api.post>(`/api/v1/admin/clinic/${clinicUuid}/invite-doctor`, d),
+ onSuccess: () => {
+ toast.success('دعوتنامه ارسال شد');
+ onInvited?.();
+ onClose();
+ },
+ onError: (e: Error) => toast.error(e.message),
+ });
+
+ return (
+
+
e.stopPropagation()}>
+
+ دعوت پزشک به کلینیک
+
+
+
+
+
+ );
+}
diff --git a/assets/admin/components/ui/PersianDateInput.tsx b/assets/admin/components/ui/PersianDateInput.tsx
new file mode 100644
index 00000000..10360233
--- /dev/null
+++ b/assets/admin/components/ui/PersianDateInput.tsx
@@ -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(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 (
+
+ {/* visible text layer */}
+
+
+ {value ? formatDate(value) : placeholder}
+ {value && (
+ { e.stopPropagation(); onChange(''); }}
+ style={{ display: 'flex', alignItems: 'center', cursor: 'pointer', color: 'var(--text-3)' }}
+ >
+
+
+ )}
+
+
+ {/* hidden native input — opens picker on click */}
+
onChange(e.target.value)}
+ style={{
+ position: 'absolute', opacity: 0, pointerEvents: 'none',
+ width: 1, height: 1, top: 0, left: 0,
+ }}
+ tabIndex={-1}
+ />
+
+ );
+}
diff --git a/assets/admin/lib/utils.ts b/assets/admin/lib/utils.ts
index 83792a6e..e683b11a 100644
--- a/assets/admin/lib/utils.ts
+++ b/assets/admin/lib/utils.ts
@@ -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 {
diff --git a/assets/admin/pages/AppointmentsPage.tsx b/assets/admin/pages/AppointmentsPage.tsx
index 1a78d1bf..5aa8f0ae 100644
--- a/assets/admin/pages/AppointmentsPage.tsx
+++ b/assets/admin/pages/AppointmentsPage.tsx
@@ -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 = {
+ 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 = {
- 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 = {
- 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 (
+
+
+ {m.label}
+
+ );
+}
// ── 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 (
+
+
+
+ {formatDate(date)}
+
+
+
+ {appts.map((a) => (
+
onView(a.uuid)}
+ onMouseEnter={e => (e.currentTarget.style.boxShadow = '0 2px 8px rgba(0,0,0,.07)')}
+ onMouseLeave={e => (e.currentTarget.style.boxShadow = 'none')}
+ >
+
+ {a.appointment_time}
+
+
+
+ {a.patient_name || maskMobile(a.patient_mobile)}
+
+
+ دکتر {a.doctor_name}{a.clinic_name ? ` · ${a.clinic_name}` : ''}
+
+
+
+
+
+ ))}
+
+
+ );
+}
+
+function TimelineView({ items, loading, onView, groupByDoctor }: TimelineProps) {
if (loading) {
return (
- {Array.from({ length: 6 }).map((_, i) => (
-
-
+ {Array.from({ length: 5 }).map((_, i) => (
+
))}
@@ -71,97 +136,339 @@ function TimelineView({ items, loading, onView }: TimelineProps) {
return
هیچ نوبتی یافت نشد
;
}
- // گروهبندی بر اساس تاریخ
- const grouped = items.reduce
>((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>((acc, a) => {
+ (acc[a.doctor_name] ??= []).push(a);
+ return acc;
+ }, {});
+
+ return (
+
+ {Object.entries(byDoctor).map(([docName, docAppts]) => {
+ const byDate = docAppts.reduce
>((acc, a) => {
+ (acc[a.appointment_date] ??= []).push(a);
+ return acc;
+ }, {});
+ return (
+
+
+
+ دکتر {docName}
+ {docAppts.length} نوبت
+
+ {Object.entries(byDate).map(([date, appts]) => (
+
+ ))}
+
+ );
+ })}
+
+ );
+ }
+
+ // default: group by date only
+ const byDate = items.reduce>((acc, a) => {
+ (acc[a.appointment_date] ??= []).push(a);
return acc;
}, {});
return (
- {Object.entries(grouped).map(([date, appts]) => (
-
-
-
- {new Date(date).toLocaleDateString('fa-IR', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' })}
-
+ {Object.entries(byDate).map(([date, appts]) => (
+
+ ))}
+
+ );
+}
+
+// ── Doctor-grouped table ──────────────────────────────────────────────────
+
+interface DoctorGroupedTableProps {
+ items: Appointment[];
+ loading: boolean;
+ onView: (uuid: string) => void;
+ columns: Column
[];
+}
+
+function DoctorGroupedTable({ items, loading, onView, columns }: DoctorGroupedTableProps) {
+ if (loading) {
+ return (
+
+ {Array.from({ length: 5 }).map((_, i) => (
+
+ ))}
+
+ );
+ }
+ if (!items.length) {
+ return هیچ نوبتی یافت نشد
;
+ }
+
+ const byDoctor = items.reduce>((acc, a) => {
+ (acc[a.doctor_name] ??= []).push(a);
+ return acc;
+ }, {});
+
+ return (
+
+ {Object.entries(byDoctor).map(([docName, docAppts]) => (
+
+ {/* Doctor header row */}
+
+
+ دکتر {docName}
+ {docAppts.length} نوبت
-
- {appts.map((a) => (
-
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')}
- >
- {/* ساعت */}
-
- {a.appointment_time}
-
- {/* اطلاعات */}
-
-
- {a.patient_name || maskMobile(a.patient_mobile)}
-
-
- دکتر {a.doctor_name}{a.clinic_name ? ` · ${a.clinic_name}` : ''}
-
-
-
- {/* وضعیت */}
-
- {APPT_LABEL[a.status] ?? a.status}
-
-
-
-
- ))}
-
+ {/* Appointments table for this doctor */}
+
+ columns={columns}
+ data={docAppts}
+ loading={false}
+ emptyMessage=""
+ actions={(appt) => (
+
+ )}
+ />
))}
);
}
+// ── 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>([]);
+ 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 }>>(
+ `/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 = {
+ 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 (
+
+
e.stopPropagation()}>
+
+ ثبت نوبت جدید
+
+
+
+
+
+
+
+ { setDoctorUuid(e.target.value); setStep(1); setSlots([]); setSelectedSlot(null); }}
+ />
+
+
+
+
+
+
+
+
+
+
+
+ {isAdmin && (
+
+
+ setPatientMobile(e.target.value)} />
+
+ )}
+
+
+
+ {step >= 2 && slots.length > 0 && (
+
+
+
+ {slots.map((s) => (
+
+ ))}
+
+
+ )}
+
+ {step >= 2 && slots.length === 0 && (
+
نوبت خالی در این تاریخ وجود ندارد
+ )}
+
+ {step >= 3 && (
+
+
+
+ )}
+
+
+
+
+
+
+
+
+ );
+}
+
// ── 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>(`${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[] = [
{
key: 'patient',
@@ -169,7 +476,7 @@ export default function AppointmentsPage() {
render: (a) => (
{(a.patient_name ?? '؟').slice(0, 2)}
@@ -180,41 +487,41 @@ export default function AppointmentsPage() {
),
},
- { 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) => {a.clinic_name ?? '—'} },
{
key: 'appointment_date',
- header: 'تاریخ نوبت',
+ header: 'تاریخ و ساعت',
render: (a) => (
- {formatDate(a.appointment_date)}
-
{a.appointment_time}
+ {formatDate(a.appointment_date)}
+
+ {a.appointment_time}
),
},
{
key: 'status',
header: 'وضعیت',
- render: (a) => ,
+ render: (a) => ,
},
{
- key: 'amount',
- header: 'مبلغ',
- render: (a) => <>{formatRial(a.amount)} تومان>,
+ key: 'created_at',
+ header: 'ثبت در',
+ render: (a) => {formatDate(a.created_at)},
},
- { key: 'created_at', header: 'تاریخ ثبت', render: (a) => {formatDate(a.created_at)} },
];
- 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() {
{pageTitle}
مدیریت و پیگیری نوبتهای درمانی
-
@@ -251,8 +558,34 @@ export default function AppointmentsPage() {
{/* Main card */}
+
+ {/* Doctor filter tabs — only for clinic with multiple doctors */}
+ {isClinic && doctorNames.length > 1 && (
+
+ setDoctorFilter('')}
+ className={`btn sm ${!doctorFilter ? 'primary' : 'ghost'}`}
+ style={{ fontSize: 12 }}
+ >
+ همه پزشکان
+
+ {doctorNames.map(name => (
+ setDoctorFilter(name)}
+ className={`btn sm ${doctorFilter === name ? 'primary' : 'ghost'}`}
+ style={{ fontSize: 12 }}
+ >
+ دکتر {name}
+
+ ))}
+
+ )}
+
- {/* جستجو */}
- {/* فیلتر تاریخ */}
-
-
- { setDateFilter(e.target.value); setPage(1); }}
- style={{ direction: 'ltr' }}
- />
-
+
{ setDateFilter(v); setPage(1); }}
+ placeholder="فیلتر تاریخ"
+ />
- {/* فیلتر وضعیت */}
+
+ {newApptOpen && (
+
setNewApptOpen(false)}
+ onCreated={refetch}
+ defaultDoctorUuid={isDoctor ? (dbUuid ?? '') : ''}
+ />
+ )}
);
}
diff --git a/assets/admin/pages/ClinicDetailPage.tsx b/assets/admin/pages/ClinicDetailPage.tsx
index 2bdbfdb6..2c10deb9 100644
--- a/assets/admin/pages/ClinicDetailPage.tsx
+++ b/assets/admin/pages/ClinicDetailPage.tsx
@@ -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
= {
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;
-
-function InviteModal({ clinicUuid, onClose, onInvited }: {
- clinicUuid: string; onClose: () => void; onInvited: () => void;
-}) {
- const { register, handleSubmit, formState: { errors } } = useForm({
- resolver: zodResolver(inviteSchema),
- });
-
- const inviteMut = useMutation({
- mutationFn: (d: InviteForm) =>
- api.post>(`/api/v1/admin/clinic/${clinicUuid}/invite-doctor`, d),
- onSuccess: () => { toast.success('دعوتنامه ارسال شد'); onInvited(); onClose(); },
- onError: (e: Error) => toast.error(e.message),
- });
-
- return (
-
-
e.stopPropagation()}>
-
- دعوت پزشک به کلینیک
-
-
-
-
-
- );
-}
// ── Main Page ──────────────────────────────────────────────────────────────
@@ -748,13 +691,17 @@ export default function ClinicDetailPage() {
openEdit('basic')}>
ویرایش
- toggleMut.mutate()} disabled={toggleMut.isPending}>
- {clinic.is_active ? 'غیرفعال کردن' : 'فعال کردن'}
-
- setDeleteOpen(true)}>
- حذف
-
+ {primaryRole === 'admin' && (
+ <>
+ toggleMut.mutate()} disabled={toggleMut.isPending}>
+ {clinic.is_active ? 'غیرفعال کردن' : 'فعال کردن'}
+
+ setDeleteOpen(true)}>
+ حذف
+
+ >
+ )}
@@ -1054,7 +1001,7 @@ export default function ClinicDetailPage() {
{/* Invite doctor modal */}
{inviteOpen && uuid && createPortal(
-
setInviteOpen(false)}
onInvited={() => { qc.invalidateQueries({ queryKey: ['clinic-invitations', uuid] }); setDoctorsTab('invitations'); }}
diff --git a/assets/admin/pages/DashboardPage.tsx b/assets/admin/pages/DashboardPage.tsx
index c77faedd..5fe19b0b 100644
--- a/assets/admin/pages/DashboardPage.tsx
+++ b/assets/admin/pages/DashboardPage.tsx
@@ -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>('/api/v1/dashboard/clinic'),
@@ -524,6 +526,7 @@ function ClinicDashboard() {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const d = useMemo(() => (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 ;
@@ -541,10 +544,16 @@ function ClinicDashboard() {
داشبورد کلینیک
{today} · {d?.clinic.name ?? context?.name ?? ''}
- q.refetch()}>
-
- بهروزرسانی
-
+
+
setInviteOpen(true)}>
+
+ دعوت پزشک
+
+
q.refetch()}>
+
+ بهروزرسانی
+
+
@@ -571,29 +580,48 @@ function ClinicDashboard() {
پزشکان کلینیک
-
همه
+
+ setInviteOpen(true)}>
+ + دعوت جدید
+
+ {clinicUuid && (
+ مدیریت
+ )}
+
{!d?.doctors.length ? (
-
پزشکی ثبت نشده
+
+
هنوز پزشکی دعوت نشده
+
setInviteOpen(true)}>
+ دعوت اولین پزشک
+
+
) : (
{d.doctors.map((doc, i) => (
-
دکتر {doc.name}
{formatNumber(doc.today_count)} امروز
-
+
))}
)}
+
+ {inviteOpen && clinicUuid && (
+ setInviteOpen(false)}
+ onInvited={() => q.refetch()}
+ />
+ )}
);
}
diff --git a/assets/admin/styles.css b/assets/admin/styles.css
index fc9a2aa2..94384442 100644
--- a/assets/admin/styles.css
+++ b/assets/admin/styles.css
@@ -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;
diff --git a/package-lock.json b/package-lock.json
index 4f984611..f553055c 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -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",
diff --git a/package.json b/package.json
index 8632392a..2c095cc7 100644
--- a/package.json
+++ b/package.json
@@ -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",
diff --git a/public/favicon.ico b/public/favicon.ico
new file mode 100644
index 0000000000000000000000000000000000000000..9a264ae0771f84e5ed507fe76d2ca1f02487ec56
GIT binary patch
literal 188
zcmZQzU<5)11qKkwu#AC$K@5mH1N_{1xum#&OkPh9mmnam0KyzhK=O~o+r>bNv%n*=
zn1O-s2naJy)#j513PyOkIEHw1Cj0pPKH=bfz$N&yyRL+Y*am@>dFOoWd=5_e;?H8r
z_g6t`Yr+HFgd;j{HZ1da#29r;_Lrkc*1-eO4XQJfSQjsSxq_8BgK^`@DLDpTnloNR
aGcdf47f@fmSmZp=4hBzGKbLh*2~7a}o;Xba
literal 0
HcmV?d00001
diff --git a/public/favicon.png b/public/favicon.png
new file mode 100644
index 0000000000000000000000000000000000000000..ef695c47036acf9302d50b4baa19060159c0da5a
GIT binary patch
literal 166
zcmeAS@N?(olHy`uVBq!ia0vp^3LwnE1SJ1Ryj={WI14-?iy0XBj({-ZRBb+KpkRcj
zi(`mKXR?pq?-LH*2V8bP0l+XkKHbootEnv(__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";
diff --git a/src/Admin/Controller/AdminApiController.php b/src/Admin/Controller/AdminApiController.php
index 9c60e22f..e9f8492c 100644
--- a/src/Admin/Controller/AdminApiController.php
+++ b/src/Admin/Controller/AdminApiController.php
@@ -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
{
diff --git a/src/Appointment/Controller/MyAppointmentsController.php b/src/Appointment/Controller/MyAppointmentsController.php
index 09eec05c..60a0e683 100644
--- a/src/Appointment/Controller/MyAppointmentsController.php
+++ b/src/Appointment/Controller/MyAppointmentsController.php
@@ -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);
diff --git a/src/ClinicInvitation/Controller/ClinicInvitationController.php b/src/ClinicInvitation/Controller/ClinicInvitationController.php
index 2e316c0a..4a652783 100644
--- a/src/ClinicInvitation/Controller/ClinicInvitationController.php
+++ b/src/ClinicInvitation/Controller/ClinicInvitationController.php
@@ -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'])]
diff --git a/yarn.lock b/yarn.lock
index 0e3ba861..1e16f62d 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -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"