From df7a78470118cbb34e752e45faaa74ec8617355b Mon Sep 17 00:00:00 2001 From: hamed <15238-genius.ha@users.noreply.drupalcode.org> Date: Mon, 15 Jun 2026 14:18:25 +0330 Subject: [PATCH] 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. --- .claude/prompt/implement-phase2-frontend.md | 711 ++++++---- .claude/prompt/secretary-context-scope.md | 43 +- .claude/prompt/seed-realistic-data.md | 280 ++++ .claude/prompt/subscription-gate-ui.md | 343 +++-- .claude/prompt/ui-ux-pages-redesign.md | 231 ++-- TEST_USERS.md | 114 +- assets/admin/components/layout/Sidebar.tsx | 412 ++++-- assets/admin/pages/MySecretariesPage.tsx | 1209 +++++++++++------ docs/PRD/prd.md | 325 +++-- docs/api/secretary.md | 253 ++-- .../task-12-secretary-completion/task.md | 67 +- .../task-12-secretary-completion/user_flow.md | 2 +- seed_realistic_data.php | 439 ++++++ .../Controller/SecretaryController.php | 8 +- .../Repository/DoctorSecretaryRepository.php | 4 +- 15 files changed, 3024 insertions(+), 1417 deletions(-) create mode 100644 .claude/prompt/seed-realistic-data.md create mode 100644 seed_realistic_data.php diff --git a/.claude/prompt/implement-phase2-frontend.md b/.claude/prompt/implement-phase2-frontend.md index 7fd1da6f..ab3ea748 100644 --- a/.claude/prompt/implement-phase2-frontend.md +++ b/.claude/prompt/implement-phase2-frontend.md @@ -32,25 +32,27 @@ PATCH /api/v1/staff/{uuid}/toggle → فعال/غیرفعال ``` **Response GET:** + ```json { - "success": true, - "data": [ - { - "uuid": "...", - "full_name": "علی رضایی", - "phone": "09121234567", - "job_title": "پرستار", - "address": null, - "national_code": "0012345678", - "active": true, - "created_at": 1718000000 - } - ] + "success": true, + "data": [ + { + "uuid": "...", + "full_name": "علی رضایی", + "phone": "09121234567", + "job_title": "پرستار", + "address": null, + "national_code": "0012345678", + "active": true, + "created_at": 1718000000 + } + ] } ``` **Response PATCH toggle:** + ```json { "success": true, "data": { "active": false } } ``` @@ -58,6 +60,7 @@ PATCH /api/v1/staff/{uuid}/toggle → فعال/غیرفعال ### فایل‌های جدید **`assets/admin/pages/StaffPage.tsx`** — صفحه مدیریت پرسنل: + - `useQuery(['staff'])` → `GET /api/v1/staff` → `data?.data ?? []` - جدول با ستون‌ها: نام، سمت، تلفن، کد ملی، وضعیت (badge)، عملیات - دکمه «افزودن پرسنل» → Modal ایجاد @@ -66,37 +69,48 @@ PATCH /api/v1/staff/{uuid}/toggle → فعال/غیرفعال - حذف سخت نداریم — فقط toggle **فیلدهای فرم:** + ```ts interface StaffFormData { - full_name: string; // required - phone?: string; - job_title?: string; - address?: string; - national_code?: string; + full_name: string; // required + phone?: string; + job_title?: string; + address?: string; + national_code?: string; } ``` **Zod schema:** + ```ts const schema = z.object({ - full_name: z.string().min(2, 'نام حداقل ۲ کاراکتر باید باشد'), - phone: z.string().optional(), - job_title: z.string().optional(), - address: z.string().optional(), - national_code: z.string().optional(), + full_name: z.string().min(2, "نام حداقل ۲ کاراکتر باید باشد"), + phone: z.string().optional(), + job_title: z.string().optional(), + address: z.string().optional(), + national_code: z.string().optional(), }); ``` ### Route و Sidebar در `App.tsx` اضافه کن: + ```tsx -} /> + + + + } +/> ``` در 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 commit -m "feat: add staff management page (TASK-10)" @@ -116,38 +130,56 @@ POST /api/v1/subscription-payment → شروع پرداخت { period_uuid, gate ``` **Response GET /plans:** + ```json { - "success": true, - "data": [ - { - "uuid": "...", - "name": "basic", - "level": 1, - "max_secretaries": 2, - "features": { "patient_records": true, "services": true }, - "periods": [ - { "uuid": "...", "label": "تریال ۱ ماهه", "duration_months": 1, "price_rials": 0, "is_trial": true }, - { "uuid": "...", "label": "۱ ماهه", "duration_months": 1, "price_rials": 250000, "is_trial": false } - ] - } - ] + "success": true, + "data": [ + { + "uuid": "...", + "name": "basic", + "level": 1, + "max_secretaries": 2, + "features": { "patient_records": true, "services": true }, + "periods": [ + { + "uuid": "...", + "label": "تریال ۱ ماهه", + "duration_months": 1, + "price_rials": 0, + "is_trial": true + }, + { + "uuid": "...", + "label": "۱ ماهه", + "duration_months": 1, + "price_rials": 250000, + "is_trial": false + } + ] + } + ] } ``` **Response GET /my:** + ```json { - "success": true, - "data": { - "plan": { "name": "basic", "level": 1, "features": { "patient_records": true, "services": true } }, - "period": { "label": "۶ ماهه", "duration_months": 6 }, - "is_trial": false, - "starts_at": 1718000000, - "expires_at": 1733360000, - "used_trial": true, - "days_remaining": 42 - } + "success": true, + "data": { + "plan": { + "name": "basic", + "level": 1, + "features": { "patient_records": true, "services": true } + }, + "period": { "label": "۶ ماهه", "duration_months": 6 }, + "is_trial": false, + "starts_at": 1718000000, + "expires_at": 1733360000, + "used_trial": true, + "days_remaining": 42 + } } ``` @@ -156,51 +188,62 @@ POST /api/v1/subscription-payment → شروع پرداخت { period_uuid, gate **`assets/admin/pages/SubscriptionPage.tsx`** — صفحه اشتراک: **بخش ۱ — وضعیت فعلی:** + - نمایش پلن فعلی (نام، تاریخ انقضا به شمسی، روزهای باقی‌مانده) - اگر `used_trial = false` → دکمه «فعال‌سازی تریال رایگان» (Basic یک ماهه) - نوار پیشرفت روزهای باقیمانده (اگر `expires_at` دارد) **بخش ۲ — کارت‌های پلن:** + - سه کارت: Free / Basic / Professional - هر کارت: نام پلن، ویژگی‌ها (لیست چک‌مارک)، دوره‌های قیمت به صورت badge/tab - دکمه «خرید» برای هر دوره → modal انتخاب gateway → `POST /api/v1/subscription-payment` → redirect به URL برگشتی - اگر پلن فعلی همان پلن است → دکمه «تمدید» **Types جدید (`assets/admin/types/index.ts`):** + ```ts export interface SubscriptionPlan { - uuid: string; - name: string; - level: number; - max_secretaries: number; - features: Record; - periods: SubscriptionPeriod[]; + uuid: string; + name: string; + level: number; + max_secretaries: number; + features: Record; + periods: SubscriptionPeriod[]; } export interface SubscriptionPeriod { - uuid: string; - label: string; - duration_months: number; - price_rials: number; - is_trial: boolean; + uuid: string; + label: string; + duration_months: number; + price_rials: number; + is_trial: boolean; } export interface MySubscription { - plan: { name: string; level: number; features: Record }; - period?: { label: string; duration_months: number }; - is_trial: boolean; - starts_at?: number; - expires_at?: number | null; - used_trial: boolean; - days_remaining?: number; + plan: { name: string; level: number; features: Record }; + period?: { label: string; duration_months: number }; + is_trial: boolean; + starts_at?: number; + expires_at?: number | null; + used_trial: boolean; + days_remaining?: number; } ``` ### Route ```tsx -} /> + + + + } +/> ``` ### کامیت + ``` git commit -m "feat: add subscription management page (TASK-11)" ``` @@ -213,73 +256,83 @@ git commit -m "feat: add subscription management page (TASK-11)" ### وضعیت فعلی SecretariesPage.tsx -فایل موجود است و لیست منشیان را نشان می‌دهد. باید: +فایل موجود است و لیست منشی ها را نشان می‌دهد. باید: 1. **Modal ویرایش permissions** اضافه کن: - - دکمه «دسترسی‌ها» (ShieldCheckIcon) کنار هر ردیف - - Modal با checkbox matrix: + - دکمه «دسترسی‌ها» (ShieldCheckIcon) کنار هر ردیف + - Modal با checkbox matrix: ```tsx // ساختار permissions که باید از API بخوانی و ذخیره کنی const DEFAULT_PERMISSIONS = { - appointments: { view: true, create: false, cancel: false, update_status: false }, - addresses: { view: true, create: false, update: false, delete: false }, - clinic_info: { view: true, update: false }, - insurances: { view: true, create: false, update: false, delete: false }, + appointments: { + view: true, + create: false, + cancel: false, + update_status: false, + }, + addresses: { view: true, create: false, update: false, delete: false }, + clinic_info: { view: true, update: false }, + insurances: { view: true, create: false, update: false, delete: false }, }; const PERMISSION_LABELS = { - appointments: { - label: 'نوبت‌ها', - actions: [ - { key: 'view', label: 'مشاهده' }, - { key: 'create', label: 'ایجاد' }, - { key: 'cancel', label: 'لغو' }, - { key: 'update_status', label: 'تغییر وضعیت' }, - ], - }, - addresses: { - label: 'آدرس‌ها', - actions: [ - { key: 'view', label: 'مشاهده' }, - { key: 'create', label: 'ایجاد' }, - { key: 'update', label: 'ویرایش' }, - { key: 'delete', label: 'حذف' }, - ], - }, - clinic_info: { - label: 'اطلاعات کلینیک', - actions: [ - { key: 'view', label: 'مشاهده' }, - { key: 'update', label: 'ویرایش' }, - ], - }, - insurances: { - label: 'بیمه‌ها', - actions: [ - { key: 'view', label: 'مشاهده' }, - { key: 'create', label: 'ایجاد' }, - { key: 'update', label: 'ویرایش' }, - { key: 'delete', label: 'حذف' }, - ], - }, + appointments: { + label: "نوبت‌ها", + actions: [ + { key: "view", label: "مشاهده" }, + { key: "create", label: "ایجاد" }, + { key: "cancel", label: "لغو" }, + { key: "update_status", label: "تغییر وضعیت" }, + ], + }, + addresses: { + label: "آدرس‌ها", + actions: [ + { key: "view", label: "مشاهده" }, + { key: "create", label: "ایجاد" }, + { key: "update", label: "ویرایش" }, + { key: "delete", label: "حذف" }, + ], + }, + clinic_info: { + label: "اطلاعات کلینیک", + actions: [ + { key: "view", label: "مشاهده" }, + { key: "update", label: "ویرایش" }, + ], + }, + insurances: { + label: "بیمه‌ها", + actions: [ + { key: "view", label: "مشاهده" }, + { key: "create", label: "ایجاد" }, + { key: "update", label: "ویرایش" }, + { key: "delete", label: "حذف" }, + ], + }, }; ``` 2. **PATCH `/api/v1/secretary/{uuid}`** با body: + ```json { "permissions": { "appointments": { "view": true, ... }, ... } } ``` 3. **خطای `ERR_SECRETARY_LIMIT_REACHED` (403)** را هنگام ایجاد منشی handle کن: + ```tsx // در onError useMutation create: -if (err.code === 'ERR_SECRETARY_LIMIT_REACHED') { - toast.error('سقف تعداد منشی پنل فعلی شما پر است. برای افزودن منشی بیشتر پنل را ارتقا دهید.'); +if (err.code === "ERR_SECRETARY_LIMIT_REACHED") { + toast.error( + "سقف تعداد منشی پنل فعلی شما پر است. برای افزودن منشی بیشتر پنل را ارتقا دهید.", + ); } ``` ### کامیت + ``` git commit -m "feat: add secretary permissions matrix and subscription limit handling (TASK-12)" ``` @@ -303,28 +356,28 @@ DELETE /api/v1/service-item/{uuid} → حذف آیتم ``` **Response GET /service-sections:** + ```json { - "success": true, - "data": [ - { "uuid": "...", "name": "تزریقات", "active": true } - ] + "success": true, + "data": [{ "uuid": "...", "name": "تزریقات", "active": true }] } ``` **Response GET /service-items/{sectionUuid}:** + ```json { - "success": true, - "data": [ - { - "uuid": "...", - "name": "سرم ۵۰۰cc", - "price_rials": 85000, - "staff": { "uuid": "...", "full_name": "علی رضایی" }, - "active": true - } - ] + "success": true, + "data": [ + { + "uuid": "...", + "name": "سرم ۵۰۰cc", + "price_rials": 85000, + "staff": { "uuid": "...", "full_name": "علی رضایی" }, + "active": true + } + ] } ``` @@ -333,48 +386,60 @@ DELETE /api/v1/service-item/{uuid} → حذف آیتم **`assets/admin/pages/ClinicServicesPage.tsx`** — صفحه دو‌بخشی: **ستون چپ** — لیست بخش‌ها (ServiceSection): + - لیست ساده با دکمه‌های ویرایش/حذف - دکمه «+ بخش جدید» → Modal با فیلد `name` - کلیک روی بخش → بارگذاری آیتم‌های آن در ستون راست **ستون راست** — لیست آیتم‌های بخش انتخابی (ServiceItem): + - جدول: نام، قیمت (`formatRial()`), پرسنل مسئول، وضعیت - دکمه «+ آیتم جدید» - حذف: اگر `ERR_SERVICE_ITEM_IN_USE` (409) → toast.error('این سرویس در پرونده بیمار استفاده شده و قابل حذف نیست') **فرم آیتم:** + ```ts interface ServiceItemForm { - section_uuid: string; // hidden — از state - name: string; // required - price_rials: number; // required - staff_uuid?: string; // SearchableSelect از GET /api/v1/staff + section_uuid: string; // hidden — از state + name: string; // required + price_rials: number; // required + staff_uuid?: string; // SearchableSelect از GET /api/v1/staff } ``` **Types جدید:** + ```ts export interface ServiceSection { - uuid: string; - name: string; - active: boolean; + uuid: string; + name: string; + active: boolean; } export interface ServiceItem { - uuid: string; - name: string; - price_rials: number; - staff: { uuid: string; full_name: string } | null; - active: boolean; + uuid: string; + name: string; + price_rials: number; + staff: { uuid: string; full_name: string } | null; + active: boolean; } ``` ### Route ```tsx -} /> + + + + } +/> ``` ### کامیت + ``` git commit -m "feat: add clinic services management page (TASK-13)" ``` @@ -394,39 +459,54 @@ PATCH /api/v1/sms/settings → ذخیره تنظیمات ``` **Response GET /sms/wallet/balance:** + ```json { - "success": true, - "data": { - "balance_rials": 150000, - "sms_price_rials": 250, - "estimated_sms_count": 600 - } + "success": true, + "data": { + "balance_rials": 150000, + "sms_price_rials": 250, + "estimated_sms_count": 600 + } } ``` **Response GET /sms/wallet/logs:** + ```json { - "success": true, - "data": [ - { "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 } + "success": true, + "data": [ + { + "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 } } ``` **Response GET /sms/settings:** + ```json { - "success": true, - "data": { - "reminder_enabled": true, - "reminder_hours_before": 3, - "post_visit_enabled": false, - "post_visit_text": null - } + "success": true, + "data": { + "reminder_enabled": true, + "reminder_hours_before": 3, + "post_visit_enabled": false, + "post_visit_text": null + } } ``` @@ -435,48 +515,60 @@ PATCH /api/v1/sms/settings → ذخیره تنظیمات **`assets/admin/pages/SmsWalletPage.tsx`** — صفحه کیف پول پیامک: **بخش ۱ — کارت موجودی:** + - نمایش `balance_rials` با `formatRial()` - نمایش `estimated_sms_count` (تعداد پیامک قابل ارسال) - دکمه «شارژ کیف پول» → Modal با فیلد مبلغ + انتخاب gateway → redirect به URL **بخش ۲ — تنظیمات:** + - Toggle «یادآوری قبل از نوبت» + فیلد عددی «چند ساعت قبل» - Toggle «پیامک بعد از ویزیت» + textarea متن پیامک - دکمه ذخیره → `PATCH /api/v1/sms/settings` **بخش ۳ — تاریخچه تراکنش‌ها:** + - جدول: نوع (badge سبز credit / قرمز debit)، مبلغ، توضیحات، تاریخ - Pagination **Types جدید:** + ```ts export interface SmsWalletBalance { - balance_rials: number; - sms_price_rials: number; - estimated_sms_count: number; + balance_rials: number; + sms_price_rials: number; + estimated_sms_count: number; } export interface SmsWalletLog { - uuid: string; - type: 'credit' | 'debit'; - amount_rials: number; - description: string; - created_at: number; + uuid: string; + type: "credit" | "debit"; + amount_rials: number; + description: string; + created_at: number; } export interface SmsSettings { - reminder_enabled: boolean; - reminder_hours_before: number; - post_visit_enabled: boolean; - post_visit_text: string | null; + reminder_enabled: boolean; + reminder_hours_before: number; + post_visit_enabled: boolean; + post_visit_text: string | null; } ``` ### Route ```tsx -} /> + + + + } +/> ``` ### کامیت + ``` git commit -m "feat: add SMS wallet and settings page (TASK-14)" ``` @@ -497,54 +589,59 @@ PATCH /api/v1/session/{uuid} → ویرایش مراجعه ``` **Response GET /patients:** + ```json { - "success": true, - "data": [ - { - "uuid": "...", - "user": { "uuid": "...", "fullName": "علی محمدی", "phone": "09121234567" }, - "created_at": 1710000000 - } - ], - "meta": { "totalRecords": 12, "totalPages": 2, "currentPage": 1 } + "success": true, + "data": [ + { + "uuid": "...", + "user": { + "uuid": "...", + "fullName": "علی محمدی", + "phone": "09121234567" + }, + "created_at": 1710000000 + } + ], + "meta": { "totalRecords": 12, "totalPages": 2, "currentPage": 1 } } ``` **Response GET /patient/{uuid}/sessions:** + ```json { - "success": true, - "data": [ - { - "uuid": "...", - "appointment_uuid": null, - "visit_price_rials": 500000, - "base_insurance_discount_percent": "30.00", - "supplementary_discount_percent": "0.00", - "services_total_rials": 130000, - "final_price_rials": 445000, - "payment_method": "cash", - "notes": "...", - "created_at": 1718000000, - "updated_at": 1718000000 - } - ], - "meta": { "totalRecords": 5, "totalPages": 1, "currentPage": 1 } + "success": true, + "data": [ + { + "uuid": "...", + "appointment_uuid": null, + "visit_price_rials": 500000, + "base_insurance_discount_percent": "30.00", + "supplementary_discount_percent": "0.00", + "services_total_rials": 130000, + "final_price_rials": 445000, + "payment_method": "cash", + "notes": "...", + "created_at": 1718000000, + "updated_at": 1718000000 + } + ], + "meta": { "totalRecords": 5, "totalPages": 1, "currentPage": 1 } } ``` **Request POST /patient/{uuid}/session:** + ```json { - "visit_price_rials": 500000, - "base_insurance_discount_percent": 30, - "supplementary_discount_percent": 0, - "payment_method": "cash", - "notes": "...", - "services": [ - { "service_item_uuid": "...", "staff_uuid": null } - ] + "visit_price_rials": 500000, + "base_insurance_discount_percent": 30, + "supplementary_discount_percent": 0, + "payment_method": "cash", + "notes": "...", + "services": [{ "service_item_uuid": "...", "staff_uuid": null }] } ``` @@ -553,58 +650,68 @@ PATCH /api/v1/session/{uuid} → ویرایش مراجعه **`assets/admin/pages/MyPatientsPage.tsx`** — بازنویسی کامل: **نمای لیست بیماران:** + - جستجو (`?search=`) با debounce ۴۰۰ms - جدول: نام بیمار، تلفن، تاریخ ثبت، عملیات - دکمه «+ بیمار جدید» → Modal با `SearchableSelect` برای پیدا کردن کاربر (search by phone) - کلیک روی ردیف → نمای جزئیات پرونده (state محلی یا route `/my-patients/:uuid`) **نمای جزئیات پرونده:** + - مشخصات بیمار + دکمه «+ مراجعه جدید» - لیست مراجعات (accordion یا جدول): - - قیمت ویزیت، تخفیف بیمه پایه، تخفیف تکمیلی، مجموع سرویس‌ها، قیمت نهایی - - وضعیت پرداخت (`payment_method`) - - یادداشت - - دکمه ویرایش (فقط `notes` و `payment_method`) + - قیمت ویزیت، تخفیف بیمه پایه، تخفیف تکمیلی، مجموع سرویس‌ها، قیمت نهایی + - وضعیت پرداخت (`payment_method`) + - یادداشت + - دکمه ویرایش (فقط `notes` و `payment_method`) **Modal ثبت مراجعه جدید:** + ```ts interface SessionFormData { - visit_price_rials: number; - base_insurance_discount_percent: number; // 0-100 - supplementary_discount_percent: number; // 0-100 - payment_method: 'cash' | 'card' | 'insurance' | 'online' | 'pending'; - notes?: string; - services: { service_item_uuid: string; staff_uuid?: string }[]; + visit_price_rials: number; + base_insurance_discount_percent: number; // 0-100 + supplementary_discount_percent: number; // 0-100 + payment_method: "cash" | "card" | "insurance" | "online" | "pending"; + notes?: string; + services: { service_item_uuid: string; staff_uuid?: string }[]; } ``` + - محاسبه `final_price_rials` به صورت live در فرانت: + ```ts const afterBase = visitPrice * (1 - baseDiscount / 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; ``` + - افزودن سرویس: `SearchableSelect` از `GET /api/v1/service-items/{sectionUuid}` (باید ابتدا بخش را انتخاب کند) **Types جدید:** + ```ts export interface PatientRecord { - uuid: string; - user: { uuid: string; fullName: string; phone: string }; - created_at: number; + uuid: string; + user: { uuid: string; fullName: string; phone: string }; + created_at: number; } export interface PatientSession { - uuid: string; - appointment_uuid: string | null; - visit_price_rials: number; - base_insurance_discount_percent: string; - supplementary_discount_percent: string; - services_total_rials: number; - final_price_rials: number; - payment_method: string; - notes: string | null; - created_at: number; - updated_at: number; + uuid: string; + appointment_uuid: string | null; + visit_price_rials: number; + base_insurance_discount_percent: string; + supplementary_discount_percent: string; + services_total_rials: number; + final_price_rials: number; + payment_method: string; + notes: string | null; + created_at: number; + updated_at: number; } ``` @@ -612,10 +719,18 @@ export interface PatientSession { ```tsx // مسیر موجود کافی است: -} /> + + + + } +/> ``` ### کامیت + ``` 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:** + ```json { "stats": { @@ -646,6 +762,7 @@ GET /api/v1/admin/dashboard/charts?from=UNIX&to=UNIX → سری‌های زما ``` **Response GET /admin/dashboard/charts?from=UNIX&to=UNIX:** + ```json { "success": true, @@ -670,44 +787,52 @@ GET /api/v1/admin/dashboard/charts?from=UNIX&to=UNIX → سری‌های زما **۱. فیلتر زمانی — اضافه کن:** ```tsx -type Preset = 'this_week' | 'this_month' | '3_months' | 'custom'; +type Preset = "this_week" | "this_month" | "3_months" | "custom"; // تبدیل به Unix: function getRange(preset: Preset): { from: number; to: number } { - const now = Math.floor(Date.now() / 1000); - switch (preset) { - case 'this_week': - // شنبه‌ی این هفته — ساده‌ترین راه: now - روزهای گذشته از شنبه - const dayOfWeek = new Date().getDay(); // 0=Sun,6=Sat - const daysSinceSat = (dayOfWeek + 1) % 7; - return { from: now - daysSinceSat * 86400, to: now }; - case 'this_month': - const d = new Date(); d.setDate(1); d.setHours(0,0,0,0); - return { from: Math.floor(d.getTime() / 1000), to: now }; - case '3_months': - return { from: now - 90 * 86400, to: now }; - default: - return { from: now - 30 * 86400, to: now }; - } + const now = Math.floor(Date.now() / 1000); + switch (preset) { + case "this_week": + // شنبه‌ی این هفته — ساده‌ترین راه: now - روزهای گذشته از شنبه + const dayOfWeek = new Date().getDay(); // 0=Sun,6=Sat + const daysSinceSat = (dayOfWeek + 1) % 7; + return { from: now - daysSinceSat * 86400, to: now }; + case "this_month": + const d = new Date(); + d.setDate(1); + d.setHours(0, 0, 0, 0); + return { from: Math.floor(d.getTime() / 1000), to: now }; + case "3_months": + return { from: now - 90 * 86400, to: now }; + default: + return { from: now - 30 * 86400, to: now }; + } } ``` preset buttons: + ```tsx -
- {(['this_week', 'this_month', '3_months'] as Preset[]).map(p => ( - - ))} +
+ {(["this_week", "this_month", "3_months"] as Preset[]).map((p) => ( + + ))}
``` **۲. فیلدهای جدید در کارت‌های آمار:** + - کارت «موجودی پیامک»: `stats.sms_wallet_balance` → `formatRial()` - کارت «بیماران یکتا»: `stats.unique_patients_count` - کارت «درآمد دوره»: `stats.revenue_period_rials` → `formatRial()` @@ -715,6 +840,7 @@ preset buttons: **۳. نمودارها (برای admin role):** از **SVG inline** موجود در DashboardPage استفاده کن (کلاس `SvgLineChart` که در فایل موجود است): + ```tsx // appointments_by_day و revenue_by_day داده‌های خطی دارند // فقط آرایه عددی را استخراج کن: @@ -726,41 +852,54 @@ const revData = charts?.revenue_by_day?.map((d: any) => d.amount) ?? []; ``` **subscription_sales_by_plan** — جدول ساده: + ```tsx - - - {subData.map(row => ( - - - - - - ))} - + + + + + + + + + {subData.map((row) => ( + + + + + + ))} +
پلنتعداددرآمد
{row.plan}{row.count}{formatRial(row.revenue)}
پلنتعداددرآمد
{row.plan}{row.count}{formatRial(row.revenue)}
``` **API call با from/to:** + ```tsx -const [preset, setPreset] = useState('this_month'); +const [preset, setPreset] = useState("this_month"); const range = getRange(preset); // برای admin: const { data: charts } = useQuery({ - queryKey: ['dashboard-charts', range.from, range.to], - queryFn: () => api.get(`/api/v1/admin/dashboard/charts?from=${range.from}&to=${range.to}`), + queryKey: ["dashboard-charts", range.from, range.to], + queryFn: () => + api.get( + `/api/v1/admin/dashboard/charts?from=${range.from}&to=${range.to}`, + ), }); // برای doctor: const { data: doctorDash } = useQuery({ - queryKey: ['dashboard-doctor', range.from, range.to], - queryFn: () => api.get(`/api/v1/dashboard/doctor?from=${range.from}&to=${range.to}`), - enabled: primaryRole === 'doctor', + queryKey: ["dashboard-doctor", range.from, range.to], + queryFn: () => + 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)" ``` @@ -790,18 +929,18 @@ 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/utils.ts` | `formatRial`, `formatDate`, `formatDateTime`, `formatNumber` | -| `assets/admin/types/index.ts` | همه TypeScript interfaces | -| `assets/admin/stores/authStore.ts` | `primaryRole`, `dbUuid`, `doctorUuid` | -| `assets/admin/components/ui/DataTable.tsx` | جدول — `Column[]` | -| `assets/admin/components/ui/Modal.tsx` | Modal | -| `assets/admin/components/ui/SearchableSelect.tsx` | dropdown با جستجو | -| `docs/api/staff.md` | API staff | -| `docs/api/subscription.md` | API subscription | -| `docs/api/clinic-services.md` | API services | -| `docs/api/sms.md` | API SMS wallet | -| `docs/api/patient.md` | API patient records | -| `docs/api/dashboard.md` | API dashboard | +| فایل | نقش | +| ------------------------------------------------- | ------------------------------------------------------------ | +| `assets/admin/lib/api.ts` | fetch wrapper — `api.get/post/patch/delete` | +| `assets/admin/lib/utils.ts` | `formatRial`, `formatDate`, `formatDateTime`, `formatNumber` | +| `assets/admin/types/index.ts` | همه TypeScript interfaces | +| `assets/admin/stores/authStore.ts` | `primaryRole`, `dbUuid`, `doctorUuid` | +| `assets/admin/components/ui/DataTable.tsx` | جدول — `Column[]` | +| `assets/admin/components/ui/Modal.tsx` | Modal | +| `assets/admin/components/ui/SearchableSelect.tsx` | dropdown با جستجو | +| `docs/api/staff.md` | API staff | +| `docs/api/subscription.md` | API subscription | +| `docs/api/clinic-services.md` | API services | +| `docs/api/sms.md` | API SMS wallet | +| `docs/api/patient.md` | API patient records | +| `docs/api/dashboard.md` | API dashboard | diff --git a/.claude/prompt/secretary-context-scope.md b/.claude/prompt/secretary-context-scope.md index df4a5d75..2d6abf3c 100644 --- a/.claude/prompt/secretary-context-scope.md +++ b/.claude/prompt/secretary-context-scope.md @@ -14,30 +14,31 @@ قوانین: -| ایجادکننده | `owner_type` | `clinic_id` | -|-----------|-------------|-------------| -| خود دکتر | `'doctor'` | `null` | -| کلینیک | `'clinic'` | `` | +| ایجادکننده | `owner_type` | `clinic_id` | +| ---------- | ------------ | ------------- | +| خود دکتر | `'doctor'` | `null` | +| کلینیک | `'clinic'` | `` | دسترسی‌ها، داشبورد، و فیلتر نوبت‌ها باید فقط در scope مربوطه اعمال شوند. ## فایل‌های مرتبط -| فایل | نقش | -|------|-----| -| `src/Secretary/Entity/DoctorSecretary.php` | entity اصلی — باید `owner_type` و `clinic_id` اضافه شود | -| `src/Secretary/Controller/SecretaryController.php` | ایجاد و لیست منشی — باید scope را تنظیم کند | -| `src/Secretary/Repository/DoctorSecretaryRepository.php` | query ها — باید scope-aware شوند | -| `src/Appointment/Controller/MyAppointmentsController.php` | فیلتر نوبت‌های منشی — باید scope-aware شود | -| `src/Dashboard/Controller/DashboardController.php` | داشبورد منشی — باید scope-aware شود | -| `src/Patient/Controller/PatientController.php` | دسترسی منشی به پرونده بیمار — باید scope-aware شود | -| `src/Auth/Controller/AuthController.php` | `/api/v1/me` context builder — باید scope را در context نشان دهد | -| `migrations/` | migration جدید برای `owner_type` + `clinic_id` | -| `docs/api/secretary.md` | مستندات API | +| فایل | نقش | +| --------------------------------------------------------- | ---------------------------------------------------------------- | +| `src/Secretary/Entity/DoctorSecretary.php` | entity اصلی — باید `owner_type` و `clinic_id` اضافه شود | +| `src/Secretary/Controller/SecretaryController.php` | ایجاد و لیست منشی — باید scope را تنظیم کند | +| `src/Secretary/Repository/DoctorSecretaryRepository.php` | query ها — باید scope-aware شوند | +| `src/Appointment/Controller/MyAppointmentsController.php` | فیلتر نوبت‌های منشی — باید scope-aware شود | +| `src/Dashboard/Controller/DashboardController.php` | داشبورد منشی — باید scope-aware شود | +| `src/Patient/Controller/PatientController.php` | دسترسی منشی به پرونده بیمار — باید scope-aware شود | +| `src/Auth/Controller/AuthController.php` | `/api/v1/me` context builder — باید scope را در context نشان دهد | +| `migrations/` | migration جدید برای `owner_type` + `clinic_id` | +| `docs/api/secretary.md` | مستندات API | ## وضعیت فعلی ### Entity (بدون scope) + ```php // src/Secretary/Entity/DoctorSecretary.php // هیچ فیلد owner_type یا clinic_id وجود ندارد @@ -54,6 +55,7 @@ class DoctorSecretary ``` ### Repository — scope-blind + ```php public function findActiveBySecretary(User $user): ?DoctorSecretary { @@ -63,6 +65,7 @@ public function findActiveBySecretary(User $user): ?DoctorSecretary ``` ### Controller — ایجاد بدون scope + ```php // create() در SecretaryController // هیچ owner_type یا clinic_id ست نمی‌شود @@ -70,6 +73,7 @@ $secretary = new DoctorSecretary($doctor, $secretaryUser); ``` ### Auth context builder — scope-blind + ```php // AuthController::buildContexts() foreach ($this->secretaryRepo->findAllActiveBySecretary($user) as $rel) { @@ -86,6 +90,7 @@ foreach ($this->secretaryRepo->findAllActiveBySecretary($user) as $rel) { ``` ### Appointment فیلتر — فقط یک رابطه + ```php // MyAppointmentsController — منشی فقط به نوبت‌های یک دکتر دسترسی دارد } elseif (in_array('ROLE_SECRETARY', $roles, true)) { @@ -128,6 +133,7 @@ public function getClinic(): ?Clinic { return $this->clinic; } ``` در `toArray()` هم اضافه شود: + ```php 'owner_type' => $this->ownerType, 'clinic_uuid' => $this->clinic?->getUuid(), @@ -136,6 +142,7 @@ public function getClinic(): ?Clinic { return $this->clinic; } ### ۲. Migration بعد از تغییر Entity: + ```bash ddev exec php bin/console doctrine:migrations:diff --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 public function findByClinic(Clinic $clinic): array { @@ -225,6 +233,7 @@ public function findByClinic(Clinic $clinic): array ### ۴. SecretaryController: ایجاد با scope در `create()`: + - اگر `currentUser` دارای `ROLE_CLINIC` بود → `ownerType = 'clinic'`، `clinic = clinicRepo->findByUser($currentUser)` - اگر `ROLE_DOCTOR` بود → `ownerType = 'doctor'`، `clinic = null` @@ -238,6 +247,7 @@ if ($currentUser->hasRole('ROLE_CLINIC')) { ``` همچنین `canManageDoctor()` باید scope را چک کند: + - `ROLE_DOCTOR` فقط می‌تواند روابط `owner_type='doctor'` را مدیریت کند - `ROLE_CLINIC` فقط می‌تواند روابط `owner_type='clinic'` متعلق به کلینیک خودش را مدیریت کند @@ -278,6 +288,7 @@ foreach ($this->secretaryRepo->findAllActiveBySecretary($user) as $rel) { ### ۶. Appointment و Dashboard: scope-aware در `MyAppointmentsController` و `DashboardController` به جای `findActiveBySecretary`: + - اگر `db_uuid` در JWT به یک کلینیک اشاره دارد → همه دکترهای کلینیک که این منشی به آن‌ها متصل است - اگر به یک دکتر اشاره دارد → فقط همان دکتر با `owner_type='doctor'` diff --git a/.claude/prompt/seed-realistic-data.md b/.claude/prompt/seed-realistic-data.md new file mode 100644 index 00000000..ac547e19 --- /dev/null +++ b/.claude/prompt/seed-realistic-data.md @@ -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": , + "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 شهر ایجاد شد +``` diff --git a/.claude/prompt/subscription-gate-ui.md b/.claude/prompt/subscription-gate-ui.md index 808447ad..4b5fd445 100644 --- a/.claude/prompt/subscription-gate-ui.md +++ b/.claude/prompt/subscription-gate-ui.md @@ -3,6 +3,7 @@ ## زمینه سیستم اشتراک در backend کاملاً پیاده است: + - `SubscriptionService::hasFeature(entityType, entityId, feature)` — بررسی دسترسی به قابلیت - `SubscriptionService::getSecretaryLimit(...)` — حداکثر منشی مجاز - `SubscriptionPlan::features` — یک آرایه JSON مثل `{ "patient_records": true, "services": true, "sms_panel": true }` @@ -11,6 +12,7 @@ **مشکل:** Frontend هیچ اطلاعی از اشتراک فعال کاربر ندارد. Sidebar همه منوها را به همه نقش‌ها نشان می‌دهد، صفحات بدون هشدار باز می‌شوند، و کاربر با پیام خطای backend مواجه می‌شود بجای راهنمای ارتقاء پنل. **هدف:** وقتی کاربر (پزشک / کلینیک / منشی) وارد پنل می‌شود: + 1. اطلاعات اشتراک فعال لود شود و در یک context/hook مشترک در دسترس باشد 2. آیتم‌های Sidebar که نیاز به feature دارند — اگر feature فعال نیست — با آیکون قفل نمایش داده شوند یا پنهان شوند 3. صفحاتی که feature ندارند، یک بنر «برای استفاده از این قابلیت پنل خود را ارتقاء دهید» نمایش دهند @@ -21,12 +23,12 @@ ### قابلیت‌های محدودشده توسط اشتراک -| Feature Key | صفحه/منو | نقش مرتبط | -|-------------|----------|----------| -| `patient_records` | `/admin/my-patients` | doctor, clinic, secretary | -| `services` | `/admin/clinic-services` | doctor, clinic | -| `sms_panel` | `/admin/sms-wallet` | doctor, clinic | -| `max_secretaries` | `/admin/my-secretaries` (تعداد) | doctor, clinic | +| Feature Key | صفحه/منو | نقش مرتبط | +| ----------------- | ------------------------------- | ------------------------- | +| `patient_records` | `/admin/my-patients` | doctor, clinic, secretary | +| `services` | `/admin/clinic-services` | doctor, clinic | +| `sms_panel` | `/admin/sms-wallet` | doctor, clinic | +| `max_secretaries` | `/admin/my-secretaries` (تعداد) | doctor, clinic | **نکته منشی:** منشی اشتراک مستقل ندارد — اشتراک entity ای که به آن متصل است (دکتر یا کلینیک) اعمال می‌شود. endpoint `/api/v1/subscription/my` برای منشی null برمی‌گرداند (چون `resolveEntity` در `SubscriptionController` فقط ROLE_DOCTOR و ROLE_CLINIC را handle می‌کند). @@ -34,24 +36,25 @@ ## فایل‌های مرتبط -| فایل | نقش | -|------|-----| +| فایل | نقش | +| -------------------------------------------------------- | ------------------------------------------------------------- | | `src/Subscription/Controller/SubscriptionController.php` | endpoint `GET /api/v1/subscription/my` — برای doctor و clinic | -| `src/Subscription/Service/SubscriptionService.php` | `hasFeature()`, `getSecretaryLimit()` | -| `assets/admin/stores/authStore.ts` | state مرکزی auth — باید subscription هم اینجا باشد | -| `assets/admin/components/layout/Sidebar.tsx` | `buildSections()` — منوها بر اساس `primaryRole` | -| `assets/admin/App.tsx` | `RoleRoute` — گیت نقش‌ها، باید feature gate هم اضافه شود | -| `assets/admin/pages/MyPatientsPage.tsx` | نیاز به `patient_records` | -| `assets/admin/pages/ClinicServicesPage.tsx` | نیاز به `services` | -| `assets/admin/pages/SmsWalletPage.tsx` | نیاز به `sms_panel` | -| `assets/admin/pages/MySecretariesPage.tsx` | نیاز به `max_secretaries` بیش از ۱ | -| `assets/admin/pages/SubscriptionPage.tsx` | صفحه ارتقاء پنل — مقصد CTA ها | +| `src/Subscription/Service/SubscriptionService.php` | `hasFeature()`, `getSecretaryLimit()` | +| `assets/admin/stores/authStore.ts` | state مرکزی auth — باید subscription هم اینجا باشد | +| `assets/admin/components/layout/Sidebar.tsx` | `buildSections()` — منوها بر اساس `primaryRole` | +| `assets/admin/App.tsx` | `RoleRoute` — گیت نقش‌ها، باید feature gate هم اضافه شود | +| `assets/admin/pages/MyPatientsPage.tsx` | نیاز به `patient_records` | +| `assets/admin/pages/ClinicServicesPage.tsx` | نیاز به `services` | +| `assets/admin/pages/SmsWalletPage.tsx` | نیاز به `sms_panel` | +| `assets/admin/pages/MySecretariesPage.tsx` | نیاز به `max_secretaries` بیش از ۱ | +| `assets/admin/pages/SubscriptionPage.tsx` | صفحه ارتقاء پنل — مقصد CTA ها | --- ## وضعیت فعلی ### Backend — endpoint اشتراک + ```php // SubscriptionController.php — GET /api/v1/subscription/my // فقط doctor و clinic را handle می‌کند @@ -70,50 +73,77 @@ private function resolveEntity(User $user): array ``` پاسخ: + ```json { - "success": true, - "data": { - "subscription": { - "uuid": "...", - "plan": { "name": "basic", "level": 1, "features": { "patient_records": true, "services": true, "sms_panel": false }, "max_secretaries": 3 }, - "is_trial": false, - "expires_at": 1750000000, - "days_remaining": 45 - }, - "used_trial": false - } + "success": true, + "data": { + "subscription": { + "uuid": "...", + "plan": { + "name": "basic", + "level": 1, + "features": { + "patient_records": true, + "services": true, + "sms_panel": false + }, + "max_secretaries": 3 + }, + "is_trial": false, + "expires_at": 1750000000, + "days_remaining": 45 + }, + "used_trial": false + } } ``` اگر اشتراک فعال نداشته باشد: `"subscription": null` ### Frontend — authStore + ```ts // authStore.ts — فعلاً subscription اصلاً در store نیست interface AuthState { - primaryRole: 'admin' | 'clinic' | 'doctor' | 'secretary' | 'user' | null; - dbUuid: string | null; - // ... subscription وجود ندارد + primaryRole: "admin" | "clinic" | "doctor" | "secretary" | "user" | null; + dbUuid: string | null; + // ... subscription وجود ندارد } ``` ### Frontend — Sidebar + ```tsx // Sidebar.tsx — همه آیتم‌ها را بدون بررسی feature نمایش می‌دهد -if (primaryRole === 'doctor') { - return [{ - label: 'مدیریت', - items: [ - { to: '/admin/my-patients', icon: FolderOpenIcon, label: 'پرونده بیماران' }, - { to: '/admin/clinic-services', icon: WrenchScrewdriverIcon, label: 'سرویس‌ها' }, - { to: '/admin/sms-wallet', icon: DevicePhoneMobileIcon, label: 'کیف پول پیامک' }, - ], - }]; +if (primaryRole === "doctor") { + return [ + { + label: "مدیریت", + items: [ + { + to: "/admin/my-patients", + icon: FolderOpenIcon, + label: "پرونده بیماران", + }, + { + to: "/admin/clinic-services", + icon: WrenchScrewdriverIcon, + label: "سرویس‌ها", + }, + { + to: "/admin/sms-wallet", + icon: DevicePhoneMobileIcon, + label: "کیف پول پیامک", + }, + ], + }, + ]; } ``` ### Frontend — App.tsx + ```tsx // فقط نقش‌ها چک می‌شوند، feature gate وجود ندارد } /> @@ -148,34 +178,38 @@ if ($user->hasRole('ROLE_SECRETARY')) { یک فایل `assets/admin/hooks/useSubscription.ts` بساز: ```ts -import { useQuery } from '@tanstack/react-query'; -import { api } from '../lib/api'; -import { useAuthStore } from '../stores/authStore'; -import type { ApiResponse, MySubscriptionData } from '../types'; +import { useQuery } from "@tanstack/react-query"; +import { api } from "../lib/api"; +import { useAuthStore } from "../stores/authStore"; +import type { ApiResponse, MySubscriptionData } from "../types"; export function useSubscription() { - const primaryRole = useAuthStore((s) => s.primaryRole); - const enabled = primaryRole === 'doctor' || primaryRole === 'clinic' || primaryRole === 'secretary'; + const primaryRole = useAuthStore((s) => s.primaryRole); + const enabled = + primaryRole === "doctor" || + primaryRole === "clinic" || + primaryRole === "secretary"; - const { data } = useQuery>({ - queryKey: ['subscription-my'], - queryFn: () => api.get('/api/v1/subscription/my'), - enabled, - staleTime: 2 * 60 * 1000, - }); + const { data } = useQuery>({ + queryKey: ["subscription-my"], + queryFn: () => api.get("/api/v1/subscription/my"), + enabled, + staleTime: 2 * 60 * 1000, + }); - const sub = data?.data?.subscription ?? null; - const features: Record = sub?.plan?.features ?? {}; - const maxSecretaries: number = sub?.plan?.max_secretaries ?? 1; - const hasPlan = sub !== null; + const sub = data?.data?.subscription ?? null; + const features: Record = sub?.plan?.features ?? {}; + const maxSecretaries: number = sub?.plan?.max_secretaries ?? 1; + const hasPlan = sub !== null; - return { - subscription: sub, - hasFeature: (key: string) => hasPlan && (features[key] ?? false), - maxSecretaries, - hasPlan, - isExpiringSoon: (sub?.days_remaining ?? 0) > 0 && (sub?.days_remaining ?? 0) <= 7, - }; + return { + subscription: sub, + hasFeature: (key: string) => hasPlan && (features[key] ?? false), + maxSecretaries, + hasPlan, + isExpiringSoon: + (sub?.days_remaining ?? 0) > 0 && (sub?.days_remaining ?? 0) <= 7, + }; } ``` @@ -186,28 +220,30 @@ export function useSubscription() { در `Sidebar.tsx`: ```tsx -import { useSubscription } from '../../hooks/useSubscription'; +import { useSubscription } from "../../hooks/useSubscription"; // ... export default function Sidebar() { - const primaryRole = useAuthStore((s) => s.primaryRole); - const dbUuid = useAuthStore((s) => s.dbUuid); - const { hasFeature } = useSubscription(); - // ... + const primaryRole = useAuthStore((s) => s.primaryRole); + const dbUuid = useAuthStore((s) => s.dbUuid); + const { hasFeature } = useSubscription(); + // ... } ``` نوع `SectionItem` را گسترش بده: + ```ts type SectionItem = { - to: string; - icon: React.ElementType; - label: string; - feature?: string; // اگر تعریف شد، بدون آن feature → آیکون قفل + to: string; + icon: React.ElementType; + label: string; + feature?: string; // اگر تعریف شد، بدون آن feature → آیکون قفل }; ``` آیتم‌های محدود را با `feature` mark کن: + ```tsx { to: '/admin/my-patients', icon: FolderOpenIcon, label: 'پرونده بیماران', feature: 'patient_records' }, { to: '/admin/clinic-services',icon: WrenchScrewdriverIcon, label: 'سرویس‌ها', feature: 'services' }, @@ -215,18 +251,19 @@ type SectionItem = { ``` در render هر آیتم: + ```tsx const isLocked = item.feature ? !hasFeature(item.feature) : false; - - {item.label} - {isLocked && } - + + {item.label} + {isLocked && } +; ``` import کن: `import { LockClosedIcon } from '@heroicons/react/24/outline';` @@ -236,81 +273,96 @@ import کن: `import { LockClosedIcon } from '@heroicons/react/24/outline';` یک component کوچک `assets/admin/components/ui/FeatureGate.tsx` بساز: ```tsx -import React from 'react'; -import { Link } from 'react-router-dom'; -import { LockClosedIcon } from '@heroicons/react/24/outline'; -import { useSubscription } from '../../hooks/useSubscription'; +import React from "react"; +import { Link } from "react-router-dom"; +import { LockClosedIcon } from "@heroicons/react/24/outline"; +import { useSubscription } from "../../hooks/useSubscription"; interface FeatureGateProps { - feature: string; - children: React.ReactNode; + feature: string; + children: React.ReactNode; } export default function FeatureGate({ feature, children }: FeatureGateProps) { - const { hasFeature, hasPlan } = useSubscription(); + const { hasFeature, hasPlan } = useSubscription(); - if (hasFeature(feature)) return <>{children}; + if (hasFeature(feature)) return <>{children}; - return ( -
-
- -
-
-
- این قابلیت در پنل فعلی شما فعال نیست + return ( +
+
+ +
+
+
+ این قابلیت در پنل فعلی شما فعال نیست +
+
+ {hasPlan + ? "برای استفاده از این قابلیت پنل خود را ارتقاء دهید" + : "برای استفاده از این قابلیت یک پنل اشتراکی فعال کنید"} +
+
+ + {hasPlan ? "ارتقاء پنل" : "مشاهده پنل‌های اشتراکی"} +
-
- {hasPlan - ? 'برای استفاده از این قابلیت پنل خود را ارتقاء دهید' - : 'برای استفاده از این قابلیت یک پنل اشتراکی فعال کنید'} -
-
- - {hasPlan ? 'ارتقاء پنل' : 'مشاهده پنل‌های اشتراکی'} - -
- ); + ); } ``` ### ۵. Frontend — استفاده از FeatureGate در صفحات در **`MyPatientsPage.tsx`** محتوای اصلی را wrap کن: + ```tsx -import FeatureGate from '../components/ui/FeatureGate'; +import FeatureGate from "../components/ui/FeatureGate"; // ... return ( - - {/* کد فعلی صفحه */} - + {/* کد فعلی صفحه */} ); ``` در **`ClinicServicesPage.tsx`**: + ```tsx -return ( - - {/* کد فعلی */} - -); +return {/* کد فعلی */}; ``` در **`SmsWalletPage.tsx`**: + ```tsx -return ( - - {/* کد فعلی */} - -); +return {/* کد فعلی */}; ``` ### ۶. Frontend — محدودیت تعداد منشی در MySecretariesPage @@ -318,27 +370,34 @@ return ( در **`MySecretariesPage.tsx`** وقتی کاربر می‌خواهد منشی جدید اضافه کند: ```tsx -import { useSubscription } from '../hooks/useSubscription'; +import { useSubscription } from "../hooks/useSubscription"; // ... const { maxSecretaries } = useSubscription(); -// تعداد فعلی منشیان از data موجود -const activeCount = secretaries.filter(s => s.is_active).length; +// تعداد فعلی منشی ها از data موجود +const activeCount = secretaries.filter((s) => s.is_active).length; const isAtLimit = activeCount >= maxSecretaries; // روی دکمه «افزودن منشی»: -{isAtLimit && ( -
- برای افزودن منشی بیشتر پنل خود را ارتقاء دهید -
-)} + افزودن منشی +; +{ + isAtLimit && ( +
+ برای افزودن منشی بیشتر{" "} + پنل خود را ارتقاء دهید +
+ ); +} ``` --- diff --git a/.claude/prompt/ui-ux-pages-redesign.md b/.claude/prompt/ui-ux-pages-redesign.md index 32bb424e..64469652 100644 --- a/.claude/prompt/ui-ux-pages-redesign.md +++ b/.claude/prompt/ui-ux-pages-redesign.md @@ -10,41 +10,31 @@ ```css /* متغیرها */ ---primary: oklch(0.55 0.2 256) ---primary-subtle: oklch(0.97 0.02 256) ---surface: #fff ---border: oklch(0.9 0 0) ---text-1 / --text-2 / --text-3 ---r (border-radius base) ---gap / --card-pad - -/* کلاس‌های کاربردی */ -.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 /* آواتار دایره‌ای */ +--primary: oklch(0.55 0.2 256) --primary-subtle: oklch(0.97 0.02 256) + --surface: #fff --border: oklch(0.9 0 0) --text-1 / --text-2 / --text-3 --r + (border-radius base) --gap / --card-pad /* کلاس‌های کاربردی */ .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 صفحات:** `} />` ## فایل‌های مرتبط -| فایل | نقش | -|------|-----| -| `assets/admin/pages/MyPatientsPage.tsx` | پرونده بیماران — دو حالت: لیست + جزئیات بیمار | -| `assets/admin/pages/StaffPage.tsx` | مدیریت پرسنل — CRUD | -| `assets/admin/pages/MySecretariesPage.tsx` | مدیریت منشیان — CRUD + permissions matrix | -| `assets/admin/pages/ClinicServicesPage.tsx` | سرویس‌های کلینیک — دو پنل: بخش‌ها + آیتم‌ها | -| `assets/admin/pages/SmsWalletPage.tsx` | کیف پول پیامک — balance + settings + logs | -| `assets/admin/styles.css` | سیستم CSS کامل | -| `assets/admin/pages/SubscriptionPage.tsx` | **مرجع طراحی** — بازطراحی‌شده با gradient + progress bar | +| فایل | نقش | +| ------------------------------------------- | -------------------------------------------------------- | +| `assets/admin/pages/MyPatientsPage.tsx` | پرونده بیماران — دو حالت: لیست + جزئیات بیمار | +| `assets/admin/pages/StaffPage.tsx` | مدیریت پرسنل — CRUD | +| `assets/admin/pages/MySecretariesPage.tsx` | مدیریت منشی ها — CRUD + permissions matrix | +| `assets/admin/pages/ClinicServicesPage.tsx` | سرویس‌های کلینیک — دو پنل: بخش‌ها + آیتم‌ها | +| `assets/admin/pages/SmsWalletPage.tsx` | کیف پول پیامک — balance + settings + logs | +| `assets/admin/styles.css` | سیستم CSS کامل | +| `assets/admin/pages/SubscriptionPage.tsx` | **مرجع طراحی** — بازطراحی‌شده با gradient + progress bar | ## وضعیت فعلی هر صفحه @@ -173,6 +163,7 @@ PermissionsMatrix: جدول checkbox با ۶ ستون برای ۴ بخش — د **هدف:** لیست با identity واضح + پرونده بیمار با مینی‌داشبورد. **الف — حالت لیست بیماران:** + - Header اختصاصی: `` با description «مراجعه‌کنندگان ثبت‌شده شما» - Search bar را با یک wrapper card جداگانه به یک toolbar تبدیل کن (نه داخل card جدول) - `DataTable` را با ردیف‌های بهتر جایگزین کن: هر ردیف آواتار gradient + نام بولد + شماره با آیکون تلفن + تاریخ ثبت شمسی @@ -180,15 +171,17 @@ PermissionsMatrix: جدول checkbox با ۶ ستون برای ۴ بخش — د - وقتی بیماری نیست: Empty State با آیکون بزرگ + متن راهنما **ب — حالت پرونده بیمار (selectedRecord):** + - PageHeader با نام بیمار + شماره + تاریخ ثبت - یک بنر/info-card بالای صفحه: نام، تلفن، تعداد مراجعات (از `totalSes`) - SessionRow باید بهتر شود — هر مراجعه یک card مستقل با: - - سربرگ: تاریخ شمسی + روش پرداخت به عنوان badge - - بدنه: قیمت ویزیت + تخفیف بیمه + جمع خدمات + قیمت نهایی در یک `.grid-2` یا flex row - - footer: لیست خدمات (services) اگر وجود داشت - - دکمه «ویرایش» در گوشه بالا سمت چپ کارت + - سربرگ: تاریخ شمسی + روش پرداخت به عنوان badge + - بدنه: قیمت ویزیت + تخفیف بیمه + جمع خدمات + قیمت نهایی در یک `.grid-2` یا flex row + - footer: لیست خدمات (services) اگر وجود داشت + - دکمه «ویرایش» در گوشه بالا سمت چپ کارت **ج — Modal ثبت مراجعه:** + - سه فیلد قیمت/بیمه پایه/بیمه تکمیلی را در یک `.grid-2` یا grid سه‌ستونه گذاشتن خوب است — این کافیست - بخش «خدمات» را با یک separator بصری از فیلدهای اصلی جدا کن - لیست خدمات انتخاب‌شده را با badge یا chip نشان بده (نه فقط متن) @@ -202,20 +195,23 @@ PermissionsMatrix: جدول checkbox با ۶ ستون برای ۴ بخش — د **هدف:** صفحه‌ای با KPI bar + جدول بهتر + فرم واضح‌تر. **الف — KPI bar:** + - بالای جدول، یک `.stat-grid` با ۳ card: تعداد پرسنل کل، تعداد فعال، تعداد غیرفعال - داده از همان `staff` query محاسبه شود (نه API جدید): - ```ts - const total = staff.length; - const active = staff.filter((s) => s.active).length; - const inactive = total - active; - ``` + ```ts + const total = staff.length; + const active = staff.filter((s) => s.active).length; + const inactive = total - active; + ``` **ب — جدول بهتر:** + - ستون «نام» را بهبود بده: `full_name` + `job_title` زیرش (مثل MyPatientsPage) - ستون «تلفن» و «کد ملی» را در موبایل پنهان کن (CSS media query اگر لازم بود) - ردیف‌های hover state با background تغییر کند **ج — فرم StaffFormFields:** + - فیلدهای `full_name` و `job_title` را در یک ردیف (`.grid-2`) قرار بده - فیلدهای `phone` و `national_code` را در یک ردیف دیگر - فیلد `address` تنها بماند (textarea یا input تمام عرض) @@ -226,7 +222,8 @@ PermissionsMatrix: جدول checkbox با ۶ ستون برای ۴ بخش — د **هدف:** جدول دستی را به DataTable شیک تبدیل کن + PermissionsMatrix بصری‌تر + Empty State جذاب. -**الف — جدول منشیان:** +**الف — جدول منشی ها:** + - جدول دستی را **حذف کن** و از `DataTable` component استفاده کن - ستون «نام»: آواتار کوچک (initial از `user_name`) + نام بولد - ستون «موبایل»: با `maskMobile()` و `dir="ltr"` + آیکون تلفن @@ -235,21 +232,32 @@ PermissionsMatrix: جدول checkbox با ۶ ستون برای ۴ بخش — د - ستون «عملیات»: دو دکمه `sm` (ویرایش دسترسی‌ها + غیرفعال‌سازی) **ب — Empty State:** + - وقتی `secretaries.length === 0`: - ```tsx -
- -
هنوز منشی‌ای اضافه نشده
-
- منشی می‌تواند نوبت‌ها و اطلاعات کلینیک را مدیریت کند + ```tsx +
+ +
+ هنوز منشی‌ای اضافه نشده +
+
+ منشی می‌تواند نوبت‌ها و اطلاعات کلینیک را مدیریت کند +
+
- -
- ``` + ``` **ج — PermissionsMatrix:** + - ردیف‌های جدول با رنگ متناوب (striped) یا hover state - checkbox‌ها با `accent-color: var(--primary)` (قبلاً بود، نگه دار) - header ستون‌ها را bold و با background کم‌رنگ کن @@ -262,38 +270,49 @@ PermissionsMatrix: جدول checkbox با ۶ ستون برای ۴ بخش — د **هدف:** پنل بخش‌ها بهتر + جدول آیتم‌ها با badge فعال/غیرفعال. **الف — پنل بخش‌ها (ستون ۲۸۰px):** + - هر بخش یک item با: - - آیکون `WrenchScrewdriverIcon` در سمت راست (color از theme) - - نام بخش bold - - badge «فعال» / «غیرفعال» بر اساس `s.active` - - دکمه‌های edit/delete فقط هنگام hover نشان داده شوند (opacity transition) + - آیکون `WrenchScrewdriverIcon` در سمت راست (color از theme) + - نام بخش bold + - badge «فعال» / «غیرفعال» بر اساس `s.active` + - دکمه‌های edit/delete فقط هنگام hover نشان داده شوند (opacity transition) - انتخاب شده: border `var(--primary)` + background `var(--primary-subtle)` - افزودن بخش: دکمه با آیکون + متن «بخش جدید» (نه فقط آیکون) **ب — پنل آیتم‌ها (ستون 1fr):** + - بالای جدول یک toolbar: نام بخش به عنوان breadcrumb + تعداد آیتم‌ها + دکمه «سرویس جدید» - هر row آیتم: - - badge «فعال» / «غیرفعال» برای `item.active` - - قیمت با `formatRial()` و رنگ primary - - نام پرسنل با avatar خیلی کوچک اگر وجود داشت + - badge «فعال» / «غیرفعال» برای `item.active` + - قیمت با `formatRial()` و رنگ primary + - نام پرسنل با avatar خیلی کوچک اگر وجود داشت - Empty state وقتی `items.length === 0`: - ```tsx -
- - سرویسی در این بخش وجود ندارد -
- -
- ``` + ```tsx +
+ + سرویسی در این بخش وجود ندارد +
+ +
+ ``` **ج — State اولیه (هیچ بخشی انتخاب نشده):** + - جای پیام «یک بخش را از سمت راست انتخاب کنید» یک آنیمیشن ساده یا placeholder card با رنگ dashed border قرار بده: - ```tsx -
- -
یک بخش انتخاب کنید تا سرویس‌های آن را ببینید
-
- ``` + ```tsx +
+ +
یک بخش انتخاب کنید تا سرویس‌های آن را ببینید
+
+ ``` --- @@ -302,6 +321,7 @@ PermissionsMatrix: جدول checkbox با ۶ ستون برای ۴ بخش — د **هدف:** کارت موجودی با visual hierarchy قوی + تنظیمات واضح‌تر + جدول تراکنش‌ها با visual type indicator. **الف — کارت موجودی:** + - gradient background (مثل SubscriptionPage): `background: linear-gradient(135deg, oklch(0.52 0.22 256), oklch(0.40 0.18 256))` - رنگ متن: white - موجودی را بزرگ‌تر کن: `fontSize: 36, fontWeight: 800` @@ -310,48 +330,55 @@ PermissionsMatrix: جدول checkbox با ۶ ستون برای ۴ بخش — د - دکمه شارژ: white با primary text (برعکس از theme معمول) **ب — کارت تنظیمات:** + - عنوان با divider پایین - هر toggle را با یک card داخلی wrapper کن: - ```tsx -
- -
-
یادآوری قبل از نوبت
-
ارسال خودکار چند ساعت قبل از نوبت
+ ```tsx +
+ +
+
یادآوری قبل از نوبت
+
ارسال خودکار چند ساعت قبل از نوبت
+
-
- ``` + ``` - input «چند ساعت قبل» را با border و padding واضح‌تر style کن **ج — جدول تراکنش‌ها:** + - ستون «نوع» را با یک آیکون بهبود بده: - - `credit`: `ArrowUpCircleIcon` با رنگ سبز - - `debit`: `ArrowDownCircleIcon` با رنگ قرمز + - `credit`: `ArrowUpCircleIcon` با رنگ سبز + - `debit`: `ArrowDownCircleIcon` با رنگ قرمز - ستون «مبلغ» را بر اساس نوع رنگ بده: credit → سبز، debit → قرمز - header table با background خاکستری کم‌رنگ **د — Modal شارژ:** + - انتخاب gateway را به radio card تبدیل کن (مثل SubscriptionPage): - ```tsx - {(['mellat', 'sep'] as const).map((gw) => ( -
setGateway(gw)} - style={{ - border: `2px solid ${gateway === gw ? 'var(--primary)' : 'var(--border)'}`, - borderRadius: 8, - padding: '10px 14px', - cursor: 'pointer', - background: gateway === gw ? 'var(--primary-subtle)' : '', - transition: 'all 0.15s', - flex: 1, - textAlign: 'center', - }} - > -
{gw === 'mellat' ? 'بانک ملت' : 'سپ'}
-
- ))} - ``` + ```tsx + { + (["mellat", "sep"] as const).map((gw) => ( +
setGateway(gw)} + style={{ + border: `2px solid ${gateway === gw ? "var(--primary)" : "var(--border)"}`, + borderRadius: 8, + padding: "10px 14px", + cursor: "pointer", + background: gateway === gw ? "var(--primary-subtle)" : "", + transition: "all 0.15s", + flex: 1, + textAlign: "center", + }} + > +
+ {gw === "mellat" ? "بانک ملت" : "سپ"} +
+
+ )); + } + ``` - پیش‌نمایش مبلغ: وقتی کاربر عدد می‌زند، زیر input بنویس «پرداخت X ریال از طریق Y» ## نکات مهم @@ -364,5 +391,5 @@ PermissionsMatrix: جدول checkbox با ۶ ستون برای ۴ بخش — د 6. **SubscriptionPage** را به عنوان مرجع visual نگه دار — gradient، progress bar، و card layout آن مدل است 7. **بعد از هر صفحه** `ddev exec yarn dev` اجرا کن تا TypeScript error نداشته باشد 8. **SessionRow** در MyPatientsPage: قبل از ویرایش، آن component را کامل بخوان (احتمالاً inline در همان فایل است) -9. **`maskMobile()`** از `../lib/utils` — برای نمایش شماره موبایل منشیان استفاده کن +9. **`maskMobile()`** از `../lib/utils` — برای نمایش شماره موبایل منشی ها استفاده کن 10. **`formatDate()` و `formatDateTime()`** — همه تاریخ‌ها شمسی نمایش داده شوند diff --git a/TEST_USERS.md b/TEST_USERS.md index 1114e2be..881e355d 100644 --- a/TEST_USERS.md +++ b/TEST_USERS.md @@ -2,6 +2,8 @@ **پنل ادمین:** https://clinic-pro.ddev.site/admin +> رمز عبور همه کاربران seed‌شده: `Test@1234` + --- ## ادمین @@ -14,65 +16,93 @@ --- -## کاربران Multi-Context - -### کاربر ۱ — دکتر در چند کلینیک - -| فیلد | مقدار | -| -------- | -------------------------- | -| موبایل | `09100000011` | -| پسورد | `Test1234` | -| نقش | `ROLE_DOCTOR` | -| Contexts | مطب شخصی دکتر آرمان رضایی | -| | کلینیک جامع حضرت علی (عضو) | -| | بیمارستان ابن سینا (عضو) | - ---- - -### کاربر ۲ — صاحب کلینیک +## کلینیک نمونه — تبریز | فیلد | مقدار | | ----------- | -------------------------------------- | -| موبایل | `09100000022` | -| پسورد | `Test1234` | +| موبایل | `09100100000` | +| پسورد | `Test@1234` | | نقش | `ROLE_CLINIC` | -| Contexts | کلینیک تست پگاه | -| UUID کلینیک | `0730373f-7c0a-4610-8120-d58ecf4a9916` | +| نام | کلینیک تخصصی امید تبریز | +| UUID کلینیک | `9ac73318-d313-4772-bf1e-418d47f8f4bc` | +| شهر | تبریز (id: 101) | --- -### کاربر ۳ — منشی - -| فیلد | مقدار | -| -------- | ---------------------- | -| موبایل | `09100000033` | -| پسورد | `Test1234` | -| نقش | `ROLE_SECRETARY` | -| Contexts | مطب دکتر مهناز سلیمانی | - ---- - -### کاربر ۴ — دکتر + صاحب کلینیک +## دکتر نمونه کامل — تبریز | فیلد | مقدار | | ----------- | -------------------------------------- | -| موبایل | `09100000044` | -| پسورد | `Test1234` | -| نقش | `ROLE_DOCTOR` + `ROLE_CLINIC` | -| Contexts | مطب شخصی دکتر سامان علوی | -| | کلینیک تخصصی علوی | -| UUID کلینیک | `26a1ff95-32aa-4a96-b0fd-03e75e810be9` | +| موبایل | `09100100001` | +| پسورد | `Test@1234` | +| نقش | `ROLE_DOCTOR` | +| نام | دکتر آرمان رضایی | +| تخصص | قلب و عروق + داخلی | +| 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 -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 +``` diff --git a/assets/admin/components/layout/Sidebar.tsx b/assets/admin/components/layout/Sidebar.tsx index 1a13e7f8..8b7ad506 100644 --- a/assets/admin/components/layout/Sidebar.tsx +++ b/assets/admin/components/layout/Sidebar.tsx @@ -3,155 +3,308 @@ import { ArrowsRightLeftIcon, BanknotesIcon, BuildingOffice2Icon, - ClipboardDocumentCheckIcon, - Cog6ToothIcon, CalendarDaysIcon, ChartBarIcon, ChatBubbleLeftEllipsisIcon, + ClipboardDocumentCheckIcon, + Cog6ToothIcon, CreditCardIcon, DevicePhoneMobileIcon, DocumentTextIcon, + FolderOpenIcon, HeartIcon, + IdentificationIcon, KeyIcon, LockClosedIcon, StarIcon, TagIcon, UserCircleIcon, UserGroupIcon, - UsersIcon, UserPlusIcon, + UsersIcon, WrenchScrewdriverIcon, - FolderOpenIcon, - IdentificationIcon, } from "@heroicons/react/24/outline"; import { NavLink, useNavigate } from "react-router-dom"; +import { useSubscription } from "../../hooks/useSubscription"; import { useAuthStore } from "../../stores/authStore"; 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[] }; -function buildSections(primaryRole: string | null, dbUuid: string | null): Section[] { - if (primaryRole === 'admin') { +function buildSections( + primaryRole: string | null, + dbUuid: string | null, +): Section[] { + if (primaryRole === "admin") { return [ { - label: 'عمومی', + label: "عمومی", items: [ - { to: '/admin/dashboard', icon: ChartBarIcon, label: 'داشبورد' }, - { to: '/admin/users', icon: UserGroupIcon, label: 'کاربران' }, - { to: '/admin/doctors', icon: HeartIcon, label: 'پزشکان' }, - { to: '/admin/clinics', icon: BuildingOffice2Icon, label: 'کلینیک‌ها' }, + { + to: "/admin/dashboard", + icon: ChartBarIcon, + label: "داشبورد", + }, + { + to: "/admin/users", + icon: UserGroupIcon, + label: "کاربران", + }, + { to: "/admin/doctors", icon: HeartIcon, label: "پزشکان" }, + { + to: "/admin/clinics", + icon: BuildingOffice2Icon, + label: "کلینیک‌ها", + }, ], }, { - label: 'مدیریت', + label: "مدیریت", items: [ - { to: '/admin/appointments', icon: CalendarDaysIcon, label: 'نوبت‌ها' }, - { to: '/admin/payments', icon: CreditCardIcon, label: 'پرداخت‌ها' }, - { to: '/admin/settlements', icon: BanknotesIcon, label: 'تسویه‌حساب' }, - { to: '/admin/pre-registrations', icon: ClipboardDocumentCheckIcon, label: 'درخواست‌های ثبت‌نام' }, + { + to: "/admin/appointments", + icon: CalendarDaysIcon, + label: "نوبت‌ها", + }, + { + to: "/admin/payments", + icon: CreditCardIcon, + label: "پرداخت‌ها", + }, + { + to: "/admin/settlements", + icon: BanknotesIcon, + label: "تسویه‌حساب", + }, + { + to: "/admin/pre-registrations", + icon: ClipboardDocumentCheckIcon, + label: "درخواست‌های ثبت‌نام", + }, ], }, { - label: 'محتوا', + label: "محتوا", items: [ - { to: '/admin/comments', icon: ChatBubbleLeftEllipsisIcon, label: 'نظرات' }, - { to: '/admin/ratings', icon: StarIcon, label: 'امتیازها' }, - { to: '/admin/blogs', icon: DocumentTextIcon, label: 'بلاگ' }, - { to: '/admin/sms', icon: DevicePhoneMobileIcon, label: 'پیامک' }, + { + to: "/admin/comments", + icon: ChatBubbleLeftEllipsisIcon, + label: "نظرات", + }, + { to: "/admin/ratings", icon: StarIcon, label: "امتیازها" }, + { + to: "/admin/blogs", + icon: DocumentTextIcon, + label: "بلاگ", + }, + { + to: "/admin/sms", + icon: DevicePhoneMobileIcon, + label: "پیامک", + }, ], }, { - label: 'سیستم', + label: "سیستم", items: [ - { to: '/admin/categories', icon: TagIcon, 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: 'تنظیمات' }, + { + to: "/admin/categories", + icon: TagIcon, + 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') { - const clinicTo = dbUuid ? `/admin/clinics/${dbUuid}` : '/admin/my-clinic'; + if (primaryRole === "clinic") { + const clinicTo = dbUuid + ? `/admin/clinics/${dbUuid}` + : "/admin/my-clinic"; return [ { - label: 'عمومی', + label: "عمومی", 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: [ - { to: '/admin/appointments', icon: CalendarDaysIcon, label: 'نوبت‌ها' }, - { 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' }, + { + to: "/admin/appointments", + icon: CalendarDaysIcon, + label: "نوبت‌ها", + }, + { + 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: [ - { to: '/admin/subscription', icon: CreditCardIcon, label: 'پنل اشتراکی' }, + { + to: "/admin/subscription", + icon: CreditCardIcon, + label: "پنل اشتراکی", + }, ], }, ]; } - if (primaryRole === 'doctor') { + if (primaryRole === "doctor") { return [ { - label: 'عمومی', + label: "عمومی", items: [ - { to: '/admin/dashboard', icon: ChartBarIcon, label: 'داشبورد' }, + { + to: "/admin/dashboard", + icon: ChartBarIcon, + label: "داشبورد", + }, ], }, { - label: 'پروفایل', + label: "پروفایل", items: [ - { to: '/admin/profile', icon: UserCircleIcon, label: 'پروفایل من' }, + { + to: "/admin/profile", + icon: UserCircleIcon, + label: "پروفایل من", + }, ], }, { - label: 'مدیریت', + label: "مدیریت", items: [ - { to: '/admin/appointments', icon: CalendarDaysIcon, label: 'نوبت‌های من' }, - { 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' }, + { + to: "/admin/appointments", + icon: CalendarDaysIcon, + label: "نوبت‌های من", + }, + { + 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: [ - { to: '/admin/subscription', icon: CreditCardIcon, label: 'پنل اشتراکی' }, + { + to: "/admin/subscription", + icon: CreditCardIcon, + label: "پنل اشتراکی", + }, ], }, ]; } - if (primaryRole === 'secretary') { + if (primaryRole === "secretary") { return [ { - label: 'عمومی', + label: "عمومی", items: [ - { to: '/admin/dashboard', icon: ChartBarIcon, label: 'داشبورد' }, + { + to: "/admin/dashboard", + icon: ChartBarIcon, + label: "داشبورد", + }, ], }, { - label: 'مدیریت', + label: "مدیریت", 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 [ { - label: 'عمومی', - items: [{ to: '/admin/dashboard', icon: ChartBarIcon, label: 'داشبورد' }], + label: "عمومی", + items: [ + { + to: "/admin/dashboard", + icon: ChartBarIcon, + label: "داشبورد", + }, + ], }, ]; } const ROLE_LABELS: Record = { - admin: 'مدیر کل', - clinic: 'مالک کلینیک', - doctor: 'پزشک', - secretary: 'منشی', - user: 'کاربر', + admin: "مدیر کل", + clinic: "مالک کلینیک", + doctor: "پزشک", + secretary: "منشی", + user: "کاربر", }; const HUES = [256, 205, 162, 295, 272]; @@ -186,13 +345,14 @@ interface Props { } export default function Sidebar({ mobileOpen: _m, onMobileClose: _c }: Props) { - const sidebarOpen = useUiStore((s) => s.sidebarOpen); - const { logout, primaryRole, userName, availableContexts, dbUuid } = useAuthStore(); + const sidebarOpen = useUiStore((s) => s.sidebarOpen); + const { logout, primaryRole, userName, availableContexts, dbUuid } = + useAuthStore(); const { hasFeature } = useSubscription(); const navigate = useNavigate(); const sections = buildSections(primaryRole, dbUuid); - const initials = (userName ?? 'U').charAt(0).toUpperCase(); + const initials = (userName ?? "U").charAt(0).toUpperCase(); return (
@@ -252,20 +449,37 @@ export default function Sidebar({ mobileOpen: _m, onMobileClose: _c }: Props) {
{ logout(); navigate('/admin/login'); }} + onClick={() => { + logout(); + navigate("/admin/login"); + }} title="خروج از سیستم" >
{initials}
- {userName ?? 'کاربر'} - {ROLE_LABELS[primaryRole ?? ''] ?? primaryRole ?? ''} + {userName ?? "کاربر"} + + {ROLE_LABELS[primaryRole ?? ""] ?? + primaryRole ?? + ""} +
- +
diff --git a/assets/admin/pages/MySecretariesPage.tsx b/assets/admin/pages/MySecretariesPage.tsx index 2755b617..814c5fa7 100644 --- a/assets/admin/pages/MySecretariesPage.tsx +++ b/assets/admin/pages/MySecretariesPage.tsx @@ -1,500 +1,811 @@ -import React, { useState } from 'react'; -import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; -import { PlusIcon, PencilIcon, NoSymbolIcon, CheckCircleIcon, IdentificationIcon, PhoneIcon } from '@heroicons/react/24/outline'; -import { useForm } from 'react-hook-form'; -import { zodResolver } from '@hookform/resolvers/zod'; -import { z } from 'zod'; -import { toast } from 'sonner'; -import { api } from '../lib/api'; -import type { ApiResponse } from '../lib/api'; -import type { Secretary, SecretaryPermissions } from '../types'; -import { formatDate, maskMobile } from '../lib/utils'; -import { ActiveBadge } from '../components/ui/StatusBadge'; -import DataTable, { type Column } from '../components/ui/DataTable'; -import Modal from '../components/ui/Modal'; -import ConfirmDialog from '../components/ui/ConfirmDialog'; -import PageHeader from '../components/ui/PageHeader'; -import { useAuthStore } from '../stores/authStore'; -import { useSubscription } from '../hooks/useSubscription'; -import { Link } from 'react-router-dom'; +import { + CheckCircleIcon, + IdentificationIcon, + NoSymbolIcon, + PencilIcon, + PhoneIcon, + PlusIcon, +} from "@heroicons/react/24/outline"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useState } from "react"; +import { useForm } from "react-hook-form"; +import { Link } from "react-router-dom"; +import { toast } from "sonner"; +import { z } from "zod"; +import ConfirmDialog from "../components/ui/ConfirmDialog"; +import DataTable, { type Column } from "../components/ui/DataTable"; +import Modal from "../components/ui/Modal"; +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 ────────────────────────────────────────── const DEFAULT_PERMISSIONS: SecretaryPermissions = { - appointments: { view: true, create: false, cancel: false, update_status: false }, - addresses: { view: true, create: false, update: false, delete: false }, - clinic_info: { view: true, update: false }, - insurances: { view: true, create: false, update: false, delete: false }, + appointments: { + view: true, + create: false, + cancel: false, + update_status: false, + }, + addresses: { view: true, create: false, update: false, delete: false }, + clinic_info: { view: true, update: false }, + insurances: { view: true, create: false, update: false, delete: false }, }; type PermSection = keyof SecretaryPermissions; -const PERMISSION_LABELS: Record = { - appointments: { - label: 'نوبت‌ها', - actions: [ - { key: 'view', label: 'مشاهده' }, - { key: 'create', label: 'ایجاد' }, - { key: 'cancel', label: 'لغو' }, - { key: 'update_status', label: 'تغییر وضعیت' }, - ], - }, - addresses: { - label: 'آدرس‌ها', - actions: [ - { key: 'view', label: 'مشاهده' }, - { key: 'create', label: 'ایجاد' }, - { key: 'update', label: 'ویرایش' }, - { key: 'delete', label: 'حذف' }, - ], - }, - clinic_info: { - label: 'اطلاعات کلینیک', - actions: [ - { key: 'view', label: 'مشاهده' }, - { key: 'update', label: 'ویرایش' }, - ], - }, - insurances: { - label: 'بیمه‌ها', - actions: [ - { key: 'view', label: 'مشاهده' }, - { key: 'create', label: 'ایجاد' }, - { key: 'update', label: 'ویرایش' }, - { key: 'delete', label: 'حذف' }, - ], - }, +const PERMISSION_LABELS: Record< + PermSection, + { label: string; actions: { key: string; label: string }[] } +> = { + appointments: { + label: "نوبت‌ها", + actions: [ + { key: "view", label: "مشاهده" }, + { key: "create", label: "ایجاد" }, + { key: "cancel", label: "لغو" }, + { key: "update_status", label: "تغییر وضعیت" }, + ], + }, + addresses: { + label: "آدرس‌ها", + actions: [ + { key: "view", label: "مشاهده" }, + { key: "create", label: "ایجاد" }, + { key: "update", label: "ویرایش" }, + { key: "delete", label: "حذف" }, + ], + }, + clinic_info: { + label: "اطلاعات کلینیک", + actions: [ + { key: "view", label: "مشاهده" }, + { key: "update", label: "ویرایش" }, + ], + }, + insurances: { + label: "بیمه‌ها", + actions: [ + { key: "view", label: "مشاهده" }, + { key: "create", label: "ایجاد" }, + { key: "update", label: "ویرایش" }, + { key: "delete", label: "حذف" }, + ], + }, }; -const ALL_ACTIONS = ['view', 'create', 'update', 'delete', 'cancel', 'update_status']; -const ACTION_HEADERS = ['مشاهده', 'ایجاد', 'ویرایش', 'حذف', 'لغو', 'تغییر وضعیت']; +const ALL_ACTIONS = [ + "view", + "create", + "update", + "delete", + "cancel", + "update_status", +]; +const ACTION_HEADERS = [ + "مشاهده", + "ایجاد", + "ویرایش", + "حذف", + "لغو", + "تغییر وضعیت", +]; function PermissionsMatrix({ - permissions, - onChange, + permissions, + onChange, }: { - permissions: SecretaryPermissions; - onChange: (p: SecretaryPermissions) => void; + permissions: SecretaryPermissions; + onChange: (p: SecretaryPermissions) => void; }) { - const toggle = (section: PermSection, action: string) => { - const cur = (permissions[section] as Record)[action]; - onChange({ - ...permissions, - [section]: { ...(permissions[section] as Record), [action]: !cur }, - }); - }; + const toggle = (section: PermSection, action: string) => { + const cur = (permissions[section] as Record)[action]; + onChange({ + ...permissions, + [section]: { + ...(permissions[section] as Record), + [action]: !cur, + }, + }); + }; - return ( -
- - - - - {ACTION_HEADERS.map((h) => ( - - ))} - - - - {(Object.keys(PERMISSION_LABELS) as PermSection[]).map((section, idx) => { - const config = PERMISSION_LABELS[section]; - const sectionPrm = permissions[section] as Record; - return ( - - - {ALL_ACTIONS.map((action) => { - const ac = config.actions.find((a) => a.key === action); - if (!ac) return ( - - ); - return ( - - ); - })} - - ); - })} - -
بخش{h}
- {config.label} - - toggle(section, action)} - style={{ width: 16, height: 16, accentColor: 'var(--primary)', cursor: 'pointer' }} - /> -
-
- ); + return ( +
+ + + + + {ACTION_HEADERS.map((h) => ( + + ))} + + + + {(Object.keys(PERMISSION_LABELS) as PermSection[]).map( + (section, idx) => { + const config = PERMISSION_LABELS[section]; + const sectionPrm = permissions[section] as Record< + string, + boolean + >; + return ( + + + {ALL_ACTIONS.map((action) => { + const ac = config.actions.find( + (a) => a.key === action, + ); + if (!ac) + return ( + + ); + return ( + + ); + })} + + ); + }, + )} + +
+ بخش + + {h} +
+ {config.label} + + — + + + toggle(section, action) + } + style={{ + width: 16, + height: 16, + accentColor: + "var(--primary)", + cursor: "pointer", + }} + /> +
+
+ ); } // ── Create form schema ───────────────────────────────────────────────────── const createSchema = z.object({ - name: z.string().min(2, 'نام الزامی است'), - mobile_number: z.string().regex(/^09[0-9]{9}$/, 'شماره موبایل معتبر نیست'), + name: z.string().min(2, "نام الزامی است"), + mobile_number: z.string().regex(/^09[0-9]{9}$/, "شماره موبایل معتبر نیست"), }); type CreateForm = z.infer; -interface ClinicDoctor { uuid: string; name: string; } +interface ClinicDoctor { + uuid: string; + name: string; +} // ── Main component ───────────────────────────────────────────────────────── export default function MySecretariesPage() { - const qc = useQueryClient(); - const { doctorUuid, dbUuid, primaryRole } = useAuthStore(); + const qc = useQueryClient(); + const { doctorUuid, dbUuid, primaryRole } = useAuthStore(); - const isClinic = primaryRole === 'clinic'; - const { maxSecretaries } = useSubscription(); + const isClinic = primaryRole === "clinic"; + const { maxSecretaries } = useSubscription(); - // for clinic: selected doctor to add secretary for - const [selectedDoctorUuid, setSelectedDoctorUuid] = useState(''); + // for clinic: selected doctor to add secretary for + const [selectedDoctorUuid, setSelectedDoctorUuid] = useState(""); - const activeDoctorUuid = isClinic ? selectedDoctorUuid : (doctorUuid ?? ''); + const activeDoctorUuid = isClinic ? selectedDoctorUuid : (doctorUuid ?? ""); - const [createOpen, setCreateOpen] = useState(false); - const [editTarget, setEditTarget] = useState(null); - const [editPerms, setEditPerms] = useState(DEFAULT_PERMISSIONS); - const [deleteTarget, setDeleteTarget] = useState(null); + const [createOpen, setCreateOpen] = useState(false); + const [editTarget, setEditTarget] = useState(null); + const [editPerms, setEditPerms] = + useState(DEFAULT_PERMISSIONS); + const [deleteTarget, setDeleteTarget] = useState(null); - // clinic: load clinic's doctors - const { data: clinicDoctorsData, isLoading: clinicDoctorsLoading } = useQuery>({ - queryKey: ['clinic-doctors', dbUuid], - queryFn: () => api.get(`/api/v1/clinic/doctor-list/${dbUuid}`), - enabled: isClinic && !!dbUuid, - }); - const clinicDoctors: ClinicDoctor[] = clinicDoctorsData?.data?.data ?? []; + // clinic: load clinic's doctors + const { data: clinicDoctorsData, isLoading: clinicDoctorsLoading } = + useQuery>({ + queryKey: ["clinic-doctors", dbUuid], + queryFn: () => api.get(`/api/v1/clinic/doctor-list/${dbUuid}`), + enabled: isClinic && !!dbUuid, + }); + const clinicDoctors: ClinicDoctor[] = clinicDoctorsData?.data?.data ?? []; - // clinic: load ALL secretaries across all its doctors - const { data: clinicSecrData, isLoading: clinicSecrLoading } = useQuery>({ - queryKey: ['my-secretaries-clinic', dbUuid], - queryFn: () => api.get(`/api/v1/secretaries/clinic/${dbUuid}`), - enabled: isClinic && !!dbUuid, - }); + // clinic: load ALL secretaries across all its doctors + const { data: clinicSecrData, isLoading: clinicSecrLoading } = useQuery< + ApiResponse + >({ + queryKey: ["my-secretaries-clinic", dbUuid], + queryFn: () => api.get(`/api/v1/secretaries/clinic/${dbUuid}`), + enabled: isClinic && !!dbUuid, + }); - // doctor: load secretaries for the doctor - const { data: doctorSecrData, isLoading: doctorSecrLoading } = useQuery>({ - queryKey: ['my-secretaries', activeDoctorUuid], - queryFn: () => api.get(`/api/v1/secretaries/${activeDoctorUuid}`), - enabled: !isClinic && !!activeDoctorUuid, - }); + // doctor: load secretaries for the doctor + const { data: doctorSecrData, isLoading: doctorSecrLoading } = useQuery< + ApiResponse + >({ + queryKey: ["my-secretaries", activeDoctorUuid], + queryFn: () => api.get(`/api/v1/secretaries/${activeDoctorUuid}`), + enabled: !isClinic && !!activeDoctorUuid, + }); - const secretaries = isClinic - ? (clinicSecrData?.data ?? []) - : (doctorSecrData?.data ?? []); - const activeSecretaryCount = secretaries.filter((s) => s.is_active).length; - const isLoading = isClinic ? clinicSecrLoading : doctorSecrLoading; + const secretaries = isClinic + ? (clinicSecrData?.data ?? []) + : (doctorSecrData?.data ?? []); + const activeSecretaryCount = secretaries.filter((s) => s.is_active).length; + const isLoading = isClinic ? clinicSecrLoading : doctorSecrLoading; - const createForm = useForm({ resolver: zodResolver(createSchema) }); + const createForm = useForm({ + resolver: zodResolver(createSchema), + }); - const createMutation = useMutation({ - mutationFn: (body: CreateForm) => - api.post('/api/v1/secretary', { ...body, doctor_uuid: activeDoctorUuid }), - onSuccess: () => { - toast.success('منشی اضافه شد'); - setCreateOpen(false); - createForm.reset(); - qc.invalidateQueries({ queryKey: ['my-secretaries'] }); - qc.invalidateQueries({ queryKey: ['my-secretaries-clinic'] }); - }, - onError: (e: any) => toast.error(e.message), - }); + const createMutation = useMutation({ + mutationFn: (body: CreateForm) => + api.post("/api/v1/secretary", { + ...body, + doctor_uuid: activeDoctorUuid, + }), + onSuccess: () => { + toast.success("منشی اضافه شد"); + setCreateOpen(false); + createForm.reset(); + qc.invalidateQueries({ queryKey: ["my-secretaries"] }); + qc.invalidateQueries({ queryKey: ["my-secretaries-clinic"] }); + }, + onError: (e: any) => toast.error(e.message), + }); - const updatePermsMutation = useMutation({ - mutationFn: ({ uuid, permissions }: { uuid: string; permissions: SecretaryPermissions }) => - api.patch(`/api/v1/secretary/${uuid}`, { permissions: { version: 1, resources: permissions } }), - onSuccess: () => { - toast.success('دسترسی‌ها بروزرسانی شد'); - setEditTarget(null); - qc.invalidateQueries({ queryKey: ['my-secretaries'] }); - qc.invalidateQueries({ queryKey: ['my-secretaries-clinic'] }); - }, - onError: (e: any) => toast.error(e.message), - }); + const updatePermsMutation = useMutation({ + mutationFn: ({ + uuid, + permissions, + }: { + uuid: string; + permissions: SecretaryPermissions; + }) => + api.patch(`/api/v1/secretary/${uuid}`, { + permissions: { version: 1, resources: permissions }, + }), + onSuccess: () => { + toast.success("دسترسی‌ها بروزرسانی شد"); + setEditTarget(null); + qc.invalidateQueries({ queryKey: ["my-secretaries"] }); + qc.invalidateQueries({ queryKey: ["my-secretaries-clinic"] }); + }, + onError: (e: any) => toast.error(e.message), + }); - const toggleActiveMutation = useMutation({ - mutationFn: ({ uuid, active }: { uuid: string; active: boolean }) => - api.patch(`/api/v1/secretary/${uuid}`, { active }), - onSuccess: (_, { active }) => { - toast.success(active ? 'منشی فعال شد' : 'منشی غیرفعال شد'); - setDeleteTarget(null); - qc.invalidateQueries({ queryKey: ['my-secretaries'] }); - qc.invalidateQueries({ queryKey: ['my-secretaries-clinic'] }); - }, - onError: (e: any) => toast.error(e.message), - }); + const toggleActiveMutation = useMutation({ + mutationFn: ({ uuid, active }: { uuid: string; active: boolean }) => + api.patch(`/api/v1/secretary/${uuid}`, { active }), + onSuccess: (_, { active }) => { + toast.success(active ? "منشی فعال شد" : "منشی غیرفعال شد"); + setDeleteTarget(null); + qc.invalidateQueries({ queryKey: ["my-secretaries"] }); + qc.invalidateQueries({ queryKey: ["my-secretaries-clinic"] }); + }, + onError: (e: any) => toast.error(e.message), + }); - const openEdit = (s: Secretary) => { - setEditTarget(s); - setEditPerms(s.permissions ?? DEFAULT_PERMISSIONS); - }; + const openEdit = (s: Secretary) => { + setEditTarget(s); + setEditPerms(s.permissions ?? DEFAULT_PERMISSIONS); + }; - const handleCreateOpen = () => { - if (isClinic && !selectedDoctorUuid) { - toast.error('ابتدا یک پزشک را انتخاب کنید'); - return; - } - setCreateOpen(true); - }; - - const selectedDoctorName = clinicDoctors.find(d => d.uuid === selectedDoctorUuid)?.name ?? ''; - - // for clinic: show doctor column in the table - const clinicColumns: Column[] = isClinic ? [ - { - key: 'doctor_name', - header: 'پزشک', - render: (s) => ( - {s.doctor_name} - ), - }, - ] : []; - - const allColumns: Column[] = [ - { - key: 'user_name', - header: 'منشی', - render: (s) => ( -
-
- {(s.user_name ?? '?').charAt(0)} -
-
{s.user_name}
-
- ), - }, - ...clinicColumns, - { - key: 'mobile_number', - header: 'موبایل', - render: (s) => ( -
- - {maskMobile(s.mobile_number)} -
- ), - }, - { - key: 'is_active', - header: 'وضعیت', - render: (s) => , - }, - { - key: 'created_at', - header: 'تاریخ ثبت', - render: (s) => ( - {formatDate(Number(s.created_at))} - ), - }, - { - key: 'uuid', - header: 'عملیات', - render: (s) => ( -
- - -
- ), - }, - ]; - - return ( - <> - = maxSecretaries ? ( - - ارتقاء پنل برای افزودن منشی - - ) : ( - - ) + const handleCreateOpen = () => { + if (isClinic && !selectedDoctorUuid) { + toast.error("ابتدا یک پزشک را انتخاب کنید"); + return; } - /> + setCreateOpen(true); + }; - {/* کلینیک: انتخاب پزشک برای افزودن منشی */} - {isClinic && ( -
-
- - {clinicDoctorsLoading ? ( -

در حال بارگذاری...

- ) : clinicDoctors.length === 0 ? ( -

هیچ پزشکی در این کلینیک تعریف نشده است. ابتدا پزشک اضافه کنید.

+ const selectedDoctorName = + clinicDoctors.find((d) => d.uuid === selectedDoctorUuid)?.name ?? ""; + + // for clinic: show doctor column in the table + const clinicColumns: Column[] = isClinic + ? [ + { + key: "doctor_name", + header: "پزشک", + render: (s) => ( + + {s.doctor_name} + + ), + }, + ] + : []; + + const allColumns: Column[] = [ + { + key: "user_name", + header: "منشی", + render: (s) => ( +
+
+ {(s.user_name ?? "?").charAt(0)} +
+
+ {s.user_name} +
+
+ ), + }, + ...clinicColumns, + { + key: "mobile_number", + header: "موبایل", + render: (s) => ( +
+ + {maskMobile(s.mobile_number)} +
+ ), + }, + { + key: "is_active", + header: "وضعیت", + render: (s) => , + }, + { + key: "created_at", + header: "تاریخ ثبت", + render: (s) => ( + + {formatDate(Number(s.created_at))} + + ), + }, + { + key: "uuid", + header: "عملیات", + render: (s) => ( +
+ + +
+ ), + }, + ]; + + return ( + <> + = maxSecretaries ? ( + + ارتقاء پنل برای افزودن منشی + + ) : ( + + ) + } + /> + + {/* کلینیک: انتخاب پزشک برای افزودن منشی */} + {isClinic && ( +
+
+ + {clinicDoctorsLoading ? ( +

+ در حال بارگذاری... +

+ ) : clinicDoctors.length === 0 ? ( +

+ هیچ پزشکی در این کلینیک تعریف نشده است. ابتدا + پزشک اضافه کنید. +

+ ) : ( + + )} +
+
+ )} + + {!isClinic && !doctorUuid ? ( +
+ پروفایل پزشک یافت نشد +
) : ( - +
+ {secretaries.length === 0 && !isLoading ? ( +
+ +
+ هنوز منشی‌ای اضافه نشده +
+
+ {isClinic + ? "برای افزودن منشی، ابتدا یک پزشک را از لیست بالا انتخاب کنید" + : "منشی می‌تواند نوبت‌ها و اطلاعات کلینیک را مدیریت کند"} +
+ {!isClinic && ( + + )} +
+ ) : ( + + )} +
)} -
-
- )} - {!isClinic && !doctorUuid ? ( -
- پروفایل پزشک یافت نشد -
- ) : ( -
- {secretaries.length === 0 && !isLoading ? ( -
- -
هنوز منشی‌ای اضافه نشده
-
- {isClinic - ? 'برای افزودن منشی، ابتدا یک پزشک را از لیست بالا انتخاب کنید' - : 'منشی می‌تواند نوبت‌ها و اطلاعات کلینیک را مدیریت کند'} -
- {!isClinic && ( - - )} -
- ) : ( - - )} -
- )} - - {/* Modal افزودن منشی */} - { setCreateOpen(false); createForm.reset(); }} - title="افزودن منشی جدید" - size="sm" - footer={ - <> - - + + + } > - {createMutation.isPending ? 'در حال افزودن...' : 'افزودن منشی'} - - - } - > - {isClinic && selectedDoctorName && ( -
- - منشی برای {selectedDoctorName} ثبت می‌شود -
- )} -
createMutation.mutate(d))}> -
- - - {createForm.formState.errors.name && ( - {createForm.formState.errors.name.message} - )} -
-
- - { - const digits = e.target.value.replace(/\D/g, ''); - createForm.setValue('mobile_number', digits, { shouldValidate: createForm.formState.isSubmitted }); - }} - style={createForm.formState.errors.mobile_number ? { borderColor: 'var(--danger)' } : undefined} - /> - {createForm.formState.errors.mobile_number && ( - {createForm.formState.errors.mobile_number.message} - )} -
-

- شماره باید با ۰۹ شروع شده و ۱۱ رقم باشد. اگر کاربری با این شماره در سیستم وجود داشته باشد، به عنوان منشی اضافه می‌شود. -

-
-
+ {isClinic && selectedDoctorName && ( +
+ + منشی برای {selectedDoctorName} ثبت + می‌شود +
+ )} +
+ createMutation.mutate(d), + )} + > +
+ + + {createForm.formState.errors.name && ( + + {createForm.formState.errors.name.message} + + )} +
+
+ + { + const digits = e.target.value.replace( + /\D/g, + "", + ); + createForm.setValue("mobile_number", digits, { + shouldValidate: + createForm.formState.isSubmitted, + }); + }} + style={ + createForm.formState.errors.mobile_number + ? { borderColor: "var(--danger)" } + : undefined + } + /> + {createForm.formState.errors.mobile_number && ( + + { + createForm.formState.errors.mobile_number + .message + } + + )} +
+

