# حساب کاربری برای پرسنل — نقش `ROLE_STAFF`، ورود به پنل، داشبورد اختصاصی و مشاهدهٔ سرویس‌های تخصیص‌یافته

## پروژه

`clinicpro` (بک‌اند Symfony + پنل ادمین React). cross-repo نیست؛ `nobat724_front` تغییری ندارد.

## زمینه

امروز پرسنل (`clinic_staff`) فقط یک «رکورد اطلاعاتی» است: کلینیک یا پزشک از
`/admin/staff` یک ردیف با نام/تلفن/سمت/کد ملی می‌سازد و همان ردیف در جاهای دیگر
به‌عنوان «مجری سرویس» انتخاب می‌شود:

- `ServiceItem::$staffMembers` (جدول `service_item_staff`) — پرسنل تخصیص‌یافته به هر سرویس
- `Appointment::$staff` (`appointments.staff_id`) — پرسنل نوبت
- `SessionService::$staff` — پرسنل مجری سرویس در جلسهٔ بیمار

اما `ClinicStaff` هیچ ارتباطی با `users` ندارد، پس پرسنل نه می‌تواند لاگین کند و نه
داشبوردی دارد. الگوی مشابهی که در پروژه **کار می‌کند** «منشی» است: منشی یک `User`
است با `ROLE_SECRETARY` که از طریق ردیف `DoctorSecretary` به مالک (پزشک/کلینیک) وصل
می‌شود (`SecretaryService::resolveSecretaryUser`). همین الگو باید برای پرسنل تکرار شود.

## مشکل / هدف

وقتی کلینیک یا پزشک در `/admin/staff` پرسنل اضافه می‌کند، اگر شمارهٔ موبایل بدهد و
گزینهٔ «ایجاد حساب کاربری» را بزند:

1. یک `User` با نقش `ROLE_STAFF` ساخته/به‌روزرسانی شود و به همان ردیف `ClinicStaff` وصل شود.
2. آن کاربر بتواند با موبایل/رمز در `/admin/login` وارد شود.
3. بعد از ورود، `primary_role = 'staff'` بگیرد و محیط کاری‌اش همان مطب/کلینیکِ مالک باشد.
4. داشبورد اختصاصی «پرسنل» ببیند: سرویس‌هایی که به او تخصیص داده شده + نوبت‌های خودش.
5. **به هیچ چیز دیگری دسترسی نداشته باشد** — نه لیست بیماران، نه سرویس‌های کل کلینیک،
   نه مالی، نه مدیریت پرسنل.

### تحلیل — نکتهٔ امنیتی که نباید نادیده گرفته شود

بند ۵ سخت‌ترین بخش کار است و اگر ساده گرفته شود یک نشت اطلاعات کامل می‌سازد:

- اکثر کنترلرها فقط `#[IsGranted('IS_AUTHENTICATED_FULLY')]` دارند و tenant را از
  `EntityContextResolver` می‌گیرند.
- `SecretaryAccessChecker::denyUnlessGranted` و `ClinicDoctorAccessChecker` برای
  کاربری که منشی/پزشکِ مهمان **نیست** عملاً no-op هستند (فقط نقش خودشان را می‌سنجند).
- پس به‌محض اینکه `EntityContextResolver` برای کاربر staff محیط کلینیک را resolve کند،
  `GET /api/v1/service-items` **همهٔ** سرویس‌های کلینیک را برمی‌گرداند، `/api/v1/patients`
  همهٔ بیماران را، و…

بنابراین طراحی این تسک **default-deny** است: یک `StaffRouteGuardSubscriber` روی رویداد
`kernel.controller` که برای کاربرِ «فقط staff» هر مسیر خارج از allowlist را ۴۰۳ می‌کند.
دلیل انتخاب Subscriber به‌جای افزودن `denyUnlessGranted` به ده‌ها کنترلر: تک‌نقطه‌ای
بودن تصمیم (اگر فردا کنترلر جدیدی اضافه شود، به‌صورت پیش‌فرض بسته است، نه باز).

## معیار پذیرش

