Every number on it was zero. "نوبتهای امروز من" counted appointments where appointments.staff_id matches — a column no booking path fills by default, and which is NULL on every row in the database. "سرویسهای من" read only direct service assignment, so an operator whose whole job comes from a treatment protocol was told they had no services. The landing page of the only role that has one data page said, in effect, that they had nothing to do — while they had two sessions booked that day. Today's work now comes from TreatmentSessionRepository::findTodayForStaff, the same queue rule the sessions page uses, so there is one definition of "my work today" rather than two that disagree. Services are the union of direct assignment and protocol authorisation. The two stat cards are links to the pages they name; a number with no destination made the user hunt the sidebar for a page the card had just mentioned. Each row of the work list opens that session. The avatar moves from its own full-width card into the header — two lines of text were costing a card and pushing the day's work below the fold on mobile. The assigned-appointments table renders only when it has rows. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
15 KiB
Dashboard API
Role-specific dashboard endpoints. Each endpoint requires the corresponding role JWT.
GET /api/v1/dashboard/clinic
Returns stats and today's schedule for the authenticated clinic owner.
Auth: ROLE_CLINIC required
Query params
| Param | Type | Default | Description |
|---|---|---|---|
from |
int (unix) | start of current month | Period start for patient/revenue stats |
to |
int (unix) | now | Period end for patient/revenue stats |
patients_year |
int (Jalali) | current Jalali year | Year of the month drawn by charts.appointments_by_day |
patients_month |
int 1..12 |
current Jalali month | Jalali month drawn by charts.appointments_by_day |
revenue_year |
int (Jalali) | current Jalali year | Jalali year drawn by charts.revenue_by_month |
patients_year / revenue_year are clamped to 1300..1500 and patients_month to 1..12.
Response 200
{
"success": true,
"data": {
"clinic": {
"uuid": "string",
"name": "string",
"is_active": true,
"logo": "string | null"
},
"stats": {
"total_doctors": 5,
"today_appointments": 12,
"this_month_appointments": 87,
"pending_invitations": 2,
"sms_wallet_balance": 50000,
"unique_patients_count": 34,
"total_patients": 210,
"revenue_period_rials": 12500000,
"today_payments_rials": 5225000,
"week_payments_rials": 560000200
},
"charts": {
"revenue_by_day": [
{ "label": "۷ خرداد", "amount_rials": 3200000 }
],
"revenue_by_month": [
{ "label": "فروردین", "amount_rials": 3200000 }
],
"appointments_by_day": [
{ "label": "۱", "count": 9 }
]
},
"charts_period": { "patients_year": 1405, "patients_month": 4, "revenue_year": 1405 },
"period": { "from": 1717200000, "to": 1719792000 },
"today_appointments": [
{
"uuid": "string",
"patient_name": "string | null",
"patient_mobile": "string | null",
"doctor_name": "string",
"service_name": "string | null",
"slot_start": 1700000000,
"slot_end": 1700001800,
"status": "reserved"
}
],
"doctors": [
{
"uuid": "string",
"name": "string",
"today_count": 3
}
]
}
}
Field notes:
sms_wallet_balance— current SMS wallet balance in Rials (0 if wallet not yet created)unique_patients_count— distinct patients with at least one session in thefrom–toperiodtotal_patients— distinct patients ever (no period filter)revenue_period_rials— sum offinal_price_rialsfrom all patient sessions in the periodtoday_payments_rials/week_payments_rials— revenue for today / the last 7 dayscharts.appointments_by_day— «نمودار تعداد بیماران»: one entry per day of the requested Jalali month (28–31 entries), label = Jalali day number in Persian digits, empty days arecount: 0charts.revenue_by_month— «میزان درآمد»: exactly 12 entries, one per Jalali month ofrevenue_year, label = Persian month namecharts.revenue_by_day— last 7 days; kept only as the source oftoday_payments_rials/week_payments_rials, not drawn by the dashboard chartscharts_period— the effective (post-clamp) chart period, so the UI can reflect what was actually renderedtoday_appointments— up to 5 records, ordered byslot_start ASC; each row carriespatient_mobile,doctor_name(personnel),service_name(nullable — booked service item), andslot_endfor the «لیست نوبتهای جدید» dashboard table (that table renders read-only —statusis still returned but no longer shown)doctors— all doctors belonging to this clinic; each includes their appointment count for today
Errors
| Code | HTTP | Description |
|---|---|---|
ERR_NOT_FOUND_001 |
404 | Clinic not found for this user |
GET /api/v1/dashboard/doctor
Returns stats and today's schedule for the authenticated doctor.
Auth: ROLE_DOCTOR required
Query params
| Param | Type | Default | Description |
|---|---|---|---|
from |
int (unix) | start of current month | Period start for patient/revenue stats |
to |
int (unix) | now | Period end for patient/revenue stats |
patients_year |
int (Jalali) | current Jalali year | Year of the month drawn by charts.appointments_by_day |
patients_month |
int 1..12 |
current Jalali month | Jalali month drawn by charts.appointments_by_day |
revenue_year |
int (Jalali) | current Jalali year | Jalali year drawn by charts.revenue_by_month |
Response 200
{
"success": true,
"data": {
"doctor": {
"uuid": "string",
"name": "string",
"degree": "string | null"
},
"stats": {
"today_appointments": 8,
"tomorrow_appointments": 5,
"this_month_appointments": 62,
"avg_rating": 4.6,
"total_ratings": 34,
"sms_wallet_balance": 25000,
"unique_patients_count": 18,
"total_patients": 140,
"revenue_period_rials": 6800000,
"today_payments_rials": 5225000,
"week_payments_rials": 42000000
},
"charts": {
"revenue_by_day": [ { "label": "۷ خرداد", "amount_rials": 3200000 } ],
"revenue_by_month": [ { "label": "فروردین", "amount_rials": 3200000 } ],
"appointments_by_day": [ { "label": "۱", "count": 4 } ]
},
"charts_period": { "patients_year": 1405, "patients_month": 4, "revenue_year": 1405 },
"period": { "from": 1717200000, "to": 1719792000 },
"today_appointments": [
{
"uuid": "string",
"patient_name": "string | null",
"patient_mobile": "string",
"doctor_name": "string",
"service_name": "string | null",
"slot_start": 1700000000,
"slot_end": 1700001800,
"status": "reserved"
}
],
"clinics": [
{
"uuid": "string",
"name": "string",
"logo": "string | null"
}
]
}
}
Field notes:
today_appointments— up to 10 records, ordered byslot_start ASC; each row carriespatient_mobile,doctor_name,service_name(nullable), andslot_endfor the dashboard appointments tableavg_rating— rounded to 1 decimal;nullif no ratings yetclinics— all clinics the doctor belongs tosms_wallet_balance,unique_patients_count,revenue_period_rials— same semantics as clinic dashboardcharts.*,charts_period— same Jalali-period semantics as the clinic dashboard;revenue_by_day/revenue_by_monthare omitted entirely when the caller may not see financials (see the note at the end of this file)
Errors
| Code | HTTP | Description |
|---|---|---|
ERR_NOT_FOUND_001 |
404 | Doctor profile not found for this user |
GET /api/v1/dashboard/secretary
Returns stats for the authenticated secretary and (conditionally) today's appointments.
Auth: ROLE_SECRETARY required
Response 200
پاسخ بر اساس scope محیطِ فعالِ منشی دو شکل دارد. کلید scope تمایزدهنده است: در scope=doctor فیلد doctor هست (نه clinic) و در scope=clinic فیلد clinic (نه doctor). کلاینت باید هر دو را مدیریت کند و بهصورت مستقیم به data.doctor.name دسترسی نگیرد.
منشیِ مطبِ شخصی (scope=doctor):
{
"success": true,
"data": {
"scope": "doctor",
"doctor": { "uuid": "string", "name": "string", "degree": "string | null" },
"permissions": { "resources": { "appointments": { "view": true, "update_status": false } } },
"stats": { "today_appointments": 8, "tomorrow_appointments": 5 },
"today_appointments": [
{ "uuid": "string", "patient_name": "string | null", "patient_mobile": "string", "slot_start": 1700000000, "status": "reserved" }
]
}
}
منشیِ کلینیک (scope=clinic) — بدون کلید doctor:
{
"success": true,
"data": {
"scope": "clinic",
"clinic": { "uuid": "string", "name": "string" },
"permissions": { "resources": { "appointments": { "view": true } } },
"stats": { "today_appointments": 12, "tomorrow_appointments": 7 },
"today_appointments": [
{ "uuid": "string", "patient_name": "string | null", "patient_mobile": "string", "slot_start": 1700000000, "status": "reserved", "doctor_name": "string" }
]
}
}
today_appointments — only populated when permissions.resources.appointments.view === true; up to 10 records (doctor scope) / 20 (clinic scope) when visible. در scope کلینیک هر ردیف doctor_name هم دارد.
Errors
| Code | HTTP | Description |
|---|---|---|
ERR_FORBIDDEN_001 |
403 | Secretary relation not configured or inactive |
GET /api/v1/dashboard/staff
داشبورد پرسنل: کارِ امروزِ اپراتور و سرویسهایی که مجاز به انجامشان است.
کارِ امروز از جلسات درمان میآید، نه از
appointments.staff_id. آن ستون را هیچ مسیرِ نوبتدهی بهصورت پیشفرض پر نمیکند، پسtoday_appointmentsبرای اپراتوری که تمام روز جلسه دارد هم خالی است.today_sessionsهمان صفِTreatmentSessionRepository::findTodayForStaffاست — یک قاعده، نه دو تا. قواعدش در treatment.md.
servicesهم اتحادِ دو منبع است: تخصیصِ مستقیمِ سرویس، و پروتکلِ «طول درمان» که این پرسنل را مجاز دانسته. بدون دومی، اپراتورِ لیزر «هیچ سرویسی ندارید» میدید.
Auth: ROLE_STAFF — و علاوه بر نقش، باید ردیف فعالِ clinic_staff در محیط فعال وجود
داشته باشد. توکن تا انقضا معتبر میماند، پس غیرفعالکردن پرسنل همان لحظه با همین بررسی
دسترسی را میبندد.
این تنها اندپوینت دادهٔ نقش staff است؛ بقیهٔ /api/v1/* برای این نقش ۴۰۳ میدهد
(StaffRouteGuardSubscriber — رجوع به auth.md).
Response 200 (خروجی واقعی)
{
"success": true,
"data": {
"scope": "doctor",
"staff": {
"uuid": "c99320e2-257a-4d96-9b3a-7723fe198e79",
"full_name": "زهرا احمدی",
"job_title": "پرستار"
},
"owner": { "name": "09390039833" },
"permissions": {
"version": 1,
"resources": { "services": { "view": true }, "appointments": { "view": true } }
},
"stats": { "today_sessions": 2, "today_appointments": 0, "services": 1 },
"services": [
{
"uuid": "f9ffb607-f137-4ffb-8327-76427fd6fe55",
"name": "سرم",
"section_name": "تزریقات",
"price_rials": 1000000,
"duration_minutes": null
}
],
"today_sessions": [
{
"uuid": "86300220-c8e3-4928-b5e2-ce54a75cd59b",
"session_number": 1,
"total_sessions": 3,
"status": "in_progress",
"service_name": "لیزر توتال",
"patient_name": "محمد رسولی",
"slot_start": 1786084200
}
],
"today_appointments": []
}
}
| فیلد | نوع | توضیح |
|---|---|---|
scope |
doctor | clinic |
نوع محیطِ فعال |
owner.name |
string | نام مطب/کلینیکِ مالک |
services |
array | سرویسهای فعالِ تخصیصیافته به این پرسنل (service_item_staff و ستون legacy تکی) |
today_appointments |
array | نوبتهای امروز با appointments.staff_id برابر این پرسنل — uuid, patient_name, patient_mobile, slot_start, status |
permissions |
object | ثابت است و ویرایشپذیر نیست |
Errors
| Code | HTTP | Description |
|---|---|---|
ERR_FORBIDDEN_001 |
403 | محیط کاری تنظیم نشده، یا ردیف پرسنل در آن محیط فعال نیست |
ERR_AUTH_001 |
401 | بدون توکن |
خروجی واقعی حالت غیرفعال:
{"success":false,"data":null,"errors":[{"code":"ERR_FORBIDDEN_001","message":"محیط کاری پرسنل تنظیم نشده"}]}
GET /api/v1/admin/dashboard/charts
Returns time-series chart data for admin dashboard. All series are filtered to the given from–to window.
Auth: ROLE_ADMIN required
Query params
| Param | Type | Default | Description |
|---|---|---|---|
from |
int (unix) | 30 days ago | Period start |
to |
int (unix) | now | Period end |
Response 200
{
"success": true,
"data": {
"appointments_by_day": [
{ "date": "06/01", "count": 12 }
],
"revenue_by_day": [
{ "date": "06/01", "amount": 3500000 }
],
"appointment_status": [
{ "status": "confirmed", "count": 320 }
],
"top_specialties": [
{ "name": "قلب و عروق", "count": 85 }
],
"subscription_sales_by_plan": [
{ "plan": "basic", "count": 14, "revenue": 4060000 }
],
"period": { "from": 1717200000, "to": 1719792000 }
}
}
Field notes:
appointments_by_day/revenue_by_day— one entry per calendar day in the period; days with no data appear ascount: 0/amount: 0appointment_status— all-time counts, not filtered by periodtop_specialties— top 8 by appointment volume, all-timesubscription_sales_by_plan— subscriptions created in period, grouped by plan;revenuesums only payments with statusreceived
Doctor dashboard is context-scoped (2026-07)
GET /api/v1/dashboard/doctor now accepts an optional clinic_uuid. When absent it falls back
to the caller's stored active context (user_active_context), then to their role.
In a clinic context the response is restricted to that clinic:
- appointment counts and
today_appointmentsonly include appointments whoseaddress_idbelongs to that clinic; - the financial fields are omitted entirely —
revenue_period_rials,today_payments_rials,week_payments_rials,sms_wallet_balance,unique_patients_count,total_patients, andcharts.revenue_by_day, andcharts.revenue_by_month. They describe the doctor's personal practice and have no meaning inside someone else's clinic. They return only in the personal context, or for the clinic's own owner holdingpayments.view; clinicscomes back as[]— the "کلینیکهای من" list belongs to the personal dashboard.
A clinic_uuid the caller has no access to is ignored and the personal context is used.
New response field:
"context": { "type": "personal" | "clinic", "clinic_uuid": "…|null", "clinic_name": "…|null" }
The admin SPA dispatches on this: primaryRole === 'doctor' && context.scope === 'clinic' renders
InvitedDoctorDashboard (appointment tiles + today's list only) instead of the full doctor
dashboard. Hiding the cards client-side was not enough — the endpoint is directly callable.