- 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.
46 KiB
پرامپت: داشبورد چند-نقشه (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_SECRETARYClinic: فیلدuser(ManyToOne به User) — صاحب کلینیک. رابطه ManyToMany باDoctorاز طریق جدولclinic_doctorsDoctor: فیلد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،isAuthenticatedSidebar.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-..." }
پیادهسازی:
available_contextsکاربر را محاسبه کن (همان منطق مرحله ۱)- بررسی کن آیا
db_uuidارسالشده در لیستavailable_contextsکاربر هست — اگر نه: خطای403 - در جدول
user_active_contextمقدارdb_uuidرا ذخیره/بهروز کن db_keyجدید را محاسبه کن:hash_hmac('sha256', $dbUuid, $appSecret)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و updatedbUuid،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لیست: ۵ نوبت اخیر امروز این کلینیک (از طریق JOINclinic_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برای این دکتر — با DQLclinics: کلینیکهایی که این دکتر در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
ترتیب اجرا (پیشنهادی)
UserActiveContextentity جدید → migrationDoctorSecretaryRepository— اضافه:findAllActiveBySecretary()وfindActiveBySecretary()AuthController::userInfo()— گسترش:primary_role،db_uuid،db_key،context،available_contextsAuthController::switchContext()— endpoint جدیدPOST /api/v1/auth/switch-contextauthStore.ts— اضافه:dbUuid،dbKey،availableContexts،fetchMe()،switchContext()App.tsx— fetchMe در mount + redirect به/admin/select-contextاگر چند-contextSelectContextPage.tsx— صفحه انتخاب محیط کاریDashboardController— هر سه endpointDashboardPage.tsx— sub-dashboardهاSidebar.tsx— پویا + دکمه تغییر محیط کاریApp.tsx— RoleRouteMyClinicPage.tsxMyAppointmentsController— بکاندAppointmentsPage.tsx— دو نما (جدولی/زمانبندی) + endpoint پویا + آمار هدرAppointmententity — وضعیتهای جدید +booked_by+commission_rials→ migrationSiteConfigentity + repository → migrationSiteConfigController—GET/PATCH /api/v1/admin/settingsSettingsPage.tsx— پنل ادمین تنظیم کمیسیونAppointmentController::book()— منطق کمیسیون +booked_byDoctor/Clinicentity — فیلدهایnotification_mobile→ migrationDoctorNotificationController— سه endpoint OTP تأیید شماره- فرانتاند بخش اطلاعرسانی در صفحه پروفایل دکتر / کلینیک
- بعد از هر مرحله بکاند:
ddev exec php bin/console cache:clear - بعد از هر مرحله فرانتاند:
ddev exec yarn dev
خلاصه فایلهای جدید/تغییریافته
بکاند (تغییر)
src/Auth/Controller/AuthController.php— گسترشuserInfo(): اضافه کردنprimary_role،db_uuid،db_key،context،available_contexts+ endpoint جدیدswitch-contextsrc/Secretary/Repository/DoctorSecretaryRepository.php— اضافه:findActiveBySecretary()وfindAllActiveBySecretary()src/Appointment/Entity/Appointment.php— وضعیتهای جدید + فیلدهایbooked_by،commission_rialssrc/Appointment/Controller/AppointmentController.php— منطق کمیسیون + ارسال پیامک اطلاعرسانیsrc/Doctor/Entity/Doctor.php— فیلدهایnotification_mobile،notification_mobile_verifiedsrc/Clinic/Entity/Clinic.php— فیلدهایnotification_mobile،notification_mobile_verified
بکاند (جدید)
src/Auth/Entity/UserActiveContext.php— ذخیره context فعال کاربرsrc/Dashboard/Controller/DashboardController.phpsrc/Appointment/Controller/MyAppointmentsController.phpsrc/Admin/Entity/SiteConfig.php— موجودیت تنظیمات سایت (key-value)src/Admin/Repository/SiteConfigRepository.php— متدهایget(),set(),all()src/Admin/Controller/SiteConfigController.php—GET/PATCH /api/v1/admin/settingssrc/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/settingsassets/admin/components/layout/Sidebar.tsx— تبدیل به پویا + دکمه تغییر محیط کاریassets/admin/pages/DashboardPage.tsx— multi-roleassets/admin/pages/AppointmentsPage.tsx— endpoint پویا + دو نما (جدولی/زمانبندی) + آمار هدر
فرانتاند (جدید)
assets/admin/pages/SelectContextPage.tsx— انتخاب محیط کاری برای کاربران چند-contextassets/admin/pages/MyClinicPage.tsxassets/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-contextdocs/api/appointment.md— اضافه:GET /api/v1/my/appointments، وضعیتهای جدید،booked_by،commission_rialsdocs/api/admin.md— اضافه:GET/PATCH /api/v1/admin/settingsdocs/api/doctor.md— اضافه: endpoint هایnotification-mobiledocs/api/dashboard.md— فایل جدید برای سه endpoint داشبورد