Files
clinicpro/.claude/prompt/multi-role-dashboard.md
T
hamed e7b90a6399 feat(api): add dashboard endpoints for clinic, doctor, and secretary roles
- Implemented GET /api/v1/dashboard/clinic to return clinic stats and today's schedule for clinic owners.
- Implemented GET /api/v1/dashboard/doctor to return doctor's stats and today's schedule for doctors.
- Implemented GET /api/v1/dashboard/secretary to return stats and conditional appointments for secretaries.

feat(migrations): create user_active_context and mobile_verification_otp tables

- Added migration to create user_active_context table for tracking active user sessions.
- Added migration to create mobile_verification_otp table for handling mobile number verification.

feat(migrations): create site_config table for application settings

- Added migration to create site_config table to store various site configuration settings.

feat(appointments): create MyAppointmentsController for user-specific appointments

- Added MyAppointmentsController to handle fetching user-specific appointments with pagination and filtering.

feat(auth): implement NotificationMobileController for mobile number verification

- Added NotificationMobileController to handle OTP requests and verification for mobile number changes.

feat(auth): create MobileVerificationOtp entity for OTP management

- Created MobileVerificationOtp entity to manage OTP records for mobile verification.

feat(auth): create UserActiveContext entity for user session management

- Created UserActiveContext entity to manage user active sessions.

feat(config): implement SiteConfigController for managing site settings

- Added SiteConfigController to handle fetching and updating site configuration settings.

feat(config): create SiteConfig entity and repository for configuration management

- Created SiteConfig entity and repository to manage site configuration data.
2026-06-11 12:20:12 +03:30

46 KiB
Raw Blame History

پرامپت: داشبورد چند-نقشه (Multi-Role Dashboard)

هدف کلی

پنل /admin/ باید علاوه بر ادمین، برای نقش‌های زیر نیز کار کند — هر نقش فقط بخش‌هایی می‌بیند که به آن دسترسی دارد:

نقش نام فارسی ROLE در Symfony
ادمین سیستم مدیر کل ROLE_ADMIN
صاحب کلینیک مالک کلینیک ROLE_CLINIC
دکتر عضو کلینیک پزشک ROLE_DOCTOR
منشی منشی ROLE_SECRETARY

وضعیت فعلی API (مهم — قبل از پیاده‌سازی بخوان)

API های موجود (استفاده کن، تغییر نده)

Endpoint داکیومنت توضیح
GET /oauth/userinfo auth.md اطلاعات کاربر — باید primary_role و context به آن اضافه شود
GET /api/v1/admin/dashboard/stats admin.md KPI های داشبورد ادمین
GET /api/v1/admin/dashboard/charts admin.md نمودار ۳۰ روزه ادمین
GET /api/v1/admin/dashboard/recent admin.md آخرین فعالیت‌ها برای ادمین
GET /api/v1/admin/appointments admin.md لیست همه نوبت‌ها — فقط ROLE_ADMIN
GET /api/v1/appointments/doctor/{doctorUuid} appointment.md نوبت‌های یک دکتر خاص
GET /api/v1/appointments/user appointment.md نوبت‌های کاربر جاری
GET /api/v1/clinics/{uuid} clinic.md جزئیات کلینیک
GET /api/v1/admin/clinics admin.md لیست کلینیک‌ها — فقط ROLE_ADMIN

تغییر روی API موجود

Endpoint فایل توضیح
GET /oauth/userinfo src/Auth/Controller/AuthController.php اضافه کردن primary_role و context به پاسخ موجود

API های جدید که باید ساخته شوند

Endpoint فایل کنترلر توضیح
POST /api/v1/auth/switch-context src/Auth/Controller/AuthController.php تغییر context فعال (انتخاب محیط کاری)
GET /api/v1/dashboard/clinic src/Dashboard/Controller/DashboardController.php داشبورد صاحب کلینیک
GET /api/v1/dashboard/doctor همان داشبورد دکتر
GET /api/v1/dashboard/secretary همان داشبورد منشی
GET /api/v1/my/appointments src/Appointment/Controller/MyAppointmentsController.php نوبت‌های فیلترشده بر اساس نقش

بعد از ساخت هر API جدید، فایل مستندات متناظر در docs/api/ را نیز به‌روز کن.


مفاهیم کلیدی معماری Multi-Context (مهم — قبل از پیاده‌سازی بخوان)

این سیستم از معماری Multi-Tenant / Multi-Context استفاده می‌کند. یعنی یک کاربر می‌تواند در چند محیط مختلف فعالیت کند و باید هنگام login محیط کاری خود را انتخاب کند.

مفهوم db_uuid

db_uuid برابر UUID موجودیت فعال فعلی است — نه UUID کاربر:

وضعیت مقدار db_uuid
دکتر مستقل (مطب شخصی) UUID خود دکتر
صاحب کلینیک UUID کلینیک
دکتر فعال در یک کلینیک UUID آن کلینیک
منشی یک دکتر UUID دکتر
منشی یک کلینیک UUID کلینیک

مفهوم db_key

مقدار db_key یک hash امنیتی است:

db_key = HMAC-SHA256(db_uuid, APP_SECRET)

هر بار که context فعال تغییر کند، باید db_key جدید تولید شود.

مفهوم available_contexts

آرایه‌ای از همه محیط‌های کاری که کاربر می‌تواند در آن‌ها فعالیت کند:

"available_contexts": [
  {
    "type": "doctor",
    "db_uuid": "doctor-uuid-...",
    "name": "مطب شخصی دکتر احمدی",
    "role": "doctor"
  },
  {
    "type": "clinic",
    "db_uuid": "clinic-uuid-1",
    "name": "کلینیک سلامت",
    "role": "doctor"
  },
  {
    "type": "clinic",
    "db_uuid": "clinic-uuid-2",
    "name": "کلینیک آریا",
    "role": "secretary",
    "permissions": { ... }
  }
]

سناریوهای چند-context

