- Implemented SidebarStaff component tests to ensure staff users see only their dashboard and services. - Created StaffMyServicesPage to display assigned services for staff users. - Added migration to link clinic staff rows to user accounts for ROLE_STAFF access. - Defined StaffPermissions class for static permissions related to staff role. - Introduced StaffRouteGuardSubscriber to restrict API access for staff users. - Developed StaffAccountService for managing staff user accounts and linking them to clinic staff. - Added comprehensive tests for StaffAccountService to validate user creation, mobile number handling, and account attachment. - Implemented tests for staff dashboard access to ensure proper permissions and access control. - Created tests for staff login context to verify correct environment visibility based on user roles.
385 lines
14 KiB
Markdown
385 lines
14 KiB
Markdown
# 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 (28–31 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
|
||
|
||
داشبورد پرسنل: سرویسهایی که به این پرسنل تخصیص یافته و نوبتهای امروزِ خودش.
|
||
|
||
**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_appointments": 0, "services": 1 },
|
||
"services": [
|
||
{
|
||
"uuid": "f9ffb607-f137-4ffb-8327-76427fd6fe55",
|
||
"name": "سرم",
|
||
"section_name": "تزریقات",
|
||
"price_rials": 1000000,
|
||
"duration_minutes": null
|
||
}
|
||
],
|
||
"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.
|