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.
This commit is contained in:
hamed
2026-06-15 11:03:56 +03:30
parent 55f646e2d4
commit 5cdcec23a9
32 changed files with 1487 additions and 128 deletions
+447
View File
@@ -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<ApiResponse<{uuid: string; name: string}[]>>({
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<string, string>();
[...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<HTMLInputElement>) => {
// حذف همه کاراکترهای غیر عددی (فارسی و لاتین)
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 (
<input
type="text"
inputMode="numeric"
value={display}
onChange={handleChange}
placeholder={placeholder}
dir="ltr"
style={{ textAlign: 'left' }}
/>
);
}
```
استفاده در `ClinicServicesPage.tsx`:
```tsx
// جایگزین کردن
<input {...itemForm.register('price_rials')} type="number" .../>
// با
<PriceInput
value={itemForm.watch('price_rials') ?? 0}
onChange={(v) => 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' && (
<div style={{ background: 'oklch(0.98 0.04 60)', border: '1px solid oklch(0.85 0.1 60)', borderRadius: 8, padding: '8px 12px', fontSize: 12.5, color: 'oklch(0.5 0.15 60)' }}>
متن شما در انتظار تأیید مدیر است
</div>
)}
{currentSettings.post_visit_text_status === 'approved' && (
<div style={{ background: 'var(--primary-subtle)', ... }}>
متن تأیید شده در حال استفاده
</div>
)}
{currentSettings.post_visit_text_status === 'rejected' && (
<div style={{ background: '#fef2f2', ... }}>
رد شد: {currentSettings.post_visit_text_reject_reason}
</div>
)}
```
**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`
+63
View File
@@ -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<string, string> = {
'۰': '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<HTMLInputElement>) => {
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 (
<input
type="text"
inputMode="numeric"
value={display}
onChange={handleChange}
placeholder={placeholder}
className={className}
style={{ textAlign: 'left', direction: 'ltr', ...style }}
disabled={disabled}
/>
);
}
+16 -3
View File
@@ -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<ApiResponse<{ data: { uuid: string; name: string }[] }>>({
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<string, string>();
// 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);
+7 -1
View File
@@ -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() {
</div>
<div className="field">
<label>قیمت (ریال) *</label>
<input {...itemForm.register('price_rials')} type="number" min={0} placeholder="85000" dir="ltr" />
<PriceInput
value={itemForm.watch('price_rials') ?? 0}
onChange={(v) => itemForm.setValue('price_rials', v)}
placeholder="۸۵,۰۰۰"
min={0}
/>
</div>
<div className="field">
<label>پرسنل مسئول</label>
+102 -15
View File
@@ -134,35 +134,64 @@ const createSchema = z.object({
});
type CreateForm = z.infer<typeof createSchema>;
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<string>('');
const activeDoctorUuid = isClinic ? selectedDoctorUuid : (doctorUuid ?? '');
const [createOpen, setCreateOpen] = useState(false);
const [editTarget, setEditTarget] = useState<Secretary | null>(null);
const [editPerms, setEditPerms] = useState<SecretaryPermissions>(DEFAULT_PERMISSIONS);
const [deleteTarget, setDeleteTarget] = useState<Secretary | null>(null);
const { data, isLoading } = useQuery<ApiResponse<Secretary[]>>({
queryKey: ['my-secretaries', doctorUuid],
queryFn: () => api.get(`/api/v1/secretaries/${doctorUuid}`),
enabled: !!doctorUuid,
// clinic: load clinic's doctors
const { data: clinicDoctorsData, isLoading: clinicDoctorsLoading } = useQuery<ApiResponse<{ data: ClinicDoctor[] }>>({
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<ApiResponse<Secretary[]>>({
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<ApiResponse<Secretary[]>>({
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<CreateForm>({ 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<Secretary>[] = [
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<Secretary>[] = isClinic ? [
{
key: 'doctor_name',
header: 'پزشک',
render: (s) => (
<span style={{ fontSize: 13, color: 'var(--text-2)', fontWeight: 500 }}>{s.doctor_name}</span>
),
},
] : [];
const allColumns: Column<Secretary>[] = [
{
key: 'user_name',
header: 'منشی',
@@ -212,6 +264,7 @@ export default function MySecretariesPage() {
</div>
),
},
...clinicColumns,
{
key: 'mobile_number',
header: 'موبایل',
@@ -256,14 +309,39 @@ export default function MySecretariesPage() {
title="منشیان من"
description="مدیریت منشیان و دسترسی‌های آن‌ها"
action={
<button className="btn primary sm" onClick={() => setCreateOpen(true)}>
<button className="btn primary sm" onClick={handleCreateOpen} disabled={isClinic && !selectedDoctorUuid}>
<PlusIcon style={{ width: 16 }} />
افزودن منشی
</button>
}
/>
{!doctorUuid ? (
{/* کلینیک: انتخاب پزشک برای افزودن منشی */}
{isClinic && (
<div className="card card-pad" style={{ marginBottom: 16 }}>
<div className="field" style={{ marginBottom: 0 }}>
<label>پزشک مورد نظر برای افزودن منشی جدید</label>
{clinicDoctorsLoading ? (
<p style={{ fontSize: 13, color: 'var(--text-3)' }}>در حال بارگذاری...</p>
) : clinicDoctors.length === 0 ? (
<p style={{ fontSize: 13, color: 'var(--text-3)' }}>هیچ پزشکی در این کلینیک تعریف نشده است. ابتدا پزشک اضافه کنید.</p>
) : (
<select
value={selectedDoctorUuid}
onChange={(e) => setSelectedDoctorUuid(e.target.value)}
style={{ width: '100%', maxWidth: 360 }}
>
<option value=""> یک پزشک را انتخاب کنید </option>
{clinicDoctors.map((d) => (
<option key={d.uuid} value={d.uuid}>{d.name}</option>
))}
</select>
)}
</div>
</div>
)}
{!isClinic && !doctorUuid ? (
<div className="card card-pad" style={{ textAlign: 'center', color: 'var(--text-3)' }}>
پروفایل پزشک یافت نشد
</div>
@@ -274,20 +352,29 @@ export default function MySecretariesPage() {
<IdentificationIcon style={{ width: 48, color: 'var(--text-3)', margin: '0 auto 16px', display: 'block', opacity: 0.4 }} />
<div style={{ fontWeight: 600, fontSize: 15, marginBottom: 8, color: 'var(--text-2)' }}>هنوز منشیای اضافه نشده</div>
<div style={{ color: 'var(--text-3)', fontSize: 13.5, marginBottom: 20 }}>
منشی میتواند نوبتها و اطلاعات کلینیک را مدیریت کند
{isClinic
? 'برای افزودن منشی، ابتدا یک پزشک را از لیست بالا انتخاب کنید'
: 'منشی می‌تواند نوبت‌ها و اطلاعات کلینیک را مدیریت کند'}
</div>
<button className="btn primary sm" onClick={() => setCreateOpen(true)}>
<PlusIcon style={{ width: 16 }} /> افزودن اولین منشی
</button>
{!isClinic && (
<button className="btn primary sm" onClick={handleCreateOpen}>
<PlusIcon style={{ width: 16 }} /> افزودن اولین منشی
</button>
)}
</div>
) : (
<DataTable columns={columns} data={secretaries} loading={isLoading} emptyMessage="منشی‌ای ثبت نشده است" />
<DataTable columns={allColumns} data={secretaries} loading={isLoading} emptyMessage="منشی‌ای ثبت نشده است" />
)}
</div>
)}
{/* Modal افزودن منشی */}
<Modal open={createOpen} onClose={() => setCreateOpen(false)} title="افزودن منشی جدید">
{isClinic && selectedDoctorName && (
<div style={{ marginBottom: 12, padding: '8px 12px', background: 'var(--primary-subtle)', borderRadius: 8, fontSize: 13, color: 'var(--primary)' }}>
منشی برای دکتر {selectedDoctorName} اضافه میشود
</div>
)}
<form onSubmit={createForm.handleSubmit((d) => createMutation.mutate(d))}>
<div className="field">
<label>شماره موبایل *</label>
+179 -18
View File
@@ -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<typeof schema>;
@@ -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<FormValues>({ 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() {
<div style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}>
{/* toggle فعال/غیرفعال */}
<label style={{ display: 'flex', alignItems: 'center', gap: 12, cursor: 'pointer' }}>
<div style={{ position: 'relative', width: 44, height: 24, flexShrink: 0 }}>
<input
type="checkbox"
checked={commissionEnabled}
onChange={(e) => {
const target = e.target;
const input = document.querySelector<HTMLInputElement>('input[name="commission_enabled"]');
if (input) {
input.value = target.checked ? '1' : '0';
// Trigger react-hook-form change
input.dispatchEvent(new Event('input', { bubbles: true }));
}
}}
style={{ opacity: 0, width: 0, height: 0, position: 'absolute' }}
/>
<input type="hidden" {...register('commission_enabled')} />
<input type="hidden" {...register('commission_enabled')} />
<div
style={{ position: 'relative', width: 44, height: 24, flexShrink: 0, cursor: 'pointer' }}
onClick={() => setValue('commission_enabled', commissionEnabled ? '0' : '1', { shouldDirty: true })}
>
<div style={{
width: 44, height: 24, borderRadius: 12,
background: commissionEnabled ? 'var(--primary)' : 'var(--border)',
@@ -281,6 +303,136 @@ export default function SettingsPage() {
</div>
</div>
{/* درگاه پرداخت */}
<div className="card card-pad">
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: '1.25rem' }}>
<div className="ico" style={{ background: '#fef3c7', color: '#d97706', width: 36, height: 36, borderRadius: 10 }}>
<span style={{ fontSize: 16 }}>💳</span>
</div>
<h3 style={{ fontSize: 15 }}>درگاه پرداخت</h3>
</div>
{/* حالت تست */}
<div style={{ marginBottom: '1.25rem', padding: '12px 16px', borderRadius: 'var(--r-sm)', background: paymentTestMode ? '#fef9c3' : 'var(--surface)', border: `1px solid ${paymentTestMode ? '#fbbf24' : 'var(--border)'}` }}>
<label style={{ display: 'flex', alignItems: 'center', gap: 12, cursor: 'pointer' }}>
<input type="hidden" {...register('payment_test_mode')} />
<div
style={{ position: 'relative', width: 44, height: 24, flexShrink: 0, cursor: 'pointer' }}
onClick={() => setValue('payment_test_mode', paymentTestMode ? '0' : '1', { shouldDirty: true })}
>
<div style={{ width: 44, height: 24, borderRadius: 12, background: paymentTestMode ? '#f59e0b' : 'var(--border)', transition: 'background .2s', position: 'relative' }}>
<div style={{ position: 'absolute', top: 2, width: 20, height: 20, borderRadius: '50%', background: '#fff', transition: 'right .2s', right: paymentTestMode ? 2 : 22, boxShadow: '0 1px 3px rgba(0,0,0,.2)' }} />
</div>
</div>
<div>
<div style={{ fontSize: 14, fontWeight: 600 }}>{paymentTestMode ? 'حالت تست فعال' : 'حالت تست غیرفعال'}</div>
<div className="muted" style={{ fontSize: 12, marginTop: 2 }}>
{paymentTestMode ? 'همه پرداخت‌ها از درگاه آزمایشی رد می‌شوند (پول واقعی کسر نمی‌شود)' : 'پرداخت‌ها از درگاه واقعی انجام می‌شوند'}
</div>
</div>
</label>
</div>
{/* Mellat */}
<div style={{ marginBottom: '1.25rem' }}>
<div style={{ fontSize: 13.5, fontWeight: 600, marginBottom: '0.75rem', display: 'flex', alignItems: 'center', gap: 6 }}>
<span style={{ width: 8, height: 8, borderRadius: '50%', background: '#6366f1', display: 'inline-block' }} />
درگاه ملت (Mellat)
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: '0.75rem' }}>
<div>
<label style={{ fontSize: 12.5, fontWeight: 500, display: 'block', marginBottom: 4 }}>شناسه پایانه</label>
<input {...register('mellat_terminal_id')} dir="ltr" placeholder="12345678"
style={{ width: '100%', height: 38, padding: '0 12px', borderRadius: 'var(--r-sm)', border: '1px solid var(--border)', background: 'var(--surface)', color: 'var(--text)', fontSize: 13, boxSizing: 'border-box' }}
/>
</div>
<div>
<label style={{ fontSize: 12.5, fontWeight: 500, display: 'block', marginBottom: 4 }}>نام کاربری</label>
<input {...register('mellat_username')} dir="ltr" placeholder="username"
style={{ width: '100%', height: 38, padding: '0 12px', borderRadius: 'var(--r-sm)', border: '1px solid var(--border)', background: 'var(--surface)', color: 'var(--text)', fontSize: 13, boxSizing: 'border-box' }}
/>
</div>
<div>
<label style={{ fontSize: 12.5, fontWeight: 500, display: 'block', marginBottom: 4 }}>رمز عبور</label>
<div style={{ position: 'relative' }}>
<input {...register('mellat_password')} type={showMellatPassword ? 'text' : 'password'} dir="ltr" placeholder="••••••••"
style={{ width: '100%', height: 38, padding: '0 36px 0 12px', borderRadius: 'var(--r-sm)', border: '1px solid var(--border)', background: 'var(--surface)', color: 'var(--text)', fontSize: 13, boxSizing: 'border-box' }}
/>
<button type="button" onClick={() => setShowMellatPassword(p => !p)}
style={{ position: 'absolute', left: 10, top: '50%', transform: 'translateY(-50%)', background: 'none', border: 'none', cursor: 'pointer', color: 'var(--muted)', fontSize: 12 }}>
{showMellatPassword ? 'پنهان' : 'نمایش'}
</button>
</div>
</div>
</div>
</div>
{/* SEP */}
<div>
<div style={{ fontSize: 13.5, fontWeight: 600, marginBottom: '0.75rem', display: 'flex', alignItems: 'center', gap: 6 }}>
<span style={{ width: 8, height: 8, borderRadius: '50%', background: '#10b981', display: 'inline-block' }} />
درگاه سپ (SEP)
</div>
<div style={{ maxWidth: 240 }}>
<label style={{ fontSize: 12.5, fontWeight: 500, display: 'block', marginBottom: 4 }}>شناسه پایانه</label>
<input {...register('sep_terminal_id')} dir="ltr" placeholder="12345678"
style={{ width: '100%', height: 38, padding: '0 12px', borderRadius: 'var(--r-sm)', border: '1px solid var(--border)', background: 'var(--surface)', color: 'var(--text)', fontSize: 13, boxSizing: 'border-box' }}
/>
</div>
</div>
</div>
{/* تنظیمات پیامک */}
<div className="card card-pad">
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: '1.25rem' }}>
<div className="ico" style={{ background: '#ede9fe', color: '#7c3aed', width: 36, height: 36, borderRadius: 10 }}>
<span style={{ fontSize: 16 }}>📱</span>
</div>
<h3 style={{ fontSize: 15 }}>تنظیمات پیامک (SMS)</h3>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '1rem' }}>
<div>
<label style={{ fontSize: 12.5, fontWeight: 500, display: 'block', marginBottom: 4 }}>سرویس پیامک</label>
<select {...register('sms_provider')}
style={{ width: '100%', height: 38, padding: '0 12px', borderRadius: 'var(--r-sm)', border: '1px solid var(--border)', background: 'var(--surface)', color: 'var(--text)', fontSize: 13 }}>
<option value="kavenegar">کاوهنگار</option>
<option value="rangineh">رنگینه</option>
</select>
</div>
<div>
<label style={{ fontSize: 12.5, fontWeight: 500, display: 'block', marginBottom: 4 }}>قیمت هر پیامک (ریال)</label>
<input {...register('sms_price_rials')} type="number" min={0} dir="ltr" placeholder="500"
style={{ width: '100%', height: 38, padding: '0 12px', borderRadius: 'var(--r-sm)', border: '1px solid var(--border)', background: 'var(--surface)', color: 'var(--text)', fontSize: 13, boxSizing: 'border-box' }}
/>
</div>
</div>
<div style={{ marginTop: '1rem' }}>
<div style={{ fontSize: 13, fontWeight: 600, marginBottom: '0.75rem' }}>تنظیمات کاوهنگار</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '0.75rem' }}>
<div>
<label style={{ fontSize: 12.5, fontWeight: 500, display: 'block', marginBottom: 4 }}>API Key</label>
<div style={{ position: 'relative' }}>
<input {...register('kavenegar_api_key')} type={showKavenegarKey ? 'text' : 'password'} dir="ltr" placeholder="••••••••••••••••"
style={{ width: '100%', height: 38, padding: '0 36px 0 12px', borderRadius: 'var(--r-sm)', border: '1px solid var(--border)', background: 'var(--surface)', color: 'var(--text)', fontSize: 13, boxSizing: 'border-box' }}
/>
<button type="button" onClick={() => setShowKavenegarKey(p => !p)}
style={{ position: 'absolute', left: 10, top: '50%', transform: 'translateY(-50%)', background: 'none', border: 'none', cursor: 'pointer', color: 'var(--muted)', fontSize: 12 }}>
{showKavenegarKey ? 'پنهان' : 'نمایش'}
</button>
</div>
</div>
<div>
<label style={{ fontSize: 12.5, fontWeight: 500, display: 'block', marginBottom: 4 }}>شماره فرستنده</label>
<input {...register('kavenegar_sender')} dir="ltr" placeholder="10008664"
style={{ width: '100%', height: 38, padding: '0 12px', borderRadius: 'var(--r-sm)', border: '1px solid var(--border)', background: 'var(--surface)', color: 'var(--text)', fontSize: 13, boxSizing: 'border-box' }}
/>
</div>
</div>
</div>
</div>
{/* دکمه ذخیره */}
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 10 }}>
<button
@@ -293,6 +445,15 @@ export default function SettingsPage() {
commission_percent: settings.commission_percent,
max_cancel_hours_before: settings.max_cancel_hours_before,
appointment_reminder_hours: settings.appointment_reminder_hours,
payment_test_mode: settings.payment_test_mode,
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_api_key: settings.kavenegar_api_key,
kavenegar_sender: settings.kavenegar_sender,
sms_price_rials: settings.sms_price_rials,
})}
disabled={!isDirty || mutation.isPending}
>
+97 -1
View File
@@ -21,7 +21,7 @@ const templateSchema = z.object({
});
type TemplateFormData = z.infer<typeof templateSchema>;
type Tab = 'samples' | 'pending' | 'logs';
type Tab = 'samples' | 'pending' | 'logs' | 'post-visit-review';
const STATUS_LOG_META: Record<string, { label: string; cls: string }> = {
sent: { label: 'ارسال شده', cls: 'green' },
@@ -38,6 +38,8 @@ export default function SmsPage() {
const [rejectReason, setRejectReason] = useState('');
const [addOpen, setAddOpen] = useState(false);
const [deleteTarget, setDeleteTarget] = useState<SmsTemplate | null>(null);
const [postVisitRejectId, setPostVisitRejectId] = useState<number | null>(null);
const [postVisitRejectReason, setPostVisitRejectReason] = useState('');
const limit = 15;
const sampleTemplatesQuery = useQuery({
@@ -114,6 +116,33 @@ export default function SmsPage() {
onError: (err: Error) => toast.error(err.message),
});
const postVisitReviewQuery = useQuery<ApiResponse<{ data: Array<{ id: number; entity_type: string; entity_id: number; post_visit_text_pending: string; post_visit_text_status: string }> }>>({
queryKey: ['sms-post-visit-review'],
queryFn: () => api.get('/api/v1/admin/sms/settings/review'),
enabled: activeTab === 'post-visit-review',
});
const postVisitApproveMutation = useMutation({
mutationFn: (id: number) => api.post(`/api/v1/admin/sms/settings/${id}/approve`, {}),
onSuccess: () => {
toast.success('متن پیامک تأیید شد');
qc.invalidateQueries({ queryKey: ['sms-post-visit-review'] });
},
onError: (err: Error) => toast.error(err.message),
});
const postVisitRejectMutation = useMutation({
mutationFn: ({ id, reason }: { id: number; reason: string }) =>
api.post(`/api/v1/admin/sms/settings/${id}/reject`, { reason }),
onSuccess: () => {
toast.success('متن پیامک رد شد');
setPostVisitRejectId(null);
setPostVisitRejectReason('');
qc.invalidateQueries({ queryKey: ['sms-post-visit-review'] });
},
onError: (err: Error) => toast.error(err.message),
});
const templateColumns: Column<SmsTemplate>[] = [
{ key: 'name', header: 'نام', render: (t) => <b>{t.name}</b> },
{
@@ -153,9 +182,12 @@ export default function SmsPage() {
setPage(1);
};
const postVisitPendingCount = (postVisitReviewQuery.data?.data as any)?.data?.length ?? 0;
const TABS: { key: Tab; label: string; badge?: number }[] = [
{ key: 'samples', label: 'قالب‌های نمونه' },
{ key: 'pending', label: 'در انتظار تأیید', badge: pendingCount },
{ key: 'post-visit-review', label: 'متن پیامک ویزیت', badge: postVisitPendingCount },
{ key: 'logs', label: 'لاگ‌های ارسال' },
];
@@ -262,6 +294,48 @@ export default function SmsPage() {
</>
)}
{activeTab === 'post-visit-review' && (
<div style={{ padding: '16px' }}>
{postVisitReviewQuery.isLoading ? (
<div className="muted" style={{ fontSize: 13 }}>در حال بارگذاری...</div>
) : ((postVisitReviewQuery.data?.data as any)?.data ?? []).length === 0 ? (
<div style={{ textAlign: 'center', padding: '32px 16px', color: 'var(--text-3)', fontSize: 13 }}>
متنی برای بررسی وجود ندارد
</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
{((postVisitReviewQuery.data?.data as any)?.data ?? []).map((item: any) => (
<div key={item.id} className="card card-pad">
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: 10 }}>
<div>
<span className="badge gray" style={{ fontSize: 11 }}>{item.entity_type} #{item.entity_id}</span>
</div>
<div style={{ display: 'flex', gap: 8 }}>
<button
className="btn primary sm"
disabled={postVisitApproveMutation.isPending}
onClick={() => postVisitApproveMutation.mutate(item.id)}
>
<CheckIcon style={{ width: 14 }} /> تأیید
</button>
<button
className="btn danger sm"
onClick={() => { setPostVisitRejectId(item.id); setPostVisitRejectReason(''); }}
>
<XMarkIcon style={{ width: 14 }} /> رد
</button>
</div>
</div>
<div style={{ fontSize: 13, lineHeight: 1.8, padding: '10px 14px', background: 'var(--surface)', borderRadius: 8, border: '1px solid var(--border)', direction: 'rtl' }}>
{item.post_visit_text_pending}
</div>
</div>
))}
</div>
)}
</div>
)}
{activeTab === 'logs' && (
<>
<DataTable<SmsLog>
@@ -328,6 +402,28 @@ export default function SmsPage() {
</div>
</Modal>
<Modal open={!!postVisitRejectId} title="رد متن پیامک ویزیت"
onClose={() => { setPostVisitRejectId(null); setPostVisitRejectReason(''); }}
footer={
<>
<button onClick={() => { setPostVisitRejectId(null); setPostVisitRejectReason(''); }} className="btn ghost sm">لغو</button>
<button
onClick={() => postVisitRejectId && postVisitRejectMutation.mutate({ id: postVisitRejectId, reason: postVisitRejectReason })}
disabled={!postVisitRejectReason || postVisitRejectMutation.isPending}
className="btn danger sm">
رد کردن
</button>
</>
}
>
<div className="form-row">
<label>دلیل رد</label>
<textarea value={postVisitRejectReason} onChange={(e) => setPostVisitRejectReason(e.target.value)}
rows={4} placeholder="دلیل رد را بنویسید..."
className="input" style={{ resize: 'none', height: 'auto' }} />
</div>
</Modal>
<ConfirmDialog
open={!!approveTarget}
title="تأیید قالب پیامک"
+34 -8
View File
@@ -218,14 +218,40 @@ export default function SmsWalletPage() {
</div>
{currentSettings.post_visit_enabled && (
<div className="field" style={{ marginBottom: 0, marginRight: 4 }}>
<label style={{ fontSize: 13 }}>متن پیامک</label>
<textarea
value={currentSettings.post_visit_text ?? ''}
onChange={(e) => setLocalSettings({ ...currentSettings, post_visit_text: e.target.value })}
rows={3}
placeholder="ممنون از مراجعه شما..."
/>
<div style={{ marginRight: 4 }}>
<div className="field" style={{ marginBottom: 8 }}>
<label style={{ fontSize: 13 }}>متن پیامک</label>
<textarea
value={currentSettings.post_visit_text_pending ?? currentSettings.post_visit_text ?? ''}
onChange={(e) => setLocalSettings({ ...currentSettings, post_visit_text: e.target.value })}
rows={3}
placeholder="ممنون از مراجعه شما..."
/>
</div>
{/* وضعیت تأیید */}
{currentSettings.post_visit_text_status === 'pending' && (
<div style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '8px 12px', borderRadius: 8, background: '#fef9c3', border: '1px solid #fbbf24', fontSize: 12.5, marginBottom: 8 }}>
<span style={{ fontSize: 14 }}></span>
<span>متن پیامک در انتظار تأیید ادمین است</span>
</div>
)}
{currentSettings.post_visit_text_status === 'approved' && (
<div style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '8px 12px', borderRadius: 8, background: '#dcfce7', border: '1px solid #86efac', fontSize: 12.5, marginBottom: 8 }}>
<span style={{ fontSize: 14 }}></span>
<span>متن پیامک تأیید شده و فعال است</span>
</div>
)}
{currentSettings.post_visit_text_status === 'rejected' && (
<div style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '8px 12px', borderRadius: 8, background: '#fee2e2', border: '1px solid #fca5a5', fontSize: 12.5, marginBottom: 8 }}>
<span style={{ fontSize: 14 }}></span>
<div>
<div>متن پیامک رد شد</div>
{currentSettings.post_visit_text_reject_reason && (
<div style={{ color: '#dc2626', marginTop: 2 }}>دلیل: {currentSettings.post_visit_text_reject_reason}</div>
)}
</div>
</div>
)}
</div>
)}
+3
View File
@@ -392,6 +392,9 @@ export interface SmsSettings {
reminder_hours_before: number;
post_visit_enabled: boolean;
post_visit_text: string | null;
post_visit_text_pending?: string | null;
post_visit_text_status?: 'none' | 'pending' | 'approved' | 'rejected';
post_visit_text_reject_reason?: string | null;
}
export interface PatientRecord {
+8 -8
View File
@@ -47,13 +47,13 @@ services:
App\Payment\Gateway\MellatGateway:
arguments:
$terminalId: '%env(MELLAT_TERMINAL_ID)%'
$username: '%env(MELLAT_USERNAME)%'
$password: '%env(MELLAT_PASSWORD)%'
$terminalId: '%env(default::MELLAT_TERMINAL_ID)%'
$username: '%env(default::MELLAT_USERNAME)%'
$password: '%env(default::MELLAT_PASSWORD)%'
App\Payment\Gateway\SepGateway:
arguments:
$terminalId: '%env(SEP_TERMINAL_ID)%'
$terminalId: '%env(default::SEP_TERMINAL_ID)%'
App\Payment\Controller\PaymentController:
arguments:
@@ -62,13 +62,13 @@ services:
App\Sms\Provider\KavehNegarProvider:
arguments:
$apiKey: '%env(KAVENEGAR_API_KEY)%'
$sender: '%env(KAVENEGAR_SENDER)%'
$apiKey: '%env(default::KAVENEGAR_API_KEY)%'
$sender: '%env(default::KAVENEGAR_SENDER)%'
App\Sms\Provider\RanginehProvider:
arguments:
$apiKey: '%env(RANGINEH_API_KEY)%'
$sender: '%env(RANGINEH_SENDER)%'
$apiKey: '%env(default::RANGINEH_API_KEY)%'
$sender: '%env(default::RANGINEH_SENDER)%'
App\Blog\Controller\BlogController:
arguments:
+29 -2
View File
@@ -775,7 +775,16 @@ Returns all site configuration values.
"site_name": "ClinicPro",
"support_phone": "",
"max_cancel_hours_before": "24",
"appointment_reminder_hours": "2"
"appointment_reminder_hours": "2",
"payment_test_mode": "0",
"mellat_terminal_id": "",
"mellat_username": "",
"mellat_password": "",
"sep_terminal_id": "",
"sms_provider": "kavenegar",
"kavenegar_api_key": "",
"kavenegar_sender": "",
"sms_price_rials": "500"
}
}
```
@@ -791,7 +800,16 @@ Update one or more settings. Unknown keys are silently ignored.
{
"commission_enabled": "1",
"commission_percent": "5",
"site_name": "کلینیک‌پرو"
"site_name": "کلینیک‌پرو",
"payment_test_mode": "1",
"mellat_terminal_id": "12345678",
"mellat_username": "user",
"mellat_password": "pass",
"sep_terminal_id": "87654321",
"sms_provider": "kavenegar",
"kavenegar_api_key": "your-api-key",
"kavenegar_sender": "10008664",
"sms_price_rials": "500"
}
```
@@ -802,6 +820,15 @@ Update one or more settings. Unknown keys are silently ignored.
- `commission_percent` — integer string, `0``100`
- Commission applies only to regular users (`booked_by = user`); secretaries are exempt
**Payment gateway rules:**
- `payment_test_mode``"1"` = all payments use MockGateway (no real bank calls), `"0"` = real gateways
- Gateway credentials (mellat/sep) read from DB first, fallback to env vars if DB value is empty
- MockGateway callback: same URL pattern + `&mock=1&ResCode=0&RefId=MOCK-{orderId}`
**SMS provider rules:**
- `sms_provider``"kavenegar"` or `"rangineh"`
- Kavenegar API key and sender read from DB first, fallback to env vars `KAVENEGAR_API_KEY`, `KAVENEGAR_SENDER`
---
## Pre-Registration Management
+1 -1
View File
@@ -5,7 +5,7 @@
Patient records track patients per entity (doctor or clinic). Each record holds multiple sessions (visits). Access requires an active subscription with the `patient_records` feature.
**Base path:** `/api/v1`
**Auth:** Bearer JWT (doctor or clinic role required)
**Auth:** Bearer JWT (doctor, clinic, or secretary with `appointments.view` permission required)
---
+56 -8
View File
@@ -10,7 +10,7 @@ Secretaries are linked to a doctor and have granular permissions controlling wha
Create a secretary for a doctor.
**Permission:** `ROLE_DOCTOR` must own the doctor profile
**Permission:** `ROLE_DOCTOR` (must own the doctor) | `ROLE_CLINIC` (must have the doctor in its clinic) | `ROLE_ADMIN`
### Request Body (`application/json`)
```json
@@ -74,10 +74,12 @@ Create a secretary for a doctor.
"success": true,
"data": {
"uuid": "sec-uuid-...",
"user_name": "علی محمدی",
"mobile_number": "09123456789",
"active": true,
"doctor_name": "دکتر احمد رضایی",
"doctor_uuid": "...",
"is_active": true,
"permissions": { ... },
"doctor": { "uuid": "...", "title": "دکتر علی احمدی" },
"created_at": 1717000000
}
}
@@ -87,9 +89,9 @@ Create a secretary for a doctor.
| Code | HTTP | Description |
|------|------|-------------|
| `ERR_AUTH_001` | 401 | Missing token |
| `ERR_AUTH_006` | 403 | Not a doctor or not the doctor's owner |
| `ERR_AUTH_006` | 403 | Not the doctor owner / clinic owner / admin |
| `ERR_NOT_FOUND_001` | 404 | Doctor not found |
| `ERR_CONFLICT_001` | 409 | Mobile number already in use |
| `ERR_CONFLICT_001` | 409 | Secretary already added for this doctor |
| `ERR_SECRETARY_001` | 422 | Plan limit for secretaries reached |
---
@@ -189,7 +191,7 @@ Delete a secretary.
Get all secretaries for a specific doctor.
**Permission:** `AUTH` must be the doctor or `ROLE_ADMIN`
**Permission:** `ROLE_DOCTOR` (must own doctor) | `ROLE_CLINIC` (must have doctor in clinic) | `ROLE_ADMIN`
### Path Parameters
| Param | Type | Description |
@@ -203,8 +205,11 @@ Get all secretaries for a specific doctor.
"data": [
{
"uuid": "...",
"user_name": "علی محمدی",
"mobile_number": "09...",
"active": true,
"doctor_name": "دکتر احمد رضایی",
"doctor_uuid": "...",
"is_active": true,
"permissions": { ... },
"created_at": 1717000000
}
@@ -216,11 +221,54 @@ Get all secretaries for a specific doctor.
| Code | HTTP | Description |
|------|------|-------------|
| `ERR_AUTH_001` | 401 | Missing token |
| `ERR_FORBIDDEN_001` | 403 | Not the doctor |
| `ERR_FORBIDDEN_001` | 403 | Not authorized |
| `ERR_NOT_FOUND_001` | 404 | Doctor not found |
---
## GET `/api/v1/secretaries/clinic/{clinicUuid}`
Get all secretaries across **all doctors** of a clinic.
**Permission:** `ROLE_CLINIC` (must own clinic) | `ROLE_ADMIN`
### Path Parameters
| Param | Type | Description |
|-------|------|-------------|
| `clinicUuid` | string (UUID) | Clinic UUID |
### Response `200`
```json
{
"success": true,
"data": [
{
"uuid": "...",
"user_name": "علی محمدی",
"mobile_number": "09...",
"doctor_name": "دکتر احمد رضایی",
"doctor_uuid": "...",
"is_active": true,
"permissions": { ... },
"created_at": 1717000000
}
]
}
```
### Notes
- یک منشی می‌تواند برای یک یا چند دکتر تعریف شود (جداگانه در جدول `doctor_secretaries`)
- این endpoint همه منشیان همه دکترهای کلینیک را یکجا برمی‌گرداند با ستون `doctor_name` برای تشخیص
### Errors
| Code | HTTP | Description |
|------|------|-------------|
| `ERR_AUTH_001` | 401 | Missing token |
| `ERR_FORBIDDEN_001` | 403 | Not clinic owner |
| `ERR_NOT_FOUND_001` | 404 | Clinic not found |
---
## محدودیت پنل اشتراکی
تعداد منشی‌های مجاز بر اساس پنل فعال doctor تعیین می‌شود:
+43
View File
@@ -390,6 +390,9 @@ Updated template with `status: "rejected"`.
"reminder_hours_before": 2,
"post_visit_enabled": false,
"post_visit_text": null,
"post_visit_text_pending": null,
"post_visit_text_status": "none",
"post_visit_text_reject_reason": null,
"updated_at": 1718000000
}
}
@@ -408,10 +411,50 @@ Updated template with `status: "rejected"`.
}
```
**تغییر رفتار `post_visit_text`:** متن ارسال‌شده مستقیماً اعمال نمی‌شود — در فیلد `post_visit_text_pending` ذخیره می‌شود و وضعیت `post_visit_text_status` به `pending` تغییر می‌کند. پس از تأیید ادمین، به `post_visit_text` منتقل می‌شود.
**مقادیر `post_visit_text_status`:** `none` | `pending` | `approved` | `rejected`
---
## Admin Endpoints
### GET /api/v1/admin/sms/settings/review
**Permission:** `ROLE_ADMIN` — لیست همه تنظیمات SMS با وضعیت `pending`
```json
{
"success": true,
"data": {
"data": [
{
"id": 3,
"entity_type": "doctor",
"entity_id": 7,
"post_visit_text_pending": "متن در انتظار تأیید",
"post_visit_text_status": "pending",
...
}
]
}
}
```
### POST /api/v1/admin/sms/settings/{id}/approve
**Permission:** `ROLE_ADMIN` — تأیید متن پیامک. `post_visit_text_pending` به `post_visit_text` منتقل می‌شود.
### POST /api/v1/admin/sms/settings/{id}/reject
**Permission:** `ROLE_ADMIN` — رد متن پیامک.
```json
{ "reason": "متن نامناسب است" }
```
---
### GET /api/v1/admin/sms/wallet-report
**Permission:** `ROLE_ADMIN` — لیست همه کیف‌های پیامکی (paginated)
+1 -1
View File
@@ -57,7 +57,7 @@
| phone | string | ❌ |
| job_title | string | ❌ |
| address | string | ❌ |
| national_code | string(10) | ❌ |
| national_code | string(15) — ارقام فارسی به لاتین تبدیل می‌شوند | ❌ |
**Response 201:**
```json
+31
View File
@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20260615060415 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE clinic_staff CHANGE national_code national_code VARCHAR(15) DEFAULT NULL');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE clinic_staff CHANGE national_code national_code VARCHAR(10) DEFAULT NULL');
}
}
+31
View File
@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20260615061938 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE sms_settings ADD post_visit_text_pending LONGTEXT DEFAULT NULL, ADD post_visit_text_status VARCHAR(20) NOT NULL, ADD post_visit_text_reject_reason LONGTEXT DEFAULT NULL');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE sms_settings DROP post_visit_text_pending, DROP post_visit_text_status, DROP post_visit_text_reject_reason');
}
}
@@ -20,6 +20,19 @@ class SiteConfigController extends BaseController
'support_phone',
'max_cancel_hours_before',
'appointment_reminder_hours',
// payment gateways
'payment_test_mode',
'mellat_terminal_id',
'mellat_username',
'mellat_password',
'sep_terminal_id',
// sms provider
'sms_provider',
'kavenegar_api_key',
'kavenegar_sender',
'rangineh_api_key',
'rangineh_sender',
'sms_price_rials',
];
public function __construct(
@@ -16,6 +16,19 @@ class SiteConfigRepository extends ServiceEntityRepository
'support_phone' => '',
'max_cancel_hours_before' => '24',
'appointment_reminder_hours' => '2',
// payment gateways
'payment_test_mode' => '0',
'mellat_terminal_id' => '',
'mellat_username' => '',
'mellat_password' => '',
'sep_terminal_id' => '',
// sms provider
'sms_provider' => 'kavenegar',
'kavenegar_api_key' => '',
'kavenegar_sender' => '',
'rangineh_api_key' => '',
'rangineh_sender' => '',
'sms_price_rials' => '500',
];
public function __construct(ManagerRegistry $registry)
+16 -7
View File
@@ -7,6 +7,7 @@ use App\Auth\Repository\UserRepository;
use App\Clinic\Repository\ClinicRepository;
use App\Doctor\Repository\DoctorRepository;
use App\Patient\Entity\PatientRecord;
use App\Secretary\Repository\DoctorSecretaryRepository;
use App\Patient\Repository\PatientRecordRepository;
use App\Patient\Repository\PatientSessionRepository;
use App\Patient\Service\PatientService;
@@ -24,13 +25,14 @@ use Symfony\Component\Security\Http\Attribute\IsGranted;
class PatientController extends BaseController
{
public function __construct(
private readonly PatientRecordRepository $recordRepo,
private readonly PatientSessionRepository $sessionRepo,
private readonly PatientService $patientService,
private readonly SubscriptionService $subscriptionService,
private readonly UserRepository $userRepo,
private readonly DoctorRepository $doctorRepo,
private readonly ClinicRepository $clinicRepo,
private readonly PatientRecordRepository $recordRepo,
private readonly PatientSessionRepository $sessionRepo,
private readonly PatientService $patientService,
private readonly SubscriptionService $subscriptionService,
private readonly UserRepository $userRepo,
private readonly DoctorRepository $doctorRepo,
private readonly ClinicRepository $clinicRepo,
private readonly DoctorSecretaryRepository $secretaryRepo,
) {}
#[Route('/api/v1/patients', methods: ['GET'])]
@@ -170,6 +172,13 @@ class PatientController extends BaseController
return $clinic !== null ? ['clinic', $clinic->getId()] : ['clinic', null];
}
if ($user->hasRole('ROLE_SECRETARY')) {
$secretary = $this->secretaryRepo->findActiveBySecretary($user);
if ($secretary !== null && ($secretary->getPermissions()['appointments']['view'] ?? false)) {
return ['doctor', $secretary->getDoctor()->getId()];
}
}
return ['unknown', null];
}
@@ -8,7 +8,9 @@ use App\Auth\Entity\User;
use App\Clinic\Repository\ClinicRepository;
use App\Doctor\Repository\DoctorRepository;
use App\Payment\Entity\Payment;
use App\Config\Repository\SiteConfigRepository;
use App\Payment\Gateway\MellatGateway;
use App\Payment\Gateway\MockGateway;
use App\Payment\Gateway\SepGateway;
use App\Payment\Repository\PaymentRepository;
use App\Payment\Service\CircuitBreakerService;
@@ -38,11 +40,13 @@ class PaymentController extends BaseController
private readonly AppointmentRepository $appointmentRepo,
private readonly MellatGateway $mellat,
private readonly SepGateway $sep,
private readonly MockGateway $mock,
private readonly CircuitBreakerService $circuitBreaker,
private readonly SubscriptionService $subscriptionService,
private readonly SmsWalletService $smsWalletService,
private readonly DoctorRepository $doctorRepo,
private readonly ClinicRepository $clinicRepo,
private readonly SiteConfigRepository $configRepo,
private readonly string $appBaseUrl,
private readonly string $allowedFrontendHosts = '',
) {}
@@ -520,6 +524,10 @@ class PaymentController extends BaseController
private function resolveGateway(string $name): \App\Payment\Gateway\PaymentGatewayInterface|null
{
if ($this->configRepo->get('payment_test_mode') === '1') {
return $this->mock;
}
return match ($name) {
'mellat' => $this->mellat,
'sep' => $this->sep,
+25 -10
View File
@@ -2,6 +2,7 @@
namespace App\Payment\Gateway;
use App\Config\Repository\SiteConfigRepository;
use Symfony\Contracts\HttpClient\HttpClientInterface;
class MellatGateway implements PaymentGatewayInterface
@@ -9,14 +10,20 @@ class MellatGateway implements PaymentGatewayInterface
private const PAYMENT_URL = 'https://bpm.shaparak.ir/pgwchannel/startpay.mellat';
public function __construct(
private readonly HttpClientInterface $httpClient,
private readonly string $terminalId,
private readonly string $username,
private readonly string $password,
private readonly HttpClientInterface $httpClient,
private readonly SiteConfigRepository $configRepo,
private readonly string $terminalId = '',
private readonly string $username = '',
private readonly string $password = '',
) {}
public function getName(): string { return 'mellat'; }
private function cfg(string $key, string $envFallback): string
{
return $this->configRepo->get($key) ?: $envFallback;
}
public function initiate(int $amountRials, string $orderId, string $callbackUrl): PaymentInitResult
{
try {
@@ -74,13 +81,17 @@ class MellatGateway implements PaymentGatewayInterface
private function buildRequestPayload(int $amount, string $orderId, string $callbackUrl): string
{
$terminalId = $this->cfg('mellat_terminal_id', $this->terminalId);
$username = $this->cfg('mellat_username', $this->username);
$password = $this->cfg('mellat_password', $this->password);
return <<<XML
<soapenv:Envelope xmlns:soapenv="http://schemas.xmlsoap.org/soap/envelope/" xmlns:int="http://interfaces.core.sw.bsl.com/westernmellat">
<soapenv:Body>
<int:bpPayRequest>
<terminalId>{$this->terminalId}</terminalId>
<userName>{$this->username}</userName>
<userPassword>{$this->password}</userPassword>
<terminalId>{$terminalId}</terminalId>
<userName>{$username}</userName>
<userPassword>{$password}</userPassword>
<orderId>{$orderId}</orderId>
<amount>{$amount}</amount>
<localDate>{$this->date()}</localDate>
@@ -96,13 +107,17 @@ XML;
private function buildVerifyPayload(string $refId): string
{
$terminalId = $this->cfg('mellat_terminal_id', $this->terminalId);
$username = $this->cfg('mellat_username', $this->username);
$password = $this->cfg('mellat_password', $this->password);
return <<<XML
<soapenv:Envelope xmlns:soapenv="http://schemas.xmlsoap.org/soap/envelope/" xmlns:int="http://interfaces.core.sw.bsl.com/westernmellat">
<soapenv:Body>
<int:bpVerifyRequest>
<terminalId>{$this->terminalId}</terminalId>
<userName>{$this->username}</userName>
<userPassword>{$this->password}</userPassword>
<terminalId>{$terminalId}</terminalId>
<userName>{$username}</userName>
<userPassword>{$password}</userPassword>
<orderId>{$refId}</orderId>
<saleOrderId>{$refId}</saleOrderId>
<saleReferenceId>{$refId}</saleReferenceId>
+27
View File
@@ -0,0 +1,27 @@
<?php
namespace App\Payment\Gateway;
class MockGateway implements PaymentGatewayInterface
{
public function getName(): string { return 'mock'; }
public function initiate(int $amountRials, string $orderId, string $callbackUrl): PaymentInitResult
{
$redirectUrl = $callbackUrl . '&mock=1&ResCode=0&RefId=MOCK-' . $orderId;
return new PaymentInitResult(true, redirectUrl: $redirectUrl, token: 'MOCK-' . $orderId);
}
public function verify(array $callbackData): PaymentVerifyResult
{
$mock = $callbackData['mock'] ?? '0';
$resCode = $callbackData['ResCode'] ?? ($callbackData['State'] ?? '');
if ($mock !== '1' && $mock !== 1) {
return new PaymentVerifyResult(false, errorMessage: 'mock callback مجاز نیست');
}
$refId = $callbackData['RefId'] ?? $callbackData['order_id'] ?? 'MOCK-REF';
return new PaymentVerifyResult(true, referenceId: $refId);
}
}
+11 -4
View File
@@ -2,6 +2,7 @@
namespace App\Payment\Gateway;
use App\Config\Repository\SiteConfigRepository;
use Symfony\Contracts\HttpClient\HttpClientInterface;
class SepGateway implements PaymentGatewayInterface
@@ -10,19 +11,25 @@ class SepGateway implements PaymentGatewayInterface
private const PAYMENT_URL = 'https://sep.shaparak.ir/OnlinePG/OnlinePG';
public function __construct(
private readonly HttpClientInterface $httpClient,
private readonly string $terminalId,
private readonly HttpClientInterface $httpClient,
private readonly SiteConfigRepository $configRepo,
private readonly string $terminalId = '',
) {}
public function getName(): string { return 'sep'; }
private function cfg(string $key, string $envFallback): string
{
return $this->configRepo->get($key) ?: $envFallback;
}
public function initiate(int $amountRials, string $orderId, string $callbackUrl): PaymentInitResult
{
try {
$response = $this->httpClient->request('POST', self::TOKEN_URL, [
'json' => [
'action' => 'token',
'TerminalId' => $this->terminalId,
'TerminalId' => $this->cfg('sep_terminal_id', $this->terminalId),
'Amount' => $amountRials,
'ResNum' => $orderId,
'RedirectUrl' => $callbackUrl,
@@ -57,7 +64,7 @@ class SepGateway implements PaymentGatewayInterface
$response = $this->httpClient->request('POST', self::TOKEN_URL, [
'json' => [
'action' => 'verify',
'TerminalId' => $this->terminalId,
'TerminalId' => $this->cfg('sep_terminal_id', $this->terminalId),
'RefNum' => $refNum,
],
'timeout' => 10,
@@ -4,7 +4,9 @@ namespace App\Secretary\Controller;
use App\Auth\Entity\User;
use App\Auth\Repository\UserRepository;
use App\Clinic\Entity\Clinic;
use App\Clinic\Repository\ClinicRepository;
use App\Doctor\Entity\Doctor;
use App\Doctor\Repository\DoctorRepository;
use App\Secretary\Entity\DoctorSecretary;
use App\Secretary\Repository\DoctorSecretaryRepository;
@@ -46,8 +48,7 @@ class SecretaryController extends BaseController
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'دکتر یافت نشد', 404);
}
// Only the doctor owner or admin can create secretary
if ($doctor->getUser()->getId() !== $currentUser->getId() && !$currentUser->hasRole('ROLE_ADMIN')) {
if (!$this->canManageDoctor($doctor, $currentUser)) {
return $this->error(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403);
}
@@ -164,7 +165,7 @@ class SecretaryController extends BaseController
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'دکتر یافت نشد', 404);
}
if ($doctor->getUser()->getId() !== $currentUser->getId() && !$currentUser->hasRole('ROLE_ADMIN')) {
if (!$this->canManageDoctor($doctor, $currentUser)) {
return $this->error(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403);
}
@@ -176,9 +177,49 @@ class SecretaryController extends BaseController
return $this->success(['data' => $secretaries]);
}
#[Route('/api/v1/secretaries/clinic/{clinicUuid}', methods: ['GET'])]
public function listByClinic(string $clinicUuid, #[CurrentUser] User $currentUser): JsonResponse
{
$clinic = $this->clinicRepo->findByUuid($clinicUuid);
if ($clinic === null) {
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'کلینیک یافت نشد', 404);
}
if ($clinic->getUser()->getId() !== $currentUser->getId() && !$currentUser->hasRole('ROLE_ADMIN')) {
return $this->error(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403);
}
$secretaries = array_map(
fn(DoctorSecretary $s) => $s->toArray(),
$this->secretaryRepo->findByClinic($clinic)
);
return $this->success(['data' => $secretaries]);
}
private function canManage(DoctorSecretary $secretary, User $user): bool
{
return $secretary->getDoctor()->getUser()->getId() === $user->getId()
|| $user->hasRole('ROLE_ADMIN');
return $this->canManageDoctor($secretary->getDoctor(), $user);
}
private function canManageDoctor(Doctor $doctor, User $user): bool
{
if ($user->hasRole('ROLE_ADMIN')) {
return true;
}
if ($doctor->getUser()->getId() === $user->getId()) {
return true;
}
// clinic owner can manage secretaries of its own doctors
if ($user->hasRole('ROLE_CLINIC')) {
$clinic = $this->clinicRepo->findByUser($user);
if ($clinic !== null && $this->secretaryRepo->isDoctorInClinic($doctor, $clinic)) {
return true;
}
}
return false;
}
}
+8 -14
View File
@@ -97,20 +97,14 @@ class DoctorSecretary
public function toArray(): array
{
return [
'uuid' => $this->uuid,
'user' => [
'uuid' => $this->secretary->getUuid(),
'realname' => $this->secretary->getRealName(),
'mobile' => $this->secretary->getMobileNumber(),
'picture' => null,
],
'doctor' => [
'uuid' => $this->doctor->getUuid(),
'name' => $this->doctor->getName(),
],
'active' => $this->active,
'permissions' => $this->getPermissions(),
'created_at' => $this->createdAt,
'uuid' => $this->uuid,
'user_name' => $this->secretary->getRealName(),
'mobile_number' => $this->secretary->getMobileNumber(),
'doctor_name' => $this->doctor->getName(),
'doctor_uuid' => $this->doctor->getUuid(),
'is_active' => $this->active,
'permissions' => $this->getPermissions(),
'created_at' => $this->createdAt,
];
}
}
@@ -3,6 +3,7 @@
namespace App\Secretary\Repository;
use App\Auth\Entity\User;
use App\Clinic\Entity\Clinic;
use App\Doctor\Entity\Doctor;
use App\Secretary\Entity\DoctorSecretary;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
@@ -37,6 +38,28 @@ class DoctorSecretaryRepository extends ServiceEntityRepository
return $this->findBy(['doctor' => $doctor], ['createdAt' => 'DESC']);
}
public function isDoctorInClinic(Doctor $doctor, Clinic $clinic): bool
{
return $clinic->getDoctors()->contains($doctor);
}
/** @return DoctorSecretary[] — all secretaries across all doctors of a clinic */
public function findByClinic(Clinic $clinic): array
{
$doctorIds = $clinic->getDoctors()->map(fn(Doctor $d) => $d->getId())->toArray();
if (empty($doctorIds)) {
return [];
}
return $this->createQueryBuilder('s')
->join('s.doctor', 'd')
->where('d.id IN (:ids)')
->setParameter('ids', $doctorIds)
->orderBy('s.createdAt', 'DESC')
->getQuery()
->getResult();
}
public function findActiveBySecretary(User $user): ?DoctorSecretary
{
return $this->findOneBy(['secretary' => $user, 'active' => true]);
+66 -6
View File
@@ -8,6 +8,7 @@ use App\Config\Repository\SiteConfigRepository;
use App\Doctor\Repository\DoctorRepository;
use App\Payment\Entity\Payment;
use App\Payment\Gateway\MellatGateway;
use App\Payment\Gateway\MockGateway;
use App\Payment\Gateway\SepGateway;
use App\Payment\Repository\PaymentRepository;
use App\Shared\Constant\ErrorCodes;
@@ -35,6 +36,7 @@ class SmsWalletController extends BaseController
private readonly SiteConfigRepository $configRepo,
private readonly MellatGateway $mellat,
private readonly SepGateway $sep,
private readonly MockGateway $mock,
private readonly DoctorRepository $doctorRepo,
private readonly ClinicRepository $clinicRepo,
private readonly string $appBaseUrl,
@@ -75,11 +77,15 @@ class SmsWalletController extends BaseController
return $this->error(ErrorCodes::ERR_PAYMENT_002, 'مبلغ شارژ نامعتبر است', 422);
}
$gateway = match ($gatewayName) {
'mellat' => $this->mellat,
'sep' => $this->sep,
default => null,
};
if ($this->configRepo->get('payment_test_mode') === '1') {
$gateway = $this->mock;
} else {
$gateway = match ($gatewayName) {
'mellat' => $this->mellat,
'sep' => $this->sep,
default => null,
};
}
if ($gateway === null) {
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'درگاه پرداخت نامعتبر است', 422);
@@ -172,13 +178,67 @@ class SmsWalletController extends BaseController
if (isset($data['reminder_enabled'])) { $settings->setReminderEnabled((bool) $data['reminder_enabled']); }
if (isset($data['reminder_hours_before'])) { $settings->setReminderHoursBefore((int) $data['reminder_hours_before']); }
if (isset($data['post_visit_enabled'])) { $settings->setPostVisitEnabled((bool) $data['post_visit_enabled']); }
if (array_key_exists('post_visit_text', $data)) { $settings->setPostVisitText($data['post_visit_text']); }
if (array_key_exists('post_visit_text', $data) && $data['post_visit_text'] !== null) {
$text = trim((string) $data['post_visit_text']);
if ($text !== '') {
$settings->submitPostVisitText($text);
}
}
$this->settingsRepo->save($settings);
return $this->success($settings->toArray());
}
#[Route('/api/v1/admin/sms/settings/review', methods: ['GET'])]
#[IsGranted('ROLE_ADMIN')]
public function adminReviewList(): JsonResponse
{
$pending = $this->settingsRepo->createQueryBuilder('s')
->where('s.postVisitTextStatus = :status')
->setParameter('status', SmsSettings::TEXT_STATUS_PENDING)
->getQuery()
->getResult();
return $this->success(['data' => array_map(fn(SmsSettings $s) => $s->toArray(), $pending)]);
}
#[Route('/api/v1/admin/sms/settings/{id}/approve', methods: ['POST'])]
#[IsGranted('ROLE_ADMIN')]
public function adminApprove(int $id): JsonResponse
{
$settings = $this->settingsRepo->find($id);
if ($settings === null) {
return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'تنظیمات یافت نشد', 404);
}
$settings->approvePostVisitText();
$this->settingsRepo->save($settings);
return $this->success($settings->toArray());
}
#[Route('/api/v1/admin/sms/settings/{id}/reject', methods: ['POST'])]
#[IsGranted('ROLE_ADMIN')]
public function adminReject(int $id, Request $request): JsonResponse
{
$settings = $this->settingsRepo->find($id);
if ($settings === null) {
return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'تنظیمات یافت نشد', 404);
}
$data = json_decode($request->getContent(), true) ?? [];
$reason = trim($data['reason'] ?? '');
if ($reason === '') {
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'دلیل رد الزامی است', 422);
}
$settings->rejectPostVisitText($reason);
$this->settingsRepo->save($settings);
return $this->success($settings->toArray());
}
#[Route('/api/v1/admin/sms/wallet-report', methods: ['GET'])]
#[IsGranted('ROLE_ADMIN')]
public function adminReport(Request $request): JsonResponse
+56 -7
View File
@@ -33,9 +33,23 @@ class SmsSettings
#[ORM\Column(name: 'post_visit_text', type: 'text', nullable: true)]
private ?string $postVisitText = null;
#[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';
#[ORM\Column(name: 'post_visit_text_reject_reason', type: 'text', nullable: true)]
private ?string $postVisitTextRejectReason = null;
#[ORM\Column(name: 'updated_at', type: 'integer')]
private int $updatedAt;
public const TEXT_STATUS_NONE = 'none';
public const TEXT_STATUS_PENDING = 'pending';
public const TEXT_STATUS_APPROVED = 'approved';
public const TEXT_STATUS_REJECTED = 'rejected';
public function __construct(string $entityType, int $entityId)
{
$this->entityType = $entityType;
@@ -51,21 +65,56 @@ class SmsSettings
public function isPostVisitEnabled(): bool { return $this->postVisitEnabled; }
public function getPostVisitText(): ?string { return $this->postVisitText; }
public function getPostVisitTextPending(): ?string { return $this->postVisitTextPending; }
public function getPostVisitTextStatus(): string { return $this->postVisitTextStatus; }
public function getPostVisitTextRejectReason(): ?string { return $this->postVisitTextRejectReason; }
public function setReminderEnabled(bool $v): self { $this->reminderEnabled = $v; $this->updatedAt = time(); return $this; }
public function setReminderHoursBefore(int $v): self { $this->reminderHoursBefore = $v; $this->updatedAt = time(); return $this; }
public function setPostVisitEnabled(bool $v): self { $this->postVisitEnabled = $v; $this->updatedAt = time(); return $this; }
public function setPostVisitText(?string $v): self { $this->postVisitText = $v; $this->updatedAt = time(); return $this; }
public function submitPostVisitText(string $text): self
{
$this->postVisitTextPending = $text;
$this->postVisitTextStatus = self::TEXT_STATUS_PENDING;
$this->postVisitTextRejectReason = null;
$this->updatedAt = time();
return $this;
}
public function approvePostVisitText(): self
{
if ($this->postVisitTextPending !== null) {
$this->postVisitText = $this->postVisitTextPending;
}
$this->postVisitTextPending = null;
$this->postVisitTextStatus = self::TEXT_STATUS_APPROVED;
$this->updatedAt = time();
return $this;
}
public function rejectPostVisitText(string $reason): self
{
$this->postVisitTextStatus = self::TEXT_STATUS_REJECTED;
$this->postVisitTextRejectReason = $reason;
$this->updatedAt = time();
return $this;
}
public function toArray(): array
{
return [
'entity_type' => $this->entityType,
'entity_id' => $this->entityId,
'reminder_enabled' => $this->reminderEnabled,
'reminder_hours_before' => $this->reminderHoursBefore,
'post_visit_enabled' => $this->postVisitEnabled,
'post_visit_text' => $this->postVisitText,
'updated_at' => $this->updatedAt,
'entity_type' => $this->entityType,
'entity_id' => $this->entityId,
'reminder_enabled' => $this->reminderEnabled,
'reminder_hours_before' => $this->reminderHoursBefore,
'post_visit_enabled' => $this->postVisitEnabled,
'post_visit_text' => $this->postVisitText,
'post_visit_text_pending' => $this->postVisitTextPending,
'post_visit_text_status' => $this->postVisitTextStatus,
'post_visit_text_reject_reason' => $this->postVisitTextRejectReason,
'updated_at' => $this->updatedAt,
];
}
}
+11 -6
View File
@@ -2,6 +2,7 @@
namespace App\Sms\Provider;
use App\Config\Repository\SiteConfigRepository;
use Symfony\Contracts\HttpClient\HttpClientInterface;
class KavehNegarProvider implements SmsProviderInterface
@@ -9,22 +10,26 @@ class KavehNegarProvider implements SmsProviderInterface
private const BASE = 'https://api.kavenegar.com/v1';
public function __construct(
private readonly HttpClientInterface $httpClient,
private readonly string $apiKey,
private readonly string $sender,
private readonly HttpClientInterface $httpClient,
private readonly SiteConfigRepository $configRepo,
private readonly string $apiKey = '',
private readonly string $sender = '',
) {}
private function key(): string { return $this->configRepo->get('kavenegar_api_key') ?: $this->apiKey; }
private function sender(): string { return $this->configRepo->get('kavenegar_sender') ?: $this->sender; }
public function getName(): string { return 'kavenegar'; }
public function send(string $mobile, string $message): bool
{
try {
$resp = $this->httpClient->request('POST',
self::BASE . '/' . $this->apiKey . '/sms/send.json', [
self::BASE . '/' . $this->key() . '/sms/send.json', [
'body' => http_build_query([
'receptor' => $mobile,
'message' => $message,
'sender' => $this->sender,
'sender' => $this->sender(),
]),
'timeout' => 10,
]
@@ -44,7 +49,7 @@ class KavehNegarProvider implements SmsProviderInterface
$params['token' . ($i > 0 ? $i + 1 : '')] = $v;
}
$resp = $this->httpClient->request('POST',
self::BASE . '/' . $this->apiKey . '/verify/lookup.json', [
self::BASE . '/' . $this->key() . '/verify/lookup.json', [
'body' => http_build_query($params),
'timeout' => 10,
]
+15 -2
View File
@@ -59,7 +59,7 @@ class StaffController extends BaseController
$staff->setPhone($data['phone'] ?? null);
$staff->setJobTitle($data['job_title'] ?? null);
$staff->setAddress($data['address'] ?? null);
$staff->setNationalCode($data['national_code'] ?? null);
$staff->setNationalCode($this->toLatinDigits($data['national_code'] ?? null));
$this->staffRepo->save($staff);
@@ -86,7 +86,7 @@ class StaffController extends BaseController
if (array_key_exists('phone', $data)) { $staff->setPhone($data['phone']); }
if (array_key_exists('job_title', $data)) { $staff->setJobTitle($data['job_title']); }
if (array_key_exists('address', $data)) { $staff->setAddress($data['address']); }
if (array_key_exists('national_code', $data)){ $staff->setNationalCode($data['national_code']); }
if (array_key_exists('national_code', $data)){ $staff->setNationalCode($this->toLatinDigits($data['national_code'])); }
$this->staffRepo->save($staff);
@@ -137,4 +137,17 @@ class StaffController extends BaseController
&& $staff->getEntityType() === $entityType
&& $staff->getEntityId() === $entityId;
}
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',
]);
}
}
+1 -1
View File
@@ -37,7 +37,7 @@ class ClinicStaff
#[ORM\Column(type: 'text', nullable: true)]
private ?string $address = null;
#[ORM\Column(name: 'national_code', type: 'string', length: 10, nullable: true)]
#[ORM\Column(name: 'national_code', type: 'string', length: 15, nullable: true)]
private ?string $nationalCode = null;
#[ORM\Column(type: 'boolean')]