diff --git a/.claude/prompt/multi-role-dashboard.md b/.claude/prompt/multi-role-dashboard.md new file mode 100644 index 00000000..eaa5e522 --- /dev/null +++ b/.claude/prompt/multi-role-dashboard.md @@ -0,0 +1,447 @@ +# پرامپت: داشبورد چند-نقشه (Multi-Role Dashboard) + +## هدف کلی + +پنل `/admin/` باید علاوه بر ادمین، برای نقش‌های زیر نیز کار کند — هر نقش فقط بخش‌هایی می‌بیند که به آن دسترسی دارد: + +| نقش | نام فارسی | ROLE در Symfony | +|-----|-----------|-----------------| +| ادمین سیستم | مدیر کل | `ROLE_ADMIN` | +| صاحب کلینیک | مالک کلینیک | `ROLE_CLINIC` | +| دکتر عضو کلینیک | پزشک | `ROLE_DOCTOR` | +| منشی | منشی | `ROLE_SECRETARY` | + +--- + +## وضعیت فعلی (مهم — قبل از تغییر بخوان) + +### بک‌اند +- کلاس `AdminApiController` با `#[IsGranted('ROLE_ADMIN')]` روی کل کلاس — تمام endpoint های داشبورد فعلی فقط برای ادمین +- موجودیت‌ها: + - `User`: فیلد `roles: array` — مقادیر ممکن: `ROLE_USER`, `ROLE_ADMIN`, `ROLE_DOCTOR`, `ROLE_CLINIC`, `ROLE_SECRETARY` + - `Clinic`: فیلد `user` (ManyToOne به User) — صاحب کلینیک. رابطه ManyToMany با `Doctor` از طریق جدول `clinic_doctors` + - `Doctor`: فیلد `user` (OneToOne به User). دارای `mobileNumber` و رابطه با `Specialty` + - `DoctorSecretary`: فیلد `doctor` (ManyToOne)، `secretary` (ManyToOne به User)، `permissions` (JSON): + ``` + { version:1, resources: { + appointments: { view, create, cancel, update_status }, + addresses: { view, create, update, delete }, + clinic_info: { view, update }, + insurances: { view, create, update, delete } + }} + ``` +- JWT: از LexikJWTBundle — payload شامل: `username` (mobile_number)، `roles` (آرایه)، `iat`، `exp` +- endpoint های فعلی داشبورد: + - `GET /api/v1/admin/dashboard/stats` → ۱۲ KPI عمومی + - `GET /api/v1/admin/dashboard/charts` → نمودار ۳۰ روزه + - `GET /api/v1/admin/dashboard/recent` → آخرین نوبت‌ها، پرداخت‌ها، کاربران + - همه با `ROLE_ADMIN` + +### فرانت‌اند +- `authStore.ts` (Zustand + persist در `clinicpro-auth`): فقط `token`، `refreshToken`، `isAuthenticated` +- `Sidebar.tsx`: لیست ثابت — بدون هیچ فیلتر نقشی +- `App.tsx`: همه routes با `PrivateRoute` (فقط isAuthenticated بررسی می‌شود) +- `DashboardPage.tsx`: سه query + نمودار Recharts + mini lists فعلی + +--- + +## مرحله ۱ — endpoint شناسایی کاربر (بک‌اند) + +### فایل جدید: `src/Auth/Controller/MeController.php` + +``` +GET /api/v1/me [IS_AUTHENTICATED_FULLY] +``` + +پاسخ: +```json +{ + "success": true, + "data": { + "uuid": "...", + "mobile": "09...", + "name": "دکتر علی ...", + "roles": ["ROLE_USER", "ROLE_DOCTOR"], + "primary_role": "doctor", + "context": { + "doctor_uuid": "...", + "doctor_name": "دکتر علی احمدی" + } + } +} +``` + +**قانون `primary_role`** (اولویت‌بندی): +- `ROLE_ADMIN` → `"admin"` +- `ROLE_CLINIC` → `"clinic"` +- `ROLE_DOCTOR` → `"doctor"` +- `ROLE_SECRETARY` → `"secretary"` +- بقیه → `"user"` + +**پر کردن `context`**: +- `ROLE_DOCTOR`: از `DoctorRepository::findByUser($user)` → `{doctor_uuid, doctor_name}` +- `ROLE_CLINIC`: از `ClinicRepository::findOneBy(['user' => $user])` → `{clinic_uuid, clinic_name, clinic_logo}` +- `ROLE_SECRETARY`: از `DoctorSecretaryRepository::findActiveBySecretary($user)` (متد جدید) → `{doctor_uuid, doctor_name, secretary_uuid, permissions}` +- اگر موجودیت پیدا نشد: `context: null` + +**متد جدید در `DoctorSecretaryRepository`**: +```php +public function findActiveBySecretary(User $user): ?DoctorSecretary +{ + return $this->findOneBy(['secretary' => $user, 'active' => true]); +} +``` + +**security.yaml** — endpoint `/api/v1/me` را به firewall `api` اضافه کن (نه public_endpoints، چون نیاز به احراز هویت دارد — فایروال `api` آن را پوشش می‌دهد). + +--- + +## مرحله ۲ — به‌روز کردن `authStore.ts` (فرانت‌اند) + +```typescript +// assets/admin/stores/authStore.ts + +interface AuthState { + token: string | null; + refreshToken: string | null; + isAuthenticated: boolean; + // فیلدهای جدید: + userUuid: string | null; + userName: string | null; + primaryRole: 'admin' | 'clinic' | 'doctor' | 'secretary' | 'user' | null; + context: Record | null; +} +``` + +- متد `login(token, refreshToken)`: بعد از ذخیره token، یک `GET /api/v1/me` بزند و نتیجه را ذخیره کند +- متد `logout()`: همه فیلدها را پاک کند +- متد جدید `fetchMe()`: `GET /api/v1/me` و update store — در `App.tsx` هنگام mount فراخوانی شود (اگر token موجود بود اما `primaryRole` خالی بود، تا بعد از reload صفحه role بازیابی شود) + +--- + +## مرحله ۳ — محافظت route ها (فرانت‌اند) + +### در `App.tsx`، کامپوننت `RoleRoute` اضافه کن: + +```tsx +function RoleRoute({ roles, children }: { roles: string[]; children: ReactNode }) { + const primaryRole = useAuthStore(s => s.primaryRole); + if (!primaryRole) return
در حال بارگذاری...
; + if (!roles.includes(primaryRole)) return ; + return <>{children}; +} +``` + +**Route های فقط ادمین** (با `` بپوشان): +- `/admin/users`, `/admin/users/:uuid` +- `/admin/payments` +- `/admin/settlements` +- `/admin/representations`, `/admin/representations/:uuid` +- `/admin/comments` +- `/admin/ratings` +- `/admin/sms` +- `/admin/categories` +- `/admin/blogs`, `/admin/blogs/new`, `/admin/blogs/:uuid/edit` +- `/admin/secretaries` +- `/admin/clinics` (لیست کل کلینیک‌ها) + +**Route های admin + clinic**: +- `/admin/clinics/:uuid` — ادمین همه را می‌بیند، clinic فقط کلینیک خودش را +- `/admin/doctors` — ادمین همه، clinic فقط پزشکان کلینیکش + +**Route های مشترک همه نقش‌ها**: +- `/admin/dashboard` +- `/admin/appointments`, `/admin/appointments/:uuid` + +**Route های جدید**: +- `/admin/my-clinic` → `` — فقط `ROLE_CLINIC` + +--- + +## مرحله ۴ — Sidebar پویا (فرانت‌اند) + +### فایل `assets/admin/components/layout/Sidebar.tsx` + +ساختار `sections` را به یک تابع تبدیل کن که `primaryRole` و `context` می‌گیرد: + +```tsx +function buildSections( + primaryRole: string | null, + context: Record | null +): Section[] +``` + +#### ادمین — همه لینک‌های فعلی (بدون تغییر) + +#### صاحب کلینیک (`clinic`): +``` +عمومی: + • داشبورد /admin/dashboard + • کلینیک من /admin/my-clinic + • پزشکان /admin/doctors + +مدیریت: + • نوبت‌ها /admin/appointments +``` + +#### دکتر (`doctor`): +``` +عمومی: + • داشبورد /admin/dashboard + +مدیریت: + • نوبت‌های من /admin/appointments +``` + +#### منشی (`secretary`) — بر اساس `context.permissions.resources`: +``` +عمومی: + • داشبورد /admin/dashboard + +مدیریت (شرطی): + • نوبت‌ها /admin/appointments ← اگر appointments.view = true +``` + +در Sidebar، permissions را از `useAuthStore(s => s.context)` بخوان. + +--- + +## مرحله ۵ — endpoint های داشبورد جدید (بک‌اند) + +### فایل جدید: `src/Dashboard/Controller/DashboardController.php` + +سه endpoint جداگانه — هر سه از `BaseController` extend می‌کنند: + +--- + +### `GET /api/v1/dashboard/clinic` `[ROLE_CLINIC]` + +پاسخ: +```json +{ + "clinic": { "uuid":"...", "name":"...", "is_active": true, "logo":"..." }, + "stats": { + "total_doctors": 5, + "today_appointments": 12, + "this_month_appointments": 87, + "pending_invitations": 2 + }, + "today_appointments": [ + { "uuid":"...", "patient_name":"...", "doctor_name":"...", "slot_start": 1234567890, "status":"reserved" } + ], + "doctors": [ + { "uuid":"...", "name":"دکتر ...", "specialty":"...", "today_count": 3 } + ] +} +``` + +پیاده‌سازی: +- کلینیک را از `ClinicRepository::findOneBy(['user' => $user])` بگیر +- اگر نبود: `throw new AppException('ERR_NOT_FOUND_001', 'کلینیک یافت نشد', 404)` +- `today_appointments` و `this_month_appointments`: از جدول `appointments` با JOIN به `clinic_doctors` فیلتر کن +- `pending_invitations`: از `clinic_doctor_invitations` با `status='pending'` بشمار +- `today_appointments` لیست: ۵ نوبت اخیر امروز این کلینیک (از طریق JOIN `clinic_doctors`) +- `doctors`: لیست پزشکان کلینیک با شمارش نوبت امروز آن‌ها + +--- + +### `GET /api/v1/dashboard/doctor` `[ROLE_DOCTOR]` + +پاسخ: +```json +{ + "doctor": { "uuid":"...", "name":"...", "degree":"...", "profile_image":"..." }, + "stats": { + "today_appointments": 5, + "tomorrow_appointments": 3, + "this_month_appointments": 42, + "avg_rating": 4.7, + "total_ratings": 18 + }, + "today_appointments": [ + { "uuid":"...", "patient_name":"...", "patient_mobile":"...", "slot_start": 1234567890, "status":"reserved" } + ], + "clinics": [ + { "uuid":"...", "name":"کلینیک ...", "logo":"..." } + ] +} +``` + +پیاده‌سازی: +- دکتر از `DoctorRepository::findByUser($user)` — اگر نبود `404` +- `today_appointments`: نوبت‌های این دکتر با `slot_start` در بازه ابتدا تا انتهای امروز +- `tomorrow_appointments`: همان برای فردا +- `avg_rating`: AVG(overall) از جدول `ratings` برای این دکتر +- `clinics`: کلینیک‌هایی که این دکتر در `clinic_doctors` آن‌هاست + +--- + +### `GET /api/v1/dashboard/secretary` `[ROLE_SECRETARY]` + +پاسخ: +```json +{ + "doctor": { "uuid":"...", "name":"...", "degree":"..." }, + "permissions": { ... }, + "stats": { + "today_appointments": 4, + "tomorrow_appointments": 2 + }, + "today_appointments": [ + { "uuid":"...", "patient_name":"...", "patient_mobile":"...", "slot_start": 1234567890, "status":"reserved" } + ] +} +``` + +پیاده‌سازی: +- از `DoctorSecretaryRepository::findActiveBySecretary($user)` اولین رابطه فعال بگیر +- اگر نبود: `throw new AppException('ERR_FORBIDDEN_001', 'دسترسی منشی تنظیم نشده', 403)` +- بررسی `appointments.view = true` در permissions — اگر false بود، `today_appointments` آرایه خالی برگردان +- نوبت‌های دکتر مربوطه را برگردان + +--- + +## مرحله ۶ — DashboardPage.tsx چند-نقشه (فرانت‌اند) + +فایل `assets/admin/pages/DashboardPage.tsx` را به این شکل بازنویسی کن: + +```tsx +export default function DashboardPage() { + const primaryRole = useAuthStore(s => s.primaryRole); + + if (!primaryRole) return ; + if (primaryRole === 'admin') return ; + if (primaryRole === 'clinic') return ; + if (primaryRole === 'doctor') return ; + if (primaryRole === 'secretary') return ; + return

نقش شما برای داشبورد تعریف نشده

; +} +``` + +**AdminDashboard**: کد فعلی DashboardPage عیناً — فقط در یک تابع بپیچ + +**ClinicDashboard**: +- یک query به `/api/v1/dashboard/clinic` +- ۴ کارت KPI: تعداد پزشکان / نوبت امروز / نوبت این ماه / دعوتنامه در انتظار +- جدول نوبت‌های امروز (ستون: بیمار، پزشک، زمان، وضعیت) +- لیست پزشکان با تعداد نوبت امروز +- دکمه "مدیریت کلینیک" → navigate به `/admin/my-clinic` + +**DoctorDashboard**: +- یک query به `/api/v1/dashboard/doctor` +- ۴ کارت KPI: نوبت امروز / فردا / این ماه / میانگین امتیاز (با ستاره) +- جدول نوبت‌های امروز (ستون: بیمار، موبایل `dir="ltr"`, زمان، وضعیت) +- لیست کلینیک‌های عضو به شکل badge + +**SecretaryDashboard**: +- یک query به `/api/v1/dashboard/secretary` +- نام دکتر مربوطه در header کارت +- ۲ کارت KPI: نوبت امروز / فردا +- جدول نوبت‌های امروز +- لیست مجوزهای فعال با آیکون ✓ + +--- + +## مرحله ۷ — صفحه "کلینیک من" (فرانت‌اند) + +### فایل جدید: `assets/admin/pages/MyClinicPage.tsx` + +```tsx +export default function MyClinicPage() { + const context = useAuthStore(s => s.context); + const clinicUuid = context?.clinic_uuid; + + if (!clinicUuid) return ( +
+

کلینیک شما هنوز ثبت نشده است.

+
+ ); + + // همان محتوای ClinicDetailPage — اما uuid از context + // دکمه "حذف کلینیک" نشان داده نشود + // بقیه همه فعال: ویرایش، تغییر وضعیت، آپلود لوگو، گالری، دعوت پزشک +} +``` + +بهترین رویکرد: کد مشترک را از `ClinicDetailPage.tsx` در یک کامپوننت `ClinicDetailView` جدا کن که `uuid` و `showDeleteButton` را به عنوان prop می‌گیرد. هر دو صفحه از آن استفاده کنند. + +--- + +## مرحله ۸ — نوبت‌های فیلترشده (بک‌اند + فرانت‌اند) + +### بک‌اند — endpoint جدید: `GET /api/v1/my/appointments` `[IS_AUTHENTICATED_FULLY]` + +در یک Controller جدید یا در `AppointmentController`: + +``` +GET /api/v1/my/appointments?page=1&limit=15&status=...&search=... +``` + +بر اساس نقش فیلتر: +- `ROLE_ADMIN`: همه نوبت‌ها (redirect به `/api/v1/admin/appointments`) +- `ROLE_CLINIC`: نوبت‌هایی که doctor آن در `clinic_doctors` این کلینیک است +- `ROLE_DOCTOR`: نوبت‌های این دکتر +- `ROLE_SECRETARY`: نوبت‌های دکتری که این منشی به آن وصل است (اگر `appointments.view = true`) + +پاسخ: همان فرمت `paginated()` موجود. + +### فرانت‌اند — `AppointmentsPage.tsx` + +```tsx +const primaryRole = useAuthStore(s => s.primaryRole); +const endpoint = primaryRole === 'admin' + ? `/api/v1/admin/appointments?...` + : `/api/v1/my/appointments?...`; +``` + +--- + +## نکات مهم پیاده‌سازی + +### CSS / UI — فقط template CSS +- `.card`, `.card-pad`, `.badge.green/.blue/.amber/.violet/.gray` +- `.btn.primary/.ghost/.soft/.sm` +- `.skeleton` برای loading +- `.empty` برای حالت خالی +- گرادیان آواتار: `HUES_LIST = [256, 205, 162, 295, 272]` با OKLCH: + `background: \`linear-gradient(145deg, oklch(0.62 0.15 ${hue}), oklch(0.48 0.16 ${hue}))\`` +- هیچ Tailwind نیست + +### پاسخ‌های API +- `$this->success($data)` → `{ success, data: $data }` — برای single resource +- `$this->paginated($items, $total, $page, $limit)` → `{ success, data: $items[], meta: {...} }` +- `$this->error(...)` → `{ success:false, errors:[...] }` + +### ترتیب اجرا (پیشنهادی) +1. `MeController` + بک‌اند test با curl +2. `authStore.ts` — اضافه کردن fetchMe + فیلدهای جدید +3. `App.tsx` — fetchMe در mount +4. `DashboardController` — هر سه endpoint +5. `DashboardPage.tsx` — sub-dashboardها +6. `Sidebar.tsx` — پویا +7. `App.tsx` — RoleRoute +8. `MyClinicPage.tsx` +9. `AppointmentsPage.tsx` — فیلتر endpoint + +بعد از هر مرحله: `ddev exec php bin/console cache:clear` و `ddev exec yarn dev` + +--- + +## خلاصه فایل‌های جدید/تغییریافته + +### بک‌اند (جدید) +- `src/Auth/Controller/MeController.php` +- `src/Dashboard/Controller/DashboardController.php` + +### بک‌اند (تغییر) +- `src/Secretary/Repository/DoctorSecretaryRepository.php` — اضافه: `findActiveBySecretary()` + +### فرانت‌اند (تغییر) +- `assets/admin/stores/authStore.ts` — اضافه: primaryRole، context، fetchMe() +- `assets/admin/App.tsx` — اضافه: RoleRoute، fetchMe در mount، route های جدید +- `assets/admin/components/layout/Sidebar.tsx` — تبدیل به پویا +- `assets/admin/pages/DashboardPage.tsx` — multi-role +- `assets/admin/pages/AppointmentsPage.tsx` — endpoint پویا + +### فرانت‌اند (جدید) +- `assets/admin/pages/MyClinicPage.tsx` diff --git a/.claude/skills/add-admin-endpoint/SKILL.md b/.claude/skills/add-admin-endpoint/SKILL.md new file mode 100644 index 00000000..264a27aa --- /dev/null +++ b/.claude/skills/add-admin-endpoint/SKILL.md @@ -0,0 +1,69 @@ +--- +name: add-admin-endpoint +description: Add a new paginated admin API endpoint to AdminApiController. Use when the user wants to add a backend admin list, stats, or action endpoint — things like "add an endpoint for X", "create an admin API for Y", "I need a route that lists Z". +--- + +## Target file +`src/Admin/Controller/AdminApiController.php` + +All admin endpoints live here. The class already has `#[IsGranted('ROLE_ADMIN')]` and injects `EntityManagerInterface $em`. + +## Checklist + +1. **Stats endpoint** (optional but standard): a separate `#[Route('/api/v1/admin/{entity}/stats')]` method that returns counts via raw SQL (`$this->em->getConnection()->fetchOne()`). Return with `$this->success([...])`. + +2. **List endpoint**: use QueryBuilder with `->getArrayResult()` — never load full entities for list queries (entity getters may not exist for all fields). Pattern: + +```php +#[Route('/api/v1/admin/{entities}', methods: ['GET'])] +public function list{Entity}(Request $request): JsonResponse +{ + $page = max(1, (int) $request->query->get('page', 1)); + $limit = min(100, max(1, (int) $request->query->get('limit', 15))); + $search = trim((string) $request->query->get('search', '')); + + $qb = $this->em->createQueryBuilder() + ->select('e.id, e.uuid, e.someField, e.createdAt') + ->from(SomeEntity::class, 'e'); + + if ($search !== '') { + $qb->andWhere('e.name LIKE :s')->setParameter('s', "%$search%"); + } + + $total = (clone $qb)->select('COUNT(e.id)')->getQuery()->getSingleScalarResult(); + + $items = $qb + ->orderBy('e.id', 'DESC') + ->setFirstResult(($page - 1) * $limit) + ->setMaxResults($limit) + ->getQuery() + ->getArrayResult(); + + return $this->paginated($items, (int) $total, $page, $limit); +} +``` + +3. **Action endpoints** (toggle status, etc.) follow this shape: + +```php +#[Route('/api/v1/admin/{entities}/{uuid}/status', methods: ['POST'])] +public function toggle{Entity}Status(string $uuid): JsonResponse +{ + $entity = $this->em->getRepository(SomeEntity::class)->findOneBy(['uuid' => $uuid]); + if (!$entity) return $this->error('NOT_FOUND', 'Entity not found', 404); + + $entity->setIsActive(!$entity->getIsActive()); + $this->em->flush(); + + return $this->success(['is_active' => $entity->getIsActive()]); +} +``` + +## Critical rules + +- **Always use `getArrayResult()`** for list queries. Never call entity getters inside admin list methods. +- `createdAt` and `updatedAt` are Unix integer timestamps — do not format them in PHP, let the frontend handle it. +- For JOINs to categories (city, state, specialty), use LEFT JOIN in DQL and select the name field directly into the array result. +- Response shape for lists: `$this->paginated($items, $total, $page, $limit)` — frontend reads `data?.data` for items and `data?.meta?.totalRecords` for count. +- Add `use` imports for any new entity class at the top of the file. +- Run `/sync-db` only if a new entity or column was added as part of this change. diff --git a/.claude/skills/add-admin-page/SKILL.md b/.claude/skills/add-admin-page/SKILL.md new file mode 100644 index 00000000..50efea37 --- /dev/null +++ b/.claude/skills/add-admin-page/SKILL.md @@ -0,0 +1,220 @@ +--- +name: add-admin-page +description: Add a new admin React page to the frontend. Use when the user wants a new admin panel page — list views, detail pages, management UIs. Triggers on "add a page for X", "create an admin page", "I need a UI for managing Y", "build the frontend for Z". +--- + +## Files to create/modify + +| Action | Path | +|--------|------| +| Create | `assets/admin/pages/{Name}Page.tsx` | +| Edit | `assets/admin/types/index.ts` — add the TypeScript interface | +| Edit | `assets/admin/App.tsx` — add the route | + +## Step 1 — Add the TypeScript type + +Add to `assets/admin/types/index.ts`: + +```ts +export interface SomeName { + uuid: string; + // ... fields matching the backend array result +} +``` + +## Step 2 — Register the route + +In `assets/admin/App.tsx`, add inside the `` routes block: + +```tsx +import SomeNamePage from './pages/SomeNamePage'; +// ... +} /> +} /> {/* if detail page needed */} +``` + +## Step 3 — Create the page + +Standard list page pattern: + +```tsx +import React, { useState, useEffect } from 'react'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { useNavigate } from 'react-router-dom'; +import { MagnifyingGlassIcon, PlusIcon, EyeIcon, TrashIcon, ArrowPathIcon, CheckCircleIcon, XCircleIcon } from '@heroicons/react/24/outline'; +import { toast } from 'sonner'; +import { api } from '../lib/api'; +import type { ApiResponse, PaginatedResponse } from '../lib/api'; +import { formatDate } from '../lib/utils'; +import ConfirmDialog from '../components/ui/ConfirmDialog'; +import Pagination from '../components/ui/Pagination'; +import type { SomeName } from '../types'; + +export default function SomeNamePage() { + const navigate = useNavigate(); + const qc = useQueryClient(); + const [page, setPage] = useState(1); + const [limit] = useState(15); + const [searchInput, setSearchInput] = useState(''); + const [search, setSearch] = useState(''); + const [deleteTarget, setDeleteTarget] = useState(null); + + // Debounced search — always 350 ms + useEffect(() => { + const t = setTimeout(() => { setSearch(searchInput); setPage(1); }, 350); + return () => clearTimeout(t); + }, [searchInput]); + + // List query + const listQ = useQuery({ + queryKey: ['admin-some-names', page, limit, search], + queryFn: () => { + const p = new URLSearchParams({ page: String(page), limit: String(limit) }); + if (search) p.set('search', search); + return api.get>(`/api/v1/admin/some-names?${p}`); + }, + }); + + const items = listQ.data?.data ?? []; + const total = listQ.data?.meta?.totalRecords ?? 0; + + // Stats query (if stats endpoint exists) + const statsQ = useQuery({ + queryKey: ['admin-some-names-stats'], + queryFn: () => api.get>('/api/v1/admin/some-names/stats'), + staleTime: 30_000, + }); + // IMPORTANT: stats data may be double-nested depending on backend shape. + // Use this pattern to handle both cases: + const stats = (statsQ.data?.data as any)?.data ?? statsQ.data?.data; + + // Delete mutation + const deleteMut = useMutation({ + mutationFn: (uuid: string) => api.delete>(`/api/v1/some-names/${uuid}`), + onSuccess: () => { + toast.success('حذف شد'); + setDeleteTarget(null); + qc.invalidateQueries({ queryKey: ['admin-some-names'] }); + }, + onError: (e: Error) => toast.error(e.message), + }); + + // Toggle status mutation + const toggleMut = useMutation({ + mutationFn: (uuid: string) => api.post>(`/api/v1/admin/some-names/${uuid}/status`, {}), + onSuccess: () => { + toast.success('وضعیت تغییر کرد'); + qc.invalidateQueries({ queryKey: ['admin-some-names'] }); + }, + onError: (e: Error) => toast.error(e.message), + }); + + return ( +
+ {/* Header */} +
+
+

عنوان صفحه

+
توضیح کوتاه
+
+ +
+ + {/* KPI cards — only if stats endpoint exists */} + {/*
...
*/} + + {/* Main card */} +
+ {/* Toolbar */} +
+
+
+ + setSearchInput(e.target.value)} placeholder="جستجو..." /> +
+
+ +
+
+ + {/* Table */} +
+ + + + + + + + + + + {listQ.isLoading && Array.from({ length: 5 }).map((_, i) => ( + {Array.from({ length: 4 }).map((_, j) => ( + + ))} + ))} + {!listQ.isLoading && items.length === 0 && ( + + )} + {!listQ.isLoading && items.map((item) => ( + navigate(`/admin/some-names/${item.uuid}`)}> + + + + + + ))} + +
ناموضعیتتاریخ
موردی یافت نشد
{/* render fields */} + + + {(item as any).is_active ? 'فعال' : 'غیرفعال'} + + {formatDate((item as any).created_at)} e.stopPropagation()}> +
+ + + +
+
+
+ + +
+ + deleteTarget && deleteMut.mutate(deleteTarget.uuid)} + onCancel={() => setDeleteTarget(null)} + /> +
+ ); +} +``` + +## Critical rules + +- **Never use `data?.data?.data`** unless the endpoint is a `$this->success(['data' => ...])` double-nest. Standard `$this->success($array)` → extract with `data?.data`. `$this->paginated()` → items at `data?.data`, total at `data?.meta?.totalRecords`. +- Stats from `$this->success($stats)` may still be double-nested in older endpoints — use `(statsQ.data?.data as any)?.data ?? statsQ.data?.data` to handle both. +- Category API (`/api/v1/categorys/{bundle}`) is always triple-nested: extract with `data?.data?.data ?? []`. +- Search debounce is always 350ms via `setTimeout` in a `useEffect`. +- Query keys follow the format `['admin-entity-name', page, limit, search, ...filters]`. +- After creating the page, run `ddev exec yarn dev` to check for TypeScript errors. diff --git a/.claude/skills/sync-db/SKILL.md b/.claude/skills/sync-db/SKILL.md new file mode 100644 index 00000000..2c62431a --- /dev/null +++ b/.claude/skills/sync-db/SKILL.md @@ -0,0 +1,15 @@ +--- +name: sync-db +description: Run the standard Doctrine migration cycle after any entity change. Use whenever an entity is added or modified, a new column/relation is needed, or the user says "migrate", "sync the database", "generate migration", or asks to apply schema changes. +disable-model-invocation: true +--- + +Run these three commands in sequence and report the output of each step: + +```bash +ddev exec php bin/console doctrine:migrations:diff --no-interaction +ddev exec php bin/console doctrine:migrations:migrate --no-interaction +ddev exec php bin/console cache:clear +``` + +If `migrations:diff` reports "No changes detected", skip `migrations:migrate` and say so. If any step fails, stop and show the full error output. diff --git a/README.MD b/README.MD index b8d6245e..06270681 100644 --- a/README.MD +++ b/README.MD @@ -1,6 +1,6 @@ # ClinicPro — مستندات کامل پروژه -> **تاریخ آخرین به‌روزرسانی:** ۱۴۰۵/۰۳/۲۰ | **نسخه Symfony:** 7.4 | **PHP:** ≥ 8.2 +> **آخرین به‌روزرسانی:** ۱۴۰۵/۰۳/۲۱ | **Symfony:** 7.x | **PHP:** ≥ 8.2 | **React:** 19 --- @@ -8,1028 +8,775 @@ 1. [معرفی پروژه](#۱-معرفی-پروژه) 2. [تکنولوژی Stack](#۲-تکنولوژی-stack) -3. [ساختار دایرکتوری](#۳-ساختار-دایرکتوری) -4. [راه‌اندازی محیط توسعه](#۴-راه‌اندازی-محیط-توسعه) +3. [راه‌اندازی محیط توسعه](#۳-راه‌اندازی-محیط-توسعه) +4. [آدرس‌های مهم](#۴-آدرس‌های-مهم) 5. [متغیرهای محیطی](#۵-متغیرهای-محیطی) -6. [پایگاه داده — موجودیت‌ها](#۶-پایگاه-داده--موجودیت‌ها) -7. [API Endpoints](#۷-api-endpoints) -8. [امنیت و احراز هویت](#۸-امنیت-و-احراز-هویت) -9. [پنل ادمین React](#۹-پنل-ادمین-react) -10. [Frontend Build](#۱۰-frontend-build) -11. [دستورات Console](#۱۱-دستورات-console) -12. [Migrations](#۱۲-migrations) -13. [سرویس‌های خارجی](#۱۳-سرویس‌های-خارجی) +6. [دستورات Console](#۶-دستورات-console) +7. [امنیت و احراز هویت](#۷-امنیت-و-احراز-هویت) +8. [پایگاه داده — جداول و موجودیت‌ها](#۸-پایگاه-داده--جداول-و-موجودیت‌ها) +9. [API Endpoints](#۹-api-endpoints) +10. [پنل ادمین React](#۱۰-پنل-ادمین-react) +11. [Migrations](#۱۱-migrations) +12. [سرویس‌های خارجی](#۱۲-سرویس‌های-خارجی) +13. [ساختار دایرکتوری](#۱۳-ساختار-دایرکتوری) --- ## ۱. معرفی پروژه -**ClinicPro** یک سیستم جامع مدیریت کلینیک و نوبت‌دهی پزشکی است که شامل: +**ClinicPro** سیستم جامع مدیریت کلینیک و نوبت‌دهی پزشکی است — مهاجرت‌یافته از Drupal به **Symfony 7**. شامل یک REST API کامل و یک پنل ادمین React SPA داخل Symfony. -- **API Backend:** Symfony 7.4 (REST API + JWT Auth) -- **Admin Panel:** React 19 + TypeScript (SPA داخل Symfony) -- **ویژگی‌های اصلی:** - - مدیریت پزشکان، کلینیک‌ها و کاربران - - سیستم نوبت‌دهی با مدیریت اسلات زمانی - - درگاه پرداخت (ملت و سپ) - - سیستم کیف پول و تسویه‌حساب - - نمایندگان (Representation) با کمیسیون - - احراز هویت OTP + رمز عبور - - مدیریت منشی با دسترسی‌های دانه‌ای - - ارسال پیامک (KaveNegar / Rangineh) - - امتیاز و نظرات پزشکان - - بلاگ - - مستندات API تعاملی (Swagger UI) — محافظت‌شده با HTTP Basic Auth +### ویژگی‌های اصلی + +- احراز هویت OTP + رمز عبور + JWT +- مدیریت پزشکان با پروفایل کامل، تخصص، آدرس‌های مطب +- مدیریت کلینیک‌ها با گالری، نقشه، دعوت پزشک +- سیستم نوبت‌دهی با اسلات هفتگی، روزهای استثناء، تعطیلات +- درگاه پرداخت (ملت + سپ) با کیف پول و تسویه‌حساب +- مدیریت نمایندگان (agents) با کمیسیون +- منشی با دسترسی‌های دانه‌ای (fine-grained permissions) +- سیستم دعوتنامه پزشک به کلینیک با توکن ۷۲ ساعته +- ارسال پیامک async از طریق KaveNegar / Rangineh +- امتیاز و نظرات پزشکان +- بلاگ با مدیریت کامل +- داشبورد آمار real-time +- Swagger UI محافظت‌شده با HTTP Basic Auth --- ## ۲. تکنولوژی Stack ### Backend + | لایه | تکنولوژی | |------|----------| -| Framework | Symfony 7.4 | -| PHP | >= 8.2 | -| ORM | Doctrine ORM 3.6 | -| Auth | JWT (lexik/jwt-authentication-bundle) | +| Framework | Symfony 7.x | +| PHP | ≥ 8.2 | +| ORM | Doctrine ORM | +| Auth | LexikJWT + OTP | | Queue | Symfony Messenger | -| Cache/Session | Redis | +| Cache | Redis | | Database | MariaDB 11.8 | -| API Docs | NelmioApiDocBundle v5 + swagger-php v6 | +| API Docs | NelmioApiDocBundle + swagger-php | | Rate Limiting | Symfony Rate Limiter | -### Frontend (Admin) +### Frontend (Admin SPA) + | لایه | تکنولوژی | |------|----------| | Framework | React 19 + TypeScript | | Routing | React Router DOM v7 | -| Styling | Tailwind CSS v4 | -| State (server) | TanStack Query v5 | -| State (client) | Zustand v5 | +| Server State | TanStack Query v5 | +| Client State | Zustand v5 (persist) | | Forms | React Hook Form + Zod | +| Charts | Recharts | +| Maps | React Leaflet 5 | | Icons | Heroicons v2 | -| Toast | Sonner | -| Tables | TanStack Table v8 | -| Build | Webpack Encore (Symfony UX React) | +| Notifications | Sonner | +| Date | jalaali-js | +| Build | Webpack Encore 6 | --- -## ۳. ساختار دایرکتوری - -``` -clinic-pro-symfony/ -├── assets/ -│ ├── admin/ ← React Admin SPA -│ │ ├── index.tsx ← Entry point -│ │ ├── App.tsx ← Router + PrivateRoute -│ │ ├── styles.css ← Tailwind CSS v4 -│ │ ├── components/ -│ │ │ └── layout/ -│ │ │ ├── AdminLayout.tsx -│ │ │ ├── Sidebar.tsx -│ │ │ └── Topbar.tsx -│ │ ├── pages/ ← ۲۲ صفحه پیاده‌سازی‌شده -│ │ ├── stores/ -│ │ │ ├── authStore.ts ← Zustand JWT store -│ │ │ └── uiStore.ts ← Sidebar state -│ │ ├── hooks/ -│ │ └── lib/ -│ ├── app.js ← Symfony main entry -│ ├── styles/app.css -│ └── react/controllers/ ← UX React controllers -│ -├── config/ -│ ├── packages/ -│ │ ├── security.yaml ← Firewalls + ACL -│ │ ├── doctrine.yaml -│ │ ├── lexik_jwt_authentication.yaml -│ │ ├── messenger.yaml -│ │ ├── nelmio_api_doc.yaml ← Swagger config -│ │ ├── rate_limiter.yaml -│ │ └── webpack_encore.yaml -│ └── routes/ -│ ├── security.yaml -│ └── nelmio_api_doc.yaml -│ -├── migrations/ ← 17 Doctrine migrations -│ -├── public/ -│ ├── index.php -│ ├── build/ ← Webpack output -│ └── bundles/nelmioapidoc/ ← Swagger UI assets (local) -│ -├── src/ -│ ├── Admin/Controller/ ← AdminApiController + SPA catch-all -│ ├── Appointment/ ← نوبت‌دهی -│ ├── Auth/ ← احراز هویت -│ ├── Blog/ ← بلاگ -│ ├── Category/ ← دسته‌بندی‌ها -│ ├── Clinic/ ← کلینیک‌ها -│ ├── Doctor/ ← پزشکان -│ ├── Insurance/ ← بیمه -│ ├── Payment/ ← پرداخت -│ ├── Rating/ ← امتیاز و نظرات -│ ├── Representation/ ← نمایندگان -│ ├── Secretary/ ← منشی‌ها -│ ├── Settlement/ ← تسویه‌حساب -│ ├── Shared/ ← زیرساخت مشترک -│ ├── Sms/ ← پیامک -│ ├── UserProfile/ ← پروفایل کاربر -│ └── Kernel.php -│ -├── tests/ -├── webpack.config.js -├── tsconfig.json -├── postcss.config.js -├── composer.json -├── package.json -└── .env -``` - ---- - -## ۴. راه‌اندازی محیط توسعه +## ۳. راه‌اندازی محیط توسعه ### پیش‌نیازها -- [ddev](https://ddev.readthedocs.io/) نصب شده -- Docker Desktop -### مراحل اجرا +- [ddev](https://ddev.readthedocs.io/) نصب‌شده + +### مراحل ```bash -# ۱. کلون پروژه -git clone +# ۱. clone و وارد شدن به پروژه +git clone clinic-pro-symfony cd clinic-pro-symfony # ۲. راه‌اندازی ddev ddev start -# ۳. نصب PHP packages -ddev composer install +# ۳. نصب وابستگی‌های PHP +ddev exec composer install -# ۴. ساخت JWT keys +# ۴. تولید کلیدهای JWT ddev exec php bin/console lexik:jwt:generate-keypair -# ۵. اجرای migrations +# ۵. اجرای migration های پایگاه داده ddev exec php bin/console doctrine:migrations:migrate --no-interaction -# ۶. نصب npm packages -npm install --legacy-peer-deps +# ۶. نصب وابستگی‌های Node و بیلد فرانت‌اند +ddev exec yarn install +ddev exec yarn dev -# ۷. Build frontend (dev) -npm run dev - -# ۸. یا watch mode -npm run watch +# ۷. ایجاد اولین ادمین (اختیاری) +ddev exec php bin/console app:create-admin ``` -### دسترسی +### دستورات روزانه -| سرویس | آدرس | توضیح | -|-------|------|-------| -| Admin Panel | https://clinic-pro.ddev.site/admin | JWT auth | -| API | https://clinic-pro.ddev.site/api/v1 | REST API | -| Swagger UI | https://clinic-pro.ddev.site/api/doc | HTTP Basic Auth | -| Health Check | https://clinic-pro.ddev.site/health | عمومی | +```bash +# بیلد فرانت‌اند (یکبار) +ddev exec yarn dev -### اطلاعات ادمین پیش‌فرض +# حالت watch (هنگام توسعه) +ddev exec yarn watch + +# پاک کردن cache بعد از تغییر backend +ddev exec php bin/console cache:clear + +# اجرای queue worker +ddev exec php bin/console messenger:consume async +``` + +--- + +## ۴. آدرس‌های مهم + +| سرویس | آدرس | +|-------|------| +| **وب‌سایت** | https://clinic-pro.ddev.site | +| **پنل ادمین** | https://clinic-pro.ddev.site/admin | +| **Swagger UI** | https://clinic-pro.ddev.site/api/doc | +| **Health Check** | https://clinic-pro.ddev.site/health | + +### ورود به Swagger UI + +> آدرس: `https://clinic-pro.ddev.site/api/doc` | فیلد | مقدار | |------|-------| -| موبایل | `09120671713` | -| رمز عبور | `admin1234` | -| نقش | `ROLE_ADMIN` | +| **نام کاربری** | `admin` | +| **رمز عبور** | `clinic123` | -### اطلاعات ورود به Swagger UI - -| فیلد | مقدار پیش‌فرض | -|------|--------------| -| Username | `admin` | -| Password | `clinic-pro-docs` | - -> برای تغییر رمز: `ddev exec php -r "echo password_hash('رمز-جدید', PASSWORD_BCRYPT) . PHP_EOL;"` — hash را در `.env` در `API_DOC_PASSWORD` قرار دهید. +پس از ورود، برای احراز هویت API درون Swagger ابتدا از `/api/v1/user/login` توکن بگیرید، سپس دکمه **Authorize** را بزنید و `Bearer ` وارد کنید. --- ## ۵. متغیرهای محیطی -فایل `.env` — کلیدها (مقادیر حساس در `.env.local` تنظیم می‌شوند): +فایل `.env` (مقادیر پیش‌فرض توسعه): ```env -APP_ENV=dev|prod -APP_SECRET= # کلید امنیتی Symfony -DEFAULT_URI= # آدرس پایه سایت (مثلاً https://clinic-pro.ddev.site) +# ── برنامه ───────────────────────────────────────────────────── +APP_ENV=dev +APP_SECRET=clinic_pro_secret_change_in_prod +DEFAULT_URI=https://clinic-pro.ddev.site +APP_BASE_URL=https://clinic-pro.ddev.site -DATABASE_URL= # DSN پایگاه داده MariaDB +# ── پایگاه داده ────────────────────────────────────────────────── +DATABASE_URL="mysql://db:db@db:3306/db?serverVersion=8.0&charset=utf8mb4" -JWT_SECRET_KEY= # مسیر کلید خصوصی JWT -JWT_PUBLIC_KEY= # مسیر کلید عمومی JWT -JWT_PASSPHRASE= # رمز کلید JWT +# ── JWT ───────────────────────────────────────────────────────── +JWT_SECRET_KEY=%kernel.project_dir%/config/jwt/private.pem +JWT_PUBLIC_KEY=%kernel.project_dir%/config/jwt/public.pem +JWT_PASSPHRASE=<رشته تصادفی> -CORS_ALLOW_ORIGIN= # آدرس‌های مجاز CORS (regex) +# ── CORS ──────────────────────────────────────────────────────── +CORS_ALLOW_ORIGIN='^https?://(clinic-pro\.ddev\.site|localhost)(:[0-9]+)?$' +ALLOWED_FRONTEND_HOSTS=clinic-pro.ddev.site,localhost -MESSENGER_TRANSPORT_DSN= # DSN صف پیام (Redis) -REDIS_URL= # آدرس Redis +# ── صف پیام و Cache ────────────────────────────────────────────── +MESSENGER_TRANSPORT_DSN=redis://redis:6379/messages +REDIS_URL=redis://redis:6379 -REFRESH_TOKEN_TTL=2592000 # عمر refresh token (ثانیه) = ۳۰ روز -OTP_TTL=1200 # عمر کد OTP (ثانیه) = ۲۰ دقیقه +# ── توکن‌ها ────────────────────────────────────────────────────── +REFRESH_TOKEN_TTL=2592000 # ۳۰ روز (ثانیه) +OTP_TTL=1200 # ۲۰ دقیقه (ثانیه) -# SMS -KAVENEGAR_API_KEY= -KAVENEGAR_SENDER= -RANGINEH_API_KEY= -RANGINEH_SENDER= -SMS_PROVIDER=kavenegar|rangineh # ارائه‌دهنده فعال +# ── پیامک ──────────────────────────────────────────────────────── +SMS_PROVIDER=kavenegar +KAVENEGAR_API_KEY=<کلید> +KAVENEGAR_SENDER=<شماره فرستنده> +RANGINEH_API_KEY=<کلید> +RANGINEH_SENDER=<شماره فرستنده> -# File Upload -MAX_FILE_SIZE_BYTES=5242880 # حداکثر حجم فایل (۵ مگابایت) -UPLOAD_DIR= # مسیر ذخیره فایل‌ها +# ── فایل آپلود ─────────────────────────────────────────────────── +MAX_FILE_SIZE_BYTES=5242880 # 5MB +UPLOAD_DIR=var/uploads -# Payment -ALLOWED_FRONTEND_HOSTS= # هاست‌های مجاز برای redirect پرداخت -MELLAT_TERMINAL_ID= -MELLAT_USERNAME= -MELLAT_PASSWORD= -SEP_TERMINAL_ID= -APP_BASE_URL= # آدرس پایه برای callback پرداخت +# ── درگاه پرداخت ───────────────────────────────────────────────── +MELLAT_TERMINAL_ID=<شناسه> +MELLAT_USERNAME=<کاربری> +MELLAT_PASSWORD=<رمز> +SEP_TERMINAL_ID=<شناسه> -# API Documentation (Swagger UI) -API_DOC_USERNAME=admin # نام کاربری ورود به /api/doc -API_DOC_PASSWORD= # bcrypt hash رمز عبور +# ── Swagger UI ─────────────────────────────────────────────────── +API_DOC_USERNAME=admin +API_DOC_PASSWORD= # plain text: clinic123 ``` --- -## ۶. پایگاه داده — موجودیت‌ها +## ۶. دستورات Console -### جداول پایگاه داده +```bash +# ── Cache ──────────────────────────────────────────────────────── +ddev exec php bin/console cache:clear -| جدول | Entity | توضیح | -|------|--------|-------| -| `users` | `Auth\Entity\User` | کاربران سیستم | -| `doctors` | `Doctor\Entity\Doctor` | پروفایل پزشکان | -| `doctor_addresses` | `Doctor\Entity\DoctorAddress` | آدرس مطب | -| `clinics` | `Clinic\Entity\Clinic` | کلینیک‌ها | -| `categories` | `Category\Entity\Category` | دسته‌بندی‌ها | -| `appointments` | `Appointment\Entity\Appointment` | نوبت‌ها | -| `weekly_schedules` | `Appointment\Entity\WeeklySchedule` | برنامه هفتگی | -| `date_overrides` | `Appointment\Entity\DateOverride` | تغییر برنامه خاص | -| `holidays` | `Appointment\Entity\Holiday` | تعطیلات | -| `payments` | `Payment\Entity\Payment` | پرداخت‌ها | -| `settlements` | `Settlement\Entity\Settlement` | درخواست تسویه | -| `wallet_transactions` | `Settlement\Entity\WalletTransaction` | تراکنش کیف پول | -| `representations` | `Representation\Entity\Representation` | نمایندگان | -| `doctor_secretaries` | `Secretary\Entity\DoctorSecretary` | منشی‌های پزشک | -| `comments` | `Rating\Entity\Comment` | نظرات | -| `rates` | `Rating\Entity\Rate` | امتیازها | -| `likes` | `Rating\Entity\Like` | لایک نظرات | -| `blogs` | `Blog\Entity\Blog` | مقالات بلاگ | -| `sms_templates` | `Sms\Entity\SmsTemplate` | قالب‌های پیامک | -| `sms_logs` | `Sms\Entity\SmsLog` | لاگ ارسال پیامک | -| `profiles` | `UserProfile\Entity\UserProfile` | پروفایل پزشکی کاربر | -| `doctor_insurances` | `Insurance\Entity\DoctorInsurance` | بیمه‌های پزشک | +# ── Database ───────────────────────────────────────────────────── +ddev exec php bin/console doctrine:migrations:migrate --no-interaction +ddev exec php bin/console doctrine:migrations:diff --no-interaction # بعد از تغییر entity +ddev exec php bin/console doctrine:migrations:status -### جداول Join (ManyToMany) +# ── Debug ──────────────────────────────────────────────────────── +ddev exec php bin/console debug:router | grep api +ddev exec php bin/console debug:container | grep -i service -| جدول | رابطه | -|------|-------| -| `doctor_specialties` | Doctor ↔ Category (specialty) | -| `doctor_expertise` | Doctor ↔ Category (expertise) | -| `doctor_states` | Doctor ↔ Category (state) | -| `doctor_cities` | Doctor ↔ Category (city) | -| `clinic_doctors` | Clinic ↔ Doctor | -| `clinic_specialties` | Clinic ↔ Category | -| `clinic_services` | Clinic ↔ Category | -| `clinic_insurances` | Clinic ↔ Category | +# ── Queue ──────────────────────────────────────────────────────── +ddev exec php bin/console messenger:consume async ---- +# ── Test ───────────────────────────────────────────────────────── +ddev exec php bin/phpunit +ddev exec php bin/phpunit tests/SomeTest.php -### User (users) +# ── Static Analysis ────────────────────────────────────────────── +ddev exec php vendor/bin/phpstan analyse -``` -id INT PK AUTO_INCREMENT -uuid VARCHAR(36) UNIQUE -mobile_number VARCHAR(20) UNIQUE ← شناسه ورود -password_hash VARCHAR(255) nullable -email VARCHAR(100) nullable -real_name VARCHAR(100) nullable -roles JSON ← ['ROLE_USER', 'ROLE_ADMIN', ...] -status SMALLINT default:1 ← 1=active, 0=inactive -created_at INT (unix timestamp) -updated_at INT (unix timestamp) -``` - -**نقش‌های موجود:** `ROLE_USER` | `ROLE_ADMIN` | `ROLE_DOCTOR` | `ROLE_CLINIC` | `ROLE_REPRESENTATION` | `ROLE_SECRETARY` - ---- - -### Doctor (doctors) - -``` -id INT PK -uuid VARCHAR(36) UNIQUE -user_id INT FK → users -name VARCHAR(255) -gender VARCHAR(10) nullable -medical_system_code VARCHAR(25) nullable -mobile_number VARCHAR(15) nullable -activity_time INT nullable ← مدت ویزیت (دقیقه) -degree VARCHAR(30) nullable ← general/specialist/subspecialist -info TEXT nullable -images JSON nullable -doctor_rate FLOAT default:3.5 -doctor_rate_percentage FLOAT default:60.0 ← سهم پزشک از پرداخت (%) -active_doctor_appointment BOOL default:true -representation_id INT nullable -created_at / updated_at INT +# ── Frontend ───────────────────────────────────────────────────── +ddev exec yarn dev # بیلد یکبار +ddev exec yarn watch # حالت watch +ddev exec yarn build # پروداکشن +ddev exec npx tsc --noEmit # فقط type check ``` --- -### Clinic (clinics) - -``` -id INT PK -uuid VARCHAR(36) UNIQUE -user_id INT FK → users -name VARCHAR(255) nullable -info TEXT nullable -address TEXT nullable -telephone VARCHAR(50) nullable -is_24_7 BOOL default:false -working_days VARCHAR(255) nullable -latitude/longitude FLOAT nullable -city_id INT nullable → categories (bundle='city') -state_id INT nullable → categories (bundle='state') -representation_id INT nullable -images_clinic JSON nullable -clinic_logo JSON nullable -``` - ---- - -### Representation (representations) - -``` -id INT PK -uuid VARCHAR(36) UNIQUE -full_name VARCHAR(255) -mobile_number VARCHAR(20) -city_id INT nullable → categories (bundle='city') ← ID شهر (نه نام) -wallet_balance INT default:0 -commission_rate FLOAT default:10.0 -is_active BOOL default:true -created_at INT (unix timestamp) -updated_at INT (unix timestamp) -``` - -> **توجه:** `city_id` یک FK عددی به جدول `categories` (bundle='city') است. نام شهر از طریق JOIN در API برگردانده می‌شود. - ---- - -### Appointment (appointments) - -``` -id INT PK -uuid VARCHAR(36) UNIQUE -doctor_id INT FK → doctors -user_id INT FK → users -slot_start INT (unix timestamp) -slot_end INT (unix timestamp) -status VARCHAR(30) -note VARCHAR(255) nullable -version INT (optimistic locking) -``` - -**وضعیت‌های نوبت:** -``` -waiting_for_payment → در انتظار پرداخت -reserved → رزرو شده -checked_in → ورود به مطب -waiting → در صف انتظار -in_progress → در حال ویزیت -visited → ویزیت شده -completed → تکمیل شده -cancelled_by_doctor → لغو توسط پزشک -cancelled_by_user → لغو توسط کاربر -auto_cancel_unpaid → لغو خودکار (پرداخت‌نشده) -no_show → غیبت -``` - ---- - -### Payment (payments) - -``` -id INT PK -uuid VARCHAR(36) UNIQUE -order_id VARCHAR(64) UNIQUE -user_id INT FK -appointment_id INT FK nullable -amount_rials INT -status VARCHAR(30) ← pending|success|failed|refunded -gateway VARCHAR(20) ← mellat|sep -type VARCHAR(30) ← appointment|subscription -gateway_token VARCHAR(255) nullable -reference_id VARCHAR(255) nullable -frontend_address VARCHAR(500) nullable -callback_ip VARCHAR(45) nullable -``` - ---- - -### Category (categories) - -``` -id INT PK -uuid VARCHAR(36) UNIQUE -bundle VARCHAR(32) ← نوع دسته‌بندی -label VARCHAR(255) nullable -status SMALLINT default:1 -parent_id INT nullable ← برای city → state -weight INT default:0 -representation_id INT nullable -``` - -**نوع‌های bundle:** -``` -state ← استان‌ها -city ← شهرها (parent_id = state.id) -specially_doctor ← تخصص پزشک -doctor_services ← خدمات پزشک -insurance_type ← نوع بیمه پایه -supplementary_insurance ← بیمه تکمیلی -tag ← تگ بلاگ -``` - ---- - -### Settlement + Wallet - -```sql --- settlements -uuid, user_id, amount_rials, status(pending|approved|rejected|paid), -bank_account(JSON), admin_note, reviewed_by, reviewed_at - --- wallet_transactions -user_id, amount, type(credit|debit), balance_after, description -``` - ---- - -### DoctorSecretary - -``` -uuid -doctor_id FK → doctors -secretary_id FK → users -permission JSON: - { - "appointments": { "view": true, "create": true, "cancel": false, "update_status": false }, - "addresses": { "view": true, "create": false, "update": false, "delete": false }, - "clinic_info": { "view": true, "update": false }, - "insurances": { "view": true, "create": false, "update": false, "delete": false } - } -active BOOL -``` - ---- - -## ۷. API Endpoints - -### Base URL: `https://clinic-pro.ddev.site` - -> مستندات کامل تعاملی: **https://clinic-pro.ddev.site/api/doc** (نیاز به HTTP Basic Auth) - ---- - -### 🔐 Auth (`/api/v1/user/` & `/oauth/`) - -| Method | Path | Auth | توضیح | -|--------|------|------|-------| -| POST | `/api/v1/user/login` | Public | ورود با موبایل و رمز → JWT + refresh | -| POST | `/api/v1/user/send-code` | Public | ارسال کد OTP به موبایل | -| POST | `/api/v1/user/verify-code` | Public | تأیید کد OTP | -| POST | `/api/v1/user/register` | Public | ثبت‌نام کاربر جدید | -| POST | `/oauth/token` | Public | دریافت JWT (mobile grant) | -| POST | `/oauth/token/refresh` | Public | تمدید JWT با refresh token | -| GET | `/oauth/userinfo` | ✅ | اطلاعات کاربر جاری | -| POST | `/oauth/logout` | ✅ | خروج و ابطال refresh token | -| GET | `/session/token` | Public | CSRF session token | - -**درخواست Login:** -```json -POST /api/v1/user/login -{ "mobile_number": "09120671713", "password": "admin1234" } -``` -**پاسخ موفق:** -```json -{ - "access_token": "eyJ...", - "refresh_token": "8e75...", - "token_type": "Bearer", - "expires_in": 3600, - "refresh_token_expires_in": 2592000 -} -``` - ---- - -### 🩺 Doctors - -| Method | Path | Auth | توضیح | -|--------|------|------|-------| -| GET | `/api/v1/doctors` | Public | لیست پزشکان (فیلتر + pagination) | -| GET | `/api/v1/doctor/{uuid}` | Public | جزئیات پزشک | -| POST | `/api/v1/doctor` | ✅ | ایجاد پروفایل پزشک | -| PATCH | `/api/v1/doctor/{uuid}` | ✅ | به‌روزرسانی پروفایل | -| DELETE | `/api/v1/doctor/{uuid}` | ADMIN | حذف پزشک | -| POST | `/file/upload/clinic_pro/doctor/field_image` | ✅ | آپلود تصویر | -| GET | `/api/v1/clinic-pro/doctor-addresses/{doctorId}` | Public | لیست آدرس‌ها | -| POST | `/api/v1/clinic-pro/doctor-address` | ✅ | افزودن آدرس | -| PATCH | `/api/v1/clinic-pro/doctor-address/{id}` | ✅ | ویرایش آدرس | -| DELETE | `/api/v1/clinic-pro/doctor-address/{id}` | ✅ | حذف آدرس | - ---- - -### 🏥 Clinics - -| Method | Path | Auth | توضیح | -|--------|------|------|-------| -| GET | `/api/v1/clinics` | Public | لیست کلینیک‌ها | -| GET | `/api/v1/clinic/{uuid}` | Public | جزئیات کلینیک | -| POST | `/api/v1/clinic` | ✅ | ایجاد کلینیک | -| PATCH | `/api/v1/clinic/{uuid}` | ✅ | ویرایش کلینیک | -| GET | `/api/v1/clinic/doctor-list/{clinicUuid}` | Public | پزشکان کلینیک | -| POST | `/file/upload/clinic_pro/clinic/field_image_clinic` | ✅ | آپلود تصویر | -| POST | `/file/upload/clinic_pro/clinic/field_clinic_logo` | ✅ | آپلود لوگو | - ---- - -### 📅 Appointments - -| Method | Path | Auth | توضیح | -|--------|------|------|-------| -| GET | `/api/v1/appointment-slots` | Public | اسلات‌های خالی پزشک | -| POST | `/api/v1/appointment` | ✅ | رزرو نوبت | -| GET | `/api/v1/appointment/{uuid}` | ✅ | جزئیات نوبت | -| PATCH | `/api/v1/appointment/{uuid}/status` | ✅ | تغییر وضعیت نوبت | -| GET | `/api/v1/appointments/doctor/{doctorUuid}` | ✅ | نوبت‌های پزشک | -| GET | `/api/v1/appointments/user` | ✅ | نوبت‌های کاربر | - -**تنظیمات نوبت‌دهی:** - -| Method | Path | توضیح | -|--------|------|-------| -| POST | `/api/v1/appointment-settings/weekly-schedule` | برنامه هفتگی | -| POST | `/api/v1/appointment-settings/date-override` | تغییر برنامه خاص | -| POST | `/api/v1/appointment-settings/holidays` | ثبت تعطیلات | - ---- - -### 💳 Payments - -| Method | Path | Auth | توضیح | -|--------|------|------|-------| -| POST | `/api/v1/payment/appointment` | ✅ | شروع پرداخت نوبت | -| GET/POST | `/api/v1/payment/callback/{gateway}` | Public | callback درگاه | -| GET | `/api/v1/payment/{uuid}` | ✅ | وضعیت پرداخت | -| POST | `/api/v1/subscription-payment` | ✅ | پرداخت اشتراک | - -**پارامتر `{gateway}`:** `mellat` یا `sep` - ---- - -### 🏦 Settlement & Wallet - -| Method | Path | Auth | توضیح | -|--------|------|------|-------| -| GET | `/api/v1/wallet/balance` | ✅ | موجودی کیف پول | -| GET | `/api/v1/wallet/transactions` | ✅ | تاریخچه تراکنش‌ها | -| POST | `/api/v1/settlement` | ✅ | درخواست تسویه | -| GET | `/api/v1/settlement` | ✅ | لیست درخواست‌ها | -| GET | `/api/v1/settlement/{uuid}` | ✅ | جزئیات درخواست | -| POST | `/api/v1/settlement/{uuid}/approve` | ADMIN | تأیید تسویه | -| POST | `/api/v1/settlement/{uuid}/reject` | ADMIN | رد تسویه | - ---- - -### ⭐ Ratings & Comments - -| Method | Path | Auth | توضیح | -|--------|------|------|-------| -| POST | `/api/v1/rate` | ✅ | امتیاز به پزشک (۱-۵) | -| GET | `/api/v1/rate/{doctorUuid}` | Public | میانگین امتیاز پزشک | -| POST | `/api/v1/comment` | ✅ | ثبت نظر | -| GET | `/api/v1/comments/{doctorUuid}` | Public | نظرات تأییدشده | -| DELETE | `/api/v1/comment/{uuid}` | ✅ | حذف نظر | -| POST | `/api/v1/like/{commentUuid}` | ✅ | لایک/آنلایک نظر | - ---- - -### 🤝 Representations (نمایندگان) - -| Method | Path | Auth | توضیح | -|--------|------|------|-------| -| POST | `/api/v1/representation` | ADMIN | ایجاد نماینده | -| GET | `/api/v1/representation/{uuid}` | ✅ | جزئیات نماینده | -| PATCH | `/api/v1/representation/{uuid}` | ✅ | ویرایش | -| DELETE | `/api/v1/representation/{uuid}` | ADMIN | حذف | -| GET | `/api/v1/representation/{uuid}/dashboard/monthly` | ✅ | آمار ماهانه (شمسی) | -| GET | `/api/v1/representation/{uuid}/dashboard/yearly` | ✅ | آمار سالانه | - ---- - -### 🔑 Secretaries (منشی‌ها) - -| Method | Path | Auth | توضیح | -|--------|------|------|-------| -| POST | `/api/v1/secretary` | ✅ | ایجاد منشی | -| GET | `/api/v1/secretary/{uuid}` | ✅ | جزئیات | -| PATCH | `/api/v1/secretary/{uuid}` | ✅ | ویرایش دسترسی‌ها | -| DELETE | `/api/v1/secretary/{uuid}` | ✅ | حذف | -| GET | `/api/v1/secretaries/{doctorUuid}` | ✅ | لیست منشی‌های پزشک | - ---- - -### 📱 SMS - -| Method | Path | Auth | توضیح | -|--------|------|------|-------| -| GET | `/api/v1/admin/sms/templates` | ADMIN | لیست قالب‌ها | -| POST | `/api/v1/sms/template` | ADMIN | ایجاد قالب | -| PATCH | `/api/v1/sms/template/{uuid}` | ADMIN | ویرایش قالب | -| POST | `/api/v1/sms/template/{uuid}/submit` | ADMIN | ارسال برای تأیید | -| POST | `/api/v1/admin/sms/template/{uuid}/approve` | ADMIN | تأیید قالب | -| POST | `/api/v1/admin/sms/template/{uuid}/reject` | ADMIN | رد قالب | -| POST | `/api/v1/sms/send` | ADMIN | ارسال پیامک مستقیم | -| POST | `/api/v1/sms/send-template` | ADMIN | ارسال با قالب | - ---- - -### 🗂 Categories - -| Method | Path | Auth | توضیح | -|--------|------|------|-------| -| GET | `/api/v1/categorys/{bundle}` | Public | لیست دسته‌بندی (`?state_id=X`) | -| POST | `/api/v1/category` | ADMIN | ایجاد دسته‌بندی | -| PATCH | `/api/v1/category/{id}` | ADMIN | ویرایش | -| DELETE | `/api/v1/category/{id}` | ADMIN | حذف | - -**مقادیر `{bundle}`:** `state` | `city` | `specially_doctor` | `doctor_services` | `insurance_type` | `supplementary_insurance` | `tag` - ---- - -### 📝 Blog - -| Method | Path | Auth | توضیح | -|--------|------|------|-------| -| GET | `/api/v1/blogs` | Public | لیست مقالات منتشرشده | -| GET | `/api/v1/blog/{slug}` | Public | مقاله با slug یا uuid | -| POST | `/api/v1/blog` | ADMIN | ایجاد مقاله | -| PATCH | `/api/v1/blog/{uuid}` | ADMIN | ویرایش | -| DELETE | `/api/v1/blog/{uuid}` | ADMIN | حذف | -| POST | `/file/upload/clinic_pro/blog/field_image` | ADMIN | آپلود تصویر مقاله | - ---- - -### 🖥 Admin API (`/api/v1/admin/`) - -همه endpoint‌های ادمین نیاز به `ROLE_ADMIN` دارند: - -| Method | Path | توضیح | -|--------|------|-------| -| GET | `/api/v1/admin/users` | لیست کاربران (`?search=`) | -| GET | `/api/v1/admin/appointments` | لیست نوبت‌ها | -| GET | `/api/v1/admin/payments` | لیست پرداخت‌ها | -| GET | `/api/v1/admin/representations` | لیست نمایندگان (`?city_id=`) | -| GET | `/api/v1/admin/secretaries` | لیست منشی‌ها | -| GET | `/api/v1/admin/rates` | لیست امتیازها | -| GET | `/api/v1/admin/comments` | لیست نظرات | -| GET | `/api/v1/admin/sms/logs` | لاگ پیامک‌ها | -| GET | `/api/v1/admin/settlements` | لیست تسویه‌حساب‌ها | -| GET | `/api/v1/admin/sms/sample-templates` | قالب‌های نمونه پیامک | -| GET | `/api/v1/admin/dashboard/stats` | آمار کلی داشبورد | -| GET | `/api/v1/admin/dashboard/recent` | فعالیت‌های اخیر | - ---- - -### ❤️ Health - -| Method | Path | توضیح | -|--------|------|-------| -| GET | `/health` | بررسی وضعیت DB و Redis | - ---- - -## ۸. امنیت و احراز هویت +## ۷. امنیت و احراز هویت ### جریان احراز هویت ``` -[Client] → POST /api/v1/user/login - { mobile_number, password } - - ↓ PasswordAuthenticator - -[Symfony] → Rate Limit check (IP) - → findByMobile() - → verify password_hash - → check isStaff() - - ↓ موفق - -[Response] → { - access_token: "eyJ...", // JWT — عمر: ۱ ساعت - refresh_token: "8e75...", // در DB کش — عمر: ۳۰ روز - expires_in: 3600 - } +۱. POST /api/v1/user/send-code → ارسال OTP به موبایل +۲. POST /api/v1/user/verify-code → تأیید OTP → دریافت uuid +۳. POST /oauth/token → ارسال uuid → دریافت JWT + refresh token +۴. Authorization: Bearer → همراه هر درخواست محافظت‌شده ``` -### JWT Token Payload +### جریان Login با رمز عبور + +``` +POST /api/v1/user/login +{ "mobile_number": "09...", "password": "..." } +→ { "access_token": "...", "refresh_token": "..." } +``` + +### نقش‌های کاربری + +| نقش | توضیح | دسترسی | +|-----|--------|--------| +| `ROLE_USER` | پایه — همه کاربران | نوبت‌گیری، پروفایل | +| `ROLE_ADMIN` | مدیر کل | پنل ادمین کامل | +| `ROLE_CLINIC` | صاحب کلینیک | مدیریت کلینیک خود | +| `ROLE_DOCTOR` | پزشک | مدیریت نوبت‌ها، پروفایل | +| `ROLE_SECRETARY` | منشی | بر اساس permissions دکتر | + +### دسترسی‌های منشی (JSON) ```json { - "iat": 1781031215, - "exp": 1781034815, - "roles": ["ROLE_ADMIN", "ROLE_USER"], - "username": "09120671713" -} -``` - -### استفاده از Token - -``` -Authorization: Bearer eyJ... -``` - -### Firewalls - -``` -dev → ^/(_profiler|_wdt|assets|build)/ — no security -health → ^/health$ — no security -api_doc → ^/api/doc — HTTP Basic Auth (InMemoryUser) -public_endpoints → مسیرهای عمومی API — no security -payment_callback → callback درگاه پرداخت — no security -api → ^/(api|oauth)/ — JWT authenticator -``` - -### محافظت از Swagger UI - -مسیر `/api/doc` از طریق یک firewall جداگانه با **HTTP Basic Authentication** محافظت می‌شود: - -```yaml -# config/packages/security.yaml -api_doc: - pattern: ^/api/doc - http_basic: - realm: "ClinicPro API Documentation" - provider: api_doc_provider -``` - -اطلاعات ورود از متغیرهای محیطی `API_DOC_USERNAME` و `API_DOC_PASSWORD` (bcrypt hash) خوانده می‌شود. - -### نقش‌ها و دسترسی‌ها - -| نقش | دسترسی | -|-----|--------| -| `ROLE_USER` | کاربر عادی — رزرو نوبت، پرداخت | -| `ROLE_DOCTOR` | پزشک — مدیریت پروفایل و نوبت‌ها | -| `ROLE_CLINIC` | مدیر کلینیک | -| `ROLE_REPRESENTATION` | نماینده — آمار و کیف پول | -| `ROLE_SECRETARY` | منشی — بر اساس permissions JSON | -| `ROLE_ADMIN` | ادمین کامل — تمام endpoint‌ها | - -### Rate Limiting - -```yaml -# config/packages/rate_limiter.yaml -login: 5 تلاش در دقیقه (per IP) -send_code: 3 درخواست در 5 دقیقه (per IP) -``` - ---- - -## ۹. پنل ادمین React - -### آدرس: `https://clinic-pro.ddev.site/admin` - -### نحوه کار - -``` -[Browser] GET /admin/** - ↓ -[Symfony] AdminController::index() - ↓ -[Twig] templates/admin/index.html.twig - ↓ (HTML shell + Webpack assets) -[React] mounts در #admin-root - ↓ -[React Router] مسیریابی client-side -``` - -### مسیرهای React (پیاده‌سازی‌شده) - -| مسیر | صفحه | توضیح | -|------|------|-------| -| `/admin/login` | LoginPage | ورود با JWT | -| `/admin/dashboard` | DashboardPage | آمار واقعی از API | -| `/admin/users` | UsersPage | لیست کاربران | -| `/admin/users/:uuid` | UserDetailPage | جزئیات کاربر | -| `/admin/doctors` | DoctorsPage | لیست پزشکان | -| `/admin/doctors/:uuid` | DoctorDetailPage | جزئیات پزشک | -| `/admin/clinics` | ClinicsPage | لیست کلینیک‌ها | -| `/admin/clinics/:uuid` | ClinicDetailPage | جزئیات کلینیک | -| `/admin/appointments` | AppointmentsPage | لیست نوبت‌ها | -| `/admin/appointments/:uuid` | AppointmentDetailPage | جزئیات نوبت | -| `/admin/payments` | PaymentsPage | لیست پرداخت‌ها | -| `/admin/payments/:uuid` | PaymentDetailPage | جزئیات پرداخت | -| `/admin/settlements` | SettlementsPage | لیست تسویه‌حساب‌ها | -| `/admin/representations` | RepresentationsPage | لیست نمایندگان (فیلتر شهر) | -| `/admin/representations/:uuid` | RepresentationDetailPage | جزئیات نماینده | -| `/admin/comments` | CommentsPage | مدیریت نظرات | -| `/admin/ratings` | RatingsPage | لیست امتیازها | -| `/admin/sms` | SmsPage | مدیریت پیامک | -| `/admin/categories` | CategoriesPage | مدیریت دسته‌بندی‌ها | -| `/admin/blogs` | BlogsPage | لیست مقالات | -| `/admin/blogs/new` | BlogFormPage | ایجاد مقاله | -| `/admin/blogs/:uuid/edit` | BlogFormPage | ویرایش مقاله | -| `/admin/secretaries` | SecretariesPage | مدیریت منشی‌ها | - -### Auth Guard - -```typescript -// اگر کاربر لاگین نباشد → redirect به /admin/login -// اگر کاربر لاگین باشد و /admin/login باز کند → redirect به /admin/dashboard -``` - -### JWT ذخیره‌سازی - -```typescript -// Zustand + localStorage (با persist middleware) -// کلید: 'clinicpro-auth' -{ - token: string | null, // access_token - refreshToken: string | null, - isAuthenticated: boolean -} -``` - -### طراحی (Design System) - -```css -/* رنگ اصلی */ ---color-primary-500: #8b5cf6; /* بنفش */ ---color-bg-sidebar: #0f172a; /* dark navy */ ---color-bg-body: #f1f5f9; /* خاکستری روشن */ - -/* فونت */ -font-family: "Vazirmatn", "Inter", sans-serif; -direction: rtl; -``` - ---- - -## ۱۰. Frontend Build - -### دستورات npm - -```bash -ddev exec yarn dev # build یک‌بار (dev) -ddev exec yarn watch # build + watch -ddev exec yarn build # build production (minified + hashed) -``` - -> **توجه:** خطای `lightningcss.linux-arm64-gnu.node` در محیط ddev از پیش وجود دارد و JS/TS compilation را مسدود نمی‌کند. - -### فایل‌های خروجی - -``` -public/build/ -├── runtime.js ← webpack runtime -├── admin.js ← React Admin bundle -├── admin.css ← Tailwind CSS -├── app.js ← Symfony main bundle -├── vendors-*.js ← کتابخانه‌های مشترک -└── manifest.json ← نقشه فایل‌ها -``` - -### تنظیمات TypeScript (`tsconfig.json`) - -```json -{ - "compilerOptions": { - "target": "ES2020", - "jsx": "react-jsx", - "strict": true, - "baseUrl": ".", - "paths": { "@/*": ["assets/admin/*"] } + "version": 1, + "resources": { + "appointments": { "view": true, "create": true, "cancel": false, "update_status": true }, + "addresses": { "view": true, "create": false, "update": false, "delete": false }, + "clinic_info": { "view": true, "update": false }, + "insurances": { "view": true, "create": false, "update": false, "delete": false } } } ``` +### JWT + +- مدت اعتبار access token: **۱ ساعت** +- Refresh token: **۳۰ روز** (ذخیره در Redis) +- پیلود: `{ username: mobile_number, roles: [...], iat, exp }` +- تجدید: `POST /oauth/token/refresh` + +### Firewall های security.yaml + +| Firewall | Pattern | توضیح | +|----------|---------|--------| +| `api_doc` | `^/api/doc` | HTTP Basic Auth (admin / clinic123) | +| `public_endpoints` | OTP، OAuth، endpoint های عمومی | بدون احراز هویت | +| `payment_callback` | `/api/v1/payment/callback/` | بدون احراز هویت | +| `api` | `^/(api\|oauth\|file/)` | JWT stateless | + --- -## ۱۱. دستورات Console +## ۸. پایگاه داده — جداول و موجودیت‌ها -### دستورات کاربردی +### جداول اصلی + +| جدول | موجودیت | توضیح | +|------|---------|--------| +| `users` | User | uuid، mobile_number (unique)، roles (JSON)، status | +| `doctors` | Doctor | user_id (OneToOne)، degree، doctor_rate، active_doctor_appointment | +| `doctor_addresses` | DoctorAddress | doctor_id، name، latitude، longitude | +| `clinics` | Clinic | user_id (owner)، name، telephone، is_24_7، latitude، longitude، images_clinic (JSON) | +| `clinic_doctor_invitations` | ClinicDoctorInvitation | token (unique 96char hex)، status، expires_at (+72h)، mobile، uuid | +| `appointments` | Appointment | doctor_id، user_id، slot_start، slot_end، status، price | +| `weekly_schedules` | WeeklySchedule | doctor_id (OneToOne)، setting (JSON) | +| `date_overrides` | DateOverride | doctor_id، date، active، setting (JSON) | +| `holidays` | Holiday | doctor_id، start_date، end_date | +| `payments` | Payment | order_id (unique)، amount_rials، status، gateway (mellat\|sep) | +| `settlements` | Settlement | user_id، amount_rials، status، bank_account (JSON) | +| `wallet_transactions` | WalletTransaction | user_id، amount_rials، type (credit\|debit)، balance_after | +| `representations` | Representation | full_name، commission_percent، city_id | +| `doctor_secretaries` | DoctorSecretary | doctor_id، secretary_id، permissions (JSON)، active | +| `rates` | Rate | doctor_id، user_id، overall، diagnosis_accuracy، skill، behavior | +| `comments` | Comment | doctor_id، user_id، body، status (approved\|pending\|rejected) | +| `likes` | Like | comment_id، user_id | +| `blogs` | Blog | author_id، title، slug (unique)، body، status | +| `sms_templates` | SmsTemplate | name، body، provider_code، status | +| `sms_logs` | SmsLog | mobile، message، provider، success | +| `profiles` | UserProfile | blood_type، gender، insurance_id | +| `provinces` | Province | name | +| `cities` | City | name، province_id (FK) | +| `specialties` | Specialty | name، parent_id (nullable برای زیر-تخصص) | +| `doctor_services` | DoctorService | name | +| `insurances` | Insurance | name، type (basic\|supplementary) | +| `tags` | Tag | name | + +### جداول رابطه‌ای (ManyToMany) + +| جدول | رابطه | +|------|-------| +| `clinic_doctors` | Clinic ↔ Doctor | +| `clinic_specialties` | Clinic ↔ Specialty | +| `clinic_services` | Clinic ↔ DoctorService | +| `clinic_insurances` | Clinic ↔ Insurance | +| `doctor_specialties` | Doctor ↔ Specialty | +| `doctor_expertise` | Doctor ↔ Category | +| `doctor_provinces` | Doctor ↔ Province | + +### وابستگی‌های کلیدی + +``` +User ──OneToOne──► Doctor ──ManyToMany──► Specialty + ──OneToOne──► Representation + ──OneToMany──► Appointment + ──OneToMany──► Clinic (owner) + +Doctor ──OneToMany──► DoctorAddress + ──OneToMany──► Appointment + ──ManyToMany──► Clinic + +Clinic ──ManyToOne──► User (owner) + ──ManyToMany──► Doctor + ──OneToMany──► ClinicDoctorInvitation +``` + +### نکته timestamp + +همه فیلدهای تاریخ (createdAt، updatedAt، slotStart، ...) به صورت **integer Unix timestamp** ذخیره می‌شوند — نه DateTime object. + +--- + +## ۹. API Endpoints + +### احراز هویت + +| متد | آدرس | توضیح | دسترسی | +|-----|------|--------|--------| +| POST | `/api/v1/user/send-code` | ارسال OTP | عمومی | +| POST | `/api/v1/user/verify-code` | تأیید OTP | عمومی | +| POST | `/api/v1/user/register` | ثبت‌نام | عمومی | +| POST | `/api/v1/user/login` | لاگین با رمز عبور | عمومی | +| POST | `/oauth/token` | دریافت JWT | عمومی | +| POST | `/oauth/token/refresh` | تجدید توکن | عمومی | +| GET | `/oauth/userinfo` | اطلاعات کاربر جاری | احراز هویت | +| POST | `/oauth/logout` | خروج | احراز هویت | + +### پزشکان + +| متد | آدرس | توضیح | دسترسی | +|-----|------|--------|--------| +| GET | `/api/v1/doctors` | لیست پزشکان | عمومی | +| GET | `/api/v1/doctor/{uuid}` | جزئیات پزشک | عمومی | +| POST | `/api/v1/doctor` | ایجاد پروفایل | احراز هویت | +| PATCH | `/api/v1/doctor/{uuid}` | ویرایش | احراز هویت | +| DELETE | `/api/v1/doctor/{uuid}` | حذف | احراز هویت | +| POST | `/file/upload/clinic_pro/doctor/field_image` | آپلود تصویر | احراز هویت | +| GET | `/api/v1/clinic-pro/doctor-addresses/{doctorId}` | آدرس‌های مطب | عمومی | +| POST | `/api/v1/clinic-pro/doctor-address` | افزودن آدرس | احراز هویت | +| PATCH | `/api/v1/clinic-pro/doctor-address/{id}` | ویرایش آدرس | احراز هویت | +| DELETE | `/api/v1/clinic-pro/doctor-address/{id}` | حذف آدرس | احراز هویت | +| POST | `/api/v1/clinic-pro/doctor-address/from-clinic/{clinicUuid}` | ایجاد از کلینیک | احراز هویت | + +### کلینیک‌ها + +| متد | آدرس | توضیح | دسترسی | +|-----|------|--------|--------| +| GET | `/api/v1/clinics` | لیست کلینیک‌ها | عمومی | +| GET | `/api/v1/clinic/{uuid}` | جزئیات کلینیک | عمومی | +| POST | `/api/v1/clinic` | ایجاد کلینیک | احراز هویت | +| PATCH | `/api/v1/clinic/{uuid}` | ویرایش | احراز هویت | +| GET | `/api/v1/clinic/doctor-list/{clinicUuid}` | پزشکان کلینیک | عمومی | +| POST | `/file/upload/clinic_pro/clinic/field_clinic_logo` | آپلود لوگو | احراز هویت | +| POST | `/file/upload/clinic_pro/clinic/field_image_clinic` | آپلود گالری | احراز هویت | + +### دعوتنامه پزشک به کلینیک + +| متد | آدرس | توضیح | دسترسی | +|-----|------|--------|--------| +| POST | `/api/v1/admin/clinic/{uuid}/invite-doctor` | ارسال دعوتنامه | ROLE_ADMIN | +| GET | `/api/v1/admin/clinic/{uuid}/invitations` | لیست دعوتنامه‌ها | ROLE_ADMIN | +| POST | `/api/v1/admin/clinic/invitation/{invUuid}/resend` | ارسال مجدد پیامک | ROLE_ADMIN | +| PATCH | `/api/v1/admin/clinic/invitation/{invUuid}/status` | تغییر وضعیت | ROLE_ADMIN | +| DELETE | `/api/v1/admin/clinic/invitation/{invUuid}` | حذف | ROLE_ADMIN | +| GET | `/api/v1/clinic-invitation/{token}` | مشاهده دعوتنامه | عمومی | +| POST | `/api/v1/clinic-invitation/{token}/accept` | پذیرش توسط دکتر | عمومی | +| POST | `/api/v1/clinic-invitation/{token}/reject` | رد کردن توسط دکتر | عمومی | + +> توکن ۹۶ کاراکتر hex (`bin2hex(random_bytes(48))`) — مدت اعتبار ۷۲ ساعت. پیامک async از طریق Symfony Messenger ارسال می‌شود. + +### نوبت‌دهی + +| متد | آدرس | توضیح | دسترسی | +|-----|------|--------|--------| +| GET | `/api/v1/appointment-slots` | اسلات‌های خالی | عمومی | +| POST | `/api/v1/appointment` | رزرو نوبت | احراز هویت | +| GET | `/api/v1/appointment/{uuid}` | جزئیات نوبت | احراز هویت | +| PATCH | `/api/v1/appointment/{uuid}/status` | تغییر وضعیت | احراز هویت | +| GET | `/api/v1/appointments/doctor/{doctorUuid}` | نوبت‌های دکتر | احراز هویت | +| GET | `/api/v1/appointments/user` | نوبت‌های کاربر | احراز هویت | + +### تنظیمات نوبت‌دهی + +| متد | آدرس | توضیح | +|-----|------|--------| +| POST/PATCH/GET | `/api/v1/appointment-settings/weekly-schedule` | برنامه هفتگی | +| POST/PATCH/DELETE/GET | `/api/v1/appointment-settings/date-override` | روز استثناء | +| POST/PATCH/DELETE/GET | `/api/v1/appointment-settings/holidays` | تعطیلات | + +### پرداخت + +| متد | آدرس | توضیح | دسترسی | +|-----|------|--------|--------| +| POST | `/api/v1/payment/appointment` | شروع پرداخت نوبت | احراز هویت | +| POST/GET | `/api/v1/payment/callback/{gateway}` | callback (mellat\|sep) | عمومی | +| GET | `/api/v1/payment/{uuid}` | وضعیت پرداخت | احراز هویت | +| POST | `/api/v1/subscription-payment` | پرداخت اشتراک | احراز هویت | + +### کیف پول و تسویه‌حساب + +| متد | آدرس | توضیح | دسترسی | +|-----|------|--------|--------| +| GET | `/api/v1/wallet/balance` | موجودی کیف پول | احراز هویت | +| GET | `/api/v1/wallet/transactions` | تاریخچه تراکنش‌ها | احراز هویت | +| POST | `/api/v1/settlement` | درخواست تسویه | احراز هویت | +| GET | `/api/v1/settlement` | لیست تسویه‌های خودم | احراز هویت | +| POST | `/api/v1/settlement/{uuid}/approve` | تأیید | ROLE_ADMIN | +| POST | `/api/v1/settlement/{uuid}/reject` | رد | ROLE_ADMIN | + +### امتیاز و نظرات + +| متد | آدرس | توضیح | دسترسی | +|-----|------|--------|--------| +| POST | `/api/v1/rate` | ثبت امتیاز | احراز هویت | +| GET | `/api/v1/rate/{doctorUuid}` | میانگین امتیاز | عمومی | +| POST | `/api/v1/comment` | ثبت نظر | احراز هویت | +| GET | `/api/v1/comments/{doctorUuid}` | نظرات تأییدشده | عمومی | +| DELETE | `/api/v1/comment/{uuid}` | حذف نظر | احراز هویت | +| POST | `/api/v1/like/{commentUuid}` | لایک | احراز هویت | + +### منشی + +| متد | آدرس | توضیح | دسترسی | +|-----|------|--------|--------| +| POST | `/api/v1/secretary` | ایجاد منشی | ROLE_DOCTOR | +| GET | `/api/v1/secretary/{uuid}` | جزئیات | احراز هویت | +| PATCH | `/api/v1/secretary/{uuid}` | ویرایش permissions | ROLE_DOCTOR | +| DELETE | `/api/v1/secretary/{uuid}` | حذف | ROLE_DOCTOR | +| GET | `/api/v1/secretaries/{doctorUuid}` | لیست منشی‌های دکتر | احراز هویت | + +### نمایندگان + +| متد | آدرس | توضیح | دسترسی | +|-----|------|--------|--------| +| POST/GET/PATCH/DELETE | `/api/v1/representation/{uuid?}` | مدیریت نماینده | احراز هویت | +| GET | `/api/v1/representation/{uuid}/dashboard/monthly` | آمار ماهانه | احراز هویت | +| GET | `/api/v1/representation/{uuid}/dashboard/yearly` | آمار سالانه | احراز هویت | + +### پیامک + +| متد | آدرس | توضیح | دسترسی | +|-----|------|--------|--------| +| POST | `/api/v1/sms/send` | ارسال مستقیم | احراز هویت | +| POST | `/api/v1/sms/send-template` | ارسال با قالب | احراز هویت | +| POST/PATCH/GET/DELETE | `/api/v1/sms/template/{uuid?}` | مدیریت قالب | احراز هویت | +| POST | `/api/v1/sms/template/{uuid}/submit` | ارسال برای تأیید | احراز هویت | +| POST | `/api/v1/admin/sms/template/{uuid}/approve` | تأیید قالب | ROLE_ADMIN | + +### تخصص، خدمات، بیمه، تگ، موقعیت + +``` +GET /api/v1/specialties عمومی +GET /api/v1/doctor-services عمومی +GET /api/v1/insurances عمومی +GET /api/v1/tags عمومی +GET /api/v1/provinces عمومی +GET /api/v1/cities?province_id= عمومی +GET /api/v1/categorys/{bundle} عمومی (legacy — bundle: state|city|specially_doctor|doctor_services|insurance_type|supplementary_insurance|tag) + +POST/PATCH/DELETE /api/v1/admin/specialty/{id?} ROLE_ADMIN +POST/PATCH/DELETE /api/v1/admin/doctor-service/{id?} ROLE_ADMIN +POST/PATCH/DELETE /api/v1/admin/insurance/{id?} ROLE_ADMIN +POST/PATCH/DELETE /api/v1/admin/tag/{id?} ROLE_ADMIN +POST/PATCH/DELETE /api/v1/admin/province/{id?} ROLE_ADMIN +POST/PATCH/DELETE /api/v1/admin/city/{id?} ROLE_ADMIN +``` + +### بلاگ + +| متد | آدرس | توضیح | دسترسی | +|-----|------|--------|--------| +| GET | `/api/v1/blogs` | لیست بلاگ‌های منتشرشده | عمومی | +| GET | `/api/v1/blog/{slug}` | جزئیات بلاگ | عمومی | +| POST | `/api/v1/blog` | نوشتن بلاگ | احراز هویت | +| PATCH | `/api/v1/blog/{uuid}` | ویرایش | احراز هویت | +| DELETE | `/api/v1/blog/{uuid}` | حذف | احراز هویت | +| POST | `/file/upload/clinic_pro/blog/field_image` | آپلود تصویر | احراز هویت | + +### پنل ادمین API + +> همه endpoint های زیر نیاز به `ROLE_ADMIN` دارند. + +| آدرس | توضیح | +|------|--------| +| `GET /api/v1/admin/dashboard/stats` | ۱۲ KPI (کاربران، پزشکان، نوبت، درآمد، ...) | +| `GET /api/v1/admin/dashboard/charts` | نمودار ۳۰ روزه نوبت و درآمد | +| `GET /api/v1/admin/dashboard/recent` | آخرین نوبت‌ها، پرداخت‌ها، کاربران | +| `GET /api/v1/admin/users` | لیست کاربران (paginated) | +| `GET /api/v1/admin/users/{uuid}` | جزئیات کاربر | +| `PUT /api/v1/admin/users/{uuid}/role` | تغییر نقش | +| `POST /api/v1/admin/users/{uuid}/status` | فعال/غیرفعال | +| `DELETE /api/v1/admin/users/{uuid}` | حذف کاربر | +| `GET /api/v1/admin/doctors` | لیست پزشکان | +| `GET /api/v1/admin/clinics` | لیست کلینیک‌ها | +| `PATCH /api/v1/admin/clinic/{uuid}/status` | تغییر وضعیت کلینیک | +| `DELETE /api/v1/admin/clinic/{uuid}` | حذف کلینیک | +| `GET /api/v1/admin/appointments` | لیست نوبت‌ها | +| `GET /api/v1/admin/payments` | لیست پرداخت‌ها | +| `GET /api/v1/admin/settlements` | لیست تسویه‌ها | +| `GET /api/v1/admin/representations` | لیست نمایندگان | +| `GET /api/v1/admin/secretaries` | لیست منشی‌ها | +| `GET /api/v1/admin/rates` | لیست امتیازها | +| `GET /api/v1/admin/comments` | لیست نظرات | +| `GET /api/v1/admin/sms/logs` | لاگ پیامک‌ها | +| `GET /api/v1/admin/sms/templates` | قالب‌های پیامک | + +### فرمت پاسخ‌های API + +```json +// Single resource +{ "success": true, "data": { ... } } + +// Paginated list +{ "success": true, "data": [...], "meta": { "totalRecords": N, "totalPages": N, "currentPage": N } } + +// Error +{ "success": false, "data": null, "errors": [{ "code": "ERR_...", "message": "..." }] } +``` + +**نکته double-nested:** برخی endpoint ها پاسخ را داخل `data` اضافه می‌کنند: +`$this->success(['data' => $entity->toArray()])` → فرانت باید با `data?.data?.data` استخراج کند. + +--- + +## ۱۰. پنل ادمین React + +### Route های پنل + +| مسیر | صفحه | توضیح | +|------|------|--------| +| `/admin/login` | LoginPage | ورود با موبایل + رمز | +| `/admin/dashboard` | DashboardPage | آمار KPI، نمودار، فعالیت‌های اخیر | +| `/admin/users` | UsersPage | لیست + جستجو + فیلتر نقش | +| `/admin/users/:uuid` | UserDetailPage | پروفایل + ویرایش نقش | +| `/admin/doctors` | DoctorsPage | لیست پزشکان | +| `/admin/doctors/new` | DoctorFormPage | ایجاد پزشک جدید | +| `/admin/doctors/:uuid` | DoctorDetailPage | پروفایل کامل + تخصص + نوبت‌ها | +| `/admin/clinics` | ClinicsPage | لیست کلینیک‌ها | +| `/admin/clinics/:uuid` | ClinicDetailPage | پروفایل + نقشه + گالری + دعوتنامه‌ها | +| `/admin/appointments` | AppointmentsPage | لیست نوبت‌ها + فیلتر | +| `/admin/appointments/:uuid` | AppointmentDetailPage | جزئیات نوبت | +| `/admin/payments` | PaymentsPage | لیست پرداخت‌ها | +| `/admin/settlements` | SettlementsPage | تسویه‌حساب | +| `/admin/representations` | RepresentationsPage | نمایندگان | +| `/admin/representations/:uuid` | RepresentationDetailPage | جزئیات نماینده | +| `/admin/comments` | CommentsPage | مدیریت نظرات | +| `/admin/ratings` | RatingsPage | امتیازها | +| `/admin/sms` | SmsPage | مدیریت پیامک | +| `/admin/categories` | CategoriesPage | تخصص/خدمات/بیمه/تگ | +| `/admin/blogs` | BlogsPage | لیست بلاگ‌ها | +| `/admin/blogs/new` | BlogFormPage | نوشتن بلاگ جدید | +| `/admin/secretaries` | SecretariesPage | مدیریت منشی‌ها | + +### الگوی دریافت داده + +```typescript +// لیست paginated +const { data } = useQuery({ + queryKey: ['resource', page, filters], + queryFn: () => api.get>(`/api/v1/admin/...?page=${page}`), +}); +const items = data?.data ?? []; // آرایه آیتم‌ها +const total = data?.meta?.totalRecords ?? 0; + +// Single resource (double-nested — برخی endpoint ها) +const raw = data?.data; +const item = (raw as any)?.data ?? raw; + +// Category API (triple-nested legacy) +const cats = data?.data?.data ?? []; +``` + +### ویژگی‌های فنی فرانت‌اند + +- **Auth Store** (Zustand persist): توکن در `localStorage['clinicpro-auth']` → `state.token` +- **Modal ها**: از `createPortal(modal, document.body)` استفاده می‌کنند تا از z-index Leaflet بگریزند +- **CSS**: کلاس‌های template اختصاصی — `.card`، `.btn.primary/.ghost/.soft/.sm`، `.badge.green/.gray/.blue`، `.overlay`، `.seg`، `.skeleton`، `.empty` +- **Avatar**: OKLCH gradient با `HUES_LIST = [256, 205, 162, 295, 272]` + +--- + +## ۱۱. Migrations ```bash -# لغو خودکار نوبت‌های منقضی‌شده -ddev exec php bin/console app:cancel-expired-appointments - -# مشاهده همه route‌ها -ddev exec php bin/console debug:router | grep api - -# پاک کردن کش -ddev exec php bin/console cache:clear - -# اجرای migrations -ddev exec php bin/console doctrine:migrations:migrate --no-interaction - -# ساخت migration بعد از تغییر entity +# ایجاد migration جدید بعد از تغییر entity ddev exec php bin/console doctrine:migrations:diff --no-interaction -# هش کردن پسورد -ddev exec php bin/console security:hash-password "رمز-جدید" - -# تولید JWT key -ddev exec php bin/console lexik:jwt:generate-keypair - -# بررسی صف پیام -ddev exec php bin/console messenger:consume async - -# خروجی OpenAPI/Swagger -ddev exec php bin/console nelmio:apidoc:dump -``` - ---- - -## ۱۲. Migrations - -**تعداد:** ۱۷ migration - -```bash -# مشاهده وضعیت -ddev exec php bin/console doctrine:migrations:status - -# اجرای migrations جدید +# اجرا ddev exec php bin/console doctrine:migrations:migrate --no-interaction -# ساخت migration جدید بعد از تغییر entity -ddev exec php bin/console doctrine:migrations:diff +# وضعیت +ddev exec php bin/console doctrine:migrations:status ``` ---- +### لیست migration های موجود -## ۱۳. سرویس‌های خارجی - -### درگاه‌های پرداخت - -| درگاه | Provider | متغیرها | -|-------|----------|---------| -| بانک ملت | `MellatGateway` | `MELLAT_TERMINAL_ID`, `MELLAT_USERNAME`, `MELLAT_PASSWORD` | -| سامان (SEP) | `SepGateway` | `SEP_TERMINAL_ID` | +| فایل | توضیح | +|------|--------| +| Version20260609130407 | جداول پایه: users, doctors, appointments | +| Version20260609131304 | پرداخت، کیف پول | +| Version20260609131553 | امتیاز، نظرات | +| Version20260609132009 | نمایندگان | +| Version20260609132708 | منشی | +| Version20260609133121 | کلینیک | +| Version20260609133334 | پیامک | +| Version20260609133546 | بلاگ | +| Version20260609134112 | تخصص، بیمه، تگ | +| Version20260609134741 | استان، شهر | +| Version20260609135126 | پروفایل، آدرس مطب | +| Version20260609135514 | برنامه هفتگی | +| Version20260609135704 | روز استثناء، تعطیلات | +| Version20260609135923 | ManyToMany: clinic_doctors, clinic_specialties | +| Version20260609140223 | ایندکس‌های اضافی | +| Version20260609140423 | doctor_insurances | +| Version20260610062539 | فیلدهای اضافی clinic | +| Version20260610175105 | فیلد is_active برای clinic | +| **Version20260610183655** | **جدول clinic_doctor_invitations** | --- -### SMS Providers +## ۱۲. سرویس‌های خارجی -| ارائه‌دهنده | Provider Class | انتخاب | -|------------|---------------|--------| -| کاوه‌نگار | `KavehNegarProvider` | `SMS_PROVIDER=kavenegar` | -| رنگینه | `RanginehProvider` | `SMS_PROVIDER=rangineh` | +### پیامک ---- +**KaveNegar** (پیش‌فرض) +- API: `https://api.kavenegar.com/v1/{KEY}/sms/send.json` +- پشتیبانی از: send (متن آزاد) + sendTemplate (قالب) + +**Rangineh** +- API: `https://rest.payamresan.com/api/v1/send` +- پشتیبانی از: send + sendTemplate + +**ارسال async:** +```php +$this->smsService->dispatchAsync($mobile, $message); +// → Symfony Messenger → Redis → SendSmsHandler +``` + +### درگاه پرداخت + +**بانک ملت (Mellat)** +- SOAP WebService: `https://bpm.shaparak.ir/pgwchannel/services/pgw?wsdl` +- تأیید: ResCode=0 + SettlePayment + +**سپ (SEP)** +- REST API با ترمینال ID ### Redis -- صف پیام (Symfony Messenger) -- کش کدهای OTP -- کش Refresh Tokens -- Rate limiter storage +- صف پیام: `redis://redis:6379/messages` +- Cache: `redis://redis:6379` +- Refresh Token: کلید `refresh_` --- -### JWT Keys +## ۱۳. ساختار دایرکتوری ``` -config/jwt/private.pem ← کلید خصوصی (در .gitignore) -config/jwt/public.pem ← کلید عمومی (در .gitignore) +clinic-pro-symfony/ +├── assets/ +│ └── admin/ # React SPA (پنل ادمین) +│ ├── App.tsx # React Router routes +│ ├── pages/ # یک فایل به ازای هر صفحه +│ ├── components/ +│ │ ├── layout/ # AdminLayout, Sidebar, Topbar +│ │ └── ui/ # DataTable, Modal, ConfirmDialog, Pagination, ... +│ ├── lib/ +│ │ ├── api.ts # fetch wrapper (JWT از localStorage) +│ │ └── utils.ts # formatRial, formatDate, formatDateTime, formatNumber +│ ├── stores/ +│ │ ├── authStore.ts # Zustand — token, isAuthenticated +│ │ └── uiStore.ts # sidebar state +│ ├── types/index.ts # TypeScript interfaces +│ └── styles.css # کلاس‌های template +├── config/ +│ ├── packages/ +│ │ ├── security.yaml # firewalls, access_control +│ │ └── lexik_jwt_authentication.yaml +│ └── services.yaml # dependency injection +├── docs/ +│ └── tasks/ # مستندات فنی هر feature +├── migrations/ # Doctrine migrations +├── src/ +│ ├── Admin/Controller/ # AdminApiController — همه endpoint های ادمین +│ ├── Appointment/ # نوبت‌دهی، زمان‌بندی، SlotCalculatorService +│ ├── Auth/ # احراز هویت، JWT، OTP، User entity +│ ├── Blog/ # بلاگ +│ ├── Category/ # کتگوری legacy (bundle system) +│ ├── Clinic/ # کلینیک +│ ├── ClinicInvitation/ # دعوتنامه پزشک به کلینیک +│ ├── Doctor/ # پزشک، آدرس مطب +│ ├── DoctorService/ # خدمات پزشکی +│ ├── Insurance/ # بیمه +│ ├── Location/ # استان، شهر +│ ├── Payment/ # پرداخت، درگاه‌ها (Mellat, Sep) +│ ├── Rating/ # امتیاز، نظرات، لایک +│ ├── Representation/ # نمایندگان +│ ├── Secretary/ # منشی +│ ├── Settlement/ # تسویه‌حساب، کیف پول +│ ├── Sms/ # پیامک، قالب، لاگ +│ ├── Specialty/ # تخصص پزشکی +│ ├── Tag/ # تگ بلاگ +│ ├── UserProfile/ # پروفایل پزشکی کاربر +│ └── Shared/ +│ ├── Controller/BaseController.php # success() / paginated() / error() +│ ├── Exception/AppException.php # domain exception با httpStatus +│ ├── Constant/ErrorCodes.php # کدهای خطا به صورت const string +│ └── EventSubscriber/ExceptionSubscriber.php +├── public/ +│ └── build/ # webpack output (gitignore شده) +├── var/ +│ └── uploads/ # فایل‌های آپلود‌شده +├── .ddev/config.yaml # PHP 8.3، MariaDB 11.8، nginx-fpm +├── .env # متغیرهای پیش‌فرض (commit نشود .env.local) +└── webpack.config.js # Webpack Encore ``` --- -## خلاصه آماری - -| معیار | تعداد | -|-------|-------| -| Domain modules | ۱۵ | -| PHP Controllers | ۱۷ | -| API Endpoints | ۹۷ (مستندسازی‌شده در Swagger) | -| Doctrine Entities | ۲۲ | -| Database Tables | ۳۰ | -| Migrations | ۱۷ | -| React Pages | ۲۳ مسیر (کاملاً پیاده‌سازی‌شده) | -| OpenAPI Tags | ۱۱ گروه | -| npm packages | ۳۴ | -| composer packages | ۲۸ | +> **یادآور توسعه:** تمام دستورات را با پیشوند `ddev exec` اجرا کنید. +> CSS فرانت‌اند از کلاس‌های template اختصاصی استفاده می‌کند (نه Tailwind). +> بعد از هر تغییر backend: `cache:clear` +> بعد از هر تغییر entity: `migrations:diff` سپس `migrations:migrate` diff --git a/assets/admin/pages/ClinicDetailPage.tsx b/assets/admin/pages/ClinicDetailPage.tsx index 846f7caf..1eef876a 100644 --- a/assets/admin/pages/ClinicDetailPage.tsx +++ b/assets/admin/pages/ClinicDetailPage.tsx @@ -258,8 +258,9 @@ async function geocodeCity(name: string): Promise<[number, number] | null> { // ── Edit Modal ───────────────────────────────────────────────────────────── -function EditModal({ clinic, onClose, onSaved }: { +function EditModal({ clinic, onClose, onSaved, initialTab = 'basic' }: { clinic: ClinicDetail; onClose: () => void; onSaved: () => void; + initialTab?: 'basic' | 'location' | 'tags'; }) { const [mapFlyTarget, setMapFlyTarget] = useState<[number, number] | null>(null); @@ -347,7 +348,7 @@ function EditModal({ clinic, onClose, onSaved }: { onError: (e: Error) => toast.error(e.message), }); - const [activeTab, setActiveTab] = useState<'basic' | 'location' | 'tags'>('basic'); + const [activeTab, setActiveTab] = useState<'basic' | 'location' | 'tags'>(initialTab); return (
@@ -567,9 +568,10 @@ export default function ClinicDetailPage() { const { uuid } = useParams<{ uuid: string }>(); const navigate = useNavigate(); const qc = useQueryClient(); - const [editOpen, setEditOpen] = useState(false); - const [deleteOpen, setDeleteOpen] = useState(false); - const [inviteOpen, setInviteOpen] = useState(false); + const [editOpen, setEditOpen] = useState(false); + const [editInitialTab, setEditInitialTab] = useState<'basic' | 'location' | 'tags'>('basic'); + const [deleteOpen, setDeleteOpen] = useState(false); + const [inviteOpen, setInviteOpen] = useState(false); const [doctorsTab, setDoctorsTab] = useState<'doctors' | 'invitations'>('doctors'); const logoInputRef = useRef(null); const galleryInputRef = useRef(null); @@ -606,6 +608,11 @@ export default function ClinicDetailPage() { const invitationList: ClinicInvitation[] = invitationsQ.data?.data ?? []; + const openEdit = (tab: 'basic' | 'location' | 'tags' = 'basic') => { + setEditInitialTab(tab); + setEditOpen(true); + }; + const toggleMut = useMutation({ mutationFn: () => api.patch>(`/api/v1/admin/clinic/${uuid}/status`, {}), onSuccess: () => { @@ -679,8 +686,8 @@ export default function ClinicDetailPage() { const json = await res.json(); const url = json?.data?.url; if (url && clinic) { - const existing = (clinic.images_clinic ?? []).filter(img => img?.url).map(img => img.url); - await api.patch(`/api/v1/clinic/${uuid}`, { image_clinic: [...existing, url] }); + const existing = (clinic.images_clinic ?? []).filter(img => img?.url); + await api.patch(`/api/v1/clinic/${uuid}`, { image_clinic: [...existing, { url }] }); toast.success('تصویر اضافه شد'); qc.invalidateQueries({ queryKey: ['clinic-detail', uuid] }); } @@ -735,7 +742,7 @@ export default function ClinicDetailPage() {
- +
- {/* Insurances */} -
- - بیمه‌ها ({formatNumber((clinic.list_bime ?? []).length)}) - - {(clinic.list_bime ?? []).length === 0 - ?

بیمه‌ای ثبت نشده

- :
- {clinic.list_bime.map(ins => ( - {ins.name} - ))} +
+
+
+ تخصص‌ها ({formatNumber((clinic.specialties ?? []).length)})
- } -
+ {(clinic.specialties ?? []).length === 0 + ?

ثبت نشده

+ :
+ {clinic.specialties.map(s => ( + {s.name} + ))} +
+ } +
- {/* Services */} -
- - خدمات ({formatNumber((clinic.services ?? []).length)}) - - {(clinic.services ?? []).length === 0 - ?

خدمتی ثبت نشده

- :
- {clinic.services.map(s => ( - {s.name} - ))} +
+
+ بیمه‌ها ({formatNumber((clinic.list_bime ?? []).length)})
- } + {(clinic.list_bime ?? []).length === 0 + ?

ثبت نشده

+ :
+ {clinic.list_bime.map(ins => ( + {ins.name} + ))} +
+ } +
+ +
+
+ خدمات ({formatNumber((clinic.services ?? []).length)}) +
+ {(clinic.services ?? []).length === 0 + ?

ثبت نشده

+ :
+ {clinic.services.map(s => ( + {s.name} + ))} +
+ } +
+
- {/* Edit modal */} - {editOpen && ( - setEditOpen(false)} - onSaved={() => { qc.invalidateQueries({ queryKey: ['clinic-detail', uuid] }); qc.invalidateQueries({ queryKey: ['admin-clinics'] }); }} /> + {/* Edit modal — portal to escape Leaflet transform context */} + {editOpen && createPortal( + setEditOpen(false)} + onSaved={() => { qc.invalidateQueries({ queryKey: ['clinic-detail', uuid] }); qc.invalidateQueries({ queryKey: ['admin-clinics'] }); }} + />, + document.body, )} {/* Invite doctor modal */} - {inviteOpen && uuid && ( + {inviteOpen && uuid && createPortal( setInviteOpen(false)} onInvited={() => { qc.invalidateQueries({ queryKey: ['clinic-invitations', uuid] }); setDoctorsTab('invitations'); }} - /> + />, + document.body, )} {/* Delete confirm */} diff --git a/assets/admin/styles.css b/assets/admin/styles.css index 1534df93..fc9a2aa2 100644 --- a/assets/admin/styles.css +++ b/assets/admin/styles.css @@ -523,7 +523,7 @@ table.t tbody tr:hover .row-actions { opacity: 1; } /* ── Modal ───────────────────────────────────────────────────── */ .overlay { - position: fixed; inset: 0; z-index: 80; display: grid; place-items: center; padding: 20px; + position: fixed; inset: 0; z-index: 1000; display: grid; place-items: center; padding: 20px; background: rgba(8,13,22,.5); backdrop-filter: blur(4px); -webkit-backdrop-filter: blur(4px); animation: fade-in .2s var(--ease); }