Compare commits
1 Commits
e45281f5ed
...
4932974579
| Author | SHA1 | Date | |
|---|---|---|---|
| 4932974579 |
@@ -17,6 +17,7 @@ import MonitorPage from '@/pages/MonitorPage'
|
|||||||
import ProposalsPage from '@/pages/ProposalsPage'
|
import ProposalsPage from '@/pages/ProposalsPage'
|
||||||
import ProposalDetailPage from '@/pages/ProposalDetailPage'
|
import ProposalDetailPage from '@/pages/ProposalDetailPage'
|
||||||
import UsersPage from '@/pages/UsersPage'
|
import UsersPage from '@/pages/UsersPage'
|
||||||
|
import CalendarPage from '@/pages/CalendarPage'
|
||||||
import SupportDetailPage from '@/pages/SupportDetailPage'
|
import SupportDetailPage from '@/pages/SupportDetailPage'
|
||||||
import MeetingDetailPage from '@/pages/MeetingDetailPage'
|
import MeetingDetailPage from '@/pages/MeetingDetailPage'
|
||||||
import axios from 'axios'
|
import axios from 'axios'
|
||||||
@@ -118,6 +119,7 @@ export default function App() {
|
|||||||
<Route path="/milestones/:id" element={<MilestoneDetailPage />} />
|
<Route path="/milestones/:id" element={<MilestoneDetailPage />} />
|
||||||
<Route path="/proposals" element={<ProposalsPage />} />
|
<Route path="/proposals" element={<ProposalsPage />} />
|
||||||
<Route path="/proposals/:id" element={<ProposalDetailPage />} />
|
<Route path="/proposals/:id" element={<ProposalDetailPage />} />
|
||||||
|
<Route path="/calendar" element={<CalendarPage />} />
|
||||||
{/* Legacy routes for backward compatibility */}
|
{/* Legacy routes for backward compatibility */}
|
||||||
<Route path="/proposes" element={<ProposalsPage />} />
|
<Route path="/proposes" element={<ProposalsPage />} />
|
||||||
<Route path="/proposes/:id" element={<ProposalDetailPage />} />
|
<Route path="/proposes/:id" element={<ProposalDetailPage />} />
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ export default function Sidebar({ user, onLogout }: Props) {
|
|||||||
{ to: '/', icon: '📊', label: 'Dashboard' },
|
{ to: '/', icon: '📊', label: 'Dashboard' },
|
||||||
{ to: '/projects', icon: '📁', label: 'Projects' },
|
{ to: '/projects', icon: '📁', label: 'Projects' },
|
||||||
{ to: '/proposals', icon: '💡', label: 'Proposals' },
|
{ to: '/proposals', icon: '💡', label: 'Proposals' },
|
||||||
|
{ to: '/calendar', icon: '📅', label: 'Calendar' },
|
||||||
{ to: '/notifications', icon: '🔔', label: 'Notifications' + (unreadCount > 0 ? ' (' + unreadCount + ')' : '') },
|
{ to: '/notifications', icon: '🔔', label: 'Notifications' + (unreadCount > 0 ? ' (' + unreadCount + ')' : '') },
|
||||||
{ to: '/monitor', icon: '📡', label: 'Monitor' },
|
{ to: '/monitor', icon: '📡', label: 'Monitor' },
|
||||||
...(user.is_admin ? [
|
...(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