From 45ee725820235e8d16f4e68231f5032ebd00cd69 Mon Sep 17 00:00:00 2001 From: hamed <15238-genius.ha@users.noreply.drupalcode.org> Date: Thu, 11 Jun 2026 14:13:42 +0330 Subject: [PATCH] feat: update appointment management API and frontend components - Added new endpoint to get today's appointment statistics with optional date filter. - Enhanced appointment listing API to support filtering by date and doctor UUID. - Updated Appointment model to include new fields and modified status values. - Implemented AppointmentStatusDropdown component for status management with visual feedback. - Created PersianCalendar component for date selection in Jalali format. - Updated API documentation to reflect changes in appointment management. --- .claude/skills/run-prompt/SKILL.md | 202 +++ .../ui/AppointmentStatusDropdown.tsx | 115 ++ .../admin/components/ui/PersianCalendar.tsx | 176 +++ assets/admin/components/ui/StatusBadge.tsx | 11 +- assets/admin/pages/AppointmentDetailPage.tsx | 23 +- assets/admin/pages/AppointmentsPage.tsx | 1128 ++++++++--------- assets/admin/types/index.ts | 22 +- docs/api/admin.md | 91 +- src/Admin/Controller/AdminApiController.php | 109 +- 9 files changed, 1241 insertions(+), 636 deletions(-) create mode 100644 .claude/skills/run-prompt/SKILL.md create mode 100644 assets/admin/components/ui/AppointmentStatusDropdown.tsx create mode 100644 assets/admin/components/ui/PersianCalendar.tsx diff --git a/.claude/skills/run-prompt/SKILL.md b/.claude/skills/run-prompt/SKILL.md new file mode 100644 index 00000000..0cc71c03 --- /dev/null +++ b/.claude/skills/run-prompt/SKILL.md @@ -0,0 +1,202 @@ +--- +name: run-prompt +description: اجرای یک فایل پرامپت .md به صورت گام‌به‌گام و ایمن. هر قابلیت را جداگانه پیاده‌سازی، تست و مستند می‌کند. استفاده کن وقتی کاربر می‌گوید "اجرای پرامپت"، "پرامپت را اجرا کن"، "run prompt"، یا مسیر یک فایل .md می‌دهد. +--- + +## نحوه دریافت ورودی + +اگر کاربر مسیر فایل داد → آن را بخوان. +اگر فایل مشخص نشد → بپرس: «مسیر فایل پرامپت .md را وارد کنید» + +--- + +## قبل از شروع — تحلیل پرامپت + +۱. فایل پرامپت را کامل بخوان +۲. مستندات موجود را بررسی کن: `docs/api/` +۳. کد مرتبط در `src/` و `assets/admin/` را مرور کن +۴. لیست قابلیت‌ها را از پرامپت استخراج کن +۵. به کاربر نمایش بده: + +``` +📋 قابلیت‌های شناسایی‌شده: + ۱. [نام قابلیت اول] + ۲. [نام قابلیت دوم] + ... + +🚀 شروع با قابلیت ۱ — آیا ادامه دهم؟ +``` + +--- + +## قوانین اجرا (اجباری — هیچ استثنایی ندارد) + +### ۱. یک قابلیت در هر مرحله +- هرگز دو قابلیت را همزمان پیاده‌سازی نکن +- هرگز بدون تأیید موفقیت مرحله قبل به مرحله بعد نرو + +### ۲. ترتیب اجرای هر قابلیت + +``` +① تحلیل ② طراحی ③ پیاده‌سازی ④ تست ⑤ رفع خطا ⑥ مستندسازی ⑦ گزارش +``` + +### ۳. سازگاری با پروژه +- همه تغییرات باید با Symfony 7 + React 19 سازگار باشند +- از الگوهای موجود پروژه پیروی کن (BaseController، TanStack Query، Zod، ...) +- اگر Entity تغییر کرد: `doctrine:migrations:diff` و `doctrine:migrations:migrate` اجرا کن +- اگر API تغییر کرد: همه بخش‌های وابسته (frontend types، api calls، ...) را اصلاح کن + +### ۴. اصول کدنویسی +- SOLID و Clean Code +- هیچ کامنت غیرضروری اضافه نکن +- نام‌گذاری معنادار +- error handling فقط در boundaries واقعی (نه defensive programming اضافی) + +--- + +## روند اجرای هر قابلیت + +### مرحله ① — تحلیل + +قبل از هر چیز: +- فایل‌های مرتبط را بخوان +- endpoint های موجود را بررسی کن: `php bin/console debug:router | grep api` +- Entity های مرتبط را شناسایی کن +- اگر سوال یا ابهامی هست → همین‌جا بپرس، نه وسط پیاده‌سازی + +### مرحله ② — طراحی + +قبل از کدنویسی، طرح کوتاه را توضیح بده: +- چه فایل‌هایی ایجاد/تغییر می‌کنند +- چه API endpoint هایی اضافه/تغییر می‌کنند +- چه migration لازم است (اگر Entity تغییر کرد) + +### مرحله ③ — پیاده‌سازی + +- **Backend اول**: Entity → Migration → Repository → Service → Controller +- **Frontend بعد**: Types → API call → Component → Route +- یک فایل در هر Edit/Write — نه bulk + +### مرحله ④ — تست + +بعد از هر قابلیت این چک‌لیست را اجرا کن: + +```bash +# PHP syntax +ddev exec php -l src/Path/To/ChangedFile.php + +# Symfony cache (اگر route یا config تغییر کرد) +ddev exec php bin/console cache:clear + +# Migration (اگر Entity تغییر کرد) +ddev exec php bin/console doctrine:migrations:diff --no-interaction +ddev exec php bin/console doctrine:migrations:migrate --no-interaction + +# Frontend build +ddev exec yarn dev + +# Route وجود دارد؟ +ddev exec php bin/console debug:router | grep "new-route-name" +``` + +اگر frontend تغییر کرد، TypeScript errors را بررسی کن: +```bash +ddev exec npx tsc --noEmit --project tsconfig.json 2>&1 | head -30 +``` + +### مرحله ⑤ — رفع خطا + +- اگر خطا بود → **همین‌جا** رفع کن، به مرحله بعد نرو +- اگر خطا در فایل دیگری بود → آن را هم رفع کن +- بعد از رفع خطا → دوباره تست را اجرا کن + +### مرحله ⑥ — مستندسازی + +بعد از موفقیت تست، مستندات را به‌روزرسانی کن: + +| تغییر | فایل مستندات | +|-------|-------------| +| `src/Auth/*` | `docs/api/auth.md` | +| `src/Doctor/*` | `docs/api/doctor.md` | +| `src/Clinic/*` | `docs/api/clinic.md` | +| `src/Appointment/Controller/AppointmentController.php` | `docs/api/appointment.md` | +| `src/Appointment/Controller/AppointmentSettings*` | `docs/api/appointment-settings.md` | +| `src/Payment/*` | `docs/api/payment.md` | +| `src/Admin/*` | `docs/api/admin.md` | +| `src/Secretary/*` | `docs/api/secretary.md` | +| `src/Representation/*` | `docs/api/representation.md` | +| `src/Blog/*` | `docs/api/blog.md` | +| `src/Rating/*` | `docs/api/rating.md` | +| `src/Settlement/*` | `docs/api/settlement.md` | +| `src/Sms/*` | `docs/api/sms.md` | + +الزامات مستندسازی: +1. endpoint جدید با method، path، permission +2. Request body با تمام فیلدها و نوع داده +3. Response format با مثال واقعی JSON +4. تمام status codes و error codes +5. اگر پارامتر query داشت → همه را مستند کن + +### مرحله ⑦ — گزارش + +بعد از اتمام هر قابلیت، گزارش کوتاه: + +``` +✅ قابلیت [شماره]: [نام] — تکمیل شد + +فایل‌های تغییر یافته: + • src/... + • assets/admin/... + • docs/api/... + +───────────────────────────────── +▶ قابلیت بعدی: [شماره] — [نام] +آیا ادامه دهم؟ +``` + +--- + +## قوانین خاص این پروژه + +### Backend +- همه controller ها از `BaseController` ارث می‌برند +- پاسخ‌ها: `$this->success($data)` | `$this->paginated(...)` | `$this->error(...)` +- لیست‌های admin از DQL array hydration (`.getArrayResult()`) استفاده کنند +- تاریخ‌ها Unix timestamp صحیح (نه DateTime object) +- بعد از هر تغییر Entity: حتماً migration بساز و اجرا کن + +### Frontend +- داده‌های paginated: items از `data?.data`، total از `data?.meta?.totalRecords` +- داده‌های single resource: از `data?.data` (ممکن است double-nested باشد) +- Category API همیشه triple-nested: `data?.data?.data ?? []` +- JWT در `localStorage['clinicpro-auth']` → `state.token` +- همه تاریخ‌ها با `formatDate()` شمسی نمایش داده شوند (`fa-IR-u-ca-persian`) +- Form: React Hook Form + Zod resolver +- State management: TanStack Query v5 برای server state، Zustand برای client state + +### CSS / UI +- از کلاس‌های موجود استفاده کن: `btn primary sm`، `badge green`، `card`، `toolbar`، `field`، `avatar`، `appt-status`، ... +- هیچ کتابخانه CSS جدید اضافه نکن مگر ضرورت قطعی داشته باشد +- RTL رعایت شود + +--- + +## مثال اجرا + +``` +کاربر: /run-prompt .claude/prompt/appointments-redesign.md + +→ فایل را می‌خوانم... +→ تحلیل می‌کنم... + +📋 قابلیت‌های شناسایی‌شده: + ۱. Stats Bar — آمار امروز (backend + frontend) + ۲. تقویم شمسی Popup (PersianCalendar component) + ۳. بازطراحی Toolbar با date navigator + ۴. نمای جدولی با inline status change + ۵. نمای زمانبندی (Schedule View) + ۶. ثبت نوبت از slot خالی + +🚀 شروع با قابلیت ۱ (Stats Bar) — آیا ادامه دهم؟ +``` diff --git a/assets/admin/components/ui/AppointmentStatusDropdown.tsx b/assets/admin/components/ui/AppointmentStatusDropdown.tsx new file mode 100644 index 00000000..4ac9fa65 --- /dev/null +++ b/assets/admin/components/ui/AppointmentStatusDropdown.tsx @@ -0,0 +1,115 @@ +import React, { useEffect, useRef, useState } from 'react'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { ChevronDownIcon } from '@heroicons/react/24/outline'; +import { toast } from 'sonner'; +import { api } from '../../lib/api'; + +const STATUS_META: Record = { + pending: { label: 'رزرو شده', color: '#3b82f6' }, + confirmed: { label: 'تأیید شده', color: '#22c55e' }, + completed: { label: 'تکمیل شده', color: '#16a34a' }, + cancelled_by_doctor: { label: 'لغو پزشک', color: '#ef4444' }, + cancelled_by_user: { label: 'لغو بیمار', color: '#ef4444' }, + no_show: { label: 'غیبت', color: '#9ca3af' }, + expired: { label: 'منقضی شده', color: '#9ca3af' }, +}; + +const TRANSITIONS: Record = { + pending: ['confirmed', 'cancelled_by_doctor', 'cancelled_by_user', 'expired'], + confirmed: ['completed', 'cancelled_by_doctor', 'cancelled_by_user', 'no_show'], +}; + +interface Props { + uuid: string; + currentStatus: string; + version: number; + queryKey: unknown[]; +} + +export default function AppointmentStatusDropdown({ uuid, currentStatus, version, queryKey }: Props) { + const [open, setOpen] = useState(false); + const ref = useRef(null); + const qc = useQueryClient(); + + useEffect(() => { + function handler(e: MouseEvent) { + if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false); + } + document.addEventListener('mousedown', handler); + return () => document.removeEventListener('mousedown', handler); + }, []); + + const mutation = useMutation({ + mutationFn: (newStatus: string) => + api.patch(`/api/v1/appointment/${uuid}/status`, { status: newStatus, version }), + onSuccess: () => { + qc.invalidateQueries({ queryKey }); + setOpen(false); + }, + onError: () => toast.error('خطا در تغییر وضعیت'), + }); + + const meta = STATUS_META[currentStatus] ?? { label: currentStatus, color: '#9ca3af' }; + const nextStatuses = TRANSITIONS[currentStatus] ?? []; + + return ( +
+ + + {open && nextStatuses.length > 0 && ( +
+ {nextStatuses.map(s => { + const sm = STATUS_META[s] ?? { label: s, color: '#9ca3af' }; + const isCurrent = s === currentStatus; + return ( + + ); + })} +
+ )} +
+ ); +} diff --git a/assets/admin/components/ui/PersianCalendar.tsx b/assets/admin/components/ui/PersianCalendar.tsx new file mode 100644 index 00000000..b7115652 --- /dev/null +++ b/assets/admin/components/ui/PersianCalendar.tsx @@ -0,0 +1,176 @@ +import React, { useEffect, useRef, useState } from 'react'; +import { ChevronRightIcon, ChevronLeftIcon } from '@heroicons/react/24/outline'; + +interface Props { + value: string; // YYYY-MM-DD Gregorian + onChange: (v: string) => void; + onClose: () => void; +} + +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', + }).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') }; +} + +function jalaliFirstWeekday(year: number, month: number): number { + // Build a Gregorian date for the 1st of this Jalali month by iterating + // Use the Intl API: find Gregorian date whose Jalali = year/month/1 + // Approximate: use known offset + const approxGreg = jalaliToGregorian(year, month, 1); + const d = new Date(approxGreg + 'T12:00:00'); + // JS getDay(): 0=Sun,1=Mon,...,6=Sat → convert to Sat=0 + return (d.getDay() + 1) % 7; // Sat=0, Sun=1, ..., Fri=6 +} + +function jalaliToGregorian(jy: number, jm: number, jd: number): string { + const jy2 = jy - 979; + const jm2 = jm - 1; + let jDay = 365 * jy2 + Math.floor(jy2 / 33) * 8 + Math.floor(((jy2 % 33) + 3) / 4); + for (let i = 0; i < jm2; i++) jDay += (i < 6 ? 31 : 30); + jDay += jd - 1; + let gDay = jDay + 79; + let gy2 = 1600 + 400 * Math.floor(gDay / 146097); + gDay %= 146097; + let leap = true; + if (gDay >= 36525) { + gDay--; + const gi = Math.floor(gDay / 36524); + gDay %= 36524; + gy2 += gi * 100; + if (gDay >= 365) { gDay++; leap = false; } + } + gy2 += Math.floor(gDay / 1461) * 4; + gDay %= 1461; + if (gDay >= 366) { + leap = false; + gDay--; + gy2 += Math.floor(gDay / 365); + gDay %= 365; + } + const gMonthDays = [31, leap ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]; + let gm = 0; + for (; gm < 12; gm++) { + if (gDay < gMonthDays[gm]) break; + gDay -= gMonthDays[gm]; + } + return `${gy2}-${String(gm + 1).padStart(2, '0')}-${String(gDay + 1).padStart(2, '0')}`; +} + +const JALALI_MONTHS = ['فروردین','اردیبهشت','خرداد','تیر','مرداد','شهریور','مهر','آبان','آذر','دی','بهمن','اسفند']; + +export default function PersianCalendar({ value, onChange, onClose }: Props) { + const ref = useRef(null); + + const todayGreg = new Date().toISOString().slice(0, 10); + const todayJ = toJalali(new Date(todayGreg + 'T12:00:00')); + const valueJ = value ? toJalali(new Date(value + 'T12:00:00')) : todayJ; + + const [viewYear, setViewYear] = useState(valueJ.year); + const [viewMonth, setViewMonth] = useState(valueJ.month); + + useEffect(() => { + function handler(e: MouseEvent) { + if (ref.current && !ref.current.contains(e.target as Node)) onClose(); + } + document.addEventListener('mousedown', handler); + return () => document.removeEventListener('mousedown', handler); + }, [onClose]); + + const daysCount = viewMonth <= 6 ? 31 : viewMonth <= 11 ? 30 : 29; + const firstWd = jalaliFirstWeekday(viewYear, viewMonth); + + function prevMonth() { + if (viewMonth === 1) { setViewYear(y => y - 1); setViewMonth(12); } + else setViewMonth(m => m - 1); + } + function nextMonth() { + if (viewMonth === 12) { setViewYear(y => y + 1); setViewMonth(1); } + else setViewMonth(m => m + 1); + } + + function selectDay(day: number) { + const greg = jalaliToGregorian(viewYear, viewMonth, day); + onChange(greg); + onClose(); + } + + const isToday = (day: number) => + todayJ.year === viewYear && todayJ.month === viewMonth && todayJ.day === day; + + const isSelected = (day: number) => + value !== '' && valueJ.year === viewYear && valueJ.month === viewMonth && valueJ.day === day; + + const cells: (number | null)[] = [...Array(firstWd).fill(null), ...Array.from({ length: daysCount }, (_, i) => i + 1)]; + while (cells.length % 7 !== 0) cells.push(null); + + return ( +
+ {/* Header */} +
+ + + {JALALI_MONTHS[viewMonth - 1]} {viewYear.toLocaleString('fa-IR')} + + +
+ + {/* Week day headers */} +
+ {WEEK_DAYS.map(d => ( +
+ {d} +
+ ))} +
+ + {/* Days grid */} +
+ {cells.map((day, i) => { + if (!day) return
; + const sel = isSelected(day); + const tod = isToday(day) && !sel; + return ( + + ); + })} +
+
+ ); +} + +const navBtnStyle: React.CSSProperties = { + background: 'transparent', border: 'none', cursor: 'pointer', + color: 'var(--text-2)', padding: 4, borderRadius: 'var(--r-sm)', + display: 'flex', alignItems: 'center', +}; diff --git a/assets/admin/components/ui/StatusBadge.tsx b/assets/admin/components/ui/StatusBadge.tsx index 7580e632..fc986cc4 100644 --- a/assets/admin/components/ui/StatusBadge.tsx +++ b/assets/admin/components/ui/StatusBadge.tsx @@ -4,18 +4,13 @@ import type { AppointmentStatus, PaymentStatus, SmsTemplateStatus, SettlementSta type BadgeColor = 'green' | 'amber' | 'red' | 'blue' | 'violet' | 'gray'; const appointmentMap: Record = { - waiting_for_payment: { color: 'amber', label: 'انتظار پرداخت' }, - reserved: { color: 'blue', label: 'رزرو شده' }, - checked_in: { color: 'violet', label: 'ورود به مطب' }, - waiting: { color: 'amber', label: 'صف انتظار' }, - in_progress: { color: 'blue', label: 'در حال ویزیت' }, - visited: { color: 'green', label: 'ویزیت شده' }, + pending: { color: 'blue', label: 'رزرو شده' }, + confirmed: { color: 'green', label: 'تأیید شده' }, completed: { color: 'green', label: 'تکمیل شده' }, cancelled_by_user: { color: 'red', label: 'لغو بیمار' }, cancelled_by_doctor: { color: 'red', label: 'لغو پزشک' }, - cancelled_by_admin: { color: 'red', label: 'لغو ادمین' }, - auto_cancel_unpaid: { color: 'gray', label: 'لغو خودکار' }, no_show: { color: 'gray', label: 'غیبت' }, + expired: { color: 'gray', label: 'منقضی' }, }; const paymentMap: Record = { diff --git a/assets/admin/pages/AppointmentDetailPage.tsx b/assets/admin/pages/AppointmentDetailPage.tsx index 6064921f..ad3df834 100644 --- a/assets/admin/pages/AppointmentDetailPage.tsx +++ b/assets/admin/pages/AppointmentDetailPage.tsx @@ -6,21 +6,19 @@ import { toast } from 'sonner'; import { api } from '../lib/api'; import type { ApiResponse } from '../lib/api'; import type { Appointment, AppointmentStatus } from '../types'; -import { formatDate, formatDateTime, formatRial } from '../lib/utils'; +import { formatDate, formatDateTime } from '../lib/utils'; import PageHeader from '../components/ui/PageHeader'; import StatusBadge from '../components/ui/StatusBadge'; import ConfirmDialog from '../components/ui/ConfirmDialog'; const ALL_STATUSES: { value: AppointmentStatus; label: string }[] = [ - { 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: 'cancelled_by_admin', label: 'لغو توسط ادمین' }, - { value: 'no_show', label: 'غیبت' }, + { value: 'pending', label: 'رزرو شده' }, + { value: 'confirmed', label: 'تأیید شده' }, + { value: 'completed', label: 'تکمیل شده' }, + { value: 'cancelled_by_doctor', label: 'لغو پزشک' }, + { value: 'cancelled_by_user', label: 'لغو بیمار' }, + { value: 'no_show', label: 'غیبت' }, + { value: 'expired', label: 'منقضی' }, ]; function InfoRow({ label, value }: { label: string; value: React.ReactNode }) { @@ -98,10 +96,9 @@ export default function AppointmentDetailPage() { {appt.patient_mobile}} /> - - - + +
diff --git a/assets/admin/pages/AppointmentsPage.tsx b/assets/admin/pages/AppointmentsPage.tsx index 5aa8f0ae..701f668d 100644 --- a/assets/admin/pages/AppointmentsPage.tsx +++ b/assets/admin/pages/AppointmentsPage.tsx @@ -1,419 +1,398 @@ -import React, { useState, useMemo } from 'react'; -import { useQuery, useMutation } from '@tanstack/react-query'; -import { useNavigate } from 'react-router-dom'; +import React, { useRef, useState } from 'react'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { - MagnifyingGlassIcon, EyeIcon, TableCellsIcon, CalendarDaysIcon as CalendarViewIcon, - CalendarIcon, ClockIcon, CheckCircleIcon, XCircleIcon, - PlusIcon, XMarkIcon, ChevronRightIcon, ChevronLeftIcon, UserCircleIcon, + PlusIcon, ChevronRightIcon, ChevronLeftIcon, CalendarDaysIcon, + TableCellsIcon, ClockIcon, UserCircleIcon, PhoneIcon, } from '@heroicons/react/24/outline'; import { toast } from 'sonner'; import { api } from '../lib/api'; import type { PaginatedResponse, ApiResponse } from '../lib/api'; import type { Appointment } from '../types'; -import { formatDate, maskMobile, toGregorianDate } from '../lib/utils'; -import DataTable, { Column } from '../components/ui/DataTable'; -import Pagination from '../components/ui/Pagination'; -import PersianDateInput from '../components/ui/PersianDateInput'; +import { formatDate, toGregorianDate } from '../lib/utils'; import { useAuthStore } from '../stores/authStore'; +import AppointmentStatusDropdown from '../components/ui/AppointmentStatusDropdown'; +import PersianCalendar from '../components/ui/PersianCalendar'; -// ── Status config ────────────────────────────────────────────────────────── +// ───────────────────────────────────────────────────────────────────────────── +// 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_META: Record = { + pending: { label: 'رزرو شده', color: '#3b82f6', bg: '#eff6ff' }, + confirmed: { label: 'تأیید شده', color: '#22c55e', bg: '#f0fdf4' }, + completed: { label: 'تکمیل شده', color: '#16a34a', bg: '#dcfce7' }, + cancelled_by_doctor: { label: 'لغو پزشک', color: '#ef4444', bg: '#fef2f2' }, + cancelled_by_user: { label: 'لغو بیمار', color: '#ef4444', bg: '#fef2f2' }, + no_show: { label: 'غیبت', color: '#9ca3af', bg: '#f9fafb' }, + expired: { label: 'منقضی', color: '#9ca3af', bg: '#f9fafb' }, }; -const STATUS_FILTERS = [ - { value: '', label: 'همه وضعیت‌ها' }, - { value: 'reserved', label: 'رزرو شده' }, - { value: 'waiting_for_payment', 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: 'غیبت' }, -]; +function statusMeta(s: string) { + return STATUS_META[s] ?? { label: s, color: '#9ca3af', bg: '#f9fafb' }; +} -function ApptStatus({ status }: { status: string }) { - const m = STATUS_META[status] ?? { label: status, cls: 'status-gray' }; +// ───────────────────────────────────────────────────────────────────────────── +// Stats icons (inline SVG) +// ───────────────────────────────────────────────────────────────────────────── + +function IconTotal() { return ( - - - {m.label} - + + + {[8,14,20,26].map(x => [8,14,20,26].map(y => ( + + )))} + ); } -// ── Timeline View ───────────────────────────────────────────────────────── - -interface TimelineProps { - items: Appointment[]; - loading: boolean; - onView: (uuid: string) => void; - groupByDoctor?: boolean; -} - -function DayGroup({ date, appts, onView }: { date: string; appts: Appointment[]; onView: (u: string) => void }) { +function IconCompleted() { 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: 5 }).map((_, i) => ( -
-
-
-
-
+function IconWaiting() { + return ( + + + + + + ); +} + +function IconCancelled() { + return ( + + + + + + ); +} + +// ───────────────────────────────────────────────────────────────────────────── +// Stats Bar +// ───────────────────────────────────────────────────────────────────────────── + +interface TodayStats { total: number; completed: number; waiting: number; cancelled: number; } + +function StatsBar({ date }: { date: string }) { + const { data } = useQuery>({ + queryKey: ['appt-today-stats', date], + queryFn: () => api.get(`/api/v1/admin/appointments/today-stats?date=${date}`), + }); + const s = data?.data ?? { total: 0, completed: 0, waiting: 0, cancelled: 0 }; + const stats = [ + { label: 'کل نوبت‌های امروز', value: s.total, icon: }, + { label: 'نوبت‌های انجام شده', value: s.completed, icon: }, + { label: 'مراجعین در انتظار', value: s.waiting, icon: }, + { label: 'نوبت‌های لغو شده', value: s.cancelled, icon: }, + ]; + return ( +
+ {stats.map((s, i) => ( +
+
{s.icon}
+
+
{s.label}
+
+ {s.value.toLocaleString('fa-IR')}
- ))} -
- ); - } - - if (!items.length) { - return

هیچ نوبتی یافت نشد

; - } - - 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(byDate).map(([date, appts]) => ( - +
))}
); } -// ── Doctor-grouped table ────────────────────────────────────────────────── +// ───────────────────────────────────────────────────────────────────────────── +// Date Navigator +// ───────────────────────────────────────────────────────────────────────────── -interface DoctorGroupedTableProps { - items: Appointment[]; - loading: boolean; - onView: (uuid: string) => void; - columns: Column[]; +function DateNavigator({ date, onChange }: { date: string; onChange: (d: string) => void }) { + const [showCal, setShowCal] = useState(false); + const ref = useRef(null); + + function addDays(n: number) { + const d = new Date(date + 'T12:00:00'); + d.setDate(d.getDate() + n); + onChange(toGregorianDate(d)); + } + + return ( +
+ +
+ {formatDate(date)} +
+ + + {showCal && ( + { onChange(v); setShowCal(false); }} onClose={() => setShowCal(false)} /> + )} +
+ ); } -function DoctorGroupedTable({ items, loading, onView, columns }: DoctorGroupedTableProps) { - if (loading) { +const navBtnSx: React.CSSProperties = { + background: 'var(--surface)', border: '1px solid var(--border)', + borderRadius: 'var(--r-sm)', height: 36, width: 36, + display: 'flex', alignItems: 'center', justifyContent: 'center', + cursor: 'pointer', color: 'var(--text-2)', +}; + +// ───────────────────────────────────────────────────────────────────────────── +// Table View +// ───────────────────────────────────────────────────────────────────────────── + +function TableView({ + items, loading, queryKey, showDoctor, +}: { + items: Appointment[]; loading: boolean; queryKey: unknown[]; showDoctor: boolean; +}) { + if (loading) return
در حال بارگذاری...
; + if (!items.length) return
نوبتی برای این روز ثبت نشده است
; + + return ( +
+ + + + + + + {showDoctor && } + + + + + + + + {items.map((a, i) => ( + + + + + {showDoctor && } + + + + + + ))} + +
ردیفنام بیمارشماره تماسپزشکشروعپایانوضعیتعملیات
{(i + 1).toLocaleString('fa-IR')} +
+ + {a.patient_name || '—'} +
+
+
+ + {a.patient_mobile} +
+
{a.doctor_name}{a.appointment_time}{a.end_time} + + + +
+
+ ); +} + +const th: React.CSSProperties = { padding: '10px 14px', textAlign: 'right', fontWeight: 600, color: 'var(--text-2)', whiteSpace: 'nowrap' }; +const td: React.CSSProperties = { padding: '10px 14px', textAlign: 'right', color: 'var(--text)', verticalAlign: 'middle' }; + +// ───────────────────────────────────────────────────────────────────────────── +// Schedule View — Slot Card +// ───────────────────────────────────────────────────────────────────────────── + +interface SlotItem { + start: number; + end: number; + start_time: string; + end_time: string; + is_available: boolean; + appointment: Appointment | null; +} + +function SlotCard({ slot, queryKey, onBook }: { slot: SlotItem; queryKey: unknown[]; onBook: (slot: SlotItem) => void }) { + if (slot.is_available) { return ( -
- {Array.from({ length: 5 }).map((_, i) => ( -
- ))} +
onBook(slot)} + style={{ + border: '1.5px dashed #93c5fd', borderRadius: 'var(--r)', padding: '10px 16px', + display: 'flex', alignItems: 'center', justifyContent: 'center', + cursor: 'pointer', color: '#3b82f6', fontSize: 13, fontWeight: 600, + background: '#eff6ff', transition: 'all 0.15s', gap: 6, + }} + onMouseEnter={e => (e.currentTarget.style.background = '#dbeafe')} + onMouseLeave={e => (e.currentTarget.style.background = '#eff6ff')} + > + نوبت جدید
); } - if (!items.length) { - return

هیچ نوبتی یافت نشد

; - } - const byDoctor = items.reduce>((acc, a) => { - (acc[a.doctor_name] ??= []).push(a); - return acc; - }, {}); + const a = slot.appointment!; + const sm = statusMeta(a.status); + return ( +
+
+ +
+ {slot.start_time} — {slot.end_time} +
+
+
+
+ + {a.patient_name || '—'} +
+
+ + {a.patient_mobile} +
+
+
+ ); +} + +function ScheduleView({ + slots, loading, queryKey, onBook, +}: { + slots: SlotItem[]; loading: boolean; queryKey: unknown[]; onBook: (s: SlotItem) => void; +}) { + if (loading) return
در حال بارگذاری...
; + if (!slots.length) return
هیچ slot زمانی برای این روز تنظیم نشده است
; return ( -
- {Object.entries(byDoctor).map(([docName, docAppts]) => ( -
- {/* Doctor header row */} +
+ {slots.map((slot, i) => ( +
+
- - دکتر {docName} - {docAppts.length} نوبت + + {slot.start_time}
+
+ ))} +
+ ); +} - {/* Appointments table for this doctor */} - - columns={columns} - data={docAppts} - loading={false} - emptyMessage="" - actions={(appt) => ( - - )} +// ───────────────────────────────────────────────────────────────────────────── +// New Appointment Modal +// ───────────────────────────────────────────────────────────────────────────── + +interface BookingSlot { start: number; end: number; start_time: string; end_time: string; doctor_uuid: string; doctor_name: string; } + +function NewAppointmentModal({ + slot, onClose, onSuccess, +}: { slot: BookingSlot; onClose: () => void; onSuccess: () => void }) { + const [mobile, setMobile] = useState(''); + const qc = useQueryClient(); + + const mutation = useMutation({ + mutationFn: () => api.post('/api/v1/admin/appointment', { + doctor_uuid: slot.doctor_uuid, + slot_start: slot.start, + slot_end: slot.end, + patient_mobile: mobile, + }), + onSuccess: () => { + toast.success('نوبت با موفقیت ثبت شد'); + onSuccess(); + onClose(); + }, + onError: (e: any) => { + const msg = e?.response?.data?.errors?.[0]?.message ?? 'خطا در ثبت نوبت'; + toast.error(msg); + }, + }); + + return ( +
+
e.stopPropagation()}> +
ثبت نوبت
+
+ {slot.start_time} تا {slot.end_time} — {slot.doctor_name} +
+
+ + 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 />
- ))} -
- ); -} - -// ── 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 && ( -
- -