- ✅ موفق:
  - `POST /api/v1/staff` با `{"full_name":"زهرا احمدی","phone":"09121110000","has_account":true,"password":"Staff@1234"}`
    توسط توکن کلینیک → `201` و در بدنه `has_account: true` و `user_uuid` غیرتهی؛ در DB
    یک `users` با `roles` شامل `ROLE_STAFF` و `clinic_staff.user_id` پرشده.
  - `POST /api/v1/user/login` با همان موبایل/رمز → `200` و `access_token`.
  - `GET /oauth/userinfo` با آن توکن → `primary_role: "staff"` و در `available_contexts`
    یک آیتم با `role: "staff"` و `db_uuid` برابر uuid کلینیک/پزشکِ مالک و
    `permissions.resources` فقط شامل `{"services":{"view":true},"appointments":{"view":true}}`.
  - `GET /api/v1/dashboard/staff` → `200` با `stats.today_appointments`، `services` (فقط
    سرویس‌هایی که این پرسنل در `service_item_staff` آن‌هاست) و `today_appointments`.
  - در پنل: ورود با آن کاربر → ریدایرکت به `/admin/dashboard` و نمایش «داشبورد پرسنل»؛
    سایدبار فقط «داشبورد» و «سرویس‌های من» را دارد.
- ❌ خطا:
  - `GET /api/v1/service-items` با توکن پرسنل → `403` با `ERR_FORBIDDEN_001` (نه ۲۰۰ با
    سرویس‌های کلینیک). همین‌طور `/api/v1/staff` (GET/POST)، `/api/v1/patients`،
    `/api/v1/appointments`، `/api/v1/dashboard/clinic`.
  - `POST /api/v1/staff` با `has_account: true` و `phone` خالی یا نامعتبر →
    `422` با `ERR_STAFF_MOBILE_INVALID`.
  - ورود پرسنلِ `active=false` → `/oauth/userinfo` هیچ context با `role: "staff"` ندارد و
    `GET /api/v1/dashboard/staff` → `403`.
- ⚠️ مرزی:
  - موبایلی که **از قبل** `User` دارد (مثلاً بیمار یا منشی): کاربر جدید ساخته نشود؛
    فقط `ROLE_STAFF` به نقش‌هایش اضافه شود و ردیف پرسنل به همان کاربر وصل شود. اگر آن
    کاربر هم منشی است و هم پرسنل → `primary_role` باید `secretary` بماند (نقش قوی‌تر) و
    context مربوط به staff هم در `available_contexts` بیاید.
  - یک نفر پرسنلِ **دو** کلینیک: دو ردیف `clinic_staff` با یک `user_id` → دو context در
    لیست؛ بعد از `switch-context` داشبورد داده‌های همان کلینیک را بدهد.
  - همان موبایل دوباره در همان کلینیک ثبت شود → `409` با `ERR_STAFF_MOBILE_TAKEN`
    (نه ساخت ردیف تکراری).
  - پرسنل بدون هیچ سرویس تخصیص‌یافته → `services: []` و پیام خالی در UI، نه ۵۰۰.
  - موبایل مالک (خودِ پزشک/کلینیک) به‌عنوان پرسنل → `422` با `ERR_STAFF_MOBILE_INVALID`
    و پیام «شماره مالک نمی‌تواند پرسنل باشد» (جلوگیری از تنزل نقش/سردرگمی context).

## فایل‌های مرتبط

| فایل | نقش |
|------|-----|
| `src/Staff/Entity/ClinicStaff.php` | افزودن رابطهٔ `user` |
| `src/Staff/Repository/ClinicStaffRepository.php` | کوئری‌های `findActiveByUser`، `findActiveByUserAndEntity`، `findByEntityAndPhone` |
| `src/Staff/Service/StaffAccountService.php` | **جدید** — ساخت/اتصال/قطع حساب کاربری پرسنل |
| `src/Staff/Controller/StaffController.php` | پذیرش `has_account`/`password` در create/update |
| `src/Staff/Controller/StaffDashboardController.php` یا `src/Dashboard/Controller/DashboardController.php` | اندپوینت داشبورد پرسنل |
| `src/Staff/Security/StaffRouteGuardSubscriber.php` | **جدید** — default-deny برای کاربر staff |
| `src/Auth/Entity/User.php` | `isStaff()` باید `ROLE_STAFF` را هم بپذیرد |
| `src/Auth/Controller/AuthController.php` | `resolvePrimaryRole()` + `buildAvailableContexts()` |
| `src/Shared/Context/EntityContextResolver.php` | resolve محیط برای کاربر staff |
| `src/Shared/Constant/ErrorCodes.php` | کدهای خطای جدید |
| `src/ClinicService/Repository/ServiceItemRepository.php` | `findByStaff(ClinicStaff)` |
| `assets/admin/pages/StaffPage.tsx` | فیلد موبایل/حساب کاربری + ستون «حساب» |
| `assets/admin/pages/DashboardPage.tsx` | `StaffDashboard` + dispatcher |
| `assets/admin/pages/StaffMyServicesPage.tsx` | **جدید** — صفحهٔ «سرویس‌های من» |
| `assets/admin/App.tsx` | `ALLOWED_ROLES` + روت‌های نقش staff |
| `assets/admin/components/layout/Sidebar.tsx` | منوی نقش staff |
| `assets/admin/types/index.ts` | فیلدهای جدید `ClinicStaff` |
| `docs/api/staff.md`، `docs/api/auth.md`، `docs/api/dashboard.md` | مستندسازی (قانون ثابت پروژه) |