+ شماره باید با ۰۹ شروع شده و ۱۱ رقم باشد. اگر کاربری با + این شماره در سیستم وجود داشته باشد، به عنوان منشی اضافه + می‌شود. +

+
+ - {/* Modal ویرایش دسترسی‌ها */} - setEditTarget(null)} - title={`دسترسی‌های ${editTarget?.user_name ?? ''}`} - size="lg" - footer={ - <> - - + + + } > - {updatePermsMutation.isPending ? 'در حال ذخیره...' : 'ذخیره دسترسی‌ها'} - - - } - > - - + + - {/* Confirm تغییر وضعیت منشی */} - deleteTarget && toggleActiveMutation.mutate({ uuid: deleteTarget.uuid, active: !deleteTarget.is_active })} - onCancel={() => setDeleteTarget(null)} - /> - - ); + {/* Confirm تغییر وضعیت منشی */} + + deleteTarget && + toggleActiveMutation.mutate({ + uuid: deleteTarget.uuid, + active: !deleteTarget.is_active, + }) + } + onCancel={() => setDeleteTarget(null)} + /> + + ); } diff --git a/docs/PRD/prd.md b/docs/PRD/prd.md index bb586a00..3f68c528 100644 --- a/docs/PRD/prd.md +++ b/docs/PRD/prd.md @@ -1,4 +1,5 @@ # PRD — ClinicPro Phase 2 + **تاریخ:** ۲۴ خرداد ۱۴۰۵ **نسخه:** 1.2 **وضعیت:** Draft @@ -13,24 +14,24 @@ ## وضعیت فعلی پروژه -| ماژول | Backend | API Doc | -|--------|---------|---------| -| Auth (OTP + JWT + switch-context) | ✅ کامل | `docs/api/auth.md` | -| Doctor profile | ✅ کامل | `docs/api/doctor.md` | -| Clinic management | ✅ کامل | `docs/api/clinic.md` | -| Appointment booking + settings | ✅ کامل | `docs/api/appointment.md`, `appointment-settings.md` | -| Secretary (`DoctorSecretary` entity) | ✅ entity + API موجود | `docs/api/secretary.md` | -| Payment gateway (Mellat, SEP) | ✅ کامل — `Payment.type='subscription'` هم موجود | `docs/api/payment.md` | -| SMS log + template | ✅ زیرساخت موجود | `docs/api/sms.md` | -| Wallet / Settlement | ✅ کامل | `docs/api/settlement.md` | -| SiteConfig (key-value store) | ✅ موجود | `docs/api/admin.md` §Settings | -| Dashboard (clinic/doctor/secretary) | ✅ کامل | `docs/api/dashboard.md` | -| Rating, Blog, Representation, Location | ✅ کامل | فایل‌های مربوطه در `docs/api/` | -| **Staff (پرسنل)** | ❌ entity وجود ندارد | ❌ فایل doc وجود ندارد | -| **Subscription tiers** | ❌ entity وجود ندارد | ❌ فایل doc وجود ندارد | -| **Clinic services** | ❌ entity وجود ندارد | ❌ فایل doc وجود ندارد | -| **Patient records** | ❌ entity وجود ندارد | ❌ فایل doc وجود ندارد | -| **SMS wallet/settings per clinic** | ❌ entity وجود ندارد | ❌ بخش در sms.md وجود ندارد | +| ماژول | Backend | API Doc | +| -------------------------------------- | ------------------------------------------------ | ---------------------------------------------------- | +| Auth (OTP + JWT + switch-context) | ✅ کامل | `docs/api/auth.md` | +| Doctor profile | ✅ کامل | `docs/api/doctor.md` | +| Clinic management | ✅ کامل | `docs/api/clinic.md` | +| Appointment booking + settings | ✅ کامل | `docs/api/appointment.md`, `appointment-settings.md` | +| Secretary (`DoctorSecretary` entity) | ✅ entity + API موجود | `docs/api/secretary.md` | +| Payment gateway (Mellat, SEP) | ✅ کامل — `Payment.type='subscription'` هم موجود | `docs/api/payment.md` | +| SMS log + template | ✅ زیرساخت موجود | `docs/api/sms.md` | +| Wallet / Settlement | ✅ کامل | `docs/api/settlement.md` | +| SiteConfig (key-value store) | ✅ موجود | `docs/api/admin.md` §Settings | +| Dashboard (clinic/doctor/secretary) | ✅ کامل | `docs/api/dashboard.md` | +| Rating, Blog, Representation, Location | ✅ کامل | فایل‌های مربوطه در `docs/api/` | +| **Staff (پرسنل)** | ❌ entity وجود ندارد | ❌ فایل doc وجود ندارد | +| **Subscription tiers** | ❌ entity وجود ندارد | ❌ فایل doc وجود ندارد | +| **Clinic services** | ❌ entity وجود ندارد | ❌ فایل doc وجود ندارد | +| **Patient records** | ❌ entity وجود ندارد | ❌ فایل doc وجود ندارد | +| **SMS wallet/settings per clinic** | ❌ entity وجود ندارد | ❌ بخش در sms.md وجود ندارد | --- @@ -41,19 +42,21 @@ ### اپیک ۱ — پرسنل (Staff) #### توضیح + مدیر کلینیک یا دکتر مستقل می‌تواند پرسنل کلینیک را ثبت کند. پرسنل حساب کاربری ندارند و نیازی به لاگین ندارند. در سرویس‌دهی، سشن‌ها و گزارشات به آن‌ها ارجاع می‌شود. #### نیازمندی‌های کارکردی -| کد | نیازمندی | -|----|---------| -| F1.1 | ایجاد / ویرایش / غیرفعال‌سازی پرسنل — **حذف سخت ممنوع** (تاریخچه حفظ شود) | +| کد | نیازمندی | +| ---- | ------------------------------------------------------------------------------------ | +| F1.1 | ایجاد / ویرایش / غیرفعال‌سازی پرسنل — **حذف سخت ممنوع** (تاریخچه حفظ شود) | | F1.2 | فیلدها: نام و نام‌خانوادگی (اجباری)، شماره تماس، عنوان شغل، آدرس، کد ملی، وضعیت فعال | -| F1.3 | پرسنل به `entity_type + entity_id` وابسته است (کلینیک یا مطب) | -| F1.4 | لیست پرسنل فقط در scope کلینیک/مطب کاربر جاری نمایش داده می‌شود | -| F1.5 | در ایجاد ServiceItem و SessionService از لیست پرسنل انتخاب می‌شود | +| F1.3 | پرسنل به `entity_type + entity_id` وابسته است (کلینیک یا مطب) | +| F1.4 | لیست پرسنل فقط در scope کلینیک/مطب کاربر جاری نمایش داده می‌شود | +| F1.5 | در ایجاد ServiceItem و SessionService از لیست پرسنل انتخاب می‌شود | #### Entity جدید + ``` clinic_staff: id, uuid @@ -69,18 +72,21 @@ clinic_staff: ``` #### API -| Method | Path | توضیح | -|--------|------|-------| -| `GET` | `/api/v1/staff` | لیست پرسنل کاربر جاری | -| `POST` | `/api/v1/staff` | ایجاد پرسنل جدید | -| `PATCH` | `/api/v1/staff/{uuid}` | ویرایش اطلاعات | + +| Method | Path | توضیح | +| ------- | ----------------------------- | -------------------------- | +| `GET` | `/api/v1/staff` | لیست پرسنل کاربر جاری | +| `POST` | `/api/v1/staff` | ایجاد پرسنل جدید | +| `PATCH` | `/api/v1/staff/{uuid}` | ویرایش اطلاعات | | `PATCH` | `/api/v1/staff/{uuid}/toggle` | فعال/غیرفعال (soft delete) | #### Frontend + - `StaffPage.tsx` — DataTable + Modal ایجاد/ویرایش - Sidebar: زیر «تنظیمات» #### فایل‌های جدید + - `src/Staff/Entity/ClinicStaff.php` - `src/Staff/Controller/StaffController.php` - `src/Staff/Repository/ClinicStaffRepository.php` @@ -91,25 +97,26 @@ clinic_staff: ### اپیک ۲ — پنل اشتراکی (Subscription Tiers) #### توضیح + سه سطح پنل با قابلیت‌های متفاوت. ادمین برای هر پنل دوره‌های زمانی با قیمت مشخص تعریف می‌کند. کلینیک/دکتر دوره + پنل انتخاب کرده و پرداخت می‌کند. #### سطوح پنل -| Level | Name | منشی | قابلیت‌های اضافه | -|-------|------|------|----------------| -| 0 | Free | ۱ منشی | نوبت‌دهی — رایگان، بدون انقضا | -| 1 | Basic | ۲ منشی | پرونده بیمار + سرویس‌ها | -| 2 | Professional | ۵ منشی | پرونده بیمار + سرویس‌ها | +| Level | Name | منشی | قابلیت‌های اضافه | +| ----- | ------------ | ------ | ----------------------------- | +| 0 | Free | ۱ منشی | نوبت‌دهی — رایگان، بدون انقضا | +| 1 | Basic | ۲ منشی | پرونده بیمار + سرویس‌ها | +| 2 | Professional | ۵ منشی | پرونده بیمار + سرویس‌ها | #### دوره‌های اشتراک (مثال — ادمین تنظیم می‌کند) -| نوع | مدت | قیمت | -|-----|-----|------| -| تریال | ۱ ماه | ۰ ریال — فقط برای Basic، یک‌بار | -| ۱ ماهه | ۱ ماه | ادمین تعیین می‌کند | -| ۳ ماهه | ۳ ماه | ادمین تعیین می‌کند | -| ۶ ماهه | ۶ ماه | ادمین تعیین می‌کند | -| ۱۲ ماهه | ۱۲ ماه | ادمین تعیین می‌کند | +| نوع | مدت | قیمت | +| ------- | ------ | ------------------------------- | +| تریال | ۱ ماه | ۰ ریال — فقط برای Basic، یک‌بار | +| ۱ ماهه | ۱ ماه | ادمین تعیین می‌کند | +| ۳ ماهه | ۳ ماه | ادمین تعیین می‌کند | +| ۶ ماهه | ۶ ماه | ادمین تعیین می‌کند | +| ۱۲ ماهه | ۱۲ ماه | ادمین تعیین می‌کند | #### قوانین تریال @@ -121,18 +128,19 @@ clinic_staff: #### نیازمندی‌های کارکردی -| کد | نیازمندی | -|----|---------| -| F2.1 | ادمین قیمت هر ترکیب (پنل × دوره) را تعریف می‌کند | -| F2.2 | ادمین می‌تواند دوره‌های جدید اضافه یا غیرفعال کند | +| کد | نیازمندی | +| ---- | ---------------------------------------------------------------------------- | +| F2.1 | ادمین قیمت هر ترکیب (پنل × دوره) را تعریف می‌کند | +| F2.2 | ادمین می‌تواند دوره‌های جدید اضافه یا غیرفعال کند | | F2.3 | پرداخت از طریق gateway موجود (Mellat/SEP) — `Payment.type='subscription'` ✅ | -| F2.4 | پس از پرداخت موفق: `starts_at=now`, `expires_at=now + months*30*86400` | -| F2.5 | تمدید از تاریخ انقضای قبلی محاسبه می‌شود (نه از now) | -| F2.6 | gate check: `hasFeature('patient_records')` — اشتراک فعال و منقضی نشده | -| F2.7 | هشدار ۷ روز قبل از انقضا به کلینیک/دکتر | -| F2.8 | گزارش فروش برای ادمین (تعداد، درآمد، بر اساس سطح و دوره) | +| F2.4 | پس از پرداخت موفق: `starts_at=now`, `expires_at=now + months*30*86400` | +| F2.5 | تمدید از تاریخ انقضای قبلی محاسبه می‌شود (نه از now) | +| F2.6 | gate check: `hasFeature('patient_records')` — اشتراک فعال و منقضی نشده | +| F2.7 | هشدار ۷ روز قبل از انقضا به کلینیک/دکتر | +| F2.8 | گزارش فروش برای ادمین (تعداد، درآمد، بر اساس سطح و دوره) | #### Entities جدید + ``` subscription_plans: id, uuid @@ -168,31 +176,34 @@ clinic_subscriptions: ``` #### API -| Method | Path | Permission | توضیح | -|--------|------|-----------|-------| -| `GET` | `/api/v1/subscription/plans` | public | لیست پنل‌ها با دوره‌ها و قیمت‌ها | -| `GET` | `/api/v1/subscription/my` | clinic/doctor | اشتراک فعال + `used_trial` + تاریخ انقضا | -| `POST` | `/api/v1/subscription/trial` | clinic/doctor | فعال‌سازی تریال (رایگان، یک‌بار) | -| `POST` | `/api/v1/subscription-payment` | clinic/doctor | شروع پرداخت — body: `{ period_uuid }` | -| `GET` | `/api/v1/admin/subscription/plans` | admin | لیست پنل‌ها | -| `POST` | `/api/v1/admin/subscription/plan` | admin | ایجاد پنل | -| `PATCH` | `/api/v1/admin/subscription/plan/{uuid}` | admin | ویرایش پنل | -| `POST` | `/api/v1/admin/subscription/period` | admin | افزودن دوره جدید | -| `PATCH` | `/api/v1/admin/subscription/period/{uuid}` | admin | ویرایش قیمت/مدت | -| `DELETE` | `/api/v1/admin/subscription/period/{uuid}` | admin | غیرفعال‌سازی دوره | -| `GET` | `/api/v1/admin/subscription/report` | admin | گزارش فروش + تریال‌ها | + +| Method | Path | Permission | توضیح | +| -------- | ------------------------------------------ | ------------- | ---------------------------------------- | +| `GET` | `/api/v1/subscription/plans` | public | لیست پنل‌ها با دوره‌ها و قیمت‌ها | +| `GET` | `/api/v1/subscription/my` | clinic/doctor | اشتراک فعال + `used_trial` + تاریخ انقضا | +| `POST` | `/api/v1/subscription/trial` | clinic/doctor | فعال‌سازی تریال (رایگان، یک‌بار) | +| `POST` | `/api/v1/subscription-payment` | clinic/doctor | شروع پرداخت — body: `{ period_uuid }` | +| `GET` | `/api/v1/admin/subscription/plans` | admin | لیست پنل‌ها | +| `POST` | `/api/v1/admin/subscription/plan` | admin | ایجاد پنل | +| `PATCH` | `/api/v1/admin/subscription/plan/{uuid}` | admin | ویرایش پنل | +| `POST` | `/api/v1/admin/subscription/period` | admin | افزودن دوره جدید | +| `PATCH` | `/api/v1/admin/subscription/period/{uuid}` | admin | ویرایش قیمت/مدت | +| `DELETE` | `/api/v1/admin/subscription/period/{uuid}` | admin | غیرفعال‌سازی دوره | +| `GET` | `/api/v1/admin/subscription/report` | admin | گزارش فروش + تریال‌ها | #### Frontend + - `SubscriptionPage.tsx`: - - بنر تریال اگر `used_trial=false` - - وضعیت پنل فعال + تاریخ انقضا (شمسی) + badge «تریال» - - جدول مقایسه پنل‌ها با انتخاب دوره - - دکمه خرید → redirect به gateway + - بنر تریال اگر `used_trial=false` + - وضعیت پنل فعال + تاریخ انقضا (شمسی) + badge «تریال» + - جدول مقایسه پنل‌ها با انتخاب دوره + - دکمه خرید → redirect به gateway - `AdminSubscriptionPage.tsx`: - - تب «پنل‌ها»: ویرایش دوره‌ها و قیمت‌ها - - تب «گزارش»: فروش بر اساس بازه زمانی + - تب «پنل‌ها»: ویرایش دوره‌ها و قیمت‌ها + - تب «گزارش»: فروش بر اساس بازه زمانی #### فایل‌های جدید + - `src/Subscription/Entity/SubscriptionPlan.php` - `src/Subscription/Entity/SubscriptionPeriod.php` - `src/Subscription/Entity/ClinicSubscription.php` @@ -205,24 +216,28 @@ clinic_subscriptions: ### اپیک ۳ — منشی (Secretary) — تکمیل #### توضیح + Entity `DoctorSecretary` و API کامل موجود است (`docs/api/secretary.md`). باید: + - محدودیت تعداد بر اساس پنل اعمال شود - UI مدیریت permissions تکمیل شود #### نیازمندی‌های کارکردی -| کد | نیازمندی | -|----|---------| -| F3.1 | بررسی سقف پنل قبل از ایجاد: Free→1، Basic→2، Pro→5 | -| F3.2 | UI لیست منشیان + اضافه‌کردن + ویرایش + غیرفعال‌سازی | +| کد | نیازمندی | +| ---- | -------------------------------------------------------------------------------------- | +| F3.1 | بررسی سقف پنل قبل از ایجاد: Free→1، Basic→2، Pro→5 | +| F3.2 | UI لیست منشی ها + اضافه‌کردن + ویرایش + غیرفعال‌سازی | | F3.3 | ویرایشگر permissions: checkbox matrix (موجود در `DoctorSecretary.DEFAULT_PERMISSIONS`) | -| F3.4 | منشی هرگز حذف نمی‌شود — `active: false` | +| F3.4 | منشی هرگز حذف نمی‌شود — `active: false` | #### تغییرات مورد نیاز + - **Backend:** `SecretaryController::create()` — بررسی سقف پنل با `SubscriptionService` - **Frontend:** Modal با checkbox matrix برای permissions در `SecretariesPage.tsx` #### API موجود (نیاز به تغییر ندارند) + `GET /api/v1/secretaries/{doctorUuid}` | `POST /api/v1/secretary` | `PATCH /api/v1/secretary/{uuid}` | `DELETE /api/v1/secretary/{uuid}` --- @@ -230,19 +245,21 @@ Entity `DoctorSecretary` و API کامل موجود است (`docs/api/secretary. ### اپیک ۴ — سرویس‌های کلینیک (Clinic Services) #### توضیح + دسترسی فقط در پنل Basic+. سرویس‌ها در دو سطح: بخش (مثلاً «تزریقات») و زیربخش (مثلاً «سرم»). #### نیازمندی‌های کارکردی -| کد | نیازمندی | -|----|---------| -| F4.1 | CRUD بخش‌ها (ServiceSection) | -| F4.2 | CRUD زیربخش‌ها (ServiceItem) با انتخاب بخش + پرسنل انجام‌دهنده | -| F4.3 | قیمت هر زیربخش قابل ویرایش | -| F4.4 | gate: `hasFeature('services')` — بدون Basic+ → 403 | +| کد | نیازمندی | +| ---- | ------------------------------------------------------------------ | +| F4.1 | CRUD بخش‌ها (ServiceSection) | +| F4.2 | CRUD زیربخش‌ها (ServiceItem) با انتخاب بخش + پرسنل انجام‌دهنده | +| F4.3 | قیمت هر زیربخش قابل ویرایش | +| F4.4 | gate: `hasFeature('services')` — بدون Basic+ → 403 | | F4.5 | لیست ServiceItems در ثبت سشن بیمار استفاده می‌شود (وابستگی اپیک ۶) | #### Entities جدید + ``` service_sections: id, uuid @@ -263,21 +280,24 @@ service_items: ``` #### API -| Method | Path | توضیح | -|--------|------|-------| -| `GET` | `/api/v1/service-sections` | لیست بخش‌ها | -| `POST` | `/api/v1/service-section` | ایجاد بخش (Basic+) | -| `PATCH` | `/api/v1/service-section/{uuid}` | ویرایش | -| `DELETE` | `/api/v1/service-section/{uuid}` | حذف | -| `GET` | `/api/v1/service-items/{sectionUuid}` | لیست زیربخش‌های یک بخش | -| `POST` | `/api/v1/service-item` | ایجاد زیربخش (Basic+) | -| `PATCH` | `/api/v1/service-item/{uuid}` | ویرایش | -| `DELETE` | `/api/v1/service-item/{uuid}` | حذف | + +| Method | Path | توضیح | +| -------- | ------------------------------------- | ---------------------- | +| `GET` | `/api/v1/service-sections` | لیست بخش‌ها | +| `POST` | `/api/v1/service-section` | ایجاد بخش (Basic+) | +| `PATCH` | `/api/v1/service-section/{uuid}` | ویرایش | +| `DELETE` | `/api/v1/service-section/{uuid}` | حذف | +| `GET` | `/api/v1/service-items/{sectionUuid}` | لیست زیربخش‌های یک بخش | +| `POST` | `/api/v1/service-item` | ایجاد زیربخش (Basic+) | +| `PATCH` | `/api/v1/service-item/{uuid}` | ویرایش | +| `DELETE` | `/api/v1/service-item/{uuid}` | حذف | #### Frontend + - `ServicesPage.tsx` — accordion دو سطحی: بخش‌ها ← زیربخش‌ها #### فایل‌های جدید + - `src/ClinicService/Entity/ServiceSection.php` - `src/ClinicService/Entity/ServiceItem.php` - `src/ClinicService/Controller/ClinicServiceController.php` @@ -288,25 +308,28 @@ service_items: ### اپیک ۵ — پنل پیامکی (SMS Panel) #### توضیح + زیرساخت SMS موجود است (`SmsLog`, `SmsTemplate`, `SmsProvider` در `src/Sms/`). باید: + - کیف پول پیامک اختصاصی برای هر کلینیک/دکتر - تنظیمات ارسال پیامک (یادآوری، بعد از ویزیت) - گزارش لاگ برای کلینیک و ادمین #### نیازمندی‌های کارکردی -| کد | نیازمندی | -|----|---------| -| F5.1 | کیف پول پیامک جدا از کیف پول مالی | -| F5.2 | شارژ از طریق gateway موجود | +| کد | نیازمندی | +| ---- | ---------------------------------------------------------------------- | +| F5.1 | کیف پول پیامک جدا از کیف پول مالی | +| F5.2 | شارژ از طریق gateway موجود | | F5.3 | ادمین تعرفه هر پیامک را تعیین می‌کند (`sms_price_rials` در SiteConfig) | -| F5.4 | کسر خودکار هزینه بعد از هر ارسال موفق | -| F5.5 | تنظیمات: یادآوری نوبت (فعال/غیرفعال + چند ساعت قبل) | -| F5.6 | تنظیمات: پیام بعد از ویزیت (فعال/غیرفعال + متن) | -| F5.7 | گزارش لاگ پیامک‌های کلینیک/مطب | -| F5.8 | گزارش ادمین: مصرف هر کلینیک + درآمد پیامکی | +| F5.4 | کسر خودکار هزینه بعد از هر ارسال موفق | +| F5.5 | تنظیمات: یادآوری نوبت (فعال/غیرفعال + چند ساعت قبل) | +| F5.6 | تنظیمات: پیام بعد از ویزیت (فعال/غیرفعال + متن) | +| F5.7 | گزارش لاگ پیامک‌های کلینیک/مطب | +| F5.8 | گزارش ادمین: مصرف هر کلینیک + درآمد پیامکی | #### Entities جدید + ``` sms_wallets: id @@ -327,20 +350,23 @@ sms_settings: ``` #### API -| Method | Path | Permission | توضیح | -|--------|------|-----------|-------| -| `GET` | `/api/v1/sms/wallet/balance` | clinic/doctor | موجودی کیف پول پیامک | -| `POST` | `/api/v1/sms/wallet/charge` | clinic/doctor | شارژ — body: `{ gateway, amount_rials }` | -| `GET` | `/api/v1/sms/wallet/logs` | clinic/doctor | تاریخچه کسر/شارژ | -| `GET` | `/api/v1/sms/settings` | clinic/doctor | تنظیمات پیامک | -| `PATCH` | `/api/v1/sms/settings` | clinic/doctor | ذخیره تنظیمات | -| `GET` | `/api/v1/admin/sms/wallet-report` | admin | گزارش مصرف و درآمد | + +| Method | Path | Permission | توضیح | +| ------- | --------------------------------- | ------------- | ---------------------------------------- | +| `GET` | `/api/v1/sms/wallet/balance` | clinic/doctor | موجودی کیف پول پیامک | +| `POST` | `/api/v1/sms/wallet/charge` | clinic/doctor | شارژ — body: `{ gateway, amount_rials }` | +| `GET` | `/api/v1/sms/wallet/logs` | clinic/doctor | تاریخچه کسر/شارژ | +| `GET` | `/api/v1/sms/settings` | clinic/doctor | تنظیمات پیامک | +| `PATCH` | `/api/v1/sms/settings` | clinic/doctor | ذخیره تنظیمات | +| `GET` | `/api/v1/admin/sms/wallet-report` | admin | گزارش مصرف و درآمد | #### Frontend + - تب «پیامک» در صفحه تنظیمات کلینیک/دکتر - نمایش موجودی + دکمه شارژ + لاگ + فرم تنظیمات #### تأثیر بر فایل‌های موجود + - `docs/api/sms.md` ← **بخش جدید wallet/settings اضافه شود** --- @@ -348,20 +374,22 @@ sms_settings: ### اپیک ۶ — پرونده بیمار (Patient Records) #### توضیح + دسترسی فقط در پنل Basic+. پرونده به صورت خودکار پس از تأیید نوبت ایجاد می‌شود یا دستی توسط منشی/دکتر. هر مراجعه یک سشن است که شامل ویزیت + سرویس‌ها است. #### نیازمندی‌های کارکردی -| کد | نیازمندی | -|----|---------| -| F6.1 | ایجاد خودکار `PatientRecord` بعد از تأیید appointment (اگر وجود نداشت) | -| F6.2 | ایجاد دستی توسط منشی یا دکتر | +| کد | نیازمندی | +| ---- | ----------------------------------------------------------------------------- | +| F6.1 | ایجاد خودکار `PatientRecord` بعد از تأیید appointment (اگر وجود نداشت) | +| F6.2 | ایجاد دستی توسط منشی یا دکتر | | F6.3 | ثبت سشن: بیمه پایه + مکمل، مبلغ ویزیت (پیش‌فرض یا دستی)، محاسبه خودکار کسورات | -| F6.4 | ثبت سرویس‌های انجام‌شده در سشن با پرسنل انجام‌دهنده | -| F6.5 | gate: `hasFeature('patient_records')` — بدون Basic+ → 403 | -| F6.6 | تاریخچه کامل مراجعات یک بیمار | +| F6.4 | ثبت سرویس‌های انجام‌شده در سشن با پرسنل انجام‌دهنده | +| F6.5 | gate: `hasFeature('patient_records')` — بدون Basic+ → 403 | +| F6.6 | تاریخچه کامل مراجعات یک بیمار | #### محاسبه مبلغ نهایی سشن + ``` visit_price_rials ← ادمین/دکتر تعیین می‌کند - base_insurance_discount% ← بیمه پایه @@ -371,6 +399,7 @@ visit_price_rials ← ادمین/دکتر تعیین می‌کند ``` #### Entities جدید + ``` patient_records: id, uuid @@ -405,21 +434,24 @@ session_services: ``` #### API -| Method | Path | Permission | توضیح | -|--------|------|-----------|-------| -| `GET` | `/api/v1/patients` | clinic/doctor (basic+) | لیست بیماران با جستجو | -| `POST` | `/api/v1/patient` | clinic/doctor (basic+) | ایجاد پرونده دستی | -| `GET` | `/api/v1/patient/{uuid}` | clinic/doctor | جزئیات پرونده | -| `GET` | `/api/v1/patient/{uuid}/sessions` | clinic/doctor | لیست مراجعات | -| `POST` | `/api/v1/patient/{uuid}/session` | clinic/doctor | ثبت مراجعه جدید | -| `PATCH` | `/api/v1/session/{uuid}` | clinic/doctor | ویرایش مراجعه | + +| Method | Path | Permission | توضیح | +| ------- | --------------------------------- | ---------------------- | --------------------- | +| `GET` | `/api/v1/patients` | clinic/doctor (basic+) | لیست بیماران با جستجو | +| `POST` | `/api/v1/patient` | clinic/doctor (basic+) | ایجاد پرونده دستی | +| `GET` | `/api/v1/patient/{uuid}` | clinic/doctor | جزئیات پرونده | +| `GET` | `/api/v1/patient/{uuid}/sessions` | clinic/doctor | لیست مراجعات | +| `POST` | `/api/v1/patient/{uuid}/session` | clinic/doctor | ثبت مراجعه جدید | +| `PATCH` | `/api/v1/session/{uuid}` | clinic/doctor | ویرایش مراجعه | #### Frontend + - `PatientsPage.tsx` — لیست بیماران + جستجو - `PatientDetailPage.tsx` — پرونده + تاریخچه مراجعات - Modal ثبت سشن جدید (بیمه + ویزیت + سرویس‌ها) #### فایل‌های جدید + - `src/Patient/Entity/PatientRecord.php` - `src/Patient/Entity/PatientSession.php` - `src/Patient/Entity/SessionService.php` @@ -431,22 +463,25 @@ session_services: ### اپیک ۷ — داشبورد هوشمند (Smart Dashboard) #### توضیح + داشبوردهای موجود (clinic/doctor/secretary در `docs/api/dashboard.md`) باید با چارت و فیلتر بازه زمانی تکمیل شوند. #### نیازمندی‌های کارکردی -| کد | نیازمندی | -|----|---------| -| F7.1 | داشبورد ادمین: چارت درآمد + چارت نوبت‌ها + گزارش فروش پنل‌ها | +| کد | نیازمندی | +| ---- | ----------------------------------------------------------------------- | +| F7.1 | داشبورد ادمین: چارت درآمد + چارت نوبت‌ها + گزارش فروش پنل‌ها | | F7.2 | داشبورد کلینیک/مطب: درآمد ماه جاری + بیماران منحصربه‌فرد + موجودی پیامک | -| F7.3 | فیلتر بازه زمانی: این هفته / این ماه / ۳ ماه / سفارشی | -| F7.4 | فیلتر نقش: منشی فقط نوبت‌ها را می‌بیند | +| F7.3 | فیلتر بازه زمانی: این هفته / این ماه / ۳ ماه / سفارشی | +| F7.4 | فیلتر نقش: منشی فقط نوبت‌ها را می‌بیند | #### API موجود (نیاز به endpoint جدید ندارد) + `GET /api/v1/admin/dashboard/stats` | `/charts` | `/recent` `GET /api/v1/dashboard/clinic` | `/doctor` | `/secretary` #### تغییر مورد نیاز + - backend: اضافه کردن پارامترهای `from` و `to` به endpoint های charts - frontend: `DashboardPage.tsx` — افزودن date range selector + نمودارها (Recharts/Chart.js) @@ -454,15 +489,15 @@ session_services: ## اولویت‌بندی پیاده‌سازی -| اولویت | اپیک | وابستگی | -|--------|------|---------| -| ۱ | اپیک ۱ — Staff | پیش‌نیاز اپیک ۴ و ۶ | -| ۲ | اپیک ۲ — Subscription | پیش‌نیاز gate در اپیک ۳، ۴، ۶ | -| ۳ | اپیک ۳ — Secretary تکمیل | وابسته به اپیک ۲ | -| ۴ | اپیک ۴ — Services | وابسته به اپیک ۱ و ۲ | -| ۵ | اپیک ۵ — SMS Panel | مستقل | -| ۶ | اپیک ۶ — Patient Records | وابسته به اپیک ۱، ۲، ۴ | -| ۷ | اپیک ۷ — Dashboard | آخر — نیاز به داده واقعی | +| اولویت | اپیک | وابستگی | +| ------ | ------------------------ | ----------------------------- | +| ۱ | اپیک ۱ — Staff | پیش‌نیاز اپیک ۴ و ۶ | +| ۲ | اپیک ۲ — Subscription | پیش‌نیاز gate در اپیک ۳، ۴، ۶ | +| ۳ | اپیک ۳ — Secretary تکمیل | وابسته به اپیک ۲ | +| ۴ | اپیک ۴ — Services | وابسته به اپیک ۱ و ۲ | +| ۵ | اپیک ۵ — SMS Panel | مستقل | +| ۶ | اپیک ۶ — Patient Records | وابسته به اپیک ۱، ۲، ۴ | +| ۷ | اپیک ۷ — Dashboard | آخر — نیاز به داده واقعی | --- @@ -488,15 +523,15 @@ sms_wallets sms_settings ## زیرساخت موجود قابل استفاده -| زیرساخت | مسیر | استفاده در فاز ۲ | -|---------|------|-----------------| -| Payment gateway | `src/Payment/` | پرداخت subscription + شارژ SMS wallet | -| `Payment.type='subscription'` | `Payment::TYPE_SUBSCRIPTION` | پرداخت پنل اشتراکی — موجود ✅ | -| SiteConfig (key-value) | `src/Config/Entity/SiteConfig.php` | `sms_price_rials`, `trial_enabled` | -| Categories (insurance) | `bundle='insurance_type'` | انتخاب بیمه در PatientSession | -| DoctorSecretary | `src/Secretary/Entity/` | اضافه کردن gate check | -| SmsLog/SmsTemplate | `src/Sms/Entity/` | ارسال پیامک‌های خودکار | -| WalletTransaction | `src/Settlement/Entity/` | الگوی مشابه برای SmsWallet | +| زیرساخت | مسیر | استفاده در فاز ۲ | +| ----------------------------- | ---------------------------------- | ------------------------------------- | +| Payment gateway | `src/Payment/` | پرداخت subscription + شارژ SMS wallet | +| `Payment.type='subscription'` | `Payment::TYPE_SUBSCRIPTION` | پرداخت پنل اشتراکی — موجود ✅ | +| SiteConfig (key-value) | `src/Config/Entity/SiteConfig.php` | `sms_price_rials`, `trial_enabled` | +| Categories (insurance) | `bundle='insurance_type'` | انتخاب بیمه در PatientSession | +| DoctorSecretary | `src/Secretary/Entity/` | اضافه کردن gate check | +| SmsLog/SmsTemplate | `src/Sms/Entity/` | ارسال پیامک‌های خودکار | +| WalletTransaction | `src/Settlement/Entity/` | الگوی مشابه برای SmsWallet | --- diff --git a/docs/api/secretary.md b/docs/api/secretary.md index a9eeabe0..67fe0048 100644 --- a/docs/api/secretary.md +++ b/docs/api/secretary.md @@ -6,10 +6,10 @@ هر رابطه منشی-پزشک دارای یک **scope** است که از تداخل بین محیط‌های مختلف جلوگیری می‌کند: -| Scope | `owner_type` | تعریف‌کننده | دسترسی | -|-------|-------------|-------------|---------| -| مطب شخصی | `doctor` | خود پزشک | فقط نوبت‌ها و داده‌های مطب شخصی | -| کلینیک | `clinic` | مدیر کلینیک | فقط نوبت‌ها و داده‌های کلینیک | +| Scope | `owner_type` | تعریف‌کننده | دسترسی | +| -------- | ------------ | ----------- | ------------------------------- | +| مطب شخصی | `doctor` | خود پزشک | فقط نوبت‌ها و داده‌های مطب شخصی | +| کلینیک | `clinic` | مدیر کلینیک | فقط نوبت‌ها و داده‌های کلینیک | - یک منشی می‌تواند هم در مطب شخصی یک دکتر و هم در کلینیک همان دکتر فعال باشد (دو ردیف مجزا) - منشی کلینیک می‌تواند به چند دکتر در همان کلینیک متصل باشد @@ -26,62 +26,80 @@ 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` ### Request Body (`application/json`) + ```json { - "doctor_uuid": "550e8400-...", - "mobile_number": "09123456789", - "password": "secretaryPass123", - "permissions": { - "version": 1, - "resources": { - "appointments": { "view": true, "create": true, "cancel": false, "update_status": true }, - "addresses": { "view": true, "create": false, "update": false, "delete": false }, - "clinic_info": { "view": true, "update": false }, - "insurances": { "view": true, "create": false, "update": false, "delete": false } + "doctor_uuid": "550e8400-...", + "mobile_number": "09123456789", + "password": "secretaryPass123", + "permissions": { + "version": 1, + "resources": { + "appointments": { + "view": true, + "create": true, + "cancel": false, + "update_status": true + }, + "addresses": { + "view": true, + "create": false, + "update": false, + "delete": false + }, + "clinic_info": { "view": true, "update": false }, + "insurances": { + "view": true, + "create": false, + "update": false, + "delete": false + } + } } - } } ``` -| Field | Type | Required | Description | -|-------|------|----------|-------------| -| `doctor_uuid` | string (UUID) | ✅ | Doctor to assign secretary to | -| `mobile_number` | string | ✅ | Secretary's login mobile | -| `password` | string | ❌ | Initial password (auto-generated if omitted) | -| `permissions` | object | ❌ | Permission set (see structure below) | +| Field | Type | Required | Description | +| --------------- | ------------- | -------- | -------------------------------------------- | +| `doctor_uuid` | string (UUID) | ✅ | Doctor to assign secretary to | +| `mobile_number` | string | ✅ | Secretary's login mobile | +| `password` | string | ❌ | Initial password (auto-generated if omitted) | +| `permissions` | object | ❌ | Permission set (see structure below) | **Permissions Structure:** + ```json { - "version": 1, - "resources": { - "appointments": { - "view": true, // Can view appointments list - "create": true, // Can book appointments - "cancel": false, // Can cancel appointments - "update_status": true // Can mark as completed/no_show - }, - "addresses": { - "view": true, - "create": false, - "update": false, - "delete": false - }, - "clinic_info": { - "view": true, - "update": false - }, - "insurances": { - "view": true, - "create": false, - "update": false, - "delete": false + "version": 1, + "resources": { + "appointments": { + "view": true, // Can view appointments list + "create": true, // Can book appointments + "cancel": false, // Can cancel appointments + "update_status": true // Can mark as completed/no_show + }, + "addresses": { + "view": true, + "create": false, + "update": false, + "delete": false + }, + "clinic_info": { + "view": true, + "update": false + }, + "insurances": { + "view": true, + "create": false, + "update": false, + "delete": false + } } - } } ``` ### Response `201` + ```json { "success": true, @@ -107,13 +125,14 @@ Create a secretary for a doctor. | `clinic` | منشی توسط مدیر کلینیک تعریف شده — فقط کلینیک | ### Errors -| Code | HTTP | Description | -|------|------|-------------| -| `ERR_AUTH_001` | 401 | Missing token | -| `ERR_AUTH_006` | 403 | Not the doctor owner / clinic owner / admin | -| `ERR_NOT_FOUND_001` | 404 | Doctor not found | -| `ERR_CONFLICT_001` | 409 | Secretary already added for this doctor | -| `ERR_SECRETARY_001` | 422 | Plan limit for secretaries reached | + +| Code | HTTP | Description | +| ------------------- | ---- | ------------------------------------------- | +| `ERR_AUTH_001` | 401 | Missing token | +| `ERR_AUTH_006` | 403 | Not the doctor owner / clinic owner / admin | +| `ERR_NOT_FOUND_001` | 404 | Doctor not found | +| `ERR_CONFLICT_001` | 409 | Secretary already added for this doctor | +| `ERR_SECRETARY_001` | 422 | Plan limit for secretaries reached | --- @@ -124,11 +143,13 @@ Get secretary detail. **Permission:** `AUTH` — must be the linked doctor or `ROLE_ADMIN` ### Path Parameters -| Param | Type | Description | -|-------|------|-------------| + +| Param | Type | Description | +| ------ | ------------- | -------------- | | `uuid` | string (UUID) | Secretary UUID | ### Response `200` + ```json { "success": true, @@ -144,11 +165,12 @@ Get secretary detail. ``` ### Errors -| Code | HTTP | Description | -|------|------|-------------| -| `ERR_AUTH_001` | 401 | Missing token | -| `ERR_FORBIDDEN_001` | 403 | Not authorized | -| `ERR_NOT_FOUND_001` | 404 | Secretary not found | + +| Code | HTTP | Description | +| ------------------- | ---- | ------------------- | +| `ERR_AUTH_001` | 401 | Missing token | +| `ERR_FORBIDDEN_001` | 403 | Not authorized | +| `ERR_NOT_FOUND_001` | 404 | Secretary not found | --- @@ -159,32 +181,40 @@ Update secretary active status or permissions. **Permission:** `ROLE_DOCTOR` — must be the linked doctor ### Request Body (`application/json`) + ```json { - "active": false, - "permissions": { - "version": 1, - "resources": { - "appointments": { "view": true, "create": false, "cancel": false, "update_status": false } + "active": false, + "permissions": { + "version": 1, + "resources": { + "appointments": { + "view": true, + "create": false, + "cancel": false, + "update_status": false + } + } } - } } ``` -| Field | Type | Required | Description | -|-------|------|----------|-------------| -| `active` | boolean | ❌ | Enable/disable secretary | -| `permissions` | object | ❌ | New permissions object | +| Field | Type | Required | Description | +| ------------- | ------- | -------- | ------------------------ | +| `active` | boolean | ❌ | Enable/disable secretary | +| `permissions` | object | ❌ | New permissions object | ### Response `200` + Updated secretary object. ### Errors -| Code | HTTP | Description | -|------|------|-------------| -| `ERR_AUTH_001` | 401 | Missing token | -| `ERR_FORBIDDEN_001` | 403 | Not the linked doctor | -| `ERR_NOT_FOUND_001` | 404 | Secretary not found | + +| Code | HTTP | Description | +| ------------------- | ---- | --------------------- | +| `ERR_AUTH_001` | 401 | Missing token | +| `ERR_FORBIDDEN_001` | 403 | Not the linked doctor | +| `ERR_NOT_FOUND_001` | 404 | Secretary not found | --- @@ -195,16 +225,18 @@ Delete a secretary. **Permission:** `ROLE_DOCTOR` — must be the linked doctor ### Response `200` + ```json { "success": true, "data": { "message": "منشی حذف شد" } } ``` ### Errors -| Code | HTTP | Description | -|------|------|-------------| -| `ERR_AUTH_001` | 401 | Missing token | -| `ERR_FORBIDDEN_001` | 403 | Not the linked doctor | -| `ERR_NOT_FOUND_001` | 404 | Secretary not found | + +| Code | HTTP | Description | +| ------------------- | ---- | --------------------- | +| `ERR_AUTH_001` | 401 | Missing token | +| `ERR_FORBIDDEN_001` | 403 | Not the linked doctor | +| `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` ### Path Parameters -| Param | Type | Description | -|-------|------|-------------| + +| Param | Type | Description | +| ------------ | ------------- | ----------- | | `doctorUuid` | string (UUID) | Doctor UUID | ### Response `200` + ```json { "success": true, @@ -239,11 +273,12 @@ Get all secretaries for a specific doctor. ``` ### Errors -| Code | HTTP | Description | -|------|------|-------------| -| `ERR_AUTH_001` | 401 | Missing token | -| `ERR_FORBIDDEN_001` | 403 | Not authorized | -| `ERR_NOT_FOUND_001` | 404 | Doctor not found | + +| Code | HTTP | Description | +| ------------------- | ---- | ---------------- | +| `ERR_AUTH_001` | 401 | Missing token | +| `ERR_FORBIDDEN_001` | 403 | Not authorized | +| `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` ### Path Parameters -| Param | Type | Description | -|-------|------|-------------| + +| Param | Type | Description | +| ------------ | ------------- | ----------- | | `clinicUuid` | string (UUID) | Clinic UUID | ### Response `200` + ```json { "success": true, @@ -278,16 +315,18 @@ Get all secretaries across **all doctors** of a clinic. ``` ### Notes -- این endpoint فقط منشیانی را برمی‌گرداند که با `owner_type='clinic'` تعریف شده‌اند -- منشیانی که خود دکتر (با `owner_type='doctor'`) تعریف کرده از این لیست مخفی هستند + +- این endpoint فقط منشی های را برمی‌گرداند که با `owner_type='clinic'` تعریف شده‌اند +- منشی های که خود دکتر (با `owner_type='doctor'`) تعریف کرده از این لیست مخفی هستند - یک منشی می‌تواند به چند دکتر در همان کلینیک متصل باشد — در لیست چندبار ظاهر می‌شود (یک ردیف به ازای هر دکتر) ### Errors -| Code | HTTP | Description | -|------|------|-------------| -| `ERR_AUTH_001` | 401 | Missing token | -| `ERR_FORBIDDEN_001` | 403 | Not clinic owner | -| `ERR_NOT_FOUND_001` | 404 | Clinic not found | + +| Code | HTTP | Description | +| ------------------- | ---- | ---------------- | +| `ERR_AUTH_001` | 401 | Missing token | +| `ERR_FORBIDDEN_001` | 403 | Not clinic owner | +| `ERR_NOT_FOUND_001` | 404 | Clinic not found | --- @@ -295,23 +334,23 @@ Get all secretaries across **all doctors** of a clinic. تعداد منشی‌های مجاز بر اساس پنل فعال doctor تعیین می‌شود: -| پنل | حداکثر منشی | -|-----|-------------| -| Free (بدون اشتراک) | ۱ | -| Basic | ۳ | -| Professional | ۱۰ | +| پنل | حداکثر منشی | +| ------------------ | ----------- | +| Free (بدون اشتراک) | ۱ | +| Basic | ۳ | +| Professional | ۱۰ | اگر تعداد منشی‌های فعال به حد مجاز رسیده باشد، ایجاد منشی جدید خطای زیر را برمی‌گرداند: ```json { - "success": false, - "errors": [ - { - "code": "ERR_SECRETARY_001", - "message": "پلن فعلی اجازه منشی بیشتر را نمی‌دهد" - } - ] + "success": false, + "errors": [ + { + "code": "ERR_SECRETARY_001", + "message": "پلن فعلی اجازه منشی بیشتر را نمی‌دهد" + } + ] } ``` diff --git a/docs/phase2_taskes/task-12-secretary-completion/task.md b/docs/phase2_taskes/task-12-secretary-completion/task.md index fb5142c9..0a12f27f 100644 --- a/docs/phase2_taskes/task-12-secretary-completion/task.md +++ b/docs/phase2_taskes/task-12-secretary-completion/task.md @@ -1,7 +1,9 @@ # تسک ۱۲: تکمیل منشی — محدودیت پنل + UI Permissions ## توضیح + Entity `DoctorSecretary` و API کامل موجود است (`src/Secretary/`). این تسک فقط دو چیز اضافه می‌کند: + 1. Backend: بررسی سقف تعداد منشی بر اساس پنل اشتراکی 2. Frontend: Modal با checkbox matrix برای ویرایش permissions @@ -9,30 +11,33 @@ Entity `DoctorSecretary` و API کامل موجود است (`src/Secretary/`). ## فایل‌های موجود که تغییر می‌کنند -| فایل | تغییر | -|------|-------| +| فایل | تغییر | +| -------------------------------------------------- | ------------------------------------------------------------------ | | `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 موجود (بدون تغییر) -| متد | مسیر | توضیح | -|-----|------|-------| -| GET | `/api/v1/secretaries/{doctorUuid}` | لیست منشیان | -| POST | `/api/v1/secretary` | ایجاد منشی ← **اینجا limit check اضافه می‌شود** | -| PATCH | `/api/v1/secretary/{uuid}` | ویرایش ← **permissions هم قابل ویرایش می‌شود** | -| DELETE | `/api/v1/secretary/{uuid}` | غیرفعال‌سازی (soft delete) | +| متد | مسیر | توضیح | +| ------ | ---------------------------------- | ----------------------------------------------- | +| GET | `/api/v1/secretaries/{doctorUuid}` | لیست منشی ها | +| POST | `/api/v1/secretary` | ایجاد منشی ← **اینجا limit check اضافه می‌شود** | +| PATCH | `/api/v1/secretary/{uuid}` | ویرایش ← **permissions هم قابل ویرایش می‌شود** | +| DELETE | `/api/v1/secretary/{uuid}` | غیرفعال‌سازی (soft delete) | ## پیش‌نیازها + - تسک ۱۴ (Secretary — موجود در کد) - **تسک ۱۱** (Subscription — `SubscriptionService` باید موجود باشد) ## زمان تخمینی + ۴ تا ۵ ساعت ## تغییر Backend — SecretaryController::create() ### وضعیت فعلی (قبل): + ```php public function create(Request $request): JsonResponse { @@ -44,6 +49,7 @@ public function create(Request $request): JsonResponse ``` ### وضعیت جدید (بعد): + ```php public function create(Request $request): JsonResponse { @@ -68,6 +74,7 @@ public function create(Request $request): JsonResponse ``` ### خطای جدید که باید در ErrorCodes.php اضافه شود: + ```php // src/Shared/Constant/ErrorCodes.php 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 ### ساختار DEFAULT_PERMISSIONS (از DoctorSecretary::DEFAULT_PERMISSIONS): + ```php [ 'version' => 1, @@ -90,6 +98,7 @@ const ERR_SECRETARY_LIMIT_REACHED = 'ERR_SECRETARY_LIMIT_REACHED'; ``` ### نمونه UI Permissions checkbox matrix در Modal: + ``` ┌─────────────────────────────────────────────────────┐ │ دسترسی‌های منشی │ @@ -104,31 +113,45 @@ const ERR_SECRETARY_LIMIT_REACHED = 'ERR_SECRETARY_LIMIT_REACHED'; ``` ### PATCH /api/v1/secretary/{uuid} با permissions: + ```json { - "permissions": { - "version": 1, - "resources": { - "appointments": { "view": true, "create": true, "cancel": true, "update_status": true }, - "addresses": { "view": true, "create": false, "update": false, "delete": false } + "permissions": { + "version": 1, + "resources": { + "appointments": { + "view": true, + "create": true, + "cancel": true, + "update_status": true + }, + "addresses": { + "view": true, + "create": false, + "update": false, + "delete": false + } + } } - } } ``` ## کدهای خطای جدید -| کد | پیام فارسی | -|----|-----------| +| کد | پیام فارسی | +| ----------------------------- | ---------------------------------------- | | `ERR_SECRETARY_LIMIT_REACHED` | سقف تعداد منشی پنل اشتراکی شما رسیده است | ## Response خطا (403) + ```json { - "success": false, - "errors": [{ - "code": "ERR_SECRETARY_LIMIT_REACHED", - "message": "سقف تعداد منشی پنل اشتراکی شما رسیده است. برای افزودن منشی بیشتر پنل را ارتقاء دهید." - }] + "success": false, + "errors": [ + { + "code": "ERR_SECRETARY_LIMIT_REACHED", + "message": "سقف تعداد منشی پنل اشتراکی شما رسیده است. برای افزودن منشی بیشتر پنل را ارتقاء دهید." + } + ] } ``` diff --git a/docs/phase2_taskes/task-12-secretary-completion/user_flow.md b/docs/phase2_taskes/task-12-secretary-completion/user_flow.md index b4b829c9..09e65c79 100644 --- a/docs/phase2_taskes/task-12-secretary-completion/user_flow.md +++ b/docs/phase2_taskes/task-12-secretary-completion/user_flow.md @@ -64,7 +64,7 @@ Professional → 5 → 403 ## نکته: منشی غیرفعال از سقف کم نمی‌شود ``` -countActive() فقط منشیان active=true را می‌شمارد +countActive() فقط منشی ها active=true را می‌شمارد یعنی: اگر 2 منشی داری و یکی را غیرفعال کنی → می‌توانی دوباره 1 منشی جدید اضافه کنی (مادامی که در پنل Basic هستی و سقف 2 داری) ``` diff --git a/seed_realistic_data.php b/seed_realistic_data.php new file mode 100644 index 00000000..65a3d712 --- /dev/null +++ b/seed_realistic_data.php @@ -0,0 +1,439 @@ +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"; diff --git a/src/Secretary/Controller/SecretaryController.php b/src/Secretary/Controller/SecretaryController.php index c4fd37e8..271aef73 100644 --- a/src/Secretary/Controller/SecretaryController.php +++ b/src/Secretary/Controller/SecretaryController.php @@ -171,7 +171,7 @@ class SecretaryController extends BaseController return $this->success(['message' => 'منشی غیرفعال شد']); } - /** لیست منشیان مطب شخصی یک دکتر */ + /** لیست منشی ها مطب شخصی یک دکتر */ #[Route('/api/v1/secretaries/{doctorUuid}', methods: ['GET'])] 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); } - // فقط خود دکتر یا ادمین می‌توانند منشیان مطب شخصی را ببینند + // فقط خود دکتر یا ادمین می‌توانند منشی ها مطب شخصی را ببینند if (!$currentUser->hasRole('ROLE_ADMIN') && $doctor->getUser()->getId() !== $currentUser->getId()) { return $this->error(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403); } @@ -193,7 +193,7 @@ class SecretaryController extends BaseController return $this->success($secretaries); } - /** لیست همه منشیان کلینیک (از همه دکترها، owner_type='clinic') */ + /** لیست همه منشی ها کلینیک (از همه دکترها، owner_type='clinic') */ #[Route('/api/v1/secretaries/clinic/{clinicUuid}', methods: ['GET'])] public function listByClinic(string $clinicUuid, #[CurrentUser] User $currentUser): JsonResponse { @@ -227,7 +227,7 @@ class SecretaryController extends BaseController } if ($secretary->getOwnerType() === DoctorSecretary::OWNER_CLINIC) { - // فقط مالک کلینیک می‌تواند منشیان کلینیکی را مدیریت کند + // فقط مالک کلینیک می‌تواند منشی ها کلینیکی را مدیریت کند if (!$user->hasRole('ROLE_CLINIC')) { return false; } diff --git a/src/Secretary/Repository/DoctorSecretaryRepository.php b/src/Secretary/Repository/DoctorSecretaryRepository.php index 43542bf4..3e18b690 100644 --- a/src/Secretary/Repository/DoctorSecretaryRepository.php +++ b/src/Secretary/Repository/DoctorSecretaryRepository.php @@ -38,7 +38,7 @@ class DoctorSecretaryRepository extends ServiceEntityRepository return $this->findBy(['doctor' => $doctor], ['createdAt' => 'DESC']); } - /** منشیان مطب شخصی یک دکتر (owner_type='doctor') */ + /** منشی ها مطب شخصی یک دکتر (owner_type='doctor') */ public function findByDoctorScope(Doctor $doctor): array { return $this->findBy( @@ -108,7 +108,7 @@ class DoctorSecretaryRepository extends ServiceEntityRepository return $clinic->getDoctors()->contains($doctor); } - /** همه منشیان کلینیک (owner_type='clinic') */ + /** همه منشی ها کلینیک (owner_type='clinic') */ public function findByClinic(Clinic $clinic): array { return $this->createQueryBuilder('s')