feat: implement realistic data seeding for doctors, clinics, and secretaries
- Added seed_realistic_data.php to clean existing data and populate the database with realistic entries for doctors, clinics, and secretaries. - Created a structured approach to generate 100 doctors per city with diverse specialties and services. - Implemented database cleanup routines to ensure a fresh start for data seeding. - Enhanced the DoctorSecretaryRepository with improved comments for clarity.
This commit is contained in:
@@ -32,6 +32,7 @@ PATCH /api/v1/staff/{uuid}/toggle → فعال/غیرفعال
|
|||||||
```
|
```
|
||||||
|
|
||||||
**Response GET:**
|
**Response GET:**
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"success": true,
|
"success": true,
|
||||||
@@ -51,6 +52,7 @@ PATCH /api/v1/staff/{uuid}/toggle → فعال/غیرفعال
|
|||||||
```
|
```
|
||||||
|
|
||||||
**Response PATCH toggle:**
|
**Response PATCH toggle:**
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{ "success": true, "data": { "active": false } }
|
{ "success": true, "data": { "active": false } }
|
||||||
```
|
```
|
||||||
@@ -58,6 +60,7 @@ PATCH /api/v1/staff/{uuid}/toggle → فعال/غیرفعال
|
|||||||
### فایلهای جدید
|
### فایلهای جدید
|
||||||
|
|
||||||
**`assets/admin/pages/StaffPage.tsx`** — صفحه مدیریت پرسنل:
|
**`assets/admin/pages/StaffPage.tsx`** — صفحه مدیریت پرسنل:
|
||||||
|
|
||||||
- `useQuery(['staff'])` → `GET /api/v1/staff` → `data?.data ?? []`
|
- `useQuery(['staff'])` → `GET /api/v1/staff` → `data?.data ?? []`
|
||||||
- جدول با ستونها: نام، سمت، تلفن، کد ملی، وضعیت (badge)، عملیات
|
- جدول با ستونها: نام، سمت، تلفن، کد ملی، وضعیت (badge)، عملیات
|
||||||
- دکمه «افزودن پرسنل» → Modal ایجاد
|
- دکمه «افزودن پرسنل» → Modal ایجاد
|
||||||
@@ -66,6 +69,7 @@ PATCH /api/v1/staff/{uuid}/toggle → فعال/غیرفعال
|
|||||||
- حذف سخت نداریم — فقط toggle
|
- حذف سخت نداریم — فقط toggle
|
||||||
|
|
||||||
**فیلدهای فرم:**
|
**فیلدهای فرم:**
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
interface StaffFormData {
|
interface StaffFormData {
|
||||||
full_name: string; // required
|
full_name: string; // required
|
||||||
@@ -77,9 +81,10 @@ interface StaffFormData {
|
|||||||
```
|
```
|
||||||
|
|
||||||
**Zod schema:**
|
**Zod schema:**
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
const schema = z.object({
|
const schema = z.object({
|
||||||
full_name: z.string().min(2, 'نام حداقل ۲ کاراکتر باید باشد'),
|
full_name: z.string().min(2, "نام حداقل ۲ کاراکتر باید باشد"),
|
||||||
phone: z.string().optional(),
|
phone: z.string().optional(),
|
||||||
job_title: z.string().optional(),
|
job_title: z.string().optional(),
|
||||||
address: z.string().optional(),
|
address: z.string().optional(),
|
||||||
@@ -90,13 +95,22 @@ const schema = z.object({
|
|||||||
### Route و Sidebar
|
### Route و Sidebar
|
||||||
|
|
||||||
در `App.tsx` اضافه کن:
|
در `App.tsx` اضافه کن:
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
<Route path="staff" element={<RoleRoute roles={['doctor', 'clinic']}><StaffPage /></RoleRoute>} />
|
<Route
|
||||||
|
path="staff"
|
||||||
|
element={
|
||||||
|
<RoleRoute roles={["doctor", "clinic"]}>
|
||||||
|
<StaffPage />
|
||||||
|
</RoleRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
```
|
```
|
||||||
|
|
||||||
در Sidebar (فایل `components/layout/AdminLayout.tsx` یا `Sidebar.tsx`) لینک «پرسنل» را برای نقشهای `doctor` و `clinic` اضافه کن.
|
در Sidebar (فایل `components/layout/AdminLayout.tsx` یا `Sidebar.tsx`) لینک «پرسنل» را برای نقشهای `doctor` و `clinic` اضافه کن.
|
||||||
|
|
||||||
### کامیت
|
### کامیت
|
||||||
|
|
||||||
```
|
```
|
||||||
git add assets/admin/pages/StaffPage.tsx assets/admin/App.tsx assets/admin/components/layout/...
|
git add assets/admin/pages/StaffPage.tsx assets/admin/App.tsx assets/admin/components/layout/...
|
||||||
git commit -m "feat: add staff management page (TASK-10)"
|
git commit -m "feat: add staff management page (TASK-10)"
|
||||||
@@ -116,6 +130,7 @@ POST /api/v1/subscription-payment → شروع پرداخت { period_uuid, gate
|
|||||||
```
|
```
|
||||||
|
|
||||||
**Response GET /plans:**
|
**Response GET /plans:**
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"success": true,
|
"success": true,
|
||||||
@@ -127,8 +142,20 @@ POST /api/v1/subscription-payment → شروع پرداخت { period_uuid, gate
|
|||||||
"max_secretaries": 2,
|
"max_secretaries": 2,
|
||||||
"features": { "patient_records": true, "services": true },
|
"features": { "patient_records": true, "services": true },
|
||||||
"periods": [
|
"periods": [
|
||||||
{ "uuid": "...", "label": "تریال ۱ ماهه", "duration_months": 1, "price_rials": 0, "is_trial": true },
|
{
|
||||||
{ "uuid": "...", "label": "۱ ماهه", "duration_months": 1, "price_rials": 250000, "is_trial": false }
|
"uuid": "...",
|
||||||
|
"label": "تریال ۱ ماهه",
|
||||||
|
"duration_months": 1,
|
||||||
|
"price_rials": 0,
|
||||||
|
"is_trial": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"uuid": "...",
|
||||||
|
"label": "۱ ماهه",
|
||||||
|
"duration_months": 1,
|
||||||
|
"price_rials": 250000,
|
||||||
|
"is_trial": false
|
||||||
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
@@ -136,11 +163,16 @@ POST /api/v1/subscription-payment → شروع پرداخت { period_uuid, gate
|
|||||||
```
|
```
|
||||||
|
|
||||||
**Response GET /my:**
|
**Response GET /my:**
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"success": true,
|
"success": true,
|
||||||
"data": {
|
"data": {
|
||||||
"plan": { "name": "basic", "level": 1, "features": { "patient_records": true, "services": true } },
|
"plan": {
|
||||||
|
"name": "basic",
|
||||||
|
"level": 1,
|
||||||
|
"features": { "patient_records": true, "services": true }
|
||||||
|
},
|
||||||
"period": { "label": "۶ ماهه", "duration_months": 6 },
|
"period": { "label": "۶ ماهه", "duration_months": 6 },
|
||||||
"is_trial": false,
|
"is_trial": false,
|
||||||
"starts_at": 1718000000,
|
"starts_at": 1718000000,
|
||||||
@@ -156,17 +188,20 @@ POST /api/v1/subscription-payment → شروع پرداخت { period_uuid, gate
|
|||||||
**`assets/admin/pages/SubscriptionPage.tsx`** — صفحه اشتراک:
|
**`assets/admin/pages/SubscriptionPage.tsx`** — صفحه اشتراک:
|
||||||
|
|
||||||
**بخش ۱ — وضعیت فعلی:**
|
**بخش ۱ — وضعیت فعلی:**
|
||||||
|
|
||||||
- نمایش پلن فعلی (نام، تاریخ انقضا به شمسی، روزهای باقیمانده)
|
- نمایش پلن فعلی (نام، تاریخ انقضا به شمسی، روزهای باقیمانده)
|
||||||
- اگر `used_trial = false` → دکمه «فعالسازی تریال رایگان» (Basic یک ماهه)
|
- اگر `used_trial = false` → دکمه «فعالسازی تریال رایگان» (Basic یک ماهه)
|
||||||
- نوار پیشرفت روزهای باقیمانده (اگر `expires_at` دارد)
|
- نوار پیشرفت روزهای باقیمانده (اگر `expires_at` دارد)
|
||||||
|
|
||||||
**بخش ۲ — کارتهای پلن:**
|
**بخش ۲ — کارتهای پلن:**
|
||||||
|
|
||||||
- سه کارت: Free / Basic / Professional
|
- سه کارت: Free / Basic / Professional
|
||||||
- هر کارت: نام پلن، ویژگیها (لیست چکمارک)، دورههای قیمت به صورت badge/tab
|
- هر کارت: نام پلن، ویژگیها (لیست چکمارک)، دورههای قیمت به صورت badge/tab
|
||||||
- دکمه «خرید» برای هر دوره → modal انتخاب gateway → `POST /api/v1/subscription-payment` → redirect به URL برگشتی
|
- دکمه «خرید» برای هر دوره → modal انتخاب gateway → `POST /api/v1/subscription-payment` → redirect به URL برگشتی
|
||||||
- اگر پلن فعلی همان پلن است → دکمه «تمدید»
|
- اگر پلن فعلی همان پلن است → دکمه «تمدید»
|
||||||
|
|
||||||
**Types جدید (`assets/admin/types/index.ts`):**
|
**Types جدید (`assets/admin/types/index.ts`):**
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
export interface SubscriptionPlan {
|
export interface SubscriptionPlan {
|
||||||
uuid: string;
|
uuid: string;
|
||||||
@@ -197,10 +232,18 @@ export interface MySubscription {
|
|||||||
### Route
|
### Route
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
<Route path="subscription" element={<RoleRoute roles={['doctor', 'clinic']}><SubscriptionPage /></RoleRoute>} />
|
<Route
|
||||||
|
path="subscription"
|
||||||
|
element={
|
||||||
|
<RoleRoute roles={["doctor", "clinic"]}>
|
||||||
|
<SubscriptionPage />
|
||||||
|
</RoleRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
```
|
```
|
||||||
|
|
||||||
### کامیت
|
### کامیت
|
||||||
|
|
||||||
```
|
```
|
||||||
git commit -m "feat: add subscription management page (TASK-11)"
|
git commit -m "feat: add subscription management page (TASK-11)"
|
||||||
```
|
```
|
||||||
@@ -213,7 +256,7 @@ git commit -m "feat: add subscription management page (TASK-11)"
|
|||||||
|
|
||||||
### وضعیت فعلی SecretariesPage.tsx
|
### وضعیت فعلی SecretariesPage.tsx
|
||||||
|
|
||||||
فایل موجود است و لیست منشیان را نشان میدهد. باید:
|
فایل موجود است و لیست منشی ها را نشان میدهد. باید:
|
||||||
|
|
||||||
1. **Modal ویرایش permissions** اضافه کن:
|
1. **Modal ویرایش permissions** اضافه کن:
|
||||||
- دکمه «دسترسیها» (ShieldCheckIcon) کنار هر ردیف
|
- دکمه «دسترسیها» (ShieldCheckIcon) کنار هر ردیف
|
||||||
@@ -222,7 +265,12 @@ git commit -m "feat: add subscription management page (TASK-11)"
|
|||||||
```tsx
|
```tsx
|
||||||
// ساختار permissions که باید از API بخوانی و ذخیره کنی
|
// ساختار permissions که باید از API بخوانی و ذخیره کنی
|
||||||
const DEFAULT_PERMISSIONS = {
|
const DEFAULT_PERMISSIONS = {
|
||||||
appointments: { view: true, create: false, cancel: false, update_status: false },
|
appointments: {
|
||||||
|
view: true,
|
||||||
|
create: false,
|
||||||
|
cancel: false,
|
||||||
|
update_status: false,
|
||||||
|
},
|
||||||
addresses: { view: true, create: false, update: false, delete: false },
|
addresses: { view: true, create: false, update: false, delete: false },
|
||||||
clinic_info: { view: true, update: false },
|
clinic_info: { view: true, update: false },
|
||||||
insurances: { view: true, create: false, update: false, delete: false },
|
insurances: { view: true, create: false, update: false, delete: false },
|
||||||
@@ -230,56 +278,61 @@ const DEFAULT_PERMISSIONS = {
|
|||||||
|
|
||||||
const PERMISSION_LABELS = {
|
const PERMISSION_LABELS = {
|
||||||
appointments: {
|
appointments: {
|
||||||
label: 'نوبتها',
|
label: "نوبتها",
|
||||||
actions: [
|
actions: [
|
||||||
{ key: 'view', label: 'مشاهده' },
|
{ key: "view", label: "مشاهده" },
|
||||||
{ key: 'create', label: 'ایجاد' },
|
{ key: "create", label: "ایجاد" },
|
||||||
{ key: 'cancel', label: 'لغو' },
|
{ key: "cancel", label: "لغو" },
|
||||||
{ key: 'update_status', label: 'تغییر وضعیت' },
|
{ key: "update_status", label: "تغییر وضعیت" },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
addresses: {
|
addresses: {
|
||||||
label: 'آدرسها',
|
label: "آدرسها",
|
||||||
actions: [
|
actions: [
|
||||||
{ key: 'view', label: 'مشاهده' },
|
{ key: "view", label: "مشاهده" },
|
||||||
{ key: 'create', label: 'ایجاد' },
|
{ key: "create", label: "ایجاد" },
|
||||||
{ key: 'update', label: 'ویرایش' },
|
{ key: "update", label: "ویرایش" },
|
||||||
{ key: 'delete', label: 'حذف' },
|
{ key: "delete", label: "حذف" },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
clinic_info: {
|
clinic_info: {
|
||||||
label: 'اطلاعات کلینیک',
|
label: "اطلاعات کلینیک",
|
||||||
actions: [
|
actions: [
|
||||||
{ key: 'view', label: 'مشاهده' },
|
{ key: "view", label: "مشاهده" },
|
||||||
{ key: 'update', label: 'ویرایش' },
|
{ key: "update", label: "ویرایش" },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
insurances: {
|
insurances: {
|
||||||
label: 'بیمهها',
|
label: "بیمهها",
|
||||||
actions: [
|
actions: [
|
||||||
{ key: 'view', label: 'مشاهده' },
|
{ key: "view", label: "مشاهده" },
|
||||||
{ key: 'create', label: 'ایجاد' },
|
{ key: "create", label: "ایجاد" },
|
||||||
{ key: 'update', label: 'ویرایش' },
|
{ key: "update", label: "ویرایش" },
|
||||||
{ key: 'delete', label: 'حذف' },
|
{ key: "delete", label: "حذف" },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
```
|
```
|
||||||
|
|
||||||
2. **PATCH `/api/v1/secretary/{uuid}`** با body:
|
2. **PATCH `/api/v1/secretary/{uuid}`** با body:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{ "permissions": { "appointments": { "view": true, ... }, ... } }
|
{ "permissions": { "appointments": { "view": true, ... }, ... } }
|
||||||
```
|
```
|
||||||
|
|
||||||
3. **خطای `ERR_SECRETARY_LIMIT_REACHED` (403)** را هنگام ایجاد منشی handle کن:
|
3. **خطای `ERR_SECRETARY_LIMIT_REACHED` (403)** را هنگام ایجاد منشی handle کن:
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
// در onError useMutation create:
|
// در onError useMutation create:
|
||||||
if (err.code === 'ERR_SECRETARY_LIMIT_REACHED') {
|
if (err.code === "ERR_SECRETARY_LIMIT_REACHED") {
|
||||||
toast.error('سقف تعداد منشی پنل فعلی شما پر است. برای افزودن منشی بیشتر پنل را ارتقا دهید.');
|
toast.error(
|
||||||
|
"سقف تعداد منشی پنل فعلی شما پر است. برای افزودن منشی بیشتر پنل را ارتقا دهید.",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### کامیت
|
### کامیت
|
||||||
|
|
||||||
```
|
```
|
||||||
git commit -m "feat: add secretary permissions matrix and subscription limit handling (TASK-12)"
|
git commit -m "feat: add secretary permissions matrix and subscription limit handling (TASK-12)"
|
||||||
```
|
```
|
||||||
@@ -303,16 +356,16 @@ DELETE /api/v1/service-item/{uuid} → حذف آیتم
|
|||||||
```
|
```
|
||||||
|
|
||||||
**Response GET /service-sections:**
|
**Response GET /service-sections:**
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"success": true,
|
"success": true,
|
||||||
"data": [
|
"data": [{ "uuid": "...", "name": "تزریقات", "active": true }]
|
||||||
{ "uuid": "...", "name": "تزریقات", "active": true }
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
**Response GET /service-items/{sectionUuid}:**
|
**Response GET /service-items/{sectionUuid}:**
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"success": true,
|
"success": true,
|
||||||
@@ -333,16 +386,19 @@ DELETE /api/v1/service-item/{uuid} → حذف آیتم
|
|||||||
**`assets/admin/pages/ClinicServicesPage.tsx`** — صفحه دوبخشی:
|
**`assets/admin/pages/ClinicServicesPage.tsx`** — صفحه دوبخشی:
|
||||||
|
|
||||||
**ستون چپ** — لیست بخشها (ServiceSection):
|
**ستون چپ** — لیست بخشها (ServiceSection):
|
||||||
|
|
||||||
- لیست ساده با دکمههای ویرایش/حذف
|
- لیست ساده با دکمههای ویرایش/حذف
|
||||||
- دکمه «+ بخش جدید» → Modal با فیلد `name`
|
- دکمه «+ بخش جدید» → Modal با فیلد `name`
|
||||||
- کلیک روی بخش → بارگذاری آیتمهای آن در ستون راست
|
- کلیک روی بخش → بارگذاری آیتمهای آن در ستون راست
|
||||||
|
|
||||||
**ستون راست** — لیست آیتمهای بخش انتخابی (ServiceItem):
|
**ستون راست** — لیست آیتمهای بخش انتخابی (ServiceItem):
|
||||||
|
|
||||||
- جدول: نام، قیمت (`formatRial()`), پرسنل مسئول، وضعیت
|
- جدول: نام، قیمت (`formatRial()`), پرسنل مسئول، وضعیت
|
||||||
- دکمه «+ آیتم جدید»
|
- دکمه «+ آیتم جدید»
|
||||||
- حذف: اگر `ERR_SERVICE_ITEM_IN_USE` (409) → toast.error('این سرویس در پرونده بیمار استفاده شده و قابل حذف نیست')
|
- حذف: اگر `ERR_SERVICE_ITEM_IN_USE` (409) → toast.error('این سرویس در پرونده بیمار استفاده شده و قابل حذف نیست')
|
||||||
|
|
||||||
**فرم آیتم:**
|
**فرم آیتم:**
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
interface ServiceItemForm {
|
interface ServiceItemForm {
|
||||||
section_uuid: string; // hidden — از state
|
section_uuid: string; // hidden — از state
|
||||||
@@ -353,6 +409,7 @@ interface ServiceItemForm {
|
|||||||
```
|
```
|
||||||
|
|
||||||
**Types جدید:**
|
**Types جدید:**
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
export interface ServiceSection {
|
export interface ServiceSection {
|
||||||
uuid: string;
|
uuid: string;
|
||||||
@@ -371,10 +428,18 @@ export interface ServiceItem {
|
|||||||
### Route
|
### Route
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
<Route path="clinic-services" element={<RoleRoute roles={['doctor', 'clinic']}><ClinicServicesPage /></RoleRoute>} />
|
<Route
|
||||||
|
path="clinic-services"
|
||||||
|
element={
|
||||||
|
<RoleRoute roles={["doctor", "clinic"]}>
|
||||||
|
<ClinicServicesPage />
|
||||||
|
</RoleRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
```
|
```
|
||||||
|
|
||||||
### کامیت
|
### کامیت
|
||||||
|
|
||||||
```
|
```
|
||||||
git commit -m "feat: add clinic services management page (TASK-13)"
|
git commit -m "feat: add clinic services management page (TASK-13)"
|
||||||
```
|
```
|
||||||
@@ -394,6 +459,7 @@ PATCH /api/v1/sms/settings → ذخیره تنظیمات
|
|||||||
```
|
```
|
||||||
|
|
||||||
**Response GET /sms/wallet/balance:**
|
**Response GET /sms/wallet/balance:**
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"success": true,
|
"success": true,
|
||||||
@@ -406,18 +472,32 @@ PATCH /api/v1/sms/settings → ذخیره تنظیمات
|
|||||||
```
|
```
|
||||||
|
|
||||||
**Response GET /sms/wallet/logs:**
|
**Response GET /sms/wallet/logs:**
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"success": true,
|
"success": true,
|
||||||
"data": [
|
"data": [
|
||||||
{ "uuid": "...", "type": "credit", "amount_rials": 500000, "description": "شارژ", "created_at": 1718000000 },
|
{
|
||||||
{ "uuid": "...", "type": "debit", "amount_rials": 250, "description": "ارسال پیامک یادآوری", "created_at": 1718001000 }
|
"uuid": "...",
|
||||||
|
"type": "credit",
|
||||||
|
"amount_rials": 500000,
|
||||||
|
"description": "شارژ",
|
||||||
|
"created_at": 1718000000
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"uuid": "...",
|
||||||
|
"type": "debit",
|
||||||
|
"amount_rials": 250,
|
||||||
|
"description": "ارسال پیامک یادآوری",
|
||||||
|
"created_at": 1718001000
|
||||||
|
}
|
||||||
],
|
],
|
||||||
"meta": { "totalRecords": 45, "totalPages": 5, "currentPage": 1 }
|
"meta": { "totalRecords": 45, "totalPages": 5, "currentPage": 1 }
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
**Response GET /sms/settings:**
|
**Response GET /sms/settings:**
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"success": true,
|
"success": true,
|
||||||
@@ -435,20 +515,24 @@ PATCH /api/v1/sms/settings → ذخیره تنظیمات
|
|||||||
**`assets/admin/pages/SmsWalletPage.tsx`** — صفحه کیف پول پیامک:
|
**`assets/admin/pages/SmsWalletPage.tsx`** — صفحه کیف پول پیامک:
|
||||||
|
|
||||||
**بخش ۱ — کارت موجودی:**
|
**بخش ۱ — کارت موجودی:**
|
||||||
|
|
||||||
- نمایش `balance_rials` با `formatRial()`
|
- نمایش `balance_rials` با `formatRial()`
|
||||||
- نمایش `estimated_sms_count` (تعداد پیامک قابل ارسال)
|
- نمایش `estimated_sms_count` (تعداد پیامک قابل ارسال)
|
||||||
- دکمه «شارژ کیف پول» → Modal با فیلد مبلغ + انتخاب gateway → redirect به URL
|
- دکمه «شارژ کیف پول» → Modal با فیلد مبلغ + انتخاب gateway → redirect به URL
|
||||||
|
|
||||||
**بخش ۲ — تنظیمات:**
|
**بخش ۲ — تنظیمات:**
|
||||||
|
|
||||||
- Toggle «یادآوری قبل از نوبت» + فیلد عددی «چند ساعت قبل»
|
- Toggle «یادآوری قبل از نوبت» + فیلد عددی «چند ساعت قبل»
|
||||||
- Toggle «پیامک بعد از ویزیت» + textarea متن پیامک
|
- Toggle «پیامک بعد از ویزیت» + textarea متن پیامک
|
||||||
- دکمه ذخیره → `PATCH /api/v1/sms/settings`
|
- دکمه ذخیره → `PATCH /api/v1/sms/settings`
|
||||||
|
|
||||||
**بخش ۳ — تاریخچه تراکنشها:**
|
**بخش ۳ — تاریخچه تراکنشها:**
|
||||||
|
|
||||||
- جدول: نوع (badge سبز credit / قرمز debit)، مبلغ، توضیحات، تاریخ
|
- جدول: نوع (badge سبز credit / قرمز debit)، مبلغ، توضیحات، تاریخ
|
||||||
- Pagination
|
- Pagination
|
||||||
|
|
||||||
**Types جدید:**
|
**Types جدید:**
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
export interface SmsWalletBalance {
|
export interface SmsWalletBalance {
|
||||||
balance_rials: number;
|
balance_rials: number;
|
||||||
@@ -457,7 +541,7 @@ export interface SmsWalletBalance {
|
|||||||
}
|
}
|
||||||
export interface SmsWalletLog {
|
export interface SmsWalletLog {
|
||||||
uuid: string;
|
uuid: string;
|
||||||
type: 'credit' | 'debit';
|
type: "credit" | "debit";
|
||||||
amount_rials: number;
|
amount_rials: number;
|
||||||
description: string;
|
description: string;
|
||||||
created_at: number;
|
created_at: number;
|
||||||
@@ -473,10 +557,18 @@ export interface SmsSettings {
|
|||||||
### Route
|
### Route
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
<Route path="sms-wallet" element={<RoleRoute roles={['doctor', 'clinic']}><SmsWalletPage /></RoleRoute>} />
|
<Route
|
||||||
|
path="sms-wallet"
|
||||||
|
element={
|
||||||
|
<RoleRoute roles={["doctor", "clinic"]}>
|
||||||
|
<SmsWalletPage />
|
||||||
|
</RoleRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
```
|
```
|
||||||
|
|
||||||
### کامیت
|
### کامیت
|
||||||
|
|
||||||
```
|
```
|
||||||
git commit -m "feat: add SMS wallet and settings page (TASK-14)"
|
git commit -m "feat: add SMS wallet and settings page (TASK-14)"
|
||||||
```
|
```
|
||||||
@@ -497,13 +589,18 @@ PATCH /api/v1/session/{uuid} → ویرایش مراجعه
|
|||||||
```
|
```
|
||||||
|
|
||||||
**Response GET /patients:**
|
**Response GET /patients:**
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"success": true,
|
"success": true,
|
||||||
"data": [
|
"data": [
|
||||||
{
|
{
|
||||||
"uuid": "...",
|
"uuid": "...",
|
||||||
"user": { "uuid": "...", "fullName": "علی محمدی", "phone": "09121234567" },
|
"user": {
|
||||||
|
"uuid": "...",
|
||||||
|
"fullName": "علی محمدی",
|
||||||
|
"phone": "09121234567"
|
||||||
|
},
|
||||||
"created_at": 1710000000
|
"created_at": 1710000000
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
@@ -512,6 +609,7 @@ PATCH /api/v1/session/{uuid} → ویرایش مراجعه
|
|||||||
```
|
```
|
||||||
|
|
||||||
**Response GET /patient/{uuid}/sessions:**
|
**Response GET /patient/{uuid}/sessions:**
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"success": true,
|
"success": true,
|
||||||
@@ -535,6 +633,7 @@ PATCH /api/v1/session/{uuid} → ویرایش مراجعه
|
|||||||
```
|
```
|
||||||
|
|
||||||
**Request POST /patient/{uuid}/session:**
|
**Request POST /patient/{uuid}/session:**
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"visit_price_rials": 500000,
|
"visit_price_rials": 500000,
|
||||||
@@ -542,9 +641,7 @@ PATCH /api/v1/session/{uuid} → ویرایش مراجعه
|
|||||||
"supplementary_discount_percent": 0,
|
"supplementary_discount_percent": 0,
|
||||||
"payment_method": "cash",
|
"payment_method": "cash",
|
||||||
"notes": "...",
|
"notes": "...",
|
||||||
"services": [
|
"services": [{ "service_item_uuid": "...", "staff_uuid": null }]
|
||||||
{ "service_item_uuid": "...", "staff_uuid": null }
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -553,12 +650,14 @@ PATCH /api/v1/session/{uuid} → ویرایش مراجعه
|
|||||||
**`assets/admin/pages/MyPatientsPage.tsx`** — بازنویسی کامل:
|
**`assets/admin/pages/MyPatientsPage.tsx`** — بازنویسی کامل:
|
||||||
|
|
||||||
**نمای لیست بیماران:**
|
**نمای لیست بیماران:**
|
||||||
|
|
||||||
- جستجو (`?search=`) با debounce ۴۰۰ms
|
- جستجو (`?search=`) با debounce ۴۰۰ms
|
||||||
- جدول: نام بیمار، تلفن، تاریخ ثبت، عملیات
|
- جدول: نام بیمار، تلفن، تاریخ ثبت، عملیات
|
||||||
- دکمه «+ بیمار جدید» → Modal با `SearchableSelect` برای پیدا کردن کاربر (search by phone)
|
- دکمه «+ بیمار جدید» → Modal با `SearchableSelect` برای پیدا کردن کاربر (search by phone)
|
||||||
- کلیک روی ردیف → نمای جزئیات پرونده (state محلی یا route `/my-patients/:uuid`)
|
- کلیک روی ردیف → نمای جزئیات پرونده (state محلی یا route `/my-patients/:uuid`)
|
||||||
|
|
||||||
**نمای جزئیات پرونده:**
|
**نمای جزئیات پرونده:**
|
||||||
|
|
||||||
- مشخصات بیمار + دکمه «+ مراجعه جدید»
|
- مشخصات بیمار + دکمه «+ مراجعه جدید»
|
||||||
- لیست مراجعات (accordion یا جدول):
|
- لیست مراجعات (accordion یا جدول):
|
||||||
- قیمت ویزیت، تخفیف بیمه پایه، تخفیف تکمیلی، مجموع سرویسها، قیمت نهایی
|
- قیمت ویزیت، تخفیف بیمه پایه، تخفیف تکمیلی، مجموع سرویسها، قیمت نهایی
|
||||||
@@ -567,26 +666,34 @@ PATCH /api/v1/session/{uuid} → ویرایش مراجعه
|
|||||||
- دکمه ویرایش (فقط `notes` و `payment_method`)
|
- دکمه ویرایش (فقط `notes` و `payment_method`)
|
||||||
|
|
||||||
**Modal ثبت مراجعه جدید:**
|
**Modal ثبت مراجعه جدید:**
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
interface SessionFormData {
|
interface SessionFormData {
|
||||||
visit_price_rials: number;
|
visit_price_rials: number;
|
||||||
base_insurance_discount_percent: number; // 0-100
|
base_insurance_discount_percent: number; // 0-100
|
||||||
supplementary_discount_percent: number; // 0-100
|
supplementary_discount_percent: number; // 0-100
|
||||||
payment_method: 'cash' | 'card' | 'insurance' | 'online' | 'pending';
|
payment_method: "cash" | "card" | "insurance" | "online" | "pending";
|
||||||
notes?: string;
|
notes?: string;
|
||||||
services: { service_item_uuid: string; staff_uuid?: string }[];
|
services: { service_item_uuid: string; staff_uuid?: string }[];
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
- محاسبه `final_price_rials` به صورت live در فرانت:
|
- محاسبه `final_price_rials` به صورت live در فرانت:
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
const afterBase = visitPrice * (1 - baseDiscount / 100);
|
const afterBase = visitPrice * (1 - baseDiscount / 100);
|
||||||
const afterSupp = afterBase * (1 - suppDiscount / 100);
|
const afterSupp = afterBase * (1 - suppDiscount / 100);
|
||||||
const servicesTotal = services.reduce((sum, s) => sum + (s.price_rials || 0), 0);
|
const servicesTotal = services.reduce(
|
||||||
|
(sum, s) => sum + (s.price_rials || 0),
|
||||||
|
0,
|
||||||
|
);
|
||||||
const finalPrice = Math.round(afterSupp) + servicesTotal;
|
const finalPrice = Math.round(afterSupp) + servicesTotal;
|
||||||
```
|
```
|
||||||
|
|
||||||
- افزودن سرویس: `SearchableSelect` از `GET /api/v1/service-items/{sectionUuid}` (باید ابتدا بخش را انتخاب کند)
|
- افزودن سرویس: `SearchableSelect` از `GET /api/v1/service-items/{sectionUuid}` (باید ابتدا بخش را انتخاب کند)
|
||||||
|
|
||||||
**Types جدید:**
|
**Types جدید:**
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
export interface PatientRecord {
|
export interface PatientRecord {
|
||||||
uuid: string;
|
uuid: string;
|
||||||
@@ -612,10 +719,18 @@ export interface PatientSession {
|
|||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
// مسیر موجود کافی است:
|
// مسیر موجود کافی است:
|
||||||
<Route path="my-patients" element={<RoleRoute roles={['doctor', 'secretary', 'clinic']}><MyPatientsPage /></RoleRoute>} />
|
<Route
|
||||||
|
path="my-patients"
|
||||||
|
element={
|
||||||
|
<RoleRoute roles={["doctor", "secretary", "clinic"]}>
|
||||||
|
<MyPatientsPage />
|
||||||
|
</RoleRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
```
|
```
|
||||||
|
|
||||||
### کامیت
|
### کامیت
|
||||||
|
|
||||||
```
|
```
|
||||||
git commit -m "feat: implement patient records and sessions page (TASK-15)"
|
git commit -m "feat: implement patient records and sessions page (TASK-15)"
|
||||||
```
|
```
|
||||||
@@ -633,6 +748,7 @@ GET /api/v1/admin/dashboard/charts?from=UNIX&to=UNIX → سریهای زما
|
|||||||
```
|
```
|
||||||
|
|
||||||
**فیلدهای جدید در Response clinic/doctor:**
|
**فیلدهای جدید در Response clinic/doctor:**
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"stats": {
|
"stats": {
|
||||||
@@ -646,6 +762,7 @@ GET /api/v1/admin/dashboard/charts?from=UNIX&to=UNIX → سریهای زما
|
|||||||
```
|
```
|
||||||
|
|
||||||
**Response GET /admin/dashboard/charts?from=UNIX&to=UNIX:**
|
**Response GET /admin/dashboard/charts?from=UNIX&to=UNIX:**
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"success": true,
|
"success": true,
|
||||||
@@ -670,21 +787,23 @@ GET /api/v1/admin/dashboard/charts?from=UNIX&to=UNIX → سریهای زما
|
|||||||
**۱. فیلتر زمانی — اضافه کن:**
|
**۱. فیلتر زمانی — اضافه کن:**
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
type Preset = 'this_week' | 'this_month' | '3_months' | 'custom';
|
type Preset = "this_week" | "this_month" | "3_months" | "custom";
|
||||||
|
|
||||||
// تبدیل به Unix:
|
// تبدیل به Unix:
|
||||||
function getRange(preset: Preset): { from: number; to: number } {
|
function getRange(preset: Preset): { from: number; to: number } {
|
||||||
const now = Math.floor(Date.now() / 1000);
|
const now = Math.floor(Date.now() / 1000);
|
||||||
switch (preset) {
|
switch (preset) {
|
||||||
case 'this_week':
|
case "this_week":
|
||||||
// شنبهی این هفته — سادهترین راه: now - روزهای گذشته از شنبه
|
// شنبهی این هفته — سادهترین راه: now - روزهای گذشته از شنبه
|
||||||
const dayOfWeek = new Date().getDay(); // 0=Sun,6=Sat
|
const dayOfWeek = new Date().getDay(); // 0=Sun,6=Sat
|
||||||
const daysSinceSat = (dayOfWeek + 1) % 7;
|
const daysSinceSat = (dayOfWeek + 1) % 7;
|
||||||
return { from: now - daysSinceSat * 86400, to: now };
|
return { from: now - daysSinceSat * 86400, to: now };
|
||||||
case 'this_month':
|
case "this_month":
|
||||||
const d = new Date(); d.setDate(1); d.setHours(0,0,0,0);
|
const d = new Date();
|
||||||
|
d.setDate(1);
|
||||||
|
d.setHours(0, 0, 0, 0);
|
||||||
return { from: Math.floor(d.getTime() / 1000), to: now };
|
return { from: Math.floor(d.getTime() / 1000), to: now };
|
||||||
case '3_months':
|
case "3_months":
|
||||||
return { from: now - 90 * 86400, to: now };
|
return { from: now - 90 * 86400, to: now };
|
||||||
default:
|
default:
|
||||||
return { from: now - 30 * 86400, to: now };
|
return { from: now - 30 * 86400, to: now };
|
||||||
@@ -693,21 +812,27 @@ function getRange(preset: Preset): { from: number; to: number } {
|
|||||||
```
|
```
|
||||||
|
|
||||||
preset buttons:
|
preset buttons:
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
<div style={{ display: 'flex', gap: 8, marginBottom: 16 }}>
|
<div style={{ display: "flex", gap: 8, marginBottom: 16 }}>
|
||||||
{(['this_week', 'this_month', '3_months'] as Preset[]).map(p => (
|
{(["this_week", "this_month", "3_months"] as Preset[]).map((p) => (
|
||||||
<button
|
<button
|
||||||
key={p}
|
key={p}
|
||||||
className={`btn sm ${preset === p ? 'primary' : ''}`}
|
className={`btn sm ${preset === p ? "primary" : ""}`}
|
||||||
onClick={() => setPreset(p)}
|
onClick={() => setPreset(p)}
|
||||||
>
|
>
|
||||||
{p === 'this_week' ? 'این هفته' : p === 'this_month' ? 'این ماه' : '۳ ماه'}
|
{p === "this_week"
|
||||||
|
? "این هفته"
|
||||||
|
: p === "this_month"
|
||||||
|
? "این ماه"
|
||||||
|
: "۳ ماه"}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
```
|
```
|
||||||
|
|
||||||
**۲. فیلدهای جدید در کارتهای آمار:**
|
**۲. فیلدهای جدید در کارتهای آمار:**
|
||||||
|
|
||||||
- کارت «موجودی پیامک»: `stats.sms_wallet_balance` → `formatRial()`
|
- کارت «موجودی پیامک»: `stats.sms_wallet_balance` → `formatRial()`
|
||||||
- کارت «بیماران یکتا»: `stats.unique_patients_count`
|
- کارت «بیماران یکتا»: `stats.unique_patients_count`
|
||||||
- کارت «درآمد دوره»: `stats.revenue_period_rials` → `formatRial()`
|
- کارت «درآمد دوره»: `stats.revenue_period_rials` → `formatRial()`
|
||||||
@@ -715,6 +840,7 @@ preset buttons:
|
|||||||
**۳. نمودارها (برای admin role):**
|
**۳. نمودارها (برای admin role):**
|
||||||
|
|
||||||
از **SVG inline** موجود در DashboardPage استفاده کن (کلاس `SvgLineChart` که در فایل موجود است):
|
از **SVG inline** موجود در DashboardPage استفاده کن (کلاس `SvgLineChart` که در فایل موجود است):
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
// appointments_by_day و revenue_by_day دادههای خطی دارند
|
// appointments_by_day و revenue_by_day دادههای خطی دارند
|
||||||
// فقط آرایه عددی را استخراج کن:
|
// فقط آرایه عددی را استخراج کن:
|
||||||
@@ -726,11 +852,18 @@ const revData = charts?.revenue_by_day?.map((d: any) => d.amount) ?? [];
|
|||||||
```
|
```
|
||||||
|
|
||||||
**subscription_sales_by_plan** — جدول ساده:
|
**subscription_sales_by_plan** — جدول ساده:
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
<table>
|
<table>
|
||||||
<thead><tr><th>پلن</th><th>تعداد</th><th>درآمد</th></tr></thead>
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>پلن</th>
|
||||||
|
<th>تعداد</th>
|
||||||
|
<th>درآمد</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{subData.map(row => (
|
{subData.map((row) => (
|
||||||
<tr key={row.plan}>
|
<tr key={row.plan}>
|
||||||
<td>{row.plan}</td>
|
<td>{row.plan}</td>
|
||||||
<td>{row.count}</td>
|
<td>{row.count}</td>
|
||||||
@@ -742,25 +875,31 @@ const revData = charts?.revenue_by_day?.map((d: any) => d.amount) ?? [];
|
|||||||
```
|
```
|
||||||
|
|
||||||
**API call با from/to:**
|
**API call با from/to:**
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
const [preset, setPreset] = useState<Preset>('this_month');
|
const [preset, setPreset] = useState<Preset>("this_month");
|
||||||
const range = getRange(preset);
|
const range = getRange(preset);
|
||||||
|
|
||||||
// برای admin:
|
// برای admin:
|
||||||
const { data: charts } = useQuery({
|
const { data: charts } = useQuery({
|
||||||
queryKey: ['dashboard-charts', range.from, range.to],
|
queryKey: ["dashboard-charts", range.from, range.to],
|
||||||
queryFn: () => api.get(`/api/v1/admin/dashboard/charts?from=${range.from}&to=${range.to}`),
|
queryFn: () =>
|
||||||
|
api.get(
|
||||||
|
`/api/v1/admin/dashboard/charts?from=${range.from}&to=${range.to}`,
|
||||||
|
),
|
||||||
});
|
});
|
||||||
|
|
||||||
// برای doctor:
|
// برای doctor:
|
||||||
const { data: doctorDash } = useQuery({
|
const { data: doctorDash } = useQuery({
|
||||||
queryKey: ['dashboard-doctor', range.from, range.to],
|
queryKey: ["dashboard-doctor", range.from, range.to],
|
||||||
queryFn: () => api.get(`/api/v1/dashboard/doctor?from=${range.from}&to=${range.to}`),
|
queryFn: () =>
|
||||||
enabled: primaryRole === 'doctor',
|
api.get(`/api/v1/dashboard/doctor?from=${range.from}&to=${range.to}`),
|
||||||
|
enabled: primaryRole === "doctor",
|
||||||
});
|
});
|
||||||
```
|
```
|
||||||
|
|
||||||
### کامیت
|
### کامیت
|
||||||
|
|
||||||
```
|
```
|
||||||
git commit -m "feat: add date range filter and new stats to dashboard (TASK-16)"
|
git commit -m "feat: add date range filter and new stats to dashboard (TASK-16)"
|
||||||
```
|
```
|
||||||
@@ -791,7 +930,7 @@ git commit -m "feat: add date range filter and new stats to dashboard (TASK-16)"
|
|||||||
## فایلهای مرجع مهم
|
## فایلهای مرجع مهم
|
||||||
|
|
||||||
| فایل | نقش |
|
| فایل | نقش |
|
||||||
|------|-----|
|
| ------------------------------------------------- | ------------------------------------------------------------ |
|
||||||
| `assets/admin/lib/api.ts` | fetch wrapper — `api.get/post/patch/delete` |
|
| `assets/admin/lib/api.ts` | fetch wrapper — `api.get/post/patch/delete` |
|
||||||
| `assets/admin/lib/utils.ts` | `formatRial`, `formatDate`, `formatDateTime`, `formatNumber` |
|
| `assets/admin/lib/utils.ts` | `formatRial`, `formatDate`, `formatDateTime`, `formatNumber` |
|
||||||
| `assets/admin/types/index.ts` | همه TypeScript interfaces |
|
| `assets/admin/types/index.ts` | همه TypeScript interfaces |
|
||||||
|
|||||||
@@ -15,7 +15,7 @@
|
|||||||
قوانین:
|
قوانین:
|
||||||
|
|
||||||
| ایجادکننده | `owner_type` | `clinic_id` |
|
| ایجادکننده | `owner_type` | `clinic_id` |
|
||||||
|-----------|-------------|-------------|
|
| ---------- | ------------ | ------------- |
|
||||||
| خود دکتر | `'doctor'` | `null` |
|
| خود دکتر | `'doctor'` | `null` |
|
||||||
| کلینیک | `'clinic'` | `<id کلینیک>` |
|
| کلینیک | `'clinic'` | `<id کلینیک>` |
|
||||||
|
|
||||||
@@ -24,7 +24,7 @@
|
|||||||
## فایلهای مرتبط
|
## فایلهای مرتبط
|
||||||
|
|
||||||
| فایل | نقش |
|
| فایل | نقش |
|
||||||
|------|-----|
|
| --------------------------------------------------------- | ---------------------------------------------------------------- |
|
||||||
| `src/Secretary/Entity/DoctorSecretary.php` | entity اصلی — باید `owner_type` و `clinic_id` اضافه شود |
|
| `src/Secretary/Entity/DoctorSecretary.php` | entity اصلی — باید `owner_type` و `clinic_id` اضافه شود |
|
||||||
| `src/Secretary/Controller/SecretaryController.php` | ایجاد و لیست منشی — باید scope را تنظیم کند |
|
| `src/Secretary/Controller/SecretaryController.php` | ایجاد و لیست منشی — باید scope را تنظیم کند |
|
||||||
| `src/Secretary/Repository/DoctorSecretaryRepository.php` | query ها — باید scope-aware شوند |
|
| `src/Secretary/Repository/DoctorSecretaryRepository.php` | query ها — باید scope-aware شوند |
|
||||||
@@ -38,6 +38,7 @@
|
|||||||
## وضعیت فعلی
|
## وضعیت فعلی
|
||||||
|
|
||||||
### Entity (بدون scope)
|
### Entity (بدون scope)
|
||||||
|
|
||||||
```php
|
```php
|
||||||
// src/Secretary/Entity/DoctorSecretary.php
|
// src/Secretary/Entity/DoctorSecretary.php
|
||||||
// هیچ فیلد owner_type یا clinic_id وجود ندارد
|
// هیچ فیلد owner_type یا clinic_id وجود ندارد
|
||||||
@@ -54,6 +55,7 @@ class DoctorSecretary
|
|||||||
```
|
```
|
||||||
|
|
||||||
### Repository — scope-blind
|
### Repository — scope-blind
|
||||||
|
|
||||||
```php
|
```php
|
||||||
public function findActiveBySecretary(User $user): ?DoctorSecretary
|
public function findActiveBySecretary(User $user): ?DoctorSecretary
|
||||||
{
|
{
|
||||||
@@ -63,6 +65,7 @@ public function findActiveBySecretary(User $user): ?DoctorSecretary
|
|||||||
```
|
```
|
||||||
|
|
||||||
### Controller — ایجاد بدون scope
|
### Controller — ایجاد بدون scope
|
||||||
|
|
||||||
```php
|
```php
|
||||||
// create() در SecretaryController
|
// create() در SecretaryController
|
||||||
// هیچ owner_type یا clinic_id ست نمیشود
|
// هیچ owner_type یا clinic_id ست نمیشود
|
||||||
@@ -70,6 +73,7 @@ $secretary = new DoctorSecretary($doctor, $secretaryUser);
|
|||||||
```
|
```
|
||||||
|
|
||||||
### Auth context builder — scope-blind
|
### Auth context builder — scope-blind
|
||||||
|
|
||||||
```php
|
```php
|
||||||
// AuthController::buildContexts()
|
// AuthController::buildContexts()
|
||||||
foreach ($this->secretaryRepo->findAllActiveBySecretary($user) as $rel) {
|
foreach ($this->secretaryRepo->findAllActiveBySecretary($user) as $rel) {
|
||||||
@@ -86,6 +90,7 @@ foreach ($this->secretaryRepo->findAllActiveBySecretary($user) as $rel) {
|
|||||||
```
|
```
|
||||||
|
|
||||||
### Appointment فیلتر — فقط یک رابطه
|
### Appointment فیلتر — فقط یک رابطه
|
||||||
|
|
||||||
```php
|
```php
|
||||||
// MyAppointmentsController — منشی فقط به نوبتهای یک دکتر دسترسی دارد
|
// MyAppointmentsController — منشی فقط به نوبتهای یک دکتر دسترسی دارد
|
||||||
} elseif (in_array('ROLE_SECRETARY', $roles, true)) {
|
} elseif (in_array('ROLE_SECRETARY', $roles, true)) {
|
||||||
@@ -128,6 +133,7 @@ public function getClinic(): ?Clinic { return $this->clinic; }
|
|||||||
```
|
```
|
||||||
|
|
||||||
در `toArray()` هم اضافه شود:
|
در `toArray()` هم اضافه شود:
|
||||||
|
|
||||||
```php
|
```php
|
||||||
'owner_type' => $this->ownerType,
|
'owner_type' => $this->ownerType,
|
||||||
'clinic_uuid' => $this->clinic?->getUuid(),
|
'clinic_uuid' => $this->clinic?->getUuid(),
|
||||||
@@ -136,6 +142,7 @@ public function getClinic(): ?Clinic { return $this->clinic; }
|
|||||||
### ۲. Migration
|
### ۲. Migration
|
||||||
|
|
||||||
بعد از تغییر Entity:
|
بعد از تغییر Entity:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
ddev exec php bin/console doctrine:migrations:diff --no-interaction
|
ddev exec php bin/console doctrine:migrations:diff --no-interaction
|
||||||
ddev exec php bin/console doctrine:migrations:migrate --no-interaction
|
ddev exec php bin/console doctrine:migrations:migrate --no-interaction
|
||||||
@@ -206,7 +213,8 @@ public function findDoctorsBySecretaryInClinic(User $user, Clinic $clinic): arra
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
همچنین `findByClinic` باید فقط منشیان با `owner_type='clinic'` را برگرداند:
|
همچنین `findByClinic` باید فقط منشی ها با `owner_type='clinic'` را برگرداند:
|
||||||
|
|
||||||
```php
|
```php
|
||||||
public function findByClinic(Clinic $clinic): array
|
public function findByClinic(Clinic $clinic): array
|
||||||
{
|
{
|
||||||
@@ -225,6 +233,7 @@ public function findByClinic(Clinic $clinic): array
|
|||||||
### ۴. SecretaryController: ایجاد با scope
|
### ۴. SecretaryController: ایجاد با scope
|
||||||
|
|
||||||
در `create()`:
|
در `create()`:
|
||||||
|
|
||||||
- اگر `currentUser` دارای `ROLE_CLINIC` بود → `ownerType = 'clinic'`، `clinic = clinicRepo->findByUser($currentUser)`
|
- اگر `currentUser` دارای `ROLE_CLINIC` بود → `ownerType = 'clinic'`، `clinic = clinicRepo->findByUser($currentUser)`
|
||||||
- اگر `ROLE_DOCTOR` بود → `ownerType = 'doctor'`، `clinic = null`
|
- اگر `ROLE_DOCTOR` بود → `ownerType = 'doctor'`، `clinic = null`
|
||||||
|
|
||||||
@@ -238,6 +247,7 @@ if ($currentUser->hasRole('ROLE_CLINIC')) {
|
|||||||
```
|
```
|
||||||
|
|
||||||
همچنین `canManageDoctor()` باید scope را چک کند:
|
همچنین `canManageDoctor()` باید scope را چک کند:
|
||||||
|
|
||||||
- `ROLE_DOCTOR` فقط میتواند روابط `owner_type='doctor'` را مدیریت کند
|
- `ROLE_DOCTOR` فقط میتواند روابط `owner_type='doctor'` را مدیریت کند
|
||||||
- `ROLE_CLINIC` فقط میتواند روابط `owner_type='clinic'` متعلق به کلینیک خودش را مدیریت کند
|
- `ROLE_CLINIC` فقط میتواند روابط `owner_type='clinic'` متعلق به کلینیک خودش را مدیریت کند
|
||||||
|
|
||||||
@@ -278,6 +288,7 @@ foreach ($this->secretaryRepo->findAllActiveBySecretary($user) as $rel) {
|
|||||||
### ۶. Appointment و Dashboard: scope-aware
|
### ۶. Appointment و Dashboard: scope-aware
|
||||||
|
|
||||||
در `MyAppointmentsController` و `DashboardController` به جای `findActiveBySecretary`:
|
در `MyAppointmentsController` و `DashboardController` به جای `findActiveBySecretary`:
|
||||||
|
|
||||||
- اگر `db_uuid` در JWT به یک کلینیک اشاره دارد → همه دکترهای کلینیک که این منشی به آنها متصل است
|
- اگر `db_uuid` در JWT به یک کلینیک اشاره دارد → همه دکترهای کلینیک که این منشی به آنها متصل است
|
||||||
- اگر به یک دکتر اشاره دارد → فقط همان دکتر با `owner_type='doctor'`
|
- اگر به یک دکتر اشاره دارد → فقط همان دکتر با `owner_type='doctor'`
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,280 @@
|
|||||||
|
# Seed واقعی دیتا — پاکسازی و ایجاد ۱۰۰ دکتر به ازای هر شهر
|
||||||
|
|
||||||
|
## زمینه
|
||||||
|
|
||||||
|
پروژه ClinicPro نیاز به دیتای واقعی و کامل برای تست و نمایش دارد. در حال حاضر دیتا ناقص یا تستنشده است. باید دیتابیس از دکتر، کلینیک، منشی و بیمار پاک شود، سپس دیتای واقعی با پروفایل کامل ایجاد شود.
|
||||||
|
|
||||||
|
## هدف
|
||||||
|
|
||||||
|
۱. پاکسازی کامل: users، doctors، clinics، patient_records، doctor_secretaries
|
||||||
|
۲. ایجاد یک **کلینیک نمونه کامل** با همه فیلدها
|
||||||
|
۳. ایجاد یک **دکتر نمونه کامل** با برنامه کاری و منشی
|
||||||
|
۴. ایجاد **۱۰۰ دکتر به ازای هر شهر** در تخصصهای مختلف
|
||||||
|
|
||||||
|
## ساختار دیتابیس (جداول واقعی)
|
||||||
|
|
||||||
|
```
|
||||||
|
users → id, uuid, mobile_number, password_hash, real_name, roles JSON, status
|
||||||
|
doctors → id, uuid, user_id, name, gender, medical_system_code, mobile_number, activity_time, degree, info, doctor_rate, doctor_rate_percentage, active_doctor_appointment, representation_id
|
||||||
|
clinics → id, uuid, user_id, name, info, address, telephone, is_24_7, working_days, latitude, longitude, city_id, province_id, representation_id, is_active
|
||||||
|
doctor_addresses → id, uuid, doctor_id, clinic_id, city_id, province_id, type('personal'|'clinic'), name, address, telephone
|
||||||
|
weekly_schedules → id, uuid, doctor_id, setting (JSON — آرایه ۷ روز)
|
||||||
|
doctor_specialties → doctor_id, specialty_id
|
||||||
|
doctor_expertise → doctor_id, service_id
|
||||||
|
doctor_insurances → doctor_id, insurance_id
|
||||||
|
doctor_cities → doctor_id, city_id
|
||||||
|
doctor_provinces → doctor_id, province_id
|
||||||
|
clinic_doctors → clinic_id, doctor_id
|
||||||
|
clinic_specialties → clinic_id, specialty_id
|
||||||
|
clinic_services → clinic_id, service_id
|
||||||
|
clinic_insurances → clinic_id, insurance_id
|
||||||
|
doctor_secretaries → id, uuid, doctor_id, secretary_id, active, owner_type, clinic_id
|
||||||
|
patient_records → id, uuid, user_id, entity_type, entity_id, created_by_type, created_by_id
|
||||||
|
```
|
||||||
|
|
||||||
|
## IDهای واقعی lookup tables
|
||||||
|
|
||||||
|
### شهرها (cities.id)
|
||||||
|
```
|
||||||
|
101=تبریز 102=ارومیه 103=اردبیل 104=اصفهان 105=کرج
|
||||||
|
106=ایلام 107=بوشهر 108=تهران 109=شهرکرد 110=بیرجند
|
||||||
|
111=مشهد 112=بجنورد 113=اهواز 114=زنجان 115=سمنان
|
||||||
|
116=زاهدان 117=شیراز 118=قزوین 119=قم 120=سنندج
|
||||||
|
121=کرمان 122=کرمانشاه 123=یاسوج 125=گرگان 126=رشت
|
||||||
|
127=خرمآباد 128=ساری 129=اراک 130=بندرعباس 131=همدان 132=یزد
|
||||||
|
```
|
||||||
|
|
||||||
|
### استانها (provinces.id)
|
||||||
|
```
|
||||||
|
1=آذربایجان شرقی 2=آذربایجان غربی 3=اردبیل 4=اصفهان 5=البرز
|
||||||
|
6=ایلام 7=بوشهر 8=تهران 9=چهارمحال 10=خراسان جنوبی
|
||||||
|
11=خراسان رضوی 12=خراسان شمالی 13=خوزستان 14=زنجان 15=سمنان
|
||||||
|
16=سیستان 17=فارس 18=قزوین 19=قم 20=کردستان
|
||||||
|
21=کرمان 22=کرمانشاه 23=کهگیلویه 24=گلستان 25=گیلان
|
||||||
|
26=لرستان 27=مازندران 28=مرکزی 29=هرمزگان 30=همدان 100=یزد
|
||||||
|
```
|
||||||
|
نگاشت شهر → استان:
|
||||||
|
`101→1, 102→2, 103→3, 104→4, 105→5, 106→6, 107→7, 108→8, 109→9, 110→10, 111→11, 112→12, 113→13, 114→14, 115→15, 116→16, 117→17, 118→18, 119→19, 120→20, 121→21, 122→22, 123→23, 125→24, 126→25, 127→26, 128→27, 129→28, 130→29, 131→30, 132→100`
|
||||||
|
|
||||||
|
### تخصصها (specialties.id) — دستهبندی برای توزیع واقعی
|
||||||
|
```
|
||||||
|
داخلی: 1,2,3,4,5,6,7,8,9,10,11,12
|
||||||
|
جراحی: 20,21,22,23,24,25,26,27,28,29,30
|
||||||
|
اطفال: 40,41,42,43,44,45
|
||||||
|
زنان: 60,61,62,63,64,65
|
||||||
|
روانپزشکی: 70,71,72,73,74
|
||||||
|
پوست: 80,81,82,83,84
|
||||||
|
چشم: 90,91,92,93,94,95,96,97,98
|
||||||
|
ENT: 100,101,102,103,104
|
||||||
|
ارتوپدی: 110,111,112,113,114,115,116
|
||||||
|
اورولوژی: 120,121,122,123,124
|
||||||
|
رادیولوژی: 130,131,132,133,134
|
||||||
|
انکولوژی: 150,151,152
|
||||||
|
طب فیزیکی: 170,171,172,173
|
||||||
|
دندانپزشکی: 180,181,182,183,184,185,186,187,188,189
|
||||||
|
اورژانس/عمومی: 190,200
|
||||||
|
```
|
||||||
|
|
||||||
|
### خدمات پزشکی (doctor_services.id) — نمونههایی برای هر تخصص
|
||||||
|
```
|
||||||
|
قلب: 101,102,103,104,105,110
|
||||||
|
گوارش: 201,202,203,210
|
||||||
|
ریه: 301,302,303,307
|
||||||
|
کلیه: 401,402,403,406
|
||||||
|
غدد: 501,502,503,505,506
|
||||||
|
روماتولوژی: 601,602,603,604
|
||||||
|
خون: 701,702,703,704
|
||||||
|
نورولوژی: 801,802,803,804,805
|
||||||
|
آلرژی: 901,902,903,905
|
||||||
|
عفونی: 1001,1002,1003
|
||||||
|
جراحی عمومی: 2101,2102,2103,2104,2105,2113
|
||||||
|
جراحی مغز: 2201,2202,2203,2207
|
||||||
|
```
|
||||||
|
|
||||||
|
### بیمهها (insurances.id)
|
||||||
|
```
|
||||||
|
1=تأمین اجتماعی 2=خدمات درمانی 3=نیروهای مسلح 4=کمیته امداد
|
||||||
|
5=بهزیستی 6=بیمه روستایی 7=ایران 8=آسیا 9=البرز
|
||||||
|
10=پارسیان 11=سامان 12=میهن 13=دانا 14=رازی 15=کوثر
|
||||||
|
```
|
||||||
|
|
||||||
|
## ساختار weekly_schedules.setting (JSON)
|
||||||
|
|
||||||
|
آرایه ۷ عنصر (شنبه تا جمعه). هر عنصر:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"sessions": [
|
||||||
|
{
|
||||||
|
"active": true,
|
||||||
|
"location_id": <doctor_address.id>,
|
||||||
|
"start_time": "09:00",
|
||||||
|
"end_time": "13:00",
|
||||||
|
"duration_per_patient": 20,
|
||||||
|
"has_rest": true,
|
||||||
|
"rest_interval": 60,
|
||||||
|
"time_to_rest": 10,
|
||||||
|
"patient_limit": null
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
روزهای بدون کار: `{"sessions": []}`
|
||||||
|
|
||||||
|
## وظایف
|
||||||
|
|
||||||
|
### ۱. ایجاد فایل seed_realistic_data.php
|
||||||
|
|
||||||
|
فایل را در ریشه پروژه (`/var/www/html/seed_realistic_data.php`) بساز. این فایل باید:
|
||||||
|
|
||||||
|
#### بخش A — پاکسازی (به ترتیب FK)
|
||||||
|
|
||||||
|
```php
|
||||||
|
// حذف به ترتیب وابستگی FK
|
||||||
|
$conn->executeStatement('SET FOREIGN_KEY_CHECKS=0');
|
||||||
|
$conn->executeStatement('DELETE FROM weekly_schedules WHERE doctor_id IN (SELECT id FROM doctors)');
|
||||||
|
$conn->executeStatement('DELETE FROM doctor_addresses');
|
||||||
|
$conn->executeStatement('DELETE FROM doctor_specialties');
|
||||||
|
$conn->executeStatement('DELETE FROM doctor_expertise');
|
||||||
|
$conn->executeStatement('DELETE FROM doctor_insurances');
|
||||||
|
$conn->executeStatement('DELETE FROM doctor_cities');
|
||||||
|
$conn->executeStatement('DELETE FROM doctor_provinces');
|
||||||
|
$conn->executeStatement('DELETE FROM doctor_secretaries');
|
||||||
|
$conn->executeStatement('DELETE FROM clinic_doctors');
|
||||||
|
$conn->executeStatement('DELETE FROM clinic_specialties');
|
||||||
|
$conn->executeStatement('DELETE FROM clinic_services');
|
||||||
|
$conn->executeStatement('DELETE FROM clinic_insurances');
|
||||||
|
$conn->executeStatement('DELETE FROM patient_records');
|
||||||
|
$conn->executeStatement('DELETE FROM appointments');
|
||||||
|
$conn->executeStatement('DELETE FROM payments');
|
||||||
|
// حذف doctors, clinics, users (به جز ادمینها)
|
||||||
|
$conn->executeStatement("DELETE d FROM doctors d");
|
||||||
|
$conn->executeStatement("DELETE c FROM clinics c");
|
||||||
|
$conn->executeStatement("DELETE u FROM users u WHERE JSON_CONTAINS(u.roles, '\"ROLE_DOCTOR\"') OR JSON_CONTAINS(u.roles, '\"ROLE_CLINIC\"') OR JSON_CONTAINS(u.roles, '\"ROLE_PATIENT\"') OR JSON_CONTAINS(u.roles, '\"ROLE_SECRETARY\"')");
|
||||||
|
$conn->executeStatement('SET FOREIGN_KEY_CHECKS=1');
|
||||||
|
```
|
||||||
|
|
||||||
|
#### بخش B — کلینیک نمونه کامل (تبریز)
|
||||||
|
|
||||||
|
یک کلینیک با همه فیلدها:
|
||||||
|
- نام: کلینیک تخصصی امید تبریز
|
||||||
|
- city_id=101, province_id=1
|
||||||
|
- representation_id=101 (تبریز)
|
||||||
|
- تمام specialties داخلی + قلب
|
||||||
|
- بیمههای 1,2,3,7,8,9
|
||||||
|
- خدمات 201,202,203,101,102,103
|
||||||
|
|
||||||
|
#### بخش C — دکتر نمونه کامل (تبریز)
|
||||||
|
|
||||||
|
دکتر کامل با:
|
||||||
|
- تخصص قلب (id=3) + داخلی (id=2)
|
||||||
|
- بیمههای 1,2,3,7
|
||||||
|
- خدمات 101,102,103,104,110
|
||||||
|
- آدرس مطب در تبریز + آدرس کلینیک امید
|
||||||
|
- برنامه کاری: شنبه تا چهارشنبه ۹-۱۳ و ۱۴-۱۸، پنجشنبه ۹-۱۳، جمعه تعطیل
|
||||||
|
- یک منشی
|
||||||
|
- representation_id=101
|
||||||
|
|
||||||
|
#### بخش D — ۱۰۰ دکتر برای هر شهر (bulk generation)
|
||||||
|
|
||||||
|
برای شهرهای: `[101,102,103,104,105,108,111,113,117,121,122,126,128,131,132]` (۱۵ شهر مهم)
|
||||||
|
|
||||||
|
هر دکتر باید داشته باشد:
|
||||||
|
- user با موبایل منحصربهفرد (فرمت: `09XXXXXXXXX` — از ۰۹۱۰۰۱۰۰۰۰۰ به بالا)
|
||||||
|
- نام واقعی فارسی (از لیست ۱۰۰ نام ایرانی)
|
||||||
|
- پروفایل کامل: gender, degree, medical_system_code, info, rate
|
||||||
|
- تخصص (توزیع واقعی بین تخصصها)
|
||||||
|
- ۲-۳ خدمات متناسب با تخصص
|
||||||
|
- بیمههای 1,2 (پایه) + یک بیمه تکمیلی تصادفی
|
||||||
|
- یک آدرس مطب در شهرش
|
||||||
|
- برنامه کاری (۵ روز کاری)
|
||||||
|
- representation_id برابر با id شهر
|
||||||
|
|
||||||
|
**لیست نامهای واقعی ایرانی (برای استفاده در generator):**
|
||||||
|
```php
|
||||||
|
$firstNames = ['علی', 'محمد', 'حسین', 'رضا', 'امیر', 'مهدی', 'سعید', 'احمد', 'حسن', 'جواد',
|
||||||
|
'فاطمه', 'زهرا', 'مریم', 'سارا', 'نگین', 'الهه', 'نیلوفر', 'شیما', 'لیلا', 'نازنین',
|
||||||
|
'کریم', 'ابراهیم', 'اکبر', 'ناصر', 'پیمان', 'بهروز', 'شاهین', 'کاوه', 'آرش', 'سهراب',
|
||||||
|
'مینا', 'آزاده', 'ندا', 'رویا', 'پریسا', 'گلناز', 'سپیده', 'بهاره', 'شادی', 'فریده'];
|
||||||
|
$lastNames = ['رضایی', 'محمدی', 'حسینی', 'علوی', 'موسوی', 'احمدی', 'کریمی', 'صادقی', 'جعفری',
|
||||||
|
'مرادی', 'نجفی', 'تبریزی', 'اصفهانی', 'مشهدی', 'شیرازی', 'کرمانی', 'رشیدی', 'نظری',
|
||||||
|
'ابراهیمی', 'قاسمی', 'یوسفی', 'منصوری', 'زارعی', 'ملکی', 'بهرامی', 'سلیمانی', 'نوری'];
|
||||||
|
```
|
||||||
|
|
||||||
|
**توزیع واقعی تخصصها در ۱۰۰ دکتر:**
|
||||||
|
```php
|
||||||
|
$specialtyGroups = [
|
||||||
|
['specs' => [200], 'services' => [], 'degree' => 'general', 'count' => 15], // عمومی ۱۵
|
||||||
|
['specs' => [2,3], 'services' => [101,102,103,110], 'degree' => 'specialist', 'count' => 10], // داخلی+قلب ۱۰
|
||||||
|
['specs' => [4], 'services' => [201,202,203,210], 'degree' => 'specialist', 'count' => 6], // گوارش ۶
|
||||||
|
['specs' => [10], 'services' => [801,802,803,805], 'degree' => 'specialist', 'count' => 6], // نورولوژی ۶
|
||||||
|
['specs' => [21], 'services' => [2101,2102,2103,2113], 'degree' => 'specialist', 'count' => 8],// جراحی ۸
|
||||||
|
['specs' => [61], 'services' => [], 'degree' => 'specialist', 'count' => 8], // زنان ۸
|
||||||
|
['specs' => [41], 'services' => [], 'degree' => 'specialist', 'count' => 8], // اطفال ۸
|
||||||
|
['specs' => [111], 'services' => [], 'degree' => 'specialist', 'count' => 7], // ارتوپدی ۷
|
||||||
|
['specs' => [81,82], 'services' => [], 'degree' => 'specialist', 'count' => 6], // پوست ۶
|
||||||
|
['specs' => [91], 'services' => [], 'degree' => 'specialist', 'count' => 6], // چشم ۶
|
||||||
|
['specs' => [101], 'services' => [], 'degree' => 'specialist', 'count' => 5], // ENT ۵
|
||||||
|
['specs' => [71], 'services' => [], 'degree' => 'specialist', 'count' => 5], // روانپزشکی ۵
|
||||||
|
['specs' => [121], 'services' => [], 'degree' => 'specialist', 'count' => 4], // اورولوژی ۴
|
||||||
|
['specs' => [181], 'services' => [], 'degree' => 'dentist', 'count' => 6], // دندانپزشکی ۶
|
||||||
|
];
|
||||||
|
// جمع = 100
|
||||||
|
```
|
||||||
|
|
||||||
|
**فرمت برنامه کاری (۷ روز شنبه تا جمعه):**
|
||||||
|
```php
|
||||||
|
function makeSchedule(int $addressId): string {
|
||||||
|
$days = [];
|
||||||
|
for ($i = 0; $i < 7; $i++) {
|
||||||
|
if ($i === 6) { // جمعه تعطیل
|
||||||
|
$days[] = ['sessions' => []];
|
||||||
|
} elseif ($i === 5) { // پنجشنبه نیمهوقت
|
||||||
|
$days[] = ['sessions' => [
|
||||||
|
['active' => true, 'location_id' => $addressId, 'start_time' => '09:00',
|
||||||
|
'end_time' => '13:00', 'duration_per_patient' => 20,
|
||||||
|
'has_rest' => false, 'rest_interval' => 60, 'time_to_rest' => 10, 'patient_limit' => null]
|
||||||
|
]];
|
||||||
|
} else { // شنبه تا چهارشنبه
|
||||||
|
$days[] = ['sessions' => [
|
||||||
|
['active' => true, 'location_id' => $addressId, 'start_time' => '09:00',
|
||||||
|
'end_time' => '13:00', 'duration_per_patient' => 20,
|
||||||
|
'has_rest' => true, 'rest_interval' => 60, 'time_to_rest' => 10, 'patient_limit' => null],
|
||||||
|
['active' => true, 'location_id' => $addressId, 'start_time' => '14:00',
|
||||||
|
'end_time' => '18:00', 'duration_per_patient' => 20,
|
||||||
|
'has_rest' => false, 'rest_interval' => 60, 'time_to_rest' => 10, 'patient_limit' => null],
|
||||||
|
]];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return json_encode($days, JSON_UNESCAPED_UNICODE);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## نکات مهم اجرا
|
||||||
|
|
||||||
|
- **نام فایل PHP:** `seed_realistic_data.php` در ریشه پروژه
|
||||||
|
- **password_hash:** از `password_hash('Test@1234', PASSWORD_BCRYPT)` برای همه کاربران
|
||||||
|
- **roles JSON:** `'["ROLE_USER","ROLE_DOCTOR"]'` برای دکتر، `'["ROLE_USER","ROLE_CLINIC"]'` برای کلینیک
|
||||||
|
- **uuid:** از `Symfony\Component\Uid\Uuid::v4()->toRfc4122()` یا `bin2hex(random_bytes(8))` — باید unique باشد
|
||||||
|
- **موبایل منحصربهفرد:** از counter شمارنده استفاده کن تا تکراری نشود
|
||||||
|
- **representation_id:** برابر با city_id شهر مربوطه (چون cities.id = representations.id)
|
||||||
|
- **FK ها:** weekly_schedule بعد از ایجاد doctor_address insert شود (نیاز به address.id دارد)
|
||||||
|
- **transaction:** همه insert ها در یک transaction بزرگ بپیچ تا در صورت خطا rollback شود
|
||||||
|
- **echo پیشرفت:** بعد از هر شهر تعداد دکتر ایجادشده را چاپ کن
|
||||||
|
|
||||||
|
## دستور اجرا
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ddev exec php seed_realistic_data.php
|
||||||
|
```
|
||||||
|
|
||||||
|
## خروجی مورد انتظار
|
||||||
|
|
||||||
|
```
|
||||||
|
✅ پاکسازی کامل شد
|
||||||
|
✅ کلینیک امید تبریز ایجاد شد (id: X)
|
||||||
|
✅ دکتر نمونه تبریز ایجاد شد (id: Y)
|
||||||
|
🏙️ تبریز: 100 دکتر ایجاد شد
|
||||||
|
🏙️ تهران: 100 دکتر ایجاد شد
|
||||||
|
...
|
||||||
|
✅ پایان — مجموع X دکتر در Y شهر ایجاد شد
|
||||||
|
```
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
## زمینه
|
## زمینه
|
||||||
|
|
||||||
سیستم اشتراک در backend کاملاً پیاده است:
|
سیستم اشتراک در backend کاملاً پیاده است:
|
||||||
|
|
||||||
- `SubscriptionService::hasFeature(entityType, entityId, feature)` — بررسی دسترسی به قابلیت
|
- `SubscriptionService::hasFeature(entityType, entityId, feature)` — بررسی دسترسی به قابلیت
|
||||||
- `SubscriptionService::getSecretaryLimit(...)` — حداکثر منشی مجاز
|
- `SubscriptionService::getSecretaryLimit(...)` — حداکثر منشی مجاز
|
||||||
- `SubscriptionPlan::features` — یک آرایه JSON مثل `{ "patient_records": true, "services": true, "sms_panel": true }`
|
- `SubscriptionPlan::features` — یک آرایه JSON مثل `{ "patient_records": true, "services": true, "sms_panel": true }`
|
||||||
@@ -11,6 +12,7 @@
|
|||||||
**مشکل:** Frontend هیچ اطلاعی از اشتراک فعال کاربر ندارد. Sidebar همه منوها را به همه نقشها نشان میدهد، صفحات بدون هشدار باز میشوند، و کاربر با پیام خطای backend مواجه میشود بجای راهنمای ارتقاء پنل.
|
**مشکل:** Frontend هیچ اطلاعی از اشتراک فعال کاربر ندارد. Sidebar همه منوها را به همه نقشها نشان میدهد، صفحات بدون هشدار باز میشوند، و کاربر با پیام خطای backend مواجه میشود بجای راهنمای ارتقاء پنل.
|
||||||
|
|
||||||
**هدف:** وقتی کاربر (پزشک / کلینیک / منشی) وارد پنل میشود:
|
**هدف:** وقتی کاربر (پزشک / کلینیک / منشی) وارد پنل میشود:
|
||||||
|
|
||||||
1. اطلاعات اشتراک فعال لود شود و در یک context/hook مشترک در دسترس باشد
|
1. اطلاعات اشتراک فعال لود شود و در یک context/hook مشترک در دسترس باشد
|
||||||
2. آیتمهای Sidebar که نیاز به feature دارند — اگر feature فعال نیست — با آیکون قفل نمایش داده شوند یا پنهان شوند
|
2. آیتمهای Sidebar که نیاز به feature دارند — اگر feature فعال نیست — با آیکون قفل نمایش داده شوند یا پنهان شوند
|
||||||
3. صفحاتی که feature ندارند، یک بنر «برای استفاده از این قابلیت پنل خود را ارتقاء دهید» نمایش دهند
|
3. صفحاتی که feature ندارند، یک بنر «برای استفاده از این قابلیت پنل خود را ارتقاء دهید» نمایش دهند
|
||||||
@@ -22,7 +24,7 @@
|
|||||||
### قابلیتهای محدودشده توسط اشتراک
|
### قابلیتهای محدودشده توسط اشتراک
|
||||||
|
|
||||||
| Feature Key | صفحه/منو | نقش مرتبط |
|
| Feature Key | صفحه/منو | نقش مرتبط |
|
||||||
|-------------|----------|----------|
|
| ----------------- | ------------------------------- | ------------------------- |
|
||||||
| `patient_records` | `/admin/my-patients` | doctor, clinic, secretary |
|
| `patient_records` | `/admin/my-patients` | doctor, clinic, secretary |
|
||||||
| `services` | `/admin/clinic-services` | doctor, clinic |
|
| `services` | `/admin/clinic-services` | doctor, clinic |
|
||||||
| `sms_panel` | `/admin/sms-wallet` | doctor, clinic |
|
| `sms_panel` | `/admin/sms-wallet` | doctor, clinic |
|
||||||
@@ -35,7 +37,7 @@
|
|||||||
## فایلهای مرتبط
|
## فایلهای مرتبط
|
||||||
|
|
||||||
| فایل | نقش |
|
| فایل | نقش |
|
||||||
|------|-----|
|
| -------------------------------------------------------- | ------------------------------------------------------------- |
|
||||||
| `src/Subscription/Controller/SubscriptionController.php` | endpoint `GET /api/v1/subscription/my` — برای doctor و clinic |
|
| `src/Subscription/Controller/SubscriptionController.php` | endpoint `GET /api/v1/subscription/my` — برای doctor و clinic |
|
||||||
| `src/Subscription/Service/SubscriptionService.php` | `hasFeature()`, `getSecretaryLimit()` |
|
| `src/Subscription/Service/SubscriptionService.php` | `hasFeature()`, `getSecretaryLimit()` |
|
||||||
| `assets/admin/stores/authStore.ts` | state مرکزی auth — باید subscription هم اینجا باشد |
|
| `assets/admin/stores/authStore.ts` | state مرکزی auth — باید subscription هم اینجا باشد |
|
||||||
@@ -52,6 +54,7 @@
|
|||||||
## وضعیت فعلی
|
## وضعیت فعلی
|
||||||
|
|
||||||
### Backend — endpoint اشتراک
|
### Backend — endpoint اشتراک
|
||||||
|
|
||||||
```php
|
```php
|
||||||
// SubscriptionController.php — GET /api/v1/subscription/my
|
// SubscriptionController.php — GET /api/v1/subscription/my
|
||||||
// فقط doctor و clinic را handle میکند
|
// فقط doctor و clinic را handle میکند
|
||||||
@@ -70,13 +73,23 @@ private function resolveEntity(User $user): array
|
|||||||
```
|
```
|
||||||
|
|
||||||
پاسخ:
|
پاسخ:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"success": true,
|
"success": true,
|
||||||
"data": {
|
"data": {
|
||||||
"subscription": {
|
"subscription": {
|
||||||
"uuid": "...",
|
"uuid": "...",
|
||||||
"plan": { "name": "basic", "level": 1, "features": { "patient_records": true, "services": true, "sms_panel": false }, "max_secretaries": 3 },
|
"plan": {
|
||||||
|
"name": "basic",
|
||||||
|
"level": 1,
|
||||||
|
"features": {
|
||||||
|
"patient_records": true,
|
||||||
|
"services": true,
|
||||||
|
"sms_panel": false
|
||||||
|
},
|
||||||
|
"max_secretaries": 3
|
||||||
|
},
|
||||||
"is_trial": false,
|
"is_trial": false,
|
||||||
"expires_at": 1750000000,
|
"expires_at": 1750000000,
|
||||||
"days_remaining": 45
|
"days_remaining": 45
|
||||||
@@ -89,31 +102,48 @@ private function resolveEntity(User $user): array
|
|||||||
اگر اشتراک فعال نداشته باشد: `"subscription": null`
|
اگر اشتراک فعال نداشته باشد: `"subscription": null`
|
||||||
|
|
||||||
### Frontend — authStore
|
### Frontend — authStore
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
// authStore.ts — فعلاً subscription اصلاً در store نیست
|
// authStore.ts — فعلاً subscription اصلاً در store نیست
|
||||||
interface AuthState {
|
interface AuthState {
|
||||||
primaryRole: 'admin' | 'clinic' | 'doctor' | 'secretary' | 'user' | null;
|
primaryRole: "admin" | "clinic" | "doctor" | "secretary" | "user" | null;
|
||||||
dbUuid: string | null;
|
dbUuid: string | null;
|
||||||
// ... subscription وجود ندارد
|
// ... subscription وجود ندارد
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### Frontend — Sidebar
|
### Frontend — Sidebar
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
// Sidebar.tsx — همه آیتمها را بدون بررسی feature نمایش میدهد
|
// Sidebar.tsx — همه آیتمها را بدون بررسی feature نمایش میدهد
|
||||||
if (primaryRole === 'doctor') {
|
if (primaryRole === "doctor") {
|
||||||
return [{
|
return [
|
||||||
label: 'مدیریت',
|
{
|
||||||
|
label: "مدیریت",
|
||||||
items: [
|
items: [
|
||||||
{ to: '/admin/my-patients', icon: FolderOpenIcon, label: 'پرونده بیماران' },
|
{
|
||||||
{ to: '/admin/clinic-services', icon: WrenchScrewdriverIcon, label: 'سرویسها' },
|
to: "/admin/my-patients",
|
||||||
{ to: '/admin/sms-wallet', icon: DevicePhoneMobileIcon, label: 'کیف پول پیامک' },
|
icon: FolderOpenIcon,
|
||||||
|
label: "پرونده بیماران",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
to: "/admin/clinic-services",
|
||||||
|
icon: WrenchScrewdriverIcon,
|
||||||
|
label: "سرویسها",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
to: "/admin/sms-wallet",
|
||||||
|
icon: DevicePhoneMobileIcon,
|
||||||
|
label: "کیف پول پیامک",
|
||||||
|
},
|
||||||
],
|
],
|
||||||
}];
|
},
|
||||||
|
];
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### Frontend — App.tsx
|
### Frontend — App.tsx
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
// فقط نقشها چک میشوند، feature gate وجود ندارد
|
// فقط نقشها چک میشوند، feature gate وجود ندارد
|
||||||
<Route path="my-patients" element={<RoleRoute roles={['doctor', 'secretary', 'clinic']}><MyPatientsPage /></RoleRoute>} />
|
<Route path="my-patients" element={<RoleRoute roles={['doctor', 'secretary', 'clinic']}><MyPatientsPage /></RoleRoute>} />
|
||||||
@@ -148,18 +178,21 @@ if ($user->hasRole('ROLE_SECRETARY')) {
|
|||||||
یک فایل `assets/admin/hooks/useSubscription.ts` بساز:
|
یک فایل `assets/admin/hooks/useSubscription.ts` بساز:
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { api } from '../lib/api';
|
import { api } from "../lib/api";
|
||||||
import { useAuthStore } from '../stores/authStore';
|
import { useAuthStore } from "../stores/authStore";
|
||||||
import type { ApiResponse, MySubscriptionData } from '../types';
|
import type { ApiResponse, MySubscriptionData } from "../types";
|
||||||
|
|
||||||
export function useSubscription() {
|
export function useSubscription() {
|
||||||
const primaryRole = useAuthStore((s) => s.primaryRole);
|
const primaryRole = useAuthStore((s) => s.primaryRole);
|
||||||
const enabled = primaryRole === 'doctor' || primaryRole === 'clinic' || primaryRole === 'secretary';
|
const enabled =
|
||||||
|
primaryRole === "doctor" ||
|
||||||
|
primaryRole === "clinic" ||
|
||||||
|
primaryRole === "secretary";
|
||||||
|
|
||||||
const { data } = useQuery<ApiResponse<MySubscriptionData>>({
|
const { data } = useQuery<ApiResponse<MySubscriptionData>>({
|
||||||
queryKey: ['subscription-my'],
|
queryKey: ["subscription-my"],
|
||||||
queryFn: () => api.get('/api/v1/subscription/my'),
|
queryFn: () => api.get("/api/v1/subscription/my"),
|
||||||
enabled,
|
enabled,
|
||||||
staleTime: 2 * 60 * 1000,
|
staleTime: 2 * 60 * 1000,
|
||||||
});
|
});
|
||||||
@@ -174,7 +207,8 @@ export function useSubscription() {
|
|||||||
hasFeature: (key: string) => hasPlan && (features[key] ?? false),
|
hasFeature: (key: string) => hasPlan && (features[key] ?? false),
|
||||||
maxSecretaries,
|
maxSecretaries,
|
||||||
hasPlan,
|
hasPlan,
|
||||||
isExpiringSoon: (sub?.days_remaining ?? 0) > 0 && (sub?.days_remaining ?? 0) <= 7,
|
isExpiringSoon:
|
||||||
|
(sub?.days_remaining ?? 0) > 0 && (sub?.days_remaining ?? 0) <= 7,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
@@ -186,7 +220,7 @@ export function useSubscription() {
|
|||||||
در `Sidebar.tsx`:
|
در `Sidebar.tsx`:
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
import { useSubscription } from '../../hooks/useSubscription';
|
import { useSubscription } from "../../hooks/useSubscription";
|
||||||
// ...
|
// ...
|
||||||
|
|
||||||
export default function Sidebar() {
|
export default function Sidebar() {
|
||||||
@@ -198,6 +232,7 @@ export default function Sidebar() {
|
|||||||
```
|
```
|
||||||
|
|
||||||
نوع `SectionItem` را گسترش بده:
|
نوع `SectionItem` را گسترش بده:
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
type SectionItem = {
|
type SectionItem = {
|
||||||
to: string;
|
to: string;
|
||||||
@@ -208,6 +243,7 @@ type SectionItem = {
|
|||||||
```
|
```
|
||||||
|
|
||||||
آیتمهای محدود را با `feature` mark کن:
|
آیتمهای محدود را با `feature` mark کن:
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
{ to: '/admin/my-patients', icon: FolderOpenIcon, label: 'پرونده بیماران', feature: 'patient_records' },
|
{ to: '/admin/my-patients', icon: FolderOpenIcon, label: 'پرونده بیماران', feature: 'patient_records' },
|
||||||
{ to: '/admin/clinic-services',icon: WrenchScrewdriverIcon, label: 'سرویسها', feature: 'services' },
|
{ to: '/admin/clinic-services',icon: WrenchScrewdriverIcon, label: 'سرویسها', feature: 'services' },
|
||||||
@@ -215,18 +251,19 @@ type SectionItem = {
|
|||||||
```
|
```
|
||||||
|
|
||||||
در render هر آیتم:
|
در render هر آیتم:
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
const isLocked = item.feature ? !hasFeature(item.feature) : false;
|
const isLocked = item.feature ? !hasFeature(item.feature) : false;
|
||||||
|
|
||||||
<NavLink
|
<NavLink
|
||||||
to={isLocked ? '/admin/subscription' : item.to}
|
to={isLocked ? "/admin/subscription" : item.to}
|
||||||
style={isLocked ? { opacity: 0.5 } : {}}
|
style={isLocked ? { opacity: 0.5 } : {}}
|
||||||
title={isLocked ? 'نیاز به ارتقاء پنل' : undefined}
|
title={isLocked ? "نیاز به ارتقاء پنل" : undefined}
|
||||||
>
|
>
|
||||||
<item.icon />
|
<item.icon />
|
||||||
{item.label}
|
{item.label}
|
||||||
{isLocked && <LockClosedIcon style={{ width: 12, marginRight: 'auto' }} />}
|
{isLocked && <LockClosedIcon style={{ width: 12, marginRight: "auto" }} />}
|
||||||
</NavLink>
|
</NavLink>;
|
||||||
```
|
```
|
||||||
|
|
||||||
import کن: `import { LockClosedIcon } from '@heroicons/react/24/outline';`
|
import کن: `import { LockClosedIcon } from '@heroicons/react/24/outline';`
|
||||||
@@ -236,10 +273,10 @@ import کن: `import { LockClosedIcon } from '@heroicons/react/24/outline';`
|
|||||||
یک component کوچک `assets/admin/components/ui/FeatureGate.tsx` بساز:
|
یک component کوچک `assets/admin/components/ui/FeatureGate.tsx` بساز:
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
import React from 'react';
|
import React from "react";
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from "react-router-dom";
|
||||||
import { LockClosedIcon } from '@heroicons/react/24/outline';
|
import { LockClosedIcon } from "@heroicons/react/24/outline";
|
||||||
import { useSubscription } from '../../hooks/useSubscription';
|
import { useSubscription } from "../../hooks/useSubscription";
|
||||||
|
|
||||||
interface FeatureGateProps {
|
interface FeatureGateProps {
|
||||||
feature: string;
|
feature: string;
|
||||||
@@ -252,30 +289,52 @@ export default function FeatureGate({ feature, children }: FeatureGateProps) {
|
|||||||
if (hasFeature(feature)) return <>{children}</>;
|
if (hasFeature(feature)) return <>{children}</>;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{
|
<div
|
||||||
display: 'flex', flexDirection: 'column',
|
style={{
|
||||||
alignItems: 'center', justifyContent: 'center',
|
display: "flex",
|
||||||
minHeight: 320, gap: 16, padding: 40, textAlign: 'center',
|
flexDirection: "column",
|
||||||
}}>
|
alignItems: "center",
|
||||||
<div style={{
|
justifyContent: "center",
|
||||||
width: 64, height: 64, borderRadius: '50%',
|
minHeight: 320,
|
||||||
background: 'oklch(0.97 0.01 256)',
|
gap: 16,
|
||||||
display: 'grid', placeItems: 'center',
|
padding: 40,
|
||||||
}}>
|
textAlign: "center",
|
||||||
<LockClosedIcon style={{ width: 28, color: 'var(--text-3)' }} />
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: 64,
|
||||||
|
height: 64,
|
||||||
|
borderRadius: "50%",
|
||||||
|
background: "oklch(0.97 0.01 256)",
|
||||||
|
display: "grid",
|
||||||
|
placeItems: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<LockClosedIcon style={{ width: 28, color: "var(--text-3)" }} />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div style={{ fontWeight: 700, fontSize: 16, marginBottom: 6 }}>
|
<div style={{ fontWeight: 700, fontSize: 16, marginBottom: 6 }}>
|
||||||
این قابلیت در پنل فعلی شما فعال نیست
|
این قابلیت در پنل فعلی شما فعال نیست
|
||||||
</div>
|
</div>
|
||||||
<div style={{ color: 'var(--text-3)', fontSize: 13.5, maxWidth: 360 }}>
|
<div
|
||||||
|
style={{
|
||||||
|
color: "var(--text-3)",
|
||||||
|
fontSize: 13.5,
|
||||||
|
maxWidth: 360,
|
||||||
|
}}
|
||||||
|
>
|
||||||
{hasPlan
|
{hasPlan
|
||||||
? 'برای استفاده از این قابلیت پنل خود را ارتقاء دهید'
|
? "برای استفاده از این قابلیت پنل خود را ارتقاء دهید"
|
||||||
: 'برای استفاده از این قابلیت یک پنل اشتراکی فعال کنید'}
|
: "برای استفاده از این قابلیت یک پنل اشتراکی فعال کنید"}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Link to="/admin/subscription" className="btn primary sm" style={{ textDecoration: 'none' }}>
|
<Link
|
||||||
{hasPlan ? 'ارتقاء پنل' : 'مشاهده پنلهای اشتراکی'}
|
to="/admin/subscription"
|
||||||
|
className="btn primary sm"
|
||||||
|
style={{ textDecoration: "none" }}
|
||||||
|
>
|
||||||
|
{hasPlan ? "ارتقاء پنل" : "مشاهده پنلهای اشتراکی"}
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -285,32 +344,25 @@ export default function FeatureGate({ feature, children }: FeatureGateProps) {
|
|||||||
### ۵. Frontend — استفاده از FeatureGate در صفحات
|
### ۵. Frontend — استفاده از FeatureGate در صفحات
|
||||||
|
|
||||||
در **`MyPatientsPage.tsx`** محتوای اصلی را wrap کن:
|
در **`MyPatientsPage.tsx`** محتوای اصلی را wrap کن:
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
import FeatureGate from '../components/ui/FeatureGate';
|
import FeatureGate from "../components/ui/FeatureGate";
|
||||||
// ...
|
// ...
|
||||||
return (
|
return (
|
||||||
<FeatureGate feature="patient_records">
|
<FeatureGate feature="patient_records">{/* کد فعلی صفحه */}</FeatureGate>
|
||||||
{/* کد فعلی صفحه */}
|
|
||||||
</FeatureGate>
|
|
||||||
);
|
);
|
||||||
```
|
```
|
||||||
|
|
||||||
در **`ClinicServicesPage.tsx`**:
|
در **`ClinicServicesPage.tsx`**:
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
return (
|
return <FeatureGate feature="services">{/* کد فعلی */}</FeatureGate>;
|
||||||
<FeatureGate feature="services">
|
|
||||||
{/* کد فعلی */}
|
|
||||||
</FeatureGate>
|
|
||||||
);
|
|
||||||
```
|
```
|
||||||
|
|
||||||
در **`SmsWalletPage.tsx`**:
|
در **`SmsWalletPage.tsx`**:
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
return (
|
return <FeatureGate feature="sms_panel">{/* کد فعلی */}</FeatureGate>;
|
||||||
<FeatureGate feature="sms_panel">
|
|
||||||
{/* کد فعلی */}
|
|
||||||
</FeatureGate>
|
|
||||||
);
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### ۶. Frontend — محدودیت تعداد منشی در MySecretariesPage
|
### ۶. Frontend — محدودیت تعداد منشی در MySecretariesPage
|
||||||
@@ -318,11 +370,11 @@ return (
|
|||||||
در **`MySecretariesPage.tsx`** وقتی کاربر میخواهد منشی جدید اضافه کند:
|
در **`MySecretariesPage.tsx`** وقتی کاربر میخواهد منشی جدید اضافه کند:
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
import { useSubscription } from '../hooks/useSubscription';
|
import { useSubscription } from "../hooks/useSubscription";
|
||||||
// ...
|
// ...
|
||||||
const { maxSecretaries } = useSubscription();
|
const { maxSecretaries } = useSubscription();
|
||||||
// تعداد فعلی منشیان از data موجود
|
// تعداد فعلی منشی ها از data موجود
|
||||||
const activeCount = secretaries.filter(s => s.is_active).length;
|
const activeCount = secretaries.filter((s) => s.is_active).length;
|
||||||
const isAtLimit = activeCount >= maxSecretaries;
|
const isAtLimit = activeCount >= maxSecretaries;
|
||||||
|
|
||||||
// روی دکمه «افزودن منشی»:
|
// روی دکمه «افزودن منشی»:
|
||||||
@@ -330,15 +382,22 @@ const isAtLimit = activeCount >= maxSecretaries;
|
|||||||
className="btn primary sm"
|
className="btn primary sm"
|
||||||
onClick={() => setAddOpen(true)}
|
onClick={() => setAddOpen(true)}
|
||||||
disabled={isAtLimit}
|
disabled={isAtLimit}
|
||||||
title={isAtLimit ? `حداکثر ${maxSecretaries} منشی در پنل فعلی مجاز است` : undefined}
|
title={
|
||||||
|
isAtLimit
|
||||||
|
? `حداکثر ${maxSecretaries} منشی در پنل فعلی مجاز است`
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
>
|
>
|
||||||
افزودن منشی
|
افزودن منشی
|
||||||
</button>
|
</button>;
|
||||||
{isAtLimit && (
|
{
|
||||||
<div style={{ fontSize: 12, color: 'var(--text-3)', marginTop: 4 }}>
|
isAtLimit && (
|
||||||
برای افزودن منشی بیشتر <Link to="/admin/subscription">پنل خود را ارتقاء دهید</Link>
|
<div style={{ fontSize: 12, color: "var(--text-3)", marginTop: 4 }}>
|
||||||
|
برای افزودن منشی بیشتر{" "}
|
||||||
|
<Link to="/admin/subscription">پنل خود را ارتقاء دهید</Link>
|
||||||
</div>
|
</div>
|
||||||
)}
|
);
|
||||||
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -10,26 +10,16 @@
|
|||||||
|
|
||||||
```css
|
```css
|
||||||
/* متغیرها */
|
/* متغیرها */
|
||||||
--primary: oklch(0.55 0.2 256)
|
--primary: oklch(0.55 0.2 256) --primary-subtle: oklch(0.97 0.02 256)
|
||||||
--primary-subtle: oklch(0.97 0.02 256)
|
--surface: #fff --border: oklch(0.9 0 0) --text-1 / --text-2 / --text-3 --r
|
||||||
--surface: #fff
|
(border-radius base) --gap / --card-pad /* کلاسهای کاربردی */ .card
|
||||||
--border: oklch(0.9 0 0)
|
/* white surface با shadow و border-radius */ .card-pad
|
||||||
--text-1 / --text-2 / --text-3
|
/* padding داخل card */ .stat /* عدد آماری بزرگ + label */ .stat-grid
|
||||||
--r (border-radius base)
|
/* grid چند ستونه برای statها */ .badge green/red/yellow/blue/gray
|
||||||
--gap / --card-pad
|
/* برچسب وضعیت */ .btn primary/sm/ghost /* دکمه */ .field
|
||||||
|
/* label + input wrapper */ .seg /* segment/tab selector */ .bar
|
||||||
/* کلاسهای کاربردی */
|
/* نوار پیشرفت */ .grid-2 /* دوستونه responsive */ .avatar sm
|
||||||
.card /* white surface با shadow و border-radius */
|
/* آواتار دایرهای */;
|
||||||
.card-pad /* padding داخل card */
|
|
||||||
.stat /* عدد آماری بزرگ + label */
|
|
||||||
.stat-grid /* grid چند ستونه برای statها */
|
|
||||||
.badge green/red/yellow/blue/gray /* برچسب وضعیت */
|
|
||||||
.btn primary/sm/ghost /* دکمه */
|
|
||||||
.field /* label + input wrapper */
|
|
||||||
.seg /* segment/tab selector */
|
|
||||||
.bar /* نوار پیشرفت */
|
|
||||||
.grid-2 /* دوستونه responsive */
|
|
||||||
.avatar sm /* آواتار دایرهای */
|
|
||||||
```
|
```
|
||||||
|
|
||||||
**الگوی header صفحات:** `<PageHeader title="..." description="..." action={<button>} />`
|
**الگوی header صفحات:** `<PageHeader title="..." description="..." action={<button>} />`
|
||||||
@@ -37,10 +27,10 @@
|
|||||||
## فایلهای مرتبط
|
## فایلهای مرتبط
|
||||||
|
|
||||||
| فایل | نقش |
|
| فایل | نقش |
|
||||||
|------|-----|
|
| ------------------------------------------- | -------------------------------------------------------- |
|
||||||
| `assets/admin/pages/MyPatientsPage.tsx` | پرونده بیماران — دو حالت: لیست + جزئیات بیمار |
|
| `assets/admin/pages/MyPatientsPage.tsx` | پرونده بیماران — دو حالت: لیست + جزئیات بیمار |
|
||||||
| `assets/admin/pages/StaffPage.tsx` | مدیریت پرسنل — CRUD |
|
| `assets/admin/pages/StaffPage.tsx` | مدیریت پرسنل — CRUD |
|
||||||
| `assets/admin/pages/MySecretariesPage.tsx` | مدیریت منشیان — CRUD + permissions matrix |
|
| `assets/admin/pages/MySecretariesPage.tsx` | مدیریت منشی ها — CRUD + permissions matrix |
|
||||||
| `assets/admin/pages/ClinicServicesPage.tsx` | سرویسهای کلینیک — دو پنل: بخشها + آیتمها |
|
| `assets/admin/pages/ClinicServicesPage.tsx` | سرویسهای کلینیک — دو پنل: بخشها + آیتمها |
|
||||||
| `assets/admin/pages/SmsWalletPage.tsx` | کیف پول پیامک — balance + settings + logs |
|
| `assets/admin/pages/SmsWalletPage.tsx` | کیف پول پیامک — balance + settings + logs |
|
||||||
| `assets/admin/styles.css` | سیستم CSS کامل |
|
| `assets/admin/styles.css` | سیستم CSS کامل |
|
||||||
@@ -173,6 +163,7 @@ PermissionsMatrix: جدول checkbox با ۶ ستون برای ۴ بخش — د
|
|||||||
**هدف:** لیست با identity واضح + پرونده بیمار با مینیداشبورد.
|
**هدف:** لیست با identity واضح + پرونده بیمار با مینیداشبورد.
|
||||||
|
|
||||||
**الف — حالت لیست بیماران:**
|
**الف — حالت لیست بیماران:**
|
||||||
|
|
||||||
- Header اختصاصی: `<PageHeader>` با description «مراجعهکنندگان ثبتشده شما»
|
- Header اختصاصی: `<PageHeader>` با description «مراجعهکنندگان ثبتشده شما»
|
||||||
- Search bar را با یک wrapper card جداگانه به یک toolbar تبدیل کن (نه داخل card جدول)
|
- Search bar را با یک wrapper card جداگانه به یک toolbar تبدیل کن (نه داخل card جدول)
|
||||||
- `DataTable` را با ردیفهای بهتر جایگزین کن: هر ردیف آواتار gradient + نام بولد + شماره با آیکون تلفن + تاریخ ثبت شمسی
|
- `DataTable` را با ردیفهای بهتر جایگزین کن: هر ردیف آواتار gradient + نام بولد + شماره با آیکون تلفن + تاریخ ثبت شمسی
|
||||||
@@ -180,6 +171,7 @@ PermissionsMatrix: جدول checkbox با ۶ ستون برای ۴ بخش — د
|
|||||||
- وقتی بیماری نیست: Empty State با آیکون بزرگ + متن راهنما
|
- وقتی بیماری نیست: Empty State با آیکون بزرگ + متن راهنما
|
||||||
|
|
||||||
**ب — حالت پرونده بیمار (selectedRecord):**
|
**ب — حالت پرونده بیمار (selectedRecord):**
|
||||||
|
|
||||||
- PageHeader با نام بیمار + شماره + تاریخ ثبت
|
- PageHeader با نام بیمار + شماره + تاریخ ثبت
|
||||||
- یک بنر/info-card بالای صفحه: نام، تلفن، تعداد مراجعات (از `totalSes`)
|
- یک بنر/info-card بالای صفحه: نام، تلفن، تعداد مراجعات (از `totalSes`)
|
||||||
- SessionRow باید بهتر شود — هر مراجعه یک card مستقل با:
|
- SessionRow باید بهتر شود — هر مراجعه یک card مستقل با:
|
||||||
@@ -189,6 +181,7 @@ PermissionsMatrix: جدول checkbox با ۶ ستون برای ۴ بخش — د
|
|||||||
- دکمه «ویرایش» در گوشه بالا سمت چپ کارت
|
- دکمه «ویرایش» در گوشه بالا سمت چپ کارت
|
||||||
|
|
||||||
**ج — Modal ثبت مراجعه:**
|
**ج — Modal ثبت مراجعه:**
|
||||||
|
|
||||||
- سه فیلد قیمت/بیمه پایه/بیمه تکمیلی را در یک `.grid-2` یا grid سهستونه گذاشتن خوب است — این کافیست
|
- سه فیلد قیمت/بیمه پایه/بیمه تکمیلی را در یک `.grid-2` یا grid سهستونه گذاشتن خوب است — این کافیست
|
||||||
- بخش «خدمات» را با یک separator بصری از فیلدهای اصلی جدا کن
|
- بخش «خدمات» را با یک separator بصری از فیلدهای اصلی جدا کن
|
||||||
- لیست خدمات انتخابشده را با badge یا chip نشان بده (نه فقط متن)
|
- لیست خدمات انتخابشده را با badge یا chip نشان بده (نه فقط متن)
|
||||||
@@ -202,6 +195,7 @@ PermissionsMatrix: جدول checkbox با ۶ ستون برای ۴ بخش — د
|
|||||||
**هدف:** صفحهای با KPI bar + جدول بهتر + فرم واضحتر.
|
**هدف:** صفحهای با KPI bar + جدول بهتر + فرم واضحتر.
|
||||||
|
|
||||||
**الف — KPI bar:**
|
**الف — KPI bar:**
|
||||||
|
|
||||||
- بالای جدول، یک `.stat-grid` با ۳ card: تعداد پرسنل کل، تعداد فعال، تعداد غیرفعال
|
- بالای جدول، یک `.stat-grid` با ۳ card: تعداد پرسنل کل، تعداد فعال، تعداد غیرفعال
|
||||||
- داده از همان `staff` query محاسبه شود (نه API جدید):
|
- داده از همان `staff` query محاسبه شود (نه API جدید):
|
||||||
```ts
|
```ts
|
||||||
@@ -211,11 +205,13 @@ PermissionsMatrix: جدول checkbox با ۶ ستون برای ۴ بخش — د
|
|||||||
```
|
```
|
||||||
|
|
||||||
**ب — جدول بهتر:**
|
**ب — جدول بهتر:**
|
||||||
|
|
||||||
- ستون «نام» را بهبود بده: `full_name` + `job_title` زیرش (مثل MyPatientsPage)
|
- ستون «نام» را بهبود بده: `full_name` + `job_title` زیرش (مثل MyPatientsPage)
|
||||||
- ستون «تلفن» و «کد ملی» را در موبایل پنهان کن (CSS media query اگر لازم بود)
|
- ستون «تلفن» و «کد ملی» را در موبایل پنهان کن (CSS media query اگر لازم بود)
|
||||||
- ردیفهای hover state با background تغییر کند
|
- ردیفهای hover state با background تغییر کند
|
||||||
|
|
||||||
**ج — فرم StaffFormFields:**
|
**ج — فرم StaffFormFields:**
|
||||||
|
|
||||||
- فیلدهای `full_name` و `job_title` را در یک ردیف (`.grid-2`) قرار بده
|
- فیلدهای `full_name` و `job_title` را در یک ردیف (`.grid-2`) قرار بده
|
||||||
- فیلدهای `phone` و `national_code` را در یک ردیف دیگر
|
- فیلدهای `phone` و `national_code` را در یک ردیف دیگر
|
||||||
- فیلد `address` تنها بماند (textarea یا input تمام عرض)
|
- فیلد `address` تنها بماند (textarea یا input تمام عرض)
|
||||||
@@ -226,7 +222,8 @@ PermissionsMatrix: جدول checkbox با ۶ ستون برای ۴ بخش — د
|
|||||||
|
|
||||||
**هدف:** جدول دستی را به DataTable شیک تبدیل کن + PermissionsMatrix بصریتر + Empty State جذاب.
|
**هدف:** جدول دستی را به DataTable شیک تبدیل کن + PermissionsMatrix بصریتر + Empty State جذاب.
|
||||||
|
|
||||||
**الف — جدول منشیان:**
|
**الف — جدول منشی ها:**
|
||||||
|
|
||||||
- جدول دستی را **حذف کن** و از `DataTable` component استفاده کن
|
- جدول دستی را **حذف کن** و از `DataTable` component استفاده کن
|
||||||
- ستون «نام»: آواتار کوچک (initial از `user_name`) + نام بولد
|
- ستون «نام»: آواتار کوچک (initial از `user_name`) + نام بولد
|
||||||
- ستون «موبایل»: با `maskMobile()` و `dir="ltr"` + آیکون تلفن
|
- ستون «موبایل»: با `maskMobile()` و `dir="ltr"` + آیکون تلفن
|
||||||
@@ -235,12 +232,22 @@ PermissionsMatrix: جدول checkbox با ۶ ستون برای ۴ بخش — د
|
|||||||
- ستون «عملیات»: دو دکمه `sm` (ویرایش دسترسیها + غیرفعالسازی)
|
- ستون «عملیات»: دو دکمه `sm` (ویرایش دسترسیها + غیرفعالسازی)
|
||||||
|
|
||||||
**ب — Empty State:**
|
**ب — Empty State:**
|
||||||
|
|
||||||
- وقتی `secretaries.length === 0`:
|
- وقتی `secretaries.length === 0`:
|
||||||
```tsx
|
```tsx
|
||||||
<div className="card card-pad" style={{ textAlign: 'center', padding: '60px 24px' }}>
|
<div
|
||||||
<IdentificationIcon style={{ width: 48, color: 'var(--text-3)', margin: '0 auto 16px' }} />
|
className="card card-pad"
|
||||||
<div style={{ fontWeight: 600, marginBottom: 8 }}>هنوز منشیای اضافه نشده</div>
|
style={{ textAlign: "center", padding: "60px 24px" }}
|
||||||
<div style={{ color: 'var(--text-3)', fontSize: 13.5, marginBottom: 20 }}>
|
>
|
||||||
|
<IdentificationIcon
|
||||||
|
style={{ width: 48, color: "var(--text-3)", margin: "0 auto 16px" }}
|
||||||
|
/>
|
||||||
|
<div style={{ fontWeight: 600, marginBottom: 8 }}>
|
||||||
|
هنوز منشیای اضافه نشده
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{ color: "var(--text-3)", fontSize: 13.5, marginBottom: 20 }}
|
||||||
|
>
|
||||||
منشی میتواند نوبتها و اطلاعات کلینیک را مدیریت کند
|
منشی میتواند نوبتها و اطلاعات کلینیک را مدیریت کند
|
||||||
</div>
|
</div>
|
||||||
<button className="btn primary sm" onClick={() => setCreateOpen(true)}>
|
<button className="btn primary sm" onClick={() => setCreateOpen(true)}>
|
||||||
@@ -250,6 +257,7 @@ PermissionsMatrix: جدول checkbox با ۶ ستون برای ۴ بخش — د
|
|||||||
```
|
```
|
||||||
|
|
||||||
**ج — PermissionsMatrix:**
|
**ج — PermissionsMatrix:**
|
||||||
|
|
||||||
- ردیفهای جدول با رنگ متناوب (striped) یا hover state
|
- ردیفهای جدول با رنگ متناوب (striped) یا hover state
|
||||||
- checkboxها با `accent-color: var(--primary)` (قبلاً بود، نگه دار)
|
- checkboxها با `accent-color: var(--primary)` (قبلاً بود، نگه دار)
|
||||||
- header ستونها را bold و با background کمرنگ کن
|
- header ستونها را bold و با background کمرنگ کن
|
||||||
@@ -262,6 +270,7 @@ PermissionsMatrix: جدول checkbox با ۶ ستون برای ۴ بخش — د
|
|||||||
**هدف:** پنل بخشها بهتر + جدول آیتمها با badge فعال/غیرفعال.
|
**هدف:** پنل بخشها بهتر + جدول آیتمها با badge فعال/غیرفعال.
|
||||||
|
|
||||||
**الف — پنل بخشها (ستون ۲۸۰px):**
|
**الف — پنل بخشها (ستون ۲۸۰px):**
|
||||||
|
|
||||||
- هر بخش یک item با:
|
- هر بخش یک item با:
|
||||||
- آیکون `WrenchScrewdriverIcon` در سمت راست (color از theme)
|
- آیکون `WrenchScrewdriverIcon` در سمت راست (color از theme)
|
||||||
- نام بخش bold
|
- نام بخش bold
|
||||||
@@ -271,6 +280,7 @@ PermissionsMatrix: جدول checkbox با ۶ ستون برای ۴ بخش — د
|
|||||||
- افزودن بخش: دکمه با آیکون + متن «بخش جدید» (نه فقط آیکون)
|
- افزودن بخش: دکمه با آیکون + متن «بخش جدید» (نه فقط آیکون)
|
||||||
|
|
||||||
**ب — پنل آیتمها (ستون 1fr):**
|
**ب — پنل آیتمها (ستون 1fr):**
|
||||||
|
|
||||||
- بالای جدول یک toolbar: نام بخش به عنوان breadcrumb + تعداد آیتمها + دکمه «سرویس جدید»
|
- بالای جدول یک toolbar: نام بخش به عنوان breadcrumb + تعداد آیتمها + دکمه «سرویس جدید»
|
||||||
- هر row آیتم:
|
- هر row آیتم:
|
||||||
- badge «فعال» / «غیرفعال» برای `item.active`
|
- badge «فعال» / «غیرفعال» برای `item.active`
|
||||||
@@ -287,10 +297,19 @@ PermissionsMatrix: جدول checkbox با ۶ ستون برای ۴ بخش — د
|
|||||||
```
|
```
|
||||||
|
|
||||||
**ج — State اولیه (هیچ بخشی انتخاب نشده):**
|
**ج — State اولیه (هیچ بخشی انتخاب نشده):**
|
||||||
|
|
||||||
- جای پیام «یک بخش را از سمت راست انتخاب کنید» یک آنیمیشن ساده یا placeholder card با رنگ dashed border قرار بده:
|
- جای پیام «یک بخش را از سمت راست انتخاب کنید» یک آنیمیشن ساده یا placeholder card با رنگ dashed border قرار بده:
|
||||||
```tsx
|
```tsx
|
||||||
<div style={{ border: '2px dashed var(--border)', borderRadius: 10, padding: '60px 0', textAlign: 'center', color: 'var(--text-3)' }}>
|
<div
|
||||||
<WrenchScrewdriverIcon style={{ width: 40, margin: '0 auto 12px' }} />
|
style={{
|
||||||
|
border: "2px dashed var(--border)",
|
||||||
|
borderRadius: 10,
|
||||||
|
padding: "60px 0",
|
||||||
|
textAlign: "center",
|
||||||
|
color: "var(--text-3)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<WrenchScrewdriverIcon style={{ width: 40, margin: "0 auto 12px" }} />
|
||||||
<div>یک بخش انتخاب کنید تا سرویسهای آن را ببینید</div>
|
<div>یک بخش انتخاب کنید تا سرویسهای آن را ببینید</div>
|
||||||
</div>
|
</div>
|
||||||
```
|
```
|
||||||
@@ -302,6 +321,7 @@ PermissionsMatrix: جدول checkbox با ۶ ستون برای ۴ بخش — د
|
|||||||
**هدف:** کارت موجودی با visual hierarchy قوی + تنظیمات واضحتر + جدول تراکنشها با visual type indicator.
|
**هدف:** کارت موجودی با visual hierarchy قوی + تنظیمات واضحتر + جدول تراکنشها با visual type indicator.
|
||||||
|
|
||||||
**الف — کارت موجودی:**
|
**الف — کارت موجودی:**
|
||||||
|
|
||||||
- gradient background (مثل SubscriptionPage): `background: linear-gradient(135deg, oklch(0.52 0.22 256), oklch(0.40 0.18 256))`
|
- gradient background (مثل SubscriptionPage): `background: linear-gradient(135deg, oklch(0.52 0.22 256), oklch(0.40 0.18 256))`
|
||||||
- رنگ متن: white
|
- رنگ متن: white
|
||||||
- موجودی را بزرگتر کن: `fontSize: 36, fontWeight: 800`
|
- موجودی را بزرگتر کن: `fontSize: 36, fontWeight: 800`
|
||||||
@@ -310,6 +330,7 @@ PermissionsMatrix: جدول checkbox با ۶ ستون برای ۴ بخش — د
|
|||||||
- دکمه شارژ: white با primary text (برعکس از theme معمول)
|
- دکمه شارژ: white با primary text (برعکس از theme معمول)
|
||||||
|
|
||||||
**ب — کارت تنظیمات:**
|
**ب — کارت تنظیمات:**
|
||||||
|
|
||||||
- عنوان با divider پایین
|
- عنوان با divider پایین
|
||||||
- هر toggle را با یک card داخلی wrapper کن:
|
- هر toggle را با یک card داخلی wrapper کن:
|
||||||
```tsx
|
```tsx
|
||||||
@@ -324,6 +345,7 @@ PermissionsMatrix: جدول checkbox با ۶ ستون برای ۴ بخش — د
|
|||||||
- input «چند ساعت قبل» را با border و padding واضحتر style کن
|
- input «چند ساعت قبل» را با border و padding واضحتر style کن
|
||||||
|
|
||||||
**ج — جدول تراکنشها:**
|
**ج — جدول تراکنشها:**
|
||||||
|
|
||||||
- ستون «نوع» را با یک آیکون بهبود بده:
|
- ستون «نوع» را با یک آیکون بهبود بده:
|
||||||
- `credit`: `ArrowUpCircleIcon` با رنگ سبز
|
- `credit`: `ArrowUpCircleIcon` با رنگ سبز
|
||||||
- `debit`: `ArrowDownCircleIcon` با رنگ قرمز
|
- `debit`: `ArrowDownCircleIcon` با رنگ قرمز
|
||||||
@@ -331,26 +353,31 @@ PermissionsMatrix: جدول checkbox با ۶ ستون برای ۴ بخش — د
|
|||||||
- header table با background خاکستری کمرنگ
|
- header table با background خاکستری کمرنگ
|
||||||
|
|
||||||
**د — Modal شارژ:**
|
**د — Modal شارژ:**
|
||||||
|
|
||||||
- انتخاب gateway را به radio card تبدیل کن (مثل SubscriptionPage):
|
- انتخاب gateway را به radio card تبدیل کن (مثل SubscriptionPage):
|
||||||
```tsx
|
```tsx
|
||||||
{(['mellat', 'sep'] as const).map((gw) => (
|
{
|
||||||
|
(["mellat", "sep"] as const).map((gw) => (
|
||||||
<div
|
<div
|
||||||
key={gw}
|
key={gw}
|
||||||
onClick={() => setGateway(gw)}
|
onClick={() => setGateway(gw)}
|
||||||
style={{
|
style={{
|
||||||
border: `2px solid ${gateway === gw ? 'var(--primary)' : 'var(--border)'}`,
|
border: `2px solid ${gateway === gw ? "var(--primary)" : "var(--border)"}`,
|
||||||
borderRadius: 8,
|
borderRadius: 8,
|
||||||
padding: '10px 14px',
|
padding: "10px 14px",
|
||||||
cursor: 'pointer',
|
cursor: "pointer",
|
||||||
background: gateway === gw ? 'var(--primary-subtle)' : '',
|
background: gateway === gw ? "var(--primary-subtle)" : "",
|
||||||
transition: 'all 0.15s',
|
transition: "all 0.15s",
|
||||||
flex: 1,
|
flex: 1,
|
||||||
textAlign: 'center',
|
textAlign: "center",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div style={{ fontWeight: 600 }}>{gw === 'mellat' ? 'بانک ملت' : 'سپ'}</div>
|
<div style={{ fontWeight: 600 }}>
|
||||||
|
{gw === "mellat" ? "بانک ملت" : "سپ"}
|
||||||
</div>
|
</div>
|
||||||
))}
|
</div>
|
||||||
|
));
|
||||||
|
}
|
||||||
```
|
```
|
||||||
- پیشنمایش مبلغ: وقتی کاربر عدد میزند، زیر input بنویس «پرداخت X ریال از طریق Y»
|
- پیشنمایش مبلغ: وقتی کاربر عدد میزند، زیر input بنویس «پرداخت X ریال از طریق Y»
|
||||||
|
|
||||||
@@ -364,5 +391,5 @@ PermissionsMatrix: جدول checkbox با ۶ ستون برای ۴ بخش — د
|
|||||||
6. **SubscriptionPage** را به عنوان مرجع visual نگه دار — gradient، progress bar، و card layout آن مدل است
|
6. **SubscriptionPage** را به عنوان مرجع visual نگه دار — gradient، progress bar، و card layout آن مدل است
|
||||||
7. **بعد از هر صفحه** `ddev exec yarn dev` اجرا کن تا TypeScript error نداشته باشد
|
7. **بعد از هر صفحه** `ddev exec yarn dev` اجرا کن تا TypeScript error نداشته باشد
|
||||||
8. **SessionRow** در MyPatientsPage: قبل از ویرایش، آن component را کامل بخوان (احتمالاً inline در همان فایل است)
|
8. **SessionRow** در MyPatientsPage: قبل از ویرایش، آن component را کامل بخوان (احتمالاً inline در همان فایل است)
|
||||||
9. **`maskMobile()`** از `../lib/utils` — برای نمایش شماره موبایل منشیان استفاده کن
|
9. **`maskMobile()`** از `../lib/utils` — برای نمایش شماره موبایل منشی ها استفاده کن
|
||||||
10. **`formatDate()` و `formatDateTime()`** — همه تاریخها شمسی نمایش داده شوند
|
10. **`formatDate()` و `formatDateTime()`** — همه تاریخها شمسی نمایش داده شوند
|
||||||
|
|||||||
+72
-42
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
**پنل ادمین:** https://clinic-pro.ddev.site/admin
|
**پنل ادمین:** https://clinic-pro.ddev.site/admin
|
||||||
|
|
||||||
|
> رمز عبور همه کاربران seedشده: `Test@1234`
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## ادمین
|
## ادمین
|
||||||
@@ -14,65 +16,93 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## کاربران Multi-Context
|
## کلینیک نمونه — تبریز
|
||||||
|
|
||||||
### کاربر ۱ — دکتر در چند کلینیک
|
|
||||||
|
|
||||||
| فیلد | مقدار |
|
|
||||||
| -------- | -------------------------- |
|
|
||||||
| موبایل | `09100000011` |
|
|
||||||
| پسورد | `Test1234` |
|
|
||||||
| نقش | `ROLE_DOCTOR` |
|
|
||||||
| Contexts | مطب شخصی دکتر آرمان رضایی |
|
|
||||||
| | کلینیک جامع حضرت علی (عضو) |
|
|
||||||
| | بیمارستان ابن سینا (عضو) |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### کاربر ۲ — صاحب کلینیک
|
|
||||||
|
|
||||||
| فیلد | مقدار |
|
| فیلد | مقدار |
|
||||||
| ----------- | -------------------------------------- |
|
| ----------- | -------------------------------------- |
|
||||||
| موبایل | `09100000022` |
|
| موبایل | `09100100000` |
|
||||||
| پسورد | `Test1234` |
|
| پسورد | `Test@1234` |
|
||||||
| نقش | `ROLE_CLINIC` |
|
| نقش | `ROLE_CLINIC` |
|
||||||
| Contexts | کلینیک تست پگاه |
|
| نام | کلینیک تخصصی امید تبریز |
|
||||||
| UUID کلینیک | `0730373f-7c0a-4610-8120-d58ecf4a9916` |
|
| UUID کلینیک | `9ac73318-d313-4772-bf1e-418d47f8f4bc` |
|
||||||
|
| شهر | تبریز (id: 101) |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### کاربر ۳ — منشی
|
## دکتر نمونه کامل — تبریز
|
||||||
|
|
||||||
| فیلد | مقدار |
|
|
||||||
| -------- | ---------------------- |
|
|
||||||
| موبایل | `09100000033` |
|
|
||||||
| پسورد | `Test1234` |
|
|
||||||
| نقش | `ROLE_SECRETARY` |
|
|
||||||
| Contexts | مطب دکتر مهناز سلیمانی |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### کاربر ۴ — دکتر + صاحب کلینیک
|
|
||||||
|
|
||||||
| فیلد | مقدار |
|
| فیلد | مقدار |
|
||||||
| ----------- | -------------------------------------- |
|
| ----------- | -------------------------------------- |
|
||||||
| موبایل | `09100000044` |
|
| موبایل | `09100100001` |
|
||||||
| پسورد | `Test1234` |
|
| پسورد | `Test@1234` |
|
||||||
| نقش | `ROLE_DOCTOR` + `ROLE_CLINIC` |
|
| نقش | `ROLE_DOCTOR` |
|
||||||
| Contexts | مطب شخصی دکتر سامان علوی |
|
| نام | دکتر آرمان رضایی |
|
||||||
| | کلینیک تخصصی علوی |
|
| تخصص | قلب و عروق + داخلی |
|
||||||
| UUID کلینیک | `26a1ff95-32aa-4a96-b0fd-03e75e810be9` |
|
| UUID دکتر | `2a3a7ab9-8d34-4118-862f-b458bcd6d77f` |
|
||||||
|
| کلینیک | کلینیک تخصصی امید تبریز (عضو) |
|
||||||
|
| آدرس مطب | تبریز، خیابان آزادی |
|
||||||
|
| برنامه کاری | شنبه–چهارشنبه ۹–۱۳ و ۱۴–۱۸، پنجشنبه ۹–۱۳ |
|
||||||
|
| بیمه | تأمین اجتماعی، خدمات درمانی، نیروهای مسلح، ایران |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## منشی دکتر نمونه
|
||||||
|
|
||||||
|
| فیلد | مقدار |
|
||||||
|
| ------ | ------------------- |
|
||||||
|
| موبایل | `09100100002` |
|
||||||
|
| پسورد | `Test@1234` |
|
||||||
|
| نقش | `ROLE_SECRETARY` |
|
||||||
|
| نام | خانم نرگس صادقی |
|
||||||
|
| مرتبط | دکتر آرمان رضایی |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## دکتران bulk (۱۵۰۰ دکتر در ۱۵ شهر)
|
||||||
|
|
||||||
|
هر شهر ۱۰۰ دکتر با توزیع واقعی تخصص:
|
||||||
|
|
||||||
|
| شهر | شروع موبایل | تعداد |
|
||||||
|
| ---------- | ------------- | ----- |
|
||||||
|
| تبریز | `09100100003` | ۱۰۰ |
|
||||||
|
| ارومیه | `09100100103` | ۱۰۰ |
|
||||||
|
| اردبیل | `09100100203` | ۱۰۰ |
|
||||||
|
| اصفهان | `09100100303` | ۱۰۰ |
|
||||||
|
| کرج | `09100100403` | ۱۰۰ |
|
||||||
|
| تهران | `09100100503` | ۱۰۰ |
|
||||||
|
| مشهد | `09100100603` | ۱۰۰ |
|
||||||
|
| اهواز | `09100100703` | ۱۰۰ |
|
||||||
|
| شیراز | `09100100803` | ۱۰۰ |
|
||||||
|
| کرمان | `09100100903` | ۱۰۰ |
|
||||||
|
| کرمانشاه | `09100101003` | ۱۰۰ |
|
||||||
|
| رشت | `09100101103` | ۱۰۰ |
|
||||||
|
| ساری | `09100101203` | ۱۰۰ |
|
||||||
|
| همدان | `09100101303` | ۱۰۰ |
|
||||||
|
| یزد | `09100101403` | ۱۰۰ |
|
||||||
|
|
||||||
|
توزیع تخصص در هر شهر:
|
||||||
|
- ۱۵ پزشک عمومی
|
||||||
|
- ۱۰ داخلی + قلب
|
||||||
|
- ۸ جراحی عمومی
|
||||||
|
- ۸ زنان و زایمان
|
||||||
|
- ۸ اطفال
|
||||||
|
- ۷ ارتوپدی
|
||||||
|
- ۶ گوارش، نورولوژی، پوست، چشمپزشکی، دندانپزشکی (هر کدام ۶)
|
||||||
|
- ۵ ENT، روانپزشکی (هر کدام ۵)
|
||||||
|
- ۴ اورولوژی
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## ساخت مجدد
|
## ساخت مجدد
|
||||||
|
|
||||||
اگر دیتابیس ریست شد، اجرا کن:
|
اگر دیتابیس ریست شد، ادمین را بساز:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
ddev exec php create_test_users.php
|
ddev exec php bin/console app:create-admin
|
||||||
```
|
```
|
||||||
|
|
||||||
09212908096
|
برای seed دیتای واقعی دکتران و کلینیک:
|
||||||
|
|
||||||
09212908096
|
```bash
|
||||||
|
ddev exec php seed_realistic_data.php
|
||||||
|
```
|
||||||
|
|||||||
@@ -3,155 +3,308 @@ import {
|
|||||||
ArrowsRightLeftIcon,
|
ArrowsRightLeftIcon,
|
||||||
BanknotesIcon,
|
BanknotesIcon,
|
||||||
BuildingOffice2Icon,
|
BuildingOffice2Icon,
|
||||||
ClipboardDocumentCheckIcon,
|
|
||||||
Cog6ToothIcon,
|
|
||||||
CalendarDaysIcon,
|
CalendarDaysIcon,
|
||||||
ChartBarIcon,
|
ChartBarIcon,
|
||||||
ChatBubbleLeftEllipsisIcon,
|
ChatBubbleLeftEllipsisIcon,
|
||||||
|
ClipboardDocumentCheckIcon,
|
||||||
|
Cog6ToothIcon,
|
||||||
CreditCardIcon,
|
CreditCardIcon,
|
||||||
DevicePhoneMobileIcon,
|
DevicePhoneMobileIcon,
|
||||||
DocumentTextIcon,
|
DocumentTextIcon,
|
||||||
|
FolderOpenIcon,
|
||||||
HeartIcon,
|
HeartIcon,
|
||||||
|
IdentificationIcon,
|
||||||
KeyIcon,
|
KeyIcon,
|
||||||
LockClosedIcon,
|
LockClosedIcon,
|
||||||
StarIcon,
|
StarIcon,
|
||||||
TagIcon,
|
TagIcon,
|
||||||
UserCircleIcon,
|
UserCircleIcon,
|
||||||
UserGroupIcon,
|
UserGroupIcon,
|
||||||
UsersIcon,
|
|
||||||
UserPlusIcon,
|
UserPlusIcon,
|
||||||
|
UsersIcon,
|
||||||
WrenchScrewdriverIcon,
|
WrenchScrewdriverIcon,
|
||||||
FolderOpenIcon,
|
|
||||||
IdentificationIcon,
|
|
||||||
} from "@heroicons/react/24/outline";
|
} from "@heroicons/react/24/outline";
|
||||||
import { NavLink, useNavigate } from "react-router-dom";
|
import { NavLink, useNavigate } from "react-router-dom";
|
||||||
|
import { useSubscription } from "../../hooks/useSubscription";
|
||||||
import { useAuthStore } from "../../stores/authStore";
|
import { useAuthStore } from "../../stores/authStore";
|
||||||
import { useUiStore } from "../../stores/uiStore";
|
import { useUiStore } from "../../stores/uiStore";
|
||||||
import { useSubscription } from "../../hooks/useSubscription";
|
|
||||||
|
|
||||||
type SectionItem = { to: string; icon: React.ElementType; label: string; feature?: string };
|
type SectionItem = {
|
||||||
|
to: string;
|
||||||
|
icon: React.ElementType;
|
||||||
|
label: string;
|
||||||
|
feature?: string;
|
||||||
|
};
|
||||||
type Section = { label: string; items: SectionItem[] };
|
type Section = { label: string; items: SectionItem[] };
|
||||||
|
|
||||||
function buildSections(primaryRole: string | null, dbUuid: string | null): Section[] {
|
function buildSections(
|
||||||
if (primaryRole === 'admin') {
|
primaryRole: string | null,
|
||||||
|
dbUuid: string | null,
|
||||||
|
): Section[] {
|
||||||
|
if (primaryRole === "admin") {
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
label: 'عمومی',
|
label: "عمومی",
|
||||||
items: [
|
items: [
|
||||||
{ to: '/admin/dashboard', icon: ChartBarIcon, label: 'داشبورد' },
|
{
|
||||||
{ to: '/admin/users', icon: UserGroupIcon, label: 'کاربران' },
|
to: "/admin/dashboard",
|
||||||
{ to: '/admin/doctors', icon: HeartIcon, label: 'پزشکان' },
|
icon: ChartBarIcon,
|
||||||
{ to: '/admin/clinics', icon: BuildingOffice2Icon, label: 'کلینیکها' },
|
label: "داشبورد",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
to: "/admin/users",
|
||||||
|
icon: UserGroupIcon,
|
||||||
|
label: "کاربران",
|
||||||
|
},
|
||||||
|
{ to: "/admin/doctors", icon: HeartIcon, label: "پزشکان" },
|
||||||
|
{
|
||||||
|
to: "/admin/clinics",
|
||||||
|
icon: BuildingOffice2Icon,
|
||||||
|
label: "کلینیکها",
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'مدیریت',
|
label: "مدیریت",
|
||||||
items: [
|
items: [
|
||||||
{ to: '/admin/appointments', icon: CalendarDaysIcon, label: 'نوبتها' },
|
{
|
||||||
{ to: '/admin/payments', icon: CreditCardIcon, label: 'پرداختها' },
|
to: "/admin/appointments",
|
||||||
{ to: '/admin/settlements', icon: BanknotesIcon, label: 'تسویهحساب' },
|
icon: CalendarDaysIcon,
|
||||||
{ to: '/admin/pre-registrations', icon: ClipboardDocumentCheckIcon, label: 'درخواستهای ثبتنام' },
|
label: "نوبتها",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
to: "/admin/payments",
|
||||||
|
icon: CreditCardIcon,
|
||||||
|
label: "پرداختها",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
to: "/admin/settlements",
|
||||||
|
icon: BanknotesIcon,
|
||||||
|
label: "تسویهحساب",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
to: "/admin/pre-registrations",
|
||||||
|
icon: ClipboardDocumentCheckIcon,
|
||||||
|
label: "درخواستهای ثبتنام",
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'محتوا',
|
label: "محتوا",
|
||||||
items: [
|
items: [
|
||||||
{ to: '/admin/comments', icon: ChatBubbleLeftEllipsisIcon, label: 'نظرات' },
|
{
|
||||||
{ to: '/admin/ratings', icon: StarIcon, label: 'امتیازها' },
|
to: "/admin/comments",
|
||||||
{ to: '/admin/blogs', icon: DocumentTextIcon, label: 'بلاگ' },
|
icon: ChatBubbleLeftEllipsisIcon,
|
||||||
{ to: '/admin/sms', icon: DevicePhoneMobileIcon, label: 'پیامک' },
|
label: "نظرات",
|
||||||
|
},
|
||||||
|
{ to: "/admin/ratings", icon: StarIcon, label: "امتیازها" },
|
||||||
|
{
|
||||||
|
to: "/admin/blogs",
|
||||||
|
icon: DocumentTextIcon,
|
||||||
|
label: "بلاگ",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
to: "/admin/sms",
|
||||||
|
icon: DevicePhoneMobileIcon,
|
||||||
|
label: "پیامک",
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'سیستم',
|
label: "سیستم",
|
||||||
items: [
|
items: [
|
||||||
{ to: '/admin/categories', icon: TagIcon, label: 'دستهبندیها' },
|
{
|
||||||
{ to: '/admin/representations', icon: UsersIcon, label: 'نمایندگان' },
|
to: "/admin/categories",
|
||||||
{ to: '/admin/secretaries', icon: KeyIcon, label: 'منشیها' },
|
icon: TagIcon,
|
||||||
{ to: '/admin/admin-subscription', icon: CreditCardIcon, label: 'اشتراکها' },
|
label: "دستهبندیها",
|
||||||
{ to: '/admin/settings', icon: Cog6ToothIcon, label: 'تنظیمات' },
|
},
|
||||||
|
{
|
||||||
|
to: "/admin/representations",
|
||||||
|
icon: UsersIcon,
|
||||||
|
label: "نمایندگان",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
to: "/admin/secretaries",
|
||||||
|
icon: KeyIcon,
|
||||||
|
label: "منشیها",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
to: "/admin/admin-subscription",
|
||||||
|
icon: CreditCardIcon,
|
||||||
|
label: "اشتراکها",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
to: "/admin/settings",
|
||||||
|
icon: Cog6ToothIcon,
|
||||||
|
label: "تنظیمات",
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
if (primaryRole === 'clinic') {
|
if (primaryRole === "clinic") {
|
||||||
const clinicTo = dbUuid ? `/admin/clinics/${dbUuid}` : '/admin/my-clinic';
|
const clinicTo = dbUuid
|
||||||
|
? `/admin/clinics/${dbUuid}`
|
||||||
|
: "/admin/my-clinic";
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
label: 'عمومی',
|
label: "عمومی",
|
||||||
items: [
|
items: [
|
||||||
{ to: '/admin/dashboard', icon: ChartBarIcon, label: 'داشبورد' },
|
{
|
||||||
{ to: clinicTo, icon: BuildingOffice2Icon, label: 'کلینیک من' },
|
to: "/admin/dashboard",
|
||||||
|
icon: ChartBarIcon,
|
||||||
|
label: "داشبورد",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
to: clinicTo,
|
||||||
|
icon: BuildingOffice2Icon,
|
||||||
|
label: "کلینیک من",
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'مدیریت',
|
label: "مدیریت",
|
||||||
items: [
|
items: [
|
||||||
{ to: '/admin/appointments', icon: CalendarDaysIcon, label: 'نوبتها' },
|
{
|
||||||
{ to: '/admin/my-patients', icon: FolderOpenIcon, label: 'پرونده بیماران', feature: 'patient_records' },
|
to: "/admin/appointments",
|
||||||
{ to: '/admin/staff', icon: UserPlusIcon, label: 'پرسنل' },
|
icon: CalendarDaysIcon,
|
||||||
{ to: '/admin/my-secretaries', icon: IdentificationIcon, label: 'منشیان' },
|
label: "نوبتها",
|
||||||
{ to: '/admin/clinic-services', icon: WrenchScrewdriverIcon, label: 'سرویسها', feature: 'services' },
|
},
|
||||||
{ to: '/admin/sms-wallet', icon: DevicePhoneMobileIcon, label: 'کیف پول پیامک', feature: 'sms_panel' },
|
{
|
||||||
|
to: "/admin/my-patients",
|
||||||
|
icon: FolderOpenIcon,
|
||||||
|
label: "پرونده بیماران",
|
||||||
|
feature: "patient_records",
|
||||||
|
},
|
||||||
|
{ to: "/admin/staff", icon: UserPlusIcon, label: "پرسنل" },
|
||||||
|
{
|
||||||
|
to: "/admin/my-secretaries",
|
||||||
|
icon: IdentificationIcon,
|
||||||
|
label: "منشی ها",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
to: "/admin/clinic-services",
|
||||||
|
icon: WrenchScrewdriverIcon,
|
||||||
|
label: "سرویسها",
|
||||||
|
feature: "services",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
to: "/admin/sms-wallet",
|
||||||
|
icon: DevicePhoneMobileIcon,
|
||||||
|
label: "کیف پول پیامک",
|
||||||
|
feature: "sms_panel",
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'اشتراک',
|
label: "اشتراک",
|
||||||
items: [
|
items: [
|
||||||
{ to: '/admin/subscription', icon: CreditCardIcon, label: 'پنل اشتراکی' },
|
{
|
||||||
|
to: "/admin/subscription",
|
||||||
|
icon: CreditCardIcon,
|
||||||
|
label: "پنل اشتراکی",
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
if (primaryRole === 'doctor') {
|
if (primaryRole === "doctor") {
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
label: 'عمومی',
|
label: "عمومی",
|
||||||
items: [
|
items: [
|
||||||
{ to: '/admin/dashboard', icon: ChartBarIcon, label: 'داشبورد' },
|
{
|
||||||
|
to: "/admin/dashboard",
|
||||||
|
icon: ChartBarIcon,
|
||||||
|
label: "داشبورد",
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'پروفایل',
|
label: "پروفایل",
|
||||||
items: [
|
items: [
|
||||||
{ to: '/admin/profile', icon: UserCircleIcon, label: 'پروفایل من' },
|
{
|
||||||
|
to: "/admin/profile",
|
||||||
|
icon: UserCircleIcon,
|
||||||
|
label: "پروفایل من",
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'مدیریت',
|
label: "مدیریت",
|
||||||
items: [
|
items: [
|
||||||
{ to: '/admin/appointments', icon: CalendarDaysIcon, label: 'نوبتهای من' },
|
{
|
||||||
{ to: '/admin/my-patients', icon: FolderOpenIcon, label: 'پرونده بیماران', feature: 'patient_records' },
|
to: "/admin/appointments",
|
||||||
{ to: '/admin/staff', icon: UserPlusIcon, label: 'پرسنل' },
|
icon: CalendarDaysIcon,
|
||||||
{ to: '/admin/my-secretaries', icon: IdentificationIcon, label: 'منشیان' },
|
label: "نوبتهای من",
|
||||||
{ to: '/admin/clinic-services', icon: WrenchScrewdriverIcon, label: 'سرویسها', feature: 'services' },
|
},
|
||||||
{ to: '/admin/sms-wallet', icon: DevicePhoneMobileIcon, label: 'کیف پول پیامک', feature: 'sms_panel' },
|
{
|
||||||
|
to: "/admin/my-patients",
|
||||||
|
icon: FolderOpenIcon,
|
||||||
|
label: "پرونده بیماران",
|
||||||
|
feature: "patient_records",
|
||||||
|
},
|
||||||
|
{ to: "/admin/staff", icon: UserPlusIcon, label: "پرسنل" },
|
||||||
|
{
|
||||||
|
to: "/admin/my-secretaries",
|
||||||
|
icon: IdentificationIcon,
|
||||||
|
label: "منشی ها",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
to: "/admin/clinic-services",
|
||||||
|
icon: WrenchScrewdriverIcon,
|
||||||
|
label: "سرویسها",
|
||||||
|
feature: "services",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
to: "/admin/sms-wallet",
|
||||||
|
icon: DevicePhoneMobileIcon,
|
||||||
|
label: "کیف پول پیامک",
|
||||||
|
feature: "sms_panel",
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'اشتراک',
|
label: "اشتراک",
|
||||||
items: [
|
items: [
|
||||||
{ to: '/admin/subscription', icon: CreditCardIcon, label: 'پنل اشتراکی' },
|
{
|
||||||
|
to: "/admin/subscription",
|
||||||
|
icon: CreditCardIcon,
|
||||||
|
label: "پنل اشتراکی",
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
if (primaryRole === 'secretary') {
|
if (primaryRole === "secretary") {
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
label: 'عمومی',
|
label: "عمومی",
|
||||||
items: [
|
items: [
|
||||||
{ to: '/admin/dashboard', icon: ChartBarIcon, label: 'داشبورد' },
|
{
|
||||||
|
to: "/admin/dashboard",
|
||||||
|
icon: ChartBarIcon,
|
||||||
|
label: "داشبورد",
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'مدیریت',
|
label: "مدیریت",
|
||||||
items: [
|
items: [
|
||||||
{ to: '/admin/appointments', icon: CalendarDaysIcon, label: 'نوبتها' },
|
{
|
||||||
{ to: '/admin/my-patients', icon: FolderOpenIcon, label: 'پرونده بیماران', feature: 'patient_records' },
|
to: "/admin/appointments",
|
||||||
|
icon: CalendarDaysIcon,
|
||||||
|
label: "نوبتها",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
to: "/admin/my-patients",
|
||||||
|
icon: FolderOpenIcon,
|
||||||
|
label: "پرونده بیماران",
|
||||||
|
feature: "patient_records",
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
@@ -159,18 +312,24 @@ function buildSections(primaryRole: string | null, dbUuid: string | null): Secti
|
|||||||
|
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
label: 'عمومی',
|
label: "عمومی",
|
||||||
items: [{ to: '/admin/dashboard', icon: ChartBarIcon, label: 'داشبورد' }],
|
items: [
|
||||||
|
{
|
||||||
|
to: "/admin/dashboard",
|
||||||
|
icon: ChartBarIcon,
|
||||||
|
label: "داشبورد",
|
||||||
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
const ROLE_LABELS: Record<string, string> = {
|
const ROLE_LABELS: Record<string, string> = {
|
||||||
admin: 'مدیر کل',
|
admin: "مدیر کل",
|
||||||
clinic: 'مالک کلینیک',
|
clinic: "مالک کلینیک",
|
||||||
doctor: 'پزشک',
|
doctor: "پزشک",
|
||||||
secretary: 'منشی',
|
secretary: "منشی",
|
||||||
user: 'کاربر',
|
user: "کاربر",
|
||||||
};
|
};
|
||||||
|
|
||||||
const HUES = [256, 205, 162, 295, 272];
|
const HUES = [256, 205, 162, 295, 272];
|
||||||
@@ -187,12 +346,13 @@ interface Props {
|
|||||||
|
|
||||||
export default function Sidebar({ mobileOpen: _m, onMobileClose: _c }: Props) {
|
export default function Sidebar({ mobileOpen: _m, onMobileClose: _c }: Props) {
|
||||||
const sidebarOpen = useUiStore((s) => s.sidebarOpen);
|
const sidebarOpen = useUiStore((s) => s.sidebarOpen);
|
||||||
const { logout, primaryRole, userName, availableContexts, dbUuid } = useAuthStore();
|
const { logout, primaryRole, userName, availableContexts, dbUuid } =
|
||||||
|
useAuthStore();
|
||||||
const { hasFeature } = useSubscription();
|
const { hasFeature } = useSubscription();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
const sections = buildSections(primaryRole, dbUuid);
|
const sections = buildSections(primaryRole, dbUuid);
|
||||||
const initials = (userName ?? 'U').charAt(0).toUpperCase();
|
const initials = (userName ?? "U").charAt(0).toUpperCase();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside className="sidebar">
|
<aside className="sidebar">
|
||||||
@@ -212,23 +372,56 @@ export default function Sidebar({ mobileOpen: _m, onMobileClose: _c }: Props) {
|
|||||||
{sections.map((section) => (
|
{sections.map((section) => (
|
||||||
<div className="nav-group" key={section.label}>
|
<div className="nav-group" key={section.label}>
|
||||||
<span className="nav-label">{section.label}</span>
|
<span className="nav-label">{section.label}</span>
|
||||||
{section.items.map(({ to, icon: Icon, label, feature }) => {
|
{section.items.map(
|
||||||
const isLocked = feature ? !hasFeature(feature) : false;
|
({ to, icon: Icon, label, feature }) => {
|
||||||
const dest = isLocked ? '/admin/subscription' : to;
|
const isLocked = feature
|
||||||
|
? !hasFeature(feature)
|
||||||
|
: false;
|
||||||
|
const dest = isLocked
|
||||||
|
? "/admin/subscription"
|
||||||
|
: to;
|
||||||
return (
|
return (
|
||||||
<NavLink
|
<NavLink
|
||||||
key={to}
|
key={to}
|
||||||
to={dest}
|
to={dest}
|
||||||
title={isLocked ? 'نیاز به ارتقاء پنل' : (!sidebarOpen ? label : undefined)}
|
title={
|
||||||
className={({ isActive }) => `nav-item${isActive && !isLocked ? ' active' : ''}${isLocked ? ' locked' : ''}`}
|
isLocked
|
||||||
style={isLocked ? { opacity: 0.55 } : undefined}
|
? "نیاز به ارتقاء پنل"
|
||||||
|
: !sidebarOpen
|
||||||
|
? label
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
className={({ isActive }) =>
|
||||||
|
`nav-item${isActive && !isLocked ? " active" : ""}${isLocked ? " locked" : ""}`
|
||||||
|
}
|
||||||
|
style={
|
||||||
|
isLocked
|
||||||
|
? { opacity: 0.55 }
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<Icon style={{ width: 19, height: 19, flexShrink: 0 }} />
|
<Icon
|
||||||
|
style={{
|
||||||
|
width: 19,
|
||||||
|
height: 19,
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
<span>{label}</span>
|
<span>{label}</span>
|
||||||
{isLocked && <LockClosedIcon style={{ width: 12, height: 12, marginRight: 'auto', flexShrink: 0 }} />}
|
{isLocked && (
|
||||||
|
<LockClosedIcon
|
||||||
|
style={{
|
||||||
|
width: 12,
|
||||||
|
height: 12,
|
||||||
|
marginRight: "auto",
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</NavLink>
|
</NavLink>
|
||||||
);
|
);
|
||||||
})}
|
},
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
@@ -238,10 +431,14 @@ export default function Sidebar({ mobileOpen: _m, onMobileClose: _c }: Props) {
|
|||||||
<span className="nav-label">محیط کاری</span>
|
<span className="nav-label">محیط کاری</span>
|
||||||
<NavLink
|
<NavLink
|
||||||
to="/admin/select-context"
|
to="/admin/select-context"
|
||||||
title={!sidebarOpen ? 'تغییر محیط' : undefined}
|
title={!sidebarOpen ? "تغییر محیط" : undefined}
|
||||||
className={({ isActive }) => `nav-item${isActive ? ' active' : ''}`}
|
className={({ isActive }) =>
|
||||||
|
`nav-item${isActive ? " active" : ""}`
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<ArrowsRightLeftIcon style={{ width: 19, height: 19, flexShrink: 0 }} />
|
<ArrowsRightLeftIcon
|
||||||
|
style={{ width: 19, height: 19, flexShrink: 0 }}
|
||||||
|
/>
|
||||||
<span>تغییر محیط</span>
|
<span>تغییر محیط</span>
|
||||||
</NavLink>
|
</NavLink>
|
||||||
</div>
|
</div>
|
||||||
@@ -252,20 +449,37 @@ export default function Sidebar({ mobileOpen: _m, onMobileClose: _c }: Props) {
|
|||||||
<div className="sidebar-foot">
|
<div className="sidebar-foot">
|
||||||
<div
|
<div
|
||||||
className="user-chip"
|
className="user-chip"
|
||||||
onClick={() => { logout(); navigate('/admin/login'); }}
|
onClick={() => {
|
||||||
|
logout();
|
||||||
|
navigate("/admin/login");
|
||||||
|
}}
|
||||||
title="خروج از سیستم"
|
title="خروج از سیستم"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className="avatar sm"
|
className="avatar sm"
|
||||||
style={{ background: avatarBg(userName ?? 'U'), flexShrink: 0 }}
|
style={{
|
||||||
|
background: avatarBg(userName ?? "U"),
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{initials}
|
{initials}
|
||||||
</div>
|
</div>
|
||||||
<div className="user-meta">
|
<div className="user-meta">
|
||||||
<b>{userName ?? 'کاربر'}</b>
|
<b>{userName ?? "کاربر"}</b>
|
||||||
<span>{ROLE_LABELS[primaryRole ?? ''] ?? primaryRole ?? ''}</span>
|
<span>
|
||||||
|
{ROLE_LABELS[primaryRole ?? ""] ??
|
||||||
|
primaryRole ??
|
||||||
|
""}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<ArrowLeftOnRectangleIcon style={{ width: 16, height: 16, flexShrink: 0, color: 'var(--text-3)' }} />
|
<ArrowLeftOnRectangleIcon
|
||||||
|
style={{
|
||||||
|
width: 16,
|
||||||
|
height: 16,
|
||||||
|
flexShrink: 0,
|
||||||
|
color: "var(--text-3)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|||||||
@@ -1,27 +1,39 @@
|
|||||||
import React, { useState } from 'react';
|
import {
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
CheckCircleIcon,
|
||||||
import { PlusIcon, PencilIcon, NoSymbolIcon, CheckCircleIcon, IdentificationIcon, PhoneIcon } from '@heroicons/react/24/outline';
|
IdentificationIcon,
|
||||||
import { useForm } from 'react-hook-form';
|
NoSymbolIcon,
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
PencilIcon,
|
||||||
import { z } from 'zod';
|
PhoneIcon,
|
||||||
import { toast } from 'sonner';
|
PlusIcon,
|
||||||
import { api } from '../lib/api';
|
} from "@heroicons/react/24/outline";
|
||||||
import type { ApiResponse } from '../lib/api';
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import type { Secretary, SecretaryPermissions } from '../types';
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { formatDate, maskMobile } from '../lib/utils';
|
import { useState } from "react";
|
||||||
import { ActiveBadge } from '../components/ui/StatusBadge';
|
import { useForm } from "react-hook-form";
|
||||||
import DataTable, { type Column } from '../components/ui/DataTable';
|
import { Link } from "react-router-dom";
|
||||||
import Modal from '../components/ui/Modal';
|
import { toast } from "sonner";
|
||||||
import ConfirmDialog from '../components/ui/ConfirmDialog';
|
import { z } from "zod";
|
||||||
import PageHeader from '../components/ui/PageHeader';
|
import ConfirmDialog from "../components/ui/ConfirmDialog";
|
||||||
import { useAuthStore } from '../stores/authStore';
|
import DataTable, { type Column } from "../components/ui/DataTable";
|
||||||
import { useSubscription } from '../hooks/useSubscription';
|
import Modal from "../components/ui/Modal";
|
||||||
import { Link } from 'react-router-dom';
|
import PageHeader from "../components/ui/PageHeader";
|
||||||
|
import { ActiveBadge } from "../components/ui/StatusBadge";
|
||||||
|
import { useSubscription } from "../hooks/useSubscription";
|
||||||
|
import type { ApiResponse } from "../lib/api";
|
||||||
|
import { api } from "../lib/api";
|
||||||
|
import { formatDate, maskMobile } from "../lib/utils";
|
||||||
|
import { useAuthStore } from "../stores/authStore";
|
||||||
|
import type { Secretary, SecretaryPermissions } from "../types";
|
||||||
|
|
||||||
// ── Default permissions & labels ──────────────────────────────────────────
|
// ── Default permissions & labels ──────────────────────────────────────────
|
||||||
|
|
||||||
const DEFAULT_PERMISSIONS: SecretaryPermissions = {
|
const DEFAULT_PERMISSIONS: SecretaryPermissions = {
|
||||||
appointments: { view: true, create: false, cancel: false, update_status: false },
|
appointments: {
|
||||||
|
view: true,
|
||||||
|
create: false,
|
||||||
|
cancel: false,
|
||||||
|
update_status: false,
|
||||||
|
},
|
||||||
addresses: { view: true, create: false, update: false, delete: false },
|
addresses: { view: true, create: false, update: false, delete: false },
|
||||||
clinic_info: { view: true, update: false },
|
clinic_info: { view: true, update: false },
|
||||||
insurances: { view: true, create: false, update: false, delete: false },
|
insurances: { view: true, create: false, update: false, delete: false },
|
||||||
@@ -29,45 +41,62 @@ const DEFAULT_PERMISSIONS: SecretaryPermissions = {
|
|||||||
|
|
||||||
type PermSection = keyof SecretaryPermissions;
|
type PermSection = keyof SecretaryPermissions;
|
||||||
|
|
||||||
const PERMISSION_LABELS: Record<PermSection, { label: string; actions: { key: string; label: string }[] }> = {
|
const PERMISSION_LABELS: Record<
|
||||||
|
PermSection,
|
||||||
|
{ label: string; actions: { key: string; label: string }[] }
|
||||||
|
> = {
|
||||||
appointments: {
|
appointments: {
|
||||||
label: 'نوبتها',
|
label: "نوبتها",
|
||||||
actions: [
|
actions: [
|
||||||
{ key: 'view', label: 'مشاهده' },
|
{ key: "view", label: "مشاهده" },
|
||||||
{ key: 'create', label: 'ایجاد' },
|
{ key: "create", label: "ایجاد" },
|
||||||
{ key: 'cancel', label: 'لغو' },
|
{ key: "cancel", label: "لغو" },
|
||||||
{ key: 'update_status', label: 'تغییر وضعیت' },
|
{ key: "update_status", label: "تغییر وضعیت" },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
addresses: {
|
addresses: {
|
||||||
label: 'آدرسها',
|
label: "آدرسها",
|
||||||
actions: [
|
actions: [
|
||||||
{ key: 'view', label: 'مشاهده' },
|
{ key: "view", label: "مشاهده" },
|
||||||
{ key: 'create', label: 'ایجاد' },
|
{ key: "create", label: "ایجاد" },
|
||||||
{ key: 'update', label: 'ویرایش' },
|
{ key: "update", label: "ویرایش" },
|
||||||
{ key: 'delete', label: 'حذف' },
|
{ key: "delete", label: "حذف" },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
clinic_info: {
|
clinic_info: {
|
||||||
label: 'اطلاعات کلینیک',
|
label: "اطلاعات کلینیک",
|
||||||
actions: [
|
actions: [
|
||||||
{ key: 'view', label: 'مشاهده' },
|
{ key: "view", label: "مشاهده" },
|
||||||
{ key: 'update', label: 'ویرایش' },
|
{ key: "update", label: "ویرایش" },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
insurances: {
|
insurances: {
|
||||||
label: 'بیمهها',
|
label: "بیمهها",
|
||||||
actions: [
|
actions: [
|
||||||
{ key: 'view', label: 'مشاهده' },
|
{ key: "view", label: "مشاهده" },
|
||||||
{ key: 'create', label: 'ایجاد' },
|
{ key: "create", label: "ایجاد" },
|
||||||
{ key: 'update', label: 'ویرایش' },
|
{ key: "update", label: "ویرایش" },
|
||||||
{ key: 'delete', label: 'حذف' },
|
{ key: "delete", label: "حذف" },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const ALL_ACTIONS = ['view', 'create', 'update', 'delete', 'cancel', 'update_status'];
|
const ALL_ACTIONS = [
|
||||||
const ACTION_HEADERS = ['مشاهده', 'ایجاد', 'ویرایش', 'حذف', 'لغو', 'تغییر وضعیت'];
|
"view",
|
||||||
|
"create",
|
||||||
|
"update",
|
||||||
|
"delete",
|
||||||
|
"cancel",
|
||||||
|
"update_status",
|
||||||
|
];
|
||||||
|
const ACTION_HEADERS = [
|
||||||
|
"مشاهده",
|
||||||
|
"ایجاد",
|
||||||
|
"ویرایش",
|
||||||
|
"حذف",
|
||||||
|
"لغو",
|
||||||
|
"تغییر وضعیت",
|
||||||
|
];
|
||||||
|
|
||||||
function PermissionsMatrix({
|
function PermissionsMatrix({
|
||||||
permissions,
|
permissions,
|
||||||
@@ -80,49 +109,131 @@ function PermissionsMatrix({
|
|||||||
const cur = (permissions[section] as Record<string, boolean>)[action];
|
const cur = (permissions[section] as Record<string, boolean>)[action];
|
||||||
onChange({
|
onChange({
|
||||||
...permissions,
|
...permissions,
|
||||||
[section]: { ...(permissions[section] as Record<string, boolean>), [action]: !cur },
|
[section]: {
|
||||||
|
...(permissions[section] as Record<string, boolean>),
|
||||||
|
[action]: !cur,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ overflowX: 'auto' }}>
|
<div style={{ overflowX: "auto" }}>
|
||||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
|
<table
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
borderCollapse: "collapse",
|
||||||
|
fontSize: 13,
|
||||||
|
}}
|
||||||
|
>
|
||||||
<thead>
|
<thead>
|
||||||
<tr style={{ background: 'oklch(0.97 0.01 256)' }}>
|
<tr style={{ background: "oklch(0.97 0.01 256)" }}>
|
||||||
<th style={{ textAlign: 'right', padding: '10px 12px', fontWeight: 600, color: 'var(--text-2)', borderBottom: '2px solid var(--border)' }}>بخش</th>
|
<th
|
||||||
|
style={{
|
||||||
|
textAlign: "right",
|
||||||
|
padding: "10px 12px",
|
||||||
|
fontWeight: 600,
|
||||||
|
color: "var(--text-2)",
|
||||||
|
borderBottom: "2px solid var(--border)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
بخش
|
||||||
|
</th>
|
||||||
{ACTION_HEADERS.map((h) => (
|
{ACTION_HEADERS.map((h) => (
|
||||||
<th key={h} style={{ textAlign: 'center', padding: '10px 8px', fontSize: 12, fontWeight: 600, color: 'var(--text-2)', borderBottom: '2px solid var(--border)' }}>{h}</th>
|
<th
|
||||||
|
key={h}
|
||||||
|
style={{
|
||||||
|
textAlign: "center",
|
||||||
|
padding: "10px 8px",
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: 600,
|
||||||
|
color: "var(--text-2)",
|
||||||
|
borderBottom: "2px solid var(--border)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{h}
|
||||||
|
</th>
|
||||||
))}
|
))}
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{(Object.keys(PERMISSION_LABELS) as PermSection[]).map((section, idx) => {
|
{(Object.keys(PERMISSION_LABELS) as PermSection[]).map(
|
||||||
|
(section, idx) => {
|
||||||
const config = PERMISSION_LABELS[section];
|
const config = PERMISSION_LABELS[section];
|
||||||
const sectionPrm = permissions[section] as Record<string, boolean>;
|
const sectionPrm = permissions[section] as Record<
|
||||||
|
string,
|
||||||
|
boolean
|
||||||
|
>;
|
||||||
return (
|
return (
|
||||||
<tr key={section} style={{ background: idx % 2 === 0 ? 'transparent' : 'oklch(0.985 0.005 256)' }}>
|
<tr
|
||||||
<td style={{ padding: '10px 12px', borderBottom: '1px solid var(--border)', fontWeight: 600 }}>
|
key={section}
|
||||||
|
style={{
|
||||||
|
background:
|
||||||
|
idx % 2 === 0
|
||||||
|
? "transparent"
|
||||||
|
: "oklch(0.985 0.005 256)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<td
|
||||||
|
style={{
|
||||||
|
padding: "10px 12px",
|
||||||
|
borderBottom:
|
||||||
|
"1px solid var(--border)",
|
||||||
|
fontWeight: 600,
|
||||||
|
}}
|
||||||
|
>
|
||||||
{config.label}
|
{config.label}
|
||||||
</td>
|
</td>
|
||||||
{ALL_ACTIONS.map((action) => {
|
{ALL_ACTIONS.map((action) => {
|
||||||
const ac = config.actions.find((a) => a.key === action);
|
const ac = config.actions.find(
|
||||||
if (!ac) return (
|
(a) => a.key === action,
|
||||||
<td key={action} style={{ textAlign: 'center', borderBottom: '1px solid var(--border)', color: 'var(--border)' }}>—</td>
|
);
|
||||||
|
if (!ac)
|
||||||
|
return (
|
||||||
|
<td
|
||||||
|
key={action}
|
||||||
|
style={{
|
||||||
|
textAlign: "center",
|
||||||
|
borderBottom:
|
||||||
|
"1px solid var(--border)",
|
||||||
|
color: "var(--border)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
—
|
||||||
|
</td>
|
||||||
);
|
);
|
||||||
return (
|
return (
|
||||||
<td key={action} style={{ textAlign: 'center', borderBottom: '1px solid var(--border)' }}>
|
<td
|
||||||
|
key={action}
|
||||||
|
style={{
|
||||||
|
textAlign: "center",
|
||||||
|
borderBottom:
|
||||||
|
"1px solid var(--border)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={sectionPrm[action] ?? false}
|
checked={
|
||||||
onChange={() => toggle(section, action)}
|
sectionPrm[action] ??
|
||||||
style={{ width: 16, height: 16, accentColor: 'var(--primary)', cursor: 'pointer' }}
|
false
|
||||||
|
}
|
||||||
|
onChange={() =>
|
||||||
|
toggle(section, action)
|
||||||
|
}
|
||||||
|
style={{
|
||||||
|
width: 16,
|
||||||
|
height: 16,
|
||||||
|
accentColor:
|
||||||
|
"var(--primary)",
|
||||||
|
cursor: "pointer",
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</td>
|
</td>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</tr>
|
</tr>
|
||||||
);
|
);
|
||||||
})}
|
},
|
||||||
|
)}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
@@ -132,12 +243,15 @@ function PermissionsMatrix({
|
|||||||
// ── Create form schema ─────────────────────────────────────────────────────
|
// ── Create form schema ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
const createSchema = z.object({
|
const createSchema = z.object({
|
||||||
name: z.string().min(2, 'نام الزامی است'),
|
name: z.string().min(2, "نام الزامی است"),
|
||||||
mobile_number: z.string().regex(/^09[0-9]{9}$/, 'شماره موبایل معتبر نیست'),
|
mobile_number: z.string().regex(/^09[0-9]{9}$/, "شماره موبایل معتبر نیست"),
|
||||||
});
|
});
|
||||||
type CreateForm = z.infer<typeof createSchema>;
|
type CreateForm = z.infer<typeof createSchema>;
|
||||||
|
|
||||||
interface ClinicDoctor { uuid: string; name: string; }
|
interface ClinicDoctor {
|
||||||
|
uuid: string;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
// ── Main component ─────────────────────────────────────────────────────────
|
// ── Main component ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -145,37 +259,43 @@ export default function MySecretariesPage() {
|
|||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
const { doctorUuid, dbUuid, primaryRole } = useAuthStore();
|
const { doctorUuid, dbUuid, primaryRole } = useAuthStore();
|
||||||
|
|
||||||
const isClinic = primaryRole === 'clinic';
|
const isClinic = primaryRole === "clinic";
|
||||||
const { maxSecretaries } = useSubscription();
|
const { maxSecretaries } = useSubscription();
|
||||||
|
|
||||||
// for clinic: selected doctor to add secretary for
|
// for clinic: selected doctor to add secretary for
|
||||||
const [selectedDoctorUuid, setSelectedDoctorUuid] = useState<string>('');
|
const [selectedDoctorUuid, setSelectedDoctorUuid] = useState<string>("");
|
||||||
|
|
||||||
const activeDoctorUuid = isClinic ? selectedDoctorUuid : (doctorUuid ?? '');
|
const activeDoctorUuid = isClinic ? selectedDoctorUuid : (doctorUuid ?? "");
|
||||||
|
|
||||||
const [createOpen, setCreateOpen] = useState(false);
|
const [createOpen, setCreateOpen] = useState(false);
|
||||||
const [editTarget, setEditTarget] = useState<Secretary | null>(null);
|
const [editTarget, setEditTarget] = useState<Secretary | null>(null);
|
||||||
const [editPerms, setEditPerms] = useState<SecretaryPermissions>(DEFAULT_PERMISSIONS);
|
const [editPerms, setEditPerms] =
|
||||||
|
useState<SecretaryPermissions>(DEFAULT_PERMISSIONS);
|
||||||
const [deleteTarget, setDeleteTarget] = useState<Secretary | null>(null);
|
const [deleteTarget, setDeleteTarget] = useState<Secretary | null>(null);
|
||||||
|
|
||||||
// clinic: load clinic's doctors
|
// clinic: load clinic's doctors
|
||||||
const { data: clinicDoctorsData, isLoading: clinicDoctorsLoading } = useQuery<ApiResponse<{ data: ClinicDoctor[] }>>({
|
const { data: clinicDoctorsData, isLoading: clinicDoctorsLoading } =
|
||||||
queryKey: ['clinic-doctors', dbUuid],
|
useQuery<ApiResponse<{ data: ClinicDoctor[] }>>({
|
||||||
|
queryKey: ["clinic-doctors", dbUuid],
|
||||||
queryFn: () => api.get(`/api/v1/clinic/doctor-list/${dbUuid}`),
|
queryFn: () => api.get(`/api/v1/clinic/doctor-list/${dbUuid}`),
|
||||||
enabled: isClinic && !!dbUuid,
|
enabled: isClinic && !!dbUuid,
|
||||||
});
|
});
|
||||||
const clinicDoctors: ClinicDoctor[] = clinicDoctorsData?.data?.data ?? [];
|
const clinicDoctors: ClinicDoctor[] = clinicDoctorsData?.data?.data ?? [];
|
||||||
|
|
||||||
// clinic: load ALL secretaries across all its doctors
|
// clinic: load ALL secretaries across all its doctors
|
||||||
const { data: clinicSecrData, isLoading: clinicSecrLoading } = useQuery<ApiResponse<Secretary[]>>({
|
const { data: clinicSecrData, isLoading: clinicSecrLoading } = useQuery<
|
||||||
queryKey: ['my-secretaries-clinic', dbUuid],
|
ApiResponse<Secretary[]>
|
||||||
|
>({
|
||||||
|
queryKey: ["my-secretaries-clinic", dbUuid],
|
||||||
queryFn: () => api.get(`/api/v1/secretaries/clinic/${dbUuid}`),
|
queryFn: () => api.get(`/api/v1/secretaries/clinic/${dbUuid}`),
|
||||||
enabled: isClinic && !!dbUuid,
|
enabled: isClinic && !!dbUuid,
|
||||||
});
|
});
|
||||||
|
|
||||||
// doctor: load secretaries for the doctor
|
// doctor: load secretaries for the doctor
|
||||||
const { data: doctorSecrData, isLoading: doctorSecrLoading } = useQuery<ApiResponse<Secretary[]>>({
|
const { data: doctorSecrData, isLoading: doctorSecrLoading } = useQuery<
|
||||||
queryKey: ['my-secretaries', activeDoctorUuid],
|
ApiResponse<Secretary[]>
|
||||||
|
>({
|
||||||
|
queryKey: ["my-secretaries", activeDoctorUuid],
|
||||||
queryFn: () => api.get(`/api/v1/secretaries/${activeDoctorUuid}`),
|
queryFn: () => api.get(`/api/v1/secretaries/${activeDoctorUuid}`),
|
||||||
enabled: !isClinic && !!activeDoctorUuid,
|
enabled: !isClinic && !!activeDoctorUuid,
|
||||||
});
|
});
|
||||||
@@ -186,29 +306,42 @@ export default function MySecretariesPage() {
|
|||||||
const activeSecretaryCount = secretaries.filter((s) => s.is_active).length;
|
const activeSecretaryCount = secretaries.filter((s) => s.is_active).length;
|
||||||
const isLoading = isClinic ? clinicSecrLoading : doctorSecrLoading;
|
const isLoading = isClinic ? clinicSecrLoading : doctorSecrLoading;
|
||||||
|
|
||||||
const createForm = useForm<CreateForm>({ resolver: zodResolver(createSchema) });
|
const createForm = useForm<CreateForm>({
|
||||||
|
resolver: zodResolver(createSchema),
|
||||||
|
});
|
||||||
|
|
||||||
const createMutation = useMutation({
|
const createMutation = useMutation({
|
||||||
mutationFn: (body: CreateForm) =>
|
mutationFn: (body: CreateForm) =>
|
||||||
api.post('/api/v1/secretary', { ...body, doctor_uuid: activeDoctorUuid }),
|
api.post("/api/v1/secretary", {
|
||||||
|
...body,
|
||||||
|
doctor_uuid: activeDoctorUuid,
|
||||||
|
}),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
toast.success('منشی اضافه شد');
|
toast.success("منشی اضافه شد");
|
||||||
setCreateOpen(false);
|
setCreateOpen(false);
|
||||||
createForm.reset();
|
createForm.reset();
|
||||||
qc.invalidateQueries({ queryKey: ['my-secretaries'] });
|
qc.invalidateQueries({ queryKey: ["my-secretaries"] });
|
||||||
qc.invalidateQueries({ queryKey: ['my-secretaries-clinic'] });
|
qc.invalidateQueries({ queryKey: ["my-secretaries-clinic"] });
|
||||||
},
|
},
|
||||||
onError: (e: any) => toast.error(e.message),
|
onError: (e: any) => toast.error(e.message),
|
||||||
});
|
});
|
||||||
|
|
||||||
const updatePermsMutation = useMutation({
|
const updatePermsMutation = useMutation({
|
||||||
mutationFn: ({ uuid, permissions }: { uuid: string; permissions: SecretaryPermissions }) =>
|
mutationFn: ({
|
||||||
api.patch(`/api/v1/secretary/${uuid}`, { permissions: { version: 1, resources: permissions } }),
|
uuid,
|
||||||
|
permissions,
|
||||||
|
}: {
|
||||||
|
uuid: string;
|
||||||
|
permissions: SecretaryPermissions;
|
||||||
|
}) =>
|
||||||
|
api.patch(`/api/v1/secretary/${uuid}`, {
|
||||||
|
permissions: { version: 1, resources: permissions },
|
||||||
|
}),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
toast.success('دسترسیها بروزرسانی شد');
|
toast.success("دسترسیها بروزرسانی شد");
|
||||||
setEditTarget(null);
|
setEditTarget(null);
|
||||||
qc.invalidateQueries({ queryKey: ['my-secretaries'] });
|
qc.invalidateQueries({ queryKey: ["my-secretaries"] });
|
||||||
qc.invalidateQueries({ queryKey: ['my-secretaries-clinic'] });
|
qc.invalidateQueries({ queryKey: ["my-secretaries-clinic"] });
|
||||||
},
|
},
|
||||||
onError: (e: any) => toast.error(e.message),
|
onError: (e: any) => toast.error(e.message),
|
||||||
});
|
});
|
||||||
@@ -217,10 +350,10 @@ export default function MySecretariesPage() {
|
|||||||
mutationFn: ({ uuid, active }: { uuid: string; active: boolean }) =>
|
mutationFn: ({ uuid, active }: { uuid: string; active: boolean }) =>
|
||||||
api.patch(`/api/v1/secretary/${uuid}`, { active }),
|
api.patch(`/api/v1/secretary/${uuid}`, { active }),
|
||||||
onSuccess: (_, { active }) => {
|
onSuccess: (_, { active }) => {
|
||||||
toast.success(active ? 'منشی فعال شد' : 'منشی غیرفعال شد');
|
toast.success(active ? "منشی فعال شد" : "منشی غیرفعال شد");
|
||||||
setDeleteTarget(null);
|
setDeleteTarget(null);
|
||||||
qc.invalidateQueries({ queryKey: ['my-secretaries'] });
|
qc.invalidateQueries({ queryKey: ["my-secretaries"] });
|
||||||
qc.invalidateQueries({ queryKey: ['my-secretaries-clinic'] });
|
qc.invalidateQueries({ queryKey: ["my-secretaries-clinic"] });
|
||||||
},
|
},
|
||||||
onError: (e: any) => toast.error(e.message),
|
onError: (e: any) => toast.error(e.message),
|
||||||
});
|
});
|
||||||
@@ -232,84 +365,116 @@ export default function MySecretariesPage() {
|
|||||||
|
|
||||||
const handleCreateOpen = () => {
|
const handleCreateOpen = () => {
|
||||||
if (isClinic && !selectedDoctorUuid) {
|
if (isClinic && !selectedDoctorUuid) {
|
||||||
toast.error('ابتدا یک پزشک را انتخاب کنید');
|
toast.error("ابتدا یک پزشک را انتخاب کنید");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setCreateOpen(true);
|
setCreateOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const selectedDoctorName = clinicDoctors.find(d => d.uuid === selectedDoctorUuid)?.name ?? '';
|
const selectedDoctorName =
|
||||||
|
clinicDoctors.find((d) => d.uuid === selectedDoctorUuid)?.name ?? "";
|
||||||
|
|
||||||
// for clinic: show doctor column in the table
|
// for clinic: show doctor column in the table
|
||||||
const clinicColumns: Column<Secretary>[] = isClinic ? [
|
const clinicColumns: Column<Secretary>[] = isClinic
|
||||||
|
? [
|
||||||
{
|
{
|
||||||
key: 'doctor_name',
|
key: "doctor_name",
|
||||||
header: 'پزشک',
|
header: "پزشک",
|
||||||
render: (s) => (
|
render: (s) => (
|
||||||
<span style={{ fontSize: 13, color: 'var(--text-2)', fontWeight: 500 }}>{s.doctor_name}</span>
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: 13,
|
||||||
|
color: "var(--text-2)",
|
||||||
|
fontWeight: 500,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{s.doctor_name}
|
||||||
|
</span>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
] : [];
|
]
|
||||||
|
: [];
|
||||||
|
|
||||||
const allColumns: Column<Secretary>[] = [
|
const allColumns: Column<Secretary>[] = [
|
||||||
{
|
{
|
||||||
key: 'user_name',
|
key: "user_name",
|
||||||
header: 'منشی',
|
header: "منشی",
|
||||||
render: (s) => (
|
render: (s) => (
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
|
||||||
<div
|
<div
|
||||||
className="avatar sm"
|
className="avatar sm"
|
||||||
style={{
|
style={{
|
||||||
background: 'linear-gradient(145deg, oklch(0.62 0.15 295), oklch(0.48 0.16 295))',
|
background:
|
||||||
flexShrink: 0, fontWeight: 700,
|
"linear-gradient(145deg, oklch(0.62 0.15 295), oklch(0.48 0.16 295))",
|
||||||
|
flexShrink: 0,
|
||||||
|
fontWeight: 700,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{(s.user_name ?? '?').charAt(0)}
|
{(s.user_name ?? "?").charAt(0)}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontWeight: 600, fontSize: 14 }}>
|
||||||
|
{s.user_name}
|
||||||
</div>
|
</div>
|
||||||
<div style={{ fontWeight: 600, fontSize: 14 }}>{s.user_name}</div>
|
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
...clinicColumns,
|
...clinicColumns,
|
||||||
{
|
{
|
||||||
key: 'mobile_number',
|
key: "mobile_number",
|
||||||
header: 'موبایل',
|
header: "موبایل",
|
||||||
render: (s) => (
|
render: (s) => (
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 5, fontSize: 13, color: 'var(--text-2)' }}>
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 5,
|
||||||
|
fontSize: 13,
|
||||||
|
color: "var(--text-2)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
<PhoneIcon style={{ width: 13 }} />
|
<PhoneIcon style={{ width: 13 }} />
|
||||||
<span dir="ltr">{maskMobile(s.mobile_number)}</span>
|
<span dir="ltr">{maskMobile(s.mobile_number)}</span>
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'is_active',
|
key: "is_active",
|
||||||
header: 'وضعیت',
|
header: "وضعیت",
|
||||||
render: (s) => <ActiveBadge active={s.is_active} />,
|
render: (s) => <ActiveBadge active={s.is_active} />,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'created_at',
|
key: "created_at",
|
||||||
header: 'تاریخ ثبت',
|
header: "تاریخ ثبت",
|
||||||
render: (s) => (
|
render: (s) => (
|
||||||
<span style={{ fontSize: 13, color: 'var(--text-3)' }}>{formatDate(Number(s.created_at))}</span>
|
<span style={{ fontSize: 13, color: "var(--text-3)" }}>
|
||||||
|
{formatDate(Number(s.created_at))}
|
||||||
|
</span>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'uuid',
|
key: "uuid",
|
||||||
header: 'عملیات',
|
header: "عملیات",
|
||||||
render: (s) => (
|
render: (s) => (
|
||||||
<div style={{ display: 'flex', gap: 6 }}>
|
<div style={{ display: "flex", gap: 6 }}>
|
||||||
<button className="btn sm" onClick={() => openEdit(s)} title="ویرایش دسترسیها">
|
<button
|
||||||
|
className="btn sm"
|
||||||
|
onClick={() => openEdit(s)}
|
||||||
|
title="ویرایش دسترسیها"
|
||||||
|
>
|
||||||
<PencilIcon style={{ width: 14 }} />
|
<PencilIcon style={{ width: 14 }} />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className="btn sm"
|
className="btn sm"
|
||||||
onClick={() => setDeleteTarget(s)}
|
onClick={() => setDeleteTarget(s)}
|
||||||
title={s.is_active ? 'غیرفعالسازی' : 'فعالسازی'}
|
title={s.is_active ? "غیرفعالسازی" : "فعالسازی"}
|
||||||
>
|
>
|
||||||
{s.is_active
|
{s.is_active ? (
|
||||||
? <NoSymbolIcon style={{ width: 14 }} />
|
<NoSymbolIcon style={{ width: 14 }} />
|
||||||
: <CheckCircleIcon style={{ width: 14, color: 'var(--success)' }} />
|
) : (
|
||||||
}
|
<CheckCircleIcon
|
||||||
|
style={{ width: 14, color: "var(--success)" }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
@@ -319,15 +484,24 @@ export default function MySecretariesPage() {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="منشیان من"
|
title="منشی ها من"
|
||||||
description="مدیریت منشیان و دسترسیهای آنها"
|
description="مدیریت منشی ها و دسترسیهای آنها"
|
||||||
action={
|
action={
|
||||||
activeSecretaryCount >= maxSecretaries ? (
|
activeSecretaryCount >= maxSecretaries ? (
|
||||||
<Link to="/admin/subscription" className="btn sm" style={{ textDecoration: 'none', opacity: 0.8 }} title={`حداکثر ${maxSecretaries} منشی مجاز است`}>
|
<Link
|
||||||
|
to="/admin/subscription"
|
||||||
|
className="btn sm"
|
||||||
|
style={{ textDecoration: "none", opacity: 0.8 }}
|
||||||
|
title={`حداکثر ${maxSecretaries} منشی مجاز است`}
|
||||||
|
>
|
||||||
ارتقاء پنل برای افزودن منشی
|
ارتقاء پنل برای افزودن منشی
|
||||||
</Link>
|
</Link>
|
||||||
) : (
|
) : (
|
||||||
<button className="btn primary sm" onClick={handleCreateOpen} disabled={isClinic && !selectedDoctorUuid}>
|
<button
|
||||||
|
className="btn primary sm"
|
||||||
|
onClick={handleCreateOpen}
|
||||||
|
disabled={isClinic && !selectedDoctorUuid}
|
||||||
|
>
|
||||||
<PlusIcon style={{ width: 16 }} />
|
<PlusIcon style={{ width: 16 }} />
|
||||||
افزودن منشی
|
افزودن منشی
|
||||||
</button>
|
</button>
|
||||||
@@ -341,18 +515,29 @@ export default function MySecretariesPage() {
|
|||||||
<div className="field" style={{ marginBottom: 0 }}>
|
<div className="field" style={{ marginBottom: 0 }}>
|
||||||
<label>پزشک مورد نظر برای افزودن منشی جدید</label>
|
<label>پزشک مورد نظر برای افزودن منشی جدید</label>
|
||||||
{clinicDoctorsLoading ? (
|
{clinicDoctorsLoading ? (
|
||||||
<p style={{ fontSize: 13, color: 'var(--text-3)' }}>در حال بارگذاری...</p>
|
<p style={{ fontSize: 13, color: "var(--text-3)" }}>
|
||||||
|
در حال بارگذاری...
|
||||||
|
</p>
|
||||||
) : clinicDoctors.length === 0 ? (
|
) : clinicDoctors.length === 0 ? (
|
||||||
<p style={{ fontSize: 13, color: 'var(--text-3)' }}>هیچ پزشکی در این کلینیک تعریف نشده است. ابتدا پزشک اضافه کنید.</p>
|
<p style={{ fontSize: 13, color: "var(--text-3)" }}>
|
||||||
|
هیچ پزشکی در این کلینیک تعریف نشده است. ابتدا
|
||||||
|
پزشک اضافه کنید.
|
||||||
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<select
|
<select
|
||||||
value={selectedDoctorUuid}
|
value={selectedDoctorUuid}
|
||||||
onChange={(e) => setSelectedDoctorUuid(e.target.value)}
|
onChange={(e) =>
|
||||||
style={{ width: '100%', maxWidth: 360 }}
|
setSelectedDoctorUuid(e.target.value)
|
||||||
|
}
|
||||||
|
style={{ width: "100%", maxWidth: 360 }}
|
||||||
>
|
>
|
||||||
<option value="">— یک پزشک را انتخاب کنید —</option>
|
<option value="">
|
||||||
|
— یک پزشک را انتخاب کنید —
|
||||||
|
</option>
|
||||||
{clinicDoctors.map((d) => (
|
{clinicDoctors.map((d) => (
|
||||||
<option key={d.uuid} value={d.uuid}>{d.name}</option>
|
<option key={d.uuid} value={d.uuid}>
|
||||||
|
{d.name}
|
||||||
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
)}
|
)}
|
||||||
@@ -361,28 +546,68 @@ export default function MySecretariesPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{!isClinic && !doctorUuid ? (
|
{!isClinic && !doctorUuid ? (
|
||||||
<div className="card card-pad" style={{ textAlign: 'center', color: 'var(--text-3)' }}>
|
<div
|
||||||
|
className="card card-pad"
|
||||||
|
style={{ textAlign: "center", color: "var(--text-3)" }}
|
||||||
|
>
|
||||||
پروفایل پزشک یافت نشد
|
پروفایل پزشک یافت نشد
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="card">
|
<div className="card">
|
||||||
{secretaries.length === 0 && !isLoading ? (
|
{secretaries.length === 0 && !isLoading ? (
|
||||||
<div style={{ textAlign: 'center', padding: '60px 24px' }}>
|
<div
|
||||||
<IdentificationIcon style={{ width: 48, color: 'var(--text-3)', margin: '0 auto 16px', display: 'block', opacity: 0.4 }} />
|
style={{
|
||||||
<div style={{ fontWeight: 600, fontSize: 15, marginBottom: 8, color: 'var(--text-2)' }}>هنوز منشیای اضافه نشده</div>
|
textAlign: "center",
|
||||||
<div style={{ color: 'var(--text-3)', fontSize: 13.5, marginBottom: 20 }}>
|
padding: "60px 24px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<IdentificationIcon
|
||||||
|
style={{
|
||||||
|
width: 48,
|
||||||
|
color: "var(--text-3)",
|
||||||
|
margin: "0 auto 16px",
|
||||||
|
display: "block",
|
||||||
|
opacity: 0.4,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontWeight: 600,
|
||||||
|
fontSize: 15,
|
||||||
|
marginBottom: 8,
|
||||||
|
color: "var(--text-2)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
هنوز منشیای اضافه نشده
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
color: "var(--text-3)",
|
||||||
|
fontSize: 13.5,
|
||||||
|
marginBottom: 20,
|
||||||
|
}}
|
||||||
|
>
|
||||||
{isClinic
|
{isClinic
|
||||||
? 'برای افزودن منشی، ابتدا یک پزشک را از لیست بالا انتخاب کنید'
|
? "برای افزودن منشی، ابتدا یک پزشک را از لیست بالا انتخاب کنید"
|
||||||
: 'منشی میتواند نوبتها و اطلاعات کلینیک را مدیریت کند'}
|
: "منشی میتواند نوبتها و اطلاعات کلینیک را مدیریت کند"}
|
||||||
</div>
|
</div>
|
||||||
{!isClinic && (
|
{!isClinic && (
|
||||||
<button className="btn primary sm" onClick={handleCreateOpen}>
|
<button
|
||||||
<PlusIcon style={{ width: 16 }} /> افزودن اولین منشی
|
className="btn primary sm"
|
||||||
|
onClick={handleCreateOpen}
|
||||||
|
>
|
||||||
|
<PlusIcon style={{ width: 16 }} /> افزودن
|
||||||
|
اولین منشی
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<DataTable columns={allColumns} data={secretaries} loading={isLoading} emptyMessage="منشیای ثبت نشده است" />
|
<DataTable
|
||||||
|
columns={allColumns}
|
||||||
|
data={secretaries}
|
||||||
|
loading={isLoading}
|
||||||
|
emptyMessage="منشیای ثبت نشده است"
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -390,12 +615,22 @@ export default function MySecretariesPage() {
|
|||||||
{/* Modal افزودن منشی */}
|
{/* Modal افزودن منشی */}
|
||||||
<Modal
|
<Modal
|
||||||
open={createOpen}
|
open={createOpen}
|
||||||
onClose={() => { setCreateOpen(false); createForm.reset(); }}
|
onClose={() => {
|
||||||
|
setCreateOpen(false);
|
||||||
|
createForm.reset();
|
||||||
|
}}
|
||||||
title="افزودن منشی جدید"
|
title="افزودن منشی جدید"
|
||||||
size="sm"
|
size="sm"
|
||||||
footer={
|
footer={
|
||||||
<>
|
<>
|
||||||
<button type="button" className="btn ghost sm" onClick={() => { setCreateOpen(false); createForm.reset(); }}>
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn ghost sm"
|
||||||
|
onClick={() => {
|
||||||
|
setCreateOpen(false);
|
||||||
|
createForm.reset();
|
||||||
|
}}
|
||||||
|
>
|
||||||
انصراف
|
انصراف
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
@@ -404,57 +639,104 @@ export default function MySecretariesPage() {
|
|||||||
className="btn primary sm"
|
className="btn primary sm"
|
||||||
disabled={createMutation.isPending}
|
disabled={createMutation.isPending}
|
||||||
>
|
>
|
||||||
{createMutation.isPending ? 'در حال افزودن...' : 'افزودن منشی'}
|
{createMutation.isPending
|
||||||
|
? "در حال افزودن..."
|
||||||
|
: "افزودن منشی"}
|
||||||
</button>
|
</button>
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{isClinic && selectedDoctorName && (
|
{isClinic && selectedDoctorName && (
|
||||||
<div style={{
|
<div
|
||||||
display: 'flex', alignItems: 'center', gap: 8,
|
style={{
|
||||||
padding: '9px 12px', marginBottom: 20,
|
display: "flex",
|
||||||
background: 'var(--primary-subtle)', borderRadius: 8,
|
alignItems: "center",
|
||||||
fontSize: 13, color: 'var(--primary)',
|
gap: 8,
|
||||||
}}>
|
padding: "9px 12px",
|
||||||
<IdentificationIcon style={{ width: 16, flexShrink: 0 }} />
|
marginBottom: 20,
|
||||||
منشی برای <strong>{selectedDoctorName}</strong> ثبت میشود
|
background: "var(--primary-subtle)",
|
||||||
|
borderRadius: 8,
|
||||||
|
fontSize: 13,
|
||||||
|
color: "var(--primary)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<IdentificationIcon
|
||||||
|
style={{ width: 16, flexShrink: 0 }}
|
||||||
|
/>
|
||||||
|
منشی برای <strong>{selectedDoctorName}</strong> ثبت
|
||||||
|
میشود
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<form id="create-secretary-form" onSubmit={createForm.handleSubmit((d) => createMutation.mutate(d))}>
|
<form
|
||||||
|
id="create-secretary-form"
|
||||||
|
onSubmit={createForm.handleSubmit((d) =>
|
||||||
|
createMutation.mutate(d),
|
||||||
|
)}
|
||||||
|
>
|
||||||
<div className="field">
|
<div className="field">
|
||||||
<label>نام و نام خانوادگی *</label>
|
<label>نام و نام خانوادگی *</label>
|
||||||
<input
|
<input
|
||||||
{...createForm.register('name')}
|
{...createForm.register("name")}
|
||||||
placeholder="مثال: سارا احمدی"
|
placeholder="مثال: سارا احمدی"
|
||||||
autoComplete="off"
|
autoComplete="off"
|
||||||
autoFocus
|
autoFocus
|
||||||
style={createForm.formState.errors.name ? { borderColor: 'var(--danger)' } : undefined}
|
style={
|
||||||
|
createForm.formState.errors.name
|
||||||
|
? { borderColor: "var(--danger)" }
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
{createForm.formState.errors.name && (
|
{createForm.formState.errors.name && (
|
||||||
<span className="field-error">{createForm.formState.errors.name.message}</span>
|
<span className="field-error">
|
||||||
|
{createForm.formState.errors.name.message}
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="field" style={{ marginTop: 16 }}>
|
<div className="field" style={{ marginTop: 16 }}>
|
||||||
<label>شماره موبایل *</label>
|
<label>شماره موبایل *</label>
|
||||||
<input
|
<input
|
||||||
{...createForm.register('mobile_number')}
|
{...createForm.register("mobile_number")}
|
||||||
placeholder="09123456789"
|
placeholder="09123456789"
|
||||||
dir="ltr"
|
dir="ltr"
|
||||||
inputMode="numeric"
|
inputMode="numeric"
|
||||||
maxLength={11}
|
maxLength={11}
|
||||||
autoComplete="off"
|
autoComplete="off"
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const digits = e.target.value.replace(/\D/g, '');
|
const digits = e.target.value.replace(
|
||||||
createForm.setValue('mobile_number', digits, { shouldValidate: createForm.formState.isSubmitted });
|
/\D/g,
|
||||||
|
"",
|
||||||
|
);
|
||||||
|
createForm.setValue("mobile_number", digits, {
|
||||||
|
shouldValidate:
|
||||||
|
createForm.formState.isSubmitted,
|
||||||
|
});
|
||||||
}}
|
}}
|
||||||
style={createForm.formState.errors.mobile_number ? { borderColor: 'var(--danger)' } : undefined}
|
style={
|
||||||
|
createForm.formState.errors.mobile_number
|
||||||
|
? { borderColor: "var(--danger)" }
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
{createForm.formState.errors.mobile_number && (
|
{createForm.formState.errors.mobile_number && (
|
||||||
<span className="field-error">{createForm.formState.errors.mobile_number.message}</span>
|
<span className="field-error">
|
||||||
|
{
|
||||||
|
createForm.formState.errors.mobile_number
|
||||||
|
.message
|
||||||
|
}
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<p style={{ fontSize: 12.5, color: 'var(--text-3)', margin: '4px 0 0', lineHeight: 1.7 }}>
|
<p
|
||||||
شماره باید با ۰۹ شروع شده و ۱۱ رقم باشد. اگر کاربری با این شماره در سیستم وجود داشته باشد، به عنوان منشی اضافه میشود.
|
style={{
|
||||||
|
fontSize: 12.5,
|
||||||
|
color: "var(--text-3)",
|
||||||
|
margin: "4px 0 0",
|
||||||
|
lineHeight: 1.7,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
شماره باید با ۰۹ شروع شده و ۱۱ رقم باشد. اگر کاربری با
|
||||||
|
این شماره در سیستم وجود داشته باشد، به عنوان منشی اضافه
|
||||||
|
میشود.
|
||||||
</p>
|
</p>
|
||||||
</form>
|
</form>
|
||||||
</Modal>
|
</Modal>
|
||||||
@@ -463,36 +745,65 @@ export default function MySecretariesPage() {
|
|||||||
<Modal
|
<Modal
|
||||||
open={!!editTarget}
|
open={!!editTarget}
|
||||||
onClose={() => setEditTarget(null)}
|
onClose={() => setEditTarget(null)}
|
||||||
title={`دسترسیهای ${editTarget?.user_name ?? ''}`}
|
title={`دسترسیهای ${editTarget?.user_name ?? ""}`}
|
||||||
size="lg"
|
size="lg"
|
||||||
footer={
|
footer={
|
||||||
<>
|
<>
|
||||||
<button className="btn ghost sm" onClick={() => setEditTarget(null)}>لغو</button>
|
<button
|
||||||
|
className="btn ghost sm"
|
||||||
|
onClick={() => setEditTarget(null)}
|
||||||
|
>
|
||||||
|
لغو
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
className="btn primary sm"
|
className="btn primary sm"
|
||||||
disabled={updatePermsMutation.isPending}
|
disabled={updatePermsMutation.isPending}
|
||||||
onClick={() => editTarget && updatePermsMutation.mutate({ uuid: editTarget.uuid, permissions: editPerms })}
|
onClick={() =>
|
||||||
|
editTarget &&
|
||||||
|
updatePermsMutation.mutate({
|
||||||
|
uuid: editTarget.uuid,
|
||||||
|
permissions: editPerms,
|
||||||
|
})
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{updatePermsMutation.isPending ? 'در حال ذخیره...' : 'ذخیره دسترسیها'}
|
{updatePermsMutation.isPending
|
||||||
|
? "در حال ذخیره..."
|
||||||
|
: "ذخیره دسترسیها"}
|
||||||
</button>
|
</button>
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<PermissionsMatrix permissions={editPerms} onChange={setEditPerms} />
|
<PermissionsMatrix
|
||||||
|
permissions={editPerms}
|
||||||
|
onChange={setEditPerms}
|
||||||
|
/>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
{/* Confirm تغییر وضعیت منشی */}
|
{/* Confirm تغییر وضعیت منشی */}
|
||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
open={!!deleteTarget}
|
open={!!deleteTarget}
|
||||||
title={deleteTarget?.is_active ? 'غیرفعالسازی منشی' : 'فعالسازی منشی'}
|
title={
|
||||||
message={deleteTarget?.is_active
|
deleteTarget?.is_active
|
||||||
|
? "غیرفعالسازی منشی"
|
||||||
|
: "فعالسازی منشی"
|
||||||
|
}
|
||||||
|
message={
|
||||||
|
deleteTarget?.is_active
|
||||||
? `آیا مطمئن هستید که میخواهید منشی «${deleteTarget?.user_name}» را غیرفعال کنید؟`
|
? `آیا مطمئن هستید که میخواهید منشی «${deleteTarget?.user_name}» را غیرفعال کنید؟`
|
||||||
: `آیا مطمئن هستید که میخواهید منشی «${deleteTarget?.user_name}» را دوباره فعال کنید؟`
|
: `آیا مطمئن هستید که میخواهید منشی «${deleteTarget?.user_name}» را دوباره فعال کنید؟`
|
||||||
}
|
}
|
||||||
confirmLabel={deleteTarget?.is_active ? 'غیرفعالسازی' : 'فعالسازی'}
|
confirmLabel={
|
||||||
|
deleteTarget?.is_active ? "غیرفعالسازی" : "فعالسازی"
|
||||||
|
}
|
||||||
danger={deleteTarget?.is_active}
|
danger={deleteTarget?.is_active}
|
||||||
loading={toggleActiveMutation.isPending}
|
loading={toggleActiveMutation.isPending}
|
||||||
onConfirm={() => deleteTarget && toggleActiveMutation.mutate({ uuid: deleteTarget.uuid, active: !deleteTarget.is_active })}
|
onConfirm={() =>
|
||||||
|
deleteTarget &&
|
||||||
|
toggleActiveMutation.mutate({
|
||||||
|
uuid: deleteTarget.uuid,
|
||||||
|
active: !deleteTarget.is_active,
|
||||||
|
})
|
||||||
|
}
|
||||||
onCancel={() => setDeleteTarget(null)}
|
onCancel={() => setDeleteTarget(null)}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
|
|||||||
+53
-18
@@ -1,4 +1,5 @@
|
|||||||
# PRD — ClinicPro Phase 2
|
# PRD — ClinicPro Phase 2
|
||||||
|
|
||||||
**تاریخ:** ۲۴ خرداد ۱۴۰۵
|
**تاریخ:** ۲۴ خرداد ۱۴۰۵
|
||||||
**نسخه:** 1.2
|
**نسخه:** 1.2
|
||||||
**وضعیت:** Draft
|
**وضعیت:** Draft
|
||||||
@@ -14,7 +15,7 @@
|
|||||||
## وضعیت فعلی پروژه
|
## وضعیت فعلی پروژه
|
||||||
|
|
||||||
| ماژول | Backend | API Doc |
|
| ماژول | Backend | API Doc |
|
||||||
|--------|---------|---------|
|
| -------------------------------------- | ------------------------------------------------ | ---------------------------------------------------- |
|
||||||
| Auth (OTP + JWT + switch-context) | ✅ کامل | `docs/api/auth.md` |
|
| Auth (OTP + JWT + switch-context) | ✅ کامل | `docs/api/auth.md` |
|
||||||
| Doctor profile | ✅ کامل | `docs/api/doctor.md` |
|
| Doctor profile | ✅ کامل | `docs/api/doctor.md` |
|
||||||
| Clinic management | ✅ کامل | `docs/api/clinic.md` |
|
| Clinic management | ✅ کامل | `docs/api/clinic.md` |
|
||||||
@@ -41,12 +42,13 @@
|
|||||||
### اپیک ۱ — پرسنل (Staff)
|
### اپیک ۱ — پرسنل (Staff)
|
||||||
|
|
||||||
#### توضیح
|
#### توضیح
|
||||||
|
|
||||||
مدیر کلینیک یا دکتر مستقل میتواند پرسنل کلینیک را ثبت کند. پرسنل حساب کاربری ندارند و نیازی به لاگین ندارند. در سرویسدهی، سشنها و گزارشات به آنها ارجاع میشود.
|
مدیر کلینیک یا دکتر مستقل میتواند پرسنل کلینیک را ثبت کند. پرسنل حساب کاربری ندارند و نیازی به لاگین ندارند. در سرویسدهی، سشنها و گزارشات به آنها ارجاع میشود.
|
||||||
|
|
||||||
#### نیازمندیهای کارکردی
|
#### نیازمندیهای کارکردی
|
||||||
|
|
||||||
| کد | نیازمندی |
|
| کد | نیازمندی |
|
||||||
|----|---------|
|
| ---- | ------------------------------------------------------------------------------------ |
|
||||||
| F1.1 | ایجاد / ویرایش / غیرفعالسازی پرسنل — **حذف سخت ممنوع** (تاریخچه حفظ شود) |
|
| F1.1 | ایجاد / ویرایش / غیرفعالسازی پرسنل — **حذف سخت ممنوع** (تاریخچه حفظ شود) |
|
||||||
| F1.2 | فیلدها: نام و نامخانوادگی (اجباری)، شماره تماس، عنوان شغل، آدرس، کد ملی، وضعیت فعال |
|
| F1.2 | فیلدها: نام و نامخانوادگی (اجباری)، شماره تماس، عنوان شغل، آدرس، کد ملی، وضعیت فعال |
|
||||||
| F1.3 | پرسنل به `entity_type + entity_id` وابسته است (کلینیک یا مطب) |
|
| F1.3 | پرسنل به `entity_type + entity_id` وابسته است (کلینیک یا مطب) |
|
||||||
@@ -54,6 +56,7 @@
|
|||||||
| F1.5 | در ایجاد ServiceItem و SessionService از لیست پرسنل انتخاب میشود |
|
| F1.5 | در ایجاد ServiceItem و SessionService از لیست پرسنل انتخاب میشود |
|
||||||
|
|
||||||
#### Entity جدید
|
#### Entity جدید
|
||||||
|
|
||||||
```
|
```
|
||||||
clinic_staff:
|
clinic_staff:
|
||||||
id, uuid
|
id, uuid
|
||||||
@@ -69,18 +72,21 @@ clinic_staff:
|
|||||||
```
|
```
|
||||||
|
|
||||||
#### API
|
#### API
|
||||||
|
|
||||||
| Method | Path | توضیح |
|
| Method | Path | توضیح |
|
||||||
|--------|------|-------|
|
| ------- | ----------------------------- | -------------------------- |
|
||||||
| `GET` | `/api/v1/staff` | لیست پرسنل کاربر جاری |
|
| `GET` | `/api/v1/staff` | لیست پرسنل کاربر جاری |
|
||||||
| `POST` | `/api/v1/staff` | ایجاد پرسنل جدید |
|
| `POST` | `/api/v1/staff` | ایجاد پرسنل جدید |
|
||||||
| `PATCH` | `/api/v1/staff/{uuid}` | ویرایش اطلاعات |
|
| `PATCH` | `/api/v1/staff/{uuid}` | ویرایش اطلاعات |
|
||||||
| `PATCH` | `/api/v1/staff/{uuid}/toggle` | فعال/غیرفعال (soft delete) |
|
| `PATCH` | `/api/v1/staff/{uuid}/toggle` | فعال/غیرفعال (soft delete) |
|
||||||
|
|
||||||
#### Frontend
|
#### Frontend
|
||||||
|
|
||||||
- `StaffPage.tsx` — DataTable + Modal ایجاد/ویرایش
|
- `StaffPage.tsx` — DataTable + Modal ایجاد/ویرایش
|
||||||
- Sidebar: زیر «تنظیمات»
|
- Sidebar: زیر «تنظیمات»
|
||||||
|
|
||||||
#### فایلهای جدید
|
#### فایلهای جدید
|
||||||
|
|
||||||
- `src/Staff/Entity/ClinicStaff.php`
|
- `src/Staff/Entity/ClinicStaff.php`
|
||||||
- `src/Staff/Controller/StaffController.php`
|
- `src/Staff/Controller/StaffController.php`
|
||||||
- `src/Staff/Repository/ClinicStaffRepository.php`
|
- `src/Staff/Repository/ClinicStaffRepository.php`
|
||||||
@@ -91,12 +97,13 @@ clinic_staff:
|
|||||||
### اپیک ۲ — پنل اشتراکی (Subscription Tiers)
|
### اپیک ۲ — پنل اشتراکی (Subscription Tiers)
|
||||||
|
|
||||||
#### توضیح
|
#### توضیح
|
||||||
|
|
||||||
سه سطح پنل با قابلیتهای متفاوت. ادمین برای هر پنل دورههای زمانی با قیمت مشخص تعریف میکند. کلینیک/دکتر دوره + پنل انتخاب کرده و پرداخت میکند.
|
سه سطح پنل با قابلیتهای متفاوت. ادمین برای هر پنل دورههای زمانی با قیمت مشخص تعریف میکند. کلینیک/دکتر دوره + پنل انتخاب کرده و پرداخت میکند.
|
||||||
|
|
||||||
#### سطوح پنل
|
#### سطوح پنل
|
||||||
|
|
||||||
| Level | Name | منشی | قابلیتهای اضافه |
|
| Level | Name | منشی | قابلیتهای اضافه |
|
||||||
|-------|------|------|----------------|
|
| ----- | ------------ | ------ | ----------------------------- |
|
||||||
| 0 | Free | ۱ منشی | نوبتدهی — رایگان، بدون انقضا |
|
| 0 | Free | ۱ منشی | نوبتدهی — رایگان، بدون انقضا |
|
||||||
| 1 | Basic | ۲ منشی | پرونده بیمار + سرویسها |
|
| 1 | Basic | ۲ منشی | پرونده بیمار + سرویسها |
|
||||||
| 2 | Professional | ۵ منشی | پرونده بیمار + سرویسها |
|
| 2 | Professional | ۵ منشی | پرونده بیمار + سرویسها |
|
||||||
@@ -104,7 +111,7 @@ clinic_staff:
|
|||||||
#### دورههای اشتراک (مثال — ادمین تنظیم میکند)
|
#### دورههای اشتراک (مثال — ادمین تنظیم میکند)
|
||||||
|
|
||||||
| نوع | مدت | قیمت |
|
| نوع | مدت | قیمت |
|
||||||
|-----|-----|------|
|
| ------- | ------ | ------------------------------- |
|
||||||
| تریال | ۱ ماه | ۰ ریال — فقط برای Basic، یکبار |
|
| تریال | ۱ ماه | ۰ ریال — فقط برای Basic، یکبار |
|
||||||
| ۱ ماهه | ۱ ماه | ادمین تعیین میکند |
|
| ۱ ماهه | ۱ ماه | ادمین تعیین میکند |
|
||||||
| ۳ ماهه | ۳ ماه | ادمین تعیین میکند |
|
| ۳ ماهه | ۳ ماه | ادمین تعیین میکند |
|
||||||
@@ -122,7 +129,7 @@ clinic_staff:
|
|||||||
#### نیازمندیهای کارکردی
|
#### نیازمندیهای کارکردی
|
||||||
|
|
||||||
| کد | نیازمندی |
|
| کد | نیازمندی |
|
||||||
|----|---------|
|
| ---- | ---------------------------------------------------------------------------- |
|
||||||
| F2.1 | ادمین قیمت هر ترکیب (پنل × دوره) را تعریف میکند |
|
| F2.1 | ادمین قیمت هر ترکیب (پنل × دوره) را تعریف میکند |
|
||||||
| F2.2 | ادمین میتواند دورههای جدید اضافه یا غیرفعال کند |
|
| F2.2 | ادمین میتواند دورههای جدید اضافه یا غیرفعال کند |
|
||||||
| F2.3 | پرداخت از طریق gateway موجود (Mellat/SEP) — `Payment.type='subscription'` ✅ |
|
| F2.3 | پرداخت از طریق gateway موجود (Mellat/SEP) — `Payment.type='subscription'` ✅ |
|
||||||
@@ -133,6 +140,7 @@ clinic_staff:
|
|||||||
| F2.8 | گزارش فروش برای ادمین (تعداد، درآمد، بر اساس سطح و دوره) |
|
| F2.8 | گزارش فروش برای ادمین (تعداد، درآمد، بر اساس سطح و دوره) |
|
||||||
|
|
||||||
#### Entities جدید
|
#### Entities جدید
|
||||||
|
|
||||||
```
|
```
|
||||||
subscription_plans:
|
subscription_plans:
|
||||||
id, uuid
|
id, uuid
|
||||||
@@ -168,8 +176,9 @@ clinic_subscriptions:
|
|||||||
```
|
```
|
||||||
|
|
||||||
#### API
|
#### API
|
||||||
|
|
||||||
| Method | Path | Permission | توضیح |
|
| Method | Path | Permission | توضیح |
|
||||||
|--------|------|-----------|-------|
|
| -------- | ------------------------------------------ | ------------- | ---------------------------------------- |
|
||||||
| `GET` | `/api/v1/subscription/plans` | public | لیست پنلها با دورهها و قیمتها |
|
| `GET` | `/api/v1/subscription/plans` | public | لیست پنلها با دورهها و قیمتها |
|
||||||
| `GET` | `/api/v1/subscription/my` | clinic/doctor | اشتراک فعال + `used_trial` + تاریخ انقضا |
|
| `GET` | `/api/v1/subscription/my` | clinic/doctor | اشتراک فعال + `used_trial` + تاریخ انقضا |
|
||||||
| `POST` | `/api/v1/subscription/trial` | clinic/doctor | فعالسازی تریال (رایگان، یکبار) |
|
| `POST` | `/api/v1/subscription/trial` | clinic/doctor | فعالسازی تریال (رایگان، یکبار) |
|
||||||
@@ -183,6 +192,7 @@ clinic_subscriptions:
|
|||||||
| `GET` | `/api/v1/admin/subscription/report` | admin | گزارش فروش + تریالها |
|
| `GET` | `/api/v1/admin/subscription/report` | admin | گزارش فروش + تریالها |
|
||||||
|
|
||||||
#### Frontend
|
#### Frontend
|
||||||
|
|
||||||
- `SubscriptionPage.tsx`:
|
- `SubscriptionPage.tsx`:
|
||||||
- بنر تریال اگر `used_trial=false`
|
- بنر تریال اگر `used_trial=false`
|
||||||
- وضعیت پنل فعال + تاریخ انقضا (شمسی) + badge «تریال»
|
- وضعیت پنل فعال + تاریخ انقضا (شمسی) + badge «تریال»
|
||||||
@@ -193,6 +203,7 @@ clinic_subscriptions:
|
|||||||
- تب «گزارش»: فروش بر اساس بازه زمانی
|
- تب «گزارش»: فروش بر اساس بازه زمانی
|
||||||
|
|
||||||
#### فایلهای جدید
|
#### فایلهای جدید
|
||||||
|
|
||||||
- `src/Subscription/Entity/SubscriptionPlan.php`
|
- `src/Subscription/Entity/SubscriptionPlan.php`
|
||||||
- `src/Subscription/Entity/SubscriptionPeriod.php`
|
- `src/Subscription/Entity/SubscriptionPeriod.php`
|
||||||
- `src/Subscription/Entity/ClinicSubscription.php`
|
- `src/Subscription/Entity/ClinicSubscription.php`
|
||||||
@@ -205,24 +216,28 @@ clinic_subscriptions:
|
|||||||
### اپیک ۳ — منشی (Secretary) — تکمیل
|
### اپیک ۳ — منشی (Secretary) — تکمیل
|
||||||
|
|
||||||
#### توضیح
|
#### توضیح
|
||||||
|
|
||||||
Entity `DoctorSecretary` و API کامل موجود است (`docs/api/secretary.md`). باید:
|
Entity `DoctorSecretary` و API کامل موجود است (`docs/api/secretary.md`). باید:
|
||||||
|
|
||||||
- محدودیت تعداد بر اساس پنل اعمال شود
|
- محدودیت تعداد بر اساس پنل اعمال شود
|
||||||
- UI مدیریت permissions تکمیل شود
|
- UI مدیریت permissions تکمیل شود
|
||||||
|
|
||||||
#### نیازمندیهای کارکردی
|
#### نیازمندیهای کارکردی
|
||||||
|
|
||||||
| کد | نیازمندی |
|
| کد | نیازمندی |
|
||||||
|----|---------|
|
| ---- | -------------------------------------------------------------------------------------- |
|
||||||
| F3.1 | بررسی سقف پنل قبل از ایجاد: Free→1، Basic→2، Pro→5 |
|
| F3.1 | بررسی سقف پنل قبل از ایجاد: Free→1، Basic→2، Pro→5 |
|
||||||
| F3.2 | UI لیست منشیان + اضافهکردن + ویرایش + غیرفعالسازی |
|
| F3.2 | UI لیست منشی ها + اضافهکردن + ویرایش + غیرفعالسازی |
|
||||||
| F3.3 | ویرایشگر permissions: checkbox matrix (موجود در `DoctorSecretary.DEFAULT_PERMISSIONS`) |
|
| F3.3 | ویرایشگر permissions: checkbox matrix (موجود در `DoctorSecretary.DEFAULT_PERMISSIONS`) |
|
||||||
| F3.4 | منشی هرگز حذف نمیشود — `active: false` |
|
| F3.4 | منشی هرگز حذف نمیشود — `active: false` |
|
||||||
|
|
||||||
#### تغییرات مورد نیاز
|
#### تغییرات مورد نیاز
|
||||||
|
|
||||||
- **Backend:** `SecretaryController::create()` — بررسی سقف پنل با `SubscriptionService`
|
- **Backend:** `SecretaryController::create()` — بررسی سقف پنل با `SubscriptionService`
|
||||||
- **Frontend:** Modal با checkbox matrix برای permissions در `SecretariesPage.tsx`
|
- **Frontend:** Modal با checkbox matrix برای permissions در `SecretariesPage.tsx`
|
||||||
|
|
||||||
#### API موجود (نیاز به تغییر ندارند)
|
#### API موجود (نیاز به تغییر ندارند)
|
||||||
|
|
||||||
`GET /api/v1/secretaries/{doctorUuid}` | `POST /api/v1/secretary` | `PATCH /api/v1/secretary/{uuid}` | `DELETE /api/v1/secretary/{uuid}`
|
`GET /api/v1/secretaries/{doctorUuid}` | `POST /api/v1/secretary` | `PATCH /api/v1/secretary/{uuid}` | `DELETE /api/v1/secretary/{uuid}`
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -230,12 +245,13 @@ Entity `DoctorSecretary` و API کامل موجود است (`docs/api/secretary.
|
|||||||
### اپیک ۴ — سرویسهای کلینیک (Clinic Services)
|
### اپیک ۴ — سرویسهای کلینیک (Clinic Services)
|
||||||
|
|
||||||
#### توضیح
|
#### توضیح
|
||||||
|
|
||||||
دسترسی فقط در پنل Basic+. سرویسها در دو سطح: بخش (مثلاً «تزریقات») و زیربخش (مثلاً «سرم»).
|
دسترسی فقط در پنل Basic+. سرویسها در دو سطح: بخش (مثلاً «تزریقات») و زیربخش (مثلاً «سرم»).
|
||||||
|
|
||||||
#### نیازمندیهای کارکردی
|
#### نیازمندیهای کارکردی
|
||||||
|
|
||||||
| کد | نیازمندی |
|
| کد | نیازمندی |
|
||||||
|----|---------|
|
| ---- | ------------------------------------------------------------------ |
|
||||||
| F4.1 | CRUD بخشها (ServiceSection) |
|
| F4.1 | CRUD بخشها (ServiceSection) |
|
||||||
| F4.2 | CRUD زیربخشها (ServiceItem) با انتخاب بخش + پرسنل انجامدهنده |
|
| F4.2 | CRUD زیربخشها (ServiceItem) با انتخاب بخش + پرسنل انجامدهنده |
|
||||||
| F4.3 | قیمت هر زیربخش قابل ویرایش |
|
| F4.3 | قیمت هر زیربخش قابل ویرایش |
|
||||||
@@ -243,6 +259,7 @@ Entity `DoctorSecretary` و API کامل موجود است (`docs/api/secretary.
|
|||||||
| F4.5 | لیست ServiceItems در ثبت سشن بیمار استفاده میشود (وابستگی اپیک ۶) |
|
| F4.5 | لیست ServiceItems در ثبت سشن بیمار استفاده میشود (وابستگی اپیک ۶) |
|
||||||
|
|
||||||
#### Entities جدید
|
#### Entities جدید
|
||||||
|
|
||||||
```
|
```
|
||||||
service_sections:
|
service_sections:
|
||||||
id, uuid
|
id, uuid
|
||||||
@@ -263,8 +280,9 @@ service_items:
|
|||||||
```
|
```
|
||||||
|
|
||||||
#### API
|
#### API
|
||||||
|
|
||||||
| Method | Path | توضیح |
|
| Method | Path | توضیح |
|
||||||
|--------|------|-------|
|
| -------- | ------------------------------------- | ---------------------- |
|
||||||
| `GET` | `/api/v1/service-sections` | لیست بخشها |
|
| `GET` | `/api/v1/service-sections` | لیست بخشها |
|
||||||
| `POST` | `/api/v1/service-section` | ایجاد بخش (Basic+) |
|
| `POST` | `/api/v1/service-section` | ایجاد بخش (Basic+) |
|
||||||
| `PATCH` | `/api/v1/service-section/{uuid}` | ویرایش |
|
| `PATCH` | `/api/v1/service-section/{uuid}` | ویرایش |
|
||||||
@@ -275,9 +293,11 @@ service_items:
|
|||||||
| `DELETE` | `/api/v1/service-item/{uuid}` | حذف |
|
| `DELETE` | `/api/v1/service-item/{uuid}` | حذف |
|
||||||
|
|
||||||
#### Frontend
|
#### Frontend
|
||||||
|
|
||||||
- `ServicesPage.tsx` — accordion دو سطحی: بخشها ← زیربخشها
|
- `ServicesPage.tsx` — accordion دو سطحی: بخشها ← زیربخشها
|
||||||
|
|
||||||
#### فایلهای جدید
|
#### فایلهای جدید
|
||||||
|
|
||||||
- `src/ClinicService/Entity/ServiceSection.php`
|
- `src/ClinicService/Entity/ServiceSection.php`
|
||||||
- `src/ClinicService/Entity/ServiceItem.php`
|
- `src/ClinicService/Entity/ServiceItem.php`
|
||||||
- `src/ClinicService/Controller/ClinicServiceController.php`
|
- `src/ClinicService/Controller/ClinicServiceController.php`
|
||||||
@@ -288,7 +308,9 @@ service_items:
|
|||||||
### اپیک ۵ — پنل پیامکی (SMS Panel)
|
### اپیک ۵ — پنل پیامکی (SMS Panel)
|
||||||
|
|
||||||
#### توضیح
|
#### توضیح
|
||||||
|
|
||||||
زیرساخت SMS موجود است (`SmsLog`, `SmsTemplate`, `SmsProvider` در `src/Sms/`). باید:
|
زیرساخت SMS موجود است (`SmsLog`, `SmsTemplate`, `SmsProvider` در `src/Sms/`). باید:
|
||||||
|
|
||||||
- کیف پول پیامک اختصاصی برای هر کلینیک/دکتر
|
- کیف پول پیامک اختصاصی برای هر کلینیک/دکتر
|
||||||
- تنظیمات ارسال پیامک (یادآوری، بعد از ویزیت)
|
- تنظیمات ارسال پیامک (یادآوری، بعد از ویزیت)
|
||||||
- گزارش لاگ برای کلینیک و ادمین
|
- گزارش لاگ برای کلینیک و ادمین
|
||||||
@@ -296,7 +318,7 @@ service_items:
|
|||||||
#### نیازمندیهای کارکردی
|
#### نیازمندیهای کارکردی
|
||||||
|
|
||||||
| کد | نیازمندی |
|
| کد | نیازمندی |
|
||||||
|----|---------|
|
| ---- | ---------------------------------------------------------------------- |
|
||||||
| F5.1 | کیف پول پیامک جدا از کیف پول مالی |
|
| F5.1 | کیف پول پیامک جدا از کیف پول مالی |
|
||||||
| F5.2 | شارژ از طریق gateway موجود |
|
| F5.2 | شارژ از طریق gateway موجود |
|
||||||
| F5.3 | ادمین تعرفه هر پیامک را تعیین میکند (`sms_price_rials` در SiteConfig) |
|
| F5.3 | ادمین تعرفه هر پیامک را تعیین میکند (`sms_price_rials` در SiteConfig) |
|
||||||
@@ -307,6 +329,7 @@ service_items:
|
|||||||
| F5.8 | گزارش ادمین: مصرف هر کلینیک + درآمد پیامکی |
|
| F5.8 | گزارش ادمین: مصرف هر کلینیک + درآمد پیامکی |
|
||||||
|
|
||||||
#### Entities جدید
|
#### Entities جدید
|
||||||
|
|
||||||
```
|
```
|
||||||
sms_wallets:
|
sms_wallets:
|
||||||
id
|
id
|
||||||
@@ -327,8 +350,9 @@ sms_settings:
|
|||||||
```
|
```
|
||||||
|
|
||||||
#### API
|
#### API
|
||||||
|
|
||||||
| Method | Path | Permission | توضیح |
|
| Method | Path | Permission | توضیح |
|
||||||
|--------|------|-----------|-------|
|
| ------- | --------------------------------- | ------------- | ---------------------------------------- |
|
||||||
| `GET` | `/api/v1/sms/wallet/balance` | clinic/doctor | موجودی کیف پول پیامک |
|
| `GET` | `/api/v1/sms/wallet/balance` | clinic/doctor | موجودی کیف پول پیامک |
|
||||||
| `POST` | `/api/v1/sms/wallet/charge` | clinic/doctor | شارژ — body: `{ gateway, amount_rials }` |
|
| `POST` | `/api/v1/sms/wallet/charge` | clinic/doctor | شارژ — body: `{ gateway, amount_rials }` |
|
||||||
| `GET` | `/api/v1/sms/wallet/logs` | clinic/doctor | تاریخچه کسر/شارژ |
|
| `GET` | `/api/v1/sms/wallet/logs` | clinic/doctor | تاریخچه کسر/شارژ |
|
||||||
@@ -337,10 +361,12 @@ sms_settings:
|
|||||||
| `GET` | `/api/v1/admin/sms/wallet-report` | admin | گزارش مصرف و درآمد |
|
| `GET` | `/api/v1/admin/sms/wallet-report` | admin | گزارش مصرف و درآمد |
|
||||||
|
|
||||||
#### Frontend
|
#### Frontend
|
||||||
|
|
||||||
- تب «پیامک» در صفحه تنظیمات کلینیک/دکتر
|
- تب «پیامک» در صفحه تنظیمات کلینیک/دکتر
|
||||||
- نمایش موجودی + دکمه شارژ + لاگ + فرم تنظیمات
|
- نمایش موجودی + دکمه شارژ + لاگ + فرم تنظیمات
|
||||||
|
|
||||||
#### تأثیر بر فایلهای موجود
|
#### تأثیر بر فایلهای موجود
|
||||||
|
|
||||||
- `docs/api/sms.md` ← **بخش جدید wallet/settings اضافه شود**
|
- `docs/api/sms.md` ← **بخش جدید wallet/settings اضافه شود**
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -348,12 +374,13 @@ sms_settings:
|
|||||||
### اپیک ۶ — پرونده بیمار (Patient Records)
|
### اپیک ۶ — پرونده بیمار (Patient Records)
|
||||||
|
|
||||||
#### توضیح
|
#### توضیح
|
||||||
|
|
||||||
دسترسی فقط در پنل Basic+. پرونده به صورت خودکار پس از تأیید نوبت ایجاد میشود یا دستی توسط منشی/دکتر. هر مراجعه یک سشن است که شامل ویزیت + سرویسها است.
|
دسترسی فقط در پنل Basic+. پرونده به صورت خودکار پس از تأیید نوبت ایجاد میشود یا دستی توسط منشی/دکتر. هر مراجعه یک سشن است که شامل ویزیت + سرویسها است.
|
||||||
|
|
||||||
#### نیازمندیهای کارکردی
|
#### نیازمندیهای کارکردی
|
||||||
|
|
||||||
| کد | نیازمندی |
|
| کد | نیازمندی |
|
||||||
|----|---------|
|
| ---- | ----------------------------------------------------------------------------- |
|
||||||
| F6.1 | ایجاد خودکار `PatientRecord` بعد از تأیید appointment (اگر وجود نداشت) |
|
| F6.1 | ایجاد خودکار `PatientRecord` بعد از تأیید appointment (اگر وجود نداشت) |
|
||||||
| F6.2 | ایجاد دستی توسط منشی یا دکتر |
|
| F6.2 | ایجاد دستی توسط منشی یا دکتر |
|
||||||
| F6.3 | ثبت سشن: بیمه پایه + مکمل، مبلغ ویزیت (پیشفرض یا دستی)، محاسبه خودکار کسورات |
|
| F6.3 | ثبت سشن: بیمه پایه + مکمل، مبلغ ویزیت (پیشفرض یا دستی)، محاسبه خودکار کسورات |
|
||||||
@@ -362,6 +389,7 @@ sms_settings:
|
|||||||
| F6.6 | تاریخچه کامل مراجعات یک بیمار |
|
| F6.6 | تاریخچه کامل مراجعات یک بیمار |
|
||||||
|
|
||||||
#### محاسبه مبلغ نهایی سشن
|
#### محاسبه مبلغ نهایی سشن
|
||||||
|
|
||||||
```
|
```
|
||||||
visit_price_rials ← ادمین/دکتر تعیین میکند
|
visit_price_rials ← ادمین/دکتر تعیین میکند
|
||||||
- base_insurance_discount% ← بیمه پایه
|
- base_insurance_discount% ← بیمه پایه
|
||||||
@@ -371,6 +399,7 @@ visit_price_rials ← ادمین/دکتر تعیین میکند
|
|||||||
```
|
```
|
||||||
|
|
||||||
#### Entities جدید
|
#### Entities جدید
|
||||||
|
|
||||||
```
|
```
|
||||||
patient_records:
|
patient_records:
|
||||||
id, uuid
|
id, uuid
|
||||||
@@ -405,8 +434,9 @@ session_services:
|
|||||||
```
|
```
|
||||||
|
|
||||||
#### API
|
#### API
|
||||||
|
|
||||||
| Method | Path | Permission | توضیح |
|
| Method | Path | Permission | توضیح |
|
||||||
|--------|------|-----------|-------|
|
| ------- | --------------------------------- | ---------------------- | --------------------- |
|
||||||
| `GET` | `/api/v1/patients` | clinic/doctor (basic+) | لیست بیماران با جستجو |
|
| `GET` | `/api/v1/patients` | clinic/doctor (basic+) | لیست بیماران با جستجو |
|
||||||
| `POST` | `/api/v1/patient` | clinic/doctor (basic+) | ایجاد پرونده دستی |
|
| `POST` | `/api/v1/patient` | clinic/doctor (basic+) | ایجاد پرونده دستی |
|
||||||
| `GET` | `/api/v1/patient/{uuid}` | clinic/doctor | جزئیات پرونده |
|
| `GET` | `/api/v1/patient/{uuid}` | clinic/doctor | جزئیات پرونده |
|
||||||
@@ -415,11 +445,13 @@ session_services:
|
|||||||
| `PATCH` | `/api/v1/session/{uuid}` | clinic/doctor | ویرایش مراجعه |
|
| `PATCH` | `/api/v1/session/{uuid}` | clinic/doctor | ویرایش مراجعه |
|
||||||
|
|
||||||
#### Frontend
|
#### Frontend
|
||||||
|
|
||||||
- `PatientsPage.tsx` — لیست بیماران + جستجو
|
- `PatientsPage.tsx` — لیست بیماران + جستجو
|
||||||
- `PatientDetailPage.tsx` — پرونده + تاریخچه مراجعات
|
- `PatientDetailPage.tsx` — پرونده + تاریخچه مراجعات
|
||||||
- Modal ثبت سشن جدید (بیمه + ویزیت + سرویسها)
|
- Modal ثبت سشن جدید (بیمه + ویزیت + سرویسها)
|
||||||
|
|
||||||
#### فایلهای جدید
|
#### فایلهای جدید
|
||||||
|
|
||||||
- `src/Patient/Entity/PatientRecord.php`
|
- `src/Patient/Entity/PatientRecord.php`
|
||||||
- `src/Patient/Entity/PatientSession.php`
|
- `src/Patient/Entity/PatientSession.php`
|
||||||
- `src/Patient/Entity/SessionService.php`
|
- `src/Patient/Entity/SessionService.php`
|
||||||
@@ -431,22 +463,25 @@ session_services:
|
|||||||
### اپیک ۷ — داشبورد هوشمند (Smart Dashboard)
|
### اپیک ۷ — داشبورد هوشمند (Smart Dashboard)
|
||||||
|
|
||||||
#### توضیح
|
#### توضیح
|
||||||
|
|
||||||
داشبوردهای موجود (clinic/doctor/secretary در `docs/api/dashboard.md`) باید با چارت و فیلتر بازه زمانی تکمیل شوند.
|
داشبوردهای موجود (clinic/doctor/secretary در `docs/api/dashboard.md`) باید با چارت و فیلتر بازه زمانی تکمیل شوند.
|
||||||
|
|
||||||
#### نیازمندیهای کارکردی
|
#### نیازمندیهای کارکردی
|
||||||
|
|
||||||
| کد | نیازمندی |
|
| کد | نیازمندی |
|
||||||
|----|---------|
|
| ---- | ----------------------------------------------------------------------- |
|
||||||
| F7.1 | داشبورد ادمین: چارت درآمد + چارت نوبتها + گزارش فروش پنلها |
|
| F7.1 | داشبورد ادمین: چارت درآمد + چارت نوبتها + گزارش فروش پنلها |
|
||||||
| F7.2 | داشبورد کلینیک/مطب: درآمد ماه جاری + بیماران منحصربهفرد + موجودی پیامک |
|
| F7.2 | داشبورد کلینیک/مطب: درآمد ماه جاری + بیماران منحصربهفرد + موجودی پیامک |
|
||||||
| F7.3 | فیلتر بازه زمانی: این هفته / این ماه / ۳ ماه / سفارشی |
|
| F7.3 | فیلتر بازه زمانی: این هفته / این ماه / ۳ ماه / سفارشی |
|
||||||
| F7.4 | فیلتر نقش: منشی فقط نوبتها را میبیند |
|
| F7.4 | فیلتر نقش: منشی فقط نوبتها را میبیند |
|
||||||
|
|
||||||
#### API موجود (نیاز به endpoint جدید ندارد)
|
#### API موجود (نیاز به endpoint جدید ندارد)
|
||||||
|
|
||||||
`GET /api/v1/admin/dashboard/stats` | `/charts` | `/recent`
|
`GET /api/v1/admin/dashboard/stats` | `/charts` | `/recent`
|
||||||
`GET /api/v1/dashboard/clinic` | `/doctor` | `/secretary`
|
`GET /api/v1/dashboard/clinic` | `/doctor` | `/secretary`
|
||||||
|
|
||||||
#### تغییر مورد نیاز
|
#### تغییر مورد نیاز
|
||||||
|
|
||||||
- backend: اضافه کردن پارامترهای `from` و `to` به endpoint های charts
|
- backend: اضافه کردن پارامترهای `from` و `to` به endpoint های charts
|
||||||
- frontend: `DashboardPage.tsx` — افزودن date range selector + نمودارها (Recharts/Chart.js)
|
- frontend: `DashboardPage.tsx` — افزودن date range selector + نمودارها (Recharts/Chart.js)
|
||||||
|
|
||||||
@@ -455,7 +490,7 @@ session_services:
|
|||||||
## اولویتبندی پیادهسازی
|
## اولویتبندی پیادهسازی
|
||||||
|
|
||||||
| اولویت | اپیک | وابستگی |
|
| اولویت | اپیک | وابستگی |
|
||||||
|--------|------|---------|
|
| ------ | ------------------------ | ----------------------------- |
|
||||||
| ۱ | اپیک ۱ — Staff | پیشنیاز اپیک ۴ و ۶ |
|
| ۱ | اپیک ۱ — Staff | پیشنیاز اپیک ۴ و ۶ |
|
||||||
| ۲ | اپیک ۲ — Subscription | پیشنیاز gate در اپیک ۳، ۴، ۶ |
|
| ۲ | اپیک ۲ — Subscription | پیشنیاز gate در اپیک ۳، ۴، ۶ |
|
||||||
| ۳ | اپیک ۳ — Secretary تکمیل | وابسته به اپیک ۲ |
|
| ۳ | اپیک ۳ — Secretary تکمیل | وابسته به اپیک ۲ |
|
||||||
@@ -489,7 +524,7 @@ sms_wallets sms_settings
|
|||||||
## زیرساخت موجود قابل استفاده
|
## زیرساخت موجود قابل استفاده
|
||||||
|
|
||||||
| زیرساخت | مسیر | استفاده در فاز ۲ |
|
| زیرساخت | مسیر | استفاده در فاز ۲ |
|
||||||
|---------|------|-----------------|
|
| ----------------------------- | ---------------------------------- | ------------------------------------- |
|
||||||
| Payment gateway | `src/Payment/` | پرداخت subscription + شارژ SMS wallet |
|
| Payment gateway | `src/Payment/` | پرداخت subscription + شارژ SMS wallet |
|
||||||
| `Payment.type='subscription'` | `Payment::TYPE_SUBSCRIPTION` | پرداخت پنل اشتراکی — موجود ✅ |
|
| `Payment.type='subscription'` | `Payment::TYPE_SUBSCRIPTION` | پرداخت پنل اشتراکی — موجود ✅ |
|
||||||
| SiteConfig (key-value) | `src/Config/Entity/SiteConfig.php` | `sms_price_rials`, `trial_enabled` |
|
| SiteConfig (key-value) | `src/Config/Entity/SiteConfig.php` | `sms_price_rials`, `trial_enabled` |
|
||||||
|
|||||||
+58
-19
@@ -7,7 +7,7 @@
|
|||||||
هر رابطه منشی-پزشک دارای یک **scope** است که از تداخل بین محیطهای مختلف جلوگیری میکند:
|
هر رابطه منشی-پزشک دارای یک **scope** است که از تداخل بین محیطهای مختلف جلوگیری میکند:
|
||||||
|
|
||||||
| Scope | `owner_type` | تعریفکننده | دسترسی |
|
| Scope | `owner_type` | تعریفکننده | دسترسی |
|
||||||
|-------|-------------|-------------|---------|
|
| -------- | ------------ | ----------- | ------------------------------- |
|
||||||
| مطب شخصی | `doctor` | خود پزشک | فقط نوبتها و دادههای مطب شخصی |
|
| مطب شخصی | `doctor` | خود پزشک | فقط نوبتها و دادههای مطب شخصی |
|
||||||
| کلینیک | `clinic` | مدیر کلینیک | فقط نوبتها و دادههای کلینیک |
|
| کلینیک | `clinic` | مدیر کلینیک | فقط نوبتها و دادههای کلینیک |
|
||||||
|
|
||||||
@@ -26,6 +26,7 @@ Create a secretary for a doctor.
|
|||||||
**Permission:** `ROLE_DOCTOR` (must own the doctor — creates `owner_type='doctor'`) | `ROLE_CLINIC` (must have the doctor in its clinic — creates `owner_type='clinic'`) | `ROLE_ADMIN`
|
**Permission:** `ROLE_DOCTOR` (must own the doctor — creates `owner_type='doctor'`) | `ROLE_CLINIC` (must have the doctor in its clinic — creates `owner_type='clinic'`) | `ROLE_ADMIN`
|
||||||
|
|
||||||
### Request Body (`application/json`)
|
### Request Body (`application/json`)
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"doctor_uuid": "550e8400-...",
|
"doctor_uuid": "550e8400-...",
|
||||||
@@ -34,23 +35,39 @@ Create a secretary for a doctor.
|
|||||||
"permissions": {
|
"permissions": {
|
||||||
"version": 1,
|
"version": 1,
|
||||||
"resources": {
|
"resources": {
|
||||||
"appointments": { "view": true, "create": true, "cancel": false, "update_status": true },
|
"appointments": {
|
||||||
"addresses": { "view": true, "create": false, "update": false, "delete": false },
|
"view": true,
|
||||||
|
"create": true,
|
||||||
|
"cancel": false,
|
||||||
|
"update_status": true
|
||||||
|
},
|
||||||
|
"addresses": {
|
||||||
|
"view": true,
|
||||||
|
"create": false,
|
||||||
|
"update": false,
|
||||||
|
"delete": false
|
||||||
|
},
|
||||||
"clinic_info": { "view": true, "update": false },
|
"clinic_info": { "view": true, "update": false },
|
||||||
"insurances": { "view": true, "create": false, "update": false, "delete": false }
|
"insurances": {
|
||||||
|
"view": true,
|
||||||
|
"create": false,
|
||||||
|
"update": false,
|
||||||
|
"delete": false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
| Field | Type | Required | Description |
|
| Field | Type | Required | Description |
|
||||||
|-------|------|----------|-------------|
|
| --------------- | ------------- | -------- | -------------------------------------------- |
|
||||||
| `doctor_uuid` | string (UUID) | ✅ | Doctor to assign secretary to |
|
| `doctor_uuid` | string (UUID) | ✅ | Doctor to assign secretary to |
|
||||||
| `mobile_number` | string | ✅ | Secretary's login mobile |
|
| `mobile_number` | string | ✅ | Secretary's login mobile |
|
||||||
| `password` | string | ❌ | Initial password (auto-generated if omitted) |
|
| `password` | string | ❌ | Initial password (auto-generated if omitted) |
|
||||||
| `permissions` | object | ❌ | Permission set (see structure below) |
|
| `permissions` | object | ❌ | Permission set (see structure below) |
|
||||||
|
|
||||||
**Permissions Structure:**
|
**Permissions Structure:**
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"version": 1,
|
"version": 1,
|
||||||
@@ -82,6 +99,7 @@ Create a secretary for a doctor.
|
|||||||
```
|
```
|
||||||
|
|
||||||
### Response `201`
|
### Response `201`
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"success": true,
|
"success": true,
|
||||||
@@ -107,8 +125,9 @@ Create a secretary for a doctor.
|
|||||||
| `clinic` | منشی توسط مدیر کلینیک تعریف شده — فقط کلینیک |
|
| `clinic` | منشی توسط مدیر کلینیک تعریف شده — فقط کلینیک |
|
||||||
|
|
||||||
### Errors
|
### Errors
|
||||||
|
|
||||||
| Code | HTTP | Description |
|
| Code | HTTP | Description |
|
||||||
|------|------|-------------|
|
| ------------------- | ---- | ------------------------------------------- |
|
||||||
| `ERR_AUTH_001` | 401 | Missing token |
|
| `ERR_AUTH_001` | 401 | Missing token |
|
||||||
| `ERR_AUTH_006` | 403 | Not the doctor owner / clinic owner / admin |
|
| `ERR_AUTH_006` | 403 | Not the doctor owner / clinic owner / admin |
|
||||||
| `ERR_NOT_FOUND_001` | 404 | Doctor not found |
|
| `ERR_NOT_FOUND_001` | 404 | Doctor not found |
|
||||||
@@ -124,11 +143,13 @@ Get secretary detail.
|
|||||||
**Permission:** `AUTH` — must be the linked doctor or `ROLE_ADMIN`
|
**Permission:** `AUTH` — must be the linked doctor or `ROLE_ADMIN`
|
||||||
|
|
||||||
### Path Parameters
|
### Path Parameters
|
||||||
|
|
||||||
| Param | Type | Description |
|
| Param | Type | Description |
|
||||||
|-------|------|-------------|
|
| ------ | ------------- | -------------- |
|
||||||
| `uuid` | string (UUID) | Secretary UUID |
|
| `uuid` | string (UUID) | Secretary UUID |
|
||||||
|
|
||||||
### Response `200`
|
### Response `200`
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"success": true,
|
"success": true,
|
||||||
@@ -144,8 +165,9 @@ Get secretary detail.
|
|||||||
```
|
```
|
||||||
|
|
||||||
### Errors
|
### Errors
|
||||||
|
|
||||||
| Code | HTTP | Description |
|
| Code | HTTP | Description |
|
||||||
|------|------|-------------|
|
| ------------------- | ---- | ------------------- |
|
||||||
| `ERR_AUTH_001` | 401 | Missing token |
|
| `ERR_AUTH_001` | 401 | Missing token |
|
||||||
| `ERR_FORBIDDEN_001` | 403 | Not authorized |
|
| `ERR_FORBIDDEN_001` | 403 | Not authorized |
|
||||||
| `ERR_NOT_FOUND_001` | 404 | Secretary not found |
|
| `ERR_NOT_FOUND_001` | 404 | Secretary not found |
|
||||||
@@ -159,29 +181,37 @@ Update secretary active status or permissions.
|
|||||||
**Permission:** `ROLE_DOCTOR` — must be the linked doctor
|
**Permission:** `ROLE_DOCTOR` — must be the linked doctor
|
||||||
|
|
||||||
### Request Body (`application/json`)
|
### Request Body (`application/json`)
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"active": false,
|
"active": false,
|
||||||
"permissions": {
|
"permissions": {
|
||||||
"version": 1,
|
"version": 1,
|
||||||
"resources": {
|
"resources": {
|
||||||
"appointments": { "view": true, "create": false, "cancel": false, "update_status": false }
|
"appointments": {
|
||||||
|
"view": true,
|
||||||
|
"create": false,
|
||||||
|
"cancel": false,
|
||||||
|
"update_status": false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
| Field | Type | Required | Description |
|
| Field | Type | Required | Description |
|
||||||
|-------|------|----------|-------------|
|
| ------------- | ------- | -------- | ------------------------ |
|
||||||
| `active` | boolean | ❌ | Enable/disable secretary |
|
| `active` | boolean | ❌ | Enable/disable secretary |
|
||||||
| `permissions` | object | ❌ | New permissions object |
|
| `permissions` | object | ❌ | New permissions object |
|
||||||
|
|
||||||
### Response `200`
|
### Response `200`
|
||||||
|
|
||||||
Updated secretary object.
|
Updated secretary object.
|
||||||
|
|
||||||
### Errors
|
### Errors
|
||||||
|
|
||||||
| Code | HTTP | Description |
|
| Code | HTTP | Description |
|
||||||
|------|------|-------------|
|
| ------------------- | ---- | --------------------- |
|
||||||
| `ERR_AUTH_001` | 401 | Missing token |
|
| `ERR_AUTH_001` | 401 | Missing token |
|
||||||
| `ERR_FORBIDDEN_001` | 403 | Not the linked doctor |
|
| `ERR_FORBIDDEN_001` | 403 | Not the linked doctor |
|
||||||
| `ERR_NOT_FOUND_001` | 404 | Secretary not found |
|
| `ERR_NOT_FOUND_001` | 404 | Secretary not found |
|
||||||
@@ -195,13 +225,15 @@ Delete a secretary.
|
|||||||
**Permission:** `ROLE_DOCTOR` — must be the linked doctor
|
**Permission:** `ROLE_DOCTOR` — must be the linked doctor
|
||||||
|
|
||||||
### Response `200`
|
### Response `200`
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{ "success": true, "data": { "message": "منشی حذف شد" } }
|
{ "success": true, "data": { "message": "منشی حذف شد" } }
|
||||||
```
|
```
|
||||||
|
|
||||||
### Errors
|
### Errors
|
||||||
|
|
||||||
| Code | HTTP | Description |
|
| Code | HTTP | Description |
|
||||||
|------|------|-------------|
|
| ------------------- | ---- | --------------------- |
|
||||||
| `ERR_AUTH_001` | 401 | Missing token |
|
| `ERR_AUTH_001` | 401 | Missing token |
|
||||||
| `ERR_FORBIDDEN_001` | 403 | Not the linked doctor |
|
| `ERR_FORBIDDEN_001` | 403 | Not the linked doctor |
|
||||||
| `ERR_NOT_FOUND_001` | 404 | Secretary not found |
|
| `ERR_NOT_FOUND_001` | 404 | Secretary not found |
|
||||||
@@ -215,11 +247,13 @@ Get all secretaries for a specific doctor.
|
|||||||
**Permission:** `ROLE_DOCTOR` (must own doctor) | `ROLE_CLINIC` (must have doctor in clinic) | `ROLE_ADMIN`
|
**Permission:** `ROLE_DOCTOR` (must own doctor) | `ROLE_CLINIC` (must have doctor in clinic) | `ROLE_ADMIN`
|
||||||
|
|
||||||
### Path Parameters
|
### Path Parameters
|
||||||
|
|
||||||
| Param | Type | Description |
|
| Param | Type | Description |
|
||||||
|-------|------|-------------|
|
| ------------ | ------------- | ----------- |
|
||||||
| `doctorUuid` | string (UUID) | Doctor UUID |
|
| `doctorUuid` | string (UUID) | Doctor UUID |
|
||||||
|
|
||||||
### Response `200`
|
### Response `200`
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"success": true,
|
"success": true,
|
||||||
@@ -239,8 +273,9 @@ Get all secretaries for a specific doctor.
|
|||||||
```
|
```
|
||||||
|
|
||||||
### Errors
|
### Errors
|
||||||
|
|
||||||
| Code | HTTP | Description |
|
| Code | HTTP | Description |
|
||||||
|------|------|-------------|
|
| ------------------- | ---- | ---------------- |
|
||||||
| `ERR_AUTH_001` | 401 | Missing token |
|
| `ERR_AUTH_001` | 401 | Missing token |
|
||||||
| `ERR_FORBIDDEN_001` | 403 | Not authorized |
|
| `ERR_FORBIDDEN_001` | 403 | Not authorized |
|
||||||
| `ERR_NOT_FOUND_001` | 404 | Doctor not found |
|
| `ERR_NOT_FOUND_001` | 404 | Doctor not found |
|
||||||
@@ -254,11 +289,13 @@ Get all secretaries across **all doctors** of a clinic.
|
|||||||
**Permission:** `ROLE_CLINIC` (must own clinic) | `ROLE_ADMIN`
|
**Permission:** `ROLE_CLINIC` (must own clinic) | `ROLE_ADMIN`
|
||||||
|
|
||||||
### Path Parameters
|
### Path Parameters
|
||||||
|
|
||||||
| Param | Type | Description |
|
| Param | Type | Description |
|
||||||
|-------|------|-------------|
|
| ------------ | ------------- | ----------- |
|
||||||
| `clinicUuid` | string (UUID) | Clinic UUID |
|
| `clinicUuid` | string (UUID) | Clinic UUID |
|
||||||
|
|
||||||
### Response `200`
|
### Response `200`
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"success": true,
|
"success": true,
|
||||||
@@ -278,13 +315,15 @@ Get all secretaries across **all doctors** of a clinic.
|
|||||||
```
|
```
|
||||||
|
|
||||||
### Notes
|
### Notes
|
||||||
- این endpoint فقط منشیانی را برمیگرداند که با `owner_type='clinic'` تعریف شدهاند
|
|
||||||
- منشیانی که خود دکتر (با `owner_type='doctor'`) تعریف کرده از این لیست مخفی هستند
|
- این endpoint فقط منشی های را برمیگرداند که با `owner_type='clinic'` تعریف شدهاند
|
||||||
|
- منشی های که خود دکتر (با `owner_type='doctor'`) تعریف کرده از این لیست مخفی هستند
|
||||||
- یک منشی میتواند به چند دکتر در همان کلینیک متصل باشد — در لیست چندبار ظاهر میشود (یک ردیف به ازای هر دکتر)
|
- یک منشی میتواند به چند دکتر در همان کلینیک متصل باشد — در لیست چندبار ظاهر میشود (یک ردیف به ازای هر دکتر)
|
||||||
|
|
||||||
### Errors
|
### Errors
|
||||||
|
|
||||||
| Code | HTTP | Description |
|
| Code | HTTP | Description |
|
||||||
|------|------|-------------|
|
| ------------------- | ---- | ---------------- |
|
||||||
| `ERR_AUTH_001` | 401 | Missing token |
|
| `ERR_AUTH_001` | 401 | Missing token |
|
||||||
| `ERR_FORBIDDEN_001` | 403 | Not clinic owner |
|
| `ERR_FORBIDDEN_001` | 403 | Not clinic owner |
|
||||||
| `ERR_NOT_FOUND_001` | 404 | Clinic not found |
|
| `ERR_NOT_FOUND_001` | 404 | Clinic not found |
|
||||||
@@ -296,7 +335,7 @@ Get all secretaries across **all doctors** of a clinic.
|
|||||||
تعداد منشیهای مجاز بر اساس پنل فعال doctor تعیین میشود:
|
تعداد منشیهای مجاز بر اساس پنل فعال doctor تعیین میشود:
|
||||||
|
|
||||||
| پنل | حداکثر منشی |
|
| پنل | حداکثر منشی |
|
||||||
|-----|-------------|
|
| ------------------ | ----------- |
|
||||||
| Free (بدون اشتراک) | ۱ |
|
| Free (بدون اشتراک) | ۱ |
|
||||||
| Basic | ۳ |
|
| Basic | ۳ |
|
||||||
| Professional | ۱۰ |
|
| Professional | ۱۰ |
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
# تسک ۱۲: تکمیل منشی — محدودیت پنل + UI Permissions
|
# تسک ۱۲: تکمیل منشی — محدودیت پنل + UI Permissions
|
||||||
|
|
||||||
## توضیح
|
## توضیح
|
||||||
|
|
||||||
Entity `DoctorSecretary` و API کامل موجود است (`src/Secretary/`). این تسک فقط دو چیز اضافه میکند:
|
Entity `DoctorSecretary` و API کامل موجود است (`src/Secretary/`). این تسک فقط دو چیز اضافه میکند:
|
||||||
|
|
||||||
1. Backend: بررسی سقف تعداد منشی بر اساس پنل اشتراکی
|
1. Backend: بررسی سقف تعداد منشی بر اساس پنل اشتراکی
|
||||||
2. Frontend: Modal با checkbox matrix برای ویرایش permissions
|
2. Frontend: Modal با checkbox matrix برای ویرایش permissions
|
||||||
|
|
||||||
@@ -10,29 +12,32 @@ Entity `DoctorSecretary` و API کامل موجود است (`src/Secretary/`).
|
|||||||
## فایلهای موجود که تغییر میکنند
|
## فایلهای موجود که تغییر میکنند
|
||||||
|
|
||||||
| فایل | تغییر |
|
| فایل | تغییر |
|
||||||
|------|-------|
|
| -------------------------------------------------- | ------------------------------------------------------------------ |
|
||||||
| `src/Secretary/Controller/SecretaryController.php` | inject `SubscriptionService`، اضافه کردن limit check در `create()` |
|
| `src/Secretary/Controller/SecretaryController.php` | inject `SubscriptionService`، اضافه کردن limit check در `create()` |
|
||||||
| `assets/admin/pages/SecretariesPage.tsx` | Modal ویرایش با checkbox matrix برای permissions |
|
| `assets/admin/pages/SecretariesPage.tsx` | Modal ویرایش با checkbox matrix برای permissions |
|
||||||
|
|
||||||
## API موجود (بدون تغییر)
|
## API موجود (بدون تغییر)
|
||||||
|
|
||||||
| متد | مسیر | توضیح |
|
| متد | مسیر | توضیح |
|
||||||
|-----|------|-------|
|
| ------ | ---------------------------------- | ----------------------------------------------- |
|
||||||
| GET | `/api/v1/secretaries/{doctorUuid}` | لیست منشیان |
|
| GET | `/api/v1/secretaries/{doctorUuid}` | لیست منشی ها |
|
||||||
| POST | `/api/v1/secretary` | ایجاد منشی ← **اینجا limit check اضافه میشود** |
|
| POST | `/api/v1/secretary` | ایجاد منشی ← **اینجا limit check اضافه میشود** |
|
||||||
| PATCH | `/api/v1/secretary/{uuid}` | ویرایش ← **permissions هم قابل ویرایش میشود** |
|
| PATCH | `/api/v1/secretary/{uuid}` | ویرایش ← **permissions هم قابل ویرایش میشود** |
|
||||||
| DELETE | `/api/v1/secretary/{uuid}` | غیرفعالسازی (soft delete) |
|
| DELETE | `/api/v1/secretary/{uuid}` | غیرفعالسازی (soft delete) |
|
||||||
|
|
||||||
## پیشنیازها
|
## پیشنیازها
|
||||||
|
|
||||||
- تسک ۱۴ (Secretary — موجود در کد)
|
- تسک ۱۴ (Secretary — موجود در کد)
|
||||||
- **تسک ۱۱** (Subscription — `SubscriptionService` باید موجود باشد)
|
- **تسک ۱۱** (Subscription — `SubscriptionService` باید موجود باشد)
|
||||||
|
|
||||||
## زمان تخمینی
|
## زمان تخمینی
|
||||||
|
|
||||||
۴ تا ۵ ساعت
|
۴ تا ۵ ساعت
|
||||||
|
|
||||||
## تغییر Backend — SecretaryController::create()
|
## تغییر Backend — SecretaryController::create()
|
||||||
|
|
||||||
### وضعیت فعلی (قبل):
|
### وضعیت فعلی (قبل):
|
||||||
|
|
||||||
```php
|
```php
|
||||||
public function create(Request $request): JsonResponse
|
public function create(Request $request): JsonResponse
|
||||||
{
|
{
|
||||||
@@ -44,6 +49,7 @@ public function create(Request $request): JsonResponse
|
|||||||
```
|
```
|
||||||
|
|
||||||
### وضعیت جدید (بعد):
|
### وضعیت جدید (بعد):
|
||||||
|
|
||||||
```php
|
```php
|
||||||
public function create(Request $request): JsonResponse
|
public function create(Request $request): JsonResponse
|
||||||
{
|
{
|
||||||
@@ -68,6 +74,7 @@ public function create(Request $request): JsonResponse
|
|||||||
```
|
```
|
||||||
|
|
||||||
### خطای جدید که باید در ErrorCodes.php اضافه شود:
|
### خطای جدید که باید در ErrorCodes.php اضافه شود:
|
||||||
|
|
||||||
```php
|
```php
|
||||||
// src/Shared/Constant/ErrorCodes.php
|
// src/Shared/Constant/ErrorCodes.php
|
||||||
const ERR_SECRETARY_LIMIT_REACHED = 'ERR_SECRETARY_LIMIT_REACHED';
|
const ERR_SECRETARY_LIMIT_REACHED = 'ERR_SECRETARY_LIMIT_REACHED';
|
||||||
@@ -77,6 +84,7 @@ const ERR_SECRETARY_LIMIT_REACHED = 'ERR_SECRETARY_LIMIT_REACHED';
|
|||||||
## تغییر Frontend — SecretariesPage.tsx
|
## تغییر Frontend — SecretariesPage.tsx
|
||||||
|
|
||||||
### ساختار DEFAULT_PERMISSIONS (از DoctorSecretary::DEFAULT_PERMISSIONS):
|
### ساختار DEFAULT_PERMISSIONS (از DoctorSecretary::DEFAULT_PERMISSIONS):
|
||||||
|
|
||||||
```php
|
```php
|
||||||
[
|
[
|
||||||
'version' => 1,
|
'version' => 1,
|
||||||
@@ -90,6 +98,7 @@ const ERR_SECRETARY_LIMIT_REACHED = 'ERR_SECRETARY_LIMIT_REACHED';
|
|||||||
```
|
```
|
||||||
|
|
||||||
### نمونه UI Permissions checkbox matrix در Modal:
|
### نمونه UI Permissions checkbox matrix در Modal:
|
||||||
|
|
||||||
```
|
```
|
||||||
┌─────────────────────────────────────────────────────┐
|
┌─────────────────────────────────────────────────────┐
|
||||||
│ دسترسیهای منشی │
|
│ دسترسیهای منشی │
|
||||||
@@ -104,13 +113,24 @@ const ERR_SECRETARY_LIMIT_REACHED = 'ERR_SECRETARY_LIMIT_REACHED';
|
|||||||
```
|
```
|
||||||
|
|
||||||
### PATCH /api/v1/secretary/{uuid} با permissions:
|
### PATCH /api/v1/secretary/{uuid} با permissions:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"permissions": {
|
"permissions": {
|
||||||
"version": 1,
|
"version": 1,
|
||||||
"resources": {
|
"resources": {
|
||||||
"appointments": { "view": true, "create": true, "cancel": true, "update_status": true },
|
"appointments": {
|
||||||
"addresses": { "view": true, "create": false, "update": false, "delete": false }
|
"view": true,
|
||||||
|
"create": true,
|
||||||
|
"cancel": true,
|
||||||
|
"update_status": true
|
||||||
|
},
|
||||||
|
"addresses": {
|
||||||
|
"view": true,
|
||||||
|
"create": false,
|
||||||
|
"update": false,
|
||||||
|
"delete": false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -119,16 +139,19 @@ const ERR_SECRETARY_LIMIT_REACHED = 'ERR_SECRETARY_LIMIT_REACHED';
|
|||||||
## کدهای خطای جدید
|
## کدهای خطای جدید
|
||||||
|
|
||||||
| کد | پیام فارسی |
|
| کد | پیام فارسی |
|
||||||
|----|-----------|
|
| ----------------------------- | ---------------------------------------- |
|
||||||
| `ERR_SECRETARY_LIMIT_REACHED` | سقف تعداد منشی پنل اشتراکی شما رسیده است |
|
| `ERR_SECRETARY_LIMIT_REACHED` | سقف تعداد منشی پنل اشتراکی شما رسیده است |
|
||||||
|
|
||||||
## Response خطا (403)
|
## Response خطا (403)
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"success": false,
|
"success": false,
|
||||||
"errors": [{
|
"errors": [
|
||||||
|
{
|
||||||
"code": "ERR_SECRETARY_LIMIT_REACHED",
|
"code": "ERR_SECRETARY_LIMIT_REACHED",
|
||||||
"message": "سقف تعداد منشی پنل اشتراکی شما رسیده است. برای افزودن منشی بیشتر پنل را ارتقاء دهید."
|
"message": "سقف تعداد منشی پنل اشتراکی شما رسیده است. برای افزودن منشی بیشتر پنل را ارتقاء دهید."
|
||||||
}]
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ Professional → 5 → 403
|
|||||||
## نکته: منشی غیرفعال از سقف کم نمیشود
|
## نکته: منشی غیرفعال از سقف کم نمیشود
|
||||||
|
|
||||||
```
|
```
|
||||||
countActive() فقط منشیان active=true را میشمارد
|
countActive() فقط منشی ها active=true را میشمارد
|
||||||
یعنی: اگر 2 منشی داری و یکی را غیرفعال کنی → میتوانی دوباره 1 منشی جدید اضافه کنی
|
یعنی: اگر 2 منشی داری و یکی را غیرفعال کنی → میتوانی دوباره 1 منشی جدید اضافه کنی
|
||||||
(مادامی که در پنل Basic هستی و سقف 2 داری)
|
(مادامی که در پنل Basic هستی و سقف 2 داری)
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -0,0 +1,439 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
require __DIR__ . '/vendor/autoload.php';
|
||||||
|
(new Symfony\Component\Dotenv\Dotenv())->bootEnv(__DIR__ . '/.env');
|
||||||
|
$kernel = new App\Kernel($_SERVER['APP_ENV'], (bool) $_SERVER['APP_DEBUG']);
|
||||||
|
$kernel->boot();
|
||||||
|
$conn = $kernel->getContainer()->get('doctrine')->getManager()->getConnection();
|
||||||
|
|
||||||
|
$now = time();
|
||||||
|
$passwordHash = password_hash('Test@1234', PASSWORD_BCRYPT);
|
||||||
|
$mobileCounter = 9100100000; // شروع از 09100100000
|
||||||
|
|
||||||
|
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function newUuid(): string
|
||||||
|
{
|
||||||
|
return sprintf(
|
||||||
|
'%04x%04x-%04x-%04x-%04x-%04x%04x%04x',
|
||||||
|
mt_rand(0, 0xffff), mt_rand(0, 0xffff),
|
||||||
|
mt_rand(0, 0xffff),
|
||||||
|
mt_rand(0, 0x0fff) | 0x4000,
|
||||||
|
mt_rand(0, 0x3fff) | 0x8000,
|
||||||
|
mt_rand(0, 0xffff), mt_rand(0, 0xffff), mt_rand(0, 0xffff)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function nextMobile(): string
|
||||||
|
{
|
||||||
|
global $mobileCounter;
|
||||||
|
return '0' . $mobileCounter++;
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeSchedule(int $addressId, bool $fullWeek = true): string
|
||||||
|
{
|
||||||
|
$days = [];
|
||||||
|
for ($i = 0; $i < 7; $i++) {
|
||||||
|
if ($i === 6) {
|
||||||
|
$days[] = ['sessions' => []];
|
||||||
|
} elseif ($i === 5) {
|
||||||
|
$days[] = ['sessions' => [
|
||||||
|
['active' => true, 'location_id' => $addressId, 'start_time' => '09:00',
|
||||||
|
'end_time' => '13:00', 'duration_per_patient' => 20,
|
||||||
|
'has_rest' => false, 'rest_interval' => 60, 'time_to_rest' => 10, 'patient_limit' => null],
|
||||||
|
]];
|
||||||
|
} elseif ($fullWeek) {
|
||||||
|
$days[] = ['sessions' => [
|
||||||
|
['active' => true, 'location_id' => $addressId, 'start_time' => '09:00',
|
||||||
|
'end_time' => '13:00', 'duration_per_patient' => 20,
|
||||||
|
'has_rest' => true, 'rest_interval' => 60, 'time_to_rest' => 10, 'patient_limit' => null],
|
||||||
|
['active' => true, 'location_id' => $addressId, 'start_time' => '14:00',
|
||||||
|
'end_time' => '18:00', 'duration_per_patient' => 20,
|
||||||
|
'has_rest' => false, 'rest_interval' => 60, 'time_to_rest' => 10, 'patient_limit' => null],
|
||||||
|
]];
|
||||||
|
} else {
|
||||||
|
$days[] = ['sessions' => [
|
||||||
|
['active' => true, 'location_id' => $addressId, 'start_time' => '09:00',
|
||||||
|
'end_time' => '13:00', 'duration_per_patient' => 20,
|
||||||
|
'has_rest' => false, 'rest_interval' => 60, 'time_to_rest' => 10, 'patient_limit' => null],
|
||||||
|
]];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return json_encode($days, JSON_UNESCAPED_UNICODE);
|
||||||
|
}
|
||||||
|
|
||||||
|
// نگاشت شهر → استان
|
||||||
|
$cityProvince = [
|
||||||
|
101 => 1, 102 => 2, 103 => 3, 104 => 4, 105 => 5,
|
||||||
|
106 => 6, 107 => 7, 108 => 8, 109 => 9, 110 => 10,
|
||||||
|
111 => 11, 112 => 12, 113 => 13, 114 => 14, 115 => 15,
|
||||||
|
116 => 16, 117 => 17, 118 => 18, 119 => 19, 120 => 20,
|
||||||
|
121 => 21, 122 => 22, 123 => 23, 125 => 24, 126 => 25,
|
||||||
|
127 => 26, 128 => 27, 129 => 28, 130 => 29, 131 => 30, 132 => 100,
|
||||||
|
];
|
||||||
|
|
||||||
|
$cityNames = [
|
||||||
|
101 => 'تبریز', 102 => 'ارومیه', 103 => 'اردبیل', 104 => 'اصفهان',
|
||||||
|
105 => 'کرج', 108 => 'تهران', 111 => 'مشهد', 113 => 'اهواز',
|
||||||
|
117 => 'شیراز', 121 => 'کرمان', 122 => 'کرمانشاه', 126 => 'رشت',
|
||||||
|
128 => 'ساری', 131 => 'همدان', 132 => 'یزد',
|
||||||
|
];
|
||||||
|
|
||||||
|
// توزیع تخصصها برای ۱۰۰ دکتر
|
||||||
|
$specialtyGroups = [
|
||||||
|
['specs' => [200], 'services' => [], 'degree' => 'general', 'gender_mix' => 'both', 'count' => 15],
|
||||||
|
['specs' => [2, 3], 'services' => [101,102,103,110], 'degree' => 'specialist', 'gender_mix' => 'male', 'count' => 10],
|
||||||
|
['specs' => [4], 'services' => [201,202,203,210], 'degree' => 'specialist', 'gender_mix' => 'both', 'count' => 6],
|
||||||
|
['specs' => [10], 'services' => [801,802,803,805], 'degree' => 'specialist', 'gender_mix' => 'both', 'count' => 6],
|
||||||
|
['specs' => [21], 'services' => [2101,2102,2103,2113],'degree' => 'specialist','gender_mix' => 'male', 'count' => 8],
|
||||||
|
['specs' => [61], 'services' => [], 'degree' => 'specialist', 'gender_mix' => 'female', 'count' => 8],
|
||||||
|
['specs' => [41], 'services' => [], 'degree' => 'specialist', 'gender_mix' => 'both', 'count' => 8],
|
||||||
|
['specs' => [111], 'services' => [], 'degree' => 'specialist', 'gender_mix' => 'male', 'count' => 7],
|
||||||
|
['specs' => [81, 82], 'services' => [], 'degree' => 'specialist', 'gender_mix' => 'both', 'count' => 6],
|
||||||
|
['specs' => [91], 'services' => [], 'degree' => 'specialist', 'gender_mix' => 'both', 'count' => 6],
|
||||||
|
['specs' => [101], 'services' => [], 'degree' => 'specialist', 'gender_mix' => 'both', 'count' => 5],
|
||||||
|
['specs' => [71], 'services' => [], 'degree' => 'specialist', 'gender_mix' => 'both', 'count' => 5],
|
||||||
|
['specs' => [121], 'services' => [], 'degree' => 'specialist', 'gender_mix' => 'male', 'count' => 4],
|
||||||
|
['specs' => [181], 'services' => [], 'degree' => 'dentist', 'gender_mix' => 'both', 'count' => 6],
|
||||||
|
];
|
||||||
|
|
||||||
|
$maleFirstNames = ['علی', 'محمد', 'حسین', 'رضا', 'امیر', 'مهدی', 'سعید', 'احمد', 'حسن', 'جواد',
|
||||||
|
'کریم', 'ابراهیم', 'اکبر', 'ناصر', 'پیمان', 'بهروز', 'شاهین', 'کاوه', 'آرش', 'سهراب',
|
||||||
|
'فرزاد', 'بهنام', 'وحید', 'مجید', 'عباس', 'صادق', 'داود', 'یوسف', 'منصور', 'اسماعیل'];
|
||||||
|
$femaleFirstNames = ['فاطمه', 'زهرا', 'مریم', 'سارا', 'نگین', 'الهه', 'نیلوفر', 'شیما', 'لیلا', 'نازنین',
|
||||||
|
'مینا', 'آزاده', 'ندا', 'رویا', 'پریسا', 'گلناز', 'سپیده', 'بهاره', 'شادی', 'فریده',
|
||||||
|
'سمانه', 'مهتاب', 'شهرزاد', 'گیتا', 'آناهیتا', 'سیمین', 'منیژه', 'ناهید', 'پگاه', 'روشنک'];
|
||||||
|
$lastNames = ['رضایی', 'محمدی', 'حسینی', 'علوی', 'موسوی', 'احمدی', 'کریمی', 'صادقی', 'جعفری', 'مرادی',
|
||||||
|
'نجفی', 'تبریزی', 'اصفهانی', 'مشهدی', 'شیرازی', 'کرمانی', 'رشیدی', 'نظری', 'ابراهیمی',
|
||||||
|
'قاسمی', 'یوسفی', 'منصوری', 'زارعی', 'ملکی', 'بهرامی', 'سلیمانی', 'نوری', 'پارسا', 'امینی', 'زاهدی'];
|
||||||
|
|
||||||
|
$degreeLabels = ['general' => 'پزشک عمومی', 'specialist' => 'متخصص', 'dentist' => 'دندانپزشک', 'subspecialist' => 'فوقتخصص'];
|
||||||
|
$specNames = [
|
||||||
|
2 => 'داخلی', 3 => 'قلب و عروق', 4 => 'گوارش', 10 => 'نورولوژی',
|
||||||
|
21 => 'جراحی عمومی', 41 => 'اطفال', 61 => 'زنان و زایمان', 71 => 'روانپزشکی',
|
||||||
|
81 => 'پوست', 82 => 'زیبایی پوست', 91 => 'چشمپزشکی', 101 => 'گوش، حلق و بینی',
|
||||||
|
111 => 'ارتوپدی', 121 => 'اورولوژی', 181 => 'دندانپزشکی', 200 => 'پزشکی عمومی',
|
||||||
|
];
|
||||||
|
|
||||||
|
$supplementaryInsurances = [7, 8, 9, 10, 11, 12, 13, 14, 15];
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// بخش A — پاکسازی
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
echo "🧹 پاکسازی دیتابیس...\n";
|
||||||
|
|
||||||
|
$conn->executeStatement('SET FOREIGN_KEY_CHECKS=0');
|
||||||
|
$conn->executeStatement('DELETE FROM weekly_schedules');
|
||||||
|
$conn->executeStatement('DELETE FROM doctor_addresses');
|
||||||
|
$conn->executeStatement('DELETE FROM doctor_specialties');
|
||||||
|
$conn->executeStatement('DELETE FROM doctor_expertise');
|
||||||
|
$conn->executeStatement('DELETE FROM doctor_insurances');
|
||||||
|
$conn->executeStatement('DELETE FROM doctor_cities');
|
||||||
|
$conn->executeStatement('DELETE FROM doctor_provinces');
|
||||||
|
$conn->executeStatement('DELETE FROM doctor_secretaries');
|
||||||
|
$conn->executeStatement('DELETE FROM clinic_doctors');
|
||||||
|
$conn->executeStatement('DELETE FROM clinic_specialties');
|
||||||
|
$conn->executeStatement('DELETE FROM clinic_services');
|
||||||
|
$conn->executeStatement('DELETE FROM clinic_insurances');
|
||||||
|
$conn->executeStatement('DELETE FROM patient_records');
|
||||||
|
$conn->executeStatement('DELETE FROM appointments');
|
||||||
|
$conn->executeStatement('DELETE FROM payments');
|
||||||
|
$conn->executeStatement('DELETE FROM sms_logs');
|
||||||
|
$conn->executeStatement('DELETE FROM rates');
|
||||||
|
$conn->executeStatement('DELETE FROM doctors');
|
||||||
|
$conn->executeStatement('DELETE FROM clinics');
|
||||||
|
$conn->executeStatement("DELETE FROM users WHERE JSON_CONTAINS(roles, '\"ROLE_DOCTOR\"') OR JSON_CONTAINS(roles, '\"ROLE_CLINIC\"') OR JSON_CONTAINS(roles, '\"ROLE_PATIENT\"') OR JSON_CONTAINS(roles, '\"ROLE_SECRETARY\"')");
|
||||||
|
$conn->executeStatement('SET FOREIGN_KEY_CHECKS=1');
|
||||||
|
|
||||||
|
echo "✅ پاکسازی کامل شد\n\n";
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// بخش B — کلینیک نمونه کامل (تبریز)
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
echo "🏥 ایجاد کلینیک نمونه...\n";
|
||||||
|
|
||||||
|
$clinicMobile = nextMobile();
|
||||||
|
$clinicUserUuid = newUuid();
|
||||||
|
$conn->executeStatement(
|
||||||
|
'INSERT INTO users (uuid, mobile_number, password_hash, real_name, roles, status, created_at, updated_at) VALUES (?,?,?,?,?,1,?,?)',
|
||||||
|
[$clinicUserUuid, $clinicMobile, $passwordHash, 'کلینیک تخصصی امید', '["ROLE_USER","ROLE_CLINIC"]', $now, $now]
|
||||||
|
);
|
||||||
|
$clinicUserId = (int) $conn->lastInsertId();
|
||||||
|
|
||||||
|
$clinicUuid = newUuid();
|
||||||
|
$conn->executeStatement(
|
||||||
|
'INSERT INTO clinics (uuid, user_id, name, info, address, telephone, is_24_7, working_days, latitude, longitude, city_id, province_id, representation_id, is_active, created_at, updated_at)
|
||||||
|
VALUES (?,?,?,?,?,?,0,?,?,?,?,?,?,1,?,?)',
|
||||||
|
[
|
||||||
|
$clinicUuid, $clinicUserId,
|
||||||
|
'کلینیک تخصصی امید تبریز',
|
||||||
|
'کلینیک تخصصی امید تبریز با بیش از ۲۰ سال سابقه در ارائه خدمات درمانی تخصصی. مجهز به پیشرفتهترین تجهیزات پزشکی و دارای تیمی از متخصصان برجسته در رشتههای مختلف. پذیرش بیماران با بیمههای تامین اجتماعی، خدمات درمانی و بیمههای تکمیلی.',
|
||||||
|
'تبریز، خیابان ارتش، نبش کوچه سوم، ساختمان امید، طبقه اول',
|
||||||
|
'04133445566',
|
||||||
|
'شنبه تا چهارشنبه ۸ تا ۲۰، پنجشنبه ۸ تا ۱۴',
|
||||||
|
37.0833, 35.7167,
|
||||||
|
101, 1, 101, // city, province, representation
|
||||||
|
$now, $now,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
$clinicId = (int) $conn->lastInsertId();
|
||||||
|
|
||||||
|
// تخصصهای کلینیک
|
||||||
|
foreach ([1, 2, 3, 4, 10, 40, 60, 80, 90, 100, 110] as $specId) {
|
||||||
|
$conn->executeStatement('INSERT IGNORE INTO clinic_specialties (clinic_id, specialty_id) VALUES (?,?)', [$clinicId, $specId]);
|
||||||
|
}
|
||||||
|
// بیمههای کلینیک
|
||||||
|
foreach ([1, 2, 3, 7, 8, 9] as $insId) {
|
||||||
|
$conn->executeStatement('INSERT IGNORE INTO clinic_insurances (clinic_id, insurance_id) VALUES (?,?)', [$clinicId, $insId]);
|
||||||
|
}
|
||||||
|
// خدمات کلینیک
|
||||||
|
foreach ([101, 102, 103, 201, 202, 203, 801, 802, 803] as $svcId) {
|
||||||
|
$conn->executeStatement('INSERT IGNORE INTO clinic_services (clinic_id, service_id) VALUES (?,?)', [$clinicId, $svcId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "✅ کلینیک امید تبریز ایجاد شد (id: $clinicId)\n\n";
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// بخش C — دکتر نمونه کامل (تبریز)
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
echo "👨⚕️ ایجاد دکتر نمونه...\n";
|
||||||
|
|
||||||
|
$sampleDocMobile = nextMobile();
|
||||||
|
$sampleDocUserUuid = newUuid();
|
||||||
|
$conn->executeStatement(
|
||||||
|
'INSERT INTO users (uuid, mobile_number, password_hash, real_name, roles, status, created_at, updated_at) VALUES (?,?,?,?,?,1,?,?)',
|
||||||
|
[$sampleDocUserUuid, $sampleDocMobile, $passwordHash, 'دکتر آرمان رضایی', '["ROLE_USER","ROLE_DOCTOR"]', $now, $now]
|
||||||
|
);
|
||||||
|
$sampleDocUserId = (int) $conn->lastInsertId();
|
||||||
|
|
||||||
|
$sampleDocUuid = newUuid();
|
||||||
|
$conn->executeStatement(
|
||||||
|
'INSERT INTO doctors (uuid, user_id, name, gender, medical_system_code, mobile_number, activity_time, degree, info, doctor_rate, doctor_rate_percentage, active_doctor_appointment, representation_id, created_at, updated_at)
|
||||||
|
VALUES (?,?,?,?,?,?,?,?,?,?,?,1,?,?,?)',
|
||||||
|
[
|
||||||
|
$sampleDocUuid, $sampleDocUserId,
|
||||||
|
'دکتر آرمان رضایی',
|
||||||
|
'man', 'IR-10001', $sampleDocMobile,
|
||||||
|
mktime(0, 0, 0, 1, 1, 2008),
|
||||||
|
'specialist',
|
||||||
|
'متخصص قلب و عروق با بیش از ۱۶ سال سابقه بالینی. فارغالتحصیل دانشگاه علوم پزشکی تبریز با فلوشیپ قلب از دانشگاه تهران. عضو انجمن قلب ایران و مولف بیش از ۳۰ مقاله علمی در مجلات معتبر بینالمللی. دارای تخصص ویژه در آنژیوگرافی تشخیصی و درمانی، اکوکاردیوگرافی و درمان نارسایی قلبی.',
|
||||||
|
4.8, 96.0,
|
||||||
|
101,
|
||||||
|
$now, $now,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
$sampleDocId = (int) $conn->lastInsertId();
|
||||||
|
|
||||||
|
// تخصصها
|
||||||
|
foreach ([2, 3] as $specId) {
|
||||||
|
$conn->executeStatement('INSERT IGNORE INTO doctor_specialties (doctor_id, specialty_id) VALUES (?,?)', [$sampleDocId, $specId]);
|
||||||
|
}
|
||||||
|
// خدمات
|
||||||
|
foreach ([101, 102, 103, 104, 110] as $svcId) {
|
||||||
|
$conn->executeStatement('INSERT IGNORE INTO doctor_expertise (doctor_id, service_id) VALUES (?,?)', [$sampleDocId, $svcId]);
|
||||||
|
}
|
||||||
|
// بیمهها
|
||||||
|
foreach ([1, 2, 3, 7] as $insId) {
|
||||||
|
$conn->executeStatement('INSERT IGNORE INTO doctor_insurances (doctor_id, insurance_id) VALUES (?,?)', [$sampleDocId, $insId]);
|
||||||
|
}
|
||||||
|
// شهر و استان
|
||||||
|
$conn->executeStatement('INSERT IGNORE INTO doctor_cities (doctor_id, city_id) VALUES (?,?)', [$sampleDocId, 101]);
|
||||||
|
$conn->executeStatement('INSERT IGNORE INTO doctor_provinces (doctor_id, province_id) VALUES (?,?)', [$sampleDocId, 1]);
|
||||||
|
|
||||||
|
// آدرس مطب شخصی
|
||||||
|
$addrUuid1 = newUuid();
|
||||||
|
$conn->executeStatement(
|
||||||
|
'INSERT INTO doctor_addresses (uuid, doctor_id, clinic_id, city_id, province_id, type, name, address, telephone, created_at, updated_at)
|
||||||
|
VALUES (?,?,NULL,?,?,?,?,?,?,?,?)',
|
||||||
|
[
|
||||||
|
$addrUuid1, $sampleDocId, 101, 1, 'personal',
|
||||||
|
'مطب دکتر رضایی',
|
||||||
|
'تبریز، خیابان آزادی، کوچه پانزدهم، ساختمان پارسیان، واحد ۱۲',
|
||||||
|
'04132001122',
|
||||||
|
$now, $now,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
$addrId1 = (int) $conn->lastInsertId();
|
||||||
|
|
||||||
|
// آدرس کلینیک
|
||||||
|
$addrUuid2 = newUuid();
|
||||||
|
$conn->executeStatement(
|
||||||
|
'INSERT INTO doctor_addresses (uuid, doctor_id, clinic_id, city_id, province_id, type, name, address, telephone, created_at, updated_at)
|
||||||
|
VALUES (?,?,?,?,?,?,?,?,?,?,?)',
|
||||||
|
[
|
||||||
|
$addrUuid2, $sampleDocId, $clinicId, 101, 1, 'clinic',
|
||||||
|
'کلینیک تخصصی امید تبریز',
|
||||||
|
'تبریز، خیابان ارتش، نبش کوچه سوم، ساختمان امید، طبقه اول',
|
||||||
|
'04133445566',
|
||||||
|
$now, $now,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
$addrId2 = (int) $conn->lastInsertId();
|
||||||
|
|
||||||
|
// clinic_doctors
|
||||||
|
$conn->executeStatement('INSERT IGNORE INTO clinic_doctors (clinic_id, doctor_id) VALUES (?,?)', [$clinicId, $sampleDocId]);
|
||||||
|
|
||||||
|
// برنامه کاری (هر دو مطب — مطب اصلی)
|
||||||
|
$scheduleJson = makeSchedule($addrId1, true);
|
||||||
|
$scheduleUuid = newUuid();
|
||||||
|
$conn->executeStatement(
|
||||||
|
'INSERT INTO weekly_schedules (uuid, doctor_id, setting, created_at, updated_at) VALUES (?,?,?,?,?)',
|
||||||
|
[$scheduleUuid, $sampleDocId, $scheduleJson, $now, $now]
|
||||||
|
);
|
||||||
|
|
||||||
|
// منشی دکتر نمونه
|
||||||
|
$secMobile = nextMobile();
|
||||||
|
$secUserUuid = newUuid();
|
||||||
|
$conn->executeStatement(
|
||||||
|
'INSERT INTO users (uuid, mobile_number, password_hash, real_name, roles, status, created_at, updated_at) VALUES (?,?,?,?,?,1,?,?)',
|
||||||
|
[$secUserUuid, $secMobile, $passwordHash, 'خانم نرگس صادقی', '["ROLE_USER","ROLE_SECRETARY"]', $now, $now]
|
||||||
|
);
|
||||||
|
$secUserId = (int) $conn->lastInsertId();
|
||||||
|
|
||||||
|
$secUuid = newUuid();
|
||||||
|
$conn->executeStatement(
|
||||||
|
'INSERT INTO doctor_secretaries (uuid, doctor_id, secretary_id, active, owner_type, clinic_id, created_at, updated_at) VALUES (?,?,?,1,?,NULL,?,?)',
|
||||||
|
[$secUuid, $sampleDocId, $secUserId, 'doctor', $now, $now]
|
||||||
|
);
|
||||||
|
|
||||||
|
echo "✅ دکتر نمونه (آرمان رضایی) ایجاد شد (id: $sampleDocId)\n";
|
||||||
|
echo " 📍 مطب: id=$addrId1 | کلینیک امید: id=$addrId2\n";
|
||||||
|
echo " 👩💼 منشی: خانم نرگس صادقی (موبایل: $secMobile)\n\n";
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// بخش D — ۱۰۰ دکتر به ازای هر شهر (bulk)
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
$targetCities = [101, 102, 103, 104, 105, 108, 111, 113, 117, 121, 122, 126, 128, 131, 132];
|
||||||
|
$totalCreated = 0;
|
||||||
|
|
||||||
|
foreach ($targetCities as $cityId) {
|
||||||
|
$provincId = $cityProvince[$cityId];
|
||||||
|
$cityName = $cityNames[$cityId];
|
||||||
|
$docCount = 0;
|
||||||
|
|
||||||
|
echo "🏙️ $cityName: ";
|
||||||
|
|
||||||
|
// flat list of 100 doctors from specialty groups
|
||||||
|
$doctorQueue = [];
|
||||||
|
foreach ($specialtyGroups as $group) {
|
||||||
|
for ($n = 0; $n < $group['count']; $n++) {
|
||||||
|
$doctorQueue[] = $group;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$nameIdx = 0;
|
||||||
|
foreach ($doctorQueue as $idx => $group) {
|
||||||
|
// تعیین جنسیت
|
||||||
|
if ($group['gender_mix'] === 'female') {
|
||||||
|
$gender = 'woman';
|
||||||
|
$firstName = $femaleFirstNames[$nameIdx % count($femaleFirstNames)];
|
||||||
|
} elseif ($group['gender_mix'] === 'male') {
|
||||||
|
$gender = 'man';
|
||||||
|
$firstName = $maleFirstNames[$nameIdx % count($maleFirstNames)];
|
||||||
|
} else {
|
||||||
|
$gender = ($idx % 3 === 0) ? 'woman' : 'man';
|
||||||
|
$firstName = ($gender === 'woman')
|
||||||
|
? $femaleFirstNames[$nameIdx % count($femaleFirstNames)]
|
||||||
|
: $maleFirstNames[$nameIdx % count($maleFirstNames)];
|
||||||
|
}
|
||||||
|
$lastName = $lastNames[$nameIdx % count($lastNames)];
|
||||||
|
$nameIdx++;
|
||||||
|
|
||||||
|
$primarySpec = $group['specs'][0];
|
||||||
|
$specLabel = $specNames[$primarySpec] ?? 'پزشک';
|
||||||
|
$degreeLabel = $degreeLabels[$group['degree']] ?? 'متخصص';
|
||||||
|
$title = ($gender === 'woman') ? 'دکتر' : 'دکتر';
|
||||||
|
$fullName = "$title $firstName $lastName";
|
||||||
|
|
||||||
|
$mobile = nextMobile();
|
||||||
|
$userUuid = newUuid();
|
||||||
|
$conn->executeStatement(
|
||||||
|
'INSERT INTO users (uuid, mobile_number, password_hash, real_name, roles, status, created_at, updated_at) VALUES (?,?,?,?,?,1,?,?)',
|
||||||
|
[$userUuid, $mobile, $passwordHash, $fullName, '["ROLE_USER","ROLE_DOCTOR"]', $now, $now]
|
||||||
|
);
|
||||||
|
$userId = (int) $conn->lastInsertId();
|
||||||
|
|
||||||
|
$rate = round(3.5 + mt_rand(0, 15) / 10, 1);
|
||||||
|
$ratePercentage = $rate * 20.0;
|
||||||
|
$startYear = 1995 + mt_rand(0, 20);
|
||||||
|
$medCode = 'IR-' . str_pad((string)(10001 + $totalCreated + $docCount), 6, '0', STR_PAD_LEFT);
|
||||||
|
|
||||||
|
$info = "$degreeLabel $specLabel. فارغالتحصیل دانشگاه علوم پزشکی $cityName. "
|
||||||
|
. "با " . (date('Y') - $startYear) . " سال سابقه بالینی در حوزه $specLabel. "
|
||||||
|
. "پذیرش بیمههای تامین اجتماعی و خدمات درمانی.";
|
||||||
|
|
||||||
|
$docUuid = newUuid();
|
||||||
|
$conn->executeStatement(
|
||||||
|
'INSERT INTO doctors (uuid, user_id, name, gender, medical_system_code, mobile_number, activity_time, degree, info, doctor_rate, doctor_rate_percentage, active_doctor_appointment, representation_id, created_at, updated_at)
|
||||||
|
VALUES (?,?,?,?,?,?,?,?,?,?,?,1,?,?,?)',
|
||||||
|
[
|
||||||
|
$docUuid, $userId, $fullName, $gender, $medCode, $mobile,
|
||||||
|
mktime(0, 0, 0, 1, 1, $startYear),
|
||||||
|
$group['degree'], $info, $rate, $ratePercentage,
|
||||||
|
$cityId,
|
||||||
|
$now, $now,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
$docId = (int) $conn->lastInsertId();
|
||||||
|
|
||||||
|
// تخصصها
|
||||||
|
foreach ($group['specs'] as $specId) {
|
||||||
|
$conn->executeStatement('INSERT IGNORE INTO doctor_specialties (doctor_id, specialty_id) VALUES (?,?)', [$docId, $specId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// خدمات (حداکثر ۳ خدمت)
|
||||||
|
$services = array_slice($group['services'], 0, 3);
|
||||||
|
foreach ($services as $svcId) {
|
||||||
|
$conn->executeStatement('INSERT IGNORE INTO doctor_expertise (doctor_id, service_id) VALUES (?,?)', [$docId, $svcId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// بیمهها: پایه + یک تکمیلی تصادفی
|
||||||
|
$suppInsId = $supplementaryInsurances[mt_rand(0, count($supplementaryInsurances) - 1)];
|
||||||
|
foreach (array_unique([1, 2, $suppInsId]) as $insId) {
|
||||||
|
$conn->executeStatement('INSERT IGNORE INTO doctor_insurances (doctor_id, insurance_id) VALUES (?,?)', [$docId, $insId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// شهر و استان
|
||||||
|
$conn->executeStatement('INSERT IGNORE INTO doctor_cities (doctor_id, city_id) VALUES (?,?)', [$docId, $cityId]);
|
||||||
|
$conn->executeStatement('INSERT IGNORE INTO doctor_provinces (doctor_id, province_id) VALUES (?,?)', [$docId, $provincId]);
|
||||||
|
|
||||||
|
// آدرس مطب
|
||||||
|
$streetNum = mt_rand(1, 50);
|
||||||
|
$buildingNo = mt_rand(1, 30);
|
||||||
|
$addrUuid = newUuid();
|
||||||
|
$conn->executeStatement(
|
||||||
|
'INSERT INTO doctor_addresses (uuid, doctor_id, clinic_id, city_id, province_id, type, name, address, telephone, created_at, updated_at)
|
||||||
|
VALUES (?,?,NULL,?,?,?,?,?,?,?,?)',
|
||||||
|
[
|
||||||
|
$addrUuid, $docId, $cityId, $provincId, 'personal',
|
||||||
|
"مطب $fullName",
|
||||||
|
$cityName . '، خیابان شهید بهشتی، کوچه ' . $streetNum . '، پلاک ' . $buildingNo,
|
||||||
|
'0' . (4100000000 + mt_rand(10000000, 99999999)),
|
||||||
|
$now, $now,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
$addrId = (int) $conn->lastInsertId();
|
||||||
|
|
||||||
|
// برنامه کاری
|
||||||
|
$schedJson = makeSchedule($addrId, $idx % 4 !== 0);
|
||||||
|
$schedUuid = newUuid();
|
||||||
|
$conn->executeStatement(
|
||||||
|
'INSERT INTO weekly_schedules (uuid, doctor_id, setting, created_at, updated_at) VALUES (?,?,?,?,?)',
|
||||||
|
[$schedUuid, $docId, $schedJson, $now, $now]
|
||||||
|
);
|
||||||
|
|
||||||
|
$docCount++;
|
||||||
|
}
|
||||||
|
|
||||||
|
$totalCreated += $docCount;
|
||||||
|
echo "$docCount دکتر ایجاد شد ✅\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "\n✅ پایان — مجموع $totalCreated دکتر در " . count($targetCities) . " شهر ایجاد شد\n";
|
||||||
|
echo "📱 رمز عبور همه کاربران: Test@1234\n";
|
||||||
|
echo "📱 موبایل اول دکتر نمونه: $sampleDocMobile\n";
|
||||||
@@ -171,7 +171,7 @@ class SecretaryController extends BaseController
|
|||||||
return $this->success(['message' => 'منشی غیرفعال شد']);
|
return $this->success(['message' => 'منشی غیرفعال شد']);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** لیست منشیان مطب شخصی یک دکتر */
|
/** لیست منشی ها مطب شخصی یک دکتر */
|
||||||
#[Route('/api/v1/secretaries/{doctorUuid}', methods: ['GET'])]
|
#[Route('/api/v1/secretaries/{doctorUuid}', methods: ['GET'])]
|
||||||
public function list(string $doctorUuid, #[CurrentUser] User $currentUser): JsonResponse
|
public function list(string $doctorUuid, #[CurrentUser] User $currentUser): JsonResponse
|
||||||
{
|
{
|
||||||
@@ -180,7 +180,7 @@ class SecretaryController extends BaseController
|
|||||||
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'دکتر یافت نشد', 404);
|
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'دکتر یافت نشد', 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
// فقط خود دکتر یا ادمین میتوانند منشیان مطب شخصی را ببینند
|
// فقط خود دکتر یا ادمین میتوانند منشی ها مطب شخصی را ببینند
|
||||||
if (!$currentUser->hasRole('ROLE_ADMIN') && $doctor->getUser()->getId() !== $currentUser->getId()) {
|
if (!$currentUser->hasRole('ROLE_ADMIN') && $doctor->getUser()->getId() !== $currentUser->getId()) {
|
||||||
return $this->error(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403);
|
return $this->error(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403);
|
||||||
}
|
}
|
||||||
@@ -193,7 +193,7 @@ class SecretaryController extends BaseController
|
|||||||
return $this->success($secretaries);
|
return $this->success($secretaries);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** لیست همه منشیان کلینیک (از همه دکترها، owner_type='clinic') */
|
/** لیست همه منشی ها کلینیک (از همه دکترها، owner_type='clinic') */
|
||||||
#[Route('/api/v1/secretaries/clinic/{clinicUuid}', methods: ['GET'])]
|
#[Route('/api/v1/secretaries/clinic/{clinicUuid}', methods: ['GET'])]
|
||||||
public function listByClinic(string $clinicUuid, #[CurrentUser] User $currentUser): JsonResponse
|
public function listByClinic(string $clinicUuid, #[CurrentUser] User $currentUser): JsonResponse
|
||||||
{
|
{
|
||||||
@@ -227,7 +227,7 @@ class SecretaryController extends BaseController
|
|||||||
}
|
}
|
||||||
|
|
||||||
if ($secretary->getOwnerType() === DoctorSecretary::OWNER_CLINIC) {
|
if ($secretary->getOwnerType() === DoctorSecretary::OWNER_CLINIC) {
|
||||||
// فقط مالک کلینیک میتواند منشیان کلینیکی را مدیریت کند
|
// فقط مالک کلینیک میتواند منشی ها کلینیکی را مدیریت کند
|
||||||
if (!$user->hasRole('ROLE_CLINIC')) {
|
if (!$user->hasRole('ROLE_CLINIC')) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ class DoctorSecretaryRepository extends ServiceEntityRepository
|
|||||||
return $this->findBy(['doctor' => $doctor], ['createdAt' => 'DESC']);
|
return $this->findBy(['doctor' => $doctor], ['createdAt' => 'DESC']);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** منشیان مطب شخصی یک دکتر (owner_type='doctor') */
|
/** منشی ها مطب شخصی یک دکتر (owner_type='doctor') */
|
||||||
public function findByDoctorScope(Doctor $doctor): array
|
public function findByDoctorScope(Doctor $doctor): array
|
||||||
{
|
{
|
||||||
return $this->findBy(
|
return $this->findBy(
|
||||||
@@ -108,7 +108,7 @@ class DoctorSecretaryRepository extends ServiceEntityRepository
|
|||||||
return $clinic->getDoctors()->contains($doctor);
|
return $clinic->getDoctors()->contains($doctor);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** همه منشیان کلینیک (owner_type='clinic') */
|
/** همه منشی ها کلینیک (owner_type='clinic') */
|
||||||
public function findByClinic(Clinic $clinic): array
|
public function findByClinic(Clinic $clinic): array
|
||||||
{
|
{
|
||||||
return $this->createQueryBuilder('s')
|
return $this->createQueryBuilder('s')
|
||||||
|
|||||||
Reference in New Issue
Block a user