## وضعیت فعلی

### `src/Staff/Entity/ClinicStaff.php` — هیچ ارتباطی با `User` ندارد

```php
#[ORM\Entity(repositoryClass: ClinicStaffRepository::class)]
#[ORM\Table(name: 'clinic_staff')]
#[ORM\Index(columns: ['entity_type', 'entity_id', 'active'], name: 'idx_staff_entity_active')]
class ClinicStaff
{
    #[ORM\Column(name: 'entity_type', type: 'string', length: 10)]
    private string $entityType;

    #[ORM\Column(name: 'entity_id', type: 'integer')]
    private int $entityId;

    #[ORM\Column(name: 'full_name', type: 'string', length: 200)]
    private string $fullName;

    #[ORM\Column(type: 'string', length: 20, nullable: true)]
    private ?string $phone = null;
    // …
}
```

### `src/Auth/Entity/User.php:121` — گیت ورود به پنل

```php
public function isStaff(): bool
{
    return $this->hasRole('ROLE_DOCTOR')
        || $this->hasRole('ROLE_CLINIC')
        || $this->hasRole('ROLE_SECRETARY')
        || $this->hasRole('ROLE_ADMIN')
        || $this->hasRole('ROLE_REPRESENTATION')
        || $this->hasRole('ROLE_IMPORTER');
}
```

`PasswordAuthenticator::onAuthenticationSuccess:79` بدون این متد لاگین را ۴۰۳ می‌کند:

```php
if (!$user->isStaff()) {
    return new JsonResponse([... ErrorCodes::ERR_AUTH_006 ...], 403);
}
```

### `src/Auth/Controller/AuthController.php:690` — نقش اصلی و لیست محیط‌ها

```php
private function resolvePrimaryRole(User $user): string
{
    $roles = $user->getRoles();
    if (in_array('ROLE_ADMIN', $roles, true))     return 'admin';
    if (in_array('ROLE_CLINIC', $roles, true))    return 'clinic';
    if (in_array('ROLE_DOCTOR', $roles, true))    return 'doctor';
    if (in_array('ROLE_SECRETARY', $roles, true)) return 'secretary';
    if (in_array('ROLE_REPRESENTATION', $roles, true)) return 'representation';
    return 'user';
}
```

و در `buildAvailableContexts()` منشی این‌طور context می‌گیرد (الگوی مرجع برای پرسنل):

```php
foreach ($this->secretaryRepo->findAllActiveBySecretary($user) as $rel) {
    // …
    $contexts[] = [
        'type'        => 'doctor',
        'db_uuid'     => $rel->getDoctor()->getUuid(),
        'name'        => 'مطب ' . $rel->getDoctor()->getName(),
        'role'        => 'secretary',
        'scope'       => 'doctor',
        'permissions' => $rel->getPermissions(),
    ];
}
```

### `src/Secretary/Service/SecretaryService.php:38` — الگوی مرجع ساخت کاربر

```php
public function resolveSecretaryUser(string $mobile, ?string $name = null, ?string $password = null): User
{
    $user = $this->userRepo->findByMobile($mobile);
    if ($user === null) {
        $user = new User($mobile);
        if (!empty($password)) {
            $user->setPasswordHash($this->hasher->hashPassword($user, $password));
        }
    }
    if (!empty($name)) {
        $user->setRealName(trim($name));
    }

    $roles = $user->getRoles();
    if (!in_array('ROLE_SECRETARY', $roles, true)) {
        $roles[] = 'ROLE_SECRETARY';
        $user->setRoles(array_values(array_unique($roles)));
    }
    $this->userRepo->save($user);

    return $user;
}
```

### `src/ClinicService/Entity/ServiceItem.php:44` — رابطهٔ سرویس ↔ پرسنل (منبع «سرویس‌های من»)

```php
#[ORM\ManyToMany(targetEntity: ClinicStaff::class, fetch: 'EAGER')]
#[ORM\JoinTable(name: 'service_item_staff')]
private Collection $staffMembers;
```

