feat: implement realistic data seeding for doctors, clinics, and secretaries

- Added seed_realistic_data.php to clean existing data and populate the database with realistic entries for doctors, clinics, and secretaries.
- Created a structured approach to generate 100 doctors per city with diverse specialties and services.
- Implemented database cleanup routines to ensure a fresh start for data seeding.
- Enhanced the DoctorSecretaryRepository with improved comments for clarity.
This commit is contained in:
hamed
2026-06-15 14:18:25 +03:30
parent 68dfe613a2
commit df7a784701
15 changed files with 3024 additions and 1417 deletions
+200 -61
View File
@@ -32,6 +32,7 @@ PATCH /api/v1/staff/{uuid}/toggle → فعال/غیرفعال
``` ```
**Response GET:** **Response GET:**
```json ```json
{ {
"success": true, "success": true,
@@ -51,6 +52,7 @@ PATCH /api/v1/staff/{uuid}/toggle → فعال/غیرفعال
``` ```
**Response PATCH toggle:** **Response PATCH toggle:**
```json ```json
{ "success": true, "data": { "active": false } } { "success": true, "data": { "active": false } }
``` ```
@@ -58,6 +60,7 @@ PATCH /api/v1/staff/{uuid}/toggle → فعال/غیرفعال
### فایل‌های جدید ### فایل‌های جدید
**`assets/admin/pages/StaffPage.tsx`** — صفحه مدیریت پرسنل: **`assets/admin/pages/StaffPage.tsx`** — صفحه مدیریت پرسنل:
- `useQuery(['staff'])``GET /api/v1/staff``data?.data ?? []` - `useQuery(['staff'])``GET /api/v1/staff``data?.data ?? []`
- جدول با ستون‌ها: نام، سمت، تلفن، کد ملی، وضعیت (badge)، عملیات - جدول با ستون‌ها: نام، سمت، تلفن، کد ملی، وضعیت (badge)، عملیات
- دکمه «افزودن پرسنل» → Modal ایجاد - دکمه «افزودن پرسنل» → Modal ایجاد
@@ -66,6 +69,7 @@ PATCH /api/v1/staff/{uuid}/toggle → فعال/غیرفعال
- حذف سخت نداریم — فقط toggle - حذف سخت نداریم — فقط toggle
**فیلدهای فرم:** **فیلدهای فرم:**
```ts ```ts
interface StaffFormData { interface StaffFormData {
full_name: string; // required full_name: string; // required
@@ -77,9 +81,10 @@ interface StaffFormData {
``` ```
**Zod schema:** **Zod schema:**
```ts ```ts
const schema = z.object({ const schema = z.object({
full_name: z.string().min(2, 'نام حداقل ۲ کاراکتر باید باشد'), full_name: z.string().min(2, "نام حداقل ۲ کاراکتر باید باشد"),
phone: z.string().optional(), phone: z.string().optional(),
job_title: z.string().optional(), job_title: z.string().optional(),
address: z.string().optional(), address: z.string().optional(),
@@ -90,13 +95,22 @@ const schema = z.object({
### Route و Sidebar ### Route و Sidebar
در `App.tsx` اضافه کن: در `App.tsx` اضافه کن:
```tsx ```tsx
<Route path="staff" element={<RoleRoute roles={['doctor', 'clinic']}><StaffPage /></RoleRoute>} /> <Route
path="staff"
element={
<RoleRoute roles={["doctor", "clinic"]}>
<StaffPage />
</RoleRoute>
}
/>
``` ```
در Sidebar (فایل `components/layout/AdminLayout.tsx` یا `Sidebar.tsx`) لینک «پرسنل» را برای نقش‌های `doctor` و `clinic` اضافه کن. در Sidebar (فایل `components/layout/AdminLayout.tsx` یا `Sidebar.tsx`) لینک «پرسنل» را برای نقش‌های `doctor` و `clinic` اضافه کن.
### کامیت ### کامیت
``` ```
git add assets/admin/pages/StaffPage.tsx assets/admin/App.tsx assets/admin/components/layout/... git add assets/admin/pages/StaffPage.tsx assets/admin/App.tsx assets/admin/components/layout/...
git commit -m "feat: add staff management page (TASK-10)" git commit -m "feat: add staff management page (TASK-10)"
@@ -116,6 +130,7 @@ POST /api/v1/subscription-payment → شروع پرداخت { period_uuid, gate
``` ```
**Response GET /plans:** **Response GET /plans:**
```json ```json
{ {
"success": true, "success": true,
@@ -127,8 +142,20 @@ POST /api/v1/subscription-payment → شروع پرداخت { period_uuid, gate
"max_secretaries": 2, "max_secretaries": 2,
"features": { "patient_records": true, "services": true }, "features": { "patient_records": true, "services": true },
"periods": [ "periods": [
{ "uuid": "...", "label": "تریال ۱ ماهه", "duration_months": 1, "price_rials": 0, "is_trial": true }, {
{ "uuid": "...", "label": "۱ ماهه", "duration_months": 1, "price_rials": 250000, "is_trial": false } "uuid": "...",
"label": "تریال ۱ ماهه",
"duration_months": 1,
"price_rials": 0,
"is_trial": true
},
{
"uuid": "...",
"label": "۱ ماهه",
"duration_months": 1,
"price_rials": 250000,
"is_trial": false
}
] ]
} }
] ]
@@ -136,11 +163,16 @@ POST /api/v1/subscription-payment → شروع پرداخت { period_uuid, gate
``` ```
**Response GET /my:** **Response GET /my:**
```json ```json
{ {
"success": true, "success": true,
"data": { "data": {
"plan": { "name": "basic", "level": 1, "features": { "patient_records": true, "services": true } }, "plan": {
"name": "basic",
"level": 1,
"features": { "patient_records": true, "services": true }
},
"period": { "label": "۶ ماهه", "duration_months": 6 }, "period": { "label": "۶ ماهه", "duration_months": 6 },
"is_trial": false, "is_trial": false,
"starts_at": 1718000000, "starts_at": 1718000000,
@@ -156,17 +188,20 @@ POST /api/v1/subscription-payment → شروع پرداخت { period_uuid, gate
**`assets/admin/pages/SubscriptionPage.tsx`** — صفحه اشتراک: **`assets/admin/pages/SubscriptionPage.tsx`** — صفحه اشتراک:
**بخش ۱ — وضعیت فعلی:** **بخش ۱ — وضعیت فعلی:**
- نمایش پلن فعلی (نام، تاریخ انقضا به شمسی، روزهای باقی‌مانده) - نمایش پلن فعلی (نام، تاریخ انقضا به شمسی، روزهای باقی‌مانده)
- اگر `used_trial = false` → دکمه «فعال‌سازی تریال رایگان» (Basic یک ماهه) - اگر `used_trial = false` → دکمه «فعال‌سازی تریال رایگان» (Basic یک ماهه)
- نوار پیشرفت روزهای باقیمانده (اگر `expires_at` دارد) - نوار پیشرفت روزهای باقیمانده (اگر `expires_at` دارد)
**بخش ۲ — کارت‌های پلن:** **بخش ۲ — کارت‌های پلن:**
- سه کارت: Free / Basic / Professional - سه کارت: Free / Basic / Professional
- هر کارت: نام پلن، ویژگی‌ها (لیست چک‌مارک)، دوره‌های قیمت به صورت badge/tab - هر کارت: نام پلن، ویژگی‌ها (لیست چک‌مارک)، دوره‌های قیمت به صورت badge/tab
- دکمه «خرید» برای هر دوره → modal انتخاب gateway → `POST /api/v1/subscription-payment` → redirect به URL برگشتی - دکمه «خرید» برای هر دوره → modal انتخاب gateway → `POST /api/v1/subscription-payment` → redirect به URL برگشتی
- اگر پلن فعلی همان پلن است → دکمه «تمدید» - اگر پلن فعلی همان پلن است → دکمه «تمدید»
**Types جدید (`assets/admin/types/index.ts`):** **Types جدید (`assets/admin/types/index.ts`):**
```ts ```ts
export interface SubscriptionPlan { export interface SubscriptionPlan {
uuid: string; uuid: string;
@@ -197,10 +232,18 @@ export interface MySubscription {
### Route ### Route
```tsx ```tsx
<Route path="subscription" element={<RoleRoute roles={['doctor', 'clinic']}><SubscriptionPage /></RoleRoute>} /> <Route
path="subscription"
element={
<RoleRoute roles={["doctor", "clinic"]}>
<SubscriptionPage />
</RoleRoute>
}
/>
``` ```
### کامیت ### کامیت
``` ```
git commit -m "feat: add subscription management page (TASK-11)" git commit -m "feat: add subscription management page (TASK-11)"
``` ```
@@ -213,7 +256,7 @@ git commit -m "feat: add subscription management page (TASK-11)"
### وضعیت فعلی SecretariesPage.tsx ### وضعیت فعلی SecretariesPage.tsx
فایل موجود است و لیست منشیان را نشان می‌دهد. باید: فایل موجود است و لیست منشی ها را نشان می‌دهد. باید:
1. **Modal ویرایش permissions** اضافه کن: 1. **Modal ویرایش permissions** اضافه کن:
- دکمه «دسترسی‌ها» (ShieldCheckIcon) کنار هر ردیف - دکمه «دسترسی‌ها» (ShieldCheckIcon) کنار هر ردیف
@@ -222,7 +265,12 @@ git commit -m "feat: add subscription management page (TASK-11)"
```tsx ```tsx
// ساختار permissions که باید از API بخوانی و ذخیره کنی // ساختار permissions که باید از API بخوانی و ذخیره کنی
const DEFAULT_PERMISSIONS = { const DEFAULT_PERMISSIONS = {
appointments: { view: true, create: false, cancel: false, update_status: false }, appointments: {
view: true,
create: false,
cancel: false,
update_status: false,
},
addresses: { view: true, create: false, update: false, delete: false }, addresses: { view: true, create: false, update: false, delete: false },
clinic_info: { view: true, update: false }, clinic_info: { view: true, update: false },
insurances: { view: true, create: false, update: false, delete: false }, insurances: { view: true, create: false, update: false, delete: false },
@@ -230,56 +278,61 @@ const DEFAULT_PERMISSIONS = {
const PERMISSION_LABELS = { const PERMISSION_LABELS = {
appointments: { appointments: {
label: 'نوبت‌ها', label: "نوبت‌ها",
actions: [ actions: [
{ key: 'view', label: 'مشاهده' }, { key: "view", label: "مشاهده" },
{ key: 'create', label: 'ایجاد' }, { key: "create", label: "ایجاد" },
{ key: 'cancel', label: 'لغو' }, { key: "cancel", label: "لغو" },
{ key: 'update_status', label: 'تغییر وضعیت' }, { key: "update_status", label: "تغییر وضعیت" },
], ],
}, },
addresses: { addresses: {
label: 'آدرس‌ها', label: "آدرس‌ها",
actions: [ actions: [
{ key: 'view', label: 'مشاهده' }, { key: "view", label: "مشاهده" },
{ key: 'create', label: 'ایجاد' }, { key: "create", label: "ایجاد" },
{ key: 'update', label: 'ویرایش' }, { key: "update", label: "ویرایش" },
{ key: 'delete', label: 'حذف' }, { key: "delete", label: "حذف" },
], ],
}, },
clinic_info: { clinic_info: {
label: 'اطلاعات کلینیک', label: "اطلاعات کلینیک",
actions: [ actions: [
{ key: 'view', label: 'مشاهده' }, { key: "view", label: "مشاهده" },
{ key: 'update', label: 'ویرایش' }, { key: "update", label: "ویرایش" },
], ],
}, },
insurances: { insurances: {
label: 'بیمه‌ها', label: "بیمه‌ها",
actions: [ actions: [
{ key: 'view', label: 'مشاهده' }, { key: "view", label: "مشاهده" },
{ key: 'create', label: 'ایجاد' }, { key: "create", label: "ایجاد" },
{ key: 'update', label: 'ویرایش' }, { key: "update", label: "ویرایش" },
{ key: 'delete', label: 'حذف' }, { key: "delete", label: "حذف" },
], ],
}, },
}; };
``` ```
2. **PATCH `/api/v1/secretary/{uuid}`** با body: 2. **PATCH `/api/v1/secretary/{uuid}`** با body:
```json ```json
{ "permissions": { "appointments": { "view": true, ... }, ... } } { "permissions": { "appointments": { "view": true, ... }, ... } }
``` ```
3. **خطای `ERR_SECRETARY_LIMIT_REACHED` (403)** را هنگام ایجاد منشی handle کن: 3. **خطای `ERR_SECRETARY_LIMIT_REACHED` (403)** را هنگام ایجاد منشی handle کن:
```tsx ```tsx
// در onError useMutation create: // در onError useMutation create:
if (err.code === 'ERR_SECRETARY_LIMIT_REACHED') { if (err.code === "ERR_SECRETARY_LIMIT_REACHED") {
toast.error('سقف تعداد منشی پنل فعلی شما پر است. برای افزودن منشی بیشتر پنل را ارتقا دهید.'); toast.error(
"سقف تعداد منشی پنل فعلی شما پر است. برای افزودن منشی بیشتر پنل را ارتقا دهید.",
);
} }
``` ```
### کامیت ### کامیت
``` ```
git commit -m "feat: add secretary permissions matrix and subscription limit handling (TASK-12)" git commit -m "feat: add secretary permissions matrix and subscription limit handling (TASK-12)"
``` ```
@@ -303,16 +356,16 @@ DELETE /api/v1/service-item/{uuid} → حذف آیتم
``` ```
**Response GET /service-sections:** **Response GET /service-sections:**
```json ```json
{ {
"success": true, "success": true,
"data": [ "data": [{ "uuid": "...", "name": "تزریقات", "active": true }]
{ "uuid": "...", "name": "تزریقات", "active": true }
]
} }
``` ```
**Response GET /service-items/{sectionUuid}:** **Response GET /service-items/{sectionUuid}:**
```json ```json
{ {
"success": true, "success": true,
@@ -333,16 +386,19 @@ DELETE /api/v1/service-item/{uuid} → حذف آیتم
**`assets/admin/pages/ClinicServicesPage.tsx`** — صفحه دو‌بخشی: **`assets/admin/pages/ClinicServicesPage.tsx`** — صفحه دو‌بخشی:
**ستون چپ** — لیست بخش‌ها (ServiceSection): **ستون چپ** — لیست بخش‌ها (ServiceSection):
- لیست ساده با دکمه‌های ویرایش/حذف - لیست ساده با دکمه‌های ویرایش/حذف
- دکمه «+ بخش جدید» → Modal با فیلد `name` - دکمه «+ بخش جدید» → Modal با فیلد `name`
- کلیک روی بخش → بارگذاری آیتم‌های آن در ستون راست - کلیک روی بخش → بارگذاری آیتم‌های آن در ستون راست
**ستون راست** — لیست آیتم‌های بخش انتخابی (ServiceItem): **ستون راست** — لیست آیتم‌های بخش انتخابی (ServiceItem):
- جدول: نام، قیمت (`formatRial()`), پرسنل مسئول، وضعیت - جدول: نام، قیمت (`formatRial()`), پرسنل مسئول، وضعیت
- دکمه «+ آیتم جدید» - دکمه «+ آیتم جدید»
- حذف: اگر `ERR_SERVICE_ITEM_IN_USE` (409) → toast.error('این سرویس در پرونده بیمار استفاده شده و قابل حذف نیست') - حذف: اگر `ERR_SERVICE_ITEM_IN_USE` (409) → toast.error('این سرویس در پرونده بیمار استفاده شده و قابل حذف نیست')
**فرم آیتم:** **فرم آیتم:**
```ts ```ts
interface ServiceItemForm { interface ServiceItemForm {
section_uuid: string; // hidden — از state section_uuid: string; // hidden — از state
@@ -353,6 +409,7 @@ interface ServiceItemForm {
``` ```
**Types جدید:** **Types جدید:**
```ts ```ts
export interface ServiceSection { export interface ServiceSection {
uuid: string; uuid: string;
@@ -371,10 +428,18 @@ export interface ServiceItem {
### Route ### Route
```tsx ```tsx
<Route path="clinic-services" element={<RoleRoute roles={['doctor', 'clinic']}><ClinicServicesPage /></RoleRoute>} /> <Route
path="clinic-services"
element={
<RoleRoute roles={["doctor", "clinic"]}>
<ClinicServicesPage />
</RoleRoute>
}
/>
``` ```
### کامیت ### کامیت
``` ```
git commit -m "feat: add clinic services management page (TASK-13)" git commit -m "feat: add clinic services management page (TASK-13)"
``` ```
@@ -394,6 +459,7 @@ PATCH /api/v1/sms/settings → ذخیره تنظیمات
``` ```
**Response GET /sms/wallet/balance:** **Response GET /sms/wallet/balance:**
```json ```json
{ {
"success": true, "success": true,
@@ -406,18 +472,32 @@ PATCH /api/v1/sms/settings → ذخیره تنظیمات
``` ```
**Response GET /sms/wallet/logs:** **Response GET /sms/wallet/logs:**
```json ```json
{ {
"success": true, "success": true,
"data": [ "data": [
{ "uuid": "...", "type": "credit", "amount_rials": 500000, "description": "شارژ", "created_at": 1718000000 }, {
{ "uuid": "...", "type": "debit", "amount_rials": 250, "description": "ارسال پیامک یادآوری", "created_at": 1718001000 } "uuid": "...",
"type": "credit",
"amount_rials": 500000,
"description": "شارژ",
"created_at": 1718000000
},
{
"uuid": "...",
"type": "debit",
"amount_rials": 250,
"description": "ارسال پیامک یادآوری",
"created_at": 1718001000
}
], ],
"meta": { "totalRecords": 45, "totalPages": 5, "currentPage": 1 } "meta": { "totalRecords": 45, "totalPages": 5, "currentPage": 1 }
} }
``` ```
**Response GET /sms/settings:** **Response GET /sms/settings:**
```json ```json
{ {
"success": true, "success": true,
@@ -435,20 +515,24 @@ PATCH /api/v1/sms/settings → ذخیره تنظیمات
**`assets/admin/pages/SmsWalletPage.tsx`** — صفحه کیف پول پیامک: **`assets/admin/pages/SmsWalletPage.tsx`** — صفحه کیف پول پیامک:
**بخش ۱ — کارت موجودی:** **بخش ۱ — کارت موجودی:**
- نمایش `balance_rials` با `formatRial()` - نمایش `balance_rials` با `formatRial()`
- نمایش `estimated_sms_count` (تعداد پیامک قابل ارسال) - نمایش `estimated_sms_count` (تعداد پیامک قابل ارسال)
- دکمه «شارژ کیف پول» → Modal با فیلد مبلغ + انتخاب gateway → redirect به URL - دکمه «شارژ کیف پول» → Modal با فیلد مبلغ + انتخاب gateway → redirect به URL
**بخش ۲ — تنظیمات:** **بخش ۲ — تنظیمات:**
- Toggle «یادآوری قبل از نوبت» + فیلد عددی «چند ساعت قبل» - Toggle «یادآوری قبل از نوبت» + فیلد عددی «چند ساعت قبل»
- Toggle «پیامک بعد از ویزیت» + textarea متن پیامک - Toggle «پیامک بعد از ویزیت» + textarea متن پیامک
- دکمه ذخیره → `PATCH /api/v1/sms/settings` - دکمه ذخیره → `PATCH /api/v1/sms/settings`
**بخش ۳ — تاریخچه تراکنش‌ها:** **بخش ۳ — تاریخچه تراکنش‌ها:**
- جدول: نوع (badge سبز credit / قرمز debit)، مبلغ، توضیحات، تاریخ - جدول: نوع (badge سبز credit / قرمز debit)، مبلغ، توضیحات، تاریخ
- Pagination - Pagination
**Types جدید:** **Types جدید:**
```ts ```ts
export interface SmsWalletBalance { export interface SmsWalletBalance {
balance_rials: number; balance_rials: number;
@@ -457,7 +541,7 @@ export interface SmsWalletBalance {
} }
export interface SmsWalletLog { export interface SmsWalletLog {
uuid: string; uuid: string;
type: 'credit' | 'debit'; type: "credit" | "debit";
amount_rials: number; amount_rials: number;
description: string; description: string;
created_at: number; created_at: number;
@@ -473,10 +557,18 @@ export interface SmsSettings {
### Route ### Route
```tsx ```tsx
<Route path="sms-wallet" element={<RoleRoute roles={['doctor', 'clinic']}><SmsWalletPage /></RoleRoute>} /> <Route
path="sms-wallet"
element={
<RoleRoute roles={["doctor", "clinic"]}>
<SmsWalletPage />
</RoleRoute>
}
/>
``` ```
### کامیت ### کامیت
``` ```
git commit -m "feat: add SMS wallet and settings page (TASK-14)" git commit -m "feat: add SMS wallet and settings page (TASK-14)"
``` ```
@@ -497,13 +589,18 @@ PATCH /api/v1/session/{uuid} → ویرایش مراجعه
``` ```
**Response GET /patients:** **Response GET /patients:**
```json ```json
{ {
"success": true, "success": true,
"data": [ "data": [
{ {
"uuid": "...", "uuid": "...",
"user": { "uuid": "...", "fullName": "علی محمدی", "phone": "09121234567" }, "user": {
"uuid": "...",
"fullName": "علی محمدی",
"phone": "09121234567"
},
"created_at": 1710000000 "created_at": 1710000000
} }
], ],
@@ -512,6 +609,7 @@ PATCH /api/v1/session/{uuid} → ویرایش مراجعه
``` ```
**Response GET /patient/{uuid}/sessions:** **Response GET /patient/{uuid}/sessions:**
```json ```json
{ {
"success": true, "success": true,
@@ -535,6 +633,7 @@ PATCH /api/v1/session/{uuid} → ویرایش مراجعه
``` ```
**Request POST /patient/{uuid}/session:** **Request POST /patient/{uuid}/session:**
```json ```json
{ {
"visit_price_rials": 500000, "visit_price_rials": 500000,
@@ -542,9 +641,7 @@ PATCH /api/v1/session/{uuid} → ویرایش مراجعه
"supplementary_discount_percent": 0, "supplementary_discount_percent": 0,
"payment_method": "cash", "payment_method": "cash",
"notes": "...", "notes": "...",
"services": [ "services": [{ "service_item_uuid": "...", "staff_uuid": null }]
{ "service_item_uuid": "...", "staff_uuid": null }
]
} }
``` ```
@@ -553,12 +650,14 @@ PATCH /api/v1/session/{uuid} → ویرایش مراجعه
**`assets/admin/pages/MyPatientsPage.tsx`** — بازنویسی کامل: **`assets/admin/pages/MyPatientsPage.tsx`** — بازنویسی کامل:
**نمای لیست بیماران:** **نمای لیست بیماران:**
- جستجو (`?search=`) با debounce ۴۰۰ms - جستجو (`?search=`) با debounce ۴۰۰ms
- جدول: نام بیمار، تلفن، تاریخ ثبت، عملیات - جدول: نام بیمار، تلفن، تاریخ ثبت، عملیات
- دکمه «+ بیمار جدید» → Modal با `SearchableSelect` برای پیدا کردن کاربر (search by phone) - دکمه «+ بیمار جدید» → Modal با `SearchableSelect` برای پیدا کردن کاربر (search by phone)
- کلیک روی ردیف → نمای جزئیات پرونده (state محلی یا route `/my-patients/:uuid`) - کلیک روی ردیف → نمای جزئیات پرونده (state محلی یا route `/my-patients/:uuid`)
**نمای جزئیات پرونده:** **نمای جزئیات پرونده:**
- مشخصات بیمار + دکمه «+ مراجعه جدید» - مشخصات بیمار + دکمه «+ مراجعه جدید»
- لیست مراجعات (accordion یا جدول): - لیست مراجعات (accordion یا جدول):
- قیمت ویزیت، تخفیف بیمه پایه، تخفیف تکمیلی، مجموع سرویس‌ها، قیمت نهایی - قیمت ویزیت، تخفیف بیمه پایه، تخفیف تکمیلی، مجموع سرویس‌ها، قیمت نهایی
@@ -567,26 +666,34 @@ PATCH /api/v1/session/{uuid} → ویرایش مراجعه
- دکمه ویرایش (فقط `notes` و `payment_method`) - دکمه ویرایش (فقط `notes` و `payment_method`)
**Modal ثبت مراجعه جدید:** **Modal ثبت مراجعه جدید:**
```ts ```ts
interface SessionFormData { interface SessionFormData {
visit_price_rials: number; visit_price_rials: number;
base_insurance_discount_percent: number; // 0-100 base_insurance_discount_percent: number; // 0-100
supplementary_discount_percent: number; // 0-100 supplementary_discount_percent: number; // 0-100
payment_method: 'cash' | 'card' | 'insurance' | 'online' | 'pending'; payment_method: "cash" | "card" | "insurance" | "online" | "pending";
notes?: string; notes?: string;
services: { service_item_uuid: string; staff_uuid?: string }[]; services: { service_item_uuid: string; staff_uuid?: string }[];
} }
``` ```
- محاسبه `final_price_rials` به صورت live در فرانت: - محاسبه `final_price_rials` به صورت live در فرانت:
```ts ```ts
const afterBase = visitPrice * (1 - baseDiscount / 100); const afterBase = visitPrice * (1 - baseDiscount / 100);
const afterSupp = afterBase * (1 - suppDiscount / 100); const afterSupp = afterBase * (1 - suppDiscount / 100);
const servicesTotal = services.reduce((sum, s) => sum + (s.price_rials || 0), 0); const servicesTotal = services.reduce(
(sum, s) => sum + (s.price_rials || 0),
0,
);
const finalPrice = Math.round(afterSupp) + servicesTotal; const finalPrice = Math.round(afterSupp) + servicesTotal;
``` ```
- افزودن سرویس: `SearchableSelect` از `GET /api/v1/service-items/{sectionUuid}` (باید ابتدا بخش را انتخاب کند) - افزودن سرویس: `SearchableSelect` از `GET /api/v1/service-items/{sectionUuid}` (باید ابتدا بخش را انتخاب کند)
**Types جدید:** **Types جدید:**
```ts ```ts
export interface PatientRecord { export interface PatientRecord {
uuid: string; uuid: string;
@@ -612,10 +719,18 @@ export interface PatientSession {
```tsx ```tsx
// مسیر موجود کافی است: // مسیر موجود کافی است:
<Route path="my-patients" element={<RoleRoute roles={['doctor', 'secretary', 'clinic']}><MyPatientsPage /></RoleRoute>} /> <Route
path="my-patients"
element={
<RoleRoute roles={["doctor", "secretary", "clinic"]}>
<MyPatientsPage />
</RoleRoute>
}
/>
``` ```
### کامیت ### کامیت
``` ```
git commit -m "feat: implement patient records and sessions page (TASK-15)" git commit -m "feat: implement patient records and sessions page (TASK-15)"
``` ```
@@ -633,6 +748,7 @@ GET /api/v1/admin/dashboard/charts?from=UNIX&to=UNIX → سری‌های زما
``` ```
**فیلدهای جدید در Response clinic/doctor:** **فیلدهای جدید در Response clinic/doctor:**
```json ```json
{ {
"stats": { "stats": {
@@ -646,6 +762,7 @@ GET /api/v1/admin/dashboard/charts?from=UNIX&to=UNIX → سری‌های زما
``` ```
**Response GET /admin/dashboard/charts?from=UNIX&to=UNIX:** **Response GET /admin/dashboard/charts?from=UNIX&to=UNIX:**
```json ```json
{ {
"success": true, "success": true,
@@ -670,21 +787,23 @@ GET /api/v1/admin/dashboard/charts?from=UNIX&to=UNIX → سری‌های زما
**۱. فیلتر زمانی — اضافه کن:** **۱. فیلتر زمانی — اضافه کن:**
```tsx ```tsx
type Preset = 'this_week' | 'this_month' | '3_months' | 'custom'; type Preset = "this_week" | "this_month" | "3_months" | "custom";
// تبدیل به Unix: // تبدیل به Unix:
function getRange(preset: Preset): { from: number; to: number } { function getRange(preset: Preset): { from: number; to: number } {
const now = Math.floor(Date.now() / 1000); const now = Math.floor(Date.now() / 1000);
switch (preset) { switch (preset) {
case 'this_week': case "this_week":
// شنبه‌ی این هفته — ساده‌ترین راه: now - روزهای گذشته از شنبه // شنبه‌ی این هفته — ساده‌ترین راه: now - روزهای گذشته از شنبه
const dayOfWeek = new Date().getDay(); // 0=Sun,6=Sat const dayOfWeek = new Date().getDay(); // 0=Sun,6=Sat
const daysSinceSat = (dayOfWeek + 1) % 7; const daysSinceSat = (dayOfWeek + 1) % 7;
return { from: now - daysSinceSat * 86400, to: now }; return { from: now - daysSinceSat * 86400, to: now };
case 'this_month': case "this_month":
const d = new Date(); d.setDate(1); d.setHours(0,0,0,0); const d = new Date();
d.setDate(1);
d.setHours(0, 0, 0, 0);
return { from: Math.floor(d.getTime() / 1000), to: now }; return { from: Math.floor(d.getTime() / 1000), to: now };
case '3_months': case "3_months":
return { from: now - 90 * 86400, to: now }; return { from: now - 90 * 86400, to: now };
default: default:
return { from: now - 30 * 86400, to: now }; return { from: now - 30 * 86400, to: now };
@@ -693,21 +812,27 @@ function getRange(preset: Preset): { from: number; to: number } {
``` ```
preset buttons: preset buttons:
```tsx ```tsx
<div style={{ display: 'flex', gap: 8, marginBottom: 16 }}> <div style={{ display: "flex", gap: 8, marginBottom: 16 }}>
{(['this_week', 'this_month', '3_months'] as Preset[]).map(p => ( {(["this_week", "this_month", "3_months"] as Preset[]).map((p) => (
<button <button
key={p} key={p}
className={`btn sm ${preset === p ? 'primary' : ''}`} className={`btn sm ${preset === p ? "primary" : ""}`}
onClick={() => setPreset(p)} onClick={() => setPreset(p)}
> >
{p === 'this_week' ? 'این هفته' : p === 'this_month' ? 'این ماه' : '۳ ماه'} {p === "this_week"
? "این هفته"
: p === "this_month"
? "این ماه"
: "۳ ماه"}
</button> </button>
))} ))}
</div> </div>
``` ```
**۲. فیلدهای جدید در کارت‌های آمار:** **۲. فیلدهای جدید در کارت‌های آمار:**
- کارت «موجودی پیامک»: `stats.sms_wallet_balance``formatRial()` - کارت «موجودی پیامک»: `stats.sms_wallet_balance``formatRial()`
- کارت «بیماران یکتا»: `stats.unique_patients_count` - کارت «بیماران یکتا»: `stats.unique_patients_count`
- کارت «درآمد دوره»: `stats.revenue_period_rials``formatRial()` - کارت «درآمد دوره»: `stats.revenue_period_rials``formatRial()`
@@ -715,6 +840,7 @@ preset buttons:
**۳. نمودارها (برای admin role):** **۳. نمودارها (برای admin role):**
از **SVG inline** موجود در DashboardPage استفاده کن (کلاس `SvgLineChart` که در فایل موجود است): از **SVG inline** موجود در DashboardPage استفاده کن (کلاس `SvgLineChart` که در فایل موجود است):
```tsx ```tsx
// appointments_by_day و revenue_by_day داده‌های خطی دارند // appointments_by_day و revenue_by_day داده‌های خطی دارند
// فقط آرایه عددی را استخراج کن: // فقط آرایه عددی را استخراج کن:
@@ -726,11 +852,18 @@ const revData = charts?.revenue_by_day?.map((d: any) => d.amount) ?? [];
``` ```
**subscription_sales_by_plan** — جدول ساده: **subscription_sales_by_plan** — جدول ساده:
```tsx ```tsx
<table> <table>
<thead><tr><th>پلن</th><th>تعداد</th><th>درآمد</th></tr></thead> <thead>
<tr>
<th>پلن</th>
<th>تعداد</th>
<th>درآمد</th>
</tr>
</thead>
<tbody> <tbody>
{subData.map(row => ( {subData.map((row) => (
<tr key={row.plan}> <tr key={row.plan}>
<td>{row.plan}</td> <td>{row.plan}</td>
<td>{row.count}</td> <td>{row.count}</td>
@@ -742,25 +875,31 @@ const revData = charts?.revenue_by_day?.map((d: any) => d.amount) ?? [];
``` ```
**API call با from/to:** **API call با from/to:**
```tsx ```tsx
const [preset, setPreset] = useState<Preset>('this_month'); const [preset, setPreset] = useState<Preset>("this_month");
const range = getRange(preset); const range = getRange(preset);
// برای admin: // برای admin:
const { data: charts } = useQuery({ const { data: charts } = useQuery({
queryKey: ['dashboard-charts', range.from, range.to], queryKey: ["dashboard-charts", range.from, range.to],
queryFn: () => api.get(`/api/v1/admin/dashboard/charts?from=${range.from}&to=${range.to}`), queryFn: () =>
api.get(
`/api/v1/admin/dashboard/charts?from=${range.from}&to=${range.to}`,
),
}); });
// برای doctor: // برای doctor:
const { data: doctorDash } = useQuery({ const { data: doctorDash } = useQuery({
queryKey: ['dashboard-doctor', range.from, range.to], queryKey: ["dashboard-doctor", range.from, range.to],
queryFn: () => api.get(`/api/v1/dashboard/doctor?from=${range.from}&to=${range.to}`), queryFn: () =>
enabled: primaryRole === 'doctor', api.get(`/api/v1/dashboard/doctor?from=${range.from}&to=${range.to}`),
enabled: primaryRole === "doctor",
}); });
``` ```
### کامیت ### کامیت
``` ```
git commit -m "feat: add date range filter and new stats to dashboard (TASK-16)" git commit -m "feat: add date range filter and new stats to dashboard (TASK-16)"
``` ```
@@ -791,7 +930,7 @@ git commit -m "feat: add date range filter and new stats to dashboard (TASK-16)"
## فایل‌های مرجع مهم ## فایل‌های مرجع مهم
| فایل | نقش | | فایل | نقش |
|------|-----| | ------------------------------------------------- | ------------------------------------------------------------ |
| `assets/admin/lib/api.ts` | fetch wrapper — `api.get/post/patch/delete` | | `assets/admin/lib/api.ts` | fetch wrapper — `api.get/post/patch/delete` |
| `assets/admin/lib/utils.ts` | `formatRial`, `formatDate`, `formatDateTime`, `formatNumber` | | `assets/admin/lib/utils.ts` | `formatRial`, `formatDate`, `formatDateTime`, `formatNumber` |
| `assets/admin/types/index.ts` | همه TypeScript interfaces | | `assets/admin/types/index.ts` | همه TypeScript interfaces |
+14 -3
View File
@@ -15,7 +15,7 @@
قوانین: قوانین:
| ایجادکننده | `owner_type` | `clinic_id` | | ایجادکننده | `owner_type` | `clinic_id` |
|-----------|-------------|-------------| | ---------- | ------------ | ------------- |
| خود دکتر | `'doctor'` | `null` | | خود دکتر | `'doctor'` | `null` |
| کلینیک | `'clinic'` | `<id کلینیک>` | | کلینیک | `'clinic'` | `<id کلینیک>` |
@@ -24,7 +24,7 @@
## فایل‌های مرتبط ## فایل‌های مرتبط
| فایل | نقش | | فایل | نقش |
|------|-----| | --------------------------------------------------------- | ---------------------------------------------------------------- |
| `src/Secretary/Entity/DoctorSecretary.php` | entity اصلی — باید `owner_type` و `clinic_id` اضافه شود | | `src/Secretary/Entity/DoctorSecretary.php` | entity اصلی — باید `owner_type` و `clinic_id` اضافه شود |
| `src/Secretary/Controller/SecretaryController.php` | ایجاد و لیست منشی — باید scope را تنظیم کند | | `src/Secretary/Controller/SecretaryController.php` | ایجاد و لیست منشی — باید scope را تنظیم کند |
| `src/Secretary/Repository/DoctorSecretaryRepository.php` | query ها — باید scope-aware شوند | | `src/Secretary/Repository/DoctorSecretaryRepository.php` | query ها — باید scope-aware شوند |
@@ -38,6 +38,7 @@
## وضعیت فعلی ## وضعیت فعلی
### Entity (بدون scope) ### Entity (بدون scope)
```php ```php
// src/Secretary/Entity/DoctorSecretary.php // src/Secretary/Entity/DoctorSecretary.php
// هیچ فیلد owner_type یا clinic_id وجود ندارد // هیچ فیلد owner_type یا clinic_id وجود ندارد
@@ -54,6 +55,7 @@ class DoctorSecretary
``` ```
### Repository — scope-blind ### Repository — scope-blind
```php ```php
public function findActiveBySecretary(User $user): ?DoctorSecretary public function findActiveBySecretary(User $user): ?DoctorSecretary
{ {
@@ -63,6 +65,7 @@ public function findActiveBySecretary(User $user): ?DoctorSecretary
``` ```
### Controller — ایجاد بدون scope ### Controller — ایجاد بدون scope
```php ```php
// create() در SecretaryController // create() در SecretaryController
// هیچ owner_type یا clinic_id ست نمی‌شود // هیچ owner_type یا clinic_id ست نمی‌شود
@@ -70,6 +73,7 @@ $secretary = new DoctorSecretary($doctor, $secretaryUser);
``` ```
### Auth context builder — scope-blind ### Auth context builder — scope-blind
```php ```php
// AuthController::buildContexts() // AuthController::buildContexts()
foreach ($this->secretaryRepo->findAllActiveBySecretary($user) as $rel) { foreach ($this->secretaryRepo->findAllActiveBySecretary($user) as $rel) {
@@ -86,6 +90,7 @@ foreach ($this->secretaryRepo->findAllActiveBySecretary($user) as $rel) {
``` ```
### Appointment فیلتر — فقط یک رابطه ### Appointment فیلتر — فقط یک رابطه
```php ```php
// MyAppointmentsController — منشی فقط به نوبت‌های یک دکتر دسترسی دارد // MyAppointmentsController — منشی فقط به نوبت‌های یک دکتر دسترسی دارد
} elseif (in_array('ROLE_SECRETARY', $roles, true)) { } elseif (in_array('ROLE_SECRETARY', $roles, true)) {
@@ -128,6 +133,7 @@ public function getClinic(): ?Clinic { return $this->clinic; }
``` ```
در `toArray()` هم اضافه شود: در `toArray()` هم اضافه شود:
```php ```php
'owner_type' => $this->ownerType, 'owner_type' => $this->ownerType,
'clinic_uuid' => $this->clinic?->getUuid(), 'clinic_uuid' => $this->clinic?->getUuid(),
@@ -136,6 +142,7 @@ public function getClinic(): ?Clinic { return $this->clinic; }
### ۲. Migration ### ۲. Migration
بعد از تغییر Entity: بعد از تغییر Entity:
```bash ```bash
ddev exec php bin/console doctrine:migrations:diff --no-interaction 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:migrations:migrate --no-interaction
@@ -206,7 +213,8 @@ public function findDoctorsBySecretaryInClinic(User $user, Clinic $clinic): arra
} }
``` ```
همچنین `findByClinic` باید فقط منشیان با `owner_type='clinic'` را برگرداند: همچنین `findByClinic` باید فقط منشی ها با `owner_type='clinic'` را برگرداند:
```php ```php
public function findByClinic(Clinic $clinic): array public function findByClinic(Clinic $clinic): array
{ {
@@ -225,6 +233,7 @@ public function findByClinic(Clinic $clinic): array
### ۴. SecretaryController: ایجاد با scope ### ۴. SecretaryController: ایجاد با scope
در `create()`: در `create()`:
- اگر `currentUser` دارای `ROLE_CLINIC` بود → `ownerType = 'clinic'`، `clinic = clinicRepo->findByUser($currentUser)` - اگر `currentUser` دارای `ROLE_CLINIC` بود → `ownerType = 'clinic'`، `clinic = clinicRepo->findByUser($currentUser)`
- اگر `ROLE_DOCTOR` بود → `ownerType = 'doctor'`، `clinic = null` - اگر `ROLE_DOCTOR` بود → `ownerType = 'doctor'`، `clinic = null`
@@ -238,6 +247,7 @@ if ($currentUser->hasRole('ROLE_CLINIC')) {
``` ```
همچنین `canManageDoctor()` باید scope را چک کند: همچنین `canManageDoctor()` باید scope را چک کند:
- `ROLE_DOCTOR` فقط می‌تواند روابط `owner_type='doctor'` را مدیریت کند - `ROLE_DOCTOR` فقط می‌تواند روابط `owner_type='doctor'` را مدیریت کند
- `ROLE_CLINIC` فقط می‌تواند روابط `owner_type='clinic'` متعلق به کلینیک خودش را مدیریت کند - `ROLE_CLINIC` فقط می‌تواند روابط `owner_type='clinic'` متعلق به کلینیک خودش را مدیریت کند
@@ -278,6 +288,7 @@ foreach ($this->secretaryRepo->findAllActiveBySecretary($user) as $rel) {
### ۶. Appointment و Dashboard: scope-aware ### ۶. Appointment و Dashboard: scope-aware
در `MyAppointmentsController` و `DashboardController` به جای `findActiveBySecretary`: در `MyAppointmentsController` و `DashboardController` به جای `findActiveBySecretary`:
- اگر `db_uuid` در JWT به یک کلینیک اشاره دارد → همه دکترهای کلینیک که این منشی به آن‌ها متصل است - اگر `db_uuid` در JWT به یک کلینیک اشاره دارد → همه دکترهای کلینیک که این منشی به آن‌ها متصل است
- اگر به یک دکتر اشاره دارد → فقط همان دکتر با `owner_type='doctor'` - اگر به یک دکتر اشاره دارد → فقط همان دکتر با `owner_type='doctor'`
+280
View File
@@ -0,0 +1,280 @@
# Seed واقعی دیتا — پاک‌سازی و ایجاد ۱۰۰ دکتر به ازای هر شهر
## زمینه
پروژه ClinicPro نیاز به دیتای واقعی و کامل برای تست و نمایش دارد. در حال حاضر دیتا ناقص یا تست‌نشده است. باید دیتابیس از دکتر، کلینیک، منشی و بیمار پاک شود، سپس دیتای واقعی با پروفایل کامل ایجاد شود.
## هدف
۱. پاک‌سازی کامل: users، doctors، clinics، patient_records، doctor_secretaries
۲. ایجاد یک **کلینیک نمونه کامل** با همه فیلدها
۳. ایجاد یک **دکتر نمونه کامل** با برنامه کاری و منشی
۴. ایجاد **۱۰۰ دکتر به ازای هر شهر** در تخصص‌های مختلف
## ساختار دیتابیس (جداول واقعی)
```
users → id, uuid, mobile_number, password_hash, real_name, roles JSON, status
doctors → id, uuid, user_id, name, gender, medical_system_code, mobile_number, activity_time, degree, info, doctor_rate, doctor_rate_percentage, active_doctor_appointment, representation_id
clinics → id, uuid, user_id, name, info, address, telephone, is_24_7, working_days, latitude, longitude, city_id, province_id, representation_id, is_active
doctor_addresses → id, uuid, doctor_id, clinic_id, city_id, province_id, type('personal'|'clinic'), name, address, telephone
weekly_schedules → id, uuid, doctor_id, setting (JSON — آرایه ۷ روز)
doctor_specialties → doctor_id, specialty_id
doctor_expertise → doctor_id, service_id
doctor_insurances → doctor_id, insurance_id
doctor_cities → doctor_id, city_id
doctor_provinces → doctor_id, province_id
clinic_doctors → clinic_id, doctor_id
clinic_specialties → clinic_id, specialty_id
clinic_services → clinic_id, service_id
clinic_insurances → clinic_id, insurance_id
doctor_secretaries → id, uuid, doctor_id, secretary_id, active, owner_type, clinic_id
patient_records → id, uuid, user_id, entity_type, entity_id, created_by_type, created_by_id
```
## IDهای واقعی lookup tables
### شهرها (cities.id)
```
101=تبریز 102=ارومیه 103=اردبیل 104=اصفهان 105=کرج
106=ایلام 107=بوشهر 108=تهران 109=شهرکرد 110=بیرجند
111=مشهد 112=بجنورد 113=اهواز 114=زنجان 115=سمنان
116=زاهدان 117=شیراز 118=قزوین 119=قم 120=سنندج
121=کرمان 122=کرمانشاه 123=یاسوج 125=گرگان 126=رشت
127=خرم‌آباد 128=ساری 129=اراک 130=بندرعباس 131=همدان 132=یزد
```
### استان‌ها (provinces.id)
```
1=آذربایجان شرقی 2=آذربایجان غربی 3=اردبیل 4=اصفهان 5=البرز
6=ایلام 7=بوشهر 8=تهران 9=چهارمحال 10=خراسان جنوبی
11=خراسان رضوی 12=خراسان شمالی 13=خوزستان 14=زنجان 15=سمنان
16=سیستان 17=فارس 18=قزوین 19=قم 20=کردستان
21=کرمان 22=کرمانشاه 23=کهگیلویه 24=گلستان 25=گیلان
26=لرستان 27=مازندران 28=مرکزی 29=هرمزگان 30=همدان 100=یزد
```
نگاشت شهر → استان:
`101→1, 102→2, 103→3, 104→4, 105→5, 106→6, 107→7, 108→8, 109→9, 110→10, 111→11, 112→12, 113→13, 114→14, 115→15, 116→16, 117→17, 118→18, 119→19, 120→20, 121→21, 122→22, 123→23, 125→24, 126→25, 127→26, 128→27, 129→28, 130→29, 131→30, 132→100`
### تخصص‌ها (specialties.id) — دسته‌بندی برای توزیع واقعی
```
داخلی: 1,2,3,4,5,6,7,8,9,10,11,12
جراحی: 20,21,22,23,24,25,26,27,28,29,30
اطفال: 40,41,42,43,44,45
زنان: 60,61,62,63,64,65
روانپزشکی: 70,71,72,73,74
پوست: 80,81,82,83,84
چشم: 90,91,92,93,94,95,96,97,98
ENT: 100,101,102,103,104
ارتوپدی: 110,111,112,113,114,115,116
اورولوژی: 120,121,122,123,124
رادیولوژی: 130,131,132,133,134
انکولوژی: 150,151,152
طب فیزیکی: 170,171,172,173
دندانپزشکی: 180,181,182,183,184,185,186,187,188,189
اورژانس/عمومی: 190,200
```
### خدمات پزشکی (doctor_services.id) — نمونه‌هایی برای هر تخصص
```
قلب: 101,102,103,104,105,110
گوارش: 201,202,203,210
ریه: 301,302,303,307
کلیه: 401,402,403,406
غدد: 501,502,503,505,506
روماتولوژی: 601,602,603,604
خون: 701,702,703,704
نورولوژی: 801,802,803,804,805
آلرژی: 901,902,903,905
عفونی: 1001,1002,1003
جراحی عمومی: 2101,2102,2103,2104,2105,2113
جراحی مغز: 2201,2202,2203,2207
```
### بیمه‌ها (insurances.id)
```
1=تأمین اجتماعی 2=خدمات درمانی 3=نیروهای مسلح 4=کمیته امداد
5=بهزیستی 6=بیمه روستایی 7=ایران 8=آسیا 9=البرز
10=پارسیان 11=سامان 12=میهن 13=دانا 14=رازی 15=کوثر
```
## ساختار weekly_schedules.setting (JSON)
آرایه ۷ عنصر (شنبه تا جمعه). هر عنصر:
```json
{
"sessions": [
{
"active": true,
"location_id": <doctor_address.id>,
"start_time": "09:00",
"end_time": "13:00",
"duration_per_patient": 20,
"has_rest": true,
"rest_interval": 60,
"time_to_rest": 10,
"patient_limit": null
}
]
}
```
روزهای بدون کار: `{"sessions": []}`
## وظایف
### ۱. ایجاد فایل seed_realistic_data.php
فایل را در ریشه پروژه (`/var/www/html/seed_realistic_data.php`) بساز. این فایل باید:
#### بخش A — پاک‌سازی (به ترتیب FK)
```php
// حذف به ترتیب وابستگی FK
$conn->executeStatement('SET FOREIGN_KEY_CHECKS=0');
$conn->executeStatement('DELETE FROM weekly_schedules WHERE doctor_id IN (SELECT id FROM doctors)');
$conn->executeStatement('DELETE FROM doctor_addresses');
$conn->executeStatement('DELETE FROM doctor_specialties');
$conn->executeStatement('DELETE FROM doctor_expertise');
$conn->executeStatement('DELETE FROM doctor_insurances');
$conn->executeStatement('DELETE FROM doctor_cities');
$conn->executeStatement('DELETE FROM doctor_provinces');
$conn->executeStatement('DELETE FROM doctor_secretaries');
$conn->executeStatement('DELETE FROM clinic_doctors');
$conn->executeStatement('DELETE FROM clinic_specialties');
$conn->executeStatement('DELETE FROM clinic_services');
$conn->executeStatement('DELETE FROM clinic_insurances');
$conn->executeStatement('DELETE FROM patient_records');
$conn->executeStatement('DELETE FROM appointments');
$conn->executeStatement('DELETE FROM payments');
// حذف doctors, clinics, users (به جز ادمین‌ها)
$conn->executeStatement("DELETE d FROM doctors d");
$conn->executeStatement("DELETE c FROM clinics c");
$conn->executeStatement("DELETE u FROM users u WHERE JSON_CONTAINS(u.roles, '\"ROLE_DOCTOR\"') OR JSON_CONTAINS(u.roles, '\"ROLE_CLINIC\"') OR JSON_CONTAINS(u.roles, '\"ROLE_PATIENT\"') OR JSON_CONTAINS(u.roles, '\"ROLE_SECRETARY\"')");
$conn->executeStatement('SET FOREIGN_KEY_CHECKS=1');
```
#### بخش B — کلینیک نمونه کامل (تبریز)
یک کلینیک با همه فیلدها:
- نام: کلینیک تخصصی امید تبریز
- city_id=101, province_id=1
- representation_id=101 (تبریز)
- تمام specialties داخلی + قلب
- بیمه‌های 1,2,3,7,8,9
- خدمات 201,202,203,101,102,103
#### بخش C — دکتر نمونه کامل (تبریز)
دکتر کامل با:
- تخصص قلب (id=3) + داخلی (id=2)
- بیمه‌های 1,2,3,7
- خدمات 101,102,103,104,110
- آدرس مطب در تبریز + آدرس کلینیک امید
- برنامه کاری: شنبه تا چهارشنبه ۹-۱۳ و ۱۴-۱۸، پنجشنبه ۹-۱۳، جمعه تعطیل
- یک منشی
- representation_id=101
#### بخش D — ۱۰۰ دکتر برای هر شهر (bulk generation)
برای شهرهای: `[101,102,103,104,105,108,111,113,117,121,122,126,128,131,132]` (۱۵ شهر مهم)
هر دکتر باید داشته باشد:
- user با موبایل منحصربه‌فرد (فرمت: `09XXXXXXXXX` — از ۰۹۱۰۰۱۰۰۰۰۰ به بالا)
- نام واقعی فارسی (از لیست ۱۰۰ نام ایرانی)
- پروفایل کامل: gender, degree, medical_system_code, info, rate
- تخصص (توزیع واقعی بین تخصص‌ها)
- ۲-۳ خدمات متناسب با تخصص
- بیمه‌های 1,2 (پایه) + یک بیمه تکمیلی تصادفی
- یک آدرس مطب در شهرش
- برنامه کاری (۵ روز کاری)
- representation_id برابر با id شهر
**لیست نام‌های واقعی ایرانی (برای استفاده در generator):**
```php
$firstNames = ['علی', 'محمد', 'حسین', 'رضا', 'امیر', 'مهدی', 'سعید', 'احمد', 'حسن', 'جواد',
'فاطمه', 'زهرا', 'مریم', 'سارا', 'نگین', 'الهه', 'نیلوفر', 'شیما', 'لیلا', 'نازنین',
'کریم', 'ابراهیم', 'اکبر', 'ناصر', 'پیمان', 'بهروز', 'شاهین', 'کاوه', 'آرش', 'سهراب',
'مینا', 'آزاده', 'ندا', 'رویا', 'پریسا', 'گلناز', 'سپیده', 'بهاره', 'شادی', 'فریده'];
$lastNames = ['رضایی', 'محمدی', 'حسینی', 'علوی', 'موسوی', 'احمدی', 'کریمی', 'صادقی', 'جعفری',
'مرادی', 'نجفی', 'تبریزی', 'اصفهانی', 'مشهدی', 'شیرازی', 'کرمانی', 'رشیدی', 'نظری',
'ابراهیمی', 'قاسمی', 'یوسفی', 'منصوری', 'زارعی', 'ملکی', 'بهرامی', 'سلیمانی', 'نوری'];
```
**توزیع واقعی تخصص‌ها در ۱۰۰ دکتر:**
```php
$specialtyGroups = [
['specs' => [200], 'services' => [], 'degree' => 'general', 'count' => 15], // عمومی ۱۵
['specs' => [2,3], 'services' => [101,102,103,110], 'degree' => 'specialist', 'count' => 10], // داخلی+قلب ۱۰
['specs' => [4], 'services' => [201,202,203,210], 'degree' => 'specialist', 'count' => 6], // گوارش ۶
['specs' => [10], 'services' => [801,802,803,805], 'degree' => 'specialist', 'count' => 6], // نورولوژی ۶
['specs' => [21], 'services' => [2101,2102,2103,2113], 'degree' => 'specialist', 'count' => 8],// جراحی ۸
['specs' => [61], 'services' => [], 'degree' => 'specialist', 'count' => 8], // زنان ۸
['specs' => [41], 'services' => [], 'degree' => 'specialist', 'count' => 8], // اطفال ۸
['specs' => [111], 'services' => [], 'degree' => 'specialist', 'count' => 7], // ارتوپدی ۷
['specs' => [81,82], 'services' => [], 'degree' => 'specialist', 'count' => 6], // پوست ۶
['specs' => [91], 'services' => [], 'degree' => 'specialist', 'count' => 6], // چشم ۶
['specs' => [101], 'services' => [], 'degree' => 'specialist', 'count' => 5], // ENT ۵
['specs' => [71], 'services' => [], 'degree' => 'specialist', 'count' => 5], // روانپزشکی ۵
['specs' => [121], 'services' => [], 'degree' => 'specialist', 'count' => 4], // اورولوژی ۴
['specs' => [181], 'services' => [], 'degree' => 'dentist', 'count' => 6], // دندانپزشکی ۶
];
// جمع = 100
```
**فرمت برنامه کاری (۷ روز شنبه تا جمعه):**
```php
function makeSchedule(int $addressId): string {
$days = [];
for ($i = 0; $i < 7; $i++) {
if ($i === 6) { // جمعه تعطیل
$days[] = ['sessions' => []];
} elseif ($i === 5) { // پنجشنبه نیمه‌وقت
$days[] = ['sessions' => [
['active' => true, 'location_id' => $addressId, 'start_time' => '09:00',
'end_time' => '13:00', 'duration_per_patient' => 20,
'has_rest' => false, 'rest_interval' => 60, 'time_to_rest' => 10, 'patient_limit' => null]
]];
} else { // شنبه تا چهارشنبه
$days[] = ['sessions' => [
['active' => true, 'location_id' => $addressId, 'start_time' => '09:00',
'end_time' => '13:00', 'duration_per_patient' => 20,
'has_rest' => true, 'rest_interval' => 60, 'time_to_rest' => 10, 'patient_limit' => null],
['active' => true, 'location_id' => $addressId, 'start_time' => '14:00',
'end_time' => '18:00', 'duration_per_patient' => 20,
'has_rest' => false, 'rest_interval' => 60, 'time_to_rest' => 10, 'patient_limit' => null],
]];
}
}
return json_encode($days, JSON_UNESCAPED_UNICODE);
}
```
## نکات مهم اجرا
- **نام فایل PHP:** `seed_realistic_data.php` در ریشه پروژه
- **password_hash:** از `password_hash('Test@1234', PASSWORD_BCRYPT)` برای همه کاربران
- **roles JSON:** `'["ROLE_USER","ROLE_DOCTOR"]'` برای دکتر، `'["ROLE_USER","ROLE_CLINIC"]'` برای کلینیک
- **uuid:** از `Symfony\Component\Uid\Uuid::v4()->toRfc4122()` یا `bin2hex(random_bytes(8))` — باید unique باشد
- **موبایل منحصربه‌فرد:** از counter شمارنده استفاده کن تا تکراری نشود
- **representation_id:** برابر با city_id شهر مربوطه (چون cities.id = representations.id)
- **FK ها:** weekly_schedule بعد از ایجاد doctor_address insert شود (نیاز به address.id دارد)
- **transaction:** همه insert ها در یک transaction بزرگ بپیچ تا در صورت خطا rollback شود
- **echo پیشرفت:** بعد از هر شهر تعداد دکتر ایجادشده را چاپ کن
## دستور اجرا
```bash
ddev exec php seed_realistic_data.php
```
## خروجی مورد انتظار
```
✅ پاک‌سازی کامل شد
✅ کلینیک امید تبریز ایجاد شد (id: X)
✅ دکتر نمونه تبریز ایجاد شد (id: Y)
🏙️ تبریز: 100 دکتر ایجاد شد
🏙️ تهران: 100 دکتر ایجاد شد
...
✅ پایان — مجموع X دکتر در Y شهر ایجاد شد
```
+126 -67
View File
@@ -3,6 +3,7 @@
## زمینه ## زمینه
سیستم اشتراک در backend کاملاً پیاده است: سیستم اشتراک در backend کاملاً پیاده است:
- `SubscriptionService::hasFeature(entityType, entityId, feature)` — بررسی دسترسی به قابلیت - `SubscriptionService::hasFeature(entityType, entityId, feature)` — بررسی دسترسی به قابلیت
- `SubscriptionService::getSecretaryLimit(...)` — حداکثر منشی مجاز - `SubscriptionService::getSecretaryLimit(...)` — حداکثر منشی مجاز
- `SubscriptionPlan::features` — یک آرایه JSON مثل `{ "patient_records": true, "services": true, "sms_panel": true }` - `SubscriptionPlan::features` — یک آرایه JSON مثل `{ "patient_records": true, "services": true, "sms_panel": true }`
@@ -11,6 +12,7 @@
**مشکل:** Frontend هیچ اطلاعی از اشتراک فعال کاربر ندارد. Sidebar همه منوها را به همه نقش‌ها نشان می‌دهد، صفحات بدون هشدار باز می‌شوند، و کاربر با پیام خطای backend مواجه می‌شود بجای راهنمای ارتقاء پنل. **مشکل:** Frontend هیچ اطلاعی از اشتراک فعال کاربر ندارد. Sidebar همه منوها را به همه نقش‌ها نشان می‌دهد، صفحات بدون هشدار باز می‌شوند، و کاربر با پیام خطای backend مواجه می‌شود بجای راهنمای ارتقاء پنل.
**هدف:** وقتی کاربر (پزشک / کلینیک / منشی) وارد پنل می‌شود: **هدف:** وقتی کاربر (پزشک / کلینیک / منشی) وارد پنل می‌شود:
1. اطلاعات اشتراک فعال لود شود و در یک context/hook مشترک در دسترس باشد 1. اطلاعات اشتراک فعال لود شود و در یک context/hook مشترک در دسترس باشد
2. آیتم‌های Sidebar که نیاز به feature دارند — اگر feature فعال نیست — با آیکون قفل نمایش داده شوند یا پنهان شوند 2. آیتم‌های Sidebar که نیاز به feature دارند — اگر feature فعال نیست — با آیکون قفل نمایش داده شوند یا پنهان شوند
3. صفحاتی که feature ندارند، یک بنر «برای استفاده از این قابلیت پنل خود را ارتقاء دهید» نمایش دهند 3. صفحاتی که feature ندارند، یک بنر «برای استفاده از این قابلیت پنل خود را ارتقاء دهید» نمایش دهند
@@ -22,7 +24,7 @@
### قابلیت‌های محدودشده توسط اشتراک ### قابلیت‌های محدودشده توسط اشتراک
| Feature Key | صفحه/منو | نقش مرتبط | | Feature Key | صفحه/منو | نقش مرتبط |
|-------------|----------|----------| | ----------------- | ------------------------------- | ------------------------- |
| `patient_records` | `/admin/my-patients` | doctor, clinic, secretary | | `patient_records` | `/admin/my-patients` | doctor, clinic, secretary |
| `services` | `/admin/clinic-services` | doctor, clinic | | `services` | `/admin/clinic-services` | doctor, clinic |
| `sms_panel` | `/admin/sms-wallet` | doctor, clinic | | `sms_panel` | `/admin/sms-wallet` | doctor, clinic |
@@ -35,7 +37,7 @@
## فایل‌های مرتبط ## فایل‌های مرتبط
| فایل | نقش | | فایل | نقش |
|------|-----| | -------------------------------------------------------- | ------------------------------------------------------------- |
| `src/Subscription/Controller/SubscriptionController.php` | endpoint `GET /api/v1/subscription/my` — برای doctor و clinic | | `src/Subscription/Controller/SubscriptionController.php` | endpoint `GET /api/v1/subscription/my` — برای doctor و clinic |
| `src/Subscription/Service/SubscriptionService.php` | `hasFeature()`, `getSecretaryLimit()` | | `src/Subscription/Service/SubscriptionService.php` | `hasFeature()`, `getSecretaryLimit()` |
| `assets/admin/stores/authStore.ts` | state مرکزی auth — باید subscription هم اینجا باشد | | `assets/admin/stores/authStore.ts` | state مرکزی auth — باید subscription هم اینجا باشد |
@@ -52,6 +54,7 @@
## وضعیت فعلی ## وضعیت فعلی
### Backend — endpoint اشتراک ### Backend — endpoint اشتراک
```php ```php
// SubscriptionController.php — GET /api/v1/subscription/my // SubscriptionController.php — GET /api/v1/subscription/my
// فقط doctor و clinic را handle می‌کند // فقط doctor و clinic را handle می‌کند
@@ -70,13 +73,23 @@ private function resolveEntity(User $user): array
``` ```
پاسخ: پاسخ:
```json ```json
{ {
"success": true, "success": true,
"data": { "data": {
"subscription": { "subscription": {
"uuid": "...", "uuid": "...",
"plan": { "name": "basic", "level": 1, "features": { "patient_records": true, "services": true, "sms_panel": false }, "max_secretaries": 3 }, "plan": {
"name": "basic",
"level": 1,
"features": {
"patient_records": true,
"services": true,
"sms_panel": false
},
"max_secretaries": 3
},
"is_trial": false, "is_trial": false,
"expires_at": 1750000000, "expires_at": 1750000000,
"days_remaining": 45 "days_remaining": 45
@@ -89,31 +102,48 @@ private function resolveEntity(User $user): array
اگر اشتراک فعال نداشته باشد: `"subscription": null` اگر اشتراک فعال نداشته باشد: `"subscription": null`
### Frontend — authStore ### Frontend — authStore
```ts ```ts
// authStore.ts — فعلاً subscription اصلاً در store نیست // authStore.ts — فعلاً subscription اصلاً در store نیست
interface AuthState { interface AuthState {
primaryRole: 'admin' | 'clinic' | 'doctor' | 'secretary' | 'user' | null; primaryRole: "admin" | "clinic" | "doctor" | "secretary" | "user" | null;
dbUuid: string | null; dbUuid: string | null;
// ... subscription وجود ندارد // ... subscription وجود ندارد
} }
``` ```
### Frontend — Sidebar ### Frontend — Sidebar
```tsx ```tsx
// Sidebar.tsx — همه آیتم‌ها را بدون بررسی feature نمایش می‌دهد // Sidebar.tsx — همه آیتم‌ها را بدون بررسی feature نمایش می‌دهد
if (primaryRole === 'doctor') { if (primaryRole === "doctor") {
return [{ return [
label: 'مدیریت', {
label: "مدیریت",
items: [ items: [
{ to: '/admin/my-patients', icon: FolderOpenIcon, label: 'پرونده بیماران' }, {
{ to: '/admin/clinic-services', icon: WrenchScrewdriverIcon, label: 'سرویس‌ها' }, to: "/admin/my-patients",
{ to: '/admin/sms-wallet', icon: DevicePhoneMobileIcon, label: 'کیف پول پیامک' }, icon: FolderOpenIcon,
label: "پرونده بیماران",
},
{
to: "/admin/clinic-services",
icon: WrenchScrewdriverIcon,
label: "سرویس‌ها",
},
{
to: "/admin/sms-wallet",
icon: DevicePhoneMobileIcon,
label: "کیف پول پیامک",
},
], ],
}]; },
];
} }
``` ```
### Frontend — App.tsx ### Frontend — App.tsx
```tsx ```tsx
// فقط نقش‌ها چک می‌شوند، feature gate وجود ندارد // فقط نقش‌ها چک می‌شوند، feature gate وجود ندارد
<Route path="my-patients" element={<RoleRoute roles={['doctor', 'secretary', 'clinic']}><MyPatientsPage /></RoleRoute>} /> <Route path="my-patients" element={<RoleRoute roles={['doctor', 'secretary', 'clinic']}><MyPatientsPage /></RoleRoute>} />
@@ -148,18 +178,21 @@ if ($user->hasRole('ROLE_SECRETARY')) {
یک فایل `assets/admin/hooks/useSubscription.ts` بساز: یک فایل `assets/admin/hooks/useSubscription.ts` بساز:
```ts ```ts
import { useQuery } from '@tanstack/react-query'; import { useQuery } from "@tanstack/react-query";
import { api } from '../lib/api'; import { api } from "../lib/api";
import { useAuthStore } from '../stores/authStore'; import { useAuthStore } from "../stores/authStore";
import type { ApiResponse, MySubscriptionData } from '../types'; import type { ApiResponse, MySubscriptionData } from "../types";
export function useSubscription() { export function useSubscription() {
const primaryRole = useAuthStore((s) => s.primaryRole); const primaryRole = useAuthStore((s) => s.primaryRole);
const enabled = primaryRole === 'doctor' || primaryRole === 'clinic' || primaryRole === 'secretary'; const enabled =
primaryRole === "doctor" ||
primaryRole === "clinic" ||
primaryRole === "secretary";
const { data } = useQuery<ApiResponse<MySubscriptionData>>({ const { data } = useQuery<ApiResponse<MySubscriptionData>>({
queryKey: ['subscription-my'], queryKey: ["subscription-my"],
queryFn: () => api.get('/api/v1/subscription/my'), queryFn: () => api.get("/api/v1/subscription/my"),
enabled, enabled,
staleTime: 2 * 60 * 1000, staleTime: 2 * 60 * 1000,
}); });
@@ -174,7 +207,8 @@ export function useSubscription() {
hasFeature: (key: string) => hasPlan && (features[key] ?? false), hasFeature: (key: string) => hasPlan && (features[key] ?? false),
maxSecretaries, maxSecretaries,
hasPlan, hasPlan,
isExpiringSoon: (sub?.days_remaining ?? 0) > 0 && (sub?.days_remaining ?? 0) <= 7, isExpiringSoon:
(sub?.days_remaining ?? 0) > 0 && (sub?.days_remaining ?? 0) <= 7,
}; };
} }
``` ```
@@ -186,7 +220,7 @@ export function useSubscription() {
در `Sidebar.tsx`: در `Sidebar.tsx`:
```tsx ```tsx
import { useSubscription } from '../../hooks/useSubscription'; import { useSubscription } from "../../hooks/useSubscription";
// ... // ...
export default function Sidebar() { export default function Sidebar() {
@@ -198,6 +232,7 @@ export default function Sidebar() {
``` ```
نوع `SectionItem` را گسترش بده: نوع `SectionItem` را گسترش بده:
```ts ```ts
type SectionItem = { type SectionItem = {
to: string; to: string;
@@ -208,6 +243,7 @@ type SectionItem = {
``` ```
آیتم‌های محدود را با `feature` mark کن: آیتم‌های محدود را با `feature` mark کن:
```tsx ```tsx
{ to: '/admin/my-patients', icon: FolderOpenIcon, label: 'پرونده بیماران', feature: 'patient_records' }, { to: '/admin/my-patients', icon: FolderOpenIcon, label: 'پرونده بیماران', feature: 'patient_records' },
{ to: '/admin/clinic-services',icon: WrenchScrewdriverIcon, label: 'سرویس‌ها', feature: 'services' }, { to: '/admin/clinic-services',icon: WrenchScrewdriverIcon, label: 'سرویس‌ها', feature: 'services' },
@@ -215,18 +251,19 @@ type SectionItem = {
``` ```
در render هر آیتم: در render هر آیتم:
```tsx ```tsx
const isLocked = item.feature ? !hasFeature(item.feature) : false; const isLocked = item.feature ? !hasFeature(item.feature) : false;
<NavLink <NavLink
to={isLocked ? '/admin/subscription' : item.to} to={isLocked ? "/admin/subscription" : item.to}
style={isLocked ? { opacity: 0.5 } : {}} style={isLocked ? { opacity: 0.5 } : {}}
title={isLocked ? 'نیاز به ارتقاء پنل' : undefined} title={isLocked ? "نیاز به ارتقاء پنل" : undefined}
> >
<item.icon /> <item.icon />
{item.label} {item.label}
{isLocked && <LockClosedIcon style={{ width: 12, marginRight: 'auto' }} />} {isLocked && <LockClosedIcon style={{ width: 12, marginRight: "auto" }} />}
</NavLink> </NavLink>;
``` ```
import کن: `import { LockClosedIcon } from '@heroicons/react/24/outline';` import کن: `import { LockClosedIcon } from '@heroicons/react/24/outline';`
@@ -236,10 +273,10 @@ import کن: `import { LockClosedIcon } from '@heroicons/react/24/outline';`
یک component کوچک `assets/admin/components/ui/FeatureGate.tsx` بساز: یک component کوچک `assets/admin/components/ui/FeatureGate.tsx` بساز:
```tsx ```tsx
import React from 'react'; import React from "react";
import { Link } from 'react-router-dom'; import { Link } from "react-router-dom";
import { LockClosedIcon } from '@heroicons/react/24/outline'; import { LockClosedIcon } from "@heroicons/react/24/outline";
import { useSubscription } from '../../hooks/useSubscription'; import { useSubscription } from "../../hooks/useSubscription";
interface FeatureGateProps { interface FeatureGateProps {
feature: string; feature: string;
@@ -252,30 +289,52 @@ export default function FeatureGate({ feature, children }: FeatureGateProps) {
if (hasFeature(feature)) return <>{children}</>; if (hasFeature(feature)) return <>{children}</>;
return ( return (
<div style={{ <div
display: 'flex', flexDirection: 'column', style={{
alignItems: 'center', justifyContent: 'center', display: "flex",
minHeight: 320, gap: 16, padding: 40, textAlign: 'center', flexDirection: "column",
}}> alignItems: "center",
<div style={{ justifyContent: "center",
width: 64, height: 64, borderRadius: '50%', minHeight: 320,
background: 'oklch(0.97 0.01 256)', gap: 16,
display: 'grid', placeItems: 'center', padding: 40,
}}> textAlign: "center",
<LockClosedIcon style={{ width: 28, color: 'var(--text-3)' }} /> }}
>
<div
style={{
width: 64,
height: 64,
borderRadius: "50%",
background: "oklch(0.97 0.01 256)",
display: "grid",
placeItems: "center",
}}
>
<LockClosedIcon style={{ width: 28, color: "var(--text-3)" }} />
</div> </div>
<div> <div>
<div style={{ fontWeight: 700, fontSize: 16, marginBottom: 6 }}> <div style={{ fontWeight: 700, fontSize: 16, marginBottom: 6 }}>
این قابلیت در پنل فعلی شما فعال نیست این قابلیت در پنل فعلی شما فعال نیست
</div> </div>
<div style={{ color: 'var(--text-3)', fontSize: 13.5, maxWidth: 360 }}> <div
style={{
color: "var(--text-3)",
fontSize: 13.5,
maxWidth: 360,
}}
>
{hasPlan {hasPlan
? 'برای استفاده از این قابلیت پنل خود را ارتقاء دهید' ? "برای استفاده از این قابلیت پنل خود را ارتقاء دهید"
: 'برای استفاده از این قابلیت یک پنل اشتراکی فعال کنید'} : "برای استفاده از این قابلیت یک پنل اشتراکی فعال کنید"}
</div> </div>
</div> </div>
<Link to="/admin/subscription" className="btn primary sm" style={{ textDecoration: 'none' }}> <Link
{hasPlan ? 'ارتقاء پنل' : 'مشاهده پنل‌های اشتراکی'} to="/admin/subscription"
className="btn primary sm"
style={{ textDecoration: "none" }}
>
{hasPlan ? "ارتقاء پنل" : "مشاهده پنل‌های اشتراکی"}
</Link> </Link>
</div> </div>
); );
@@ -285,32 +344,25 @@ export default function FeatureGate({ feature, children }: FeatureGateProps) {
### ۵. Frontend — استفاده از FeatureGate در صفحات ### ۵. Frontend — استفاده از FeatureGate در صفحات
در **`MyPatientsPage.tsx`** محتوای اصلی را wrap کن: در **`MyPatientsPage.tsx`** محتوای اصلی را wrap کن:
```tsx ```tsx
import FeatureGate from '../components/ui/FeatureGate'; import FeatureGate from "../components/ui/FeatureGate";
// ... // ...
return ( return (
<FeatureGate feature="patient_records"> <FeatureGate feature="patient_records">{/* کد فعلی صفحه */}</FeatureGate>
{/* کد فعلی صفحه */}
</FeatureGate>
); );
``` ```
در **`ClinicServicesPage.tsx`**: در **`ClinicServicesPage.tsx`**:
```tsx ```tsx
return ( return <FeatureGate feature="services">{/* کد فعلی */}</FeatureGate>;
<FeatureGate feature="services">
{/* کد فعلی */}
</FeatureGate>
);
``` ```
در **`SmsWalletPage.tsx`**: در **`SmsWalletPage.tsx`**:
```tsx ```tsx
return ( return <FeatureGate feature="sms_panel">{/* کد فعلی */}</FeatureGate>;
<FeatureGate feature="sms_panel">
{/* کد فعلی */}
</FeatureGate>
);
``` ```
### ۶. Frontend — محدودیت تعداد منشی در MySecretariesPage ### ۶. Frontend — محدودیت تعداد منشی در MySecretariesPage
@@ -318,11 +370,11 @@ return (
در **`MySecretariesPage.tsx`** وقتی کاربر می‌خواهد منشی جدید اضافه کند: در **`MySecretariesPage.tsx`** وقتی کاربر می‌خواهد منشی جدید اضافه کند:
```tsx ```tsx
import { useSubscription } from '../hooks/useSubscription'; import { useSubscription } from "../hooks/useSubscription";
// ... // ...
const { maxSecretaries } = useSubscription(); const { maxSecretaries } = useSubscription();
// تعداد فعلی منشیان از data موجود // تعداد فعلی منشی ها از data موجود
const activeCount = secretaries.filter(s => s.is_active).length; const activeCount = secretaries.filter((s) => s.is_active).length;
const isAtLimit = activeCount >= maxSecretaries; const isAtLimit = activeCount >= maxSecretaries;
// روی دکمه «افزودن منشی»: // روی دکمه «افزودن منشی»:
@@ -330,15 +382,22 @@ const isAtLimit = activeCount >= maxSecretaries;
className="btn primary sm" className="btn primary sm"
onClick={() => setAddOpen(true)} onClick={() => setAddOpen(true)}
disabled={isAtLimit} disabled={isAtLimit}
title={isAtLimit ? `حداکثر ${maxSecretaries} منشی در پنل فعلی مجاز است` : undefined} title={
isAtLimit
? `حداکثر ${maxSecretaries} منشی در پنل فعلی مجاز است`
: undefined
}
> >
افزودن منشی افزودن منشی
</button> </button>;
{isAtLimit && ( {
<div style={{ fontSize: 12, color: 'var(--text-3)', marginTop: 4 }}> isAtLimit && (
برای افزودن منشی بیشتر <Link to="/admin/subscription">پنل خود را ارتقاء دهید</Link> <div style={{ fontSize: 12, color: "var(--text-3)", marginTop: 4 }}>
برای افزودن منشی بیشتر{" "}
<Link to="/admin/subscription">پنل خود را ارتقاء دهید</Link>
</div> </div>
)} );
}
``` ```
--- ---
+66 -39
View File
@@ -10,26 +10,16 @@
```css ```css
/* متغیرها */ /* متغیرها */
--primary: oklch(0.55 0.2 256) --primary: oklch(0.55 0.2 256) --primary-subtle: oklch(0.97 0.02 256)
--primary-subtle: oklch(0.97 0.02 256) --surface: #fff --border: oklch(0.9 0 0) --text-1 / --text-2 / --text-3 --r
--surface: #fff (border-radius base) --gap / --card-pad /* کلاس‌های کاربردی */ .card
--border: oklch(0.9 0 0) /* white surface با shadow و border-radius */ .card-pad
--text-1 / --text-2 / --text-3 /* padding داخل card */ .stat /* عدد آماری بزرگ + label */ .stat-grid
--r (border-radius base) /* grid چند ستونه برای stat‌ها */ .badge green/red/yellow/blue/gray
--gap / --card-pad /* برچسب وضعیت */ .btn primary/sm/ghost /* دکمه */ .field
/* label + input wrapper */ .seg /* segment/tab selector */ .bar
/* کلاس‌های کاربردی */ /* نوار پیشرفت */ .grid-2 /* دو‌ستونه responsive */ .avatar sm
.card /* white surface با shadow و border-radius */ /* آواتار دایره‌ای */;
.card-pad /* padding داخل card */
.stat /* عدد آماری بزرگ + label */
.stat-grid /* grid چند ستونه برای stat‌ها */
.badge green/red/yellow/blue/gray /* برچسب وضعیت */
.btn primary/sm/ghost /* دکمه */
.field /* label + input wrapper */
.seg /* segment/tab selector */
.bar /* نوار پیشرفت */
.grid-2 /* دو‌ستونه responsive */
.avatar sm /* آواتار دایره‌ای */
``` ```
**الگوی header صفحات:** `<PageHeader title="..." description="..." action={<button>} />` **الگوی header صفحات:** `<PageHeader title="..." description="..." action={<button>} />`
@@ -37,10 +27,10 @@
## فایل‌های مرتبط ## فایل‌های مرتبط
| فایل | نقش | | فایل | نقش |
|------|-----| | ------------------------------------------- | -------------------------------------------------------- |
| `assets/admin/pages/MyPatientsPage.tsx` | پرونده بیماران — دو حالت: لیست + جزئیات بیمار | | `assets/admin/pages/MyPatientsPage.tsx` | پرونده بیماران — دو حالت: لیست + جزئیات بیمار |
| `assets/admin/pages/StaffPage.tsx` | مدیریت پرسنل — CRUD | | `assets/admin/pages/StaffPage.tsx` | مدیریت پرسنل — CRUD |
| `assets/admin/pages/MySecretariesPage.tsx` | مدیریت منشیان — CRUD + permissions matrix | | `assets/admin/pages/MySecretariesPage.tsx` | مدیریت منشی ها — CRUD + permissions matrix |
| `assets/admin/pages/ClinicServicesPage.tsx` | سرویس‌های کلینیک — دو پنل: بخش‌ها + آیتم‌ها | | `assets/admin/pages/ClinicServicesPage.tsx` | سرویس‌های کلینیک — دو پنل: بخش‌ها + آیتم‌ها |
| `assets/admin/pages/SmsWalletPage.tsx` | کیف پول پیامک — balance + settings + logs | | `assets/admin/pages/SmsWalletPage.tsx` | کیف پول پیامک — balance + settings + logs |
| `assets/admin/styles.css` | سیستم CSS کامل | | `assets/admin/styles.css` | سیستم CSS کامل |
@@ -173,6 +163,7 @@ PermissionsMatrix: جدول checkbox با ۶ ستون برای ۴ بخش — د
**هدف:** لیست با identity واضح + پرونده بیمار با مینی‌داشبورد. **هدف:** لیست با identity واضح + پرونده بیمار با مینی‌داشبورد.
**الف — حالت لیست بیماران:** **الف — حالت لیست بیماران:**
- Header اختصاصی: `<PageHeader>` با description «مراجعه‌کنندگان ثبت‌شده شما» - Header اختصاصی: `<PageHeader>` با description «مراجعه‌کنندگان ثبت‌شده شما»
- Search bar را با یک wrapper card جداگانه به یک toolbar تبدیل کن (نه داخل card جدول) - Search bar را با یک wrapper card جداگانه به یک toolbar تبدیل کن (نه داخل card جدول)
- `DataTable` را با ردیف‌های بهتر جایگزین کن: هر ردیف آواتار gradient + نام بولد + شماره با آیکون تلفن + تاریخ ثبت شمسی - `DataTable` را با ردیف‌های بهتر جایگزین کن: هر ردیف آواتار gradient + نام بولد + شماره با آیکون تلفن + تاریخ ثبت شمسی
@@ -180,6 +171,7 @@ PermissionsMatrix: جدول checkbox با ۶ ستون برای ۴ بخش — د
- وقتی بیماری نیست: Empty State با آیکون بزرگ + متن راهنما - وقتی بیماری نیست: Empty State با آیکون بزرگ + متن راهنما
**ب — حالت پرونده بیمار (selectedRecord):** **ب — حالت پرونده بیمار (selectedRecord):**
- PageHeader با نام بیمار + شماره + تاریخ ثبت - PageHeader با نام بیمار + شماره + تاریخ ثبت
- یک بنر/info-card بالای صفحه: نام، تلفن، تعداد مراجعات (از `totalSes`) - یک بنر/info-card بالای صفحه: نام، تلفن، تعداد مراجعات (از `totalSes`)
- SessionRow باید بهتر شود — هر مراجعه یک card مستقل با: - SessionRow باید بهتر شود — هر مراجعه یک card مستقل با:
@@ -189,6 +181,7 @@ PermissionsMatrix: جدول checkbox با ۶ ستون برای ۴ بخش — د
- دکمه «ویرایش» در گوشه بالا سمت چپ کارت - دکمه «ویرایش» در گوشه بالا سمت چپ کارت
**ج — Modal ثبت مراجعه:** **ج — Modal ثبت مراجعه:**
- سه فیلد قیمت/بیمه پایه/بیمه تکمیلی را در یک `.grid-2` یا grid سه‌ستونه گذاشتن خوب است — این کافیست - سه فیلد قیمت/بیمه پایه/بیمه تکمیلی را در یک `.grid-2` یا grid سه‌ستونه گذاشتن خوب است — این کافیست
- بخش «خدمات» را با یک separator بصری از فیلدهای اصلی جدا کن - بخش «خدمات» را با یک separator بصری از فیلدهای اصلی جدا کن
- لیست خدمات انتخاب‌شده را با badge یا chip نشان بده (نه فقط متن) - لیست خدمات انتخاب‌شده را با badge یا chip نشان بده (نه فقط متن)
@@ -202,6 +195,7 @@ PermissionsMatrix: جدول checkbox با ۶ ستون برای ۴ بخش — د
**هدف:** صفحه‌ای با KPI bar + جدول بهتر + فرم واضح‌تر. **هدف:** صفحه‌ای با KPI bar + جدول بهتر + فرم واضح‌تر.
**الف — KPI bar:** **الف — KPI bar:**
- بالای جدول، یک `.stat-grid` با ۳ card: تعداد پرسنل کل، تعداد فعال، تعداد غیرفعال - بالای جدول، یک `.stat-grid` با ۳ card: تعداد پرسنل کل، تعداد فعال، تعداد غیرفعال
- داده از همان `staff` query محاسبه شود (نه API جدید): - داده از همان `staff` query محاسبه شود (نه API جدید):
```ts ```ts
@@ -211,11 +205,13 @@ PermissionsMatrix: جدول checkbox با ۶ ستون برای ۴ بخش — د
``` ```
**ب — جدول بهتر:** **ب — جدول بهتر:**
- ستون «نام» را بهبود بده: `full_name` + `job_title` زیرش (مثل MyPatientsPage) - ستون «نام» را بهبود بده: `full_name` + `job_title` زیرش (مثل MyPatientsPage)
- ستون «تلفن» و «کد ملی» را در موبایل پنهان کن (CSS media query اگر لازم بود) - ستون «تلفن» و «کد ملی» را در موبایل پنهان کن (CSS media query اگر لازم بود)
- ردیف‌های hover state با background تغییر کند - ردیف‌های hover state با background تغییر کند
**ج — فرم StaffFormFields:** **ج — فرم StaffFormFields:**
- فیلدهای `full_name` و `job_title` را در یک ردیف (`.grid-2`) قرار بده - فیلدهای `full_name` و `job_title` را در یک ردیف (`.grid-2`) قرار بده
- فیلدهای `phone` و `national_code` را در یک ردیف دیگر - فیلدهای `phone` و `national_code` را در یک ردیف دیگر
- فیلد `address` تنها بماند (textarea یا input تمام عرض) - فیلد `address` تنها بماند (textarea یا input تمام عرض)
@@ -226,7 +222,8 @@ PermissionsMatrix: جدول checkbox با ۶ ستون برای ۴ بخش — د
**هدف:** جدول دستی را به DataTable شیک تبدیل کن + PermissionsMatrix بصری‌تر + Empty State جذاب. **هدف:** جدول دستی را به DataTable شیک تبدیل کن + PermissionsMatrix بصری‌تر + Empty State جذاب.
**الف — جدول منشیان:** **الف — جدول منشی ها:**
- جدول دستی را **حذف کن** و از `DataTable` component استفاده کن - جدول دستی را **حذف کن** و از `DataTable` component استفاده کن
- ستون «نام»: آواتار کوچک (initial از `user_name`) + نام بولد - ستون «نام»: آواتار کوچک (initial از `user_name`) + نام بولد
- ستون «موبایل»: با `maskMobile()` و `dir="ltr"` + آیکون تلفن - ستون «موبایل»: با `maskMobile()` و `dir="ltr"` + آیکون تلفن
@@ -235,12 +232,22 @@ PermissionsMatrix: جدول checkbox با ۶ ستون برای ۴ بخش — د
- ستون «عملیات»: دو دکمه `sm` (ویرایش دسترسی‌ها + غیرفعال‌سازی) - ستون «عملیات»: دو دکمه `sm` (ویرایش دسترسی‌ها + غیرفعال‌سازی)
**ب — Empty State:** **ب — Empty State:**
- وقتی `secretaries.length === 0`: - وقتی `secretaries.length === 0`:
```tsx ```tsx
<div className="card card-pad" style={{ textAlign: 'center', padding: '60px 24px' }}> <div
<IdentificationIcon style={{ width: 48, color: 'var(--text-3)', margin: '0 auto 16px' }} /> className="card card-pad"
<div style={{ fontWeight: 600, marginBottom: 8 }}>هنوز منشی‌ای اضافه نشده</div> style={{ textAlign: "center", padding: "60px 24px" }}
<div style={{ color: 'var(--text-3)', fontSize: 13.5, marginBottom: 20 }}> >
<IdentificationIcon
style={{ width: 48, color: "var(--text-3)", margin: "0 auto 16px" }}
/>
<div style={{ fontWeight: 600, marginBottom: 8 }}>
هنوز منشی‌ای اضافه نشده
</div>
<div
style={{ color: "var(--text-3)", fontSize: 13.5, marginBottom: 20 }}
>
منشی می‌تواند نوبت‌ها و اطلاعات کلینیک را مدیریت کند منشی می‌تواند نوبت‌ها و اطلاعات کلینیک را مدیریت کند
</div> </div>
<button className="btn primary sm" onClick={() => setCreateOpen(true)}> <button className="btn primary sm" onClick={() => setCreateOpen(true)}>
@@ -250,6 +257,7 @@ PermissionsMatrix: جدول checkbox با ۶ ستون برای ۴ بخش — د
``` ```
**ج — PermissionsMatrix:** **ج — PermissionsMatrix:**
- ردیف‌های جدول با رنگ متناوب (striped) یا hover state - ردیف‌های جدول با رنگ متناوب (striped) یا hover state
- checkbox‌ها با `accent-color: var(--primary)` (قبلاً بود، نگه دار) - checkbox‌ها با `accent-color: var(--primary)` (قبلاً بود، نگه دار)
- header ستون‌ها را bold و با background کم‌رنگ کن - header ستون‌ها را bold و با background کم‌رنگ کن
@@ -262,6 +270,7 @@ PermissionsMatrix: جدول checkbox با ۶ ستون برای ۴ بخش — د
**هدف:** پنل بخش‌ها بهتر + جدول آیتم‌ها با badge فعال/غیرفعال. **هدف:** پنل بخش‌ها بهتر + جدول آیتم‌ها با badge فعال/غیرفعال.
**الف — پنل بخش‌ها (ستون ۲۸۰px):** **الف — پنل بخش‌ها (ستون ۲۸۰px):**
- هر بخش یک item با: - هر بخش یک item با:
- آیکون `WrenchScrewdriverIcon` در سمت راست (color از theme) - آیکون `WrenchScrewdriverIcon` در سمت راست (color از theme)
- نام بخش bold - نام بخش bold
@@ -271,6 +280,7 @@ PermissionsMatrix: جدول checkbox با ۶ ستون برای ۴ بخش — د
- افزودن بخش: دکمه با آیکون + متن «بخش جدید» (نه فقط آیکون) - افزودن بخش: دکمه با آیکون + متن «بخش جدید» (نه فقط آیکون)
**ب — پنل آیتم‌ها (ستون 1fr):** **ب — پنل آیتم‌ها (ستون 1fr):**
- بالای جدول یک toolbar: نام بخش به عنوان breadcrumb + تعداد آیتم‌ها + دکمه «سرویس جدید» - بالای جدول یک toolbar: نام بخش به عنوان breadcrumb + تعداد آیتم‌ها + دکمه «سرویس جدید»
- هر row آیتم: - هر row آیتم:
- badge «فعال» / «غیرفعال» برای `item.active` - badge «فعال» / «غیرفعال» برای `item.active`
@@ -287,10 +297,19 @@ PermissionsMatrix: جدول checkbox با ۶ ستون برای ۴ بخش — د
``` ```
**ج — State اولیه (هیچ بخشی انتخاب نشده):** **ج — State اولیه (هیچ بخشی انتخاب نشده):**
- جای پیام «یک بخش را از سمت راست انتخاب کنید» یک آنیمیشن ساده یا placeholder card با رنگ dashed border قرار بده: - جای پیام «یک بخش را از سمت راست انتخاب کنید» یک آنیمیشن ساده یا placeholder card با رنگ dashed border قرار بده:
```tsx ```tsx
<div style={{ border: '2px dashed var(--border)', borderRadius: 10, padding: '60px 0', textAlign: 'center', color: 'var(--text-3)' }}> <div
<WrenchScrewdriverIcon style={{ width: 40, margin: '0 auto 12px' }} /> style={{
border: "2px dashed var(--border)",
borderRadius: 10,
padding: "60px 0",
textAlign: "center",
color: "var(--text-3)",
}}
>
<WrenchScrewdriverIcon style={{ width: 40, margin: "0 auto 12px" }} />
<div>یک بخش انتخاب کنید تا سرویس‌های آن را ببینید</div> <div>یک بخش انتخاب کنید تا سرویس‌های آن را ببینید</div>
</div> </div>
``` ```
@@ -302,6 +321,7 @@ PermissionsMatrix: جدول checkbox با ۶ ستون برای ۴ بخش — د
**هدف:** کارت موجودی با visual hierarchy قوی + تنظیمات واضح‌تر + جدول تراکنش‌ها با visual type indicator. **هدف:** کارت موجودی با visual hierarchy قوی + تنظیمات واضح‌تر + جدول تراکنش‌ها با visual type indicator.
**الف — کارت موجودی:** **الف — کارت موجودی:**
- gradient background (مثل SubscriptionPage): `background: linear-gradient(135deg, oklch(0.52 0.22 256), oklch(0.40 0.18 256))` - gradient background (مثل SubscriptionPage): `background: linear-gradient(135deg, oklch(0.52 0.22 256), oklch(0.40 0.18 256))`
- رنگ متن: white - رنگ متن: white
- موجودی را بزرگ‌تر کن: `fontSize: 36, fontWeight: 800` - موجودی را بزرگ‌تر کن: `fontSize: 36, fontWeight: 800`
@@ -310,6 +330,7 @@ PermissionsMatrix: جدول checkbox با ۶ ستون برای ۴ بخش — د
- دکمه شارژ: white با primary text (برعکس از theme معمول) - دکمه شارژ: white با primary text (برعکس از theme معمول)
**ب — کارت تنظیمات:** **ب — کارت تنظیمات:**
- عنوان با divider پایین - عنوان با divider پایین
- هر toggle را با یک card داخلی wrapper کن: - هر toggle را با یک card داخلی wrapper کن:
```tsx ```tsx
@@ -324,6 +345,7 @@ PermissionsMatrix: جدول checkbox با ۶ ستون برای ۴ بخش — د
- input «چند ساعت قبل» را با border و padding واضح‌تر style کن - input «چند ساعت قبل» را با border و padding واضح‌تر style کن
**ج — جدول تراکنش‌ها:** **ج — جدول تراکنش‌ها:**
- ستون «نوع» را با یک آیکون بهبود بده: - ستون «نوع» را با یک آیکون بهبود بده:
- `credit`: `ArrowUpCircleIcon` با رنگ سبز - `credit`: `ArrowUpCircleIcon` با رنگ سبز
- `debit`: `ArrowDownCircleIcon` با رنگ قرمز - `debit`: `ArrowDownCircleIcon` با رنگ قرمز
@@ -331,26 +353,31 @@ PermissionsMatrix: جدول checkbox با ۶ ستون برای ۴ بخش — د
- header table با background خاکستری کم‌رنگ - header table با background خاکستری کم‌رنگ
**د — Modal شارژ:** **د — Modal شارژ:**
- انتخاب gateway را به radio card تبدیل کن (مثل SubscriptionPage): - انتخاب gateway را به radio card تبدیل کن (مثل SubscriptionPage):
```tsx ```tsx
{(['mellat', 'sep'] as const).map((gw) => ( {
(["mellat", "sep"] as const).map((gw) => (
<div <div
key={gw} key={gw}
onClick={() => setGateway(gw)} onClick={() => setGateway(gw)}
style={{ style={{
border: `2px solid ${gateway === gw ? 'var(--primary)' : 'var(--border)'}`, border: `2px solid ${gateway === gw ? "var(--primary)" : "var(--border)"}`,
borderRadius: 8, borderRadius: 8,
padding: '10px 14px', padding: "10px 14px",
cursor: 'pointer', cursor: "pointer",
background: gateway === gw ? 'var(--primary-subtle)' : '', background: gateway === gw ? "var(--primary-subtle)" : "",
transition: 'all 0.15s', transition: "all 0.15s",
flex: 1, flex: 1,
textAlign: 'center', textAlign: "center",
}} }}
> >
<div style={{ fontWeight: 600 }}>{gw === 'mellat' ? 'بانک ملت' : 'سپ'}</div> <div style={{ fontWeight: 600 }}>
{gw === "mellat" ? "بانک ملت" : "سپ"}
</div> </div>
))} </div>
));
}
``` ```
- پیش‌نمایش مبلغ: وقتی کاربر عدد می‌زند، زیر input بنویس «پرداخت X ریال از طریق Y» - پیش‌نمایش مبلغ: وقتی کاربر عدد می‌زند، زیر input بنویس «پرداخت X ریال از طریق Y»
@@ -364,5 +391,5 @@ PermissionsMatrix: جدول checkbox با ۶ ستون برای ۴ بخش — د
6. **SubscriptionPage** را به عنوان مرجع visual نگه دار — gradient، progress bar، و card layout آن مدل است 6. **SubscriptionPage** را به عنوان مرجع visual نگه دار — gradient، progress bar، و card layout آن مدل است
7. **بعد از هر صفحه** `ddev exec yarn dev` اجرا کن تا TypeScript error نداشته باشد 7. **بعد از هر صفحه** `ddev exec yarn dev` اجرا کن تا TypeScript error نداشته باشد
8. **SessionRow** در MyPatientsPage: قبل از ویرایش، آن component را کامل بخوان (احتمالاً inline در همان فایل است) 8. **SessionRow** در MyPatientsPage: قبل از ویرایش، آن component را کامل بخوان (احتمالاً inline در همان فایل است)
9. **`maskMobile()`** از `../lib/utils` — برای نمایش شماره موبایل منشیان استفاده کن 9. **`maskMobile()`** از `../lib/utils` — برای نمایش شماره موبایل منشی ها استفاده کن
10. **`formatDate()` و `formatDateTime()`** — همه تاریخ‌ها شمسی نمایش داده شوند 10. **`formatDate()` و `formatDateTime()`** — همه تاریخ‌ها شمسی نمایش داده شوند
+72 -42
View File
@@ -2,6 +2,8 @@
**پنل ادمین:** https://clinic-pro.ddev.site/admin **پنل ادمین:** https://clinic-pro.ddev.site/admin
> رمز عبور همه کاربران seed‌شده: `Test@1234`
--- ---
## ادمین ## ادمین
@@ -14,65 +16,93 @@
--- ---
## کاربران Multi-Context ## کلینیک نمونه — تبریز
### کاربر ۱ — دکتر در چند کلینیک
| فیلد | مقدار |
| -------- | -------------------------- |
| موبایل | `09100000011` |
| پسورد | `Test1234` |
| نقش | `ROLE_DOCTOR` |
| Contexts | مطب شخصی دکتر آرمان رضایی |
| | کلینیک جامع حضرت علی (عضو) |
| | بیمارستان ابن سینا (عضو) |
---
### کاربر ۲ — صاحب کلینیک
| فیلد | مقدار | | فیلد | مقدار |
| ----------- | -------------------------------------- | | ----------- | -------------------------------------- |
| موبایل | `09100000022` | | موبایل | `09100100000` |
| پسورد | `Test1234` | | پسورد | `Test@1234` |
| نقش | `ROLE_CLINIC` | | نقش | `ROLE_CLINIC` |
| Contexts | کلینیک تست پگاه | | نام | کلینیک تخصصی امید تبریز |
| UUID کلینیک | `0730373f-7c0a-4610-8120-d58ecf4a9916` | | UUID کلینیک | `9ac73318-d313-4772-bf1e-418d47f8f4bc` |
| شهر | تبریز (id: 101) |
--- ---
### کاربر ۳ — منشی ## دکتر نمونه کامل — تبریز
| فیلد | مقدار |
| -------- | ---------------------- |
| موبایل | `09100000033` |
| پسورد | `Test1234` |
| نقش | `ROLE_SECRETARY` |
| Contexts | مطب دکتر مهناز سلیمانی |
---
### کاربر ۴ — دکتر + صاحب کلینیک
| فیلد | مقدار | | فیلد | مقدار |
| ----------- | -------------------------------------- | | ----------- | -------------------------------------- |
| موبایل | `09100000044` | | موبایل | `09100100001` |
| پسورد | `Test1234` | | پسورد | `Test@1234` |
| نقش | `ROLE_DOCTOR` + `ROLE_CLINIC` | | نقش | `ROLE_DOCTOR` |
| Contexts | مطب شخصی دکتر سامان علوی | | نام | دکتر آرمان رضایی |
| | کلینیک تخصصی علوی | | تخصص | قلب و عروق + داخلی |
| UUID کلینیک | `26a1ff95-32aa-4a96-b0fd-03e75e810be9` | | UUID دکتر | `2a3a7ab9-8d34-4118-862f-b458bcd6d77f` |
| کلینیک | کلینیک تخصصی امید تبریز (عضو) |
| آدرس مطب | تبریز، خیابان آزادی |
| برنامه کاری | شنبه–چهارشنبه ۹–۱۳ و ۱۴–۱۸، پنجشنبه ۹–۱۳ |
| بیمه | تأمین اجتماعی، خدمات درمانی، نیروهای مسلح، ایران |
---
## منشی دکتر نمونه
| فیلد | مقدار |
| ------ | ------------------- |
| موبایل | `09100100002` |
| پسورد | `Test@1234` |
| نقش | `ROLE_SECRETARY` |
| نام | خانم نرگس صادقی |
| مرتبط | دکتر آرمان رضایی |
---
## دکتران bulk (۱۵۰۰ دکتر در ۱۵ شهر)
هر شهر ۱۰۰ دکتر با توزیع واقعی تخصص:
| شهر | شروع موبایل | تعداد |
| ---------- | ------------- | ----- |
| تبریز | `09100100003` | ۱۰۰ |
| ارومیه | `09100100103` | ۱۰۰ |
| اردبیل | `09100100203` | ۱۰۰ |
| اصفهان | `09100100303` | ۱۰۰ |
| کرج | `09100100403` | ۱۰۰ |
| تهران | `09100100503` | ۱۰۰ |
| مشهد | `09100100603` | ۱۰۰ |
| اهواز | `09100100703` | ۱۰۰ |
| شیراز | `09100100803` | ۱۰۰ |
| کرمان | `09100100903` | ۱۰۰ |
| کرمانشاه | `09100101003` | ۱۰۰ |
| رشت | `09100101103` | ۱۰۰ |
| ساری | `09100101203` | ۱۰۰ |
| همدان | `09100101303` | ۱۰۰ |
| یزد | `09100101403` | ۱۰۰ |
توزیع تخصص در هر شهر:
- ۱۵ پزشک عمومی
- ۱۰ داخلی + قلب
- ۸ جراحی عمومی
- ۸ زنان و زایمان
- ۸ اطفال
- ۷ ارتوپدی
- ۶ گوارش، نورولوژی، پوست، چشم‌پزشکی، دندانپزشکی (هر کدام ۶)
- ۵ ENT، روانپزشکی (هر کدام ۵)
- ۴ اورولوژی
--- ---
## ساخت مجدد ## ساخت مجدد
اگر دیتابیس ریست شد، اجرا کن: اگر دیتابیس ریست شد، ادمین را بساز:
```bash ```bash
ddev exec php create_test_users.php ddev exec php bin/console app:create-admin
``` ```
09212908096 برای seed دیتای واقعی دکتران و کلینیک:
09212908096 ```bash
ddev exec php seed_realistic_data.php
```
+304 -90
View File
@@ -3,155 +3,308 @@ import {
ArrowsRightLeftIcon, ArrowsRightLeftIcon,
BanknotesIcon, BanknotesIcon,
BuildingOffice2Icon, BuildingOffice2Icon,
ClipboardDocumentCheckIcon,
Cog6ToothIcon,
CalendarDaysIcon, CalendarDaysIcon,
ChartBarIcon, ChartBarIcon,
ChatBubbleLeftEllipsisIcon, ChatBubbleLeftEllipsisIcon,
ClipboardDocumentCheckIcon,
Cog6ToothIcon,
CreditCardIcon, CreditCardIcon,
DevicePhoneMobileIcon, DevicePhoneMobileIcon,
DocumentTextIcon, DocumentTextIcon,
FolderOpenIcon,
HeartIcon, HeartIcon,
IdentificationIcon,
KeyIcon, KeyIcon,
LockClosedIcon, LockClosedIcon,
StarIcon, StarIcon,
TagIcon, TagIcon,
UserCircleIcon, UserCircleIcon,
UserGroupIcon, UserGroupIcon,
UsersIcon,
UserPlusIcon, UserPlusIcon,
UsersIcon,
WrenchScrewdriverIcon, WrenchScrewdriverIcon,
FolderOpenIcon,
IdentificationIcon,
} from "@heroicons/react/24/outline"; } from "@heroicons/react/24/outline";
import { NavLink, useNavigate } from "react-router-dom"; import { NavLink, useNavigate } from "react-router-dom";
import { useSubscription } from "../../hooks/useSubscription";
import { useAuthStore } from "../../stores/authStore"; import { useAuthStore } from "../../stores/authStore";
import { useUiStore } from "../../stores/uiStore"; import { useUiStore } from "../../stores/uiStore";
import { useSubscription } from "../../hooks/useSubscription";
type SectionItem = { to: string; icon: React.ElementType; label: string; feature?: string }; type SectionItem = {
to: string;
icon: React.ElementType;
label: string;
feature?: string;
};
type Section = { label: string; items: SectionItem[] }; type Section = { label: string; items: SectionItem[] };
function buildSections(primaryRole: string | null, dbUuid: string | null): Section[] { function buildSections(
if (primaryRole === 'admin') { primaryRole: string | null,
dbUuid: string | null,
): Section[] {
if (primaryRole === "admin") {
return [ return [
{ {
label: 'عمومی', label: "عمومی",
items: [ items: [
{ to: '/admin/dashboard', icon: ChartBarIcon, label: 'داشبورد' }, {
{ to: '/admin/users', icon: UserGroupIcon, label: 'کاربران' }, to: "/admin/dashboard",
{ to: '/admin/doctors', icon: HeartIcon, label: 'پزشکان' }, icon: ChartBarIcon,
{ to: '/admin/clinics', icon: BuildingOffice2Icon, label: 'کلینیک‌ها' }, label: "داشبورد",
},
{
to: "/admin/users",
icon: UserGroupIcon,
label: "کاربران",
},
{ to: "/admin/doctors", icon: HeartIcon, label: "پزشکان" },
{
to: "/admin/clinics",
icon: BuildingOffice2Icon,
label: "کلینیک‌ها",
},
], ],
}, },
{ {
label: 'مدیریت', label: "مدیریت",
items: [ items: [
{ to: '/admin/appointments', icon: CalendarDaysIcon, label: 'نوبت‌ها' }, {
{ to: '/admin/payments', icon: CreditCardIcon, label: 'پرداخت‌ها' }, to: "/admin/appointments",
{ to: '/admin/settlements', icon: BanknotesIcon, label: 'تسویه‌حساب' }, icon: CalendarDaysIcon,
{ to: '/admin/pre-registrations', icon: ClipboardDocumentCheckIcon, label: 'درخواست‌های ثبت‌نام' }, label: "نوبت‌ها",
},
{
to: "/admin/payments",
icon: CreditCardIcon,
label: "پرداخت‌ها",
},
{
to: "/admin/settlements",
icon: BanknotesIcon,
label: "تسویه‌حساب",
},
{
to: "/admin/pre-registrations",
icon: ClipboardDocumentCheckIcon,
label: "درخواست‌های ثبت‌نام",
},
], ],
}, },
{ {
label: 'محتوا', label: "محتوا",
items: [ items: [
{ to: '/admin/comments', icon: ChatBubbleLeftEllipsisIcon, label: 'نظرات' }, {
{ to: '/admin/ratings', icon: StarIcon, label: 'امتیازها' }, to: "/admin/comments",
{ to: '/admin/blogs', icon: DocumentTextIcon, label: 'بلاگ' }, icon: ChatBubbleLeftEllipsisIcon,
{ to: '/admin/sms', icon: DevicePhoneMobileIcon, label: 'پیامک' }, label: "نظرات",
},
{ to: "/admin/ratings", icon: StarIcon, label: "امتیازها" },
{
to: "/admin/blogs",
icon: DocumentTextIcon,
label: "بلاگ",
},
{
to: "/admin/sms",
icon: DevicePhoneMobileIcon,
label: "پیامک",
},
], ],
}, },
{ {
label: 'سیستم', label: "سیستم",
items: [ items: [
{ to: '/admin/categories', icon: TagIcon, label: 'دسته‌بندی‌ها' }, {
{ to: '/admin/representations', icon: UsersIcon, label: 'نمایندگان' }, to: "/admin/categories",
{ to: '/admin/secretaries', icon: KeyIcon, label: 'منشی‌ها' }, icon: TagIcon,
{ to: '/admin/admin-subscription', icon: CreditCardIcon, label: 'اشتراک‌ها' }, label: "دسته‌بندی‌ها",
{ to: '/admin/settings', icon: Cog6ToothIcon, label: 'تنظیمات' }, },
{
to: "/admin/representations",
icon: UsersIcon,
label: "نمایندگان",
},
{
to: "/admin/secretaries",
icon: KeyIcon,
label: "منشی‌ها",
},
{
to: "/admin/admin-subscription",
icon: CreditCardIcon,
label: "اشتراک‌ها",
},
{
to: "/admin/settings",
icon: Cog6ToothIcon,
label: "تنظیمات",
},
], ],
}, },
]; ];
} }
if (primaryRole === 'clinic') { if (primaryRole === "clinic") {
const clinicTo = dbUuid ? `/admin/clinics/${dbUuid}` : '/admin/my-clinic'; const clinicTo = dbUuid
? `/admin/clinics/${dbUuid}`
: "/admin/my-clinic";
return [ return [
{ {
label: 'عمومی', label: "عمومی",
items: [ items: [
{ to: '/admin/dashboard', icon: ChartBarIcon, label: 'داشبورد' }, {
{ to: clinicTo, icon: BuildingOffice2Icon, label: 'کلینیک من' }, to: "/admin/dashboard",
icon: ChartBarIcon,
label: "داشبورد",
},
{
to: clinicTo,
icon: BuildingOffice2Icon,
label: "کلینیک من",
},
], ],
}, },
{ {
label: 'مدیریت', label: "مدیریت",
items: [ items: [
{ to: '/admin/appointments', icon: CalendarDaysIcon, label: 'نوبت‌ها' }, {
{ to: '/admin/my-patients', icon: FolderOpenIcon, label: 'پرونده بیماران', feature: 'patient_records' }, to: "/admin/appointments",
{ to: '/admin/staff', icon: UserPlusIcon, label: 'پرسنل' }, icon: CalendarDaysIcon,
{ to: '/admin/my-secretaries', icon: IdentificationIcon, label: 'منشیان' }, label: "نوبت‌ها",
{ to: '/admin/clinic-services', icon: WrenchScrewdriverIcon, label: 'سرویس‌ها', feature: 'services' }, },
{ to: '/admin/sms-wallet', icon: DevicePhoneMobileIcon, label: 'کیف پول پیامک', feature: 'sms_panel' }, {
to: "/admin/my-patients",
icon: FolderOpenIcon,
label: "پرونده بیماران",
feature: "patient_records",
},
{ to: "/admin/staff", icon: UserPlusIcon, label: "پرسنل" },
{
to: "/admin/my-secretaries",
icon: IdentificationIcon,
label: "منشی ها",
},
{
to: "/admin/clinic-services",
icon: WrenchScrewdriverIcon,
label: "سرویس‌ها",
feature: "services",
},
{
to: "/admin/sms-wallet",
icon: DevicePhoneMobileIcon,
label: "کیف پول پیامک",
feature: "sms_panel",
},
], ],
}, },
{ {
label: 'اشتراک', label: "اشتراک",
items: [ items: [
{ to: '/admin/subscription', icon: CreditCardIcon, label: 'پنل اشتراکی' }, {
to: "/admin/subscription",
icon: CreditCardIcon,
label: "پنل اشتراکی",
},
], ],
}, },
]; ];
} }
if (primaryRole === 'doctor') { if (primaryRole === "doctor") {
return [ return [
{ {
label: 'عمومی', label: "عمومی",
items: [ items: [
{ to: '/admin/dashboard', icon: ChartBarIcon, label: 'داشبورد' }, {
to: "/admin/dashboard",
icon: ChartBarIcon,
label: "داشبورد",
},
], ],
}, },
{ {
label: 'پروفایل', label: "پروفایل",
items: [ items: [
{ to: '/admin/profile', icon: UserCircleIcon, label: 'پروفایل من' }, {
to: "/admin/profile",
icon: UserCircleIcon,
label: "پروفایل من",
},
], ],
}, },
{ {
label: 'مدیریت', label: "مدیریت",
items: [ items: [
{ to: '/admin/appointments', icon: CalendarDaysIcon, label: 'نوبت‌های من' }, {
{ to: '/admin/my-patients', icon: FolderOpenIcon, label: 'پرونده بیماران', feature: 'patient_records' }, to: "/admin/appointments",
{ to: '/admin/staff', icon: UserPlusIcon, label: 'پرسنل' }, icon: CalendarDaysIcon,
{ to: '/admin/my-secretaries', icon: IdentificationIcon, label: 'منشیان' }, label: "نوبت‌های من",
{ to: '/admin/clinic-services', icon: WrenchScrewdriverIcon, label: 'سرویس‌ها', feature: 'services' }, },
{ to: '/admin/sms-wallet', icon: DevicePhoneMobileIcon, label: 'کیف پول پیامک', feature: 'sms_panel' }, {
to: "/admin/my-patients",
icon: FolderOpenIcon,
label: "پرونده بیماران",
feature: "patient_records",
},
{ to: "/admin/staff", icon: UserPlusIcon, label: "پرسنل" },
{
to: "/admin/my-secretaries",
icon: IdentificationIcon,
label: "منشی ها",
},
{
to: "/admin/clinic-services",
icon: WrenchScrewdriverIcon,
label: "سرویس‌ها",
feature: "services",
},
{
to: "/admin/sms-wallet",
icon: DevicePhoneMobileIcon,
label: "کیف پول پیامک",
feature: "sms_panel",
},
], ],
}, },
{ {
label: 'اشتراک', label: "اشتراک",
items: [ items: [
{ to: '/admin/subscription', icon: CreditCardIcon, label: 'پنل اشتراکی' }, {
to: "/admin/subscription",
icon: CreditCardIcon,
label: "پنل اشتراکی",
},
], ],
}, },
]; ];
} }
if (primaryRole === 'secretary') { if (primaryRole === "secretary") {
return [ return [
{ {
label: 'عمومی', label: "عمومی",
items: [ items: [
{ to: '/admin/dashboard', icon: ChartBarIcon, label: 'داشبورد' }, {
to: "/admin/dashboard",
icon: ChartBarIcon,
label: "داشبورد",
},
], ],
}, },
{ {
label: 'مدیریت', label: "مدیریت",
items: [ items: [
{ to: '/admin/appointments', icon: CalendarDaysIcon, label: 'نوبت‌ها' }, {
{ to: '/admin/my-patients', icon: FolderOpenIcon, label: 'پرونده بیماران', feature: 'patient_records' }, to: "/admin/appointments",
icon: CalendarDaysIcon,
label: "نوبت‌ها",
},
{
to: "/admin/my-patients",
icon: FolderOpenIcon,
label: "پرونده بیماران",
feature: "patient_records",
},
], ],
}, },
]; ];
@@ -159,18 +312,24 @@ function buildSections(primaryRole: string | null, dbUuid: string | null): Secti
return [ return [
{ {
label: 'عمومی', label: "عمومی",
items: [{ to: '/admin/dashboard', icon: ChartBarIcon, label: 'داشبورد' }], items: [
{
to: "/admin/dashboard",
icon: ChartBarIcon,
label: "داشبورد",
},
],
}, },
]; ];
} }
const ROLE_LABELS: Record<string, string> = { const ROLE_LABELS: Record<string, string> = {
admin: 'مدیر کل', admin: "مدیر کل",
clinic: 'مالک کلینیک', clinic: "مالک کلینیک",
doctor: 'پزشک', doctor: "پزشک",
secretary: 'منشی', secretary: "منشی",
user: 'کاربر', user: "کاربر",
}; };
const HUES = [256, 205, 162, 295, 272]; const HUES = [256, 205, 162, 295, 272];
@@ -187,12 +346,13 @@ interface Props {
export default function Sidebar({ mobileOpen: _m, onMobileClose: _c }: Props) { export default function Sidebar({ mobileOpen: _m, onMobileClose: _c }: Props) {
const sidebarOpen = useUiStore((s) => s.sidebarOpen); const sidebarOpen = useUiStore((s) => s.sidebarOpen);
const { logout, primaryRole, userName, availableContexts, dbUuid } = useAuthStore(); const { logout, primaryRole, userName, availableContexts, dbUuid } =
useAuthStore();
const { hasFeature } = useSubscription(); const { hasFeature } = useSubscription();
const navigate = useNavigate(); const navigate = useNavigate();
const sections = buildSections(primaryRole, dbUuid); const sections = buildSections(primaryRole, dbUuid);
const initials = (userName ?? 'U').charAt(0).toUpperCase(); const initials = (userName ?? "U").charAt(0).toUpperCase();
return ( return (
<aside className="sidebar"> <aside className="sidebar">
@@ -212,23 +372,56 @@ export default function Sidebar({ mobileOpen: _m, onMobileClose: _c }: Props) {
{sections.map((section) => ( {sections.map((section) => (
<div className="nav-group" key={section.label}> <div className="nav-group" key={section.label}>
<span className="nav-label">{section.label}</span> <span className="nav-label">{section.label}</span>
{section.items.map(({ to, icon: Icon, label, feature }) => { {section.items.map(
const isLocked = feature ? !hasFeature(feature) : false; ({ to, icon: Icon, label, feature }) => {
const dest = isLocked ? '/admin/subscription' : to; const isLocked = feature
? !hasFeature(feature)
: false;
const dest = isLocked
? "/admin/subscription"
: to;
return ( return (
<NavLink <NavLink
key={to} key={to}
to={dest} to={dest}
title={isLocked ? 'نیاز به ارتقاء پنل' : (!sidebarOpen ? label : undefined)} title={
className={({ isActive }) => `nav-item${isActive && !isLocked ? ' active' : ''}${isLocked ? ' locked' : ''}`} isLocked
style={isLocked ? { opacity: 0.55 } : undefined} ? "نیاز به ارتقاء پنل"
: !sidebarOpen
? label
: undefined
}
className={({ isActive }) =>
`nav-item${isActive && !isLocked ? " active" : ""}${isLocked ? " locked" : ""}`
}
style={
isLocked
? { opacity: 0.55 }
: undefined
}
> >
<Icon style={{ width: 19, height: 19, flexShrink: 0 }} /> <Icon
style={{
width: 19,
height: 19,
flexShrink: 0,
}}
/>
<span>{label}</span> <span>{label}</span>
{isLocked && <LockClosedIcon style={{ width: 12, height: 12, marginRight: 'auto', flexShrink: 0 }} />} {isLocked && (
<LockClosedIcon
style={{
width: 12,
height: 12,
marginRight: "auto",
flexShrink: 0,
}}
/>
)}
</NavLink> </NavLink>
); );
})} },
)}
</div> </div>
))} ))}
@@ -238,10 +431,14 @@ export default function Sidebar({ mobileOpen: _m, onMobileClose: _c }: Props) {
<span className="nav-label">محیط کاری</span> <span className="nav-label">محیط کاری</span>
<NavLink <NavLink
to="/admin/select-context" to="/admin/select-context"
title={!sidebarOpen ? 'تغییر محیط' : undefined} title={!sidebarOpen ? "تغییر محیط" : undefined}
className={({ isActive }) => `nav-item${isActive ? ' active' : ''}`} className={({ isActive }) =>
`nav-item${isActive ? " active" : ""}`
}
> >
<ArrowsRightLeftIcon style={{ width: 19, height: 19, flexShrink: 0 }} /> <ArrowsRightLeftIcon
style={{ width: 19, height: 19, flexShrink: 0 }}
/>
<span>تغییر محیط</span> <span>تغییر محیط</span>
</NavLink> </NavLink>
</div> </div>
@@ -252,20 +449,37 @@ export default function Sidebar({ mobileOpen: _m, onMobileClose: _c }: Props) {
<div className="sidebar-foot"> <div className="sidebar-foot">
<div <div
className="user-chip" className="user-chip"
onClick={() => { logout(); navigate('/admin/login'); }} onClick={() => {
logout();
navigate("/admin/login");
}}
title="خروج از سیستم" title="خروج از سیستم"
> >
<div <div
className="avatar sm" className="avatar sm"
style={{ background: avatarBg(userName ?? 'U'), flexShrink: 0 }} style={{
background: avatarBg(userName ?? "U"),
flexShrink: 0,
}}
> >
{initials} {initials}
</div> </div>
<div className="user-meta"> <div className="user-meta">
<b>{userName ?? 'کاربر'}</b> <b>{userName ?? "کاربر"}</b>
<span>{ROLE_LABELS[primaryRole ?? ''] ?? primaryRole ?? ''}</span> <span>
{ROLE_LABELS[primaryRole ?? ""] ??
primaryRole ??
""}
</span>
</div> </div>
<ArrowLeftOnRectangleIcon style={{ width: 16, height: 16, flexShrink: 0, color: 'var(--text-3)' }} /> <ArrowLeftOnRectangleIcon
style={{
width: 16,
height: 16,
flexShrink: 0,
color: "var(--text-3)",
}}
/>
</div> </div>
</div> </div>
</aside> </aside>
+478 -167
View File
@@ -1,27 +1,39 @@
import React, { useState } from 'react'; import {
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; CheckCircleIcon,
import { PlusIcon, PencilIcon, NoSymbolIcon, CheckCircleIcon, IdentificationIcon, PhoneIcon } from '@heroicons/react/24/outline'; IdentificationIcon,
import { useForm } from 'react-hook-form'; NoSymbolIcon,
import { zodResolver } from '@hookform/resolvers/zod'; PencilIcon,
import { z } from 'zod'; PhoneIcon,
import { toast } from 'sonner'; PlusIcon,
import { api } from '../lib/api'; } from "@heroicons/react/24/outline";
import type { ApiResponse } from '../lib/api'; import { zodResolver } from "@hookform/resolvers/zod";
import type { Secretary, SecretaryPermissions } from '../types'; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { formatDate, maskMobile } from '../lib/utils'; import { useState } from "react";
import { ActiveBadge } from '../components/ui/StatusBadge'; import { useForm } from "react-hook-form";
import DataTable, { type Column } from '../components/ui/DataTable'; import { Link } from "react-router-dom";
import Modal from '../components/ui/Modal'; import { toast } from "sonner";
import ConfirmDialog from '../components/ui/ConfirmDialog'; import { z } from "zod";
import PageHeader from '../components/ui/PageHeader'; import ConfirmDialog from "../components/ui/ConfirmDialog";
import { useAuthStore } from '../stores/authStore'; import DataTable, { type Column } from "../components/ui/DataTable";
import { useSubscription } from '../hooks/useSubscription'; import Modal from "../components/ui/Modal";
import { Link } from 'react-router-dom'; import PageHeader from "../components/ui/PageHeader";
import { ActiveBadge } from "../components/ui/StatusBadge";
import { useSubscription } from "../hooks/useSubscription";
import type { ApiResponse } from "../lib/api";
import { api } from "../lib/api";
import { formatDate, maskMobile } from "../lib/utils";
import { useAuthStore } from "../stores/authStore";
import type { Secretary, SecretaryPermissions } from "../types";
// ── Default permissions & labels ────────────────────────────────────────── // ── Default permissions & labels ──────────────────────────────────────────
const DEFAULT_PERMISSIONS: SecretaryPermissions = { const DEFAULT_PERMISSIONS: SecretaryPermissions = {
appointments: { view: true, create: false, cancel: false, update_status: false }, appointments: {
view: true,
create: false,
cancel: false,
update_status: false,
},
addresses: { view: true, create: false, update: false, delete: false }, addresses: { view: true, create: false, update: false, delete: false },
clinic_info: { view: true, update: false }, clinic_info: { view: true, update: false },
insurances: { view: true, create: false, update: false, delete: false }, insurances: { view: true, create: false, update: false, delete: false },
@@ -29,45 +41,62 @@ const DEFAULT_PERMISSIONS: SecretaryPermissions = {
type PermSection = keyof SecretaryPermissions; type PermSection = keyof SecretaryPermissions;
const PERMISSION_LABELS: Record<PermSection, { label: string; actions: { key: string; label: string }[] }> = { const PERMISSION_LABELS: Record<
PermSection,
{ label: string; actions: { key: string; label: string }[] }
> = {
appointments: { appointments: {
label: 'نوبت‌ها', label: "نوبت‌ها",
actions: [ actions: [
{ key: 'view', label: 'مشاهده' }, { key: "view", label: "مشاهده" },
{ key: 'create', label: 'ایجاد' }, { key: "create", label: "ایجاد" },
{ key: 'cancel', label: 'لغو' }, { key: "cancel", label: "لغو" },
{ key: 'update_status', label: 'تغییر وضعیت' }, { key: "update_status", label: "تغییر وضعیت" },
], ],
}, },
addresses: { addresses: {
label: 'آدرس‌ها', label: "آدرس‌ها",
actions: [ actions: [
{ key: 'view', label: 'مشاهده' }, { key: "view", label: "مشاهده" },
{ key: 'create', label: 'ایجاد' }, { key: "create", label: "ایجاد" },
{ key: 'update', label: 'ویرایش' }, { key: "update", label: "ویرایش" },
{ key: 'delete', label: 'حذف' }, { key: "delete", label: "حذف" },
], ],
}, },
clinic_info: { clinic_info: {
label: 'اطلاعات کلینیک', label: "اطلاعات کلینیک",
actions: [ actions: [
{ key: 'view', label: 'مشاهده' }, { key: "view", label: "مشاهده" },
{ key: 'update', label: 'ویرایش' }, { key: "update", label: "ویرایش" },
], ],
}, },
insurances: { insurances: {
label: 'بیمه‌ها', label: "بیمه‌ها",
actions: [ actions: [
{ key: 'view', label: 'مشاهده' }, { key: "view", label: "مشاهده" },
{ key: 'create', label: 'ایجاد' }, { key: "create", label: "ایجاد" },
{ key: 'update', label: 'ویرایش' }, { key: "update", label: "ویرایش" },
{ key: 'delete', label: 'حذف' }, { key: "delete", label: "حذف" },
], ],
}, },
}; };
const ALL_ACTIONS = ['view', 'create', 'update', 'delete', 'cancel', 'update_status']; const ALL_ACTIONS = [
const ACTION_HEADERS = ['مشاهده', 'ایجاد', 'ویرایش', 'حذف', 'لغو', 'تغییر وضعیت']; "view",
"create",
"update",
"delete",
"cancel",
"update_status",
];
const ACTION_HEADERS = [
"مشاهده",
"ایجاد",
"ویرایش",
"حذف",
"لغو",
"تغییر وضعیت",
];
function PermissionsMatrix({ function PermissionsMatrix({
permissions, permissions,
@@ -80,49 +109,131 @@ function PermissionsMatrix({
const cur = (permissions[section] as Record<string, boolean>)[action]; const cur = (permissions[section] as Record<string, boolean>)[action];
onChange({ onChange({
...permissions, ...permissions,
[section]: { ...(permissions[section] as Record<string, boolean>), [action]: !cur }, [section]: {
...(permissions[section] as Record<string, boolean>),
[action]: !cur,
},
}); });
}; };
return ( return (
<div style={{ overflowX: 'auto' }}> <div style={{ overflowX: "auto" }}>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}> <table
style={{
width: "100%",
borderCollapse: "collapse",
fontSize: 13,
}}
>
<thead> <thead>
<tr style={{ background: 'oklch(0.97 0.01 256)' }}> <tr style={{ background: "oklch(0.97 0.01 256)" }}>
<th style={{ textAlign: 'right', padding: '10px 12px', fontWeight: 600, color: 'var(--text-2)', borderBottom: '2px solid var(--border)' }}>بخش</th> <th
style={{
textAlign: "right",
padding: "10px 12px",
fontWeight: 600,
color: "var(--text-2)",
borderBottom: "2px solid var(--border)",
}}
>
بخش
</th>
{ACTION_HEADERS.map((h) => ( {ACTION_HEADERS.map((h) => (
<th key={h} style={{ textAlign: 'center', padding: '10px 8px', fontSize: 12, fontWeight: 600, color: 'var(--text-2)', borderBottom: '2px solid var(--border)' }}>{h}</th> <th
key={h}
style={{
textAlign: "center",
padding: "10px 8px",
fontSize: 12,
fontWeight: 600,
color: "var(--text-2)",
borderBottom: "2px solid var(--border)",
}}
>
{h}
</th>
))} ))}
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{(Object.keys(PERMISSION_LABELS) as PermSection[]).map((section, idx) => { {(Object.keys(PERMISSION_LABELS) as PermSection[]).map(
(section, idx) => {
const config = PERMISSION_LABELS[section]; const config = PERMISSION_LABELS[section];
const sectionPrm = permissions[section] as Record<string, boolean>; const sectionPrm = permissions[section] as Record<
string,
boolean
>;
return ( return (
<tr key={section} style={{ background: idx % 2 === 0 ? 'transparent' : 'oklch(0.985 0.005 256)' }}> <tr
<td style={{ padding: '10px 12px', borderBottom: '1px solid var(--border)', fontWeight: 600 }}> key={section}
style={{
background:
idx % 2 === 0
? "transparent"
: "oklch(0.985 0.005 256)",
}}
>
<td
style={{
padding: "10px 12px",
borderBottom:
"1px solid var(--border)",
fontWeight: 600,
}}
>
{config.label} {config.label}
</td> </td>
{ALL_ACTIONS.map((action) => { {ALL_ACTIONS.map((action) => {
const ac = config.actions.find((a) => a.key === action); const ac = config.actions.find(
if (!ac) return ( (a) => a.key === action,
<td key={action} style={{ textAlign: 'center', borderBottom: '1px solid var(--border)', color: 'var(--border)' }}></td> );
if (!ac)
return (
<td
key={action}
style={{
textAlign: "center",
borderBottom:
"1px solid var(--border)",
color: "var(--border)",
}}
>
</td>
); );
return ( return (
<td key={action} style={{ textAlign: 'center', borderBottom: '1px solid var(--border)' }}> <td
key={action}
style={{
textAlign: "center",
borderBottom:
"1px solid var(--border)",
}}
>
<input <input
type="checkbox" type="checkbox"
checked={sectionPrm[action] ?? false} checked={
onChange={() => toggle(section, action)} sectionPrm[action] ??
style={{ width: 16, height: 16, accentColor: 'var(--primary)', cursor: 'pointer' }} false
}
onChange={() =>
toggle(section, action)
}
style={{
width: 16,
height: 16,
accentColor:
"var(--primary)",
cursor: "pointer",
}}
/> />
</td> </td>
); );
})} })}
</tr> </tr>
); );
})} },
)}
</tbody> </tbody>
</table> </table>
</div> </div>
@@ -132,12 +243,15 @@ function PermissionsMatrix({
// ── Create form schema ───────────────────────────────────────────────────── // ── Create form schema ─────────────────────────────────────────────────────
const createSchema = z.object({ const createSchema = z.object({
name: z.string().min(2, 'نام الزامی است'), name: z.string().min(2, "نام الزامی است"),
mobile_number: z.string().regex(/^09[0-9]{9}$/, 'شماره موبایل معتبر نیست'), mobile_number: z.string().regex(/^09[0-9]{9}$/, "شماره موبایل معتبر نیست"),
}); });
type CreateForm = z.infer<typeof createSchema>; type CreateForm = z.infer<typeof createSchema>;
interface ClinicDoctor { uuid: string; name: string; } interface ClinicDoctor {
uuid: string;
name: string;
}
// ── Main component ───────────────────────────────────────────────────────── // ── Main component ─────────────────────────────────────────────────────────
@@ -145,37 +259,43 @@ export default function MySecretariesPage() {
const qc = useQueryClient(); const qc = useQueryClient();
const { doctorUuid, dbUuid, primaryRole } = useAuthStore(); const { doctorUuid, dbUuid, primaryRole } = useAuthStore();
const isClinic = primaryRole === 'clinic'; const isClinic = primaryRole === "clinic";
const { maxSecretaries } = useSubscription(); const { maxSecretaries } = useSubscription();
// for clinic: selected doctor to add secretary for // for clinic: selected doctor to add secretary for
const [selectedDoctorUuid, setSelectedDoctorUuid] = useState<string>(''); const [selectedDoctorUuid, setSelectedDoctorUuid] = useState<string>("");
const activeDoctorUuid = isClinic ? selectedDoctorUuid : (doctorUuid ?? ''); const activeDoctorUuid = isClinic ? selectedDoctorUuid : (doctorUuid ?? "");
const [createOpen, setCreateOpen] = useState(false); const [createOpen, setCreateOpen] = useState(false);
const [editTarget, setEditTarget] = useState<Secretary | null>(null); const [editTarget, setEditTarget] = useState<Secretary | null>(null);
const [editPerms, setEditPerms] = useState<SecretaryPermissions>(DEFAULT_PERMISSIONS); const [editPerms, setEditPerms] =
useState<SecretaryPermissions>(DEFAULT_PERMISSIONS);
const [deleteTarget, setDeleteTarget] = useState<Secretary | null>(null); const [deleteTarget, setDeleteTarget] = useState<Secretary | null>(null);
// clinic: load clinic's doctors // clinic: load clinic's doctors
const { data: clinicDoctorsData, isLoading: clinicDoctorsLoading } = useQuery<ApiResponse<{ data: ClinicDoctor[] }>>({ const { data: clinicDoctorsData, isLoading: clinicDoctorsLoading } =
queryKey: ['clinic-doctors', dbUuid], useQuery<ApiResponse<{ data: ClinicDoctor[] }>>({
queryKey: ["clinic-doctors", dbUuid],
queryFn: () => api.get(`/api/v1/clinic/doctor-list/${dbUuid}`), queryFn: () => api.get(`/api/v1/clinic/doctor-list/${dbUuid}`),
enabled: isClinic && !!dbUuid, enabled: isClinic && !!dbUuid,
}); });
const clinicDoctors: ClinicDoctor[] = clinicDoctorsData?.data?.data ?? []; const clinicDoctors: ClinicDoctor[] = clinicDoctorsData?.data?.data ?? [];
// clinic: load ALL secretaries across all its doctors // clinic: load ALL secretaries across all its doctors
const { data: clinicSecrData, isLoading: clinicSecrLoading } = useQuery<ApiResponse<Secretary[]>>({ const { data: clinicSecrData, isLoading: clinicSecrLoading } = useQuery<
queryKey: ['my-secretaries-clinic', dbUuid], ApiResponse<Secretary[]>
>({
queryKey: ["my-secretaries-clinic", dbUuid],
queryFn: () => api.get(`/api/v1/secretaries/clinic/${dbUuid}`), queryFn: () => api.get(`/api/v1/secretaries/clinic/${dbUuid}`),
enabled: isClinic && !!dbUuid, enabled: isClinic && !!dbUuid,
}); });
// doctor: load secretaries for the doctor // doctor: load secretaries for the doctor
const { data: doctorSecrData, isLoading: doctorSecrLoading } = useQuery<ApiResponse<Secretary[]>>({ const { data: doctorSecrData, isLoading: doctorSecrLoading } = useQuery<
queryKey: ['my-secretaries', activeDoctorUuid], ApiResponse<Secretary[]>
>({
queryKey: ["my-secretaries", activeDoctorUuid],
queryFn: () => api.get(`/api/v1/secretaries/${activeDoctorUuid}`), queryFn: () => api.get(`/api/v1/secretaries/${activeDoctorUuid}`),
enabled: !isClinic && !!activeDoctorUuid, enabled: !isClinic && !!activeDoctorUuid,
}); });
@@ -186,29 +306,42 @@ export default function MySecretariesPage() {
const activeSecretaryCount = secretaries.filter((s) => s.is_active).length; const activeSecretaryCount = secretaries.filter((s) => s.is_active).length;
const isLoading = isClinic ? clinicSecrLoading : doctorSecrLoading; const isLoading = isClinic ? clinicSecrLoading : doctorSecrLoading;
const createForm = useForm<CreateForm>({ resolver: zodResolver(createSchema) }); const createForm = useForm<CreateForm>({
resolver: zodResolver(createSchema),
});
const createMutation = useMutation({ const createMutation = useMutation({
mutationFn: (body: CreateForm) => mutationFn: (body: CreateForm) =>
api.post('/api/v1/secretary', { ...body, doctor_uuid: activeDoctorUuid }), api.post("/api/v1/secretary", {
...body,
doctor_uuid: activeDoctorUuid,
}),
onSuccess: () => { onSuccess: () => {
toast.success('منشی اضافه شد'); toast.success("منشی اضافه شد");
setCreateOpen(false); setCreateOpen(false);
createForm.reset(); createForm.reset();
qc.invalidateQueries({ queryKey: ['my-secretaries'] }); qc.invalidateQueries({ queryKey: ["my-secretaries"] });
qc.invalidateQueries({ queryKey: ['my-secretaries-clinic'] }); qc.invalidateQueries({ queryKey: ["my-secretaries-clinic"] });
}, },
onError: (e: any) => toast.error(e.message), onError: (e: any) => toast.error(e.message),
}); });
const updatePermsMutation = useMutation({ const updatePermsMutation = useMutation({
mutationFn: ({ uuid, permissions }: { uuid: string; permissions: SecretaryPermissions }) => mutationFn: ({
api.patch(`/api/v1/secretary/${uuid}`, { permissions: { version: 1, resources: permissions } }), uuid,
permissions,
}: {
uuid: string;
permissions: SecretaryPermissions;
}) =>
api.patch(`/api/v1/secretary/${uuid}`, {
permissions: { version: 1, resources: permissions },
}),
onSuccess: () => { onSuccess: () => {
toast.success('دسترسی‌ها بروزرسانی شد'); toast.success("دسترسی‌ها بروزرسانی شد");
setEditTarget(null); setEditTarget(null);
qc.invalidateQueries({ queryKey: ['my-secretaries'] }); qc.invalidateQueries({ queryKey: ["my-secretaries"] });
qc.invalidateQueries({ queryKey: ['my-secretaries-clinic'] }); qc.invalidateQueries({ queryKey: ["my-secretaries-clinic"] });
}, },
onError: (e: any) => toast.error(e.message), onError: (e: any) => toast.error(e.message),
}); });
@@ -217,10 +350,10 @@ export default function MySecretariesPage() {
mutationFn: ({ uuid, active }: { uuid: string; active: boolean }) => mutationFn: ({ uuid, active }: { uuid: string; active: boolean }) =>
api.patch(`/api/v1/secretary/${uuid}`, { active }), api.patch(`/api/v1/secretary/${uuid}`, { active }),
onSuccess: (_, { active }) => { onSuccess: (_, { active }) => {
toast.success(active ? 'منشی فعال شد' : 'منشی غیرفعال شد'); toast.success(active ? "منشی فعال شد" : "منشی غیرفعال شد");
setDeleteTarget(null); setDeleteTarget(null);
qc.invalidateQueries({ queryKey: ['my-secretaries'] }); qc.invalidateQueries({ queryKey: ["my-secretaries"] });
qc.invalidateQueries({ queryKey: ['my-secretaries-clinic'] }); qc.invalidateQueries({ queryKey: ["my-secretaries-clinic"] });
}, },
onError: (e: any) => toast.error(e.message), onError: (e: any) => toast.error(e.message),
}); });
@@ -232,84 +365,116 @@ export default function MySecretariesPage() {
const handleCreateOpen = () => { const handleCreateOpen = () => {
if (isClinic && !selectedDoctorUuid) { if (isClinic && !selectedDoctorUuid) {
toast.error('ابتدا یک پزشک را انتخاب کنید'); toast.error("ابتدا یک پزشک را انتخاب کنید");
return; return;
} }
setCreateOpen(true); setCreateOpen(true);
}; };
const selectedDoctorName = clinicDoctors.find(d => d.uuid === selectedDoctorUuid)?.name ?? ''; const selectedDoctorName =
clinicDoctors.find((d) => d.uuid === selectedDoctorUuid)?.name ?? "";
// for clinic: show doctor column in the table // for clinic: show doctor column in the table
const clinicColumns: Column<Secretary>[] = isClinic ? [ const clinicColumns: Column<Secretary>[] = isClinic
? [
{ {
key: 'doctor_name', key: "doctor_name",
header: 'پزشک', header: "پزشک",
render: (s) => ( render: (s) => (
<span style={{ fontSize: 13, color: 'var(--text-2)', fontWeight: 500 }}>{s.doctor_name}</span> <span
style={{
fontSize: 13,
color: "var(--text-2)",
fontWeight: 500,
}}
>
{s.doctor_name}
</span>
), ),
}, },
] : []; ]
: [];
const allColumns: Column<Secretary>[] = [ const allColumns: Column<Secretary>[] = [
{ {
key: 'user_name', key: "user_name",
header: 'منشی', header: "منشی",
render: (s) => ( render: (s) => (
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}> <div style={{ display: "flex", alignItems: "center", gap: 10 }}>
<div <div
className="avatar sm" className="avatar sm"
style={{ style={{
background: 'linear-gradient(145deg, oklch(0.62 0.15 295), oklch(0.48 0.16 295))', background:
flexShrink: 0, fontWeight: 700, "linear-gradient(145deg, oklch(0.62 0.15 295), oklch(0.48 0.16 295))",
flexShrink: 0,
fontWeight: 700,
}} }}
> >
{(s.user_name ?? '?').charAt(0)} {(s.user_name ?? "?").charAt(0)}
</div>
<div style={{ fontWeight: 600, fontSize: 14 }}>
{s.user_name}
</div> </div>
<div style={{ fontWeight: 600, fontSize: 14 }}>{s.user_name}</div>
</div> </div>
), ),
}, },
...clinicColumns, ...clinicColumns,
{ {
key: 'mobile_number', key: "mobile_number",
header: 'موبایل', header: "موبایل",
render: (s) => ( render: (s) => (
<div style={{ display: 'flex', alignItems: 'center', gap: 5, fontSize: 13, color: 'var(--text-2)' }}> <div
style={{
display: "flex",
alignItems: "center",
gap: 5,
fontSize: 13,
color: "var(--text-2)",
}}
>
<PhoneIcon style={{ width: 13 }} /> <PhoneIcon style={{ width: 13 }} />
<span dir="ltr">{maskMobile(s.mobile_number)}</span> <span dir="ltr">{maskMobile(s.mobile_number)}</span>
</div> </div>
), ),
}, },
{ {
key: 'is_active', key: "is_active",
header: 'وضعیت', header: "وضعیت",
render: (s) => <ActiveBadge active={s.is_active} />, render: (s) => <ActiveBadge active={s.is_active} />,
}, },
{ {
key: 'created_at', key: "created_at",
header: 'تاریخ ثبت', header: "تاریخ ثبت",
render: (s) => ( render: (s) => (
<span style={{ fontSize: 13, color: 'var(--text-3)' }}>{formatDate(Number(s.created_at))}</span> <span style={{ fontSize: 13, color: "var(--text-3)" }}>
{formatDate(Number(s.created_at))}
</span>
), ),
}, },
{ {
key: 'uuid', key: "uuid",
header: 'عملیات', header: "عملیات",
render: (s) => ( render: (s) => (
<div style={{ display: 'flex', gap: 6 }}> <div style={{ display: "flex", gap: 6 }}>
<button className="btn sm" onClick={() => openEdit(s)} title="ویرایش دسترسی‌ها"> <button
className="btn sm"
onClick={() => openEdit(s)}
title="ویرایش دسترسی‌ها"
>
<PencilIcon style={{ width: 14 }} /> <PencilIcon style={{ width: 14 }} />
</button> </button>
<button <button
className="btn sm" className="btn sm"
onClick={() => setDeleteTarget(s)} onClick={() => setDeleteTarget(s)}
title={s.is_active ? 'غیرفعال‌سازی' : 'فعال‌سازی'} title={s.is_active ? "غیرفعال‌سازی" : "فعال‌سازی"}
> >
{s.is_active {s.is_active ? (
? <NoSymbolIcon style={{ width: 14 }} /> <NoSymbolIcon style={{ width: 14 }} />
: <CheckCircleIcon style={{ width: 14, color: 'var(--success)' }} /> ) : (
} <CheckCircleIcon
style={{ width: 14, color: "var(--success)" }}
/>
)}
</button> </button>
</div> </div>
), ),
@@ -319,15 +484,24 @@ export default function MySecretariesPage() {
return ( return (
<> <>
<PageHeader <PageHeader
title="منشیان من" title="منشی ها من"
description="مدیریت منشیان و دسترسی‌های آن‌ها" description="مدیریت منشی ها و دسترسی‌های آن‌ها"
action={ action={
activeSecretaryCount >= maxSecretaries ? ( activeSecretaryCount >= maxSecretaries ? (
<Link to="/admin/subscription" className="btn sm" style={{ textDecoration: 'none', opacity: 0.8 }} title={`حداکثر ${maxSecretaries} منشی مجاز است`}> <Link
to="/admin/subscription"
className="btn sm"
style={{ textDecoration: "none", opacity: 0.8 }}
title={`حداکثر ${maxSecretaries} منشی مجاز است`}
>
ارتقاء پنل برای افزودن منشی ارتقاء پنل برای افزودن منشی
</Link> </Link>
) : ( ) : (
<button className="btn primary sm" onClick={handleCreateOpen} disabled={isClinic && !selectedDoctorUuid}> <button
className="btn primary sm"
onClick={handleCreateOpen}
disabled={isClinic && !selectedDoctorUuid}
>
<PlusIcon style={{ width: 16 }} /> <PlusIcon style={{ width: 16 }} />
افزودن منشی افزودن منشی
</button> </button>
@@ -341,18 +515,29 @@ export default function MySecretariesPage() {
<div className="field" style={{ marginBottom: 0 }}> <div className="field" style={{ marginBottom: 0 }}>
<label>پزشک مورد نظر برای افزودن منشی جدید</label> <label>پزشک مورد نظر برای افزودن منشی جدید</label>
{clinicDoctorsLoading ? ( {clinicDoctorsLoading ? (
<p style={{ fontSize: 13, color: 'var(--text-3)' }}>در حال بارگذاری...</p> <p style={{ fontSize: 13, color: "var(--text-3)" }}>
در حال بارگذاری...
</p>
) : clinicDoctors.length === 0 ? ( ) : clinicDoctors.length === 0 ? (
<p style={{ fontSize: 13, color: 'var(--text-3)' }}>هیچ پزشکی در این کلینیک تعریف نشده است. ابتدا پزشک اضافه کنید.</p> <p style={{ fontSize: 13, color: "var(--text-3)" }}>
هیچ پزشکی در این کلینیک تعریف نشده است. ابتدا
پزشک اضافه کنید.
</p>
) : ( ) : (
<select <select
value={selectedDoctorUuid} value={selectedDoctorUuid}
onChange={(e) => setSelectedDoctorUuid(e.target.value)} onChange={(e) =>
style={{ width: '100%', maxWidth: 360 }} setSelectedDoctorUuid(e.target.value)
}
style={{ width: "100%", maxWidth: 360 }}
> >
<option value=""> یک پزشک را انتخاب کنید </option> <option value="">
یک پزشک را انتخاب کنید
</option>
{clinicDoctors.map((d) => ( {clinicDoctors.map((d) => (
<option key={d.uuid} value={d.uuid}>{d.name}</option> <option key={d.uuid} value={d.uuid}>
{d.name}
</option>
))} ))}
</select> </select>
)} )}
@@ -361,28 +546,68 @@ export default function MySecretariesPage() {
)} )}
{!isClinic && !doctorUuid ? ( {!isClinic && !doctorUuid ? (
<div className="card card-pad" style={{ textAlign: 'center', color: 'var(--text-3)' }}> <div
className="card card-pad"
style={{ textAlign: "center", color: "var(--text-3)" }}
>
پروفایل پزشک یافت نشد پروفایل پزشک یافت نشد
</div> </div>
) : ( ) : (
<div className="card"> <div className="card">
{secretaries.length === 0 && !isLoading ? ( {secretaries.length === 0 && !isLoading ? (
<div style={{ textAlign: 'center', padding: '60px 24px' }}> <div
<IdentificationIcon style={{ width: 48, color: 'var(--text-3)', margin: '0 auto 16px', display: 'block', opacity: 0.4 }} /> style={{
<div style={{ fontWeight: 600, fontSize: 15, marginBottom: 8, color: 'var(--text-2)' }}>هنوز منشیای اضافه نشده</div> textAlign: "center",
<div style={{ color: 'var(--text-3)', fontSize: 13.5, marginBottom: 20 }}> padding: "60px 24px",
}}
>
<IdentificationIcon
style={{
width: 48,
color: "var(--text-3)",
margin: "0 auto 16px",
display: "block",
opacity: 0.4,
}}
/>
<div
style={{
fontWeight: 600,
fontSize: 15,
marginBottom: 8,
color: "var(--text-2)",
}}
>
هنوز منشیای اضافه نشده
</div>
<div
style={{
color: "var(--text-3)",
fontSize: 13.5,
marginBottom: 20,
}}
>
{isClinic {isClinic
? 'برای افزودن منشی، ابتدا یک پزشک را از لیست بالا انتخاب کنید' ? "برای افزودن منشی، ابتدا یک پزشک را از لیست بالا انتخاب کنید"
: 'منشی می‌تواند نوبت‌ها و اطلاعات کلینیک را مدیریت کند'} : "منشی می‌تواند نوبت‌ها و اطلاعات کلینیک را مدیریت کند"}
</div> </div>
{!isClinic && ( {!isClinic && (
<button className="btn primary sm" onClick={handleCreateOpen}> <button
<PlusIcon style={{ width: 16 }} /> افزودن اولین منشی className="btn primary sm"
onClick={handleCreateOpen}
>
<PlusIcon style={{ width: 16 }} /> افزودن
اولین منشی
</button> </button>
)} )}
</div> </div>
) : ( ) : (
<DataTable columns={allColumns} data={secretaries} loading={isLoading} emptyMessage="منشی‌ای ثبت نشده است" /> <DataTable
columns={allColumns}
data={secretaries}
loading={isLoading}
emptyMessage="منشی‌ای ثبت نشده است"
/>
)} )}
</div> </div>
)} )}
@@ -390,12 +615,22 @@ export default function MySecretariesPage() {
{/* Modal افزودن منشی */} {/* Modal افزودن منشی */}
<Modal <Modal
open={createOpen} open={createOpen}
onClose={() => { setCreateOpen(false); createForm.reset(); }} onClose={() => {
setCreateOpen(false);
createForm.reset();
}}
title="افزودن منشی جدید" title="افزودن منشی جدید"
size="sm" size="sm"
footer={ footer={
<> <>
<button type="button" className="btn ghost sm" onClick={() => { setCreateOpen(false); createForm.reset(); }}> <button
type="button"
className="btn ghost sm"
onClick={() => {
setCreateOpen(false);
createForm.reset();
}}
>
انصراف انصراف
</button> </button>
<button <button
@@ -404,57 +639,104 @@ export default function MySecretariesPage() {
className="btn primary sm" className="btn primary sm"
disabled={createMutation.isPending} disabled={createMutation.isPending}
> >
{createMutation.isPending ? 'در حال افزودن...' : 'افزودن منشی'} {createMutation.isPending
? "در حال افزودن..."
: "افزودن منشی"}
</button> </button>
</> </>
} }
> >
{isClinic && selectedDoctorName && ( {isClinic && selectedDoctorName && (
<div style={{ <div
display: 'flex', alignItems: 'center', gap: 8, style={{
padding: '9px 12px', marginBottom: 20, display: "flex",
background: 'var(--primary-subtle)', borderRadius: 8, alignItems: "center",
fontSize: 13, color: 'var(--primary)', gap: 8,
}}> padding: "9px 12px",
<IdentificationIcon style={{ width: 16, flexShrink: 0 }} /> marginBottom: 20,
منشی برای <strong>{selectedDoctorName}</strong> ثبت میشود background: "var(--primary-subtle)",
borderRadius: 8,
fontSize: 13,
color: "var(--primary)",
}}
>
<IdentificationIcon
style={{ width: 16, flexShrink: 0 }}
/>
منشی برای <strong>{selectedDoctorName}</strong> ثبت
میشود
</div> </div>
)} )}
<form id="create-secretary-form" onSubmit={createForm.handleSubmit((d) => createMutation.mutate(d))}> <form
id="create-secretary-form"
onSubmit={createForm.handleSubmit((d) =>
createMutation.mutate(d),
)}
>
<div className="field"> <div className="field">
<label>نام و نام خانوادگی *</label> <label>نام و نام خانوادگی *</label>
<input <input
{...createForm.register('name')} {...createForm.register("name")}
placeholder="مثال: سارا احمدی" placeholder="مثال: سارا احمدی"
autoComplete="off" autoComplete="off"
autoFocus autoFocus
style={createForm.formState.errors.name ? { borderColor: 'var(--danger)' } : undefined} style={
createForm.formState.errors.name
? { borderColor: "var(--danger)" }
: undefined
}
/> />
{createForm.formState.errors.name && ( {createForm.formState.errors.name && (
<span className="field-error">{createForm.formState.errors.name.message}</span> <span className="field-error">
{createForm.formState.errors.name.message}
</span>
)} )}
</div> </div>
<div className="field" style={{ marginTop: 16 }}> <div className="field" style={{ marginTop: 16 }}>
<label>شماره موبایل *</label> <label>شماره موبایل *</label>
<input <input
{...createForm.register('mobile_number')} {...createForm.register("mobile_number")}
placeholder="09123456789" placeholder="09123456789"
dir="ltr" dir="ltr"
inputMode="numeric" inputMode="numeric"
maxLength={11} maxLength={11}
autoComplete="off" autoComplete="off"
onChange={(e) => { onChange={(e) => {
const digits = e.target.value.replace(/\D/g, ''); const digits = e.target.value.replace(
createForm.setValue('mobile_number', digits, { shouldValidate: createForm.formState.isSubmitted }); /\D/g,
"",
);
createForm.setValue("mobile_number", digits, {
shouldValidate:
createForm.formState.isSubmitted,
});
}} }}
style={createForm.formState.errors.mobile_number ? { borderColor: 'var(--danger)' } : undefined} style={
createForm.formState.errors.mobile_number
? { borderColor: "var(--danger)" }
: undefined
}
/> />
{createForm.formState.errors.mobile_number && ( {createForm.formState.errors.mobile_number && (
<span className="field-error">{createForm.formState.errors.mobile_number.message}</span> <span className="field-error">
{
createForm.formState.errors.mobile_number
.message
}
</span>
)} )}
</div> </div>
<p style={{ fontSize: 12.5, color: 'var(--text-3)', margin: '4px 0 0', lineHeight: 1.7 }}> <p
شماره باید با ۰۹ شروع شده و ۱۱ رقم باشد. اگر کاربری با این شماره در سیستم وجود داشته باشد، به عنوان منشی اضافه میشود. style={{
fontSize: 12.5,
color: "var(--text-3)",
margin: "4px 0 0",
lineHeight: 1.7,
}}
>
شماره باید با ۰۹ شروع شده و ۱۱ رقم باشد. اگر کاربری با
این شماره در سیستم وجود داشته باشد، به عنوان منشی اضافه
میشود.
</p> </p>
</form> </form>
</Modal> </Modal>
@@ -463,36 +745,65 @@ export default function MySecretariesPage() {
<Modal <Modal
open={!!editTarget} open={!!editTarget}
onClose={() => setEditTarget(null)} onClose={() => setEditTarget(null)}
title={`دسترسی‌های ${editTarget?.user_name ?? ''}`} title={`دسترسی‌های ${editTarget?.user_name ?? ""}`}
size="lg" size="lg"
footer={ footer={
<> <>
<button className="btn ghost sm" onClick={() => setEditTarget(null)}>لغو</button> <button
className="btn ghost sm"
onClick={() => setEditTarget(null)}
>
لغو
</button>
<button <button
className="btn primary sm" className="btn primary sm"
disabled={updatePermsMutation.isPending} disabled={updatePermsMutation.isPending}
onClick={() => editTarget && updatePermsMutation.mutate({ uuid: editTarget.uuid, permissions: editPerms })} onClick={() =>
editTarget &&
updatePermsMutation.mutate({
uuid: editTarget.uuid,
permissions: editPerms,
})
}
> >
{updatePermsMutation.isPending ? 'در حال ذخیره...' : 'ذخیره دسترسی‌ها'} {updatePermsMutation.isPending
? "در حال ذخیره..."
: "ذخیره دسترسی‌ها"}
</button> </button>
</> </>
} }
> >
<PermissionsMatrix permissions={editPerms} onChange={setEditPerms} /> <PermissionsMatrix
permissions={editPerms}
onChange={setEditPerms}
/>
</Modal> </Modal>
{/* Confirm تغییر وضعیت منشی */} {/* Confirm تغییر وضعیت منشی */}
<ConfirmDialog <ConfirmDialog
open={!!deleteTarget} open={!!deleteTarget}
title={deleteTarget?.is_active ? 'غیرفعال‌سازی منشی' : 'فعال‌سازی منشی'} title={
message={deleteTarget?.is_active deleteTarget?.is_active
? "غیرفعال‌سازی منشی"
: "فعال‌سازی منشی"
}
message={
deleteTarget?.is_active
? `آیا مطمئن هستید که می‌خواهید منشی «${deleteTarget?.user_name}» را غیرفعال کنید؟` ? `آیا مطمئن هستید که می‌خواهید منشی «${deleteTarget?.user_name}» را غیرفعال کنید؟`
: `آیا مطمئن هستید که می‌خواهید منشی «${deleteTarget?.user_name}» را دوباره فعال کنید؟` : `آیا مطمئن هستید که می‌خواهید منشی «${deleteTarget?.user_name}» را دوباره فعال کنید؟`
} }
confirmLabel={deleteTarget?.is_active ? 'غیرفعال‌سازی' : 'فعال‌سازی'} confirmLabel={
deleteTarget?.is_active ? "غیرفعال‌سازی" : "فعال‌سازی"
}
danger={deleteTarget?.is_active} danger={deleteTarget?.is_active}
loading={toggleActiveMutation.isPending} loading={toggleActiveMutation.isPending}
onConfirm={() => deleteTarget && toggleActiveMutation.mutate({ uuid: deleteTarget.uuid, active: !deleteTarget.is_active })} onConfirm={() =>
deleteTarget &&
toggleActiveMutation.mutate({
uuid: deleteTarget.uuid,
active: !deleteTarget.is_active,
})
}
onCancel={() => setDeleteTarget(null)} onCancel={() => setDeleteTarget(null)}
/> />
</> </>
+53 -18
View File
@@ -1,4 +1,5 @@
# PRD — ClinicPro Phase 2 # PRD — ClinicPro Phase 2
**تاریخ:** ۲۴ خرداد ۱۴۰۵ **تاریخ:** ۲۴ خرداد ۱۴۰۵
**نسخه:** 1.2 **نسخه:** 1.2
**وضعیت:** Draft **وضعیت:** Draft
@@ -14,7 +15,7 @@
## وضعیت فعلی پروژه ## وضعیت فعلی پروژه
| ماژول | Backend | API Doc | | ماژول | Backend | API Doc |
|--------|---------|---------| | -------------------------------------- | ------------------------------------------------ | ---------------------------------------------------- |
| Auth (OTP + JWT + switch-context) | ✅ کامل | `docs/api/auth.md` | | Auth (OTP + JWT + switch-context) | ✅ کامل | `docs/api/auth.md` |
| Doctor profile | ✅ کامل | `docs/api/doctor.md` | | Doctor profile | ✅ کامل | `docs/api/doctor.md` |
| Clinic management | ✅ کامل | `docs/api/clinic.md` | | Clinic management | ✅ کامل | `docs/api/clinic.md` |
@@ -41,12 +42,13 @@
### اپیک ۱ — پرسنل (Staff) ### اپیک ۱ — پرسنل (Staff)
#### توضیح #### توضیح
مدیر کلینیک یا دکتر مستقل می‌تواند پرسنل کلینیک را ثبت کند. پرسنل حساب کاربری ندارند و نیازی به لاگین ندارند. در سرویس‌دهی، سشن‌ها و گزارشات به آن‌ها ارجاع می‌شود. مدیر کلینیک یا دکتر مستقل می‌تواند پرسنل کلینیک را ثبت کند. پرسنل حساب کاربری ندارند و نیازی به لاگین ندارند. در سرویس‌دهی، سشن‌ها و گزارشات به آن‌ها ارجاع می‌شود.
#### نیازمندی‌های کارکردی #### نیازمندی‌های کارکردی
| کد | نیازمندی | | کد | نیازمندی |
|----|---------| | ---- | ------------------------------------------------------------------------------------ |
| F1.1 | ایجاد / ویرایش / غیرفعال‌سازی پرسنل — **حذف سخت ممنوع** (تاریخچه حفظ شود) | | F1.1 | ایجاد / ویرایش / غیرفعال‌سازی پرسنل — **حذف سخت ممنوع** (تاریخچه حفظ شود) |
| F1.2 | فیلدها: نام و نام‌خانوادگی (اجباری)، شماره تماس، عنوان شغل، آدرس، کد ملی، وضعیت فعال | | F1.2 | فیلدها: نام و نام‌خانوادگی (اجباری)، شماره تماس، عنوان شغل، آدرس، کد ملی، وضعیت فعال |
| F1.3 | پرسنل به `entity_type + entity_id` وابسته است (کلینیک یا مطب) | | F1.3 | پرسنل به `entity_type + entity_id` وابسته است (کلینیک یا مطب) |
@@ -54,6 +56,7 @@
| F1.5 | در ایجاد ServiceItem و SessionService از لیست پرسنل انتخاب می‌شود | | F1.5 | در ایجاد ServiceItem و SessionService از لیست پرسنل انتخاب می‌شود |
#### Entity جدید #### Entity جدید
``` ```
clinic_staff: clinic_staff:
id, uuid id, uuid
@@ -69,18 +72,21 @@ clinic_staff:
``` ```
#### API #### API
| Method | Path | توضیح | | Method | Path | توضیح |
|--------|------|-------| | ------- | ----------------------------- | -------------------------- |
| `GET` | `/api/v1/staff` | لیست پرسنل کاربر جاری | | `GET` | `/api/v1/staff` | لیست پرسنل کاربر جاری |
| `POST` | `/api/v1/staff` | ایجاد پرسنل جدید | | `POST` | `/api/v1/staff` | ایجاد پرسنل جدید |
| `PATCH` | `/api/v1/staff/{uuid}` | ویرایش اطلاعات | | `PATCH` | `/api/v1/staff/{uuid}` | ویرایش اطلاعات |
| `PATCH` | `/api/v1/staff/{uuid}/toggle` | فعال/غیرفعال (soft delete) | | `PATCH` | `/api/v1/staff/{uuid}/toggle` | فعال/غیرفعال (soft delete) |
#### Frontend #### Frontend
- `StaffPage.tsx` — DataTable + Modal ایجاد/ویرایش - `StaffPage.tsx` — DataTable + Modal ایجاد/ویرایش
- Sidebar: زیر «تنظیمات» - Sidebar: زیر «تنظیمات»
#### فایل‌های جدید #### فایل‌های جدید
- `src/Staff/Entity/ClinicStaff.php` - `src/Staff/Entity/ClinicStaff.php`
- `src/Staff/Controller/StaffController.php` - `src/Staff/Controller/StaffController.php`
- `src/Staff/Repository/ClinicStaffRepository.php` - `src/Staff/Repository/ClinicStaffRepository.php`
@@ -91,12 +97,13 @@ clinic_staff:
### اپیک ۲ — پنل اشتراکی (Subscription Tiers) ### اپیک ۲ — پنل اشتراکی (Subscription Tiers)
#### توضیح #### توضیح
سه سطح پنل با قابلیت‌های متفاوت. ادمین برای هر پنل دوره‌های زمانی با قیمت مشخص تعریف می‌کند. کلینیک/دکتر دوره + پنل انتخاب کرده و پرداخت می‌کند. سه سطح پنل با قابلیت‌های متفاوت. ادمین برای هر پنل دوره‌های زمانی با قیمت مشخص تعریف می‌کند. کلینیک/دکتر دوره + پنل انتخاب کرده و پرداخت می‌کند.
#### سطوح پنل #### سطوح پنل
| Level | Name | منشی | قابلیت‌های اضافه | | Level | Name | منشی | قابلیت‌های اضافه |
|-------|------|------|----------------| | ----- | ------------ | ------ | ----------------------------- |
| 0 | Free | ۱ منشی | نوبت‌دهی — رایگان، بدون انقضا | | 0 | Free | ۱ منشی | نوبت‌دهی — رایگان، بدون انقضا |
| 1 | Basic | ۲ منشی | پرونده بیمار + سرویس‌ها | | 1 | Basic | ۲ منشی | پرونده بیمار + سرویس‌ها |
| 2 | Professional | ۵ منشی | پرونده بیمار + سرویس‌ها | | 2 | Professional | ۵ منشی | پرونده بیمار + سرویس‌ها |
@@ -104,7 +111,7 @@ clinic_staff:
#### دوره‌های اشتراک (مثال — ادمین تنظیم می‌کند) #### دوره‌های اشتراک (مثال — ادمین تنظیم می‌کند)
| نوع | مدت | قیمت | | نوع | مدت | قیمت |
|-----|-----|------| | ------- | ------ | ------------------------------- |
| تریال | ۱ ماه | ۰ ریال — فقط برای Basic، یک‌بار | | تریال | ۱ ماه | ۰ ریال — فقط برای Basic، یک‌بار |
| ۱ ماهه | ۱ ماه | ادمین تعیین می‌کند | | ۱ ماهه | ۱ ماه | ادمین تعیین می‌کند |
| ۳ ماهه | ۳ ماه | ادمین تعیین می‌کند | | ۳ ماهه | ۳ ماه | ادمین تعیین می‌کند |
@@ -122,7 +129,7 @@ clinic_staff:
#### نیازمندی‌های کارکردی #### نیازمندی‌های کارکردی
| کد | نیازمندی | | کد | نیازمندی |
|----|---------| | ---- | ---------------------------------------------------------------------------- |
| F2.1 | ادمین قیمت هر ترکیب (پنل × دوره) را تعریف می‌کند | | F2.1 | ادمین قیمت هر ترکیب (پنل × دوره) را تعریف می‌کند |
| F2.2 | ادمین می‌تواند دوره‌های جدید اضافه یا غیرفعال کند | | F2.2 | ادمین می‌تواند دوره‌های جدید اضافه یا غیرفعال کند |
| F2.3 | پرداخت از طریق gateway موجود (Mellat/SEP) — `Payment.type='subscription'` ✅ | | F2.3 | پرداخت از طریق gateway موجود (Mellat/SEP) — `Payment.type='subscription'` ✅ |
@@ -133,6 +140,7 @@ clinic_staff:
| F2.8 | گزارش فروش برای ادمین (تعداد، درآمد، بر اساس سطح و دوره) | | F2.8 | گزارش فروش برای ادمین (تعداد، درآمد، بر اساس سطح و دوره) |
#### Entities جدید #### Entities جدید
``` ```
subscription_plans: subscription_plans:
id, uuid id, uuid
@@ -168,8 +176,9 @@ clinic_subscriptions:
``` ```
#### API #### API
| Method | Path | Permission | توضیح | | Method | Path | Permission | توضیح |
|--------|------|-----------|-------| | -------- | ------------------------------------------ | ------------- | ---------------------------------------- |
| `GET` | `/api/v1/subscription/plans` | public | لیست پنل‌ها با دوره‌ها و قیمت‌ها | | `GET` | `/api/v1/subscription/plans` | public | لیست پنل‌ها با دوره‌ها و قیمت‌ها |
| `GET` | `/api/v1/subscription/my` | clinic/doctor | اشتراک فعال + `used_trial` + تاریخ انقضا | | `GET` | `/api/v1/subscription/my` | clinic/doctor | اشتراک فعال + `used_trial` + تاریخ انقضا |
| `POST` | `/api/v1/subscription/trial` | clinic/doctor | فعال‌سازی تریال (رایگان، یک‌بار) | | `POST` | `/api/v1/subscription/trial` | clinic/doctor | فعال‌سازی تریال (رایگان، یک‌بار) |
@@ -183,6 +192,7 @@ clinic_subscriptions:
| `GET` | `/api/v1/admin/subscription/report` | admin | گزارش فروش + تریال‌ها | | `GET` | `/api/v1/admin/subscription/report` | admin | گزارش فروش + تریال‌ها |
#### Frontend #### Frontend
- `SubscriptionPage.tsx`: - `SubscriptionPage.tsx`:
- بنر تریال اگر `used_trial=false` - بنر تریال اگر `used_trial=false`
- وضعیت پنل فعال + تاریخ انقضا (شمسی) + badge «تریال» - وضعیت پنل فعال + تاریخ انقضا (شمسی) + badge «تریال»
@@ -193,6 +203,7 @@ clinic_subscriptions:
- تب «گزارش»: فروش بر اساس بازه زمانی - تب «گزارش»: فروش بر اساس بازه زمانی
#### فایل‌های جدید #### فایل‌های جدید
- `src/Subscription/Entity/SubscriptionPlan.php` - `src/Subscription/Entity/SubscriptionPlan.php`
- `src/Subscription/Entity/SubscriptionPeriod.php` - `src/Subscription/Entity/SubscriptionPeriod.php`
- `src/Subscription/Entity/ClinicSubscription.php` - `src/Subscription/Entity/ClinicSubscription.php`
@@ -205,24 +216,28 @@ clinic_subscriptions:
### اپیک ۳ — منشی (Secretary) — تکمیل ### اپیک ۳ — منشی (Secretary) — تکمیل
#### توضیح #### توضیح
Entity `DoctorSecretary` و API کامل موجود است (`docs/api/secretary.md`). باید: Entity `DoctorSecretary` و API کامل موجود است (`docs/api/secretary.md`). باید:
- محدودیت تعداد بر اساس پنل اعمال شود - محدودیت تعداد بر اساس پنل اعمال شود
- UI مدیریت permissions تکمیل شود - UI مدیریت permissions تکمیل شود
#### نیازمندی‌های کارکردی #### نیازمندی‌های کارکردی
| کد | نیازمندی | | کد | نیازمندی |
|----|---------| | ---- | -------------------------------------------------------------------------------------- |
| F3.1 | بررسی سقف پنل قبل از ایجاد: Free→1، Basic→2، Pro→5 | | F3.1 | بررسی سقف پنل قبل از ایجاد: Free→1، Basic→2، Pro→5 |
| F3.2 | UI لیست منشیان + اضافه‌کردن + ویرایش + غیرفعال‌سازی | | F3.2 | UI لیست منشی ها + اضافه‌کردن + ویرایش + غیرفعال‌سازی |
| F3.3 | ویرایشگر permissions: checkbox matrix (موجود در `DoctorSecretary.DEFAULT_PERMISSIONS`) | | F3.3 | ویرایشگر permissions: checkbox matrix (موجود در `DoctorSecretary.DEFAULT_PERMISSIONS`) |
| F3.4 | منشی هرگز حذف نمی‌شود — `active: false` | | F3.4 | منشی هرگز حذف نمی‌شود — `active: false` |
#### تغییرات مورد نیاز #### تغییرات مورد نیاز
- **Backend:** `SecretaryController::create()` — بررسی سقف پنل با `SubscriptionService` - **Backend:** `SecretaryController::create()` — بررسی سقف پنل با `SubscriptionService`
- **Frontend:** Modal با checkbox matrix برای permissions در `SecretariesPage.tsx` - **Frontend:** Modal با checkbox matrix برای permissions در `SecretariesPage.tsx`
#### API موجود (نیاز به تغییر ندارند) #### API موجود (نیاز به تغییر ندارند)
`GET /api/v1/secretaries/{doctorUuid}` | `POST /api/v1/secretary` | `PATCH /api/v1/secretary/{uuid}` | `DELETE /api/v1/secretary/{uuid}` `GET /api/v1/secretaries/{doctorUuid}` | `POST /api/v1/secretary` | `PATCH /api/v1/secretary/{uuid}` | `DELETE /api/v1/secretary/{uuid}`
--- ---
@@ -230,12 +245,13 @@ Entity `DoctorSecretary` و API کامل موجود است (`docs/api/secretary.
### اپیک ۴ — سرویس‌های کلینیک (Clinic Services) ### اپیک ۴ — سرویس‌های کلینیک (Clinic Services)
#### توضیح #### توضیح
دسترسی فقط در پنل Basic+. سرویس‌ها در دو سطح: بخش (مثلاً «تزریقات») و زیربخش (مثلاً «سرم»). دسترسی فقط در پنل Basic+. سرویس‌ها در دو سطح: بخش (مثلاً «تزریقات») و زیربخش (مثلاً «سرم»).
#### نیازمندی‌های کارکردی #### نیازمندی‌های کارکردی
| کد | نیازمندی | | کد | نیازمندی |
|----|---------| | ---- | ------------------------------------------------------------------ |
| F4.1 | CRUD بخش‌ها (ServiceSection) | | F4.1 | CRUD بخش‌ها (ServiceSection) |
| F4.2 | CRUD زیربخش‌ها (ServiceItem) با انتخاب بخش + پرسنل انجام‌دهنده | | F4.2 | CRUD زیربخش‌ها (ServiceItem) با انتخاب بخش + پرسنل انجام‌دهنده |
| F4.3 | قیمت هر زیربخش قابل ویرایش | | F4.3 | قیمت هر زیربخش قابل ویرایش |
@@ -243,6 +259,7 @@ Entity `DoctorSecretary` و API کامل موجود است (`docs/api/secretary.
| F4.5 | لیست ServiceItems در ثبت سشن بیمار استفاده می‌شود (وابستگی اپیک ۶) | | F4.5 | لیست ServiceItems در ثبت سشن بیمار استفاده می‌شود (وابستگی اپیک ۶) |
#### Entities جدید #### Entities جدید
``` ```
service_sections: service_sections:
id, uuid id, uuid
@@ -263,8 +280,9 @@ service_items:
``` ```
#### API #### API
| Method | Path | توضیح | | Method | Path | توضیح |
|--------|------|-------| | -------- | ------------------------------------- | ---------------------- |
| `GET` | `/api/v1/service-sections` | لیست بخش‌ها | | `GET` | `/api/v1/service-sections` | لیست بخش‌ها |
| `POST` | `/api/v1/service-section` | ایجاد بخش (Basic+) | | `POST` | `/api/v1/service-section` | ایجاد بخش (Basic+) |
| `PATCH` | `/api/v1/service-section/{uuid}` | ویرایش | | `PATCH` | `/api/v1/service-section/{uuid}` | ویرایش |
@@ -275,9 +293,11 @@ service_items:
| `DELETE` | `/api/v1/service-item/{uuid}` | حذف | | `DELETE` | `/api/v1/service-item/{uuid}` | حذف |
#### Frontend #### Frontend
- `ServicesPage.tsx` — accordion دو سطحی: بخش‌ها ← زیربخش‌ها - `ServicesPage.tsx` — accordion دو سطحی: بخش‌ها ← زیربخش‌ها
#### فایل‌های جدید #### فایل‌های جدید
- `src/ClinicService/Entity/ServiceSection.php` - `src/ClinicService/Entity/ServiceSection.php`
- `src/ClinicService/Entity/ServiceItem.php` - `src/ClinicService/Entity/ServiceItem.php`
- `src/ClinicService/Controller/ClinicServiceController.php` - `src/ClinicService/Controller/ClinicServiceController.php`
@@ -288,7 +308,9 @@ service_items:
### اپیک ۵ — پنل پیامکی (SMS Panel) ### اپیک ۵ — پنل پیامکی (SMS Panel)
#### توضیح #### توضیح
زیرساخت SMS موجود است (`SmsLog`, `SmsTemplate`, `SmsProvider` در `src/Sms/`). باید: زیرساخت SMS موجود است (`SmsLog`, `SmsTemplate`, `SmsProvider` در `src/Sms/`). باید:
- کیف پول پیامک اختصاصی برای هر کلینیک/دکتر - کیف پول پیامک اختصاصی برای هر کلینیک/دکتر
- تنظیمات ارسال پیامک (یادآوری، بعد از ویزیت) - تنظیمات ارسال پیامک (یادآوری، بعد از ویزیت)
- گزارش لاگ برای کلینیک و ادمین - گزارش لاگ برای کلینیک و ادمین
@@ -296,7 +318,7 @@ service_items:
#### نیازمندی‌های کارکردی #### نیازمندی‌های کارکردی
| کد | نیازمندی | | کد | نیازمندی |
|----|---------| | ---- | ---------------------------------------------------------------------- |
| F5.1 | کیف پول پیامک جدا از کیف پول مالی | | F5.1 | کیف پول پیامک جدا از کیف پول مالی |
| F5.2 | شارژ از طریق gateway موجود | | F5.2 | شارژ از طریق gateway موجود |
| F5.3 | ادمین تعرفه هر پیامک را تعیین می‌کند (`sms_price_rials` در SiteConfig) | | F5.3 | ادمین تعرفه هر پیامک را تعیین می‌کند (`sms_price_rials` در SiteConfig) |
@@ -307,6 +329,7 @@ service_items:
| F5.8 | گزارش ادمین: مصرف هر کلینیک + درآمد پیامکی | | F5.8 | گزارش ادمین: مصرف هر کلینیک + درآمد پیامکی |
#### Entities جدید #### Entities جدید
``` ```
sms_wallets: sms_wallets:
id id
@@ -327,8 +350,9 @@ sms_settings:
``` ```
#### API #### API
| Method | Path | Permission | توضیح | | Method | Path | Permission | توضیح |
|--------|------|-----------|-------| | ------- | --------------------------------- | ------------- | ---------------------------------------- |
| `GET` | `/api/v1/sms/wallet/balance` | clinic/doctor | موجودی کیف پول پیامک | | `GET` | `/api/v1/sms/wallet/balance` | clinic/doctor | موجودی کیف پول پیامک |
| `POST` | `/api/v1/sms/wallet/charge` | clinic/doctor | شارژ — body: `{ gateway, amount_rials }` | | `POST` | `/api/v1/sms/wallet/charge` | clinic/doctor | شارژ — body: `{ gateway, amount_rials }` |
| `GET` | `/api/v1/sms/wallet/logs` | clinic/doctor | تاریخچه کسر/شارژ | | `GET` | `/api/v1/sms/wallet/logs` | clinic/doctor | تاریخچه کسر/شارژ |
@@ -337,10 +361,12 @@ sms_settings:
| `GET` | `/api/v1/admin/sms/wallet-report` | admin | گزارش مصرف و درآمد | | `GET` | `/api/v1/admin/sms/wallet-report` | admin | گزارش مصرف و درآمد |
#### Frontend #### Frontend
- تب «پیامک» در صفحه تنظیمات کلینیک/دکتر - تب «پیامک» در صفحه تنظیمات کلینیک/دکتر
- نمایش موجودی + دکمه شارژ + لاگ + فرم تنظیمات - نمایش موجودی + دکمه شارژ + لاگ + فرم تنظیمات
#### تأثیر بر فایل‌های موجود #### تأثیر بر فایل‌های موجود
- `docs/api/sms.md`**بخش جدید wallet/settings اضافه شود** - `docs/api/sms.md`**بخش جدید wallet/settings اضافه شود**
--- ---
@@ -348,12 +374,13 @@ sms_settings:
### اپیک ۶ — پرونده بیمار (Patient Records) ### اپیک ۶ — پرونده بیمار (Patient Records)
#### توضیح #### توضیح
دسترسی فقط در پنل Basic+. پرونده به صورت خودکار پس از تأیید نوبت ایجاد می‌شود یا دستی توسط منشی/دکتر. هر مراجعه یک سشن است که شامل ویزیت + سرویس‌ها است. دسترسی فقط در پنل Basic+. پرونده به صورت خودکار پس از تأیید نوبت ایجاد می‌شود یا دستی توسط منشی/دکتر. هر مراجعه یک سشن است که شامل ویزیت + سرویس‌ها است.
#### نیازمندی‌های کارکردی #### نیازمندی‌های کارکردی
| کد | نیازمندی | | کد | نیازمندی |
|----|---------| | ---- | ----------------------------------------------------------------------------- |
| F6.1 | ایجاد خودکار `PatientRecord` بعد از تأیید appointment (اگر وجود نداشت) | | F6.1 | ایجاد خودکار `PatientRecord` بعد از تأیید appointment (اگر وجود نداشت) |
| F6.2 | ایجاد دستی توسط منشی یا دکتر | | F6.2 | ایجاد دستی توسط منشی یا دکتر |
| F6.3 | ثبت سشن: بیمه پایه + مکمل، مبلغ ویزیت (پیش‌فرض یا دستی)، محاسبه خودکار کسورات | | F6.3 | ثبت سشن: بیمه پایه + مکمل، مبلغ ویزیت (پیش‌فرض یا دستی)، محاسبه خودکار کسورات |
@@ -362,6 +389,7 @@ sms_settings:
| F6.6 | تاریخچه کامل مراجعات یک بیمار | | F6.6 | تاریخچه کامل مراجعات یک بیمار |
#### محاسبه مبلغ نهایی سشن #### محاسبه مبلغ نهایی سشن
``` ```
visit_price_rials ← ادمین/دکتر تعیین می‌کند visit_price_rials ← ادمین/دکتر تعیین می‌کند
- base_insurance_discount% ← بیمه پایه - base_insurance_discount% ← بیمه پایه
@@ -371,6 +399,7 @@ visit_price_rials ← ادمین/دکتر تعیین می‌کند
``` ```
#### Entities جدید #### Entities جدید
``` ```
patient_records: patient_records:
id, uuid id, uuid
@@ -405,8 +434,9 @@ session_services:
``` ```
#### API #### API
| Method | Path | Permission | توضیح | | Method | Path | Permission | توضیح |
|--------|------|-----------|-------| | ------- | --------------------------------- | ---------------------- | --------------------- |
| `GET` | `/api/v1/patients` | clinic/doctor (basic+) | لیست بیماران با جستجو | | `GET` | `/api/v1/patients` | clinic/doctor (basic+) | لیست بیماران با جستجو |
| `POST` | `/api/v1/patient` | clinic/doctor (basic+) | ایجاد پرونده دستی | | `POST` | `/api/v1/patient` | clinic/doctor (basic+) | ایجاد پرونده دستی |
| `GET` | `/api/v1/patient/{uuid}` | clinic/doctor | جزئیات پرونده | | `GET` | `/api/v1/patient/{uuid}` | clinic/doctor | جزئیات پرونده |
@@ -415,11 +445,13 @@ session_services:
| `PATCH` | `/api/v1/session/{uuid}` | clinic/doctor | ویرایش مراجعه | | `PATCH` | `/api/v1/session/{uuid}` | clinic/doctor | ویرایش مراجعه |
#### Frontend #### Frontend
- `PatientsPage.tsx` — لیست بیماران + جستجو - `PatientsPage.tsx` — لیست بیماران + جستجو
- `PatientDetailPage.tsx` — پرونده + تاریخچه مراجعات - `PatientDetailPage.tsx` — پرونده + تاریخچه مراجعات
- Modal ثبت سشن جدید (بیمه + ویزیت + سرویس‌ها) - Modal ثبت سشن جدید (بیمه + ویزیت + سرویس‌ها)
#### فایل‌های جدید #### فایل‌های جدید
- `src/Patient/Entity/PatientRecord.php` - `src/Patient/Entity/PatientRecord.php`
- `src/Patient/Entity/PatientSession.php` - `src/Patient/Entity/PatientSession.php`
- `src/Patient/Entity/SessionService.php` - `src/Patient/Entity/SessionService.php`
@@ -431,22 +463,25 @@ session_services:
### اپیک ۷ — داشبورد هوشمند (Smart Dashboard) ### اپیک ۷ — داشبورد هوشمند (Smart Dashboard)
#### توضیح #### توضیح
داشبوردهای موجود (clinic/doctor/secretary در `docs/api/dashboard.md`) باید با چارت و فیلتر بازه زمانی تکمیل شوند. داشبوردهای موجود (clinic/doctor/secretary در `docs/api/dashboard.md`) باید با چارت و فیلتر بازه زمانی تکمیل شوند.
#### نیازمندی‌های کارکردی #### نیازمندی‌های کارکردی
| کد | نیازمندی | | کد | نیازمندی |
|----|---------| | ---- | ----------------------------------------------------------------------- |
| F7.1 | داشبورد ادمین: چارت درآمد + چارت نوبت‌ها + گزارش فروش پنل‌ها | | F7.1 | داشبورد ادمین: چارت درآمد + چارت نوبت‌ها + گزارش فروش پنل‌ها |
| F7.2 | داشبورد کلینیک/مطب: درآمد ماه جاری + بیماران منحصربه‌فرد + موجودی پیامک | | F7.2 | داشبورد کلینیک/مطب: درآمد ماه جاری + بیماران منحصربه‌فرد + موجودی پیامک |
| F7.3 | فیلتر بازه زمانی: این هفته / این ماه / ۳ ماه / سفارشی | | F7.3 | فیلتر بازه زمانی: این هفته / این ماه / ۳ ماه / سفارشی |
| F7.4 | فیلتر نقش: منشی فقط نوبت‌ها را می‌بیند | | F7.4 | فیلتر نقش: منشی فقط نوبت‌ها را می‌بیند |
#### API موجود (نیاز به endpoint جدید ندارد) #### API موجود (نیاز به endpoint جدید ندارد)
`GET /api/v1/admin/dashboard/stats` | `/charts` | `/recent` `GET /api/v1/admin/dashboard/stats` | `/charts` | `/recent`
`GET /api/v1/dashboard/clinic` | `/doctor` | `/secretary` `GET /api/v1/dashboard/clinic` | `/doctor` | `/secretary`
#### تغییر مورد نیاز #### تغییر مورد نیاز
- backend: اضافه کردن پارامترهای `from` و `to` به endpoint های charts - backend: اضافه کردن پارامترهای `from` و `to` به endpoint های charts
- frontend: `DashboardPage.tsx` — افزودن date range selector + نمودارها (Recharts/Chart.js) - frontend: `DashboardPage.tsx` — افزودن date range selector + نمودارها (Recharts/Chart.js)
@@ -455,7 +490,7 @@ session_services:
## اولویت‌بندی پیاده‌سازی ## اولویت‌بندی پیاده‌سازی
| اولویت | اپیک | وابستگی | | اولویت | اپیک | وابستگی |
|--------|------|---------| | ------ | ------------------------ | ----------------------------- |
| ۱ | اپیک ۱ — Staff | پیش‌نیاز اپیک ۴ و ۶ | | ۱ | اپیک ۱ — Staff | پیش‌نیاز اپیک ۴ و ۶ |
| ۲ | اپیک ۲ — Subscription | پیش‌نیاز gate در اپیک ۳، ۴، ۶ | | ۲ | اپیک ۲ — Subscription | پیش‌نیاز gate در اپیک ۳، ۴، ۶ |
| ۳ | اپیک ۳ — Secretary تکمیل | وابسته به اپیک ۲ | | ۳ | اپیک ۳ — Secretary تکمیل | وابسته به اپیک ۲ |
@@ -489,7 +524,7 @@ sms_wallets sms_settings
## زیرساخت موجود قابل استفاده ## زیرساخت موجود قابل استفاده
| زیرساخت | مسیر | استفاده در فاز ۲ | | زیرساخت | مسیر | استفاده در فاز ۲ |
|---------|------|-----------------| | ----------------------------- | ---------------------------------- | ------------------------------------- |
| Payment gateway | `src/Payment/` | پرداخت subscription + شارژ SMS wallet | | Payment gateway | `src/Payment/` | پرداخت subscription + شارژ SMS wallet |
| `Payment.type='subscription'` | `Payment::TYPE_SUBSCRIPTION` | پرداخت پنل اشتراکی — موجود ✅ | | `Payment.type='subscription'` | `Payment::TYPE_SUBSCRIPTION` | پرداخت پنل اشتراکی — موجود ✅ |
| SiteConfig (key-value) | `src/Config/Entity/SiteConfig.php` | `sms_price_rials`, `trial_enabled` | | SiteConfig (key-value) | `src/Config/Entity/SiteConfig.php` | `sms_price_rials`, `trial_enabled` |
+58 -19
View File
@@ -7,7 +7,7 @@
هر رابطه منشی-پزشک دارای یک **scope** است که از تداخل بین محیط‌های مختلف جلوگیری می‌کند: هر رابطه منشی-پزشک دارای یک **scope** است که از تداخل بین محیط‌های مختلف جلوگیری می‌کند:
| Scope | `owner_type` | تعریف‌کننده | دسترسی | | Scope | `owner_type` | تعریف‌کننده | دسترسی |
|-------|-------------|-------------|---------| | -------- | ------------ | ----------- | ------------------------------- |
| مطب شخصی | `doctor` | خود پزشک | فقط نوبت‌ها و داده‌های مطب شخصی | | مطب شخصی | `doctor` | خود پزشک | فقط نوبت‌ها و داده‌های مطب شخصی |
| کلینیک | `clinic` | مدیر کلینیک | فقط نوبت‌ها و داده‌های کلینیک | | کلینیک | `clinic` | مدیر کلینیک | فقط نوبت‌ها و داده‌های کلینیک |
@@ -26,6 +26,7 @@ Create a secretary for a doctor.
**Permission:** `ROLE_DOCTOR` (must own the doctor — creates `owner_type='doctor'`) | `ROLE_CLINIC` (must have the doctor in its clinic — creates `owner_type='clinic'`) | `ROLE_ADMIN` **Permission:** `ROLE_DOCTOR` (must own the doctor — creates `owner_type='doctor'`) | `ROLE_CLINIC` (must have the doctor in its clinic — creates `owner_type='clinic'`) | `ROLE_ADMIN`
### Request Body (`application/json`) ### Request Body (`application/json`)
```json ```json
{ {
"doctor_uuid": "550e8400-...", "doctor_uuid": "550e8400-...",
@@ -34,23 +35,39 @@ Create a secretary for a doctor.
"permissions": { "permissions": {
"version": 1, "version": 1,
"resources": { "resources": {
"appointments": { "view": true, "create": true, "cancel": false, "update_status": true }, "appointments": {
"addresses": { "view": true, "create": false, "update": false, "delete": false }, "view": true,
"create": true,
"cancel": false,
"update_status": true
},
"addresses": {
"view": true,
"create": false,
"update": false,
"delete": false
},
"clinic_info": { "view": true, "update": false }, "clinic_info": { "view": true, "update": false },
"insurances": { "view": true, "create": false, "update": false, "delete": false } "insurances": {
"view": true,
"create": false,
"update": false,
"delete": false
}
} }
} }
} }
``` ```
| Field | Type | Required | Description | | Field | Type | Required | Description |
|-------|------|----------|-------------| | --------------- | ------------- | -------- | -------------------------------------------- |
| `doctor_uuid` | string (UUID) | ✅ | Doctor to assign secretary to | | `doctor_uuid` | string (UUID) | ✅ | Doctor to assign secretary to |
| `mobile_number` | string | ✅ | Secretary's login mobile | | `mobile_number` | string | ✅ | Secretary's login mobile |
| `password` | string | ❌ | Initial password (auto-generated if omitted) | | `password` | string | ❌ | Initial password (auto-generated if omitted) |
| `permissions` | object | ❌ | Permission set (see structure below) | | `permissions` | object | ❌ | Permission set (see structure below) |
**Permissions Structure:** **Permissions Structure:**
```json ```json
{ {
"version": 1, "version": 1,
@@ -82,6 +99,7 @@ Create a secretary for a doctor.
``` ```
### Response `201` ### Response `201`
```json ```json
{ {
"success": true, "success": true,
@@ -107,8 +125,9 @@ Create a secretary for a doctor.
| `clinic` | منشی توسط مدیر کلینیک تعریف شده — فقط کلینیک | | `clinic` | منشی توسط مدیر کلینیک تعریف شده — فقط کلینیک |
### Errors ### Errors
| Code | HTTP | Description | | Code | HTTP | Description |
|------|------|-------------| | ------------------- | ---- | ------------------------------------------- |
| `ERR_AUTH_001` | 401 | Missing token | | `ERR_AUTH_001` | 401 | Missing token |
| `ERR_AUTH_006` | 403 | Not the doctor owner / clinic owner / admin | | `ERR_AUTH_006` | 403 | Not the doctor owner / clinic owner / admin |
| `ERR_NOT_FOUND_001` | 404 | Doctor not found | | `ERR_NOT_FOUND_001` | 404 | Doctor not found |
@@ -124,11 +143,13 @@ Get secretary detail.
**Permission:** `AUTH` — must be the linked doctor or `ROLE_ADMIN` **Permission:** `AUTH` — must be the linked doctor or `ROLE_ADMIN`
### Path Parameters ### Path Parameters
| Param | Type | Description | | Param | Type | Description |
|-------|------|-------------| | ------ | ------------- | -------------- |
| `uuid` | string (UUID) | Secretary UUID | | `uuid` | string (UUID) | Secretary UUID |
### Response `200` ### Response `200`
```json ```json
{ {
"success": true, "success": true,
@@ -144,8 +165,9 @@ Get secretary detail.
``` ```
### Errors ### Errors
| Code | HTTP | Description | | Code | HTTP | Description |
|------|------|-------------| | ------------------- | ---- | ------------------- |
| `ERR_AUTH_001` | 401 | Missing token | | `ERR_AUTH_001` | 401 | Missing token |
| `ERR_FORBIDDEN_001` | 403 | Not authorized | | `ERR_FORBIDDEN_001` | 403 | Not authorized |
| `ERR_NOT_FOUND_001` | 404 | Secretary not found | | `ERR_NOT_FOUND_001` | 404 | Secretary not found |
@@ -159,29 +181,37 @@ Update secretary active status or permissions.
**Permission:** `ROLE_DOCTOR` — must be the linked doctor **Permission:** `ROLE_DOCTOR` — must be the linked doctor
### Request Body (`application/json`) ### Request Body (`application/json`)
```json ```json
{ {
"active": false, "active": false,
"permissions": { "permissions": {
"version": 1, "version": 1,
"resources": { "resources": {
"appointments": { "view": true, "create": false, "cancel": false, "update_status": false } "appointments": {
"view": true,
"create": false,
"cancel": false,
"update_status": false
}
} }
} }
} }
``` ```
| Field | Type | Required | Description | | Field | Type | Required | Description |
|-------|------|----------|-------------| | ------------- | ------- | -------- | ------------------------ |
| `active` | boolean | ❌ | Enable/disable secretary | | `active` | boolean | ❌ | Enable/disable secretary |
| `permissions` | object | ❌ | New permissions object | | `permissions` | object | ❌ | New permissions object |
### Response `200` ### Response `200`
Updated secretary object. Updated secretary object.
### Errors ### Errors
| Code | HTTP | Description | | Code | HTTP | Description |
|------|------|-------------| | ------------------- | ---- | --------------------- |
| `ERR_AUTH_001` | 401 | Missing token | | `ERR_AUTH_001` | 401 | Missing token |
| `ERR_FORBIDDEN_001` | 403 | Not the linked doctor | | `ERR_FORBIDDEN_001` | 403 | Not the linked doctor |
| `ERR_NOT_FOUND_001` | 404 | Secretary not found | | `ERR_NOT_FOUND_001` | 404 | Secretary not found |
@@ -195,13 +225,15 @@ Delete a secretary.
**Permission:** `ROLE_DOCTOR` — must be the linked doctor **Permission:** `ROLE_DOCTOR` — must be the linked doctor
### Response `200` ### Response `200`
```json ```json
{ "success": true, "data": { "message": "منشی حذف شد" } } { "success": true, "data": { "message": "منشی حذف شد" } }
``` ```
### Errors ### Errors
| Code | HTTP | Description | | Code | HTTP | Description |
|------|------|-------------| | ------------------- | ---- | --------------------- |
| `ERR_AUTH_001` | 401 | Missing token | | `ERR_AUTH_001` | 401 | Missing token |
| `ERR_FORBIDDEN_001` | 403 | Not the linked doctor | | `ERR_FORBIDDEN_001` | 403 | Not the linked doctor |
| `ERR_NOT_FOUND_001` | 404 | Secretary not found | | `ERR_NOT_FOUND_001` | 404 | Secretary not found |
@@ -215,11 +247,13 @@ Get all secretaries for a specific doctor.
**Permission:** `ROLE_DOCTOR` (must own doctor) | `ROLE_CLINIC` (must have doctor in clinic) | `ROLE_ADMIN` **Permission:** `ROLE_DOCTOR` (must own doctor) | `ROLE_CLINIC` (must have doctor in clinic) | `ROLE_ADMIN`
### Path Parameters ### Path Parameters
| Param | Type | Description | | Param | Type | Description |
|-------|------|-------------| | ------------ | ------------- | ----------- |
| `doctorUuid` | string (UUID) | Doctor UUID | | `doctorUuid` | string (UUID) | Doctor UUID |
### Response `200` ### Response `200`
```json ```json
{ {
"success": true, "success": true,
@@ -239,8 +273,9 @@ Get all secretaries for a specific doctor.
``` ```
### Errors ### Errors
| Code | HTTP | Description | | Code | HTTP | Description |
|------|------|-------------| | ------------------- | ---- | ---------------- |
| `ERR_AUTH_001` | 401 | Missing token | | `ERR_AUTH_001` | 401 | Missing token |
| `ERR_FORBIDDEN_001` | 403 | Not authorized | | `ERR_FORBIDDEN_001` | 403 | Not authorized |
| `ERR_NOT_FOUND_001` | 404 | Doctor not found | | `ERR_NOT_FOUND_001` | 404 | Doctor not found |
@@ -254,11 +289,13 @@ Get all secretaries across **all doctors** of a clinic.
**Permission:** `ROLE_CLINIC` (must own clinic) | `ROLE_ADMIN` **Permission:** `ROLE_CLINIC` (must own clinic) | `ROLE_ADMIN`
### Path Parameters ### Path Parameters
| Param | Type | Description | | Param | Type | Description |
|-------|------|-------------| | ------------ | ------------- | ----------- |
| `clinicUuid` | string (UUID) | Clinic UUID | | `clinicUuid` | string (UUID) | Clinic UUID |
### Response `200` ### Response `200`
```json ```json
{ {
"success": true, "success": true,
@@ -278,13 +315,15 @@ Get all secretaries across **all doctors** of a clinic.
``` ```
### Notes ### Notes
- این endpoint فقط منشیانی را برمی‌گرداند که با `owner_type='clinic'` تعریف شده‌اند
- منشیانی که خود دکتر (با `owner_type='doctor'`) تعریف کرده از این لیست مخفی هستند - این endpoint فقط منشی های را برمی‌گرداند که با `owner_type='clinic'` تعریف شده‌اند
- منشی های که خود دکتر (با `owner_type='doctor'`) تعریف کرده از این لیست مخفی هستند
- یک منشی می‌تواند به چند دکتر در همان کلینیک متصل باشد — در لیست چندبار ظاهر می‌شود (یک ردیف به ازای هر دکتر) - یک منشی می‌تواند به چند دکتر در همان کلینیک متصل باشد — در لیست چندبار ظاهر می‌شود (یک ردیف به ازای هر دکتر)
### Errors ### Errors
| Code | HTTP | Description | | Code | HTTP | Description |
|------|------|-------------| | ------------------- | ---- | ---------------- |
| `ERR_AUTH_001` | 401 | Missing token | | `ERR_AUTH_001` | 401 | Missing token |
| `ERR_FORBIDDEN_001` | 403 | Not clinic owner | | `ERR_FORBIDDEN_001` | 403 | Not clinic owner |
| `ERR_NOT_FOUND_001` | 404 | Clinic not found | | `ERR_NOT_FOUND_001` | 404 | Clinic not found |
@@ -296,7 +335,7 @@ Get all secretaries across **all doctors** of a clinic.
تعداد منشی‌های مجاز بر اساس پنل فعال doctor تعیین می‌شود: تعداد منشی‌های مجاز بر اساس پنل فعال doctor تعیین می‌شود:
| پنل | حداکثر منشی | | پنل | حداکثر منشی |
|-----|-------------| | ------------------ | ----------- |
| Free (بدون اشتراک) | ۱ | | Free (بدون اشتراک) | ۱ |
| Basic | ۳ | | Basic | ۳ |
| Professional | ۱۰ | | Professional | ۱۰ |
@@ -1,7 +1,9 @@
# تسک ۱۲: تکمیل منشی — محدودیت پنل + UI Permissions # تسک ۱۲: تکمیل منشی — محدودیت پنل + UI Permissions
## توضیح ## توضیح
Entity `DoctorSecretary` و API کامل موجود است (`src/Secretary/`). این تسک فقط دو چیز اضافه می‌کند: Entity `DoctorSecretary` و API کامل موجود است (`src/Secretary/`). این تسک فقط دو چیز اضافه می‌کند:
1. Backend: بررسی سقف تعداد منشی بر اساس پنل اشتراکی 1. Backend: بررسی سقف تعداد منشی بر اساس پنل اشتراکی
2. Frontend: Modal با checkbox matrix برای ویرایش permissions 2. Frontend: Modal با checkbox matrix برای ویرایش permissions
@@ -10,29 +12,32 @@ Entity `DoctorSecretary` و API کامل موجود است (`src/Secretary/`).
## فایل‌های موجود که تغییر می‌کنند ## فایل‌های موجود که تغییر می‌کنند
| فایل | تغییر | | فایل | تغییر |
|------|-------| | -------------------------------------------------- | ------------------------------------------------------------------ |
| `src/Secretary/Controller/SecretaryController.php` | inject `SubscriptionService`، اضافه کردن limit check در `create()` | | `src/Secretary/Controller/SecretaryController.php` | inject `SubscriptionService`، اضافه کردن limit check در `create()` |
| `assets/admin/pages/SecretariesPage.tsx` | Modal ویرایش با checkbox matrix برای permissions | | `assets/admin/pages/SecretariesPage.tsx` | Modal ویرایش با checkbox matrix برای permissions |
## API موجود (بدون تغییر) ## API موجود (بدون تغییر)
| متد | مسیر | توضیح | | متد | مسیر | توضیح |
|-----|------|-------| | ------ | ---------------------------------- | ----------------------------------------------- |
| GET | `/api/v1/secretaries/{doctorUuid}` | لیست منشیان | | GET | `/api/v1/secretaries/{doctorUuid}` | لیست منشی ها |
| POST | `/api/v1/secretary` | ایجاد منشی ← **اینجا limit check اضافه می‌شود** | | POST | `/api/v1/secretary` | ایجاد منشی ← **اینجا limit check اضافه می‌شود** |
| PATCH | `/api/v1/secretary/{uuid}` | ویرایش ← **permissions هم قابل ویرایش می‌شود** | | PATCH | `/api/v1/secretary/{uuid}` | ویرایش ← **permissions هم قابل ویرایش می‌شود** |
| DELETE | `/api/v1/secretary/{uuid}` | غیرفعال‌سازی (soft delete) | | DELETE | `/api/v1/secretary/{uuid}` | غیرفعال‌سازی (soft delete) |
## پیش‌نیازها ## پیش‌نیازها
- تسک ۱۴ (Secretary — موجود در کد) - تسک ۱۴ (Secretary — موجود در کد)
- **تسک ۱۱** (Subscription — `SubscriptionService` باید موجود باشد) - **تسک ۱۱** (Subscription — `SubscriptionService` باید موجود باشد)
## زمان تخمینی ## زمان تخمینی
۴ تا ۵ ساعت ۴ تا ۵ ساعت
## تغییر Backend — SecretaryController::create() ## تغییر Backend — SecretaryController::create()
### وضعیت فعلی (قبل): ### وضعیت فعلی (قبل):
```php ```php
public function create(Request $request): JsonResponse public function create(Request $request): JsonResponse
{ {
@@ -44,6 +49,7 @@ public function create(Request $request): JsonResponse
``` ```
### وضعیت جدید (بعد): ### وضعیت جدید (بعد):
```php ```php
public function create(Request $request): JsonResponse public function create(Request $request): JsonResponse
{ {
@@ -68,6 +74,7 @@ public function create(Request $request): JsonResponse
``` ```
### خطای جدید که باید در ErrorCodes.php اضافه شود: ### خطای جدید که باید در ErrorCodes.php اضافه شود:
```php ```php
// src/Shared/Constant/ErrorCodes.php // src/Shared/Constant/ErrorCodes.php
const ERR_SECRETARY_LIMIT_REACHED = 'ERR_SECRETARY_LIMIT_REACHED'; const ERR_SECRETARY_LIMIT_REACHED = 'ERR_SECRETARY_LIMIT_REACHED';
@@ -77,6 +84,7 @@ const ERR_SECRETARY_LIMIT_REACHED = 'ERR_SECRETARY_LIMIT_REACHED';
## تغییر Frontend — SecretariesPage.tsx ## تغییر Frontend — SecretariesPage.tsx
### ساختار DEFAULT_PERMISSIONS (از DoctorSecretary::DEFAULT_PERMISSIONS): ### ساختار DEFAULT_PERMISSIONS (از DoctorSecretary::DEFAULT_PERMISSIONS):
```php ```php
[ [
'version' => 1, 'version' => 1,
@@ -90,6 +98,7 @@ const ERR_SECRETARY_LIMIT_REACHED = 'ERR_SECRETARY_LIMIT_REACHED';
``` ```
### نمونه UI Permissions checkbox matrix در Modal: ### نمونه UI Permissions checkbox matrix در Modal:
``` ```
┌─────────────────────────────────────────────────────┐ ┌─────────────────────────────────────────────────────┐
│ دسترسی‌های منشی │ │ دسترسی‌های منشی │
@@ -104,13 +113,24 @@ const ERR_SECRETARY_LIMIT_REACHED = 'ERR_SECRETARY_LIMIT_REACHED';
``` ```
### PATCH /api/v1/secretary/{uuid} با permissions: ### PATCH /api/v1/secretary/{uuid} با permissions:
```json ```json
{ {
"permissions": { "permissions": {
"version": 1, "version": 1,
"resources": { "resources": {
"appointments": { "view": true, "create": true, "cancel": true, "update_status": true }, "appointments": {
"addresses": { "view": true, "create": false, "update": false, "delete": false } "view": true,
"create": true,
"cancel": true,
"update_status": true
},
"addresses": {
"view": true,
"create": false,
"update": false,
"delete": false
}
} }
} }
} }
@@ -119,16 +139,19 @@ const ERR_SECRETARY_LIMIT_REACHED = 'ERR_SECRETARY_LIMIT_REACHED';
## کدهای خطای جدید ## کدهای خطای جدید
| کد | پیام فارسی | | کد | پیام فارسی |
|----|-----------| | ----------------------------- | ---------------------------------------- |
| `ERR_SECRETARY_LIMIT_REACHED` | سقف تعداد منشی پنل اشتراکی شما رسیده است | | `ERR_SECRETARY_LIMIT_REACHED` | سقف تعداد منشی پنل اشتراکی شما رسیده است |
## Response خطا (403) ## Response خطا (403)
```json ```json
{ {
"success": false, "success": false,
"errors": [{ "errors": [
{
"code": "ERR_SECRETARY_LIMIT_REACHED", "code": "ERR_SECRETARY_LIMIT_REACHED",
"message": "سقف تعداد منشی پنل اشتراکی شما رسیده است. برای افزودن منشی بیشتر پنل را ارتقاء دهید." "message": "سقف تعداد منشی پنل اشتراکی شما رسیده است. برای افزودن منشی بیشتر پنل را ارتقاء دهید."
}] }
]
} }
``` ```
@@ -64,7 +64,7 @@ Professional → 5 → 403
## نکته: منشی غیرفعال از سقف کم نمی‌شود ## نکته: منشی غیرفعال از سقف کم نمی‌شود
``` ```
countActive() فقط منشیان active=true را می‌شمارد countActive() فقط منشی ها active=true را می‌شمارد
یعنی: اگر 2 منشی داری و یکی را غیرفعال کنی → می‌توانی دوباره 1 منشی جدید اضافه کنی یعنی: اگر 2 منشی داری و یکی را غیرفعال کنی → می‌توانی دوباره 1 منشی جدید اضافه کنی
(مادامی که در پنل Basic هستی و سقف 2 داری) (مادامی که در پنل Basic هستی و سقف 2 داری)
``` ```
+439
View File
@@ -0,0 +1,439 @@
<?php
declare(strict_types=1);
require __DIR__ . '/vendor/autoload.php';
(new Symfony\Component\Dotenv\Dotenv())->bootEnv(__DIR__ . '/.env');
$kernel = new App\Kernel($_SERVER['APP_ENV'], (bool) $_SERVER['APP_DEBUG']);
$kernel->boot();
$conn = $kernel->getContainer()->get('doctrine')->getManager()->getConnection();
$now = time();
$passwordHash = password_hash('Test@1234', PASSWORD_BCRYPT);
$mobileCounter = 9100100000; // شروع از 09100100000
// ── Helpers ──────────────────────────────────────────────────────────────────
function newUuid(): string
{
return sprintf(
'%04x%04x-%04x-%04x-%04x-%04x%04x%04x',
mt_rand(0, 0xffff), mt_rand(0, 0xffff),
mt_rand(0, 0xffff),
mt_rand(0, 0x0fff) | 0x4000,
mt_rand(0, 0x3fff) | 0x8000,
mt_rand(0, 0xffff), mt_rand(0, 0xffff), mt_rand(0, 0xffff)
);
}
function nextMobile(): string
{
global $mobileCounter;
return '0' . $mobileCounter++;
}
function makeSchedule(int $addressId, bool $fullWeek = true): string
{
$days = [];
for ($i = 0; $i < 7; $i++) {
if ($i === 6) {
$days[] = ['sessions' => []];
} elseif ($i === 5) {
$days[] = ['sessions' => [
['active' => true, 'location_id' => $addressId, 'start_time' => '09:00',
'end_time' => '13:00', 'duration_per_patient' => 20,
'has_rest' => false, 'rest_interval' => 60, 'time_to_rest' => 10, 'patient_limit' => null],
]];
} elseif ($fullWeek) {
$days[] = ['sessions' => [
['active' => true, 'location_id' => $addressId, 'start_time' => '09:00',
'end_time' => '13:00', 'duration_per_patient' => 20,
'has_rest' => true, 'rest_interval' => 60, 'time_to_rest' => 10, 'patient_limit' => null],
['active' => true, 'location_id' => $addressId, 'start_time' => '14:00',
'end_time' => '18:00', 'duration_per_patient' => 20,
'has_rest' => false, 'rest_interval' => 60, 'time_to_rest' => 10, 'patient_limit' => null],
]];
} else {
$days[] = ['sessions' => [
['active' => true, 'location_id' => $addressId, 'start_time' => '09:00',
'end_time' => '13:00', 'duration_per_patient' => 20,
'has_rest' => false, 'rest_interval' => 60, 'time_to_rest' => 10, 'patient_limit' => null],
]];
}
}
return json_encode($days, JSON_UNESCAPED_UNICODE);
}
// نگاشت شهر → استان
$cityProvince = [
101 => 1, 102 => 2, 103 => 3, 104 => 4, 105 => 5,
106 => 6, 107 => 7, 108 => 8, 109 => 9, 110 => 10,
111 => 11, 112 => 12, 113 => 13, 114 => 14, 115 => 15,
116 => 16, 117 => 17, 118 => 18, 119 => 19, 120 => 20,
121 => 21, 122 => 22, 123 => 23, 125 => 24, 126 => 25,
127 => 26, 128 => 27, 129 => 28, 130 => 29, 131 => 30, 132 => 100,
];
$cityNames = [
101 => 'تبریز', 102 => 'ارومیه', 103 => 'اردبیل', 104 => 'اصفهان',
105 => 'کرج', 108 => 'تهران', 111 => 'مشهد', 113 => 'اهواز',
117 => 'شیراز', 121 => 'کرمان', 122 => 'کرمانشاه', 126 => 'رشت',
128 => 'ساری', 131 => 'همدان', 132 => 'یزد',
];
// توزیع تخصص‌ها برای ۱۰۰ دکتر
$specialtyGroups = [
['specs' => [200], 'services' => [], 'degree' => 'general', 'gender_mix' => 'both', 'count' => 15],
['specs' => [2, 3], 'services' => [101,102,103,110], 'degree' => 'specialist', 'gender_mix' => 'male', 'count' => 10],
['specs' => [4], 'services' => [201,202,203,210], 'degree' => 'specialist', 'gender_mix' => 'both', 'count' => 6],
['specs' => [10], 'services' => [801,802,803,805], 'degree' => 'specialist', 'gender_mix' => 'both', 'count' => 6],
['specs' => [21], 'services' => [2101,2102,2103,2113],'degree' => 'specialist','gender_mix' => 'male', 'count' => 8],
['specs' => [61], 'services' => [], 'degree' => 'specialist', 'gender_mix' => 'female', 'count' => 8],
['specs' => [41], 'services' => [], 'degree' => 'specialist', 'gender_mix' => 'both', 'count' => 8],
['specs' => [111], 'services' => [], 'degree' => 'specialist', 'gender_mix' => 'male', 'count' => 7],
['specs' => [81, 82], 'services' => [], 'degree' => 'specialist', 'gender_mix' => 'both', 'count' => 6],
['specs' => [91], 'services' => [], 'degree' => 'specialist', 'gender_mix' => 'both', 'count' => 6],
['specs' => [101], 'services' => [], 'degree' => 'specialist', 'gender_mix' => 'both', 'count' => 5],
['specs' => [71], 'services' => [], 'degree' => 'specialist', 'gender_mix' => 'both', 'count' => 5],
['specs' => [121], 'services' => [], 'degree' => 'specialist', 'gender_mix' => 'male', 'count' => 4],
['specs' => [181], 'services' => [], 'degree' => 'dentist', 'gender_mix' => 'both', 'count' => 6],
];
$maleFirstNames = ['علی', 'محمد', 'حسین', 'رضا', 'امیر', 'مهدی', 'سعید', 'احمد', 'حسن', 'جواد',
'کریم', 'ابراهیم', 'اکبر', 'ناصر', 'پیمان', 'بهروز', 'شاهین', 'کاوه', 'آرش', 'سهراب',
'فرزاد', 'بهنام', 'وحید', 'مجید', 'عباس', 'صادق', 'داود', 'یوسف', 'منصور', 'اسماعیل'];
$femaleFirstNames = ['فاطمه', 'زهرا', 'مریم', 'سارا', 'نگین', 'الهه', 'نیلوفر', 'شیما', 'لیلا', 'نازنین',
'مینا', 'آزاده', 'ندا', 'رویا', 'پریسا', 'گلناز', 'سپیده', 'بهاره', 'شادی', 'فریده',
'سمانه', 'مهتاب', 'شهرزاد', 'گیتا', 'آناهیتا', 'سیمین', 'منیژه', 'ناهید', 'پگاه', 'روشنک'];
$lastNames = ['رضایی', 'محمدی', 'حسینی', 'علوی', 'موسوی', 'احمدی', 'کریمی', 'صادقی', 'جعفری', 'مرادی',
'نجفی', 'تبریزی', 'اصفهانی', 'مشهدی', 'شیرازی', 'کرمانی', 'رشیدی', 'نظری', 'ابراهیمی',
'قاسمی', 'یوسفی', 'منصوری', 'زارعی', 'ملکی', 'بهرامی', 'سلیمانی', 'نوری', 'پارسا', 'امینی', 'زاهدی'];
$degreeLabels = ['general' => 'پزشک عمومی', 'specialist' => 'متخصص', 'dentist' => 'دندانپزشک', 'subspecialist' => 'فوق‌تخصص'];
$specNames = [
2 => 'داخلی', 3 => 'قلب و عروق', 4 => 'گوارش', 10 => 'نورولوژی',
21 => 'جراحی عمومی', 41 => 'اطفال', 61 => 'زنان و زایمان', 71 => 'روانپزشکی',
81 => 'پوست', 82 => 'زیبایی پوست', 91 => 'چشم‌پزشکی', 101 => 'گوش، حلق و بینی',
111 => 'ارتوپدی', 121 => 'اورولوژی', 181 => 'دندانپزشکی', 200 => 'پزشکی عمومی',
];
$supplementaryInsurances = [7, 8, 9, 10, 11, 12, 13, 14, 15];
// ─────────────────────────────────────────────────────────────────────────────
// بخش A — پاک‌سازی
// ─────────────────────────────────────────────────────────────────────────────
echo "🧹 پاک‌سازی دیتابیس...\n";
$conn->executeStatement('SET FOREIGN_KEY_CHECKS=0');
$conn->executeStatement('DELETE FROM weekly_schedules');
$conn->executeStatement('DELETE FROM doctor_addresses');
$conn->executeStatement('DELETE FROM doctor_specialties');
$conn->executeStatement('DELETE FROM doctor_expertise');
$conn->executeStatement('DELETE FROM doctor_insurances');
$conn->executeStatement('DELETE FROM doctor_cities');
$conn->executeStatement('DELETE FROM doctor_provinces');
$conn->executeStatement('DELETE FROM doctor_secretaries');
$conn->executeStatement('DELETE FROM clinic_doctors');
$conn->executeStatement('DELETE FROM clinic_specialties');
$conn->executeStatement('DELETE FROM clinic_services');
$conn->executeStatement('DELETE FROM clinic_insurances');
$conn->executeStatement('DELETE FROM patient_records');
$conn->executeStatement('DELETE FROM appointments');
$conn->executeStatement('DELETE FROM payments');
$conn->executeStatement('DELETE FROM sms_logs');
$conn->executeStatement('DELETE FROM rates');
$conn->executeStatement('DELETE FROM doctors');
$conn->executeStatement('DELETE FROM clinics');
$conn->executeStatement("DELETE FROM users WHERE JSON_CONTAINS(roles, '\"ROLE_DOCTOR\"') OR JSON_CONTAINS(roles, '\"ROLE_CLINIC\"') OR JSON_CONTAINS(roles, '\"ROLE_PATIENT\"') OR JSON_CONTAINS(roles, '\"ROLE_SECRETARY\"')");
$conn->executeStatement('SET FOREIGN_KEY_CHECKS=1');
echo "✅ پاک‌سازی کامل شد\n\n";
// ─────────────────────────────────────────────────────────────────────────────
// بخش B — کلینیک نمونه کامل (تبریز)
// ─────────────────────────────────────────────────────────────────────────────
echo "🏥 ایجاد کلینیک نمونه...\n";
$clinicMobile = nextMobile();
$clinicUserUuid = newUuid();
$conn->executeStatement(
'INSERT INTO users (uuid, mobile_number, password_hash, real_name, roles, status, created_at, updated_at) VALUES (?,?,?,?,?,1,?,?)',
[$clinicUserUuid, $clinicMobile, $passwordHash, 'کلینیک تخصصی امید', '["ROLE_USER","ROLE_CLINIC"]', $now, $now]
);
$clinicUserId = (int) $conn->lastInsertId();
$clinicUuid = newUuid();
$conn->executeStatement(
'INSERT INTO clinics (uuid, user_id, name, info, address, telephone, is_24_7, working_days, latitude, longitude, city_id, province_id, representation_id, is_active, created_at, updated_at)
VALUES (?,?,?,?,?,?,0,?,?,?,?,?,?,1,?,?)',
[
$clinicUuid, $clinicUserId,
'کلینیک تخصصی امید تبریز',
'کلینیک تخصصی امید تبریز با بیش از ۲۰ سال سابقه در ارائه خدمات درمانی تخصصی. مجهز به پیشرفته‌ترین تجهیزات پزشکی و دارای تیمی از متخصصان برجسته در رشته‌های مختلف. پذیرش بیماران با بیمه‌های تامین اجتماعی، خدمات درمانی و بیمه‌های تکمیلی.',
'تبریز، خیابان ارتش، نبش کوچه سوم، ساختمان امید، طبقه اول',
'04133445566',
'شنبه تا چهارشنبه ۸ تا ۲۰، پنجشنبه ۸ تا ۱۴',
37.0833, 35.7167,
101, 1, 101, // city, province, representation
$now, $now,
]
);
$clinicId = (int) $conn->lastInsertId();
// تخصص‌های کلینیک
foreach ([1, 2, 3, 4, 10, 40, 60, 80, 90, 100, 110] as $specId) {
$conn->executeStatement('INSERT IGNORE INTO clinic_specialties (clinic_id, specialty_id) VALUES (?,?)', [$clinicId, $specId]);
}
// بیمه‌های کلینیک
foreach ([1, 2, 3, 7, 8, 9] as $insId) {
$conn->executeStatement('INSERT IGNORE INTO clinic_insurances (clinic_id, insurance_id) VALUES (?,?)', [$clinicId, $insId]);
}
// خدمات کلینیک
foreach ([101, 102, 103, 201, 202, 203, 801, 802, 803] as $svcId) {
$conn->executeStatement('INSERT IGNORE INTO clinic_services (clinic_id, service_id) VALUES (?,?)', [$clinicId, $svcId]);
}
echo "✅ کلینیک امید تبریز ایجاد شد (id: $clinicId)\n\n";
// ─────────────────────────────────────────────────────────────────────────────
// بخش C — دکتر نمونه کامل (تبریز)
// ─────────────────────────────────────────────────────────────────────────────
echo "👨‍⚕️ ایجاد دکتر نمونه...\n";
$sampleDocMobile = nextMobile();
$sampleDocUserUuid = newUuid();
$conn->executeStatement(
'INSERT INTO users (uuid, mobile_number, password_hash, real_name, roles, status, created_at, updated_at) VALUES (?,?,?,?,?,1,?,?)',
[$sampleDocUserUuid, $sampleDocMobile, $passwordHash, 'دکتر آرمان رضایی', '["ROLE_USER","ROLE_DOCTOR"]', $now, $now]
);
$sampleDocUserId = (int) $conn->lastInsertId();
$sampleDocUuid = newUuid();
$conn->executeStatement(
'INSERT INTO doctors (uuid, user_id, name, gender, medical_system_code, mobile_number, activity_time, degree, info, doctor_rate, doctor_rate_percentage, active_doctor_appointment, representation_id, created_at, updated_at)
VALUES (?,?,?,?,?,?,?,?,?,?,?,1,?,?,?)',
[
$sampleDocUuid, $sampleDocUserId,
'دکتر آرمان رضایی',
'man', 'IR-10001', $sampleDocMobile,
mktime(0, 0, 0, 1, 1, 2008),
'specialist',
'متخصص قلب و عروق با بیش از ۱۶ سال سابقه بالینی. فارغ‌التحصیل دانشگاه علوم پزشکی تبریز با فلوشیپ قلب از دانشگاه تهران. عضو انجمن قلب ایران و مولف بیش از ۳۰ مقاله علمی در مجلات معتبر بین‌المللی. دارای تخصص ویژه در آنژیوگرافی تشخیصی و درمانی، اکوکاردیوگرافی و درمان نارسایی قلبی.',
4.8, 96.0,
101,
$now, $now,
]
);
$sampleDocId = (int) $conn->lastInsertId();
// تخصص‌ها
foreach ([2, 3] as $specId) {
$conn->executeStatement('INSERT IGNORE INTO doctor_specialties (doctor_id, specialty_id) VALUES (?,?)', [$sampleDocId, $specId]);
}
// خدمات
foreach ([101, 102, 103, 104, 110] as $svcId) {
$conn->executeStatement('INSERT IGNORE INTO doctor_expertise (doctor_id, service_id) VALUES (?,?)', [$sampleDocId, $svcId]);
}
// بیمه‌ها
foreach ([1, 2, 3, 7] as $insId) {
$conn->executeStatement('INSERT IGNORE INTO doctor_insurances (doctor_id, insurance_id) VALUES (?,?)', [$sampleDocId, $insId]);
}
// شهر و استان
$conn->executeStatement('INSERT IGNORE INTO doctor_cities (doctor_id, city_id) VALUES (?,?)', [$sampleDocId, 101]);
$conn->executeStatement('INSERT IGNORE INTO doctor_provinces (doctor_id, province_id) VALUES (?,?)', [$sampleDocId, 1]);
// آدرس مطب شخصی
$addrUuid1 = newUuid();
$conn->executeStatement(
'INSERT INTO doctor_addresses (uuid, doctor_id, clinic_id, city_id, province_id, type, name, address, telephone, created_at, updated_at)
VALUES (?,?,NULL,?,?,?,?,?,?,?,?)',
[
$addrUuid1, $sampleDocId, 101, 1, 'personal',
'مطب دکتر رضایی',
'تبریز، خیابان آزادی، کوچه پانزدهم، ساختمان پارسیان، واحد ۱۲',
'04132001122',
$now, $now,
]
);
$addrId1 = (int) $conn->lastInsertId();
// آدرس کلینیک
$addrUuid2 = newUuid();
$conn->executeStatement(
'INSERT INTO doctor_addresses (uuid, doctor_id, clinic_id, city_id, province_id, type, name, address, telephone, created_at, updated_at)
VALUES (?,?,?,?,?,?,?,?,?,?,?)',
[
$addrUuid2, $sampleDocId, $clinicId, 101, 1, 'clinic',
'کلینیک تخصصی امید تبریز',
'تبریز، خیابان ارتش، نبش کوچه سوم، ساختمان امید، طبقه اول',
'04133445566',
$now, $now,
]
);
$addrId2 = (int) $conn->lastInsertId();
// clinic_doctors
$conn->executeStatement('INSERT IGNORE INTO clinic_doctors (clinic_id, doctor_id) VALUES (?,?)', [$clinicId, $sampleDocId]);
// برنامه کاری (هر دو مطب — مطب اصلی)
$scheduleJson = makeSchedule($addrId1, true);
$scheduleUuid = newUuid();
$conn->executeStatement(
'INSERT INTO weekly_schedules (uuid, doctor_id, setting, created_at, updated_at) VALUES (?,?,?,?,?)',
[$scheduleUuid, $sampleDocId, $scheduleJson, $now, $now]
);
// منشی دکتر نمونه
$secMobile = nextMobile();
$secUserUuid = newUuid();
$conn->executeStatement(
'INSERT INTO users (uuid, mobile_number, password_hash, real_name, roles, status, created_at, updated_at) VALUES (?,?,?,?,?,1,?,?)',
[$secUserUuid, $secMobile, $passwordHash, 'خانم نرگس صادقی', '["ROLE_USER","ROLE_SECRETARY"]', $now, $now]
);
$secUserId = (int) $conn->lastInsertId();
$secUuid = newUuid();
$conn->executeStatement(
'INSERT INTO doctor_secretaries (uuid, doctor_id, secretary_id, active, owner_type, clinic_id, created_at, updated_at) VALUES (?,?,?,1,?,NULL,?,?)',
[$secUuid, $sampleDocId, $secUserId, 'doctor', $now, $now]
);
echo "✅ دکتر نمونه (آرمان رضایی) ایجاد شد (id: $sampleDocId)\n";
echo " 📍 مطب: id=$addrId1 | کلینیک امید: id=$addrId2\n";
echo " 👩‍💼 منشی: خانم نرگس صادقی (موبایل: $secMobile)\n\n";
// ─────────────────────────────────────────────────────────────────────────────
// بخش D — ۱۰۰ دکتر به ازای هر شهر (bulk)
// ─────────────────────────────────────────────────────────────────────────────
$targetCities = [101, 102, 103, 104, 105, 108, 111, 113, 117, 121, 122, 126, 128, 131, 132];
$totalCreated = 0;
foreach ($targetCities as $cityId) {
$provincId = $cityProvince[$cityId];
$cityName = $cityNames[$cityId];
$docCount = 0;
echo "🏙️ $cityName: ";
// flat list of 100 doctors from specialty groups
$doctorQueue = [];
foreach ($specialtyGroups as $group) {
for ($n = 0; $n < $group['count']; $n++) {
$doctorQueue[] = $group;
}
}
$nameIdx = 0;
foreach ($doctorQueue as $idx => $group) {
// تعیین جنسیت
if ($group['gender_mix'] === 'female') {
$gender = 'woman';
$firstName = $femaleFirstNames[$nameIdx % count($femaleFirstNames)];
} elseif ($group['gender_mix'] === 'male') {
$gender = 'man';
$firstName = $maleFirstNames[$nameIdx % count($maleFirstNames)];
} else {
$gender = ($idx % 3 === 0) ? 'woman' : 'man';
$firstName = ($gender === 'woman')
? $femaleFirstNames[$nameIdx % count($femaleFirstNames)]
: $maleFirstNames[$nameIdx % count($maleFirstNames)];
}
$lastName = $lastNames[$nameIdx % count($lastNames)];
$nameIdx++;
$primarySpec = $group['specs'][0];
$specLabel = $specNames[$primarySpec] ?? 'پزشک';
$degreeLabel = $degreeLabels[$group['degree']] ?? 'متخصص';
$title = ($gender === 'woman') ? 'دکتر' : 'دکتر';
$fullName = "$title $firstName $lastName";
$mobile = nextMobile();
$userUuid = newUuid();
$conn->executeStatement(
'INSERT INTO users (uuid, mobile_number, password_hash, real_name, roles, status, created_at, updated_at) VALUES (?,?,?,?,?,1,?,?)',
[$userUuid, $mobile, $passwordHash, $fullName, '["ROLE_USER","ROLE_DOCTOR"]', $now, $now]
);
$userId = (int) $conn->lastInsertId();
$rate = round(3.5 + mt_rand(0, 15) / 10, 1);
$ratePercentage = $rate * 20.0;
$startYear = 1995 + mt_rand(0, 20);
$medCode = 'IR-' . str_pad((string)(10001 + $totalCreated + $docCount), 6, '0', STR_PAD_LEFT);
$info = "$degreeLabel $specLabel. فارغ‌التحصیل دانشگاه علوم پزشکی $cityName. "
. "با " . (date('Y') - $startYear) . " سال سابقه بالینی در حوزه $specLabel. "
. "پذیرش بیمه‌های تامین اجتماعی و خدمات درمانی.";
$docUuid = newUuid();
$conn->executeStatement(
'INSERT INTO doctors (uuid, user_id, name, gender, medical_system_code, mobile_number, activity_time, degree, info, doctor_rate, doctor_rate_percentage, active_doctor_appointment, representation_id, created_at, updated_at)
VALUES (?,?,?,?,?,?,?,?,?,?,?,1,?,?,?)',
[
$docUuid, $userId, $fullName, $gender, $medCode, $mobile,
mktime(0, 0, 0, 1, 1, $startYear),
$group['degree'], $info, $rate, $ratePercentage,
$cityId,
$now, $now,
]
);
$docId = (int) $conn->lastInsertId();
// تخصص‌ها
foreach ($group['specs'] as $specId) {
$conn->executeStatement('INSERT IGNORE INTO doctor_specialties (doctor_id, specialty_id) VALUES (?,?)', [$docId, $specId]);
}
// خدمات (حداکثر ۳ خدمت)
$services = array_slice($group['services'], 0, 3);
foreach ($services as $svcId) {
$conn->executeStatement('INSERT IGNORE INTO doctor_expertise (doctor_id, service_id) VALUES (?,?)', [$docId, $svcId]);
}
// بیمه‌ها: پایه + یک تکمیلی تصادفی
$suppInsId = $supplementaryInsurances[mt_rand(0, count($supplementaryInsurances) - 1)];
foreach (array_unique([1, 2, $suppInsId]) as $insId) {
$conn->executeStatement('INSERT IGNORE INTO doctor_insurances (doctor_id, insurance_id) VALUES (?,?)', [$docId, $insId]);
}
// شهر و استان
$conn->executeStatement('INSERT IGNORE INTO doctor_cities (doctor_id, city_id) VALUES (?,?)', [$docId, $cityId]);
$conn->executeStatement('INSERT IGNORE INTO doctor_provinces (doctor_id, province_id) VALUES (?,?)', [$docId, $provincId]);
// آدرس مطب
$streetNum = mt_rand(1, 50);
$buildingNo = mt_rand(1, 30);
$addrUuid = newUuid();
$conn->executeStatement(
'INSERT INTO doctor_addresses (uuid, doctor_id, clinic_id, city_id, province_id, type, name, address, telephone, created_at, updated_at)
VALUES (?,?,NULL,?,?,?,?,?,?,?,?)',
[
$addrUuid, $docId, $cityId, $provincId, 'personal',
"مطب $fullName",
$cityName . '، خیابان شهید بهشتی، کوچه ' . $streetNum . '، پلاک ' . $buildingNo,
'0' . (4100000000 + mt_rand(10000000, 99999999)),
$now, $now,
]
);
$addrId = (int) $conn->lastInsertId();
// برنامه کاری
$schedJson = makeSchedule($addrId, $idx % 4 !== 0);
$schedUuid = newUuid();
$conn->executeStatement(
'INSERT INTO weekly_schedules (uuid, doctor_id, setting, created_at, updated_at) VALUES (?,?,?,?,?)',
[$schedUuid, $docId, $schedJson, $now, $now]
);
$docCount++;
}
$totalCreated += $docCount;
echo "$docCount دکتر ایجاد شد ✅\n";
}
echo "\n✅ پایان — مجموع $totalCreated دکتر در " . count($targetCities) . " شهر ایجاد شد\n";
echo "📱 رمز عبور همه کاربران: Test@1234\n";
echo "📱 موبایل اول دکتر نمونه: $sampleDocMobile\n";
@@ -171,7 +171,7 @@ class SecretaryController extends BaseController
return $this->success(['message' => 'منشی غیرفعال شد']); return $this->success(['message' => 'منشی غیرفعال شد']);
} }
/** لیست منشیان مطب شخصی یک دکتر */ /** لیست منشی ها مطب شخصی یک دکتر */
#[Route('/api/v1/secretaries/{doctorUuid}', methods: ['GET'])] #[Route('/api/v1/secretaries/{doctorUuid}', methods: ['GET'])]
public function list(string $doctorUuid, #[CurrentUser] User $currentUser): JsonResponse public function list(string $doctorUuid, #[CurrentUser] User $currentUser): JsonResponse
{ {
@@ -180,7 +180,7 @@ class SecretaryController extends BaseController
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'دکتر یافت نشد', 404); return $this->error(ErrorCodes::ERR_VALIDATION_002, 'دکتر یافت نشد', 404);
} }
// فقط خود دکتر یا ادمین می‌توانند منشیان مطب شخصی را ببینند // فقط خود دکتر یا ادمین می‌توانند منشی ها مطب شخصی را ببینند
if (!$currentUser->hasRole('ROLE_ADMIN') && $doctor->getUser()->getId() !== $currentUser->getId()) { if (!$currentUser->hasRole('ROLE_ADMIN') && $doctor->getUser()->getId() !== $currentUser->getId()) {
return $this->error(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403); return $this->error(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403);
} }
@@ -193,7 +193,7 @@ class SecretaryController extends BaseController
return $this->success($secretaries); return $this->success($secretaries);
} }
/** لیست همه منشیان کلینیک (از همه دکترها، owner_type='clinic') */ /** لیست همه منشی ها کلینیک (از همه دکترها، owner_type='clinic') */
#[Route('/api/v1/secretaries/clinic/{clinicUuid}', methods: ['GET'])] #[Route('/api/v1/secretaries/clinic/{clinicUuid}', methods: ['GET'])]
public function listByClinic(string $clinicUuid, #[CurrentUser] User $currentUser): JsonResponse public function listByClinic(string $clinicUuid, #[CurrentUser] User $currentUser): JsonResponse
{ {
@@ -227,7 +227,7 @@ class SecretaryController extends BaseController
} }
if ($secretary->getOwnerType() === DoctorSecretary::OWNER_CLINIC) { if ($secretary->getOwnerType() === DoctorSecretary::OWNER_CLINIC) {
// فقط مالک کلینیک می‌تواند منشیان کلینیکی را مدیریت کند // فقط مالک کلینیک می‌تواند منشی ها کلینیکی را مدیریت کند
if (!$user->hasRole('ROLE_CLINIC')) { if (!$user->hasRole('ROLE_CLINIC')) {
return false; return false;
} }
@@ -38,7 +38,7 @@ class DoctorSecretaryRepository extends ServiceEntityRepository
return $this->findBy(['doctor' => $doctor], ['createdAt' => 'DESC']); return $this->findBy(['doctor' => $doctor], ['createdAt' => 'DESC']);
} }
/** منشیان مطب شخصی یک دکتر (owner_type='doctor') */ /** منشی ها مطب شخصی یک دکتر (owner_type='doctor') */
public function findByDoctorScope(Doctor $doctor): array public function findByDoctorScope(Doctor $doctor): array
{ {
return $this->findBy( return $this->findBy(
@@ -108,7 +108,7 @@ class DoctorSecretaryRepository extends ServiceEntityRepository
return $clinic->getDoctors()->contains($doctor); return $clinic->getDoctors()->contains($doctor);
} }
/** همه منشیان کلینیک (owner_type='clinic') */ /** همه منشی ها کلینیک (owner_type='clinic') */
public function findByClinic(Clinic $clinic): array public function findByClinic(Clinic $clinic): array
{ {
return $this->createQueryBuilder('s') return $this->createQueryBuilder('s')