Files
clinicpro/.claude/prompt/appointments-day-of-week.md
T
hamed 0b31eb7812 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.
2026-06-11 19:35:12 +03:30

405 lines
15 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# باگ‌فیکس صفحه نوبت‌ها
## فهرست باگ‌ها
| # | باگ | فایل‌ها |
|---|-----|---------|
| ۱ | کرش تقویم — `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`