### `assets/admin/App.tsx:83` — نقش‌های مجاز پنل

```tsx
const ALLOWED_ROLES = ['admin', 'doctor', 'clinic', 'secretary', 'representation'] as const;
```

### `assets/admin/hooks/usePermissions.ts` — نکتهٔ حیاتی

```ts
const perms = context?.permissions as { resources?: ... } | undefined | null;
if (!perms?.resources) return true;   // نبودِ permissions یعنی «آزاد»، نه «بسته»
```

پس context پرسنل **حتماً** باید `permissions.resources` صریح داشته باشد، وگرنه UI همه‌چیز
را باز می‌کند.

## وظایف

### ۱. مدل داده: اتصال `ClinicStaff` به `User`

`src/Staff/Entity/ClinicStaff.php`:

```php
#[ORM\ManyToOne(targetEntity: \App\Auth\Entity\User::class)]
#[ORM\JoinColumn(name: 'user_id', nullable: true, onDelete: 'SET NULL')]
private ?User $user = null;

public function getUser(): ?User      { return $this->user; }
public function hasAccount(): bool    { return $this->user !== null; }
public function setUser(?User $user): self { $this->user = $user; $this->updatedAt = time(); return $this; }
```

و در `toArray()`:

```php
'has_account' => $this->user !== null,
'user_uuid'   => $this->user?->getUuid(),
```

ایندکس لازم: `#[ORM\Index(columns: ['user_id', 'active'], name: 'idx_staff_user_active')]`
(چون `findActiveByUser` در هر بار `userinfo` صدا زده می‌شود).

`ClinicStaffRepository`:

```php
/** @return ClinicStaff[] ردیف‌های فعالِ این کاربر در همهٔ محیط‌ها */
public function findActiveByUser(User $user): array;

public function findActiveByUserAndEntity(User $user, string $entityType, int $entityId): ?ClinicStaff;

/** برای جلوگیری از ثبت تکراری یک موبایل در همان محیط */
public function findByEntityAndPhone(string $entityType, int $entityId, string $phone): ?ClinicStaff;
```

سپس migration:

```bash
ddev exec php bin/console doctrine:migrations:diff --no-interaction
ddev exec php bin/console doctrine:migrations:migrate --no-interaction
```

**نحوه تست:** `ddev exec php bin/console doctrine:schema:validate` باید سبز باشد؛
`DESCRIBE clinic_staff` ستون `user_id` را نشان دهد.

### ۲. `StaffAccountService` — تنها نقطهٔ ساخت/اتصال حساب پرسنل

`src/Staff/Service/StaffAccountService.php` (جدید). قرینهٔ `SecretaryService::resolveSecretaryUser`
است، اما با اعتبارسنجی موبایل و قاعدهٔ «مالک نمی‌تواند پرسنل خودش باشد»:

```php
class StaffAccountService
{
    public function __construct(
        private readonly UserRepository              $userRepo,
        private readonly ClinicStaffRepository       $staffRepo,
        private readonly UserPasswordHasherInterface $hasher,
        private readonly SmsService                  $smsService,
        private readonly string                      $appUrl,
    ) {}

    /**
     * حساب کاربری پرسنل را می‌سازد یا به کاربر موجود وصل می‌کند و ROLE_STAFF می‌دهد.
     *
     * @throws AppException ERR_STAFF_MOBILE_INVALID | ERR_STAFF_MOBILE_TAKEN
     */
    public function attachAccount(ClinicStaff $staff, string $mobile, ?string $password, User $owner): User
    {
        $mobile = $this->normalizeMobile($mobile);           // ارقام فارسی → لاتین
        if (!preg_match('/^09\d{9}$/', $mobile)) {
            throw new AppException(ErrorCodes::ERR_STAFF_MOBILE_INVALID, null, 422);
        }
        if ($mobile === $owner->getMobileNumber()) {
            throw new AppException(ErrorCodes::ERR_STAFF_MOBILE_INVALID, 'شماره مالک نمی‌تواند پرسنل باشد', 422);
        }

        $user = $this->userRepo->findByMobile($mobile);

        // یک موبایل، در یک محیط، فقط یک ردیف پرسنل
        $duplicate = $this->staffRepo->findByEntityAndPhone($staff->getEntityType(), $staff->getEntityId(), $mobile);
        if ($duplicate !== null && $duplicate->getId() !== $staff->getId()) {
            throw new AppException(ErrorCodes::ERR_STAFF_MOBILE_TAKEN, null, 409);
        }

        if ($user === null) {
            $user = new User($mobile);
        }
        if (!empty($password)) {
            $user->setPasswordHash($this->hasher->hashPassword($user, $password));
        }
        $user->setRealName($staff->getFullName());
        $user->addRole('ROLE_STAFF');
        $this->userRepo->save($user);

        $staff->setUser($user)->setPhone($mobile);
        $this->staffRepo->save($staff);

        $this->sendWelcomeSms($mobile, $ownerName);   // TAG_STAFF، مشابه TAG_SECRETARY

        return $user;
    }

    /** قطع دسترسی بدون حذف ردیف پرسنل (سوابق سرویس/نوبت حفظ می‌شود). */
    public function detachAccount(ClinicStaff $staff): void;
}
```

