Files
hamedandClaude Opus 5 5b3e80f92a fix(dashboard): make the staff dashboard show the operator's actual work
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>
2026-08-07 14:16:00 +03:30

15 KiB
Raw Permalink Blame History

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 the fromto period
  • total_patients — distinct patients ever (no period filter)
  • revenue_period_rials — sum of final_price_rials from all patient sessions in the period
  • today_payments_rials / week_payments_rials — revenue for today / the last 7 days
  • charts.appointments_by_day — «نمودار تعداد بیماران»: one entry per day of the requested Jalali month (2831 entries), label = Jalali day number in Persian digits, empty days are count: 0
  • charts.revenue_by_month — «میزان درآمد»: exactly 12 entries, one per Jalali month of revenue_year, label = Persian month name
  • charts.revenue_by_day — last 7 days; kept only as the source of today_payments_rials / week_payments_rials, not drawn by the dashboard charts
  • charts_period — the effective (post-clamp) chart period, so the UI can reflect what was actually rendered
  • today_appointments — up to 5 records, ordered by slot_start ASC; each row carries patient_mobile, doctor_name (personnel), service_name (nullable — booked service item), and slot_end for the «لیست نوبت‌های جدید» dashboard table (that table renders read-only — status is 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 by slot_start ASC; each row carries patient_mobile, doctor_name, service_name (nullable), and slot_end for the dashboard appointments table
  • avg_rating — rounded to 1 decimal; null if no ratings yet
  • clinics — all clinics the doctor belongs to
  • sms_wallet_balance, unique_patients_count, revenue_period_rials — same semantics as clinic dashboard
  • charts.*, charts_period — same Jalali-period semantics as the clinic dashboard; revenue_by_day / revenue_by_month are 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 fromto 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 as count: 0 / amount: 0
  • appointment_status — all-time counts, not filtered by period
  • top_specialties — top 8 by appointment volume, all-time
  • subscription_sales_by_plan — subscriptions created in period, grouped by plan; revenue sums only payments with status received

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_appointments only include appointments whose address_id belongs to that clinic;
  • the financial fields are omitted entirelyrevenue_period_rials, today_payments_rials, week_payments_rials, sms_wallet_balance, unique_patients_count, total_patients, and charts.revenue_by_day, and charts.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 holding payments.view;
  • clinics comes 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.