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:
@@ -12,8 +12,8 @@ const WEEK_DAYS = ['ش', 'ی', 'د', 'س', 'چ', 'پ', 'ج'];
|
||||
const pf = new Intl.DateTimeFormat('fa-IR-u-ca-persian', { calendar: 'persian' });
|
||||
|
||||
function toJalali(d: Date): { year: number; month: number; day: number } {
|
||||
const parts = new Intl.DateTimeFormat('fa-IR-u-ca-persian', {
|
||||
year: 'numeric', month: 'numeric', day: 'numeric', calendar: 'persian',
|
||||
const parts = new Intl.DateTimeFormat('en-u-ca-persian', {
|
||||
year: 'numeric', month: 'numeric', day: 'numeric',
|
||||
}).formatToParts(d);
|
||||
const get = (t: string) => parseInt(parts.find(p => p.type === t)?.value ?? '0', 10);
|
||||
return { year: get('year'), month: get('month'), day: get('day') };
|
||||
|
||||
@@ -127,10 +127,19 @@ function StatsBar({ date, isAdmin }: { date: string; isAdmin: boolean }) {
|
||||
// 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 }) {
|
||||
const [showCal, setShowCal] = useState(false);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
const weekDay = getPersianWeekDay(date);
|
||||
const isFriday = new Date(date + 'T12:00:00').getDay() === 5;
|
||||
|
||||
function addDays(n: number) {
|
||||
const d = new Date(date + 'T12:00:00');
|
||||
d.setDate(d.getDate() + n);
|
||||
@@ -143,11 +152,17 @@ function DateNavigator({ date, onChange }: { date: string; onChange: (d: string)
|
||||
<ChevronRightIcon style={{ width: 15, height: 15 }} />
|
||||
</button>
|
||||
<div style={{
|
||||
padding: '0 12px', height: 36, display: 'flex', alignItems: 'center',
|
||||
fontSize: 13, fontWeight: 700, color: 'var(--text)', minWidth: 110, justifyContent: 'center',
|
||||
background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 'var(--r-sm)',
|
||||
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,
|
||||
}}>
|
||||
{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>
|
||||
<button className="btn sm" style={navBtnSx} onClick={() => addDays(-1)}>
|
||||
<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>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -223,15 +237,6 @@ function TableView({
|
||||
queryKey={queryKey}
|
||||
/>
|
||||
</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>
|
||||
))}
|
||||
</tbody>
|
||||
@@ -311,7 +316,13 @@ function ScheduleView({
|
||||
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 (!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 (
|
||||
<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({
|
||||
slot, onClose, onSuccess,
|
||||
}: { slot: BookingSlot; onClose: () => void; onSuccess: () => void }) {
|
||||
const [mobile, setMobile] = useState('');
|
||||
const qc = useQueryClient();
|
||||
const [mobile, setMobile] = useState('');
|
||||
const [patientName, setPatientName] = useState('');
|
||||
|
||||
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({
|
||||
mutationFn: () => api.post(createEndpoint, {
|
||||
doctor_uuid: slot.doctor_uuid,
|
||||
slot_start: slot.start,
|
||||
slot_end: slot.end,
|
||||
doctor_uuid: slot.doctor_uuid,
|
||||
slot_start: slot.start,
|
||||
slot_end: slot.end,
|
||||
patient_mobile: mobile,
|
||||
patient_name: patientName.trim(),
|
||||
}),
|
||||
onSuccess: () => {
|
||||
toast.success('نوبت با موفقیت ثبت شد');
|
||||
@@ -359,11 +373,20 @@ function NewAppointmentModal({
|
||||
onClose();
|
||||
},
|
||||
onError: (e: any) => {
|
||||
const msg = e?.response?.data?.errors?.[0]?.message ?? 'خطا در ثبت نوبت';
|
||||
const msg = e?.response?.data?.errors?.[0]?.message ?? e?.message ?? 'خطا در ثبت نوبت';
|
||||
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 (
|
||||
<div style={{
|
||||
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 }}>
|
||||
{slot.start_time} تا {slot.end_time} — {slot.doctor_name}
|
||||
</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 }}>
|
||||
<label style={{ display: 'block', fontSize: 12, fontWeight: 600, color: 'var(--text-2)', marginBottom: 6 }}>
|
||||
موبایل بیمار
|
||||
</label>
|
||||
<label style={labelSx}>شماره موبایل بیمار *</label>
|
||||
<input
|
||||
type="tel"
|
||||
value={mobile}
|
||||
onChange={e => setMobile(e.target.value)}
|
||||
placeholder="مثال: ۰۹۱۲۳۴۵۶۷۸۹"
|
||||
style={{
|
||||
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
|
||||
placeholder="مثال: 09123456789"
|
||||
style={{ ...inputSx, 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={mobile.length < 10 || mutation.isPending}
|
||||
disabled={!isValid || mutation.isPending}
|
||||
>
|
||||
{mutation.isPending ? '...' : 'ثبت نوبت'}
|
||||
</button>
|
||||
@@ -424,7 +454,8 @@ export default function AppointmentsPage() {
|
||||
const [selectedDate, setSelectedDate] = useState(today);
|
||||
const [viewMode, setViewMode] = useState<'table' | 'schedule'>('table');
|
||||
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();
|
||||
|
||||
// ── Appointments query
|
||||
@@ -464,17 +495,22 @@ export default function AppointmentsPage() {
|
||||
if (viewMode !== 'schedule') return [];
|
||||
const rawSlots: any[] = (slotsQuery.data?.data as any)?.slots ?? [];
|
||||
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) => {
|
||||
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 {
|
||||
start: s.start,
|
||||
end: s.end,
|
||||
start_time: s.start_time ?? new Date(s.start * 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' }),
|
||||
start: slotStart,
|
||||
end: slotEnd,
|
||||
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(slotEnd * 1000).toLocaleTimeString('fa-IR', { hour: '2-digit', minute: '2-digit' }),
|
||||
is_available: !appt,
|
||||
appointment: appt,
|
||||
appointment: appt,
|
||||
};
|
||||
});
|
||||
}, [viewMode, slotsQuery.data, appointments]);
|
||||
@@ -482,6 +518,7 @@ export default function AppointmentsPage() {
|
||||
// ── Handle slot click → open booking modal
|
||||
function handleSlotClick(slot: SlotItem) {
|
||||
const doctorName = doctors.find(d => d.uuid === selectedDoctorUuid)?.name ?? '';
|
||||
setBookingHint(false);
|
||||
setBookingSlot({
|
||||
start: slot.start,
|
||||
end: slot.end,
|
||||
@@ -533,8 +570,8 @@ export default function AppointmentsPage() {
|
||||
className="btn primary sm"
|
||||
onClick={() => {
|
||||
if (!selectedDoctorUuid) { toast.error('ابتدا یک پزشک انتخاب کنید'); return; }
|
||||
const d = doctors.find(x => x.uuid === selectedDoctorUuid);
|
||||
setBookingSlot({ start: 0, end: 0, start_time: '', end_time: '', doctor_uuid: selectedDoctorUuid, doctor_name: d?.name ?? '' });
|
||||
setViewMode('schedule');
|
||||
setBookingHint(true);
|
||||
}}
|
||||
style={{ display: 'flex', alignItems: 'center', gap: 5 }}
|
||||
>
|
||||
@@ -550,7 +587,7 @@ export default function AppointmentsPage() {
|
||||
{(['table', 'schedule'] as const).map(mode => (
|
||||
<button
|
||||
key={mode}
|
||||
onClick={() => setViewMode(mode)}
|
||||
onClick={() => { setViewMode(mode); if (mode === 'table') setBookingHint(false); }}
|
||||
style={{
|
||||
padding: '5px 12px', borderRadius: 'var(--r-sm)', fontSize: 12, fontWeight: 600,
|
||||
border: 'none', cursor: 'pointer',
|
||||
@@ -606,12 +643,31 @@ export default function AppointmentsPage() {
|
||||
showDoctor={showDoctorCol}
|
||||
/>
|
||||
) : (
|
||||
<ScheduleView
|
||||
slots={mergedSlots}
|
||||
loading={apptQuery.isLoading || slotsQuery.isLoading}
|
||||
queryKey={apptQueryKey}
|
||||
onBook={handleSlotClick}
|
||||
/>
|
||||
<>
|
||||
{bookingHint && (
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', gap: 10, marginBottom: 12,
|
||||
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>
|
||||
|
||||
Reference in New Issue
Block a user