نکته‌ها:
- `addRole()` روی `User` از قبل هست (`src/Auth/Entity/User.php:107`) — از آن استفاده کن،
  آرایهٔ roles را دستی دستکاری نکن.
- برای SMS: `SmsLog::TAG_STAFF` را به ثابت‌ها و `SmsMessageTemplate` اضافه کن (الگوی
  `SmsLog::TAG_SECRETARY => [...]` در `src/Sms/Entity/SmsMessageTemplate.php:58`). اگر
  افزودن قالب پیامک ریسک/هزینه دارد، همان `TAG_SECRETARY` را استفاده نکن — به‌جایش
  ارسال SMS را در این فاز حذف کن و در پاسخ API فقط `has_account` را برگردان.
- کدهای خطای جدید در `src/Shared/Constant/ErrorCodes.php`:
  `ERR_STAFF_MOBILE_INVALID => 'شماره موبایل پرسنل معتبر نیست'`،
  `ERR_STAFF_MOBILE_TAKEN => 'برای این شماره قبلاً پرسنلی ثبت شده است'`.

**نحوه تست:** یونیت‌تست `tests/Staff/StaffAccountServiceTest.php` با سه سناریو:
کاربر جدید ساخته می‌شود / کاربر موجود فقط نقش می‌گیرد و رمز قبلی‌اش پاک نمی‌شود اگر
`password` خالی باشد / موبایل مالک → `AppException` با کد ۴۲۲.

### ۳. `StaffController` — پذیرش حساب کاربری در create/update

در `create()` و `update()` (فایل `src/Staff/Controller/StaffController.php`) بعد از
`$this->staffRepo->save($staff)`:

```php
$wantsAccount = (bool) ($data['has_account'] ?? false);
if ($wantsAccount) {
    $this->staffAccounts->attachAccount($staff, (string) ($data['phone'] ?? ''), $data['password'] ?? null, $user);
} elseif ($staff->hasAccount() && array_key_exists('has_account', $data)) {
    $this->staffAccounts->detachAccount($staff);
}

return $this->success($staff->toArray(), 201);
```

کنترلر نازک بماند: هیچ منطق hash/نقش/اعتبارسنجی موبایل داخل کنترلر نوشته نشود
(`AppException` را `ExceptionSubscriber` به envelope خطا تبدیل می‌کند).

**مهم:** `resolveEntity()` همین کنترلر نباید برای `ROLE_STAFF` چیزی برگرداند — امروز به
`['unknown', null]` می‌افتد و ۴۰۳ می‌دهد؛ همین رفتار درست است، دست نخورد (پرسنل حق
مدیریت پرسنل ندارد).

**نحوه تست:**
```bash
TOKEN=$(curl -s -X POST https://clinic-pro.ddev.site/api/v1/user/login \
  -H 'Content-Type: application/json' \
  -d '{"mobile_number":"09390039833","password":"09390039833"}' | jq -r .access_token)

curl -s -X POST https://clinic-pro.ddev.site/api/v1/staff \
  -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
  -d '{"full_name":"زهرا احمدی","phone":"09121110000","job_title":"پرستار","has_account":true,"password":"Staff@1234"}' | jq
# انتظار: 201، has_account:true، user_uuid غیرتهی
```

### ۴. Auth — نقش، محیط کاری و مجوزهای پرسنل

الف) `src/Auth/Entity/User.php` → `isStaff()` را با `|| $this->hasRole('ROLE_STAFF')` کامل کن
(بدون آن، لاگین پرسنل ۴۰۳ می‌گیرد).

ب) `AuthController::resolvePrimaryRole()` — بعد از `secretary` و قبل از `representation`:

```php
if (in_array('ROLE_STAFF', $roles, true)) return 'staff';
```

