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:
@@ -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 />} />
|
||||
|
||||
@@ -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
233
src/pages/CalendarPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user