FE-CAL-001/002/003: Add Calendar page with daily slot view and plans tab

- 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
This commit is contained in:
zhi
2026-04-01 06:48:12 +00:00
parent e45281f5ed
commit 4932974579
3 changed files with 236 additions and 0 deletions

View File

@@ -17,6 +17,7 @@ import MonitorPage from '@/pages/MonitorPage'
import ProposalsPage from '@/pages/ProposalsPage'
import ProposalDetailPage from '@/pages/ProposalDetailPage'
import UsersPage from '@/pages/UsersPage'
import CalendarPage from '@/pages/CalendarPage'
import SupportDetailPage from '@/pages/SupportDetailPage'
import MeetingDetailPage from '@/pages/MeetingDetailPage'
import axios from 'axios'
@@ -118,6 +119,7 @@ export default function App() {
<Route path="/milestones/:id" element={<MilestoneDetailPage />} />
<Route path="/proposals" element={<ProposalsPage />} />
<Route path="/proposals/:id" element={<ProposalDetailPage />} />
<Route path="/calendar" element={<CalendarPage />} />
{/* Legacy routes for backward compatibility */}
<Route path="/proposes" element={<ProposalsPage />} />
<Route path="/proposes/:id" element={<ProposalDetailPage />} />

View File

@@ -33,6 +33,7 @@ export default function Sidebar({ user, onLogout }: Props) {
{ to: '/', icon: '📊', label: 'Dashboard' },
{ to: '/projects', icon: '📁', label: 'Projects' },
{ to: '/proposals', icon: '💡', label: 'Proposals' },
{ to: '/calendar', icon: '📅', label: 'Calendar' },
{ to: '/notifications', icon: '🔔', label: 'Notifications' + (unreadCount > 0 ? ' (' + unreadCount + ')' : '') },
{ to: '/monitor', icon: '📡', label: 'Monitor' },
...(user.is_admin ? [

233
src/pages/CalendarPage.tsx Normal file
View File

@@ -0,0 +1,233 @@
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>
)
}