(ترتیب عمدی است: کسی که هم منشی است هم پرسنل، منشی می‌ماند چون نقش پرتوان‌تر است.)

ج) `AuthController::buildAvailableContexts()` — بلوک جدید در انتها:

```php
foreach ($this->staffRepo->findActiveByUser($user) as $row) {
    $owner = $row->getEntityType() === 'clinic'
        ? $this->clinicRepo->find($row->getEntityId())
        : $this->doctorRepo->find($row->getEntityId());
    if ($owner === null) { continue; }

    $contexts[] = [
        'type'        => $row->getEntityType(),
        'db_uuid'     => $owner->getUuid(),
        'name'        => $row->getEntityType() === 'clinic' ? ($owner->getName() ?? '') : 'مطب ' . $owner->getName(),
        'role'        => 'staff',
        'scope'       => $row->getEntityType(),
        'permissions' => StaffPermissions::DEFAULT,   // ثابت، نه قابل ویرایش در این فاز
    ];
}
```

با ثابتِ صریح (مثلاً `src/Staff/Security/StaffPermissions.php`):

```php
public const DEFAULT = [
    'version'   => 1,
    'resources' => [
        'services'     => ['view' => true],
        'appointments' => ['view' => true],
    ],
];
```

د) `EntityContextResolver` — تا وقتی staff در `canActInClinic()` / `canActForDoctor()`
شناخته نشود، `fromActiveContext()` برای او `null` برمی‌گرداند و داشبورد ۴۰۳ می‌دهد:

```php
// canActInClinic()
if ($this->staffRepo->findActiveByUserAndEntity($user, 'clinic', $clinic->getId()) !== null) {
    return true;
}
// canActForDoctor()
if ($this->staffRepo->findActiveByUserAndEntity($user, 'doctor', $doctor->getId()) !== null) {
    return true;
}
```

`fromRole()` برای staff هیچ fallback ندهد (مثل منشی) — محیطش فقط از `UserActiveContext`
می‌آید، چون می‌تواند پرسنل چند محیط باشد.

**نحوه تست:**
```bash
STAFF=$(curl -s -X POST https://clinic-pro.ddev.site/api/v1/user/login \
  -H 'Content-Type: application/json' \
  -d '{"mobile_number":"09121110000","password":"Staff@1234"}' | jq -r .access_token)
curl -s https://clinic-pro.ddev.site/oauth/userinfo -H "Authorization: Bearer $STAFF" | jq '.data.primary_role, .data.available_contexts'
# انتظار: "staff" و یک context با role=staff و permissions محدود
```

### ۵. Default-deny: `StaffRouteGuardSubscriber`

`src/Staff/Security/StaffRouteGuardSubscriber.php` (جدید) روی `KernelEvents::CONTROLLER`:

```php
/**
 * کاربری که «فقط» ROLE_STAFF دارد به هیچ اندپوینتی جز allowlist دسترسی ندارد.
 *
 * چرایی: بیشتر کنترلرها tenant را از EntityContextResolver می‌گیرند و مجوز را فقط
 * برای منشی/پزشکِ مهمان می‌سنجند؛ بدون این گارد، کاربر staff با context حل‌شده به
 * دادهٔ کل کلینیک می‌رسد. تصمیم در یک نقطه متمرکز است تا کنترلرِ جدید هم به‌صورت
 * پیش‌فرض بسته باشد.
 */
private const ALLOWED_PREFIXES = [
    '/api/v1/dashboard/staff',
    '/api/v1/staff/me',
    '/api/v1/auth/switch-context',
    '/api/v1/user/change-password',
    '/oauth/',
];
```

قواعد:
- فقط وقتی فعال شود که کاربر `ROLE_STAFF` دارد و **هیچ‌کدام** از
  `ROLE_ADMIN/ROLE_CLINIC/ROLE_DOCTOR/ROLE_SECRETARY/ROLE_REPRESENTATION` را ندارد.
- در غیر allowlist: `AppException(ErrorCodes::ERR_FORBIDDEN_001, null, 403)`.
- مسیرهای عمومی (غیر `/api`) دست‌نخورده بمانند.

**نحوه تست:** `tests/Staff/StaffRouteGuardTest.php` — با توکن پرسنل روی این‌ها ۴۰۳:
`/api/v1/service-items`، `/api/v1/staff`، `/api/v1/patients`، `/api/v1/appointments`،
`/api/v1/dashboard/clinic`؛ و روی `/api/v1/dashboard/staff` و `/oauth/userinfo` ۲۰۰.

### ۶. اندپوینت داشبورد پرسنل

