From 0b31eb7812bf23a2751de5a6c0ee11158baba99b Mon Sep 17 00:00:00 2001 From: hamed <15238-genius.ha@users.noreply.drupalcode.org> Date: Thu, 11 Jun 2026 19:35:12 +0330 Subject: [PATCH] fix: resolve calendar crash and enhance appointment management - Fixed calendar crash due to invalid array length in PersianCalendar.tsx by changing locale to 'en-u-ca-persian'. - Added Persian weekday display in DateNavigator with appropriate styling and logic. - Updated empty slot message to indicate when a day is off. - Made patient name a required field in appointment creation and implemented find-or-create logic for patients in both admin and user endpoints. - Corrected mobile number display to show the patient's number instead of the doctor's in appointment listings. - Ensured booked appointments are displayed correctly in the schedule view. - Removed unnecessary operations column from the appointments table view. --- .claude/prompt/appointments-day-of-week.md | 404 ++++++++++++++++++ .../admin/components/ui/PersianCalendar.tsx | 4 +- assets/admin/pages/AppointmentsPage.tsx | 156 ++++--- docs/api/admin.md | 8 +- docs/api/appointment.md | 43 ++ src/Admin/Controller/AdminApiController.php | 22 +- .../Controller/MyAppointmentsController.php | 57 +++ 7 files changed, 631 insertions(+), 63 deletions(-) create mode 100644 .claude/prompt/appointments-day-of-week.md diff --git a/.claude/prompt/appointments-day-of-week.md b/.claude/prompt/appointments-day-of-week.md new file mode 100644 index 00000000..0890c168 --- /dev/null +++ b/.claude/prompt/appointments-day-of-week.md @@ -0,0 +1,404 @@ +# باگ‌فیکس صفحه نوبت‌ها + +## فهرست باگ‌ها + +| # | باگ | فایل‌ها | +|---|-----|---------| +| ۱ | کرش تقویم — `RangeError: Invalid array length` | `PersianCalendar.tsx` | +| ۲ | روز هفته در DateNavigator نمایش داده نمی‌شود | `AppointmentsPage.tsx` | +| ۳ | پیام «slot نیست» باید «تعطیل است» باشد | `AppointmentsPage.tsx` | +| ۴ | نوبت جدید — نام اجباری + find-or-create patient | `AppointmentsPage.tsx` + دو controller | +| ۵ | در نمایش جدولی، موبایل پزشک به جای بیمار نشان داده می‌شود | `AppointmentsPage.tsx` + `AppointmentController.php` | +| ۶ | نوبت‌های رزرو شده در نمایش زمانبندی نشان داده نمی‌شوند | `AppointmentsPage.tsx` | +| ۷ | ستون عملیات («...») از نمایش جدولی حذف شود | `AppointmentsPage.tsx` | + +--- + +## باگ ۱ — کرش تقویم + +### علت ریشه‌ای + +**فایل:** `assets/admin/components/ui/PersianCalendar.tsx` + +```ts +// مشکل: locale 'fa-IR' → اعداد فارسی → parseInt('۱۴۰۴') = NaN → Array(NaN) کرش +const parts = new Intl.DateTimeFormat('fa-IR-u-ca-persian', { ... }).formatToParts(d); +const get = (t: string) => parseInt(parts.find(...), 10); +``` + +`parseInt('۱۴۰۴', 10)` در مرورگر `NaN` می‌دهد → `viewYear = NaN` → `Array(NaN).fill(null)` → کرش. + +### فیکس + +```ts +// locale را به 'en' عوض کن — تاریخ شمسی ولی اعداد ASCII +const parts = new Intl.DateTimeFormat('en-u-ca-persian', { + year: 'numeric', month: 'numeric', day: 'numeric', +}).formatToParts(d); +``` + +--- + +## باگ ۲ — روز هفته در DateNavigator + +### وضعیت فعلی + +```tsx +// فقط تاریخ شمسی — بدون روز هفته +
{formatDate(date)}
+``` + +### رفتار مورد انتظار + +``` +[ < ] ┌──────────────┐ [ > ] [ 📅 ] + │ شنبه │ + │ ۱۴۰۴/۰۳/۲۲ │ + └──────────────┘ +``` + +### الزامات UI +- روز هفته فارسی: شنبه / یک‌شنبه / دوشنبه / سه‌شنبه / چهارشنبه / پنج‌شنبه / جمعه +- روز هفته **پررنگ‌تر** از تاریخ +- **جمعه** به رنگ قرمز (`#ef4444`) — تعطیل +- واکنشی: مستقیم از prop `date` محاسبه شود، نه state جداگانه +- height کمی بیشتر (۴۴px) — هم‌تراز با دکمه‌های toolbar + +### پیاده‌سازی + +```ts +const WEEK_DAYS_FA = ['یک‌شنبه', 'دوشنبه', 'سه‌شنبه', 'چهارشنبه', 'پنج‌شنبه', 'جمعه', 'شنبه']; +// 0=Sun 1=Mon 2=Tue 3=Wed 4=Thu 5=Fri 6=Sat + +function getPersianWeekDay(gregorianDate: string): string { + return WEEK_DAYS_FA[new Date(gregorianDate + 'T12:00:00').getDay()]; +} +``` + +در `DateNavigator`: +```tsx +const weekDay = getPersianWeekDay(date); +const isFriday = new Date(date + 'T12:00:00').getDay() === 5; + +// جایگزین div تاریخ: +
+ + {weekDay} + + + {formatDate(date)} + +
+``` + +--- + +## باگ ۳ — پیام «slot نیست» + +**فایل:** `AppointmentsPage.tsx` — تابع `ScheduleView` + +```tsx +// قبل: +if (!slots.length) return
هیچ slot زمانی برای این روز تنظیم نشده است
; + +// بعد: +if (!slots.length) return ( +
+
🏖️
+
این روز تعطیل است
+
+ هیچ برنامه زمانبندی برای این روز تنظیم نشده است +
+
+); +``` + +--- + +## باگ ۴ — نوبت جدید: نام اجباری + find-or-create patient + +### مشکل فعلی + +**Frontend:** `NewAppointmentModal` فقط موبایل دارد، نام ندارد. + +**Backend `POST /api/v1/admin/appointment`** (خط ۷۵۰ در `AdminApiController.php`): +```php +$patient = $this->em->getRepository(User::class)->findOneBy(['mobileNumber' => $mobile]); +if (!$patient) return $this->error('USER_NOT_FOUND', 'بیمار با این شماره یافت نشد', 404); +// ❌ اشتباه: به جای ایجاد کاربر جدید، خطا برمی‌گرداند +``` + +**Backend `POST /api/v1/appointment`** (برای دکتر/کلینیک): +```php +// ❌ اشتباه: از کاربر لاگین‌شده (خود دکتر) به عنوان بیمار استفاده می‌کند +$appointment = new Appointment($doctor, $user, $slotStart, $slotEnd); +``` + +### فیکس Backend + +#### ۱. `POST /api/v1/admin/appointment` — در `AdminApiController.php` + +افزودن `patient_name` به body و find-or-create: + +```php +$mobile = trim($data['patient_mobile'] ?? ''); +$patientName = trim($data['patient_name'] ?? ''); + +if (empty($doctorUuid) || $slotStart <= 0 || $slotEnd <= $slotStart || empty($mobile) || empty($patientName)) { + return $this->error('VALIDATION', 'doctor_uuid، slot_start، slot_end، patient_mobile و patient_name الزامی است', 422); +} + +// find-or-create: +$patient = $this->em->getRepository(User::class)->findOneBy(['mobileNumber' => $mobile]); +if (!$patient) { + $patient = new User($mobile); + $patient->setRealName($patientName); + $patient->setRoles(['ROLE_USER']); + $this->em->persist($patient); +} +``` + +#### ۲. `POST /api/v1/my/appointment` — **endpoint جدید** در `MyAppointmentsController.php` + +این endpoint برای دکتر/کلینیک/منشی است که می‌خواهند برای بیمار نوبت بگیرند: + +```php +#[Route('/api/v1/my/appointment', methods: ['POST'])] +#[IsGranted('IS_AUTHENTICATED_FULLY')] +public function createAppointment(Request $request, #[CurrentUser] User $user): 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'] ?? ''); + $patientName = trim($data['patient_name'] ?? ''); + + if (empty($doctorUuid) || $slotStart <= 0 || $slotEnd <= $slotStart || empty($mobile) || empty($patientName)) { + return $this->error('VALIDATION', 'همه فیلدها الزامی است', 422); + } + + // فقط برای نقش‌های مجاز + $roles = $user->getRoles(); + $allowedRoles = ['ROLE_DOCTOR', 'ROLE_CLINIC', 'ROLE_SECRETARY', 'ROLE_ADMIN']; + if (!array_intersect($allowedRoles, $roles)) { + return $this->error('FORBIDDEN', 'دسترسی ندارید', 403); + } + + $doctor = $this->doctorRepo->findByUuid($doctorUuid); + if (!$doctor) return $this->error('DOCTOR_NOT_FOUND', 'پزشک یافت نشد', 404); + + // find-or-create patient + $patient = $this->em->getRepository(\App\Auth\Entity\User::class)->findOneBy(['mobileNumber' => $mobile]); + if (!$patient) { + $patient = new \App\Auth\Entity\User($mobile); + $patient->setRealName($patientName); + $patient->setRoles(['ROLE_USER']); + $this->em->persist($patient); + } + + // conflict check + $conflict = $this->em->createQueryBuilder() + ->select('COUNT(a.id)')->from(\App\Appointment\Entity\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 \App\Appointment\Entity\Appointment($doctor, $patient, $slotStart, $slotEnd); + $this->em->persist($appointment); + $this->em->flush(); + + return $this->success(['uuid' => $appointment->getUuid(), 'status' => $appointment->getStatus()], 201); +} +``` + +### فیکس Frontend — `NewAppointmentModal` + +```tsx +function NewAppointmentModal({ slot, onClose, onSuccess }) { + const [mobile, setMobile] = useState(''); + const [patientName, setPatientName] = useState(''); + const qc = useQueryClient(); + const role = useAuthStore(s => s.primaryRole); + + // ادمین از admin endpoint، بقیه از my endpoint + const createEndpoint = role === 'admin' + ? '/api/v1/admin/appointment' + : '/api/v1/my/appointment'; + + const mutation = useMutation({ + mutationFn: () => api.post(createEndpoint, { + doctor_uuid: slot.doctor_uuid, + slot_start: slot.start, + slot_end: slot.end, + patient_mobile: mobile, + patient_name: patientName, + }), + // ... + }); + + const isValid = mobile.length >= 10 && patientName.trim().length >= 2; + + return ( +
+
e.stopPropagation()} style={{ ... }}> + {/* فیلد نام */} +
+ + setPatientName(e.target.value)} + placeholder="مثال: علی محمدی" + style={{ width: '100%', height: 38, ... }} + autoFocus + /> +
+ {/* فیلد موبایل */} +
+ + setMobile(e.target.value)} + placeholder="مثال: 09123456789" + style={{ width: '100%', height: 38, direction: 'ltr', ... }} + /> +
+
+ + +
+
+
+ ); +} +``` + +--- + +## باگ ۵ — patient_mobile نشان می‌دهد موبایل پزشک + +### علت ریشه‌ای + +**فایل:** `AppointmentController.php` — endpoint `POST /api/v1/appointment` + +```php +// ❌ $user = دکتر لاگین‌شده → نه بیمار +$appointment = new Appointment($doctor, $user, $slotStart, $slotEnd); +``` + +وقتی دکتر یا کلینیک از `/api/v1/appointment` استفاده می‌کند، کاربر لاگین‌شده (خود دکتر) به عنوان بیمار ثبت می‌شود. پس `u.mobileNumber as patient_mobile` موبایل دکتر را برمی‌گرداند. + +### فیکس + +بعد از فیکس باگ ۴ (ساخت endpoint جدید `POST /api/v1/my/appointment`)، frontend را آپدیت کن تا از این endpoint استفاده کند: + +```tsx +// قبل: +const createEndpoint = role === 'admin' ? '/api/v1/admin/appointment' : '/api/v1/appointment'; + +// بعد: +const createEndpoint = role === 'admin' ? '/api/v1/admin/appointment' : '/api/v1/my/appointment'; +``` + +این تغییر باگ ۴ و ۵ را با هم رفع می‌کند. + +--- + +## باگ ۶ — نوبت‌های رزرو شده در نمایش زمانبندی + +### مشکل + +**فایل:** `AppointmentsPage.tsx` — `mergedSlots` useMemo + +نوبت‌های رزرو شده باید در زمانبندی با کارت رنگی نشان داده شوند. اگر نمایش داده نمی‌شوند، احتمالاً تطبیق timestamp ها مشکل دارد. + +### بررسی و فیکس + +```tsx +const mergedSlots: SlotItem[] = React.useMemo(() => { + if (viewMode !== 'schedule') return []; + const rawSlots: any[] = (slotsQuery.data?.data as any)?.slots ?? []; + const apptByStart = new Map(); + + // مطمئن شو slot_start عدد صحیح است + appointments.forEach(a => { + const key = typeof a.slot_start === 'number' ? a.slot_start : parseInt(String(a.slot_start), 10); + apptByStart.set(key, a); + }); + + return rawSlots.map((s: any) => { + const slotStart = typeof s.start === 'number' ? s.start : parseInt(String(s.start), 10); + const appt = apptByStart.get(slotStart) ?? null; + return { + start: slotStart, + end: typeof s.end === 'number' ? s.end : parseInt(String(s.end), 10), + start_time: s.start_time ?? new Date(slotStart * 1000).toLocaleTimeString('fa-IR', { hour: '2-digit', minute: '2-digit' }), + end_time: s.end_time ?? new Date((typeof s.end === 'number' ? s.end : parseInt(String(s.end), 10)) * 1000).toLocaleTimeString('fa-IR', { hour: '2-digit', minute: '2-digit' }), + is_available: !appt, + appointment: appt, + }; + }); +}, [viewMode, slotsQuery.data, appointments]); +``` + +همچنین مطمئن شو که بعد از ثبت نوبت جدید، هر دو query invalidate می‌شوند: +```tsx +onSuccess={() => { + qc.invalidateQueries({ queryKey: apptQueryKey }); + qc.invalidateQueries({ queryKey: slotsQueryKey }); + qc.invalidateQueries({ queryKey: ['appt-today-stats', selectedDate] }); +}} +``` + +--- + +## باگ ۷ — حذف ستون عملیات از نمایش جدولی + +**فایل:** `AppointmentsPage.tsx` — تابع `TableView` + +```tsx +// حذف شود — header: +عملیات + +// حذف شود — در هر ردیف: + + + +``` + +--- + +## فایل‌های تأثیرپذیر + +| فایل | تغییر | +|------|-------| +| `assets/admin/components/ui/PersianCalendar.tsx` | باگ ۱ — locale `en-u-ca-persian` | +| `assets/admin/pages/AppointmentsPage.tsx` | باگ ۲، ۳، ۶، ۷ — UI + merge fix + modal | +| `src/Admin/Controller/AdminApiController.php` | باگ ۴ — find-or-create در `createAppointment()` | +| `src/Appointment/Controller/MyAppointmentsController.php` | باگ ۴، ۵ — endpoint جدید `POST /api/v1/my/appointment` | + +--- + +## ترتیب اجرا + +1. **باگ ۱** — `PersianCalendar.tsx`: فیکس `toJalali` → تست: تقویم باز شود +2. **باگ ۲** — `AppointmentsPage.tsx`: روز هفته در DateNavigator +3. **باگ ۳** — `AppointmentsPage.tsx`: متن «تعطیل است» +4. **باگ ۴+۵** — backend: `AdminApiController.php` + endpoint جدید در `MyAppointmentsController.php` → `php -l` هر دو فایل +5. **باگ ۴+۵** — frontend: آپدیت `NewAppointmentModal` با فیلد نام + endpoint جدید +6. **باگ ۶** — `AppointmentsPage.tsx`: بررسی و فیکس merge timestamps +7. **باگ ۷** — `AppointmentsPage.tsx`: حذف ستون عملیات +8. تست TypeScript: `ddev exec npx tsc --noEmit --project tsconfig.json` +9. تست build: `ddev exec yarn dev` diff --git a/assets/admin/components/ui/PersianCalendar.tsx b/assets/admin/components/ui/PersianCalendar.tsx index b7115652..8a60b6a0 100644 --- a/assets/admin/components/ui/PersianCalendar.tsx +++ b/assets/admin/components/ui/PersianCalendar.tsx @@ -12,8 +12,8 @@ const WEEK_DAYS = ['ش', 'ی', 'د', 'س', 'چ', 'پ', 'ج']; const pf = new Intl.DateTimeFormat('fa-IR-u-ca-persian', { calendar: 'persian' }); function toJalali(d: Date): { year: number; month: number; day: number } { - const parts = new Intl.DateTimeFormat('fa-IR-u-ca-persian', { - year: 'numeric', month: 'numeric', day: 'numeric', calendar: 'persian', + const parts = new Intl.DateTimeFormat('en-u-ca-persian', { + year: 'numeric', month: 'numeric', day: 'numeric', }).formatToParts(d); const get = (t: string) => parseInt(parts.find(p => p.type === t)?.value ?? '0', 10); return { year: get('year'), month: get('month'), day: get('day') }; diff --git a/assets/admin/pages/AppointmentsPage.tsx b/assets/admin/pages/AppointmentsPage.tsx index 06ee8b53..39222b64 100644 --- a/assets/admin/pages/AppointmentsPage.tsx +++ b/assets/admin/pages/AppointmentsPage.tsx @@ -127,10 +127,19 @@ function StatsBar({ date, isAdmin }: { date: string; isAdmin: boolean }) { // Date Navigator // ───────────────────────────────────────────────────────────────────────────── +const WEEK_DAYS_FA = ['یک‌شنبه', 'دوشنبه', 'سه‌شنبه', 'چهارشنبه', 'پنج‌شنبه', 'جمعه', 'شنبه']; + +function getPersianWeekDay(gregorianDate: string): string { + return WEEK_DAYS_FA[new Date(gregorianDate + 'T12:00:00').getDay()]; +} + function DateNavigator({ date, onChange }: { date: string; onChange: (d: string) => void }) { const [showCal, setShowCal] = useState(false); const ref = useRef(null); + const weekDay = getPersianWeekDay(date); + const isFriday = new Date(date + 'T12:00:00').getDay() === 5; + function addDays(n: number) { const d = new Date(date + 'T12:00:00'); d.setDate(d.getDate() + n); @@ -143,11 +152,17 @@ function DateNavigator({ date, onChange }: { date: string; onChange: (d: string)
- {formatDate(date)} + + {weekDay} + + + {formatDate(date)} +
- ))} @@ -311,7 +316,13 @@ function ScheduleView({ slots: SlotItem[]; loading: boolean; queryKey: unknown[]; onBook: (s: SlotItem) => void; }) { if (loading) return
در حال بارگذاری...
; - if (!slots.length) return
هیچ slot زمانی برای این روز تنظیم نشده است
; + if (!slots.length) return ( +
+
🏖️
+
این روز تعطیل است
+
هیچ برنامه زمانبندی برای این روز تنظیم نشده است
+
+ ); return (
@@ -340,18 +351,21 @@ interface BookingSlot { start: number; end: number; start_time: string; end_time function NewAppointmentModal({ slot, onClose, onSuccess, }: { slot: BookingSlot; onClose: () => void; onSuccess: () => void }) { - const [mobile, setMobile] = useState(''); - const qc = useQueryClient(); + const [mobile, setMobile] = useState(''); + const [patientName, setPatientName] = useState(''); const role = useAuthStore(s => s.primaryRole); - const createEndpoint = role === 'admin' ? '/api/v1/admin/appointment' : '/api/v1/appointment'; + const createEndpoint = role === 'admin' ? '/api/v1/admin/appointment' : '/api/v1/my/appointment'; + + const isValid = mobile.length >= 10 && patientName.trim().length >= 2; const mutation = useMutation({ mutationFn: () => api.post(createEndpoint, { - doctor_uuid: slot.doctor_uuid, - slot_start: slot.start, - slot_end: slot.end, + doctor_uuid: slot.doctor_uuid, + slot_start: slot.start, + slot_end: slot.end, patient_mobile: mobile, + patient_name: patientName.trim(), }), onSuccess: () => { toast.success('نوبت با موفقیت ثبت شد'); @@ -359,11 +373,20 @@ function NewAppointmentModal({ onClose(); }, onError: (e: any) => { - const msg = e?.response?.data?.errors?.[0]?.message ?? 'خطا در ثبت نوبت'; + const msg = e?.response?.data?.errors?.[0]?.message ?? e?.message ?? 'خطا در ثبت نوبت'; toast.error(msg); }, }); + const inputSx: React.CSSProperties = { + width: '100%', height: 38, padding: '0 10px', borderRadius: 'var(--r-sm)', + border: '1px solid var(--border)', background: 'var(--surface)', fontSize: 13, + boxSizing: 'border-box', + }; + const labelSx: React.CSSProperties = { + display: 'block', fontSize: 12, fontWeight: 600, color: 'var(--text-2)', marginBottom: 6, + }; + return (
{slot.start_time} تا {slot.end_time} — {slot.doctor_name}
+ +
+ + setPatientName(e.target.value)} + placeholder="مثال: علی محمدی" + style={inputSx} + autoFocus + /> +
+
- + setMobile(e.target.value)} - placeholder="مثال: ۰۹۱۲۳۴۵۶۷۸۹" - style={{ - width: '100%', height: 38, padding: '0 10px', borderRadius: 'var(--r-sm)', - border: '1px solid var(--border)', background: 'var(--surface)', fontSize: 13, - direction: 'ltr', boxSizing: 'border-box', - }} - autoFocus + placeholder="مثال: 09123456789" + style={{ ...inputSx, direction: 'ltr' }} />
+
@@ -424,7 +454,8 @@ export default function AppointmentsPage() { const [selectedDate, setSelectedDate] = useState(today); const [viewMode, setViewMode] = useState<'table' | 'schedule'>('table'); const [selectedDoctorUuid, setSelectedDoctorUuid] = useState(isDoctor && dbUuid ? dbUuid : ''); - const [bookingSlot, setBookingSlot] = useState(null); + const [bookingSlot, setBookingSlot] = useState(null); + const [bookingHint, setBookingHint] = useState(false); const qc = useQueryClient(); // ── Appointments query @@ -464,17 +495,22 @@ export default function AppointmentsPage() { if (viewMode !== 'schedule') return []; const rawSlots: any[] = (slotsQuery.data?.data as any)?.slots ?? []; const apptByStart = new Map(); - appointments.forEach(a => apptByStart.set(a.slot_start, a)); + appointments.forEach(a => { + const key = typeof a.slot_start === 'number' ? a.slot_start : parseInt(String(a.slot_start), 10); + apptByStart.set(key, a); + }); return rawSlots.map((s: any) => { - const appt = apptByStart.get(s.start) ?? null; + const slotStart = typeof s.start === 'number' ? s.start : parseInt(String(s.start), 10); + const slotEnd = typeof s.end === 'number' ? s.end : parseInt(String(s.end), 10); + const appt = apptByStart.get(slotStart) ?? null; return { - start: s.start, - end: s.end, - start_time: s.start_time ?? new Date(s.start * 1000).toLocaleTimeString('fa-IR', { hour: '2-digit', minute: '2-digit' }), - end_time: s.end_time ?? new Date(s.end * 1000).toLocaleTimeString('fa-IR', { hour: '2-digit', minute: '2-digit' }), + start: slotStart, + end: slotEnd, + start_time: s.start_time ?? new Date(slotStart * 1000).toLocaleTimeString('fa-IR', { hour: '2-digit', minute: '2-digit' }), + end_time: s.end_time ?? new Date(slotEnd * 1000).toLocaleTimeString('fa-IR', { hour: '2-digit', minute: '2-digit' }), is_available: !appt, - appointment: appt, + appointment: appt, }; }); }, [viewMode, slotsQuery.data, appointments]); @@ -482,6 +518,7 @@ export default function AppointmentsPage() { // ── Handle slot click → open booking modal function handleSlotClick(slot: SlotItem) { const doctorName = doctors.find(d => d.uuid === selectedDoctorUuid)?.name ?? ''; + setBookingHint(false); setBookingSlot({ start: slot.start, end: slot.end, @@ -533,8 +570,8 @@ export default function AppointmentsPage() { className="btn primary sm" onClick={() => { if (!selectedDoctorUuid) { toast.error('ابتدا یک پزشک انتخاب کنید'); return; } - const d = doctors.find(x => x.uuid === selectedDoctorUuid); - setBookingSlot({ start: 0, end: 0, start_time: '', end_time: '', doctor_uuid: selectedDoctorUuid, doctor_name: d?.name ?? '' }); + setViewMode('schedule'); + setBookingHint(true); }} style={{ display: 'flex', alignItems: 'center', gap: 5 }} > @@ -550,7 +587,7 @@ export default function AppointmentsPage() { {(['table', 'schedule'] as const).map(mode => ( +
+ )} + + )}
diff --git a/docs/api/admin.md b/docs/api/admin.md index ebe9e7a9..4f7e2ade 100644 --- a/docs/api/admin.md +++ b/docs/api/admin.md @@ -497,7 +497,7 @@ List appointments filtered by date and/or doctor. Sorted by `slot_start ASC`. ### POST `/api/v1/admin/appointment` -Create a new appointment for a patient identified by mobile number. +Create a new appointment for a patient. If no user exists with the given mobile, a new user account is created automatically. **Permission:** `ROLE_ADMIN` @@ -508,10 +508,13 @@ Create a new appointment for a patient identified by mobile number. "slot_start": 1718438400, "slot_end": 1718439600, "patient_mobile": "09123456789", + "patient_name": "علی محمدی", "note": "optional note" } ``` +> `patient_mobile` و `patient_name` هر دو اجباری هستند. اگر کاربری با این شماره موبایل نداشته باشیم، یک کاربر جدید با نقش `ROLE_USER` ساخته می‌شود. + ### Response `201` ```json { @@ -528,9 +531,8 @@ Create a new appointment for a patient identified by mobile number. ### Error Responses | Code | HTTP | Description | |------|------|-------------| -| `VALIDATION` | 422 | Missing required fields | +| `VALIDATION` | 422 | Missing required fields (doctor_uuid, slot_start, slot_end, patient_mobile, patient_name) | | `DOCTOR_NOT_FOUND` | 404 | Doctor UUID not found | -| `USER_NOT_FOUND` | 404 | No user with that mobile number | | `SLOT_TAKEN` | 409 | Slot already booked | --- diff --git a/docs/api/appointment.md b/docs/api/appointment.md index 6f1a8191..99339ffb 100644 --- a/docs/api/appointment.md +++ b/docs/api/appointment.md @@ -278,6 +278,49 @@ Updated appointment object. --- +## POST `/api/v1/my/appointment` + +Create a new appointment for a patient. Used by doctor/clinic/secretary to book appointments on behalf of patients. If no user exists with the given mobile, a new user account is created automatically. + +**Auth:** `IS_AUTHENTICATED_FULLY` — Roles: `ROLE_DOCTOR`, `ROLE_CLINIC`, `ROLE_SECRETARY`, `ROLE_ADMIN` + +### Request Body +```json +{ + "doctor_uuid": "doctor-uuid", + "slot_start": 1718438400, + "slot_end": 1718439600, + "patient_mobile": "09123456789", + "patient_name": "علی محمدی", + "note": "optional note" +} +``` + +> اگر کاربری با این شماره موبایل وجود نداشته باشد، یک کاربر جدید با نقش `ROLE_USER` ساخته می‌شود. + +### Response `201` +```json +{ + "success": true, + "data": { + "uuid": "appt-uuid", + "slot_start": 1718438400, + "slot_end": 1718439600, + "status": "pending" + } +} +``` + +### Error Responses +| Code | HTTP | Description | +|------|------|-------------| +| `FORBIDDEN` | 403 | Role not allowed | +| `VALIDATION` | 422 | Missing required fields | +| `DOCTOR_NOT_FOUND` | 404 | Doctor UUID not found | +| `SLOT_TAKEN` | 409 | Slot already booked | + +--- + ## GET /api/v1/my/appointments Role-aware paginated list of appointments. Returns only what the authenticated user is authorized to see. diff --git a/src/Admin/Controller/AdminApiController.php b/src/Admin/Controller/AdminApiController.php index 969e86cf..528d99b2 100644 --- a/src/Admin/Controller/AdminApiController.php +++ b/src/Admin/Controller/AdminApiController.php @@ -733,21 +733,27 @@ class AdminApiController extends BaseController #[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'] ?? ''); + $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'] ?? ''); + $patientName = trim($data['patient_name'] ?? ''); - if (empty($doctorUuid) || $slotStart <= 0 || $slotEnd <= $slotStart || empty($mobile)) { - return $this->error('VALIDATION', 'doctor_uuid، slot_start، slot_end و patient_mobile الزامی است', 422); + if (empty($doctorUuid) || $slotStart <= 0 || $slotEnd <= $slotStart || empty($mobile) || empty($patientName)) { + return $this->error('VALIDATION', 'doctor_uuid، slot_start، slot_end، patient_mobile و patient_name الزامی است', 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); + if (!$patient) { + $patient = new User($mobile); + $patient->setRealName($patientName); + $patient->setRoles(['ROLE_USER']); + $this->em->persist($patient); + } $conflict = $this->em->createQueryBuilder() ->select('COUNT(a.id)') diff --git a/src/Appointment/Controller/MyAppointmentsController.php b/src/Appointment/Controller/MyAppointmentsController.php index 96bb5032..2315d0e0 100644 --- a/src/Appointment/Controller/MyAppointmentsController.php +++ b/src/Appointment/Controller/MyAppointmentsController.php @@ -24,6 +24,63 @@ class MyAppointmentsController extends BaseController private readonly DoctorSecretaryRepository $secretaryRepo, ) {} + #[Route('/api/v1/my/appointment', methods: ['POST'])] + #[IsGranted('IS_AUTHENTICATED_FULLY')] + public function createAppointment(Request $request, #[CurrentUser] User $user): JsonResponse + { + $roles = $user->getRoles(); + $allowed = ['ROLE_DOCTOR', 'ROLE_CLINIC', 'ROLE_SECRETARY', 'ROLE_ADMIN']; + if (!array_intersect($allowed, $roles)) { + return $this->error('FORBIDDEN', 'دسترسی ندارید', 403); + } + + $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'] ?? ''); + $patientName = trim($data['patient_name'] ?? ''); + + if (empty($doctorUuid) || $slotStart <= 0 || $slotEnd <= $slotStart || empty($mobile) || empty($patientName)) { + return $this->error('VALIDATION', 'همه فیلدها الزامی است', 422); + } + + $doctor = $this->doctorRepo->findByUuid($doctorUuid); + if (!$doctor) return $this->error('DOCTOR_NOT_FOUND', 'پزشک یافت نشد', 404); + + $patient = $this->em->getRepository(User::class)->findOneBy(['mobileNumber' => $mobile]); + if (!$patient) { + $patient = new User($mobile); + $patient->setRealName($patientName); + $patient->setRoles(['ROLE_USER']); + $this->em->persist($patient); + } + + $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/my/appointments', methods: ['GET'])] #[IsGranted('IS_AUTHENTICATED_FULLY')] public function myAppointments(Request $request, #[CurrentUser] User $user): JsonResponse