# باگ‌فیکس صفحه نوبت‌ها

## فهرست باگ‌ها

| # | باگ | فایل‌ها |
|---|-----|---------|
| ۱ | کرش تقویم — `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`