`GET /api/v1/dashboard/staff` — قرینهٔ `/api/v1/dashboard/secretary`
(`src/Dashboard/Controller/DashboardController.php:529`). طبق قاعدهٔ «اول بگرد، بعد بساز»:
اندپوینت موجودی وجود ندارد که خروجی محدودشده به یک پرسنل بدهد، پس ساختش لازم است.

```php
#[Route('/api/v1/dashboard/staff', methods: ['GET'])]
#[IsGranted('ROLE_STAFF')]
public function staff(#[CurrentUser] User $user): JsonResponse
{
    $context = $this->contextResolver->resolve($user);
    if (!$context->isResolved()) {
        return $this->error(ErrorCodes::ERR_FORBIDDEN_001, 'محیط کاری پرسنل تنظیم نشده', 403);
    }
    [$entityType, $entityId] = $context->toEntityPair();

    $row = $this->staffRepo->findActiveByUserAndEntity($user, $entityType, $entityId);
    if ($row === null) {
        return $this->error(ErrorCodes::ERR_FORBIDDEN_001, 'دسترسی پرسنل تنظیم نشده', 403);
    }

    return $this->success([
        'scope'   => $entityType,
        'staff'   => ['uuid' => $row->getUuid(), 'full_name' => $row->getFullName(), 'job_title' => $row->getJobTitle()],
        'owner'   => ['name' => $ownerName],
        'stats'   => ['today_appointments' => $todayCount, 'services' => count($services)],
        'services'           => $services,           // از ServiceItemRepository::findByStaff()
        'today_appointments' => $todayAppointments,  // appointments.staff_id = این پرسنل، امروز
    ]);
}
```

`ServiceItemRepository::findByStaff(ClinicStaff $staff): array` — DQL با
`INNER JOIN i.staffMembers s WHERE s = :staff AND i.active = true`، محدود به همان tenant.
خروجی سرویس‌ها فقط فیلدهای لازم: `uuid, name, price_rials, duration_minutes, section_name, active`
(قیمت لازم است چون پرسنل باید بداند چه سرویسی با چه تعرفه‌ای به او تخصیص یافته).

نوبت‌های امروز: DQL روی `Appointment` با `a.staff = :staff` و بازهٔ
`strtotime('today midnight')` تا `strtotime('tomorrow midnight') - 1` (تایم‌استمپ صحیح، نه DateTime).

**نحوه تست:** بعد از تخصیص یک سرویس به پرسنل از صفحهٔ سرویس‌ها:
```bash
curl -s https://clinic-pro.ddev.site/api/v1/dashboard/staff -H "Authorization: Bearer $STAFF" | jq '.data.services, .data.stats'
```

### ۷. پنل: فرم پرسنل + نقش staff در روتینگ و سایدبار

الف) `assets/admin/pages/StaffPage.tsx`:
- در `schema` فیلدهای `has_account: z.boolean().optional()` و `password: z.string().optional()`
  اضافه شود؛ با `superRefine`: اگر `has_account` روشن است، `phone` باید `^09\d{9}$` باشد.
- در `StaffFormFields` یک چک‌باکس «ایجاد حساب کاربری برای ورود به پنل» و ورودی رمز
  (فقط وقتی چک‌باکس روشن است). ورودی موبایل همان `phone` فعلی است با `numericField(..., 11)`.
- یک ستون جدید در `columns`: «حساب کاربری» با `ActiveBadge`/متن «دارد / ندارد» از
  `s.has_account`.
- `assets/admin/types/index.ts` → `ClinicStaff` با `has_account: boolean; user_uuid: string | null`.

ب) `assets/admin/App.tsx`:
```tsx
const ALLOWED_ROLES = ['admin', 'doctor', 'clinic', 'secretary', 'representation', 'staff'] as const;
```
و روت جدید داخل `AdminLayout`:
```tsx
<Route path="/admin/my-services" element={<RoleRoute roles={['staff']}><StaffMyServicesPage /></RoleRoute>} />
```

ج) `assets/admin/components/layout/Sidebar.tsx` — بلوک `if (primaryRole === "staff")`
قبل از `representation`، دقیقاً با ساختار بقیه (بخش «عمومی» با داشبورد + «مدیریت» با
«سرویس‌های من»). هیچ آیتم تنظیمات/مالی/بیمار نداشته باشد. `ROLE_LABELS` هم مقدار
`staff: 'پرسنل'` بگیرد.

