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:
@@ -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`
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
>
|
||||
|
||||
@@ -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="تأیید قالب پیامک"
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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 تعیین میشود:
|
||||
|
||||
@@ -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
@@ -57,7 +57,7 @@
|
||||
| phone | string | ❌ |
|
||||
| job_title | string | ❌ |
|
||||
| address | string | ❌ |
|
||||
| national_code | string(10) | ❌ |
|
||||
| national_code | string(15) — ارقام فارسی به لاتین تبدیل میشوند | ❌ |
|
||||
|
||||
**Response 201:**
|
||||
```json
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
]
|
||||
|
||||
@@ -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',
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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')]
|
||||
|
||||
Reference in New Issue
Block a user