- 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.
15 KiB
باگفیکس صفحه نوبتها
فهرست باگها
| # | باگ | فایلها |
|---|---|---|
| ۱ | کرش تقویم — 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
// مشکل: 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) → کرش.
فیکس
// locale را به 'en' عوض کن — تاریخ شمسی ولی اعداد ASCII
const parts = new Intl.DateTimeFormat('en-u-ca-persian', {
year: 'numeric', month: 'numeric', day: 'numeric',
}).formatToParts(d);
باگ ۲ — روز هفته در DateNavigator
وضعیت فعلی
// فقط تاریخ شمسی — بدون روز هفته
<div style={{ ... }}>{formatDate(date)}</div>
رفتار مورد انتظار
[ < ] ┌──────────────┐ [ > ] [ 📅 ]
│ شنبه │
│ ۱۴۰۴/۰۳/۲۲ │
└──────────────┘
الزامات UI
- روز هفته فارسی: شنبه / یکشنبه / دوشنبه / سهشنبه / چهارشنبه / پنجشنبه / جمعه
- روز هفته پررنگتر از تاریخ
- جمعه به رنگ قرمز (
#ef4444) — تعطیل - واکنشی: مستقیم از prop
dateمحاسبه شود، نه state جداگانه - height کمی بیشتر (۴۴px) — همتراز با دکمههای toolbar
پیادهسازی
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:
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
// قبل:
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):
$patient = $this->em->getRepository(User::class)->findOneBy(['mobileNumber' => $mobile]);
if (!$patient) return $this->error('USER_NOT_FOUND', 'بیمار با این شماره یافت نشد', 404);
// ❌ اشتباه: به جای ایجاد کاربر جدید، خطا برمیگرداند
Backend POST /api/v1/appointment (برای دکتر/کلینیک):
// ❌ اشتباه: از کاربر لاگینشده (خود دکتر) به عنوان بیمار استفاده میکند
$appointment = new Appointment($doctor, $user, $slotStart, $slotEnd);
فیکس Backend
۱. POST /api/v1/admin/appointment — در AdminApiController.php
افزودن patient_name به body و find-or-create:
$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 برای دکتر/کلینیک/منشی است که میخواهند برای بیمار نوبت بگیرند:
#[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
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
// ❌ $user = دکتر لاگینشده → نه بیمار
$appointment = new Appointment($doctor, $user, $slotStart, $slotEnd);
وقتی دکتر یا کلینیک از /api/v1/appointment استفاده میکند، کاربر لاگینشده (خود دکتر) به عنوان بیمار ثبت میشود. پس u.mobileNumber as patient_mobile موبایل دکتر را برمیگرداند.
فیکس
بعد از فیکس باگ ۴ (ساخت endpoint جدید POST /api/v1/my/appointment)، frontend را آپدیت کن تا از این endpoint استفاده کند:
// قبل:
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 ها مشکل دارد.
بررسی و فیکس
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 میشوند:
onSuccess={() => {
qc.invalidateQueries({ queryKey: apptQueryKey });
qc.invalidateQueries({ queryKey: slotsQueryKey });
qc.invalidateQueries({ queryKey: ['appt-today-stats', selectedDate] });
}}
باگ ۷ — حذف ستون عملیات از نمایش جدولی
فایل: AppointmentsPage.tsx — تابع TableView
// حذف شود — 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 |
ترتیب اجرا
- باگ ۱ —
PersianCalendar.tsx: فیکسtoJalali→ تست: تقویم باز شود - باگ ۲ —
AppointmentsPage.tsx: روز هفته در DateNavigator - باگ ۳ —
AppointmentsPage.tsx: متن «تعطیل است» - باگ ۴+۵ — backend:
AdminApiController.php+ endpoint جدید درMyAppointmentsController.php→php -lهر دو فایل - باگ ۴+۵ — frontend: آپدیت
NewAppointmentModalبا فیلد نام + endpoint جدید - باگ ۶ —
AppointmentsPage.tsx: بررسی و فیکس merge timestamps - باگ ۷ —
AppointmentsPage.tsx: حذف ستون عملیات - تست TypeScript:
ddev exec npx tsc --noEmit --project tsconfig.json - تست build:
ddev exec yarn dev