- 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.
177 lines
6.7 KiB
TypeScript
177 lines
6.7 KiB
TypeScript
import React, { useEffect, useRef, useState } from 'react';
|
|
import { ChevronRightIcon, ChevronLeftIcon } from '@heroicons/react/24/outline';
|
|
|
|
interface Props {
|
|
value: string; // YYYY-MM-DD Gregorian
|
|
onChange: (v: string) => void;
|
|
onClose: () => void;
|
|
}
|
|
|
|
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('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') };
|
|
}
|
|
|
|
function jalaliFirstWeekday(year: number, month: number): number {
|
|
// Build a Gregorian date for the 1st of this Jalali month by iterating
|
|
// Use the Intl API: find Gregorian date whose Jalali = year/month/1
|
|
// Approximate: use known offset
|
|
const approxGreg = jalaliToGregorian(year, month, 1);
|
|
const d = new Date(approxGreg + 'T12:00:00');
|
|
// JS getDay(): 0=Sun,1=Mon,...,6=Sat → convert to Sat=0
|
|
return (d.getDay() + 1) % 7; // Sat=0, Sun=1, ..., Fri=6
|
|
}
|
|
|
|
function jalaliToGregorian(jy: number, jm: number, jd: number): string {
|
|
const jy2 = jy - 979;
|
|
const jm2 = jm - 1;
|
|
let jDay = 365 * jy2 + Math.floor(jy2 / 33) * 8 + Math.floor(((jy2 % 33) + 3) / 4);
|
|
for (let i = 0; i < jm2; i++) jDay += (i < 6 ? 31 : 30);
|
|
jDay += jd - 1;
|
|
let gDay = jDay + 79;
|
|
let gy2 = 1600 + 400 * Math.floor(gDay / 146097);
|
|
gDay %= 146097;
|
|
let leap = true;
|
|
if (gDay >= 36525) {
|
|
gDay--;
|
|
const gi = Math.floor(gDay / 36524);
|
|
gDay %= 36524;
|
|
gy2 += gi * 100;
|
|
if (gDay >= 365) { gDay++; leap = false; }
|
|
}
|
|
gy2 += Math.floor(gDay / 1461) * 4;
|
|
gDay %= 1461;
|
|
if (gDay >= 366) {
|
|
leap = false;
|
|
gDay--;
|
|
gy2 += Math.floor(gDay / 365);
|
|
gDay %= 365;
|
|
}
|
|
const gMonthDays = [31, leap ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
|
|
let gm = 0;
|
|
for (; gm < 12; gm++) {
|
|
if (gDay < gMonthDays[gm]) break;
|
|
gDay -= gMonthDays[gm];
|
|
}
|
|
return `${gy2}-${String(gm + 1).padStart(2, '0')}-${String(gDay + 1).padStart(2, '0')}`;
|
|
}
|
|
|
|
const JALALI_MONTHS = ['فروردین','اردیبهشت','خرداد','تیر','مرداد','شهریور','مهر','آبان','آذر','دی','بهمن','اسفند'];
|
|
|
|
export default function PersianCalendar({ value, onChange, onClose }: Props) {
|
|
const ref = useRef<HTMLDivElement>(null);
|
|
|
|
const todayGreg = new Date().toISOString().slice(0, 10);
|
|
const todayJ = toJalali(new Date(todayGreg + 'T12:00:00'));
|
|
const valueJ = value ? toJalali(new Date(value + 'T12:00:00')) : todayJ;
|
|
|
|
const [viewYear, setViewYear] = useState(valueJ.year);
|
|
const [viewMonth, setViewMonth] = useState(valueJ.month);
|
|
|
|
useEffect(() => {
|
|
function handler(e: MouseEvent) {
|
|
if (ref.current && !ref.current.contains(e.target as Node)) onClose();
|
|
}
|
|
document.addEventListener('mousedown', handler);
|
|
return () => document.removeEventListener('mousedown', handler);
|
|
}, [onClose]);
|
|
|
|
const daysCount = viewMonth <= 6 ? 31 : viewMonth <= 11 ? 30 : 29;
|
|
const firstWd = jalaliFirstWeekday(viewYear, viewMonth);
|
|
|
|
function prevMonth() {
|
|
if (viewMonth === 1) { setViewYear(y => y - 1); setViewMonth(12); }
|
|
else setViewMonth(m => m - 1);
|
|
}
|
|
function nextMonth() {
|
|
if (viewMonth === 12) { setViewYear(y => y + 1); setViewMonth(1); }
|
|
else setViewMonth(m => m + 1);
|
|
}
|
|
|
|
function selectDay(day: number) {
|
|
const greg = jalaliToGregorian(viewYear, viewMonth, day);
|
|
onChange(greg);
|
|
onClose();
|
|
}
|
|
|
|
const isToday = (day: number) =>
|
|
todayJ.year === viewYear && todayJ.month === viewMonth && todayJ.day === day;
|
|
|
|
const isSelected = (day: number) =>
|
|
value !== '' && valueJ.year === viewYear && valueJ.month === viewMonth && valueJ.day === day;
|
|
|
|
const cells: (number | null)[] = [...Array(firstWd).fill(null), ...Array.from({ length: daysCount }, (_, i) => i + 1)];
|
|
while (cells.length % 7 !== 0) cells.push(null);
|
|
|
|
return (
|
|
<div ref={ref} style={{
|
|
position: 'absolute', top: '100%', right: 0, zIndex: 999, marginTop: 4,
|
|
background: 'var(--surface)', border: '1px solid var(--border)',
|
|
borderRadius: 'var(--r)', boxShadow: 'var(--shadow-lg)',
|
|
padding: '12px', width: 268, direction: 'rtl',
|
|
}}>
|
|
{/* Header */}
|
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 10 }}>
|
|
<button onClick={nextMonth} style={navBtnStyle}>
|
|
<ChevronRightIcon style={{ width: 16, height: 16 }} />
|
|
</button>
|
|
<span style={{ fontWeight: 700, fontSize: 14 }}>
|
|
{JALALI_MONTHS[viewMonth - 1]} {viewYear.toLocaleString('fa-IR')}
|
|
</span>
|
|
<button onClick={prevMonth} style={navBtnStyle}>
|
|
<ChevronLeftIcon style={{ width: 16, height: 16 }} />
|
|
</button>
|
|
</div>
|
|
|
|
{/* Week day headers */}
|
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(7, 1fr)', gap: 2, marginBottom: 4 }}>
|
|
{WEEK_DAYS.map(d => (
|
|
<div key={d} style={{ textAlign: 'center', fontSize: 11, color: 'var(--text-3)', fontWeight: 600, padding: '2px 0' }}>
|
|
{d}
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
{/* Days grid */}
|
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(7, 1fr)', gap: 2 }}>
|
|
{cells.map((day, i) => {
|
|
if (!day) return <div key={i} />;
|
|
const sel = isSelected(day);
|
|
const tod = isToday(day) && !sel;
|
|
return (
|
|
<button
|
|
key={i}
|
|
onClick={() => selectDay(day)}
|
|
style={{
|
|
width: 32, height: 32, borderRadius: '50%', fontSize: 12,
|
|
border: 'none', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center',
|
|
fontWeight: sel || tod ? 700 : 400,
|
|
background: sel ? 'var(--primary)' : tod ? 'var(--surface-2)' : 'transparent',
|
|
color: sel ? '#fff' : tod ? 'var(--text)' : 'var(--text)',
|
|
transition: 'background 0.1s',
|
|
}}
|
|
onMouseEnter={e => { if (!sel && !tod) (e.currentTarget as HTMLButtonElement).style.background = 'var(--surface-2)'; }}
|
|
onMouseLeave={e => { if (!sel && !tod) (e.currentTarget as HTMLButtonElement).style.background = 'transparent'; }}
|
|
>
|
|
{day.toLocaleString('fa-IR')}
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const navBtnStyle: React.CSSProperties = {
|
|
background: 'transparent', border: 'none', cursor: 'pointer',
|
|
color: 'var(--text-2)', padding: 4, borderRadius: 'var(--r-sm)',
|
|
display: 'flex', alignItems: 'center',
|
|
};
|