fix: resolve calendar crash and enhance appointment management
- Fixed calendar crash due to invalid array length in PersianCalendar.tsx by changing locale to 'en-u-ca-persian'. - Added Persian weekday display in DateNavigator with appropriate styling and logic. - Updated empty slot message to indicate when a day is off. - Made patient name a required field in appointment creation and implemented find-or-create logic for patients in both admin and user endpoints. - Corrected mobile number display to show the patient's number instead of the doctor's in appointment listings. - Ensured booked appointments are displayed correctly in the schedule view. - Removed unnecessary operations column from the appointments table view.
This commit is contained in:
@@ -0,0 +1,404 @@
|
|||||||
|
# باگفیکس صفحه نوبتها
|
||||||
|
|
||||||
|
## فهرست باگها
|
||||||
|
|
||||||
|
| # | باگ | فایلها |
|
||||||
|
|---|-----|---------|
|
||||||
|
| ۱ | کرش تقویم — `RangeError: Invalid array length` | `PersianCalendar.tsx` |
|
||||||
|
| ۲ | روز هفته در DateNavigator نمایش داده نمیشود | `AppointmentsPage.tsx` |
|
||||||
|
| ۳ | پیام «slot نیست» باید «تعطیل است» باشد | `AppointmentsPage.tsx` |
|
||||||
|
| ۴ | نوبت جدید — نام اجباری + find-or-create patient | `AppointmentsPage.tsx` + دو controller |
|
||||||
|
| ۵ | در نمایش جدولی، موبایل پزشک به جای بیمار نشان داده میشود | `AppointmentsPage.tsx` + `AppointmentController.php` |
|
||||||
|
| ۶ | نوبتهای رزرو شده در نمایش زمانبندی نشان داده نمیشوند | `AppointmentsPage.tsx` |
|
||||||
|
| ۷ | ستون عملیات («...») از نمایش جدولی حذف شود | `AppointmentsPage.tsx` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## باگ ۱ — کرش تقویم
|
||||||
|
|
||||||
|
### علت ریشهای
|
||||||
|
|
||||||
|
**فایل:** `assets/admin/components/ui/PersianCalendar.tsx`
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// مشکل: locale 'fa-IR' → اعداد فارسی → parseInt('۱۴۰۴') = NaN → Array(NaN) کرش
|
||||||
|
const parts = new Intl.DateTimeFormat('fa-IR-u-ca-persian', { ... }).formatToParts(d);
|
||||||
|
const get = (t: string) => parseInt(parts.find(...), 10);
|
||||||
|
```
|
||||||
|
|
||||||
|
`parseInt('۱۴۰۴', 10)` در مرورگر `NaN` میدهد → `viewYear = NaN` → `Array(NaN).fill(null)` → کرش.
|
||||||
|
|
||||||
|
### فیکس
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// locale را به 'en' عوض کن — تاریخ شمسی ولی اعداد ASCII
|
||||||
|
const parts = new Intl.DateTimeFormat('en-u-ca-persian', {
|
||||||
|
year: 'numeric', month: 'numeric', day: 'numeric',
|
||||||
|
}).formatToParts(d);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## باگ ۲ — روز هفته در DateNavigator
|
||||||
|
|
||||||
|
### وضعیت فعلی
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// فقط تاریخ شمسی — بدون روز هفته
|
||||||
|
<div style={{ ... }}>{formatDate(date)}</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
### رفتار مورد انتظار
|
||||||
|
|
||||||
|
```
|
||||||
|
[ < ] ┌──────────────┐ [ > ] [ 📅 ]
|
||||||
|
│ شنبه │
|
||||||
|
│ ۱۴۰۴/۰۳/۲۲ │
|
||||||
|
└──────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### الزامات UI
|
||||||
|
- روز هفته فارسی: شنبه / یکشنبه / دوشنبه / سهشنبه / چهارشنبه / پنجشنبه / جمعه
|
||||||
|
- روز هفته **پررنگتر** از تاریخ
|
||||||
|
- **جمعه** به رنگ قرمز (`#ef4444`) — تعطیل
|
||||||
|
- واکنشی: مستقیم از prop `date` محاسبه شود، نه state جداگانه
|
||||||
|
- height کمی بیشتر (۴۴px) — همتراز با دکمههای toolbar
|
||||||
|
|
||||||
|
### پیادهسازی
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const WEEK_DAYS_FA = ['یکشنبه', 'دوشنبه', 'سهشنبه', 'چهارشنبه', 'پنجشنبه', 'جمعه', 'شنبه'];
|
||||||
|
// 0=Sun 1=Mon 2=Tue 3=Wed 4=Thu 5=Fri 6=Sat
|
||||||
|
|
||||||
|
function getPersianWeekDay(gregorianDate: string): string {
|
||||||
|
return WEEK_DAYS_FA[new Date(gregorianDate + 'T12:00:00').getDay()];
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
در `DateNavigator`:
|
||||||
|
```tsx
|
||||||
|
const weekDay = getPersianWeekDay(date);
|
||||||
|
const isFriday = new Date(date + 'T12:00:00').getDay() === 5;
|
||||||
|
|
||||||
|
// جایگزین div تاریخ:
|
||||||
|
<div style={{
|
||||||
|
padding: '0 14px', height: 44,
|
||||||
|
display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center',
|
||||||
|
background: 'var(--surface)', border: '1px solid var(--border)',
|
||||||
|
borderRadius: 'var(--r-sm)', minWidth: 130, gap: 1,
|
||||||
|
}}>
|
||||||
|
<span style={{ fontSize: 13, fontWeight: 700, lineHeight: 1.2, color: isFriday ? '#ef4444' : 'var(--text)' }}>
|
||||||
|
{weekDay}
|
||||||
|
</span>
|
||||||
|
<span style={{ fontSize: 11, color: 'var(--text-2)', lineHeight: 1.2 }}>
|
||||||
|
{formatDate(date)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## باگ ۳ — پیام «slot نیست»
|
||||||
|
|
||||||
|
**فایل:** `AppointmentsPage.tsx` — تابع `ScheduleView`
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// قبل:
|
||||||
|
if (!slots.length) return <div>هیچ slot زمانی برای این روز تنظیم نشده است</div>;
|
||||||
|
|
||||||
|
// بعد:
|
||||||
|
if (!slots.length) return (
|
||||||
|
<div style={{ padding: 40, textAlign: 'center' }}>
|
||||||
|
<div style={{ fontSize: 32, marginBottom: 8 }}>🏖️</div>
|
||||||
|
<div style={{ fontWeight: 700, fontSize: 15, color: 'var(--text)' }}>این روز تعطیل است</div>
|
||||||
|
<div style={{ fontSize: 12, color: 'var(--text-3)', marginTop: 4 }}>
|
||||||
|
هیچ برنامه زمانبندی برای این روز تنظیم نشده است
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## باگ ۴ — نوبت جدید: نام اجباری + find-or-create patient
|
||||||
|
|
||||||
|
### مشکل فعلی
|
||||||
|
|
||||||
|
**Frontend:** `NewAppointmentModal` فقط موبایل دارد، نام ندارد.
|
||||||
|
|
||||||
|
**Backend `POST /api/v1/admin/appointment`** (خط ۷۵۰ در `AdminApiController.php`):
|
||||||
|
```php
|
||||||
|
$patient = $this->em->getRepository(User::class)->findOneBy(['mobileNumber' => $mobile]);
|
||||||
|
if (!$patient) return $this->error('USER_NOT_FOUND', 'بیمار با این شماره یافت نشد', 404);
|
||||||
|
// ❌ اشتباه: به جای ایجاد کاربر جدید، خطا برمیگرداند
|
||||||
|
```
|
||||||
|
|
||||||
|
**Backend `POST /api/v1/appointment`** (برای دکتر/کلینیک):
|
||||||
|
```php
|
||||||
|
// ❌ اشتباه: از کاربر لاگینشده (خود دکتر) به عنوان بیمار استفاده میکند
|
||||||
|
$appointment = new Appointment($doctor, $user, $slotStart, $slotEnd);
|
||||||
|
```
|
||||||
|
|
||||||
|
### فیکس Backend
|
||||||
|
|
||||||
|
#### ۱. `POST /api/v1/admin/appointment` — در `AdminApiController.php`
|
||||||
|
|
||||||
|
افزودن `patient_name` به body و find-or-create:
|
||||||
|
|
||||||
|
```php
|
||||||
|
$mobile = trim($data['patient_mobile'] ?? '');
|
||||||
|
$patientName = trim($data['patient_name'] ?? '');
|
||||||
|
|
||||||
|
if (empty($doctorUuid) || $slotStart <= 0 || $slotEnd <= $slotStart || empty($mobile) || empty($patientName)) {
|
||||||
|
return $this->error('VALIDATION', 'doctor_uuid، slot_start، slot_end، patient_mobile و patient_name الزامی است', 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
// find-or-create:
|
||||||
|
$patient = $this->em->getRepository(User::class)->findOneBy(['mobileNumber' => $mobile]);
|
||||||
|
if (!$patient) {
|
||||||
|
$patient = new User($mobile);
|
||||||
|
$patient->setRealName($patientName);
|
||||||
|
$patient->setRoles(['ROLE_USER']);
|
||||||
|
$this->em->persist($patient);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### ۲. `POST /api/v1/my/appointment` — **endpoint جدید** در `MyAppointmentsController.php`
|
||||||
|
|
||||||
|
این endpoint برای دکتر/کلینیک/منشی است که میخواهند برای بیمار نوبت بگیرند:
|
||||||
|
|
||||||
|
```php
|
||||||
|
#[Route('/api/v1/my/appointment', methods: ['POST'])]
|
||||||
|
#[IsGranted('IS_AUTHENTICATED_FULLY')]
|
||||||
|
public function createAppointment(Request $request, #[CurrentUser] User $user): JsonResponse
|
||||||
|
{
|
||||||
|
$data = json_decode($request->getContent(), true) ?? [];
|
||||||
|
$doctorUuid = trim($data['doctor_uuid'] ?? '');
|
||||||
|
$slotStart = (int) ($data['slot_start'] ?? 0);
|
||||||
|
$slotEnd = (int) ($data['slot_end'] ?? 0);
|
||||||
|
$mobile = trim($data['patient_mobile'] ?? '');
|
||||||
|
$patientName = trim($data['patient_name'] ?? '');
|
||||||
|
|
||||||
|
if (empty($doctorUuid) || $slotStart <= 0 || $slotEnd <= $slotStart || empty($mobile) || empty($patientName)) {
|
||||||
|
return $this->error('VALIDATION', 'همه فیلدها الزامی است', 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
// فقط برای نقشهای مجاز
|
||||||
|
$roles = $user->getRoles();
|
||||||
|
$allowedRoles = ['ROLE_DOCTOR', 'ROLE_CLINIC', 'ROLE_SECRETARY', 'ROLE_ADMIN'];
|
||||||
|
if (!array_intersect($allowedRoles, $roles)) {
|
||||||
|
return $this->error('FORBIDDEN', 'دسترسی ندارید', 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
$doctor = $this->doctorRepo->findByUuid($doctorUuid);
|
||||||
|
if (!$doctor) return $this->error('DOCTOR_NOT_FOUND', 'پزشک یافت نشد', 404);
|
||||||
|
|
||||||
|
// find-or-create patient
|
||||||
|
$patient = $this->em->getRepository(\App\Auth\Entity\User::class)->findOneBy(['mobileNumber' => $mobile]);
|
||||||
|
if (!$patient) {
|
||||||
|
$patient = new \App\Auth\Entity\User($mobile);
|
||||||
|
$patient->setRealName($patientName);
|
||||||
|
$patient->setRoles(['ROLE_USER']);
|
||||||
|
$this->em->persist($patient);
|
||||||
|
}
|
||||||
|
|
||||||
|
// conflict check
|
||||||
|
$conflict = $this->em->createQueryBuilder()
|
||||||
|
->select('COUNT(a.id)')->from(\App\Appointment\Entity\Appointment::class, 'a')
|
||||||
|
->where('a.doctor = :doctor')
|
||||||
|
->andWhere('a.slotStart < :end AND a.slotEnd > :start')
|
||||||
|
->andWhere("a.status NOT IN ('cancelled_by_doctor','cancelled_by_user','cancelled_by_admin','auto_cancel_unpaid')")
|
||||||
|
->setParameter('doctor', $doctor)->setParameter('start', $slotStart)->setParameter('end', $slotEnd)
|
||||||
|
->getQuery()->getSingleScalarResult();
|
||||||
|
if ($conflict > 0) return $this->error('SLOT_TAKEN', 'این نوبت قبلاً رزرو شده است', 409);
|
||||||
|
|
||||||
|
$appointment = new \App\Appointment\Entity\Appointment($doctor, $patient, $slotStart, $slotEnd);
|
||||||
|
$this->em->persist($appointment);
|
||||||
|
$this->em->flush();
|
||||||
|
|
||||||
|
return $this->success(['uuid' => $appointment->getUuid(), 'status' => $appointment->getStatus()], 201);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### فیکس Frontend — `NewAppointmentModal`
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
function NewAppointmentModal({ slot, onClose, onSuccess }) {
|
||||||
|
const [mobile, setMobile] = useState('');
|
||||||
|
const [patientName, setPatientName] = useState('');
|
||||||
|
const qc = useQueryClient();
|
||||||
|
const role = useAuthStore(s => s.primaryRole);
|
||||||
|
|
||||||
|
// ادمین از admin endpoint، بقیه از my endpoint
|
||||||
|
const createEndpoint = role === 'admin'
|
||||||
|
? '/api/v1/admin/appointment'
|
||||||
|
: '/api/v1/my/appointment';
|
||||||
|
|
||||||
|
const mutation = useMutation({
|
||||||
|
mutationFn: () => api.post(createEndpoint, {
|
||||||
|
doctor_uuid: slot.doctor_uuid,
|
||||||
|
slot_start: slot.start,
|
||||||
|
slot_end: slot.end,
|
||||||
|
patient_mobile: mobile,
|
||||||
|
patient_name: patientName,
|
||||||
|
}),
|
||||||
|
// ...
|
||||||
|
});
|
||||||
|
|
||||||
|
const isValid = mobile.length >= 10 && patientName.trim().length >= 2;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ position: 'fixed', inset: 0, ... }}>
|
||||||
|
<div onClick={e => e.stopPropagation()} style={{ ... }}>
|
||||||
|
{/* فیلد نام */}
|
||||||
|
<div style={{ marginBottom: 12 }}>
|
||||||
|
<label style={{ ... }}>نام و نام خانوادگی بیمار *</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={patientName}
|
||||||
|
onChange={e => setPatientName(e.target.value)}
|
||||||
|
placeholder="مثال: علی محمدی"
|
||||||
|
style={{ width: '100%', height: 38, ... }}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{/* فیلد موبایل */}
|
||||||
|
<div style={{ marginBottom: 16 }}>
|
||||||
|
<label style={{ ... }}>شماره موبایل بیمار *</label>
|
||||||
|
<input
|
||||||
|
type="tel"
|
||||||
|
value={mobile}
|
||||||
|
onChange={e => setMobile(e.target.value)}
|
||||||
|
placeholder="مثال: 09123456789"
|
||||||
|
style={{ width: '100%', height: 38, direction: 'ltr', ... }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
|
||||||
|
<button className="btn sm" onClick={onClose}>انصراف</button>
|
||||||
|
<button className="btn primary sm" onClick={() => mutation.mutate()} disabled={!isValid || mutation.isPending}>
|
||||||
|
{mutation.isPending ? '...' : 'ثبت نوبت'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## باگ ۵ — patient_mobile نشان میدهد موبایل پزشک
|
||||||
|
|
||||||
|
### علت ریشهای
|
||||||
|
|
||||||
|
**فایل:** `AppointmentController.php` — endpoint `POST /api/v1/appointment`
|
||||||
|
|
||||||
|
```php
|
||||||
|
// ❌ $user = دکتر لاگینشده → نه بیمار
|
||||||
|
$appointment = new Appointment($doctor, $user, $slotStart, $slotEnd);
|
||||||
|
```
|
||||||
|
|
||||||
|
وقتی دکتر یا کلینیک از `/api/v1/appointment` استفاده میکند، کاربر لاگینشده (خود دکتر) به عنوان بیمار ثبت میشود. پس `u.mobileNumber as patient_mobile` موبایل دکتر را برمیگرداند.
|
||||||
|
|
||||||
|
### فیکس
|
||||||
|
|
||||||
|
بعد از فیکس باگ ۴ (ساخت endpoint جدید `POST /api/v1/my/appointment`)، frontend را آپدیت کن تا از این endpoint استفاده کند:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// قبل:
|
||||||
|
const createEndpoint = role === 'admin' ? '/api/v1/admin/appointment' : '/api/v1/appointment';
|
||||||
|
|
||||||
|
// بعد:
|
||||||
|
const createEndpoint = role === 'admin' ? '/api/v1/admin/appointment' : '/api/v1/my/appointment';
|
||||||
|
```
|
||||||
|
|
||||||
|
این تغییر باگ ۴ و ۵ را با هم رفع میکند.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## باگ ۶ — نوبتهای رزرو شده در نمایش زمانبندی
|
||||||
|
|
||||||
|
### مشکل
|
||||||
|
|
||||||
|
**فایل:** `AppointmentsPage.tsx` — `mergedSlots` useMemo
|
||||||
|
|
||||||
|
نوبتهای رزرو شده باید در زمانبندی با کارت رنگی نشان داده شوند. اگر نمایش داده نمیشوند، احتمالاً تطبیق timestamp ها مشکل دارد.
|
||||||
|
|
||||||
|
### بررسی و فیکس
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const mergedSlots: SlotItem[] = React.useMemo(() => {
|
||||||
|
if (viewMode !== 'schedule') return [];
|
||||||
|
const rawSlots: any[] = (slotsQuery.data?.data as any)?.slots ?? [];
|
||||||
|
const apptByStart = new Map<number, Appointment>();
|
||||||
|
|
||||||
|
// مطمئن شو slot_start عدد صحیح است
|
||||||
|
appointments.forEach(a => {
|
||||||
|
const key = typeof a.slot_start === 'number' ? a.slot_start : parseInt(String(a.slot_start), 10);
|
||||||
|
apptByStart.set(key, a);
|
||||||
|
});
|
||||||
|
|
||||||
|
return rawSlots.map((s: any) => {
|
||||||
|
const slotStart = typeof s.start === 'number' ? s.start : parseInt(String(s.start), 10);
|
||||||
|
const appt = apptByStart.get(slotStart) ?? null;
|
||||||
|
return {
|
||||||
|
start: slotStart,
|
||||||
|
end: typeof s.end === 'number' ? s.end : parseInt(String(s.end), 10),
|
||||||
|
start_time: s.start_time ?? new Date(slotStart * 1000).toLocaleTimeString('fa-IR', { hour: '2-digit', minute: '2-digit' }),
|
||||||
|
end_time: s.end_time ?? new Date((typeof s.end === 'number' ? s.end : parseInt(String(s.end), 10)) * 1000).toLocaleTimeString('fa-IR', { hour: '2-digit', minute: '2-digit' }),
|
||||||
|
is_available: !appt,
|
||||||
|
appointment: appt,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}, [viewMode, slotsQuery.data, appointments]);
|
||||||
|
```
|
||||||
|
|
||||||
|
همچنین مطمئن شو که بعد از ثبت نوبت جدید، هر دو query invalidate میشوند:
|
||||||
|
```tsx
|
||||||
|
onSuccess={() => {
|
||||||
|
qc.invalidateQueries({ queryKey: apptQueryKey });
|
||||||
|
qc.invalidateQueries({ queryKey: slotsQueryKey });
|
||||||
|
qc.invalidateQueries({ queryKey: ['appt-today-stats', selectedDate] });
|
||||||
|
}}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## باگ ۷ — حذف ستون عملیات از نمایش جدولی
|
||||||
|
|
||||||
|
**فایل:** `AppointmentsPage.tsx` — تابع `TableView`
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// حذف شود — header:
|
||||||
|
<th style={th}>عملیات</th>
|
||||||
|
|
||||||
|
// حذف شود — در هر ردیف:
|
||||||
|
<td style={td}>
|
||||||
|
<button style={{ padding: '4px 10px', ... }}>...</button>
|
||||||
|
</td>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## فایلهای تأثیرپذیر
|
||||||
|
|
||||||
|
| فایل | تغییر |
|
||||||
|
|------|-------|
|
||||||
|
| `assets/admin/components/ui/PersianCalendar.tsx` | باگ ۱ — locale `en-u-ca-persian` |
|
||||||
|
| `assets/admin/pages/AppointmentsPage.tsx` | باگ ۲، ۳، ۶، ۷ — UI + merge fix + modal |
|
||||||
|
| `src/Admin/Controller/AdminApiController.php` | باگ ۴ — find-or-create در `createAppointment()` |
|
||||||
|
| `src/Appointment/Controller/MyAppointmentsController.php` | باگ ۴، ۵ — endpoint جدید `POST /api/v1/my/appointment` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ترتیب اجرا
|
||||||
|
|
||||||
|
1. **باگ ۱** — `PersianCalendar.tsx`: فیکس `toJalali` → تست: تقویم باز شود
|
||||||
|
2. **باگ ۲** — `AppointmentsPage.tsx`: روز هفته در DateNavigator
|
||||||
|
3. **باگ ۳** — `AppointmentsPage.tsx`: متن «تعطیل است»
|
||||||
|
4. **باگ ۴+۵** — backend: `AdminApiController.php` + endpoint جدید در `MyAppointmentsController.php` → `php -l` هر دو فایل
|
||||||
|
5. **باگ ۴+۵** — frontend: آپدیت `NewAppointmentModal` با فیلد نام + endpoint جدید
|
||||||
|
6. **باگ ۶** — `AppointmentsPage.tsx`: بررسی و فیکس merge timestamps
|
||||||
|
7. **باگ ۷** — `AppointmentsPage.tsx`: حذف ستون عملیات
|
||||||
|
8. تست TypeScript: `ddev exec npx tsc --noEmit --project tsconfig.json`
|
||||||
|
9. تست build: `ddev exec yarn dev`
|
||||||
@@ -12,8 +12,8 @@ const WEEK_DAYS = ['ش', 'ی', 'د', 'س', 'چ', 'پ', 'ج'];
|
|||||||
const pf = new Intl.DateTimeFormat('fa-IR-u-ca-persian', { calendar: 'persian' });
|
const pf = new Intl.DateTimeFormat('fa-IR-u-ca-persian', { calendar: 'persian' });
|
||||||
|
|
||||||
function toJalali(d: Date): { year: number; month: number; day: number } {
|
function toJalali(d: Date): { year: number; month: number; day: number } {
|
||||||
const parts = new Intl.DateTimeFormat('fa-IR-u-ca-persian', {
|
const parts = new Intl.DateTimeFormat('en-u-ca-persian', {
|
||||||
year: 'numeric', month: 'numeric', day: 'numeric', calendar: 'persian',
|
year: 'numeric', month: 'numeric', day: 'numeric',
|
||||||
}).formatToParts(d);
|
}).formatToParts(d);
|
||||||
const get = (t: string) => parseInt(parts.find(p => p.type === t)?.value ?? '0', 10);
|
const get = (t: string) => parseInt(parts.find(p => p.type === t)?.value ?? '0', 10);
|
||||||
return { year: get('year'), month: get('month'), day: get('day') };
|
return { year: get('year'), month: get('month'), day: get('day') };
|
||||||
|
|||||||
@@ -127,10 +127,19 @@ function StatsBar({ date, isAdmin }: { date: string; isAdmin: boolean }) {
|
|||||||
// Date Navigator
|
// Date Navigator
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const WEEK_DAYS_FA = ['یکشنبه', 'دوشنبه', 'سهشنبه', 'چهارشنبه', 'پنجشنبه', 'جمعه', 'شنبه'];
|
||||||
|
|
||||||
|
function getPersianWeekDay(gregorianDate: string): string {
|
||||||
|
return WEEK_DAYS_FA[new Date(gregorianDate + 'T12:00:00').getDay()];
|
||||||
|
}
|
||||||
|
|
||||||
function DateNavigator({ date, onChange }: { date: string; onChange: (d: string) => void }) {
|
function DateNavigator({ date, onChange }: { date: string; onChange: (d: string) => void }) {
|
||||||
const [showCal, setShowCal] = useState(false);
|
const [showCal, setShowCal] = useState(false);
|
||||||
const ref = useRef<HTMLDivElement>(null);
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const weekDay = getPersianWeekDay(date);
|
||||||
|
const isFriday = new Date(date + 'T12:00:00').getDay() === 5;
|
||||||
|
|
||||||
function addDays(n: number) {
|
function addDays(n: number) {
|
||||||
const d = new Date(date + 'T12:00:00');
|
const d = new Date(date + 'T12:00:00');
|
||||||
d.setDate(d.getDate() + n);
|
d.setDate(d.getDate() + n);
|
||||||
@@ -143,11 +152,17 @@ function DateNavigator({ date, onChange }: { date: string; onChange: (d: string)
|
|||||||
<ChevronRightIcon style={{ width: 15, height: 15 }} />
|
<ChevronRightIcon style={{ width: 15, height: 15 }} />
|
||||||
</button>
|
</button>
|
||||||
<div style={{
|
<div style={{
|
||||||
padding: '0 12px', height: 36, display: 'flex', alignItems: 'center',
|
padding: '0 14px', height: 44,
|
||||||
fontSize: 13, fontWeight: 700, color: 'var(--text)', minWidth: 110, justifyContent: 'center',
|
display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center',
|
||||||
background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 'var(--r-sm)',
|
background: 'var(--surface)', border: '1px solid var(--border)',
|
||||||
|
borderRadius: 'var(--r-sm)', minWidth: 130, gap: 1,
|
||||||
}}>
|
}}>
|
||||||
{formatDate(date)}
|
<span style={{ fontSize: 13, fontWeight: 700, lineHeight: 1.2, color: isFriday ? '#ef4444' : 'var(--text)' }}>
|
||||||
|
{weekDay}
|
||||||
|
</span>
|
||||||
|
<span style={{ fontSize: 11, color: 'var(--text-2)', lineHeight: 1.2 }}>
|
||||||
|
{formatDate(date)}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<button className="btn sm" style={navBtnSx} onClick={() => addDays(-1)}>
|
<button className="btn sm" style={navBtnSx} onClick={() => addDays(-1)}>
|
||||||
<ChevronLeftIcon style={{ width: 15, height: 15 }} />
|
<ChevronLeftIcon style={{ width: 15, height: 15 }} />
|
||||||
@@ -193,7 +208,6 @@ function TableView({
|
|||||||
<th style={th}>شروع</th>
|
<th style={th}>شروع</th>
|
||||||
<th style={th}>پایان</th>
|
<th style={th}>پایان</th>
|
||||||
<th style={th}>وضعیت</th>
|
<th style={th}>وضعیت</th>
|
||||||
<th style={th}>عملیات</th>
|
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@@ -223,15 +237,6 @@ function TableView({
|
|||||||
queryKey={queryKey}
|
queryKey={queryKey}
|
||||||
/>
|
/>
|
||||||
</td>
|
</td>
|
||||||
<td style={td}>
|
|
||||||
<button style={{
|
|
||||||
padding: '4px 10px', borderRadius: 99, fontSize: 12,
|
|
||||||
background: 'var(--surface-2)', border: '1px solid var(--border)',
|
|
||||||
cursor: 'pointer', color: 'var(--text-2)',
|
|
||||||
}}>
|
|
||||||
...
|
|
||||||
</button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
@@ -311,7 +316,13 @@ function ScheduleView({
|
|||||||
slots: SlotItem[]; loading: boolean; queryKey: unknown[]; onBook: (s: SlotItem) => void;
|
slots: SlotItem[]; loading: boolean; queryKey: unknown[]; onBook: (s: SlotItem) => void;
|
||||||
}) {
|
}) {
|
||||||
if (loading) return <div style={{ padding: 40, textAlign: 'center', color: 'var(--text-3)' }}>در حال بارگذاری...</div>;
|
if (loading) return <div style={{ padding: 40, textAlign: 'center', color: 'var(--text-3)' }}>در حال بارگذاری...</div>;
|
||||||
if (!slots.length) return <div style={{ padding: 40, textAlign: 'center', color: 'var(--text-3)' }}>هیچ slot زمانی برای این روز تنظیم نشده است</div>;
|
if (!slots.length) return (
|
||||||
|
<div style={{ padding: 40, textAlign: 'center' }}>
|
||||||
|
<div style={{ fontSize: 32, marginBottom: 8 }}>🏖️</div>
|
||||||
|
<div style={{ fontWeight: 700, fontSize: 15, color: 'var(--text)' }}>این روز تعطیل است</div>
|
||||||
|
<div style={{ fontSize: 12, color: 'var(--text-3)', marginTop: 4 }}>هیچ برنامه زمانبندی برای این روز تنظیم نشده است</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, padding: '4px 0' }}>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, padding: '4px 0' }}>
|
||||||
@@ -340,18 +351,21 @@ interface BookingSlot { start: number; end: number; start_time: string; end_time
|
|||||||
function NewAppointmentModal({
|
function NewAppointmentModal({
|
||||||
slot, onClose, onSuccess,
|
slot, onClose, onSuccess,
|
||||||
}: { slot: BookingSlot; onClose: () => void; onSuccess: () => void }) {
|
}: { slot: BookingSlot; onClose: () => void; onSuccess: () => void }) {
|
||||||
const [mobile, setMobile] = useState('');
|
const [mobile, setMobile] = useState('');
|
||||||
const qc = useQueryClient();
|
const [patientName, setPatientName] = useState('');
|
||||||
|
|
||||||
const role = useAuthStore(s => s.primaryRole);
|
const role = useAuthStore(s => s.primaryRole);
|
||||||
const createEndpoint = role === 'admin' ? '/api/v1/admin/appointment' : '/api/v1/appointment';
|
const createEndpoint = role === 'admin' ? '/api/v1/admin/appointment' : '/api/v1/my/appointment';
|
||||||
|
|
||||||
|
const isValid = mobile.length >= 10 && patientName.trim().length >= 2;
|
||||||
|
|
||||||
const mutation = useMutation({
|
const mutation = useMutation({
|
||||||
mutationFn: () => api.post(createEndpoint, {
|
mutationFn: () => api.post(createEndpoint, {
|
||||||
doctor_uuid: slot.doctor_uuid,
|
doctor_uuid: slot.doctor_uuid,
|
||||||
slot_start: slot.start,
|
slot_start: slot.start,
|
||||||
slot_end: slot.end,
|
slot_end: slot.end,
|
||||||
patient_mobile: mobile,
|
patient_mobile: mobile,
|
||||||
|
patient_name: patientName.trim(),
|
||||||
}),
|
}),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
toast.success('نوبت با موفقیت ثبت شد');
|
toast.success('نوبت با موفقیت ثبت شد');
|
||||||
@@ -359,11 +373,20 @@ function NewAppointmentModal({
|
|||||||
onClose();
|
onClose();
|
||||||
},
|
},
|
||||||
onError: (e: any) => {
|
onError: (e: any) => {
|
||||||
const msg = e?.response?.data?.errors?.[0]?.message ?? 'خطا در ثبت نوبت';
|
const msg = e?.response?.data?.errors?.[0]?.message ?? e?.message ?? 'خطا در ثبت نوبت';
|
||||||
toast.error(msg);
|
toast.error(msg);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const inputSx: React.CSSProperties = {
|
||||||
|
width: '100%', height: 38, padding: '0 10px', borderRadius: 'var(--r-sm)',
|
||||||
|
border: '1px solid var(--border)', background: 'var(--surface)', fontSize: 13,
|
||||||
|
boxSizing: 'border-box',
|
||||||
|
};
|
||||||
|
const labelSx: React.CSSProperties = {
|
||||||
|
display: 'block', fontSize: 12, fontWeight: 600, color: 'var(--text-2)', marginBottom: 6,
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{
|
<div style={{
|
||||||
position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.4)', zIndex: 500,
|
position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.4)', zIndex: 500,
|
||||||
@@ -377,29 +400,36 @@ function NewAppointmentModal({
|
|||||||
<div style={{ fontSize: 13, color: 'var(--text-2)', marginBottom: 16 }}>
|
<div style={{ fontSize: 13, color: 'var(--text-2)', marginBottom: 16 }}>
|
||||||
{slot.start_time} تا {slot.end_time} — {slot.doctor_name}
|
{slot.start_time} تا {slot.end_time} — {slot.doctor_name}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div style={{ marginBottom: 12 }}>
|
||||||
|
<label style={labelSx}>نام و نام خانوادگی بیمار *</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={patientName}
|
||||||
|
onChange={e => setPatientName(e.target.value)}
|
||||||
|
placeholder="مثال: علی محمدی"
|
||||||
|
style={inputSx}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div style={{ marginBottom: 16 }}>
|
<div style={{ marginBottom: 16 }}>
|
||||||
<label style={{ display: 'block', fontSize: 12, fontWeight: 600, color: 'var(--text-2)', marginBottom: 6 }}>
|
<label style={labelSx}>شماره موبایل بیمار *</label>
|
||||||
موبایل بیمار
|
|
||||||
</label>
|
|
||||||
<input
|
<input
|
||||||
type="tel"
|
type="tel"
|
||||||
value={mobile}
|
value={mobile}
|
||||||
onChange={e => setMobile(e.target.value)}
|
onChange={e => setMobile(e.target.value)}
|
||||||
placeholder="مثال: ۰۹۱۲۳۴۵۶۷۸۹"
|
placeholder="مثال: 09123456789"
|
||||||
style={{
|
style={{ ...inputSx, direction: 'ltr' }}
|
||||||
width: '100%', height: 38, padding: '0 10px', borderRadius: 'var(--r-sm)',
|
|
||||||
border: '1px solid var(--border)', background: 'var(--surface)', fontSize: 13,
|
|
||||||
direction: 'ltr', boxSizing: 'border-box',
|
|
||||||
}}
|
|
||||||
autoFocus
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
|
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
|
||||||
<button className="btn sm" onClick={onClose}>انصراف</button>
|
<button className="btn sm" onClick={onClose}>انصراف</button>
|
||||||
<button
|
<button
|
||||||
className="btn primary sm"
|
className="btn primary sm"
|
||||||
onClick={() => mutation.mutate()}
|
onClick={() => mutation.mutate()}
|
||||||
disabled={mobile.length < 10 || mutation.isPending}
|
disabled={!isValid || mutation.isPending}
|
||||||
>
|
>
|
||||||
{mutation.isPending ? '...' : 'ثبت نوبت'}
|
{mutation.isPending ? '...' : 'ثبت نوبت'}
|
||||||
</button>
|
</button>
|
||||||
@@ -424,7 +454,8 @@ export default function AppointmentsPage() {
|
|||||||
const [selectedDate, setSelectedDate] = useState(today);
|
const [selectedDate, setSelectedDate] = useState(today);
|
||||||
const [viewMode, setViewMode] = useState<'table' | 'schedule'>('table');
|
const [viewMode, setViewMode] = useState<'table' | 'schedule'>('table');
|
||||||
const [selectedDoctorUuid, setSelectedDoctorUuid] = useState<string>(isDoctor && dbUuid ? dbUuid : '');
|
const [selectedDoctorUuid, setSelectedDoctorUuid] = useState<string>(isDoctor && dbUuid ? dbUuid : '');
|
||||||
const [bookingSlot, setBookingSlot] = useState<BookingSlot | null>(null);
|
const [bookingSlot, setBookingSlot] = useState<BookingSlot | null>(null);
|
||||||
|
const [bookingHint, setBookingHint] = useState(false);
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
|
|
||||||
// ── Appointments query
|
// ── Appointments query
|
||||||
@@ -464,17 +495,22 @@ export default function AppointmentsPage() {
|
|||||||
if (viewMode !== 'schedule') return [];
|
if (viewMode !== 'schedule') return [];
|
||||||
const rawSlots: any[] = (slotsQuery.data?.data as any)?.slots ?? [];
|
const rawSlots: any[] = (slotsQuery.data?.data as any)?.slots ?? [];
|
||||||
const apptByStart = new Map<number, Appointment>();
|
const apptByStart = new Map<number, Appointment>();
|
||||||
appointments.forEach(a => apptByStart.set(a.slot_start, a));
|
appointments.forEach(a => {
|
||||||
|
const key = typeof a.slot_start === 'number' ? a.slot_start : parseInt(String(a.slot_start), 10);
|
||||||
|
apptByStart.set(key, a);
|
||||||
|
});
|
||||||
|
|
||||||
return rawSlots.map((s: any) => {
|
return rawSlots.map((s: any) => {
|
||||||
const appt = apptByStart.get(s.start) ?? null;
|
const slotStart = typeof s.start === 'number' ? s.start : parseInt(String(s.start), 10);
|
||||||
|
const slotEnd = typeof s.end === 'number' ? s.end : parseInt(String(s.end), 10);
|
||||||
|
const appt = apptByStart.get(slotStart) ?? null;
|
||||||
return {
|
return {
|
||||||
start: s.start,
|
start: slotStart,
|
||||||
end: s.end,
|
end: slotEnd,
|
||||||
start_time: s.start_time ?? new Date(s.start * 1000).toLocaleTimeString('fa-IR', { hour: '2-digit', minute: '2-digit' }),
|
start_time: s.start_time ?? new Date(slotStart * 1000).toLocaleTimeString('fa-IR', { hour: '2-digit', minute: '2-digit' }),
|
||||||
end_time: s.end_time ?? new Date(s.end * 1000).toLocaleTimeString('fa-IR', { hour: '2-digit', minute: '2-digit' }),
|
end_time: s.end_time ?? new Date(slotEnd * 1000).toLocaleTimeString('fa-IR', { hour: '2-digit', minute: '2-digit' }),
|
||||||
is_available: !appt,
|
is_available: !appt,
|
||||||
appointment: appt,
|
appointment: appt,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}, [viewMode, slotsQuery.data, appointments]);
|
}, [viewMode, slotsQuery.data, appointments]);
|
||||||
@@ -482,6 +518,7 @@ export default function AppointmentsPage() {
|
|||||||
// ── Handle slot click → open booking modal
|
// ── Handle slot click → open booking modal
|
||||||
function handleSlotClick(slot: SlotItem) {
|
function handleSlotClick(slot: SlotItem) {
|
||||||
const doctorName = doctors.find(d => d.uuid === selectedDoctorUuid)?.name ?? '';
|
const doctorName = doctors.find(d => d.uuid === selectedDoctorUuid)?.name ?? '';
|
||||||
|
setBookingHint(false);
|
||||||
setBookingSlot({
|
setBookingSlot({
|
||||||
start: slot.start,
|
start: slot.start,
|
||||||
end: slot.end,
|
end: slot.end,
|
||||||
@@ -533,8 +570,8 @@ export default function AppointmentsPage() {
|
|||||||
className="btn primary sm"
|
className="btn primary sm"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (!selectedDoctorUuid) { toast.error('ابتدا یک پزشک انتخاب کنید'); return; }
|
if (!selectedDoctorUuid) { toast.error('ابتدا یک پزشک انتخاب کنید'); return; }
|
||||||
const d = doctors.find(x => x.uuid === selectedDoctorUuid);
|
setViewMode('schedule');
|
||||||
setBookingSlot({ start: 0, end: 0, start_time: '', end_time: '', doctor_uuid: selectedDoctorUuid, doctor_name: d?.name ?? '' });
|
setBookingHint(true);
|
||||||
}}
|
}}
|
||||||
style={{ display: 'flex', alignItems: 'center', gap: 5 }}
|
style={{ display: 'flex', alignItems: 'center', gap: 5 }}
|
||||||
>
|
>
|
||||||
@@ -550,7 +587,7 @@ export default function AppointmentsPage() {
|
|||||||
{(['table', 'schedule'] as const).map(mode => (
|
{(['table', 'schedule'] as const).map(mode => (
|
||||||
<button
|
<button
|
||||||
key={mode}
|
key={mode}
|
||||||
onClick={() => setViewMode(mode)}
|
onClick={() => { setViewMode(mode); if (mode === 'table') setBookingHint(false); }}
|
||||||
style={{
|
style={{
|
||||||
padding: '5px 12px', borderRadius: 'var(--r-sm)', fontSize: 12, fontWeight: 600,
|
padding: '5px 12px', borderRadius: 'var(--r-sm)', fontSize: 12, fontWeight: 600,
|
||||||
border: 'none', cursor: 'pointer',
|
border: 'none', cursor: 'pointer',
|
||||||
@@ -606,12 +643,31 @@ export default function AppointmentsPage() {
|
|||||||
showDoctor={showDoctorCol}
|
showDoctor={showDoctorCol}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<ScheduleView
|
<>
|
||||||
slots={mergedSlots}
|
{bookingHint && (
|
||||||
loading={apptQuery.isLoading || slotsQuery.isLoading}
|
<div style={{
|
||||||
queryKey={apptQueryKey}
|
display: 'flex', alignItems: 'center', gap: 10, marginBottom: 12,
|
||||||
onBook={handleSlotClick}
|
padding: '10px 16px', borderRadius: 'var(--r-sm)',
|
||||||
/>
|
background: '#eff6ff', border: '1px solid #bfdbfe',
|
||||||
|
fontSize: 13, color: '#1d4ed8',
|
||||||
|
}}>
|
||||||
|
<span style={{ fontSize: 18 }}>👆</span>
|
||||||
|
<span>روی یک زمان خالی <strong>کلیک کنید</strong> تا نوبت جدید ثبت شود</span>
|
||||||
|
<button
|
||||||
|
onClick={() => setBookingHint(false)}
|
||||||
|
style={{ marginRight: 'auto', background: 'none', border: 'none', cursor: 'pointer', color: '#93c5fd', fontSize: 16 }}
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<ScheduleView
|
||||||
|
slots={mergedSlots}
|
||||||
|
loading={apptQuery.isLoading || slotsQuery.isLoading}
|
||||||
|
queryKey={apptQueryKey}
|
||||||
|
onBook={handleSlotClick}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+5
-3
@@ -497,7 +497,7 @@ List appointments filtered by date and/or doctor. Sorted by `slot_start ASC`.
|
|||||||
|
|
||||||
### POST `/api/v1/admin/appointment`
|
### POST `/api/v1/admin/appointment`
|
||||||
|
|
||||||
Create a new appointment for a patient identified by mobile number.
|
Create a new appointment for a patient. If no user exists with the given mobile, a new user account is created automatically.
|
||||||
|
|
||||||
**Permission:** `ROLE_ADMIN`
|
**Permission:** `ROLE_ADMIN`
|
||||||
|
|
||||||
@@ -508,10 +508,13 @@ Create a new appointment for a patient identified by mobile number.
|
|||||||
"slot_start": 1718438400,
|
"slot_start": 1718438400,
|
||||||
"slot_end": 1718439600,
|
"slot_end": 1718439600,
|
||||||
"patient_mobile": "09123456789",
|
"patient_mobile": "09123456789",
|
||||||
|
"patient_name": "علی محمدی",
|
||||||
"note": "optional note"
|
"note": "optional note"
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
> `patient_mobile` و `patient_name` هر دو اجباری هستند. اگر کاربری با این شماره موبایل نداشته باشیم، یک کاربر جدید با نقش `ROLE_USER` ساخته میشود.
|
||||||
|
|
||||||
### Response `201`
|
### Response `201`
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
@@ -528,9 +531,8 @@ Create a new appointment for a patient identified by mobile number.
|
|||||||
### Error Responses
|
### Error Responses
|
||||||
| Code | HTTP | Description |
|
| Code | HTTP | Description |
|
||||||
|------|------|-------------|
|
|------|------|-------------|
|
||||||
| `VALIDATION` | 422 | Missing required fields |
|
| `VALIDATION` | 422 | Missing required fields (doctor_uuid, slot_start, slot_end, patient_mobile, patient_name) |
|
||||||
| `DOCTOR_NOT_FOUND` | 404 | Doctor UUID not found |
|
| `DOCTOR_NOT_FOUND` | 404 | Doctor UUID not found |
|
||||||
| `USER_NOT_FOUND` | 404 | No user with that mobile number |
|
|
||||||
| `SLOT_TAKEN` | 409 | Slot already booked |
|
| `SLOT_TAKEN` | 409 | Slot already booked |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -278,6 +278,49 @@ Updated appointment object.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## POST `/api/v1/my/appointment`
|
||||||
|
|
||||||
|
Create a new appointment for a patient. Used by doctor/clinic/secretary to book appointments on behalf of patients. If no user exists with the given mobile, a new user account is created automatically.
|
||||||
|
|
||||||
|
**Auth:** `IS_AUTHENTICATED_FULLY` — Roles: `ROLE_DOCTOR`, `ROLE_CLINIC`, `ROLE_SECRETARY`, `ROLE_ADMIN`
|
||||||
|
|
||||||
|
### Request Body
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"doctor_uuid": "doctor-uuid",
|
||||||
|
"slot_start": 1718438400,
|
||||||
|
"slot_end": 1718439600,
|
||||||
|
"patient_mobile": "09123456789",
|
||||||
|
"patient_name": "علی محمدی",
|
||||||
|
"note": "optional note"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
> اگر کاربری با این شماره موبایل وجود نداشته باشد، یک کاربر جدید با نقش `ROLE_USER` ساخته میشود.
|
||||||
|
|
||||||
|
### Response `201`
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"data": {
|
||||||
|
"uuid": "appt-uuid",
|
||||||
|
"slot_start": 1718438400,
|
||||||
|
"slot_end": 1718439600,
|
||||||
|
"status": "pending"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Error Responses
|
||||||
|
| Code | HTTP | Description |
|
||||||
|
|------|------|-------------|
|
||||||
|
| `FORBIDDEN` | 403 | Role not allowed |
|
||||||
|
| `VALIDATION` | 422 | Missing required fields |
|
||||||
|
| `DOCTOR_NOT_FOUND` | 404 | Doctor UUID not found |
|
||||||
|
| `SLOT_TAKEN` | 409 | Slot already booked |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## GET /api/v1/my/appointments
|
## GET /api/v1/my/appointments
|
||||||
|
|
||||||
Role-aware paginated list of appointments. Returns only what the authenticated user is authorized to see.
|
Role-aware paginated list of appointments. Returns only what the authenticated user is authorized to see.
|
||||||
|
|||||||
@@ -733,21 +733,27 @@ class AdminApiController extends BaseController
|
|||||||
#[Route('/api/v1/admin/appointment', methods: ['POST'])]
|
#[Route('/api/v1/admin/appointment', methods: ['POST'])]
|
||||||
public function createAppointment(Request $request): JsonResponse
|
public function createAppointment(Request $request): JsonResponse
|
||||||
{
|
{
|
||||||
$data = json_decode($request->getContent(), true) ?? [];
|
$data = json_decode($request->getContent(), true) ?? [];
|
||||||
$doctorUuid = trim($data['doctor_uuid'] ?? '');
|
$doctorUuid = trim($data['doctor_uuid'] ?? '');
|
||||||
$slotStart = (int) ($data['slot_start'] ?? 0);
|
$slotStart = (int) ($data['slot_start'] ?? 0);
|
||||||
$slotEnd = (int) ($data['slot_end'] ?? 0);
|
$slotEnd = (int) ($data['slot_end'] ?? 0);
|
||||||
$mobile = trim($data['patient_mobile'] ?? '');
|
$mobile = trim($data['patient_mobile'] ?? '');
|
||||||
|
$patientName = trim($data['patient_name'] ?? '');
|
||||||
|
|
||||||
if (empty($doctorUuid) || $slotStart <= 0 || $slotEnd <= $slotStart || empty($mobile)) {
|
if (empty($doctorUuid) || $slotStart <= 0 || $slotEnd <= $slotStart || empty($mobile) || empty($patientName)) {
|
||||||
return $this->error('VALIDATION', 'doctor_uuid، slot_start، slot_end و patient_mobile الزامی است', 422);
|
return $this->error('VALIDATION', 'doctor_uuid، slot_start، slot_end، patient_mobile و patient_name الزامی است', 422);
|
||||||
}
|
}
|
||||||
|
|
||||||
$doctor = $this->em->getRepository(Doctor::class)->findOneBy(['uuid' => $doctorUuid]);
|
$doctor = $this->em->getRepository(Doctor::class)->findOneBy(['uuid' => $doctorUuid]);
|
||||||
if (!$doctor) return $this->error('DOCTOR_NOT_FOUND', 'پزشک یافت نشد', 404);
|
if (!$doctor) return $this->error('DOCTOR_NOT_FOUND', 'پزشک یافت نشد', 404);
|
||||||
|
|
||||||
$patient = $this->em->getRepository(User::class)->findOneBy(['mobileNumber' => $mobile]);
|
$patient = $this->em->getRepository(User::class)->findOneBy(['mobileNumber' => $mobile]);
|
||||||
if (!$patient) return $this->error('USER_NOT_FOUND', 'بیمار با این شماره یافت نشد', 404);
|
if (!$patient) {
|
||||||
|
$patient = new User($mobile);
|
||||||
|
$patient->setRealName($patientName);
|
||||||
|
$patient->setRoles(['ROLE_USER']);
|
||||||
|
$this->em->persist($patient);
|
||||||
|
}
|
||||||
|
|
||||||
$conflict = $this->em->createQueryBuilder()
|
$conflict = $this->em->createQueryBuilder()
|
||||||
->select('COUNT(a.id)')
|
->select('COUNT(a.id)')
|
||||||
|
|||||||
@@ -24,6 +24,63 @@ class MyAppointmentsController extends BaseController
|
|||||||
private readonly DoctorSecretaryRepository $secretaryRepo,
|
private readonly DoctorSecretaryRepository $secretaryRepo,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
#[Route('/api/v1/my/appointment', methods: ['POST'])]
|
||||||
|
#[IsGranted('IS_AUTHENTICATED_FULLY')]
|
||||||
|
public function createAppointment(Request $request, #[CurrentUser] User $user): JsonResponse
|
||||||
|
{
|
||||||
|
$roles = $user->getRoles();
|
||||||
|
$allowed = ['ROLE_DOCTOR', 'ROLE_CLINIC', 'ROLE_SECRETARY', 'ROLE_ADMIN'];
|
||||||
|
if (!array_intersect($allowed, $roles)) {
|
||||||
|
return $this->error('FORBIDDEN', 'دسترسی ندارید', 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
$data = json_decode($request->getContent(), true) ?? [];
|
||||||
|
$doctorUuid = trim($data['doctor_uuid'] ?? '');
|
||||||
|
$slotStart = (int) ($data['slot_start'] ?? 0);
|
||||||
|
$slotEnd = (int) ($data['slot_end'] ?? 0);
|
||||||
|
$mobile = trim($data['patient_mobile'] ?? '');
|
||||||
|
$patientName = trim($data['patient_name'] ?? '');
|
||||||
|
|
||||||
|
if (empty($doctorUuid) || $slotStart <= 0 || $slotEnd <= $slotStart || empty($mobile) || empty($patientName)) {
|
||||||
|
return $this->error('VALIDATION', 'همه فیلدها الزامی است', 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
$doctor = $this->doctorRepo->findByUuid($doctorUuid);
|
||||||
|
if (!$doctor) return $this->error('DOCTOR_NOT_FOUND', 'پزشک یافت نشد', 404);
|
||||||
|
|
||||||
|
$patient = $this->em->getRepository(User::class)->findOneBy(['mobileNumber' => $mobile]);
|
||||||
|
if (!$patient) {
|
||||||
|
$patient = new User($mobile);
|
||||||
|
$patient->setRealName($patientName);
|
||||||
|
$patient->setRoles(['ROLE_USER']);
|
||||||
|
$this->em->persist($patient);
|
||||||
|
}
|
||||||
|
|
||||||
|
$conflict = $this->em->createQueryBuilder()
|
||||||
|
->select('COUNT(a.id)')->from(Appointment::class, 'a')
|
||||||
|
->where('a.doctor = :doctor')
|
||||||
|
->andWhere('a.slotStart < :end AND a.slotEnd > :start')
|
||||||
|
->andWhere("a.status NOT IN ('cancelled_by_doctor','cancelled_by_user','cancelled_by_admin','auto_cancel_unpaid')")
|
||||||
|
->setParameter('doctor', $doctor)
|
||||||
|
->setParameter('start', $slotStart)
|
||||||
|
->setParameter('end', $slotEnd)
|
||||||
|
->getQuery()->getSingleScalarResult();
|
||||||
|
|
||||||
|
if ($conflict > 0) return $this->error('SLOT_TAKEN', 'این نوبت قبلاً رزرو شده است', 409);
|
||||||
|
|
||||||
|
$appointment = new Appointment($doctor, $patient, $slotStart, $slotEnd);
|
||||||
|
if (!empty($data['note'])) $appointment->setNote($data['note']);
|
||||||
|
$this->em->persist($appointment);
|
||||||
|
$this->em->flush();
|
||||||
|
|
||||||
|
return $this->success([
|
||||||
|
'uuid' => $appointment->getUuid(),
|
||||||
|
'slot_start' => $slotStart,
|
||||||
|
'slot_end' => $slotEnd,
|
||||||
|
'status' => $appointment->getStatus(),
|
||||||
|
], 201);
|
||||||
|
}
|
||||||
|
|
||||||
#[Route('/api/v1/my/appointments', methods: ['GET'])]
|
#[Route('/api/v1/my/appointments', methods: ['GET'])]
|
||||||
#[IsGranted('IS_AUTHENTICATED_FULLY')]
|
#[IsGranted('IS_AUTHENTICATED_FULLY')]
|
||||||
public function myAppointments(Request $request, #[CurrentUser] User $user): JsonResponse
|
public function myAppointments(Request $request, #[CurrentUser] User $user): JsonResponse
|
||||||
|
|||||||
Reference in New Issue
Block a user