دکتر چند-کلینیکی (Multi-Clinic Doctor):

  • دکتری که هم مطب شخصی دارد هم در چند کلینیک فعالیت می‌کند
  • available_contexts شامل: مطب شخصی + هر کلینیکی که عضو است
  • هر context مستقل: نوبت‌ها، تنظیمات و برنامه هفتگی جداگانه

منشی چند-کلینیکی (Multi-Clinic Secretary):

  • منشی که هم برای دکتر A و هم کلینیک B کار می‌کند
  • available_contexts شامل همه روابط فعال DoctorSecretary آن منشی

قانون انتخاب context:

  • اگر یک context: سیستم به‌صورت خودکار همان را فعال می‌کند
  • اگر بیش از یک context: بعد از login، صفحه انتخاب محیط کاری نمایش داده می‌شود
  • پس از انتخاب، db_uuid و db_key و context بر اساس انتخاب کاربر به‌روز می‌شوند

وضعیت فعلی کد (مهم — قبل از تغییر بخوان)

بک‌اند

  • کلاس AdminApiController با #[IsGranted('ROLE_ADMIN')] روی کل کلاس — تمام endpoint های داشبورد فعلی فقط برای ادمین
  • موجودیت‌ها:
    • User: فیلد roles: array — مقادیر ممکن: ROLE_USER, ROLE_ADMIN, ROLE_DOCTOR, ROLE_CLINIC, ROLE_SECRETARY
    • Clinic: فیلد user (ManyToOne به User) — صاحب کلینیک. رابطه ManyToMany با Doctor از طریق جدول clinic_doctors
    • Doctor: فیلد user (OneToOne به User). دارای mobileNumber و رابطه با Specialty. متد findByUser(User) در DoctorRepository موجود است.
    • DoctorSecretary: فیلد doctor (ManyToOne)، secretary (ManyToOne به User)، permissions (JSON):
      { version:1, resources: {
          appointments: { view, create, cancel, update_status },
          addresses:    { view, create, update, delete },
          clinic_info:  { view, update },
          insurances:   { view, create, update, delete }
      }}
      
  • ClinicRepository::findByUser(User $user) موجود است.
  • DoctorRepository::findByUser(User $user) موجود است.
  • JWT: از LexikJWTBundle — payload شامل: username (mobile_number)، roles (آرایه)، iat، exp

