From 5cdcec23a9f51ad0f0b99a7c02e743c5a05b8a8c Mon Sep 17 00:00:00 2001 From: hamed <15238-genius.ha@users.noreply.drupalcode.org> Date: Mon, 15 Jun 2026 11:03:56 +0330 Subject: [PATCH] feat: enhance staff management and payment gateway features - Fix national code handling in staff creation and updates to support Persian digits. - Update ClinicStaff entity to allow longer national codes (up to 15 characters). - Implement support for clinic secretaries in SecretaryController, allowing creation without a doctor UUID. - Add a new endpoint to retrieve doctors associated with a clinic for secretary management. - Improve appointment management by ensuring doctors are selectable even when no appointments exist. - Extend PatientController to allow secretaries to create patient records if they have the appropriate permissions. - Introduce a PriceInput component for better price formatting in forms, supporting Persian digits. - Add a MockGateway for testing payment processes without real transactions. - Enhance SMS settings management with an approval flow for post-visit text messages, including new fields for pending text and status. - Update migrations to reflect changes in database schema for national codes and SMS settings. --- .claude/prompt/fix-bugs-and-features.md | 447 ++++++++++++++++++ assets/admin/components/ui/PriceInput.tsx | 63 +++ assets/admin/pages/AppointmentsPage.tsx | 19 +- assets/admin/pages/ClinicServicesPage.tsx | 8 +- assets/admin/pages/MySecretariesPage.tsx | 117 ++++- assets/admin/pages/SettingsPage.tsx | 197 +++++++- assets/admin/pages/SmsPage.tsx | 98 +++- assets/admin/pages/SmsWalletPage.tsx | 42 +- assets/admin/types/index.ts | 3 + config/services.yaml | 16 +- docs/api/admin.md | 31 +- docs/api/patient.md | 2 +- docs/api/secretary.md | 64 ++- docs/api/sms.md | 43 ++ docs/api/staff.md | 2 +- migrations/Version20260615060415.php | 31 ++ migrations/Version20260615061938.php | 31 ++ .../Controller/SiteConfigController.php | 13 + .../Repository/SiteConfigRepository.php | 13 + src/Patient/Controller/PatientController.php | 23 +- src/Payment/Controller/PaymentController.php | 8 + src/Payment/Gateway/MellatGateway.php | 35 +- src/Payment/Gateway/MockGateway.php | 27 ++ src/Payment/Gateway/SepGateway.php | 15 +- .../Controller/SecretaryController.php | 51 +- src/Secretary/Entity/DoctorSecretary.php | 22 +- .../Repository/DoctorSecretaryRepository.php | 23 + src/Sms/Controller/SmsWalletController.php | 72 ++- src/Sms/Entity/SmsSettings.php | 63 ++- src/Sms/Provider/KavehNegarProvider.php | 17 +- src/Staff/Controller/StaffController.php | 17 +- src/Staff/Entity/ClinicStaff.php | 2 +- 32 files changed, 1487 insertions(+), 128 deletions(-) create mode 100644 .claude/prompt/fix-bugs-and-features.md create mode 100644 assets/admin/components/ui/PriceInput.tsx create mode 100644 migrations/Version20260615060415.php create mode 100644 migrations/Version20260615061938.php create mode 100644 src/Payment/Gateway/MockGateway.php diff --git a/.claude/prompt/fix-bugs-and-features.md b/.claude/prompt/fix-bugs-and-features.md new file mode 100644 index 00000000..5ed7914b --- /dev/null +++ b/.claude/prompt/fix-bugs-and-features.md @@ -0,0 +1,447 @@ +# رفع باگ‌ها و افزودن قابلیت‌های جدید — Phase 3 + +## زمینه + +چندین باگ و قابلیت جدید شناسایی شده‌اند که باید در یک session رفع/پیاده‌سازی شوند: +- باگ‌های backend روی staff/secretary/appointments +- قابلیت‌های جدید: مدیریت درگاه پرداخت و SMS از پنل ادمین، post-visit text approval flow + +--- + +## مشکل / هدف + +### باگ ۱ — خطای 500 در ایجاد پرسنل (national_code > 10 chars) + +`POST /api/v1/staff` با `national_code: "۴۲۳۲۰۵۱۰۹۸۷"` → HTTP 500 + +**ریشه:** `ClinicStaff.nationalCode` column طول ۱۰ دارد ولی کد ملی ۱۱ رقم فارسی ارسال شده. باید: +1. طول column به ۲۰ افزایش یابد (برای ارقام فارسی که ۲بایتی هستند) +2. یا ارقام فارسی به لاتین تبدیل شوند قبل از ذخیره + +**راه‌حل:** ارقام فارسی را در controller به لاتین تبدیل کن + طول column را به ۱۵ افزایش بده. + +```php +// در StaffController::create و update +$data['national_code'] = $this->toLatinDigits($data['national_code'] ?? null); + +private function toLatinDigits(?string $str): ?string { + if ($str === null) return null; + return strtr($str, ['۰'=>'0','۱'=>'1','۲'=>'2','۳'=>'3','۴'=>'4','۵'=>'5','۶'=>'6','۷'=>'7','۸'=>'8','۹'=>'9', + '٠'=>'0','١'=>'1','٢'=>'2','٣'=>'3','٤'=>'4','٥'=>'5','٦'=>'6','٧'=>'7','٨'=>'8','٩'=>'9']); +} +``` + +### باگ ۲ — doctor_uuid: null در ایجاد منشی از پنل کلینیک + +`POST /api/v1/secretary` با `{mobile_number: "09...", doctor_uuid: null}` → HTTP 422 + +**ریشه:** `MySecretariesPage.tsx` از `useAuthStore().doctorUuid` استفاده می‌کند. وقتی کاربر `clinic` است، `doctorUuid` در auth store null است چون clinic دارای uuid پزشک نیست. + +**جریان فعلی:** `SecretaryController::create` فقط doctor_uuid می‌پذیرد و clinic نمی‌تواند منشی اضافه کند. + +**راه‌حل — دو بخش:** + +**الف — Backend:** `SecretaryController::create` باید clinic_uuid هم بپذیرد: +```php +// اگر doctor_uuid خالی بود و clinic_uuid داشتیم، منشی برای کلینیک ایجاد شود +$doctorUuid = trim($data['doctor_uuid'] ?? ''); +$clinicUuid = trim($data['clinic_uuid'] ?? ''); + +if (!empty($clinicUuid) && empty($doctorUuid)) { + // منشی برای clinic — از ClinicRepository پیدا کن + $clinic = $this->clinicRepo->findByUuid($clinicUuid); + if ($clinic === null) return $this->error(...); + // clinic می‌تواند منشی داشته باشد — DoctorSecretary نیست، باید entity جدید یا متفاوت باشد +} +``` + +**توجه مهم:** `DoctorSecretary` entity مستقیماً به `Doctor` وصل است. برای clinic باید بررسی کنی: +- آیا `DoctorSecretary` دارای فیلد clinic FK هم هست؟ +- اگر نه، باید تصمیم بگیری: یا کلینیک یکی از دکتران خود را انتخاب کند که منشی برای آن تعریف شود، یا یک entity جدید `ClinicSecretary` بسازیم. + +**پیشنهاد ساده‌تر (frontend):** در `MySecretariesPage.tsx`، وقتی `primaryRole === 'clinic'`، یک dropdown نشان بده که کاربر ابتدا یکی از پزشکان کلینیک را انتخاب کند، سپس منشی برای آن پزشک تعریف شود. این تغییر فقط frontend است و API دست نمی‌خورد. + +اگر clinic دکتری ندارد → پیام «ابتدا یک پزشک به کلینیک اضافه کنید». + +```tsx +// در MySecretariesPage.tsx +const { dbUuid, primaryRole, doctorUuid } = useAuthStore(); + +// کلینیک باید پزشک انتخاب کند +const [selectedDoctorUuid, setSelectedDoctorUuid] = useState( + primaryRole === 'doctor' ? (doctorUuid ?? '') : '' +); + +// query پزشکان کلینیک (فقط برای clinic) +const { data: clinicDoctorsData } = useQuery({ + queryKey: ['clinic-doctors-for-secretary', dbUuid], + queryFn: () => api.get(`/api/v1/appointments/clinic-doctors/${dbUuid}`), // endpoint موجود را بررسی کن + enabled: primaryRole === 'clinic' && !!dbUuid, +}); +``` + +**بررسی endpoint موجود برای لیست پزشکان کلینیک:** بررسی کن آیا endpoint‌ای برای لیست پزشکان یک کلینیک وجود دارد. اگر نه، باید در AdminApiController یا ClinicController اضافه شود. + +### باگ ۳ — در AppointmentsPage پزشکی برای انتخاب نیست (پنل کلینیک) + +**ریشه:** `doctors` از `appointments` محاسبه می‌شود (unique doctorها از نتایج query). اگر هنوز نوبتی ثبت نشده باشد، لیست پزشکان خالی است پس در `SearchableSelect` چیزی نمایش داده نمی‌شود. + +**راه‌حل:** یک endpoint برای لیست پزشکان کلینیک بساز و در AppointmentsPage برای role=clinic استفاده کن: + +```tsx +// در AppointmentsPage.tsx +const { data: clinicDoctorsData } = useQuery>({ + queryKey: ['clinic-doctors', dbUuid], + queryFn: () => api.get(`/api/v1/clinic/${dbUuid}/doctors`), + enabled: isClinic && !!dbUuid, +}); + +// merge با doctors از appointments +const allDoctors = React.useMemo(() => { + const fromAppts = doctors; // موجود + const fromClinc = (clinicDoctorsData?.data ?? []).map(d => ({ uuid: d.uuid, name: d.name })); + const merged = new Map(); + [...fromClinc, ...fromAppts].forEach(d => merged.set(d.uuid, d.name)); + return Array.from(merged.entries()).map(([uuid, name]) => ({ uuid, name })); +}, [doctors, clinicDoctorsData]); +``` + +**Backend endpoint جدید:** `GET /api/v1/clinic/{uuid}/doctors` — در `ClinicController` یا `AdminApiController` اضافه شود: +```php +#[Route('/api/v1/clinic/{uuid}/doctors', methods: ['GET'])] +public function clinicDoctors(string $uuid, #[CurrentUser] User $user): JsonResponse +{ + $clinic = $this->clinicRepo->findByUuid($uuid); + // بررسی دسترسی: فقط صاحب clinic یا admin + // برگرداندن لیست پزشکان: uuid + نام +} +``` + +### باگ ۴ — منشی نمی‌تواند پرونده بیمار تشکیل دهد + +**ریشه:** `PatientController::resolveEntity` فقط `ROLE_DOCTOR` و `ROLE_CLINIC` بررسی می‌کند. برای `ROLE_SECRETARY` عدد null برمی‌گرداند و `assertPatientGate` خطای 403 می‌دهد. + +**راه‌حل:** `resolveEntity` را در PatientController گسترش بده تا secretary را پشتیبانی کند: + +```php +// src/Patient/Controller/PatientController.php +// اضافه کردن SecretaryRepository به constructor +private readonly DoctorSecretaryRepository $secretaryRepo, + +private function resolveEntity(User $user): array +{ + if ($user->hasRole('ROLE_DOCTOR')) { + $doctor = $this->doctorRepo->findByUser($user); + return $doctor !== null ? ['doctor', $doctor->getId()] : ['doctor', null]; + } + + if ($user->hasRole('ROLE_CLINIC')) { + $clinic = $this->clinicRepo->findByUser($user); + return $clinic !== null ? ['clinic', $clinic->getId()] : ['clinic', null]; + } + + if ($user->hasRole('ROLE_SECRETARY')) { + // secretary دسترسی به entity پزشک خود را دارد اگر permissions.appointments.view = true باشد + $secretary = $this->secretaryRepo->findActiveByUser($user); + if ($secretary !== null && ($secretary->getPermissions()['appointments']['view'] ?? false)) { + return ['doctor', $secretary->getDoctor()->getId()]; + } + } + + return ['unknown', null]; +} +``` + +**بررسی `findActiveByUser`:** آیا این متد در `DoctorSecretaryRepository` وجود دارد؟ اگر نه، اضافه‌اش کن: +```php +public function findActiveByUser(User $user): ?DoctorSecretary +{ + return $this->findOneBy(['secretary' => $user, 'active' => true]); +} +``` + +--- + +### قابلیت ۵ — فرمت قیمت با کاما در فرم‌ها + +در فرم‌های input قیمت (ClinicServicesPage، SmsWalletPage، SubscriptionPage)، وقتی کاربر عدد می‌زند باید با کاما نمایش داده شود، ولی مقدار واقعی (number) ذخیره شود. + +**راه‌حل:** یک component `PriceInput` در `assets/admin/components/ui/` بساز: + +```tsx +// assets/admin/components/ui/PriceInput.tsx +interface PriceInputProps { + value: number | ''; + onChange: (value: number) => void; + placeholder?: string; + min?: number; +} + +export default function PriceInput({ value, onChange, placeholder, min = 0 }: PriceInputProps) { + const [display, setDisplay] = useState(value ? new Intl.NumberFormat('fa-IR').format(Number(value)) : ''); + + const handleChange = (e: React.ChangeEvent) => { + // حذف همه کاراکترهای غیر عددی (فارسی و لاتین) + const raw = e.target.value.replace(/[^\d۰-۹٠-٩]/g, ''); + // تبدیل به لاتین + const latin = raw.replace(/[۰-۹]/g, d => String('۰۱۲۳۴۵۶۷۸۹'.indexOf(d))); + const num = parseInt(latin, 10) || 0; + onChange(num); + setDisplay(num ? new Intl.NumberFormat('fa-IR').format(num) : ''); + }; + + return ( + + ); +} +``` + +استفاده در `ClinicServicesPage.tsx`: +```tsx +// جایگزین کردن + +// با + itemForm.setValue('price_rials', v)} + min={0} + placeholder="85,000" +/> +``` + +--- + +### قابلیت ۶ — مدیریت درگاه‌های پرداخت از پنل ادمین + +**Backend:** +1. کلیدهای پرداخت را در `SiteConfig` ذخیره کن (نه .env) +2. `SiteConfigController::ALLOWED_KEYS` را گسترش بده: + +```php +private const ALLOWED_KEYS = [ + // ... موجود ... + // درگاه‌های پرداخت + 'mellat_terminal_id', + 'mellat_username', + 'mellat_password', + 'sep_terminal_id', + 'sep_enabled', + 'mellat_enabled', + // درگاه SMS + 'sms_provider', // kavenegar / mock + 'kavenegar_api_key', + 'kavenegar_sender', + 'sms_price_rials', + // درگاه تست + 'payment_test_mode', // true/false +]; +``` + +3. `MellatGateway` و `SepGateway` را تغییر بده تا از `SiteConfigRepository` بخوانند به جای env: + +```php +// MellatGateway.php +class MellatGateway implements PaymentGatewayInterface +{ + public function __construct( + private readonly HttpClientInterface $httpClient, + private readonly SiteConfigRepository $configRepo, + ) {} + + private function getTerminalId(): string { + return $this->configRepo->get('mellat_terminal_id') ?? $_ENV['MELLAT_TERMINAL_ID'] ?? ''; + } + // همانند برای username و password +``` + +4. یک **Mock Gateway** (درگاه تست) اضافه کن: + +```php +// src/Payment/Gateway/MockGateway.php +class MockGateway implements PaymentGatewayInterface +{ + public function getName(): string { return 'mock'; } + + public function initiate(int $amountRials, string $orderId, string $callbackUrl): PaymentInitResult + { + // redirect به callback مستقیم با success + $successUrl = $callbackUrl . '&status=success&mock=1&order_id=' . $orderId; + return new PaymentInitResult(true, redirectUrl: $successUrl, token: 'mock-' . $orderId); + } + + public function verify(array $callbackData): PaymentVerifyResult + { + return new PaymentVerifyResult(true, refId: 'MOCK-' . time()); + } +} +``` + +5. در `PaymentController` (یا جایی که gateway انتخاب می‌شود)، وقتی `payment_test_mode = true` در config، از `MockGateway` استفاده کن. + +**Frontend — صفحه جدید SettingsPage یا بخشی از SettingsPage:** + +فعلاً `SettingsPage.tsx` در `assets/admin/pages/` وجود دارد. بخش «درگاه پرداخت» را به آن اضافه کن: + +```tsx +// در SettingsPage.tsx بخش جدید اضافه کن +// یا یک صفحه جدید GatewaySettingsPage.tsx بساز + +const PAYMENT_KEYS = ['mellat_enabled', 'mellat_terminal_id', 'mellat_username', 'mellat_password', + 'sep_enabled', 'sep_terminal_id', 'payment_test_mode']; +const SMS_KEYS = ['sms_provider', 'kavenegar_api_key', 'kavenegar_sender', 'sms_price_rials']; +``` + +فیلدها: +- `payment_test_mode`: toggle — اگر true، درگاه mock استفاده شود +- `mellat_enabled` / `sep_enabled`: toggle +- `mellat_terminal_id`, `mellat_username`, `mellat_password`: text fields (password masked) +- `sep_terminal_id`: text field +- `sms_provider`: select (kavenegar / mock) +- `kavenegar_api_key`: text field +- `kavenegar_sender`: text field +- `sms_price_rials`: number + +--- + +### قابلیت ۷ — approval flow برای post-visit SMS text + +**وضعیت فعلی:** `SmsSettings.post_visit_text` یک فیلد ساده text است. وقتی doctor/clinic متن می‌نویسد، مستقیم ذخیره و استفاده می‌شود. + +**هدف:** متن post-visit باید ابتدا توسط ادمین تأیید شود قبل از اینکه در ارسال SMS استفاده شود. + +**Backend — تغییرات Entity:** + +در `SmsSettings` سه فیلد جدید اضافه کن: +```php +#[ORM\Column(name: 'post_visit_text_pending', type: 'text', nullable: true)] +private ?string $postVisitTextPending = null; // متن در انتظار تأیید + +#[ORM\Column(name: 'post_visit_text_status', type: 'string', length: 20)] +private string $postVisitTextStatus = 'none'; // none | pending | approved | rejected + +#[ORM\Column(name: 'post_visit_text_reject_reason', type: 'text', nullable: true)] +private ?string $postVisitTextRejectReason = null; +``` + +منطق: +- وقتی doctor/clinic متن جدید ارسال می‌کند → `postVisitTextPending` ذخیره شود، `postVisitTextStatus = 'pending'`، `postVisitText` (approved) دست نخورد +- وقتی ادمین approve می‌کند → `postVisitText = postVisitTextPending`، `postVisitTextStatus = 'approved'` +- وقتی ادمین reject می‌کند → `postVisitTextStatus = 'rejected'`، `postVisitTextRejectReason` ذخیره شود + +**Backend — endpoint ادمین:** + +در `SmsWalletController` یا یک controller جدید اضافه کن: +```php +#[Route('/api/v1/admin/sms/settings/review', methods: ['GET'])] +#[IsGranted('ROLE_ADMIN')] +public function pendingReviews(Request $request): JsonResponse +{ + // لیست همه sms_settings که post_visit_text_status = 'pending' +} + +#[Route('/api/v1/admin/sms/settings/{id}/approve', methods: ['POST'])] +#[IsGranted('ROLE_ADMIN')] +public function approvePostVisit(int $id): JsonResponse +{ + // $settings->setPostVisitText($settings->getPostVisitTextPending()); + // $settings->setPostVisitTextStatus('approved'); +} + +#[Route('/api/v1/admin/sms/settings/{id}/reject', methods: ['POST'])] +#[IsGranted('ROLE_ADMIN')] +public function rejectPostVisit(int $id, Request $request): JsonResponse +{ + // $settings->setPostVisitTextStatus('rejected'); + // $settings->setPostVisitTextRejectReason($data['reason']); +} +``` + +**Backend — تغییر `updateSettings`:** + +در `PATCH /api/v1/sms/settings`: +```php +if (array_key_exists('post_visit_text', $data)) { + // به جای ذخیره مستقیم، در pending بگذار + $settings->setPostVisitTextPending($data['post_visit_text']); + $settings->setPostVisitTextStatus('pending'); + $settings->setPostVisitTextRejectReason(null); + // postVisitText (approved) دست نخورد +} +``` + +**Frontend — SmsWalletPage.tsx:** + +نمایش وضعیت pending/approved/rejected: +```tsx +// بعد از textarea متن پیامک +{currentSettings.post_visit_text_status === 'pending' && ( +
+ متن شما در انتظار تأیید مدیر است +
+)} +{currentSettings.post_visit_text_status === 'approved' && ( +
+ ✓ متن تأیید شده — در حال استفاده +
+)} +{currentSettings.post_visit_text_status === 'rejected' && ( +
+ رد شد: {currentSettings.post_visit_text_reject_reason} +
+)} +``` + +**Frontend — بخش ادمین در SmsPage.tsx یا پنل مدیریت:** + +تب یا بخش جدید «تأیید متن پیامک» در `SmsPage.tsx` اضافه کن که لیست pending reviews را نشان دهد. + +--- + +## فایل‌های مرتبط + +| فایل | نقش | +|------|-----| +| `src/Staff/Controller/StaffController.php` | باگ ۱: national_code validation | +| `src/Staff/Entity/ClinicStaff.php` | باگ ۱: column length | +| `migrations/` | باید migration جدید بسازی | +| `src/Secretary/Controller/SecretaryController.php` | باگ ۲: clinic secretary support | +| `src/Patient/Controller/PatientController.php` | باگ ۴: secretary resolveEntity | +| `src/Patient/Repository/PatientRecordRepository.php` | بررسی findActiveByUser | +| `src/Secretary/Repository/DoctorSecretaryRepository.php` | اضافه کردن findActiveByUser | +| `src/Clinic/Controller/ClinicController.php` | endpoint لیست پزشکان کلینیک | +| `src/Config/Controller/SiteConfigController.php` | قابلیت ۶: allowed keys گسترش | +| `src/Payment/Gateway/MellatGateway.php` | قابلیت ۶: از config بخواند | +| `src/Payment/Gateway/SepGateway.php` | قابلیت ۶: از config بخواند | +| `src/Payment/Gateway/MockGateway.php` | قابلیت ۶: درگاه تست (جدید) | +| `src/Sms/Entity/SmsSettings.php` | قابلیت ۷: فیلدهای approval | +| `src/Sms/Controller/SmsWalletController.php` | قابلیت ۷: updateSettings تغییر + endpoint ادمین | +| `assets/admin/pages/AppointmentsPage.tsx` | باگ ۳: clinic doctors query | +| `assets/admin/pages/MySecretariesPage.tsx` | باگ ۲: clinic با انتخاب پزشک | +| `assets/admin/pages/SmsWalletPage.tsx` | قابلیت ۷: نمایش وضعیت | +| `assets/admin/pages/SettingsPage.tsx` | قابلیت ۶: بخش درگاه‌های پرداخت | +| `assets/admin/pages/SmsPage.tsx` | قابلیت ۷: تأیید/رد متن (ادمین) | +| `assets/admin/components/ui/PriceInput.tsx` | قابلیت ۵: جدید | +| `assets/admin/lib/utils.ts` | قابلیت ۵: `toLatinDigits` helper | + +--- + +## نکات مهم + +1. **ترتیب اجرا:** ابتدا باگ‌ها (۱→۲→۳→۴)، بعد قابلیت‌ها (۵→۶→۷) +2. **Migration لازم است** برای: `ClinicStaff.national_code` (length)، `SmsSettings` (سه فیلد جدید) +3. **`payment_test_mode`:** وقتی true است، gateway selector در SmsWalletPage باید `mock` را هم نشان بدهد +4. **Gateway از config:** اگر config خالی بود → fallback به env variable (backward compatible) +5. **`SiteConfigRepository::getAll()`:** مراقب باش کلیدهای حساس (password، api_key) را در response برنگردانی — یا mask کن (مثلاً `***` نشان بده) یا از GET response حذف کن +6. **approval flow:** فقط `postVisitText` (approved) در ارسال SMS واقعی استفاده شود؛ `postVisitTextPending` هرگز مستقیم ارسال نشود +7. **PatientController + secretary:** secretary فقط اگر `permissions.appointments.view = true` باشد می‌تواند پرونده ببیند — بررسی کن `patient_records` feature هم در subscription secretary لازم است یا نه +8. **`formatRial` موجود است** در `utils.ts` و از `Intl.NumberFormat('fa-IR')` استفاده می‌کند — `PriceInput` هم باید همین format را داشته باشد +9. **کلید `toLatinDigits`:** این helper را در `utils.ts` export کن تا در backend هم بتوانیم از آن الهام بگیریم (PHP version) +10. **docs/api باید update شود** برای هر endpoint جدید/تغییریافته: `docs/api/staff.md`، `docs/api/secretary.md`، `docs/api/admin.md`، `docs/api/sms.md` diff --git a/assets/admin/components/ui/PriceInput.tsx b/assets/admin/components/ui/PriceInput.tsx new file mode 100644 index 00000000..0f55f785 --- /dev/null +++ b/assets/admin/components/ui/PriceInput.tsx @@ -0,0 +1,63 @@ +import React, { useEffect, useState } from 'react'; + +interface PriceInputProps { + value: number | ''; + onChange: (value: number) => void; + placeholder?: string; + className?: string; + style?: React.CSSProperties; + disabled?: boolean; + min?: number; +} + +const PERSIAN_TO_LATIN: Record = { + '۰': '0', '۱': '1', '۲': '2', '۳': '3', '۴': '4', + '۵': '5', '۶': '6', '۷': '7', '۸': '8', '۹': '9', + '٠': '0', '١': '1', '٢': '2', '٣': '3', '٤': '4', + '٥': '5', '٦': '6', '٧': '7', '٨': '8', '٩': '9', +}; + +function toLatinDigits(str: string): string { + return str.replace(/[۰-۹٠-٩]/g, (ch) => PERSIAN_TO_LATIN[ch] ?? ch); +} + +function formatDisplay(num: number): string { + if (num === 0) return ''; + return new Intl.NumberFormat('fa-IR').format(num); +} + +export default function PriceInput({ + value, + onChange, + placeholder = '0', + className, + style, + disabled, + min = 0, +}: PriceInputProps) { + const [display, setDisplay] = useState(() => (value !== '' && value > 0 ? formatDisplay(value) : '')); + + useEffect(() => { + setDisplay(value !== '' && Number(value) > 0 ? formatDisplay(Number(value)) : ''); + }, [value]); + + const handleChange = (e: React.ChangeEvent) => { + const raw = toLatinDigits(e.target.value).replace(/[^0-9]/g, ''); + const num = raw === '' ? 0 : Math.max(min, parseInt(raw, 10)); + onChange(num); + setDisplay(num > 0 ? formatDisplay(num) : ''); + }; + + return ( + + ); +} diff --git a/assets/admin/pages/AppointmentsPage.tsx b/assets/admin/pages/AppointmentsPage.tsx index 48789de8..2b4bf199 100644 --- a/assets/admin/pages/AppointmentsPage.tsx +++ b/assets/admin/pages/AppointmentsPage.tsx @@ -551,14 +551,27 @@ export default function AppointmentsPage() { }); const appointments: Appointment[] = apptQuery.data?.data ?? EMPTY_ARR; - // ── Unique doctors from results (for clinic tabs) + // ── Clinic: load doctors from clinic profile (not derived from appointments) + const clinicDoctorsQuery = useQuery>({ + queryKey: ['clinic-doctors', dbUuid], + queryFn: () => api.get(`/api/v1/clinic/doctor-list/${dbUuid}`), + enabled: isClinic && !!dbUuid, + }); + const clinicDoctorsList = clinicDoctorsQuery.data?.data?.data ?? []; + + // ── Unique doctors from results (for clinic tabs) + merge with clinic list const doctors = React.useMemo(() => { const map = new Map(); + // first from clinic API (authoritative list) + clinicDoctorsList.forEach(d => map.set(d.uuid, d.name)); + // then supplement with appointment data (for admin view) appointments.forEach(a => { - if (a.doctor_uuid && a.doctor_name) map.set(a.doctor_uuid, a.doctor_name); + if (a.doctor_uuid && a.doctor_name && !map.has(a.doctor_uuid)) { + map.set(a.doctor_uuid, a.doctor_name); + } }); return Array.from(map.entries()).map(([uuid, name]) => ({ uuid, name })); - }, [appointments]); + }, [appointments, clinicDoctorsList]); const showDoctorTabs = isClinic && doctors.length >= 2; const showDoctorCol = isAdmin || (isClinic && !selectedDoctorUuid); diff --git a/assets/admin/pages/ClinicServicesPage.tsx b/assets/admin/pages/ClinicServicesPage.tsx index e5bc965f..c639411d 100644 --- a/assets/admin/pages/ClinicServicesPage.tsx +++ b/assets/admin/pages/ClinicServicesPage.tsx @@ -10,6 +10,7 @@ import type { ApiResponse } from '../lib/api'; import type { ServiceSection, ServiceItem, ClinicStaff } from '../types'; import { formatRial } from '../lib/utils'; import Modal from '../components/ui/Modal'; +import PriceInput from '../components/ui/PriceInput'; import ConfirmDialog from '../components/ui/ConfirmDialog'; import PageHeader from '../components/ui/PageHeader'; import SearchableSelect from '../components/ui/SearchableSelect'; @@ -359,7 +360,12 @@ export default function ClinicServicesPage() {
- + itemForm.setValue('price_rials', v)} + placeholder="۸۵,۰۰۰" + min={0} + />
diff --git a/assets/admin/pages/MySecretariesPage.tsx b/assets/admin/pages/MySecretariesPage.tsx index eef2ded3..327c701e 100644 --- a/assets/admin/pages/MySecretariesPage.tsx +++ b/assets/admin/pages/MySecretariesPage.tsx @@ -134,35 +134,64 @@ const createSchema = z.object({ }); type CreateForm = z.infer; +interface ClinicDoctor { uuid: string; name: string; } + // ── Main component ───────────────────────────────────────────────────────── export default function MySecretariesPage() { const qc = useQueryClient(); - const { doctorUuid } = useAuthStore(); + const { doctorUuid, dbUuid, primaryRole } = useAuthStore(); + + const isClinic = primaryRole === 'clinic'; + + // for clinic: selected doctor to add secretary for + const [selectedDoctorUuid, setSelectedDoctorUuid] = useState(''); + + const activeDoctorUuid = isClinic ? selectedDoctorUuid : (doctorUuid ?? ''); const [createOpen, setCreateOpen] = useState(false); const [editTarget, setEditTarget] = useState(null); const [editPerms, setEditPerms] = useState(DEFAULT_PERMISSIONS); const [deleteTarget, setDeleteTarget] = useState(null); - const { data, isLoading } = useQuery>({ - queryKey: ['my-secretaries', doctorUuid], - queryFn: () => api.get(`/api/v1/secretaries/${doctorUuid}`), - enabled: !!doctorUuid, + // clinic: load clinic's doctors + const { data: clinicDoctorsData, isLoading: clinicDoctorsLoading } = useQuery>({ + queryKey: ['clinic-doctors', dbUuid], + queryFn: () => api.get(`/api/v1/clinic/doctor-list/${dbUuid}`), + enabled: isClinic && !!dbUuid, + }); + const clinicDoctors: ClinicDoctor[] = clinicDoctorsData?.data?.data ?? []; + + // clinic: load ALL secretaries across all its doctors + const { data: clinicSecrData, isLoading: clinicSecrLoading } = useQuery>({ + queryKey: ['my-secretaries-clinic', dbUuid], + queryFn: () => api.get(`/api/v1/secretaries/clinic/${dbUuid}`), + enabled: isClinic && !!dbUuid, }); - const secretaries = data?.data ?? []; + // doctor: load secretaries for the doctor + const { data: doctorSecrData, isLoading: doctorSecrLoading } = useQuery>({ + queryKey: ['my-secretaries', activeDoctorUuid], + queryFn: () => api.get(`/api/v1/secretaries/${activeDoctorUuid}`), + enabled: !isClinic && !!activeDoctorUuid, + }); + + const secretaries = isClinic + ? (clinicSecrData?.data ?? []) + : (doctorSecrData?.data ?? []); + const isLoading = isClinic ? clinicSecrLoading : doctorSecrLoading; const createForm = useForm({ resolver: zodResolver(createSchema) }); const createMutation = useMutation({ mutationFn: (body: CreateForm) => - api.post('/api/v1/secretary', { ...body, doctor_uuid: doctorUuid }), + api.post('/api/v1/secretary', { ...body, doctor_uuid: activeDoctorUuid }), onSuccess: () => { toast.success('منشی اضافه شد'); setCreateOpen(false); createForm.reset(); qc.invalidateQueries({ queryKey: ['my-secretaries'] }); + qc.invalidateQueries({ queryKey: ['my-secretaries-clinic'] }); }, onError: (e: any) => toast.error(e.message), }); @@ -174,6 +203,7 @@ export default function MySecretariesPage() { toast.success('دسترسی‌ها بروزرسانی شد'); setEditTarget(null); qc.invalidateQueries({ queryKey: ['my-secretaries'] }); + qc.invalidateQueries({ queryKey: ['my-secretaries-clinic'] }); }, onError: (e: any) => toast.error(e.message), }); @@ -184,6 +214,7 @@ export default function MySecretariesPage() { toast.success('منشی غیرفعال شد'); setDeleteTarget(null); qc.invalidateQueries({ queryKey: ['my-secretaries'] }); + qc.invalidateQueries({ queryKey: ['my-secretaries-clinic'] }); }, onError: (e: any) => toast.error(e.message), }); @@ -193,7 +224,28 @@ export default function MySecretariesPage() { setEditPerms(s.permissions ?? DEFAULT_PERMISSIONS); }; - const columns: Column[] = [ + const handleCreateOpen = () => { + if (isClinic && !selectedDoctorUuid) { + toast.error('ابتدا یک پزشک را انتخاب کنید'); + return; + } + setCreateOpen(true); + }; + + const selectedDoctorName = clinicDoctors.find(d => d.uuid === selectedDoctorUuid)?.name ?? ''; + + // for clinic: show doctor column in the table + const clinicColumns: Column[] = isClinic ? [ + { + key: 'doctor_name', + header: 'پزشک', + render: (s) => ( + {s.doctor_name} + ), + }, + ] : []; + + const allColumns: Column[] = [ { key: 'user_name', header: 'منشی', @@ -212,6 +264,7 @@ export default function MySecretariesPage() {
), }, + ...clinicColumns, { key: 'mobile_number', header: 'موبایل', @@ -256,14 +309,39 @@ export default function MySecretariesPage() { title="منشیان من" description="مدیریت منشیان و دسترسی‌های آن‌ها" action={ - } /> - {!doctorUuid ? ( + {/* کلینیک: انتخاب پزشک برای افزودن منشی */} + {isClinic && ( +
+
+ + {clinicDoctorsLoading ? ( +

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

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

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

+ ) : ( + + )} +
+
+ )} + + {!isClinic && !doctorUuid ? (
پروفایل پزشک یافت نشد
@@ -274,20 +352,29 @@ export default function MySecretariesPage() {
هنوز منشی‌ای اضافه نشده
- منشی می‌تواند نوبت‌ها و اطلاعات کلینیک را مدیریت کند + {isClinic + ? 'برای افزودن منشی، ابتدا یک پزشک را از لیست بالا انتخاب کنید' + : 'منشی می‌تواند نوبت‌ها و اطلاعات کلینیک را مدیریت کند'}
- + {!isClinic && ( + + )} ) : ( - + )} )} {/* Modal افزودن منشی */} setCreateOpen(false)} title="افزودن منشی جدید"> + {isClinic && selectedDoctorName && ( +
+ منشی برای دکتر {selectedDoctorName} اضافه می‌شود +
+ )}
createMutation.mutate(d))}>
diff --git a/assets/admin/pages/SettingsPage.tsx b/assets/admin/pages/SettingsPage.tsx index 5e6510a8..39672615 100644 --- a/assets/admin/pages/SettingsPage.tsx +++ b/assets/admin/pages/SettingsPage.tsx @@ -1,4 +1,4 @@ -import React, { useEffect } from 'react'; +import React, { useEffect, useState } from 'react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useForm } from 'react-hook-form'; import { z } from 'zod'; @@ -19,6 +19,17 @@ const schema = z.object({ }, 'درصد باید بین ۰ تا ۱۰۰ باشد'), max_cancel_hours_before: z.string(), appointment_reminder_hours: z.string(), + // payment gateways + payment_test_mode: z.string(), + mellat_terminal_id: z.string(), + mellat_username: z.string(), + mellat_password: z.string(), + sep_terminal_id: z.string(), + // sms + sms_provider: z.string(), + kavenegar_api_key: z.string(), + kavenegar_sender: z.string(), + sms_price_rials: z.string(), }); type FormValues = z.infer; @@ -30,12 +41,23 @@ interface Settings { commission_percent: string; max_cancel_hours_before: string; appointment_reminder_hours: string; + payment_test_mode: string; + mellat_terminal_id: string; + mellat_username: string; + mellat_password: string; + sep_terminal_id: string; + sms_provider: string; + kavenegar_api_key: string; + kavenegar_sender: string; + sms_price_rials: string; } // ── Component ───────────────────────────────────────────────────────────── export default function SettingsPage() { const qc = useQueryClient(); + const [showMellatPassword, setShowMellatPassword] = useState(false); + const [showKavenegarKey, setShowKavenegarKey] = useState(false); const { data, isLoading } = useQuery({ queryKey: ['admin-settings'], @@ -51,6 +73,7 @@ export default function SettingsPage() { handleSubmit, reset, watch, + setValue, formState: { errors, isDirty }, } = useForm({ resolver: zodResolver(schema) }); @@ -63,6 +86,15 @@ export default function SettingsPage() { commission_percent: settings.commission_percent ?? '0', max_cancel_hours_before: settings.max_cancel_hours_before ?? '24', appointment_reminder_hours: settings.appointment_reminder_hours ?? '2', + payment_test_mode: settings.payment_test_mode ?? '0', + mellat_terminal_id: settings.mellat_terminal_id ?? '', + mellat_username: settings.mellat_username ?? '', + mellat_password: settings.mellat_password ?? '', + sep_terminal_id: settings.sep_terminal_id ?? '', + sms_provider: settings.sms_provider ?? 'kavenegar', + kavenegar_api_key: settings.kavenegar_api_key ?? '', + kavenegar_sender: settings.kavenegar_sender ?? '', + sms_price_rials: settings.sms_price_rials ?? '500', }); } }, [settings, reset]); @@ -75,7 +107,8 @@ export default function SettingsPage() { }, }); - const commissionEnabled = watch('commission_enabled') === '1'; + const commissionEnabled = watch('commission_enabled') === '1'; + const paymentTestMode = watch('payment_test_mode') === '1'; const onSubmit = (values: FormValues) => { mutation.mutate(values); @@ -167,22 +200,11 @@ export default function SettingsPage() {
{/* toggle فعال/غیرفعال */}
+ )} + {activeTab === 'logs' && ( <> @@ -328,6 +402,28 @@ export default function SmsPage() {
+ { setPostVisitRejectId(null); setPostVisitRejectReason(''); }} + footer={ + <> + + + + } + > +
+ +