24 KiB
بازطراحی کامل صفحه نوبتها (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|expiredconfirmed→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های پر را هم برگرداند با اطلاعات نوبت:
{
"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:
#[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 موازی میزند:
GET /api/v1/appointment-slots?doctor_uuid=...&date=...→ لیست slotهای خالی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: ثبت نوبت از نمای زمانبندی
پیششرط: دکتر باید برنامه هفتگی داشته باشد
جریان بررسی:
- وقتی دکتری انتخاب میشود، ابتدا
GET /api/v1/appointment-settings/weekly-schedule/{doctor_uuid}فراخوانی شود - اگر
404برگشت → نوار هشدار نمایش داده شود:⚠️ دکتر [نام] هنوز برنامه هفتگی تنظیم نکرده است. [تنظیم برنامه] ← لینک به صفحه تنظیمات دکتر - اگر schedule موجود بود → نوبتها بر اساس آن محاسبه شوند
Slot Format خروجی SlotCalculatorService:
{
"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
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
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)
اولویتبندی:
- Holiday (تعطیلات) → هیچ slotای نیست
- Date Override با
active: false→ هیچ slotای نیست - Date Override با
active: true→ ازcustom_slotsآن روز استفاده شود - Weekly Schedule → از sessionهای روز هفته مربوطه (ایندکس: شنبه=0 ... جمعه=6)
خروجی slot بعد از محاسبه و حذف slotهای رزروشده:
{ "start": 1718438400, "end": 1718439600, "start_time": "09:00", "end_time": "09:20", "location_id": 1973 }
این endpoint فقط slotهای خالی برمیگرداند (booked slots حذف شدهاند).
نکات پیادهسازی
- Date-based: queryKey شامل
selectedDate— پیشفرض امروز (Gregorian) - Doctor state: یک state مشترک
selectedDoctorUuidبرای هر دو نما - Schedule view — دو query موازی:
useQuery(['slots', doctorUuid, date])→ slotهای خالی از/api/v1/appointment-slotsuseQuery(['appointments', date, doctorUuid])→ نوبتهای پر از/api/v1/admin/appointments- Merge در frontend: آرایه کامل با
is_availableflag
- Slot کلیک:
onSlotClick(slot)→setBookingSlot(slot)→ mini modal با فقط یک فیلد موبایل - Optimistic lock: PATCH status باید
versionرا از نوبت بگیرد - Status transitions: فقط انتقالهای مجاز از وضعیت فعلی در dropdown نشان داده شود
- بررسی schedule: اگر دکتر schedule نداشت هشدار نشان بده + لینک تنظیمات دکتر
- محور زمان RTL: CSS grid دو ستونه — کارتها چپ، محور زمان راست
- Reload نکن: بعد از هر عملیات فقط
queryClient.invalidateQueries - وضعیتهای درست:
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
- دکمههای قبل/بعد: فلش
<>