// ─── Calendar Page ───────────────────────────────────────────────────────────
const STATUS_APPT = {
scheduled: { color: 'blue', label: 'Scheduled' },
completed: { color: 'green', label: 'Completed' },
cancelled: { color: 'red', label: 'Cancelled' },
rescheduled: { color: 'amber', label: 'Rescheduled' },
};
function ApptModal({ appt, onClose, onSave }) {
const isEdit = !!appt;
const now = new Date();
const pad = n => String(n).padStart(2, '0');
const defaultStart = `${now.getFullYear()}-${pad(now.getMonth()+1)}-${pad(now.getDate()+1)}T10:00`;
const defaultEnd = `${now.getFullYear()}-${pad(now.getMonth()+1)}-${pad(now.getDate()+1)}T11:00`;
const initForm = isEdit ? {
title: appt.title || '',
contact_name: appt.contact_name || '',
contact_phone: appt.contact_phone || '',
scheduled_start: new Date(appt.scheduled_start).toISOString().slice(0, 16),
scheduled_end: appt.scheduled_end ? new Date(appt.scheduled_end).toISOString().slice(0, 16) : '',
notes: appt.notes || '',
status: appt.status || 'scheduled'
} : {
title: '', contact_name: '', contact_phone: '', scheduled_start: defaultStart, scheduled_end: defaultEnd, notes: '', status: 'scheduled'
};
const [form, setForm] = React.useState(initForm);
const [saving, setSaving] = React.useState(false);
const [error, setError] = React.useState('');
const set = k => v => setForm(p => ({ ...p, [k]: v }));
const handleSave = async () => {
setSaving(true); setError('');
try {
const res = await fetch(isEdit ? `/api/appointments/${appt.id}` : '/api/appointments', {
method: isEdit ? 'PATCH' : 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(form),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || data.detail || 'Failed to save');
onSave(data.appointment || data, isEdit);
onClose();
} catch (e) {
setError(e.message);
} finally {
setSaving(false);
}
};
return (
e.stopPropagation()}>
{isEdit ? 'Edit Appointment' : 'New Appointment'}
✕
{isEdit && (
Status
)}
{error &&
{error}
}
Cancel
{saving ? 'Saving…' : (isEdit ? 'Save Changes' : 'Create Appointment')}
);
}
function CalendarPage() {
const [view, setView] = React.useState('Month');
const [editingAppt, setEditingAppt] = React.useState(null);
const [showModal, setShowModal] = React.useState(false);
const [appts, setAppts] = React.useState([]);
const [loading, setLoading] = React.useState(true);
const [curDate, setCurDate] = React.useState(new Date());
React.useEffect(() => {
fetch('/api/appointments')
.then(r => r.json())
.then(data => { setAppts(Array.isArray(data) ? data : []); setLoading(false); })
.catch(() => { setLoading(false); });
}, []);
const curMonth = new Date(curDate.getFullYear(), curDate.getMonth(), 1);
const monthName = curMonth.toLocaleString('en-US', { month: 'long', year: 'numeric' });
const weekName = `Week of ${new Date(curDate.setDate(curDate.getDate() - curDate.getDay())).toLocaleDateString('en-US', { month: 'short', day: 'numeric' })}`;
const dayName = curDate.toLocaleDateString('en-US', { weekday: 'long', month: 'long', day: 'numeric', year: 'numeric' });
const firstDay = curMonth.getDay();
const daysInMonth = new Date(curMonth.getFullYear(), curMonth.getMonth() + 1, 0).getDate();
const cells = Array(firstDay).fill(null).concat(Array.from({ length: daysInMonth }, (_, i) => i + 1));
while (cells.length % 7 !== 0) cells.push(null);
const today = new Date();
const getApptForDay = (d, m, y) => appts.filter(a => {
const ad = new Date(a.scheduled_start);
return ad.getDate() === d && ad.getMonth() === m && ad.getFullYear() === y;
}).sort((a, b) => new Date(a.scheduled_start) - new Date(b.scheduled_start));
const upcoming = [...appts]
.filter(a => a.status === 'scheduled' && new Date(a.scheduled_start) >= new Date())
.sort((a, b) => new Date(a.scheduled_start) - new Date(b.scheduled_start))
.slice(0, 20);
const prevRange = () => {
if (view === 'Month') setCurDate(new Date(curDate.getFullYear(), curDate.getMonth() - 1, 1));
else if (view === 'Week') setCurDate(new Date(curDate.getFullYear(), curDate.getMonth(), curDate.getDate() - 7));
else setCurDate(new Date(curDate.getFullYear(), curDate.getMonth(), curDate.getDate() - 1));
};
const nextRange = () => {
if (view === 'Month') setCurDate(new Date(curDate.getFullYear(), curDate.getMonth() + 1, 1));
else if (view === 'Week') setCurDate(new Date(curDate.getFullYear(), curDate.getMonth(), curDate.getDate() + 7));
else setCurDate(new Date(curDate.getFullYear(), curDate.getMonth(), curDate.getDate() + 1));
};
const handleDayClick = (day) => {
if (!day) return;
setCurDate(new Date(curMonth.getFullYear(), curMonth.getMonth(), day));
setView('Day');
};
const renderApptBlock = (a) => (
{ e.stopPropagation(); setEditingAppt(a); setShowModal(true); }}
style={{ cursor: 'pointer', background: 'rgba(90,126,245,0.2)', borderRadius: 4, padding: '4px 6px', fontSize: 11, color: '#a0b0f0', marginBottom: 4, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', borderLeft: `2px solid ${STATUS_APPT[a.status]?.color === 'green' ? '#34d399' : (STATUS_APPT[a.status]?.color === 'red' ? '#f87171' : '#5a7ef5')}` }}>
{new Date(a.scheduled_start).toLocaleTimeString('en-IN', { hour: '2-digit', minute: '2-digit', hour12: true }).replace(' ', '')} {a.contact_name} - {a.title}
);
return (
{ setEditingAppt(null); setShowModal(true); }}>+ New Appointment} />
{loading ? : (
‹
{view === 'Month' ? monthName : (view === 'Week' ? weekName : dayName)}
›
{view === 'Month' && (
<>
{['Sun','Mon','Tue','Wed','Thu','Fri','Sat'].map(d => (
{d}
))}
{cells.map((day, i) => {
const dayAppts = day ? getApptForDay(day, curMonth.getMonth(), curMonth.getFullYear()) : [];
const isToday = day && today.getMonth() === curMonth.getMonth() && today.getFullYear() === curMonth.getFullYear() && day === today.getDate();
return (
handleDayClick(day)} style={{ cursor: day ? 'pointer' : 'default', minHeight: 90, padding: 4, borderRadius: 8, background: isToday ? 'rgba(90,126,245,0.08)' : 'transparent', border: isToday ? '1px solid rgba(90,126,245,0.3)' : '1px solid transparent', transition: 'background 0.2s' }}>
{day &&
{day}
}
{dayAppts.slice(0, 3).map((a) => renderApptBlock(a))}
{dayAppts.length > 3 &&
+{dayAppts.length - 3} more
}
);
})}
>
)}
{view === 'Week' && (
{Array.from({length: 7}).map((_, i) => {
const startOfWeek = new Date(curDate);
startOfWeek.setDate(curDate.getDate() - curDate.getDay() + i);
const isToday = startOfWeek.toDateString() === today.toDateString();
const dayAppts = getApptForDay(startOfWeek.getDate(), startOfWeek.getMonth(), startOfWeek.getFullYear());
return (
{ setCurDate(startOfWeek); setView('Day'); }}>
{['Sun','Mon','Tue','Wed','Thu','Fri','Sat'][i]} {startOfWeek.getDate()}
{dayAppts.map(a => renderApptBlock(a))}
);
})}
)}
{view === 'Day' && (
{getApptForDay(curDate.getDate(), curDate.getMonth(), curDate.getFullYear()).length === 0 ? (
No appointments for this day.
) : getApptForDay(curDate.getDate(), curDate.getMonth(), curDate.getFullYear()).map(a => (
{ setEditingAppt(a); setShowModal(true); }} style={{ cursor: 'pointer', background: 'rgba(255,255,255,0.03)', borderLeft: `3px solid ${STATUS_APPT[a.status]?.color === 'green' ? '#34d399' : (STATUS_APPT[a.status]?.color === 'red' ? '#f87171' : '#5a7ef5')}`, borderRadius: 8, padding: '16px 20px', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
{a.title}
{a.contact_name} · {a.contact_phone}
{a.notes &&
{a.notes}
}
{new Date(a.scheduled_start).toLocaleTimeString('en-IN', { hour: '2-digit', minute: '2-digit', hour12: true })}
{STATUS_APPT[a.status]?.label || a.status}
))}
)}
{upcoming.length === 0 ? (
No upcoming appointments.
) : upcoming.map(a => (
{ setEditingAppt(a); setShowModal(true); }} style={{ cursor: 'pointer', padding: '12px 18px', borderBottom: '1px solid rgba(255,255,255,0.04)', transition: 'background 0.2s' }} onMouseEnter={e => e.currentTarget.style.background = 'rgba(255,255,255,0.03)'} onMouseLeave={e => e.currentTarget.style.background = 'transparent'}>
{a.title}
{STATUS_APPT[a.status]?.label || a.status}
{a.contact_name} · {a.contact_phone}
{new Date(a.scheduled_start).toLocaleString('en-IN', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit', hour12: true })}
))}
)}
{showModal && (
{ setShowModal(false); setEditingAppt(null); }}
onSave={(appt, isEdit) => {
if (isEdit) {
setAppts(p => p.map(a => a.id === appt.id ? appt : a));
} else {
setAppts(p => [appt, ...p]);
}
}}
/>
)}
);
}
Object.assign(window, { CalendarPage });