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

405 lines
15 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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`
```json
{
"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 `from``to` 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`
```json
{
"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`):**
```json
{
"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`:**
```json
{
"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](treatment.md#جلسات-امروز-من-یک-صف-است).
>
> `services` هم اتحادِ دو منبع است: تخصیصِ مستقیمِ سرویس، و پروتکلِ «طول درمان» که این
> پرسنل را مجاز دانسته. بدون دومی، اپراتورِ لیزر «هیچ سرویسی ندارید» می‌دید.
**Auth:** `ROLE_STAFF` — و علاوه بر نقش، باید ردیف **فعالِ** `clinic_staff` در محیط فعال وجود
داشته باشد. توکن تا انقضا معتبر می‌ماند، پس غیرفعال‌کردن پرسنل همان لحظه با همین بررسی
دسترسی را می‌بندد.
این تنها اندپوینت دادهٔ نقش `staff` است؛ بقیهٔ `/api/v1/*` برای این نقش ۴۰۳ می‌دهد
(`StaffRouteGuardSubscriber` — رجوع به [auth.md](auth.md)).
### Response `200` (خروجی واقعی)
```json
{
"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 | بدون توکن |
خروجی واقعی حالت غیرفعال:
```json
{"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`
```json
{
"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 entirely** — `revenue_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:
```json
"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.