- New CalendarPage with daily view (date navigation, slot list) and plans tab - Route /calendar added in App.tsx - Sidebar entry added after Proposals - Daily view: shows time slots with type, status, priority, duration, event data - Distinguishes real vs virtual (plan) slots visually - Plans tab: shows schedule plan rules with schedule parameters
234 lines
8.5 KiB
TypeScript
234 lines
8.5 KiB
TypeScript
import { useState, useEffect } from 'react'
|
|
import api from '@/services/api'
|
|
import dayjs from 'dayjs'
|
|
|
|
// Types for Calendar entities
|
|
interface TimeSlotResponse {
|
|
slot_id: string // real int id or "plan-{planId}-{date}" for virtual
|
|
date: string
|
|
slot_type: 'work' | 'on_call' | 'entertainment' | 'system'
|
|
estimated_duration: number
|
|
scheduled_at: string // HH:mm
|
|
started_at: string | null
|
|
attended: boolean
|
|
actual_duration: number | null
|
|
event_type: 'job' | 'entertainment' | 'system_event' | null
|
|
event_data: Record<string, any> | null
|
|
priority: number
|
|
status: 'not_started' | 'ongoing' | 'deferred' | 'skipped' | 'paused' | 'finished' | 'aborted'
|
|
plan_id: number | null
|
|
is_virtual: boolean
|
|
created_at: string | null
|
|
updated_at: string | null
|
|
}
|
|
|
|
interface SchedulePlanResponse {
|
|
id: number
|
|
slot_type: 'work' | 'on_call' | 'entertainment' | 'system'
|
|
estimated_duration: number
|
|
event_type: string | null
|
|
event_data: Record<string, any> | null
|
|
at_time: string
|
|
on_day: string | null
|
|
on_week: number | null
|
|
on_month: string | null
|
|
is_active: boolean
|
|
created_at: string
|
|
updated_at: string | null
|
|
}
|
|
|
|
const SLOT_TYPE_ICONS: Record<string, string> = {
|
|
work: '💼',
|
|
on_call: '📞',
|
|
entertainment: '🎮',
|
|
system: '⚙️',
|
|
}
|
|
|
|
const STATUS_CLASSES: Record<string, string> = {
|
|
not_started: 'status-open',
|
|
ongoing: 'status-undergoing',
|
|
deferred: 'status-pending',
|
|
skipped: 'status-closed',
|
|
paused: 'status-pending',
|
|
finished: 'status-completed',
|
|
aborted: 'status-closed',
|
|
}
|
|
|
|
export default function CalendarPage() {
|
|
const [selectedDate, setSelectedDate] = useState(dayjs().format('YYYY-MM-DD'))
|
|
const [slots, setSlots] = useState<TimeSlotResponse[]>([])
|
|
const [plans, setPlans] = useState<SchedulePlanResponse[]>([])
|
|
const [loading, setLoading] = useState(false)
|
|
const [activeTab, setActiveTab] = useState<'daily' | 'plans'>('daily')
|
|
const [error, setError] = useState('')
|
|
|
|
const fetchSlots = async (date: string) => {
|
|
setLoading(true)
|
|
setError('')
|
|
try {
|
|
const { data } = await api.get<TimeSlotResponse[]>(`/calendar/day?date=${date}`)
|
|
setSlots(data)
|
|
} catch (err: any) {
|
|
setError(err.response?.data?.detail || 'Failed to load calendar')
|
|
setSlots([])
|
|
} finally {
|
|
setLoading(false)
|
|
}
|
|
}
|
|
|
|
const fetchPlans = async () => {
|
|
try {
|
|
const { data } = await api.get<SchedulePlanResponse[]>('/calendar/plans')
|
|
setPlans(data)
|
|
} catch (err: any) {
|
|
console.error('Failed to load plans:', err)
|
|
}
|
|
}
|
|
|
|
useEffect(() => {
|
|
fetchSlots(selectedDate)
|
|
fetchPlans()
|
|
}, [])
|
|
|
|
useEffect(() => {
|
|
if (activeTab === 'daily') {
|
|
fetchSlots(selectedDate)
|
|
}
|
|
}, [selectedDate])
|
|
|
|
const handleDateChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
setSelectedDate(e.target.value)
|
|
}
|
|
|
|
const goToday = () => {
|
|
setSelectedDate(dayjs().format('YYYY-MM-DD'))
|
|
}
|
|
|
|
const goPrev = () => {
|
|
setSelectedDate(dayjs(selectedDate).subtract(1, 'day').format('YYYY-MM-DD'))
|
|
}
|
|
|
|
const goNext = () => {
|
|
setSelectedDate(dayjs(selectedDate).add(1, 'day').format('YYYY-MM-DD'))
|
|
}
|
|
|
|
const formatPlanSchedule = (plan: SchedulePlanResponse) => {
|
|
const parts: string[] = [`at ${plan.at_time}`]
|
|
if (plan.on_day) parts.push(`on ${plan.on_day}`)
|
|
if (plan.on_week) parts.push(`week ${plan.on_week}`)
|
|
if (plan.on_month) parts.push(`in ${plan.on_month}`)
|
|
return parts.join(' · ')
|
|
}
|
|
|
|
return (
|
|
<div className="calendar-page">
|
|
<div className="page-header" style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
|
|
<h2>📅 Calendar</h2>
|
|
</div>
|
|
|
|
{/* Tab switcher */}
|
|
<div style={{ display: 'flex', gap: 8, marginBottom: 16 }}>
|
|
<button
|
|
className={activeTab === 'daily' ? 'btn-primary' : 'btn-secondary'}
|
|
onClick={() => setActiveTab('daily')}
|
|
>
|
|
Daily View
|
|
</button>
|
|
<button
|
|
className={activeTab === 'plans' ? 'btn-primary' : 'btn-secondary'}
|
|
onClick={() => setActiveTab('plans')}
|
|
>
|
|
Plans ({plans.length})
|
|
</button>
|
|
</div>
|
|
|
|
{error && <div className="error-message" style={{ color: 'var(--danger)', marginBottom: 12 }}>{error}</div>}
|
|
|
|
{activeTab === 'daily' && (
|
|
<>
|
|
{/* Date navigation */}
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 16 }}>
|
|
<button className="btn-secondary" onClick={goPrev}>◀</button>
|
|
<input
|
|
type="date"
|
|
value={selectedDate}
|
|
onChange={handleDateChange}
|
|
style={{ fontSize: '1rem', padding: '4px 8px' }}
|
|
/>
|
|
<button className="btn-secondary" onClick={goNext}>▶</button>
|
|
<button className="btn-transition" onClick={goToday}>Today</button>
|
|
</div>
|
|
|
|
{/* Slot list */}
|
|
{loading ? (
|
|
<div className="loading">Loading...</div>
|
|
) : slots.length === 0 ? (
|
|
<div className="empty" style={{ padding: '40px 0', color: 'var(--text-secondary)', textAlign: 'center' }}>
|
|
No slots scheduled for {dayjs(selectedDate).format('MMMM D, YYYY')}.
|
|
</div>
|
|
) : (
|
|
<div className="milestone-grid">
|
|
{slots.map((slot) => (
|
|
<div
|
|
key={slot.slot_id}
|
|
className="milestone-card"
|
|
style={{ opacity: slot.is_virtual ? 0.8 : 1 }}
|
|
>
|
|
<div className="milestone-card-header" style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
|
|
<span style={{ fontSize: '1.2rem' }}>{SLOT_TYPE_ICONS[slot.slot_type] || '📋'}</span>
|
|
<span className="badge">{slot.slot_type.replace('_', ' ')}</span>
|
|
<span className={`badge ${STATUS_CLASSES[slot.status] || ''}`}>{slot.status.replace('_', ' ')}</span>
|
|
{slot.is_virtual && <span className="badge" style={{ background: 'var(--text-secondary)', color: 'white', fontSize: '0.7rem' }}>plan</span>}
|
|
<span style={{ marginLeft: 'auto', fontWeight: 600 }}>{slot.scheduled_at}</span>
|
|
</div>
|
|
<div style={{ marginTop: 8, display: 'flex', gap: 16, fontSize: '0.9rem', color: 'var(--text-secondary)' }}>
|
|
<span>⏱ {slot.estimated_duration} min</span>
|
|
<span>⚡ Priority: {slot.priority}</span>
|
|
{slot.event_type && <span>📌 {slot.event_type.replace('_', ' ')}</span>}
|
|
</div>
|
|
{slot.event_data && slot.event_data.code && (
|
|
<div style={{ marginTop: 4, fontSize: '0.85rem', color: 'var(--text-secondary)' }}>
|
|
🔗 {slot.event_data.code}
|
|
</div>
|
|
)}
|
|
{slot.event_data && slot.event_data.event && (
|
|
<div style={{ marginTop: 4, fontSize: '0.85rem', color: 'var(--text-secondary)' }}>
|
|
📣 {slot.event_data.event}
|
|
</div>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
|
|
{activeTab === 'plans' && (
|
|
<div className="milestone-grid">
|
|
{plans.length === 0 ? (
|
|
<div className="empty" style={{ padding: '40px 0', color: 'var(--text-secondary)', textAlign: 'center' }}>
|
|
No schedule plans configured.
|
|
</div>
|
|
) : (
|
|
plans.map((plan) => (
|
|
<div key={plan.id} className="milestone-card">
|
|
<div className="milestone-card-header" style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
|
|
<span style={{ fontSize: '1.2rem' }}>{SLOT_TYPE_ICONS[plan.slot_type] || '📋'}</span>
|
|
<span className="badge">{plan.slot_type.replace('_', ' ')}</span>
|
|
{!plan.is_active && <span className="badge status-closed">inactive</span>}
|
|
<span style={{ marginLeft: 'auto', fontWeight: 600 }}>Plan #{plan.id}</span>
|
|
</div>
|
|
<div style={{ marginTop: 8, fontSize: '0.9rem', color: 'var(--text-secondary)' }}>
|
|
<div>🔄 {formatPlanSchedule(plan)}</div>
|
|
<div>⏱ {plan.estimated_duration} min</div>
|
|
{plan.event_type && <div>📌 {plan.event_type.replace('_', ' ')}</div>}
|
|
</div>
|
|
</div>
|
|
))
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|