فرانت‌اند

  • authStore.ts (Zustand + persist در clinicpro-auth): فقط token، refreshToken، isAuthenticated
  • Sidebar.tsx: لیست ثابت — بدون هیچ فیلتر نقشی
  • App.tsx: همه routes با PrivateRoute (فقط isAuthenticated بررسی می‌شود)
  • DashboardPage.tsx: سه query به /api/v1/admin/dashboard/* + نمودار Recharts + mini lists

مرحله ۱ — گسترش /oauth/userinfo (بک‌اند)

فایل: src/Auth/Controller/AuthController.php

endpoint موجود /oauth/userinfo را گسترش بده — همان route، همان permission، فقط دو فیلد جدید به پاسخ اضافه می‌شود.

پاسخ فعلی:

{
  "success": true,
  "data": {
    "id": 4766,
    "uuid": "...",
    "mobile_number": "09...",
    "realName": "دکتر وحید درویشی",
    "status": 1,
    "roles": ["ROLE_USER", "ROLE_DOCTOR"]
  }
}

پاسخ بعد از تغییر (فیلدهای جدید اضافه):

{
  "success": true,
  "data": {
    "id": 4766,
    "uuid": "...",
    "mobile_number": "09...",
    "realName": "دکتر وحید درویشی",
    "status": 1,
    "roles": ["ROLE_USER", "ROLE_DOCTOR"],
    "primary_role": "doctor",
    "db_uuid": "clinic-uuid-currently-active",
    "db_key": "hmac-sha256-hash...",
    "context": {
      "type": "clinic",
      "db_uuid": "clinic-uuid-currently-active",
      "name": "کلینیک سلامت",
      "role": "doctor"
    },
    "available_contexts": [
      {
        "type": "doctor",
        "db_uuid": "doctor-uuid-...",
        "name": "مطب شخصی دکتر وحید درویشی",
        "role": "doctor"
      },
      {
        "type": "clinic",
        "db_uuid": "clinic-uuid-currently-active",
        "name": "کلینیک سلامت",
        "role": "doctor"
      }
    ]
  }
}

اگر کاربر تنها یک context دارد، available_contexts آرایه‌ای با یک عنصر است و db_uuid همان را نشان می‌دهد. اگر context فعال هنوز انتخاب نشده (اولین login چند-context)، db_uuid و db_key باید null باشند — frontend صفحه انتخاب محیط کاری را نشان می‌دهد.

قانون primary_role (اولویت‌بندی):

  • ROLE_ADMIN"admin"
  • ROLE_CLINIC"clinic"
  • ROLE_DOCTOR"doctor"
  • ROLE_SECRETARY"secretary"
  • بقیه → "user"

ساختن available_contexts در بک‌اند:

برای هر کاربر، همه context های ممکن را جمع می‌کنیم:

$contexts = [];

// اگر دکتر است: مطب شخصی خودش
if ($doctor = $this->doctorRepo->findByUser($user)) {
    $contexts[] = [
        'type'    => 'doctor',
        'db_uuid' => $doctor->getUuid(),
        'name'    => 'مطب شخصی ' . $doctor->getName(),
        'role'    => 'doctor',
    ];
    // کلینیک‌هایی که عضو است
    foreach ($doctor->getClinics() as $clinic) {
        $contexts[] = [
            'type'    => 'clinic',
            'db_uuid' => $clinic->getUuid(),
            'name'    => $clinic->getName(),
            'role'    => 'doctor',
        ];
    }
}

// اگر صاحب کلینیک است
if ($clinic = $this->clinicRepo->findByUser($user)) {
    // اگر قبلاً از طریق عضویت دکتری اضافه نشده
    $alreadyAdded = array_filter($contexts, fn($c) => $c['db_uuid'] === $clinic->getUuid());
    if (empty($alreadyAdded)) {
        $contexts[] = [
            'type'    => 'clinic',
            'db_uuid' => $clinic->getUuid(),
            'name'    => $clinic->getName(),
            'role'    => 'clinic',
        ];
    }
}

// اگر منشی است: همه روابط فعال DoctorSecretary
if ($user->hasRole('ROLE_SECRETARY')) {
    $secretaryRelations = $this->doctorSecretaryRepo->findAllActiveBySecretary($user);
    foreach ($secretaryRelations as $rel) {
        $contexts[] = [
            'type'        => 'doctor',
            'db_uuid'     => $rel->getDoctor()->getUuid(),
            'name'        => 'مطب ' . $rel->getDoctor()->getName(),
            'role'        => 'secretary',
            'permissions' => $rel->getPermissions(),
        ];
    }
}

تولید db_uuid و db_key در بک‌اند:

  • db_uuid: از session/cookie/JWT claim ذخیره‌شده — مقدار آخرین context انتخاب‌شده توسط کاربر
  • اگر context هنوز انتخاب نشده (یا یک context وجود دارد): اولین آیتم available_contexts را به‌صورت خودکار فعال کن
  • db_key = hash_hmac('sha256', $dbUuid, $this->getParameter('app.secret'))

ذخیره context فعال: چون JWT بی‌حالت است، context انتخاب‌شده باید در user session جدا یا درون JWT ذخیره شود. ساده‌ترین روش: یک جدول user_active_context با فیلدهای user_id، db_uuid، updated_at. /oauth/userinfo از این جدول می‌خواند؛ /api/v1/auth/switch-context آن را به‌روز می‌کند.

متدهای جدید در Repository ها:

// DoctorSecretaryRepository
public function findAllActiveBySecretary(User $user): array
{
    return $this->findBy(['secretary' => $user, 'active' => true]);
}

// متد قبلی همچنان نگه داشته شود:
public function findActiveBySecretary(User $user): ?DoctorSecretary
{
    return $this->findOneBy(['secretary' => $user, 'active' => true]);
}

داکیومنت: بعد از پیاده‌سازی، پاسخ /oauth/userinfo را در docs/api/auth.md به‌روز کن.


مرحله ۱.۵ — switch-context API (بک‌اند) — API جدید

فایل: src/Auth/Controller/AuthController.php

POST /api/v1/auth/switch-context [IS_AUTHENTICATED_FULLY]

کاربر یک db_uuid از لیست available_contexts خود انتخاب می‌کند.

Request body:

{ "db_uuid": "clinic-uuid-..." }

پیاده‌سازی:

  1. available_contexts کاربر را محاسبه کن (همان منطق مرحله ۱)
  2. بررسی کن آیا db_uuid ارسال‌شده در لیست available_contexts کاربر هست — اگر نه: خطای 403
  3. در جدول user_active_context مقدار db_uuid را ذخیره/به‌روز کن
  4. db_key جدید را محاسبه کن: hash_hmac('sha256', $dbUuid, $appSecret)
  5. context فعال را بر اساس db_uuid انتخاب‌شده بساز

Response 200:

{
  "success": true,
  "data": {
    "db_uuid": "clinic-uuid-...",
    "db_key": "new-hmac-hash...",
    "context": {
      "type": "clinic",
      "db_uuid": "clinic-uuid-...",
      "name": "کلینیک سلامت",
      "role": "doctor"
    }
  }
}

Errors:

Code HTTP توضیح
ERR_AUTH_001 401 توکن وجود ندارد
ERR_AUTH_006 403 db_uuid در لیست context های این کاربر نیست
ERR_VALIDATION_001 422 db_uuid ارسال نشده

موجودیت جدید مورد نیازsrc/Auth/Entity/UserActiveContext.php:

#[ORM\Entity]
#[ORM\Table(name: 'user_active_context')]
class UserActiveContext {
    #[ORM\Id]
    #[ORM\OneToOne(targetEntity: User::class)]
    #[ORM\JoinColumn(name: 'user_id', onDelete: 'CASCADE')]
    private User $user;

    #[ORM\Column(name: 'db_uuid', type: 'string', length: 36)]
    private string $dbUuid;

    #[ORM\Column(name: 'updated_at', type: 'integer')]
    private int $updatedAt;
}

Migration: بعد از ساخت entity، doctrine:migrations:diff و migrate اجرا کن.

داکیومنت: بعد از پیاده‌سازی، این endpoint را به docs/api/auth.md اضافه کن.


مرحله ۲ — به‌روز کردن authStore.ts (فرانت‌اند)

// assets/admin/stores/authStore.ts

interface ContextItem {
  type: 'doctor' | 'clinic';
  db_uuid: string;
  name: string;
  role: 'admin' | 'clinic' | 'doctor' | 'secretary';
  permissions?: Record<string, any>;
}

interface AuthState {
  token: string | null;
  refreshToken: string | null;
  isAuthenticated: boolean;
  // فیلدهای جدید:
  userUuid: string | null;
  userName: string | null;
  primaryRole: 'admin' | 'clinic' | 'doctor' | 'secretary' | 'user' | null;
  dbUuid: string | null;           // UUID موجودیت فعال
  dbKey: string | null;            // hash امنیتی context فعال
  context: ContextItem | null;     // context فعال انتخاب‌شده
  availableContexts: ContextItem[]; // همه context های قابل انتخاب
}
  • متد login(token, refreshToken): بعد از ذخیره token، یک GET /oauth/userinfo بزند و نتیجه را ذخیره کند
  • متد logout(): همه فیلدها را پاک کند
  • متد جدید fetchMe(): GET /oauth/userinfo و update store — در App.tsx هنگام mount فراخوانی شود (اگر token موجود بود اما primaryRole خالی بود، تا بعد از reload صفحه role بازیابی شود)
  • متد جدید switchContext(dbUuid: string): POST /api/v1/auth/switch-context و update dbUuid، dbKey، context در store

مرحله ۲.۵ — صفحه انتخاب محیط کاری (فرانت‌اند) — جدید

فایل جدید: assets/admin/pages/SelectContextPage.tsx

این صفحه فقط هنگامی نمایش داده می‌شود که کاربر بیش از یک context دارد.

شرط نمایش (در App.tsx):

// بعد از fetchMe، قبل از route های اصلی:
if (isAuthenticated && availableContexts.length > 1 && !dbUuid) {
  return <Navigate to="/admin/select-context" replace />;
}

UI صفحه:

export default function SelectContextPage() {
  const { availableContexts, switchContext } = useAuthStore();
  const navigate = useNavigate();

  const handleSelect = async (dbUuid: string) => {
    await switchContext(dbUuid);   // POST /api/v1/auth/switch-context
    navigate('/admin/dashboard', { replace: true });
  };

  return (
    <div className="select-context-page">
      <h2>محیط کاری خود را انتخاب کنید</h2>
      <div className="context-list">
        {availableContexts.map(ctx => (
          <button
            key={ctx.db_uuid}
            className="context-card"
            onClick={() => handleSelect(ctx.db_uuid)}
          >
            <span className="context-type-badge">{ctx.role}</span>
            <span className="context-name">{ctx.name}</span>
          </button>
        ))}
      </div>
    </div>
  );
}
  • هر کارت: نام محیط کاری + نقش (مطب شخصی / کلینیک / منشی)
  • بعد از انتخاب: switchContext را صدا می‌زند → store به‌روز می‌شود → redirect به dashboard
  • دکمه تغییر محیط کاری در Sidebar هم باید موجود باشد (کلیک → /admin/select-context)

مرحله ۳ — محافظت route ها (فرانت‌اند)

در App.tsx، کامپوننت RoleRoute اضافه کن:

function RoleRoute({ roles, children }: { roles: string[]; children: ReactNode }) {
  const primaryRole = useAuthStore(s => s.primaryRole);
  if (!primaryRole) return <div style={{padding:40, textAlign:'center'}}>در حال بارگذاری...</div>;
  if (!roles.includes(primaryRole)) return <Navigate to="/admin/dashboard" replace />;
  return <>{children}</>;
}

Route های فقط ادمین (با <RoleRoute roles={['admin']}> بپوشان):

  • /admin/users, /admin/users/:uuid
  • /admin/payments
  • /admin/settlements
  • /admin/representations, /admin/representations/:uuid
  • /admin/comments
  • /admin/ratings
  • /admin/sms
  • /admin/categories
  • /admin/blogs, /admin/blogs/new, /admin/blogs/:uuid/edit
  • /admin/secretaries
  • /admin/clinics (لیست کل کلینیک‌ها)

Route های admin + clinic:

  • /admin/clinics/:uuid — ادمین همه را می‌بیند، clinic فقط کلینیک خودش را
  • /admin/doctors — ادمین همه، clinic فقط پزشکان کلینیکش

Route های مشترک همه نقش‌ها:

  • /admin/dashboard
  • /admin/appointments, /admin/appointments/:uuid

Route های جدید:

  • /admin/my-clinic<MyClinicPage /> — فقط ROLE_CLINIC

مرحله ۴ — Sidebar پویا (فرانت‌اند)

فایل assets/admin/components/layout/Sidebar.tsx

ساختار sections را به یک تابع تبدیل کن که primaryRole و context می‌گیرد:

function buildSections(
  primaryRole: string | null,
  context: Record<string, any> | null
): Section[]

ادمین — همه لینک‌های فعلی (بدون تغییر)

صاحب کلینیک (clinic):

عمومی:
  • داشبورد         /admin/dashboard
  • کلینیک من       /admin/my-clinic
  • پزشکان          /admin/doctors

مدیریت:
  • نوبت‌ها          /admin/appointments

دکتر (doctor):

عمومی:
  • داشبورد         /admin/dashboard

مدیریت:
  • نوبت‌های من     /admin/appointments

منشی (secretary) — بر اساس context.permissions.resources:

عمومی:
  • داشبورد         /admin/dashboard

مدیریت (شرطی):
  • نوبت‌ها          /admin/appointments    ← اگر appointments.view = true

در Sidebar، permissions را از useAuthStore(s => s.context) بخوان.


مرحله ۵ — endpoint های داشبورد جدید (بک‌اند) — API های جدید

فایل جدید: src/Dashboard/Controller/DashboardController.php

سه endpoint جداگانه — هر سه از BaseController extend می‌کنند. پاسخ با $this->success($data) — یعنی فرانت با data?.data می‌خواند.


GET /api/v1/dashboard/clinic [ROLE_CLINIC]

پاسخ:

{
  "success": true,
  "data": {
    "clinic": { "uuid":"...", "name":"...", "is_active": true, "logo":"..." },
    "stats": {
      "total_doctors": 5,
      "today_appointments": 12,
      "this_month_appointments": 87,
      "pending_invitations": 2
    },
    "today_appointments": [
      { "uuid":"...", "patient_name":"...", "doctor_name":"...", "slot_start": 1234567890, "status":"reserved" }
    ],
    "doctors": [
      { "uuid":"...", "name":"دکتر ...", "specialty":"...", "today_count": 3 }
    ]
  }
}

پیاده‌سازی:

  • کلینیک را از ClinicRepository::findByUser($user) بگیر
  • اگر نبود: return $this->error('ERR_NOT_FOUND_001', 'کلینیک یافت نشد', 404)
  • today_appointments و this_month_appointments: از جدول appointments با JOIN به clinic_doctors فیلتر کن
  • pending_invitations: از clinic_doctor_invitations با status='pending' بشمار
  • today_appointments لیست: ۵ نوبت اخیر امروز این کلینیک (از طریق JOIN clinic_doctors)
  • doctors: لیست پزشکان کلینیک با شمارش نوبت امروز آن‌ها
  • از DQL array hydration استفاده کن (.getArrayResult())

GET /api/v1/dashboard/doctor [ROLE_DOCTOR]

پاسخ:

{
  "success": true,
  "data": {
    "doctor": { "uuid":"...", "name":"...", "degree":"..." },
    "stats": {
      "today_appointments": 5,
      "tomorrow_appointments": 3,
      "this_month_appointments": 42,
      "avg_rating": 4.7,
      "total_ratings": 18
    },
    "today_appointments": [
      { "uuid":"...", "patient_name":"...", "patient_mobile":"...", "slot_start": 1234567890, "status":"reserved" }
    ],
    "clinics": [
      { "uuid":"...", "name":"کلینیک ...", "logo":"..." }
    ]
  }
}

پیاده‌سازی:

  • دکتر از DoctorRepository::findByUser($user) — اگر نبود return $this->error(..., 404)
  • today_appointments: نوبت‌های این دکتر با slot_start در بازه ابتدا تا انتهای امروز
  • tomorrow_appointments: همان برای فردا
  • avg_rating: AVG(overall) از جدول ratings برای این دکتر — با DQL
  • clinics: کلینیک‌هایی که این دکتر در clinic_doctors آن‌هاست
  • از DQL array hydration استفاده کن

GET /api/v1/dashboard/secretary [ROLE_SECRETARY]

پاسخ:

{
  "success": true,
  "data": {
    "doctor": { "uuid":"...", "name":"...", "degree":"..." },
    "permissions": { "version": 1, "resources": { ... } },
    "stats": {
      "today_appointments": 4,
      "tomorrow_appointments": 2
    },
    "today_appointments": [
      { "uuid":"...", "patient_name":"...", "patient_mobile":"...", "slot_start": 1234567890, "status":"reserved" }
    ]
  }
}

پیاده‌سازی:

  • از DoctorSecretaryRepository::findActiveBySecretary($user) اولین رابطه فعال بگیر
  • اگر نبود: return $this->error('ERR_FORBIDDEN_001', 'دسترسی منشی تنظیم نشده', 403)
  • بررسی appointments.view = true در permissions — اگر false بود، today_appointments آرایه خالی برگردان
  • نوبت‌های دکتر مربوطه را برگردان

داکیومنت: بعد از پیاده‌سازی، فایل docs/api/dashboard.md جدید بساز.


مرحله ۶ — DashboardPage.tsx چند-نقشه (فرانت‌اند)

فایل assets/admin/pages/DashboardPage.tsx را به این شکل بازنویسی کن:

export default function DashboardPage() {
  const primaryRole = useAuthStore(s => s.primaryRole);

  if (!primaryRole) return <LoadingSkeleton />;
  if (primaryRole === 'admin')     return <AdminDashboard />;
  if (primaryRole === 'clinic')    return <ClinicDashboard />;
  if (primaryRole === 'doctor')    return <DoctorDashboard />;
  if (primaryRole === 'secretary') return <SecretaryDashboard />;
  return <div className="card card-pad"><p className="muted">نقش شما برای داشبورد تعریف نشده</p></div>;
}

AdminDashboard: کد فعلی DashboardPage عیناً — فقط در یک تابع بپیچ. از endpointهای موجود /api/v1/admin/dashboard/* استفاده می‌کند.

ClinicDashboard:

  • یک query به GET /api/v1/dashboard/clinic (جدید)
  • استخراج: data?.data (چون $this->success() یک‌بار nest می‌کند)
  • ۴ کارت KPI: تعداد پزشکان / نوبت امروز / نوبت این ماه / دعوتنامه در انتظار
  • جدول نوبت‌های امروز (ستون: بیمار، پزشک، زمان، وضعیت)
  • لیست پزشکان با تعداد نوبت امروز
  • دکمه "مدیریت کلینیک" → navigate به /admin/my-clinic

DoctorDashboard:

  • یک query به GET /api/v1/dashboard/doctor (جدید)
  • استخراج: data?.data
  • ۴ کارت KPI: نوبت امروز / فردا / این ماه / میانگین امتیاز (با ستاره)
  • جدول نوبت‌های امروز (ستون: بیمار، موبایل dir="ltr", زمان، وضعیت)
  • لیست کلینیک‌های عضو به شکل badge

SecretaryDashboard:

  • یک query به GET /api/v1/dashboard/secretary (جدید)
  • استخراج: data?.data
  • نام دکتر مربوطه در header کارت
  • ۲ کارت KPI: نوبت امروز / فردا
  • جدول نوبت‌های امروز
  • لیست مجوزهای فعال با آیکون ✓

مرحله ۷ — صفحه "کلینیک من" (فرانت‌اند)

فایل جدید: assets/admin/pages/MyClinicPage.tsx

export default function MyClinicPage() {
  const context = useAuthStore(s => s.context);
  const clinicUuid = context?.clinic_uuid;
  
  if (!clinicUuid) return (
    <div className="card card-pad">
      <p>کلینیک شما هنوز ثبت نشده است.</p>
    </div>
  );
  
  // از endpoint موجود GET /api/v1/clinics/{uuid} استفاده کن
  // همان محتوای ClinicDetailPage — اما uuid از context
  // دکمه "حذف کلینیک" نشان داده نشود
  // بقیه همه فعال: ویرایش، تغییر وضعیت، آپلود لوگو، گالری، دعوت پزشک
}

بهترین رویکرد: کد مشترک را از ClinicDetailPage.tsx در یک کامپوننت ClinicDetailView جدا کن که uuid و showDeleteButton را به عنوان prop می‌گیرد. هر دو صفحه از آن استفاده کنند.

GET /api/v1/clinics/{uuid} و PATCH /api/v1/clinic/{uuid} هر دو موجودند — نیازی به API جدید نیست.


مرحله ۸ — نوبت‌های فیلترشده (بک‌اند + فرانت‌اند) — API جدید

بک‌اند — endpoint جدید: GET /api/v1/my/appointments [IS_AUTHENTICATED_FULLY]

فایل جدید: src/Appointment/Controller/MyAppointmentsController.php

GET /api/v1/my/appointments?page=1&limit=15&status=...&search=...

بر اساس نقش فیلتر:

  • ROLE_ADMIN: forward به همان query موجود در AdminApiController::appointments()
  • ROLE_CLINIC: نوبت‌هایی که doctor آن در clinic_doctors این کلینیک است
  • ROLE_DOCTOR: نوبت‌های این دکتر — از DoctorRepository::findByUser($user) uuid بگیر
  • ROLE_SECRETARY: نوبت‌های دکتری که این منشی به آن وصل است (اگر appointments.view = true)

پاسخ: همان فرمت $this->paginated() موجود:

{
  "success": true,
  "data": [ { "uuid":"...", "doctor_name":"...", "patient_name":"...", "slot_start":..., "status":"..." } ],
  "meta": { "totalRecords": 100, "totalPages": 7, "currentPage": 1 }
}

داکیومنت: بعد از پیاده‌سازی، endpoint را به docs/api/appointment.md اضافه کن.

فرانت‌اند — AppointmentsPage.tsx

const primaryRole = useAuthStore(s => s.primaryRole);
const endpoint = primaryRole === 'admin'
  ? `/api/v1/admin/appointments`
  : `/api/v1/my/appointments`;

برای ادمین از endpoint موجود استفاده می‌شود؛ برای بقیه نقش‌ها از endpoint جدید.

صفحه نوبت‌ها باید دو نمای قابل‌تعویض داشته باشد — یک toggle بین «جدولی» و «زمانبندی» (مطابق تصاویر طراحی).

هدر صفحه (مشترک هر دو نما):

  • ۴ کارت آمار: «کل نوبت‌های امروز» | «نوبت‌های انجام شده» | «مراجعین در انتظار» | «نوبت‌های لغو شده»
  • دکمه «+ نوبت جدید»
  • دکمه toggle نما (جدولی / زمانبندی)
  • انتخابگر پرسنل/دکتر (dropdown)
  • انتخابگر تاریخ با Jalali calendar (< روز > + آیکون calendar)

نمای جدولی (TableView):

  • جدول با ستون‌ها: ردیف | شماره تماس | شروع | پایان | سرویس | پرسنل | وضعیت | عملیات
  • در ستون «وضعیت»: dropdown تغییر وضعیت با رنگ‌بندی (ثبت شده=آبی، قطعی شده=سبز، در حال پیگیری=نارنجی، سالن=بنفش، ویزیت شده=سبز تیره، لغو شده=قرمز)
  • ستون «عملیات»: دکمه ... با منو

نمای زمانبندی (TimelineView):

  • تب‌های افقی یک دکتر به ازای هر تب (نام دکتر)
  • محور زمان عمودی در سمت راست (فارسی: HH:MM)
  • هر اسلات زمانی یا:
    • پر: کارت نوبت با رنگ پس‌زمینه بر اساس وضعیت + نام بیمار + شماره تماس + سرویس + دکمه عملیات + dropdown وضعیت
    • خالی: کارت خالی با دکمه «+ نوبت جدید» (کلیک → مودال ثبت نوبت با slot از پیش پر شده)
  • رنگ کارت‌ها: ویزیت شده=سبز روشن | لغو شده=قرمز روشن | در حال پیگیری=نارنجی روشن | سالن=بنفش روشن | ثبت شده / قطعی=آبی روشن | انتظار پرداخت=خاکستری

وضعیت‌های نوبت (باید در entity و frontend هر دو باشند):

مقدار DB نمایش فارسی رنگ
waiting_for_payment انتظار پرداخت خاکستری
pending ثبت شده آبی
following در حال پیگیری نارنجی
in_salon سالن بنفش
visited ویزیت شده سبز
cancelled_by_doctor لغو شده (دکتر) قرمز
cancelled_by_user لغو شده (کاربر) قرمز
expired منقضی شده خاکستری تیره
no_show غایب خاکستری تیره

Transition های مجاز (ALLOWED_TRANSITIONS در entity):

waiting_for_payment → [pending, expired, cancelled_by_user]
pending             → [following, in_salon, visited, cancelled_by_doctor, cancelled_by_user, expired, no_show]
following           → [in_salon, visited, cancelled_by_doctor, cancelled_by_user]
in_salon            → [visited, cancelled_by_doctor]

داکیومنت: بعد از پیاده‌سازی، وضعیت‌های جدید و فیلدهای جدید را در docs/api/appointment.md به‌روز کن.


مرحله ۹ — سیستم کمیسیون نوبت‌دهی (بک‌اند + فرانت‌اند) — جدید

منطق کسب‌وکار:

  • کاربر عادی نوبت می‌گیرد → باید کمیسیون سایت (نه قیمت نوبت) پرداخت کند
  • منشی / دکتر / کلینیک نوبت می‌دهد → بدون کمیسیون
  • مقدار کمیسیون در پنل ادمین توسط مدیر تنظیم می‌شود (مثلاً ۱۰,۰۰۰ تومان به ازای هر نوبت)

بک‌اند — موجودیت SiteConfig (جدید):

فایل جدید: src/Admin/Entity/SiteConfig.php

#[ORM\Entity]
#[ORM\Table(name: 'site_config')]
class SiteConfig {
    #[ORM\Id]
    #[ORM\Column(type: 'string', length: 100)]
    private string $configKey;

    #[ORM\Column(name: 'config_value', type: 'text', nullable: true)]
    private ?string $configValue;

    #[ORM\Column(name: 'updated_at', type: 'integer')]
    private int $updatedAt;
}

فایل جدید: src/Admin/Repository/SiteConfigRepository.php

public function get(string $key, mixed $default = null): mixed
public function set(string $key, mixed $value): void
public function all(): array

بک‌اند — کنترلر تنظیمات ادمین (جدید):

فایل جدید: src/Admin/Controller/SiteConfigController.php

GET  /api/v1/admin/settings   [ROLE_ADMIN]  → { booking_commission_rials: int }
PATCH /api/v1/admin/settings  [ROLE_ADMIN]  → body: { booking_commission_rials: int (>=0) }

بک‌اند — تغییرات Appointment entity:

فایل: src/Appointment/Entity/Appointment.php

فیلدهای جدید:

#[ORM\Column(name: 'booked_by', type: 'string', length: 20)]
private string $bookedBy = self::BOOKED_BY_USER;    // 'user' | 'secretary'

#[ORM\Column(name: 'commission_rials', type: 'integer', nullable: true)]
private ?int $commissionRials = null;

ثابت‌های جدید:

public const BOOKED_BY_USER      = 'user';
public const BOOKED_BY_SECRETARY = 'secretary';

Migration: بعد از تغییر entity اجرا کن: ddev exec php bin/console doctrine:migrations:diff و سپس migrate

بک‌اند — تغییرات AppointmentController::book():

فایل: src/Appointment/Controller/AppointmentController.php

منطق در POST /api/v1/appointment:

اگر caller دارای ROLE_SECRETARY یا ROLE_DOCTOR یا ROLE_CLINIC بود:
    bookedBy = 'secretary'
    status   = 'pending'
    commissionRials = null  (بدون کمیسیون)
در غیر اینصورت (ROLE_USER):
    bookedBy = 'user'
    status   = 'waiting_for_payment'
    commissionRials = SiteConfigRepository::get('booking_commission_rials', 0)
    → مقدار commission_rials را در پاسخ برگردان تا frontend به درگاه هدایت کند

پاسخ برای کاربر عادی (اضافه به پاسخ معمول):

{
  "uuid": "...",
  "status": "waiting_for_payment",
  "booked_by": "user",
  "commission_rials": 10000,
  "payment_required": true
}

فرانت‌اند — صفحه تنظیمات ادمین (جدید):

فایل جدید: assets/admin/pages/SettingsPage.tsx

  • Route: /admin/settings — فقط ROLE_ADMIN
  • یک فرم ساده:
    • فیلد «کمیسیون نوبت (ریال)»: عدد، validation >= 0
    • دکمه «ذخیره»
  • GET /api/v1/admin/settings برای مقدار اولیه
  • PATCH /api/v1/admin/settings برای ذخیره
  • نمایش مقدار با formatRial() از lib/utils.ts

فرانت‌اند — تغییرات AppointmentsPage.tsx:

  • بعد از ثبت موفق نوبت توسط کاربر عادی (اگر payment_required: true)، کاربر را به صفحه درگاه پرداخت هدایت کن

داکیومنت: بعد از پیاده‌سازی به‌روز کن:

  • docs/api/appointment.md — اضافه: فیلدهای booked_by، commission_rials، وضعیت‌های جدید
  • docs/api/admin.md — اضافه: GET/PATCH /api/v1/admin/settings

مرحله ۱۰ — شماره موبایل اطلاع‌رسانی نوبت (بک‌اند + فرانت‌اند) — جدید

منطق کسب‌وکار:

  • دکتر یا کلینیک می‌تواند یک شماره موبایل برای دریافت پیامک هنگام ثبت نوبت جدید تنظیم کند
  • این شماره می‌تواند: موبایل خود دکتر، موبایل منشی، یا یک شماره دیگر باشد
  • شماره باید با OTP تأیید شود قبل از فعال شدن
  • اگر شماره تنظیم نشده باشد، پیامک ارسال نمی‌شود

بک‌اند — فیلد جدید روی موجودیت‌ها:

روی Doctor entity:

#[ORM\Column(name: 'notification_mobile', type: 'string', length: 20, nullable: true)]
private ?string $notificationMobile = null;

#[ORM\Column(name: 'notification_mobile_verified', type: 'boolean')]
private bool $notificationMobileVerified = false;

روی Clinic entity (اگر کلینیک بخواهد):

#[ORM\Column(name: 'notification_mobile', type: 'string', length: 20, nullable: true)]
private ?string $notificationMobile = null;

#[ORM\Column(name: 'notification_mobile_verified', type: 'boolean')]
private bool $notificationMobileVerified = false;

Migration: بعد از تغییر entity اجرا کن.

بک‌اند — endpoint های جدید:

فایل: src/Doctor/Controller/DoctorNotificationController.php (یا در کنترلر موجود دکتر)

PATCH /api/v1/doctor/notification-mobile        [ROLE_DOCTOR | ROLE_CLINIC | ROLE_ADMIN]
  body: { mobile: "09..." }
  → شماره را ذخیره کن (verified=false)، OTP ارسال کن
  → response: { message: "کد تأیید ارسال شد" }

POST  /api/v1/doctor/notification-mobile/verify [ROLE_DOCTOR | ROLE_CLINIC | ROLE_ADMIN]
  body: { mobile: "09...", code: "12345" }
  → کد OTP را بررسی کن → notification_mobile_verified = true
  → response: { message: "شماره تأیید شد" }

GET   /api/v1/doctor/notification-mobile        [ROLE_DOCTOR | ROLE_CLINIC | ROLE_ADMIN]
  → response: { mobile: "09...", verified: true }

OTP: از زیرساخت پیامک موجود (src/Sms/) استفاده کن — همان روشی که برای تأیید موبایل کاربر استفاده می‌شود.

ارسال پیامک هنگام ثبت نوبت: در AppointmentController::book() بعد از ذخیره نوبت:

  • اگر doctor.notificationMobile پر بود و notificationMobileVerified = true:
    • یک پیامک با متن «نوبت جدید — بیمار: {نام} | زمان: {ساعت}» ارسال کن

فرانت‌اند — تب/بخش تنظیمات اطلاع‌رسانی:

در صفحه اطلاعات دکتر (DoctorDetailPage یا پروفایل دکتر) یک بخش جدید اضافه کن:

«شماره اطلاع‌رسانی نوبت»:

  • نمایش شماره فعلی (اگر موجود) + وضعیت تأیید (تأیید شده / تأیید نشده)
  • دکمه «تغییر شماره»: باز می‌کند یک فرم یک‌فیلدی (ورودی موبایل) + دکمه «ارسال کد»
  • بعد از ارسال: فیلد کد OTP ظاهر می‌شود + دکمه «تأیید»
  • پیشنهاد سریع: «استفاده از موبایل دکتر» (موبایل دکتر را از context پر می‌کند)

داکیومنت: بعد از پیاده‌سازی به‌روز کن:

  • docs/api/doctor.md — اضافه: سه endpoint notification-mobile

نکات مهم پیاده‌سازی

CSS / UI — فقط template CSS

  • .card, .card-pad, .badge.green/.blue/.amber/.violet/.gray
  • .btn.primary/.ghost/.soft/.sm
  • .skeleton برای loading
  • .empty برای حالت خالی
  • گرادیان آواتار: HUES_LIST = [256, 205, 162, 295, 272] با OKLCH: background: \linear-gradient(145deg, oklch(0.62 0.15 ${hue}), oklch(0.48 0.16 ${hue}))``
  • هیچ Tailwind نیست

پاسخ‌های API

  • $this->success($data){ success, data: $data } — فرانت با data?.data می‌خواند
  • $this->paginated($items, $total, $page, $limit){ success, data: $items[], meta: {...} } — فرانت با data?.data و data?.meta?.totalRecords
  • $this->error(...){ success:false, errors:[...] }

بک‌اند — قوانین کلی

  • همه Admin queries از DQL array hydration استفاده کنند (.getArrayResult())
  • Timestamps همه integer Unix هستند
  • نوبت‌های «امروز»: بازه strtotime('today midnight') تا strtotime('tomorrow midnight') - 1

ترتیب اجرا (پیشنهادی)

  1. UserActiveContext entity جدید → migration
  2. DoctorSecretaryRepository — اضافه: findAllActiveBySecretary() و findActiveBySecretary()
  3. AuthController::userInfo() — گسترش: primary_role، db_uuid، db_key، context، available_contexts
  4. AuthController::switchContext() — endpoint جدید POST /api/v1/auth/switch-context
  5. authStore.ts — اضافه: dbUuid، dbKey، availableContexts، fetchMe()، switchContext()
  6. App.tsx — fetchMe در mount + redirect به /admin/select-context اگر چند-context
  7. SelectContextPage.tsx — صفحه انتخاب محیط کاری
  8. DashboardController — هر سه endpoint
  9. DashboardPage.tsx — sub-dashboardها
  10. Sidebar.tsx — پویا + دکمه تغییر محیط کاری
  11. App.tsx — RoleRoute
  12. MyClinicPage.tsx
  13. MyAppointmentsController — بک‌اند
  14. AppointmentsPage.tsx — دو نما (جدولی/زمانبندی) + endpoint پویا + آمار هدر
  15. Appointment entity — وضعیت‌های جدید + booked_by + commission_rials → migration
  16. SiteConfig entity + repository → migration
  17. SiteConfigControllerGET/PATCH /api/v1/admin/settings
  18. SettingsPage.tsx — پنل ادمین تنظیم کمیسیون
  19. AppointmentController::book() — منطق کمیسیون + booked_by
  20. Doctor/Clinic entity — فیلدهای notification_mobile → migration
  21. DoctorNotificationController — سه endpoint OTP تأیید شماره
  22. فرانت‌اند بخش اطلاع‌رسانی در صفحه پروفایل دکتر / کلینیک
  23. بعد از هر مرحله بک‌اند: ddev exec php bin/console cache:clear
  24. بعد از هر مرحله فرانت‌اند: ddev exec yarn dev

خلاصه فایل‌های جدید/تغییریافته

بک‌اند (تغییر)

  • src/Auth/Controller/AuthController.php — گسترش userInfo(): اضافه کردن primary_role، db_uuid، db_key، context، available_contexts + endpoint جدید switch-context
  • src/Secretary/Repository/DoctorSecretaryRepository.php — اضافه: findActiveBySecretary() و findAllActiveBySecretary()
  • src/Appointment/Entity/Appointment.php — وضعیت‌های جدید + فیلدهای booked_by، commission_rials
  • src/Appointment/Controller/AppointmentController.php — منطق کمیسیون + ارسال پیامک اطلاع‌رسانی
  • src/Doctor/Entity/Doctor.php — فیلدهای notification_mobile، notification_mobile_verified
  • src/Clinic/Entity/Clinic.php — فیلدهای notification_mobile، notification_mobile_verified

بک‌اند (جدید)

  • src/Auth/Entity/UserActiveContext.php — ذخیره context فعال کاربر
  • src/Dashboard/Controller/DashboardController.php
  • src/Appointment/Controller/MyAppointmentsController.php
  • src/Admin/Entity/SiteConfig.php — موجودیت تنظیمات سایت (key-value)
  • src/Admin/Repository/SiteConfigRepository.php — متدهای get(), set(), all()
  • src/Admin/Controller/SiteConfigController.phpGET/PATCH /api/v1/admin/settings
  • src/Doctor/Controller/DoctorNotificationController.php — سه endpoint شماره اطلاع‌رسانی

فرانت‌اند (تغییر)

  • assets/admin/stores/authStore.ts — اضافه: primaryRole، dbUuid، dbKey، context، availableContexts، fetchMe()، switchContext()
  • assets/admin/App.tsx — اضافه: RoleRoute، fetchMe در mount، redirect به select-context اگر چند-context، route های جدید + /admin/settings
  • assets/admin/components/layout/Sidebar.tsx — تبدیل به پویا + دکمه تغییر محیط کاری
  • assets/admin/pages/DashboardPage.tsx — multi-role
  • assets/admin/pages/AppointmentsPage.tsx — endpoint پویا + دو نما (جدولی/زمانبندی) + آمار هدر

فرانت‌اند (جدید)

  • assets/admin/pages/SelectContextPage.tsx — انتخاب محیط کاری برای کاربران چند-context
  • assets/admin/pages/MyClinicPage.tsx
  • assets/admin/pages/SettingsPage.tsx — تنظیم کمیسیون نوبت

Migration

  • migrations/VersionXXX.php — اضافه: ستون‌های booked_by، commission_rials به appointments؛ جدول site_config؛ ستون‌های notification_mobile، notification_mobile_verified به doctors و clinics

داکیومنت (به‌روزرسانی/جدید)

  • docs/api/auth.md — به‌روز: پاسخ /oauth/userinfo با primary_role، db_uuid، db_key، context، available_contexts + اضافه: POST /api/v1/auth/switch-context
  • docs/api/appointment.md — اضافه: GET /api/v1/my/appointments، وضعیت‌های جدید، booked_by، commission_rials
  • docs/api/admin.md — اضافه: GET/PATCH /api/v1/admin/settings
  • docs/api/doctor.md — اضافه: endpoint های notification-mobile
  • docs/api/dashboard.mdفایل جدید برای سه endpoint داشبورد