د) `assets/admin/pages/DashboardPage.tsx` — کامپوننت `StaffDashboard` قرینهٔ
`SecretaryDashboard` (همان `LoadingSkeleton`، همان کارت‌های KPI، همان حالت خطا) و در
dispatcher: `if (primaryRole === 'staff') return <StaffDashboard />;`

ه) `assets/admin/pages/StaffMyServicesPage.tsx` — جدول سرویس‌های تخصیص‌یافته با
`DataTable` + `PageHeader` (بدون دکمهٔ ایجاد/ویرایش؛ فقط خواندنی). چون از داشبورد باز
می‌شود، `backTo="/admin/dashboard"` بدهد.

**نحوه تست:**
```bash
ddev exec npx tsc --noEmit --project tsconfig.json
ddev exec yarn dev
ddev exec yarn test
```
سپس دستی: ورود با `09121110000 / Staff@1234` در `/admin/login` →
داشبورد پرسنل، سایدبار دو آیتمی، ورود مستقیم به `/admin/patients` → ریدایرکت به داشبورد.

### ۸. تست‌ها و مستندات

- `tests/Staff/StaffAccountServiceTest.php` — یونیت (وظیفهٔ ۲).
- `tests/Staff/StaffRouteGuardTest.php` — فانکشنال default-deny (وظیفهٔ ۵).
- `tests/Staff/StaffDashboardTest.php` — موفق (۲۰۰ با سرویس‌های خودش) / خطا (پرسنل
  غیرفعال → ۴۰۳) / مرزی (بدون سرویس → `services: []`).
- `TenantSchemaCoverageTest` باید همچنان سبز باشد (`clinic_staff` از قبل tenant-keyed است؛
  ستون `user_id` طبقه‌بندی آن را عوض نمی‌کند — اگر تست قرمز شد، دلیلش را بررسی کن، نه
  اینکه entity را به `GlobalTables` اضافه کنی).
- اجرای کامل: `ddev exec php bin/phpunit` و `ddev exec php vendor/bin/phpstan analyse`.
- مستندات (قانون ثابت پروژه): `docs/api/staff.md` (فیلدهای جدید create/update + اندپوینت
  `/api/v1/staff/me` اگر ساخته شد)، `docs/api/auth.md` (نقش `staff` در `primary_role` و
  context جدید)، `docs/api/dashboard.md` (اندپوینت `/api/v1/dashboard/staff`).

## نکات مهم

- **پرسنل ≠ منشی.** منشی مجوزهای قابل‌ویرایش دارد (`DoctorSecretary.permission`)؛ پرسنل در
  این فاز مجوز ثابت و حداقلی دارد (`StaffPermissions::DEFAULT`). ویرایشگر مجوز پرسنل
  ساخته نشود — abstraction «برای آینده» ممنوع است.
- **`usePermissions` نبودِ `permissions` را «آزاد» تفسیر می‌کند** — context پرسنل حتماً
  آبجکت صریح `resources` داشته باشد، وگرنه UI همه‌چیز را باز می‌کند.
- **غیرفعال‌سازی پرسنل باید دسترسی را قطع کند:** `PATCH /api/v1/staff/{uuid}/toggle` وقتی
  `active=false` می‌شود، `findActiveByUser` دیگر آن ردیف را برنمی‌گرداند، پس context حذف
  می‌شود. اما توکن JWT قبلی تا انقضا معتبر است؛ به همین دلیل گارد وظیفهٔ ۶ (بررسی
  `findActiveByUserAndEntity` در هر درخواست داشبورد) لازم است و نمی‌توان فقط به context
  اکتفا کرد.
- **حذف نشدن سوابق:** `detachAccount` فقط `user_id` را `null` می‌کند؛ ردیف `clinic_staff` و
  ارجاعات `service_item_staff` / `appointments.staff_id` / `session_services.staff_id` دست
  نمی‌خورند.
- **تایم‌استمپ‌ها `int` Unix** و تاریخ‌ها در UI شمسی با `formatDate` — طبق قواعد پروژه.
- **الگو:** `StaffAccountService` نقش Service Layer را دارد (قرینهٔ `SecretaryService`) و
  `StaffRouteGuardSubscriber` الگوی Guard/Interceptor است؛ انتخابشان برای تک‌نقطه‌ای کردن
  دو تصمیم است: «چه کسی حساب دارد» و «چه چیزی برای staff باز است».
- **رشته‌های UI فارسی** بمانند و صفحات جدید از همان `PageHeader` / `DataTable` /
  `SettingsLayout` و توکن‌های `styles.css` استفاده کنند — طراحی جدید ساخته نشود.
