feat: complete redesign of the appointments page with new UI and functionality

This commit is contained in:
hamed
2026-06-11 13:58:46 +03:30
parent 82e1c264a1
commit 92a258832b
+463
View File
@@ -0,0 +1,463 @@
# بازطراحی کامل صفحه نوبت‌ها (AppointmentsPage)
## هدف
صفحه `/admin/appointments` باید کاملاً بازطراحی شود تا شبیه به تصاویر مرجع باشد.
دو حالت نمایش وجود دارد: **جدولی** و **زمانبندی**. هر دو حالت date-based هستند (نه pagination کلی).
---
## یک کامپوننت برای همه نقش‌ها
**همه پنل‌ها — دکتر، کلینیک، ادمین — دقیقاً همین صفحه را می‌بینند.** یک کامپوننت `AppointmentsPage.tsx` برای هر سه نقش. تفاوت‌ها فقط در رفتار داده‌ها است، نه UI:
| ویژگی | ادمین | کلینیک | دکتر |
|-------|-------|--------|------|
| API لیست نوبت‌ها | `/api/v1/admin/appointments` | `/api/v1/my/appointments` | `/api/v1/my/appointments` |
| API slot‌های خالی | `/api/v1/appointment-slots` | `/api/v1/appointment-slots` | `/api/v1/appointment-slots` |
| dropdown پرسنل | همه دکترها | فقط دکترهای کلینیک | فقط خودش (auto-select) |
| Doctor Tabs | نمایش داده نشود | ✅ نمایش (اگر ≥۲ دکتر) | نمایش داده نشود |
| ثبت نوبت | با patient_mobile | با patient_mobile | با patient_mobile |
| تغییر وضعیت | ✅ | ✅ | ✅ |
**نکته**: وقتی `primaryRole === 'doctor'`، دکتر UUID از `useAuthStore().dbUuid` گرفته می‌شود و dropdown پرسنل auto-select + read-only است.
**نکته**: وقتی `primaryRole === 'clinic'`، لیست دکترهای قابل انتخاب از نوبت‌های برگشتی استخراج می‌شود (unique `doctor_uuid` + `doctor_name`).
---
## ساختار کلی صفحه
### ۱. نوار آمار بالا (Stats Bar)
چهار کارت آمار **امروز** (نه کل):
- **کل نوبت‌های امروز** — icon: شبکه نقطه‌ای بنفش
- **نوبت‌های انجام شده** — icon: آدم سبز
- **مراجعین در انتظار** — icon: ساعت نارنجی
- **نوبت‌های لغو شده** — icon: ضربدر قرمز
هر کارت: عدد بزرگ + برچسب + آیکون گرافیکی رنگی.
داده از API: `GET /api/v1/admin/appointments/today-stats?date=YYYY-MM-DD`
پاسخ: `{ total, completed, waiting, cancelled }`
---
### ۲. نوار کنترل (Toolbar)
```
[+ نوبت جدید] [آیکون جدول] [نمایش جدولی] [زمانبندی] [پرسنل را انتخاب کنید ▼] [←] [۱۴۰۳/۰۶/۰۵] [→] [📅]
```
**اجزا:**
- دکمه **"+ نوبت جدید"**: primary، باز می‌کند modal ثبت نوبت
- **تاگل نما**: دو تب inline — "نمایش جدولی" (active=رنگی) و "زمانبندی"
- **انتخاب پرسنل**: dropdown با لیست پزشکان — برای ادمین همه، برای کلینیک پزشکان خودش
- **ناوبری تاریخ**: دکمه‌های `←` `→` برای روز قبلی/بعدی + تاریخ شمسی + آیکون تقویم که PersianCalendar popup باز می‌کند
- **تقویم popup شمسی**: ماه/سال فارسی، روزهای هفته فارسی (ش ی د س چ پ ج)، امروز highlight خاکستری، روز انتخابی circle آبی، دکمه‌های ماه قبل/بعد
**نکته مهم**: نما date-based است — هر بار یک روز خاص نمایش داده می‌شود. پیش‌فرض = امروز.
---
### ۳. سیستم وضعیت‌ها
**وضعیت‌های واقعی backend** (entity `Appointment.php`):
| نام نمایشی | backend value (`STATUS_*`) | رنگ |
|-----------------|----------------------------|------------|
| رزرو شده | `pending` | آبی |
| تأیید شده | `confirmed` | سبز |
| تکمیل شده | `completed` | سبز تیره |
| لغو پزشک | `cancelled_by_doctor` | قرمز |
| لغو بیمار | `cancelled_by_user` | قرمز |
| غیبت | `no_show` | خاکستری |
| منقضی شده | `expired` | خاکستری |
**ماشین حالت (ALLOWED_TRANSITIONS)**:
- `pending``confirmed` | `cancelled_by_doctor` | `cancelled_by_user` | `expired`
- `confirmed``completed` | `cancelled_by_doctor` | `cancelled_by_user` | `no_show`
**PATCH status موجود** در `AppointmentController.php`:
```
PATCH /api/v1/appointment/{uuid}/status
Body: { "status": "confirmed", "version": 1 }
```
دارای optimistic lock (version field). دکتر و ادمین هر دو مجاز هستند.
**نکته مهم**: وضعیت‌هایی مثل `waiting_for_payment`, `checked_in`, `waiting`, `in_progress`, `visited` که در frontend قدیمی بودند در entity وجود ندارند. فقط از وضعیت‌های بالا استفاده شود.
**تغییر inline وضعیت**: کلیک روی badge → dropdown با دایره‌های رنگی → انتخاب → PATCH:
```
PATCH /api/v1/admin/appointment/{uuid}/status
Body: { "status": "visited" }
```
پس از موفقیت: فقط cache همان query را invalidate کند (بدون reload صفحه).
---
### ۴. نمای جدولی (Table View)
**ستون‌ها**:
`ردیف` | `نام بیمار` | `شماره تماس` | `شروع` | `پایان` | `وضعیت` | `عملیات`
- **ردیف**: شماره ردیف ساده
- **شروع / پایان**: ساعت HH:MM
- **وضعیت**: badge قابل کلیک با `▼` → dropdown تغییر وضعیت
- **عملیات**: دکمه `...` → منو: مشاهده جزئیات، لغو نوبت
**API**: `GET /api/v1/admin/appointments?date=YYYY-MM-DD&doctor_uuid=...&limit=100`
(در این نما limit بالا، بدون pagination — همه نوبت‌های یک روز)
---
### ۵. نمای زمانبندی (Schedule View)
**بالا**: Doctor Tabs — تب برای هر پزشک، کلیک = سوئیچ به نوبت‌های آن دکتر
**Layout** (RTL):
```
[ کارت‌های نوبت — ستون عریض چپ ] [ ● ۰۸:۰۰ — محور زمان راست ]
```
**محور زمان (Time Axis)**:
- خط عمودی نقطه‌ای (dashed) از بالا به پایین
- نقطه ● در زمان شروع هر slot
- label زمان فارسی کنار نقطه (۰۸:۰۰، ۰۸:۴۰)
**کارت نوبت پر**:
```
┌────────────────────────────────────────────────────────────┐
│ ▼ ویزیت شده ۰۸:۰۰ ● ─ ─ ─ ─ ─ ─ │ ← header
│ ۰۸:۳۵ ● │
├────────────────────────────────────────────────────────────┤
│ 👤 ساغر صابری نژاد 📞 ۰۹۳۵۶۶۱۹۴۳۸ │ ← body
│ سرویس: [نام سرویس] [+ اضافه] │
├────────────────────────────────────────────────────────────┤
│ [عملیات ...] │ ← footer
└────────────────────────────────────────────────────────────┘
```
رنگ‌بندی: border + background-tint متناسب با وضعیت:
- ویزیت شده: border سبز، background سبز ۵٪
- لغو شده: border قرمز، background قرمز ۵٪
- در حال پیگیری: border نارنجی، background نارنجی ۵٪
- سالن: border بنفش، background بنفش ۵٪
- ثبت شده/قطعی: border آبی، background آبی ۵٪
**کارت slot خالی**:
```
┌ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┐
│ ⊕ نوبت جدید │
└ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┘
```
- border dashed آبی کمرنگ
- کلیک → modal ثبت نوبت با doctor_uuid + slot_start/end پیش‌پر شده
**API برای نمای زمانبندی**:
`GET /api/v1/appointment-slots?doctor_uuid=...&date=YYYY-MM-DD`
باید slot‌های پر را هم برگرداند با اطلاعات نوبت:
```json
{
"slots": [
{
"start": 1234567890,
"end": 1234568490,
"label": "09:10",
"is_available": false,
"appointment": {
"uuid": "...",
"patient_name": "...",
"patient_mobile": "...",
"status": "visited",
"service_name": "..."
}
},
{
"start": 1234569000,
"end": 1234569600,
"label": "09:45",
"is_available": true,
"appointment": null
}
]
}
```
---
## وضعیت Backend موجود (بررسی شده)
### ✅ موجود و قابل استفاده
| Endpoint | وضعیت |
|----------|-------|
| `GET /api/v1/appointment-slots?doctor_uuid=...&date=YYYY-MM-DD` | ✅ موجود — **فقط slot‌های خالی** برمی‌گرداند |
| `POST /api/v1/appointment` | ✅ موجود — برای کاربر لاگین‌شده |
| `POST /api/v1/admin/appointment` | ✅ موجود — برای ادمین با patient_mobile |
| `PATCH /api/v1/appointment/{uuid}/status` | ✅ موجود — با optimistic lock و transition validation |
| `GET /api/v1/admin/appointments?date=...` | باید بررسی شود آیا date filter دارد |
### 🔴 ندارد — باید اضافه شود
**۱. آمار امروز** — در `AdminApiController`:
```php
#[Route('/api/v1/admin/appointments/today-stats', methods: ['GET'])]
// پارامتر: ?date=YYYY-MM-DD
// منطق: COUNT GROUP BY status با DATE(FROM_UNIXTIME(slot_start)) = :date
// پاسخ: { total, completed, waiting, cancelled }
```
**۲. Slot‌های کامل (پر + خالی)** — برای نمای زمانبندی:
**روش پیشنهادی (بدون backend جدید)**:
- Frontend دو query موازی می‌زند:
1. `GET /api/v1/appointment-slots?doctor_uuid=...&date=...` → لیست slot‌های خالی
2. `GET /api/v1/admin/appointments?date=...&doctor_uuid=...` → لیست نوبت‌های پر
- Merge در frontend: هر slot یا `{ is_available: true }` یا `{ is_available: false, appointment: {...} }`
اگر merge پیچیده شد، گزینه دوم اضافه کردن param به slot endpoint:
```
GET /api/v1/appointment-slots?doctor_uuid=...&date=...&include_booked=1
```
که slot‌های رزرو‌شده را هم با فیلد `appointment` برمی‌گرداند.
---
---
## ⭐ مهم‌ترین Feature: ثبت نوبت از نمای زمانبندی
### پیش‌شرط: دکتر باید برنامه هفتگی داشته باشد
**جریان بررسی**:
1. وقتی دکتری انتخاب می‌شود، ابتدا `GET /api/v1/appointment-settings/weekly-schedule/{doctor_uuid}` فراخوانی شود
2. اگر `404` برگشت → نوار هشدار نمایش داده شود:
```
⚠️ دکتر [نام] هنوز برنامه هفتگی تنظیم نکرده است.
[تنظیم برنامه] ← لینک به صفحه تنظیمات دکتر
```
3. اگر schedule موجود بود → نوبت‌ها بر اساس آن محاسبه شوند
**Slot Format خروجی `SlotCalculatorService`**:
```json
{
"start": 1718438400,
"end": 1718439600,
"start_time": "09:00",
"end_time": "09:20",
"location_id": 1973
}
```
فیلد `label` برای نمایش: `"09:00"` (از `start_time`)
### جریان ثبت نوبت از slot خالی (اصلی‌ترین UX)
کلیک روی کارت `+ نوبت جدید` در نمای زمانبندی:
```
┌──────────────────────────────┐
│ ثبت نوبت — ۰۹:۱۰ تا ۰۹:۳۰ │
│ دکتر: [نام دکتر] │
│ ─────────────────────────── │
│ موبایل بیمار: [___________] │ ← فقط این فیلد!
│ [ثبت نوبت] [انصراف] │
└──────────────────────────────┘
```
- **تنها یک فیلد ورودی**: شماره موبایل بیمار
- doctor_uuid + slot_start + slot_end از slot کلیک‌شده پیش‌پر هستند
- Submit → `POST /api/v1/admin/appointment` با `{ doctor_uuid, slot_start, slot_end, patient_mobile }`
- موفقیت → slot خالی تبدیل به کارت نوبت `pending` می‌شود (بدون reload کل صفحه)
**برای نقش دکتر/منشی** (نه ادمین):
- بیمار را با موبایل پیدا کند
- اگر کاربر یافت نشد → خطا: "بیمار با این شماره در سیستم یافت نشد"
- (نمی‌توان کاربر جدید از اینجا ساخت)
### Doctor Tabs در داشبورد کلینیک
وقتی `primaryRole === 'clinic'`:
- تب‌های دکتر بالای نمای زمانبندی نمایش داده شوند
- هر تب: نام دکتر
- کلیک روی تب → نمایش نوبت‌های آن دکتر برای تاریخ انتخابی
- داده‌ها از `GET /api/v1/admin/appointments?date=...` فیلتر شده با `doctor_uuid`
- برای گرفتن لیست دکترهای کلینیک: از نوبت‌های برگشتی unique doctor_name/uuid استخراج شود
---
## کامپوننت‌های جدید Frontend
### `PersianCalendar.tsx`
```tsx
interface Props {
value: string; // YYYY-MM-DD
onChange: (v: string) => void;
onClose: () => void;
}
```
- محاسبه ماه/سال شمسی از `Intl.DateTimeFormat('fa-IR-u-ca-persian')`
- گرید ۷ ستونه با padding برای روز اول ماه
- تبدیل روز انتخابی شمسی → Gregorian: از `new Date()` با تاریخ ISO ساخته‌شده
### `AppointmentStatusDropdown.tsx`
```tsx
interface Props {
uuid: string;
currentStatus: string;
onChanged: () => void;
}
```
- دکمه badge با ``
- dropdown با لیست وضعیت‌ها و دایره رنگی
- useMutation برای PATCH
- بعد از موفقیت: invalidate query
---
## فایل‌های تأثیرپذیر
| فایل | تغییر |
|------|-------|
| `assets/admin/pages/AppointmentsPage.tsx` | بازنویسی کامل |
| `assets/admin/components/ui/PersianCalendar.tsx` | جدید |
| `assets/admin/components/ui/PersianDateInput.tsx` | افزودن PersianCalendar popup |
| `assets/admin/components/ui/AppointmentStatusDropdown.tsx` | جدید |
| `assets/admin/styles.css` | CSS schedule view + status colors |
| `src/Admin/Controller/AdminApiController.php` | today-stats + PATCH status |
| `src/Appointment/Controller/AppointmentController.php` | بهبود slots |
| `docs/api/admin.md` | مستندسازی endpoint‌های جدید |
| `docs/api/appointment.md` | مستندسازی بهبود slots |
---
## منطق محاسبه Slot‌ها (SlotCalculatorService)
اولویت‌بندی:
1. **Holiday** (تعطیلات) → هیچ slot‌ای نیست
2. **Date Override** با `active: false` → هیچ slot‌ای نیست
3. **Date Override** با `active: true` → از `custom_slots` آن روز استفاده شود
4. **Weekly Schedule** → از session‌های روز هفته مربوطه (ایندکس: شنبه=0 ... جمعه=6)
خروجی slot بعد از محاسبه و حذف slot‌های رزرو‌شده:
```json
{ "start": 1718438400, "end": 1718439600, "start_time": "09:00", "end_time": "09:20", "location_id": 1973 }
```
این endpoint **فقط slot‌های خالی** برمی‌گرداند (booked slots حذف شده‌اند).
---
## نکات پیاده‌سازی
1. **Date-based**: queryKey شامل `selectedDate` — پیش‌فرض امروز (Gregorian)
2. **Doctor state**: یک state مشترک `selectedDoctorUuid` برای هر دو نما
3. **Schedule view — دو query موازی**:
- `useQuery(['slots', doctorUuid, date])` → slot‌های خالی از `/api/v1/appointment-slots`
- `useQuery(['appointments', date, doctorUuid])` → نوبت‌های پر از `/api/v1/admin/appointments`
- Merge در frontend: آرایه کامل با `is_available` flag
4. **Slot کلیک**: `onSlotClick(slot)` → `setBookingSlot(slot)` → mini modal با فقط یک فیلد موبایل
5. **Optimistic lock**: PATCH status باید `version` را از نوبت بگیرد
6. **Status transitions**: فقط انتقال‌های مجاز از وضعیت فعلی در dropdown نشان داده شود
7. **بررسی schedule**: اگر دکتر schedule نداشت هشدار نشان بده + لینک تنظیمات دکتر
8. **محور زمان RTL**: CSS grid دو ستونه — کارت‌ها چپ، محور زمان راست
9. **Reload نکن**: بعد از هر عملیات فقط `queryClient.invalidateQueries`
10. **وضعیت‌های درست**: `pending`, `confirmed`, `completed`, `cancelled_by_doctor`, `cancelled_by_user`, `no_show`, `expired`
---
## ⭐ الزام دیزاین: دقیقاً شبیه تصاویر مرجع
> **این صفحه باید pixel-perfect شبیه تصاویر مرجع باشد. هیچ اجزایی از دیزاین نباید تغییر کند.**
---
### همه تاریخ‌ها شمسی (Jalali)
- **تمام** تاریخ‌های نمایشی — فیلتر، جدول، کارت‌ها، محور زمان، تقویم popup — باید شمسی باشند
- از `Intl.DateTimeFormat('fa-IR-u-ca-persian')` برای تبدیل استفاده شود
- input فیلتر تاریخ: مقدار داخلی `YYYY-MM-DD` میلادی باشد (برای API) ولی **نمایش** شمسی
- تقویم popup باید ماه/سال/روز شمسی نشان دهد
- هیچ تاریخی به فرمت `mm/dd/yyyy` یا میلادی به کاربر نمایش داده نشود
---
### مشخصات دقیق دیزاین از تصاویر مرجع
#### Stats Bar
```
┌───────────────────────────────────────────────────────────────────┐
│ کل نوبت‌های امروز │ نوبت‌های انجام شده │ مراجعین در انتظار │ نوبت‌های لغو شده │
│ [آیکون بنفش] │ [آیکون سبز] │ [آیکون نارنجی] │ [آیکون قرمز] │
│ ۲۳۶ │ ۲۰۰ │ ۲۳۶ │ ۱۲ │
└───────────────────────────────────────────────────────────────────┘
```
- یک کارت سفید با border کمرنگ — ۴ بخش با divider عمودی بینشان
- آیکون‌ها: دایره رنگی با SVG گرافیکی داخل (نه heroicon ساده)
- بنفش: grid/شبکه نقطه‌ای
- سبز: فیگور آدم
- نارنجی: ساعت شنی (hourglass)
- قرمز: دایره با X
- عدد bold بزرگ + متن label زیرش (ترتیب: label بالا، عدد پایین — از راست به چپ)
#### Toolbar
```
[+ نوبت جدید] [≡] | [نمایش جدولی] [زمانبندی] | [پرسنل را انتخاب کنید... ▼] | [<] [۱۴۰۳/۰۶/۰۵] [>] [📅]
```
- دکمه "نوبت جدید": آبی تیره، آیکون +، گرد
- دکمه کنار آن: خاکستری border، آیکون ≡ (list/filter icon)
- تب‌های نما: "نمایش جدولی" (active=پس‌زمینه سفید، border) | "زمانبندی" (غیرactive=متن خاکستری)، کنار هم در یک pill container خاکستری کمرنگ
- Dropdown پرسنل: کشیده، placeholder "پرسنل را انتخاب کنید..."، فاصله زیاد بین تب‌ها و date nav
- Date nav: دو فلش `<` `>`، تاریخ شمسی وسط، آیکون تقویم در راست‌ترین موقعیت
#### جدول — ستون‌های هدر دکتر
```
┌─────────────────────┬──────────────────────┐
│ دکتر فتحی │ دکتر امینی فر │ ← سرتیتر گروه‌بندی دکتر
├──────┬──────┬───────┼───────┬──────┬────────┤
│ شروع │ پایان│ سرویس │ شروع │ پایان│ سرویس │ ← سرتیتر ستون‌ها
```
- وقتی چند دکتر: ستون‌های هر دکتر کنار هم، با سرتیتر دکتر spanning
#### Status Badge (inline قابل کلیک)
- شکل: pill (border-radius کامل)
- محتوا: `▼ [متن وضعیت]`
- رنگ‌بندی pill متناسب با وضعیت (همان رنگ‌های تعریف‌شده)
#### Status Dropdown (کلیک روی badge)
```
┌──────────────────────┐
│ ○ ثبت شده │ ← دایره آبی توخالی + متن آبی
│ ○ قطعی شده │ ← دایره سبز توخالی + متن سبز
│ ○ در حال پیگیری │ ← دایره نارنجی توخالی + متن نارنجی
│ ○ سالن │ ← دایره بنفش توخالی + متن بنفش
│ ● ویزیت شده │ ← دایره سبز پر (وضعیت فعلی) + متن سبز
│ ○ لغو شده │ ← دایره قرمز توخالی + متن قرمز
└──────────────────────┘
```
- کارت سفید با shadow
- هر گزینه: دایره رنگی (filled=وضعیت فعلی، outlined=بقیه) + متن رنگی
- با کلیک روی گزینه: PATCH و بسته شدن dropdown
#### دکمه عملیات
- متن "عملیات" + "..." در یک pill خاکستری کمرنگ
- یا فقط "..." به عنوان icon button
#### تقویم Popup (کلیک روی آیکون 📅)
```
┌─────────────────────────────────────┐
│ < تیر ۱۴۰۴ > │
│ شنبه یکشنبه دوشنبه ... جمعه │
│ ۱ ۲ ۳ ۴ ۵ ۶ ۷ │
│ ۸ ۹ ۱۰ ۱۱ [۱۲] ۱۳ ۱۴ │ ← ۱۲ = روز انتخابی (circle آبی)
│ ... │
└─────────────────────────────────────┘
```
- ظاهر: کارت سفید، border کمرنگ، shadow
- header: نام ماه شمسی + سال + دکمه‌های ماه قبل/بعد
- روز هفته: ش | ی | د | س | چ | پ | ج
- روز انتخابی: circle آبی solid
- امروز: circle خاکستری کمرنگ (اگر با انتخابی فرق داشت)
- Popup مانند calendar بسته می‌شود وقتی خارج از آن کلیک شود
#### Pagination
```
< 1 2 3 4 5 ... 20 >
```
- عدد فعال: آبی bold
- دکمه‌های قبل/بعد: فلش `<` `>`