Compare commits
1 Commits
4932974579
...
978d69eea3
| Author | SHA1 | Date | |
|---|---|---|---|
| 978d69eea3 |
@@ -3,19 +3,23 @@ import api from '@/services/api'
|
|||||||
import dayjs from 'dayjs'
|
import dayjs from 'dayjs'
|
||||||
|
|
||||||
// Types for Calendar entities
|
// Types for Calendar entities
|
||||||
|
type SlotType = 'work' | 'on_call' | 'entertainment' | 'system'
|
||||||
|
type EventType = 'job' | 'entertainment' | 'system_event'
|
||||||
|
type SlotStatus = 'not_started' | 'ongoing' | 'deferred' | 'skipped' | 'paused' | 'finished' | 'aborted'
|
||||||
|
|
||||||
interface TimeSlotResponse {
|
interface TimeSlotResponse {
|
||||||
slot_id: string // real int id or "plan-{planId}-{date}" for virtual
|
slot_id: string // real int id or "plan-{planId}-{date}" for virtual
|
||||||
date: string
|
date: string
|
||||||
slot_type: 'work' | 'on_call' | 'entertainment' | 'system'
|
slot_type: SlotType
|
||||||
estimated_duration: number
|
estimated_duration: number
|
||||||
scheduled_at: string // HH:mm
|
scheduled_at: string // HH:mm
|
||||||
started_at: string | null
|
started_at: string | null
|
||||||
attended: boolean
|
attended: boolean
|
||||||
actual_duration: number | null
|
actual_duration: number | null
|
||||||
event_type: 'job' | 'entertainment' | 'system_event' | null
|
event_type: EventType | null
|
||||||
event_data: Record<string, any> | null
|
event_data: Record<string, any> | null
|
||||||
priority: number
|
priority: number
|
||||||
status: 'not_started' | 'ongoing' | 'deferred' | 'skipped' | 'paused' | 'finished' | 'aborted'
|
status: SlotStatus
|
||||||
plan_id: number | null
|
plan_id: number | null
|
||||||
is_virtual: boolean
|
is_virtual: boolean
|
||||||
created_at: string | null
|
created_at: string | null
|
||||||
@@ -24,7 +28,7 @@ interface TimeSlotResponse {
|
|||||||
|
|
||||||
interface SchedulePlanResponse {
|
interface SchedulePlanResponse {
|
||||||
id: number
|
id: number
|
||||||
slot_type: 'work' | 'on_call' | 'entertainment' | 'system'
|
slot_type: SlotType
|
||||||
estimated_duration: number
|
estimated_duration: number
|
||||||
event_type: string | null
|
event_type: string | null
|
||||||
event_data: Record<string, any> | null
|
event_data: Record<string, any> | null
|
||||||
@@ -37,6 +41,32 @@ interface SchedulePlanResponse {
|
|||||||
updated_at: string | null
|
updated_at: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface WorkloadWarning {
|
||||||
|
period: string
|
||||||
|
slot_type: string
|
||||||
|
current: number
|
||||||
|
minimum: number
|
||||||
|
message: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ScheduleResponse {
|
||||||
|
slot: TimeSlotResponse
|
||||||
|
warnings: WorkloadWarning[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const SLOT_TYPES: { value: SlotType; label: string }[] = [
|
||||||
|
{ value: 'work', label: 'Work' },
|
||||||
|
{ value: 'on_call', label: 'On Call' },
|
||||||
|
{ value: 'entertainment', label: 'Entertainment' },
|
||||||
|
{ value: 'system', label: 'System' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const EVENT_TYPES: { value: EventType; label: string }[] = [
|
||||||
|
{ value: 'job', label: 'Job' },
|
||||||
|
{ value: 'entertainment', label: 'Entertainment' },
|
||||||
|
{ value: 'system_event', label: 'System Event' },
|
||||||
|
]
|
||||||
|
|
||||||
const SLOT_TYPE_ICONS: Record<string, string> = {
|
const SLOT_TYPE_ICONS: Record<string, string> = {
|
||||||
work: '💼',
|
work: '💼',
|
||||||
on_call: '📞',
|
on_call: '📞',
|
||||||
@@ -62,6 +92,21 @@ export default function CalendarPage() {
|
|||||||
const [activeTab, setActiveTab] = useState<'daily' | 'plans'>('daily')
|
const [activeTab, setActiveTab] = useState<'daily' | 'plans'>('daily')
|
||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
|
|
||||||
|
// Create/Edit slot modal state
|
||||||
|
const [showSlotModal, setShowSlotModal] = useState(false)
|
||||||
|
const [editingSlot, setEditingSlot] = useState<TimeSlotResponse | null>(null)
|
||||||
|
const [slotForm, setSlotForm] = useState({
|
||||||
|
slot_type: 'work' as SlotType,
|
||||||
|
scheduled_at: '09:00',
|
||||||
|
estimated_duration: 25,
|
||||||
|
event_type: '' as string,
|
||||||
|
event_data_code: '',
|
||||||
|
event_data_event: '',
|
||||||
|
priority: 50,
|
||||||
|
})
|
||||||
|
const [slotSaving, setSlotSaving] = useState(false)
|
||||||
|
const [warnings, setWarnings] = useState<WorkloadWarning[]>([])
|
||||||
|
|
||||||
const fetchSlots = async (date: string) => {
|
const fetchSlots = async (date: string) => {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
setError('')
|
setError('')
|
||||||
@@ -100,17 +145,9 @@ export default function CalendarPage() {
|
|||||||
setSelectedDate(e.target.value)
|
setSelectedDate(e.target.value)
|
||||||
}
|
}
|
||||||
|
|
||||||
const goToday = () => {
|
const goToday = () => setSelectedDate(dayjs().format('YYYY-MM-DD'))
|
||||||
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 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 formatPlanSchedule = (plan: SchedulePlanResponse) => {
|
||||||
const parts: string[] = [`at ${plan.at_time}`]
|
const parts: string[] = [`at ${plan.at_time}`]
|
||||||
@@ -120,6 +157,121 @@ export default function CalendarPage() {
|
|||||||
return parts.join(' · ')
|
return parts.join(' · ')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Slot create/edit ---
|
||||||
|
const openCreateSlotModal = () => {
|
||||||
|
setEditingSlot(null)
|
||||||
|
setSlotForm({
|
||||||
|
slot_type: 'work',
|
||||||
|
scheduled_at: '09:00',
|
||||||
|
estimated_duration: 25,
|
||||||
|
event_type: '',
|
||||||
|
event_data_code: '',
|
||||||
|
event_data_event: '',
|
||||||
|
priority: 50,
|
||||||
|
})
|
||||||
|
setWarnings([])
|
||||||
|
setError('')
|
||||||
|
setShowSlotModal(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const openEditSlotModal = (slot: TimeSlotResponse) => {
|
||||||
|
setEditingSlot(slot)
|
||||||
|
setSlotForm({
|
||||||
|
slot_type: slot.slot_type,
|
||||||
|
scheduled_at: slot.scheduled_at,
|
||||||
|
estimated_duration: slot.estimated_duration,
|
||||||
|
event_type: slot.event_type || '',
|
||||||
|
event_data_code: slot.event_data?.code || '',
|
||||||
|
event_data_event: slot.event_data?.event || '',
|
||||||
|
priority: slot.priority,
|
||||||
|
})
|
||||||
|
setWarnings([])
|
||||||
|
setError('')
|
||||||
|
setShowSlotModal(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const buildEventData = () => {
|
||||||
|
if (!slotForm.event_type) return null
|
||||||
|
if (slotForm.event_type === 'job') {
|
||||||
|
return slotForm.event_data_code ? { type: 'Task', code: slotForm.event_data_code } : null
|
||||||
|
}
|
||||||
|
if (slotForm.event_type === 'system_event') {
|
||||||
|
return slotForm.event_data_event ? { event: slotForm.event_data_event } : null
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSaveSlot = async () => {
|
||||||
|
setSlotSaving(true)
|
||||||
|
setError('')
|
||||||
|
setWarnings([])
|
||||||
|
try {
|
||||||
|
const payload: any = {
|
||||||
|
slot_type: slotForm.slot_type,
|
||||||
|
scheduled_at: slotForm.scheduled_at,
|
||||||
|
estimated_duration: slotForm.estimated_duration,
|
||||||
|
priority: slotForm.priority,
|
||||||
|
event_type: slotForm.event_type || null,
|
||||||
|
event_data: buildEventData(),
|
||||||
|
}
|
||||||
|
|
||||||
|
let response: any
|
||||||
|
if (editingSlot) {
|
||||||
|
// Edit existing slot
|
||||||
|
payload.date = selectedDate
|
||||||
|
response = await api.post(`/calendar/edit?date=${selectedDate}&slot_id=${editingSlot.slot_id}`, payload)
|
||||||
|
} else {
|
||||||
|
// Create new slot
|
||||||
|
payload.date = selectedDate
|
||||||
|
response = await api.post('/calendar/schedule', payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for warnings in response
|
||||||
|
if (response.data?.warnings && response.data.warnings.length > 0) {
|
||||||
|
setWarnings(response.data.warnings)
|
||||||
|
}
|
||||||
|
|
||||||
|
setShowSlotModal(false)
|
||||||
|
fetchSlots(selectedDate)
|
||||||
|
} catch (err: any) {
|
||||||
|
const detail = err.response?.data?.detail
|
||||||
|
if (typeof detail === 'string' && detail.toLowerCase().includes('overlap')) {
|
||||||
|
setError(`⚠️ Overlap conflict: ${detail}`)
|
||||||
|
} else {
|
||||||
|
setError(detail || 'Save failed')
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setSlotSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Slot cancel ---
|
||||||
|
const handleCancelSlot = async (slot: TimeSlotResponse) => {
|
||||||
|
if (!confirm(`Cancel this ${slot.slot_type} slot at ${slot.scheduled_at}?`)) return
|
||||||
|
setError('')
|
||||||
|
try {
|
||||||
|
await api.post(`/calendar/cancel?date=${selectedDate}&slot_id=${slot.slot_id}`)
|
||||||
|
fetchSlots(selectedDate)
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.response?.data?.detail || 'Cancel failed')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Plan cancel ---
|
||||||
|
const handleCancelPlan = async (plan: SchedulePlanResponse) => {
|
||||||
|
if (!confirm(`Cancel plan #${plan.id}? This won't affect past materialized slots.`)) return
|
||||||
|
setError('')
|
||||||
|
try {
|
||||||
|
await api.post(`/calendar/plans/${plan.id}/cancel`)
|
||||||
|
fetchPlans()
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.response?.data?.detail || 'Cancel plan failed')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if a date is in the past
|
||||||
|
const isPastDate = dayjs(selectedDate).isBefore(dayjs().startOf('day'))
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="calendar-page">
|
<div className="calendar-page">
|
||||||
<div className="page-header" style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
|
<div className="page-header" style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
|
||||||
@@ -144,6 +296,18 @@ export default function CalendarPage() {
|
|||||||
|
|
||||||
{error && <div className="error-message" style={{ color: 'var(--danger)', marginBottom: 12 }}>{error}</div>}
|
{error && <div className="error-message" style={{ color: 'var(--danger)', marginBottom: 12 }}>{error}</div>}
|
||||||
|
|
||||||
|
{/* Workload warnings banner */}
|
||||||
|
{warnings.length > 0 && (
|
||||||
|
<div style={{ background: 'var(--warning-bg, #fff3cd)', border: '1px solid var(--warning-border, #ffc107)', borderRadius: 8, padding: 12, marginBottom: 12 }}>
|
||||||
|
<strong>⚠️ Workload Warnings:</strong>
|
||||||
|
<ul style={{ margin: '4px 0 0 16px', padding: 0 }}>
|
||||||
|
{warnings.map((w, i) => (
|
||||||
|
<li key={i}>{w.message} ({w.period} {w.slot_type}: {w.current}/{w.minimum} min)</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{activeTab === 'daily' && (
|
{activeTab === 'daily' && (
|
||||||
<>
|
<>
|
||||||
{/* Date navigation */}
|
{/* Date navigation */}
|
||||||
@@ -157,8 +321,18 @@ export default function CalendarPage() {
|
|||||||
/>
|
/>
|
||||||
<button className="btn-secondary" onClick={goNext}>▶</button>
|
<button className="btn-secondary" onClick={goNext}>▶</button>
|
||||||
<button className="btn-transition" onClick={goToday}>Today</button>
|
<button className="btn-transition" onClick={goToday}>Today</button>
|
||||||
|
{!isPastDate && (
|
||||||
|
<button className="btn-primary" onClick={openCreateSlotModal} style={{ marginLeft: 'auto' }}>+ New Slot</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Deferred slots notice */}
|
||||||
|
{slots.some(s => s.status === 'deferred') && (
|
||||||
|
<div style={{ background: 'var(--warning-bg, #fff3cd)', border: '1px solid var(--warning-border, #ffc107)', borderRadius: 8, padding: 10, marginBottom: 12, fontSize: '0.9rem' }}>
|
||||||
|
⏳ Some slots are <strong>deferred</strong> — they were postponed due to scheduling conflicts or agent unavailability.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Slot list */}
|
{/* Slot list */}
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="loading">Loading...</div>
|
<div className="loading">Loading...</div>
|
||||||
@@ -196,6 +370,25 @@ export default function CalendarPage() {
|
|||||||
📣 {slot.event_data.event}
|
📣 {slot.event_data.event}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{/* Action buttons for non-past, modifiable slots */}
|
||||||
|
{!isPastDate && (slot.status === 'not_started' || slot.status === 'deferred') && (
|
||||||
|
<div style={{ marginTop: 8, display: 'flex', gap: 6 }}>
|
||||||
|
<button
|
||||||
|
className="btn-transition"
|
||||||
|
style={{ padding: '4px 10px', fontSize: '0.8rem' }}
|
||||||
|
onClick={() => openEditSlotModal(slot)}
|
||||||
|
>
|
||||||
|
✏️ Edit
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="btn-danger"
|
||||||
|
style={{ padding: '4px 10px', fontSize: '0.8rem' }}
|
||||||
|
onClick={() => handleCancelSlot(slot)}
|
||||||
|
>
|
||||||
|
❌ Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -223,11 +416,135 @@ export default function CalendarPage() {
|
|||||||
<div>⏱ {plan.estimated_duration} min</div>
|
<div>⏱ {plan.estimated_duration} min</div>
|
||||||
{plan.event_type && <div>📌 {plan.event_type.replace('_', ' ')}</div>}
|
{plan.event_type && <div>📌 {plan.event_type.replace('_', ' ')}</div>}
|
||||||
</div>
|
</div>
|
||||||
|
{plan.is_active && (
|
||||||
|
<div style={{ marginTop: 8 }}>
|
||||||
|
<button
|
||||||
|
className="btn-danger"
|
||||||
|
style={{ padding: '4px 10px', fontSize: '0.8rem' }}
|
||||||
|
onClick={() => handleCancelPlan(plan)}
|
||||||
|
>
|
||||||
|
❌ Cancel Plan
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Create/Edit Slot Modal */}
|
||||||
|
{showSlotModal && (
|
||||||
|
<div className="modal-overlay" onClick={() => setShowSlotModal(false)}>
|
||||||
|
<div className="modal" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<h3>{editingSlot ? 'Edit Slot' : 'New Slot'}</h3>
|
||||||
|
<p style={{ color: 'var(--text-secondary)', fontSize: '0.9rem' }}>
|
||||||
|
Date: <strong>{dayjs(selectedDate).format('MMMM D, YYYY')}</strong>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<label style={{ display: 'block', marginBottom: 8 }}>
|
||||||
|
<strong>Slot Type</strong>
|
||||||
|
<select
|
||||||
|
value={slotForm.slot_type}
|
||||||
|
onChange={(e) => setSlotForm({ ...slotForm, slot_type: e.target.value as SlotType })}
|
||||||
|
style={{ width: '100%', marginTop: 4 }}
|
||||||
|
>
|
||||||
|
{SLOT_TYPES.map((t) => (
|
||||||
|
<option key={t.value} value={t.value}>{t.label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label style={{ display: 'block', marginBottom: 8 }}>
|
||||||
|
<strong>Scheduled At</strong>
|
||||||
|
<input
|
||||||
|
type="time"
|
||||||
|
value={slotForm.scheduled_at}
|
||||||
|
onChange={(e) => setSlotForm({ ...slotForm, scheduled_at: e.target.value })}
|
||||||
|
style={{ width: '100%', marginTop: 4 }}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label style={{ display: 'block', marginBottom: 8 }}>
|
||||||
|
<strong>Estimated Duration (minutes, 1–50)</strong>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
max={50}
|
||||||
|
value={slotForm.estimated_duration}
|
||||||
|
onChange={(e) => setSlotForm({ ...slotForm, estimated_duration: Number(e.target.value) })}
|
||||||
|
style={{ width: '100%', marginTop: 4 }}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label style={{ display: 'block', marginBottom: 8 }}>
|
||||||
|
<strong>Priority (0–99)</strong>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
max={99}
|
||||||
|
value={slotForm.priority}
|
||||||
|
onChange={(e) => setSlotForm({ ...slotForm, priority: Number(e.target.value) })}
|
||||||
|
style={{ width: '100%', marginTop: 4 }}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label style={{ display: 'block', marginBottom: 8 }}>
|
||||||
|
<strong>Event Type</strong>
|
||||||
|
<select
|
||||||
|
value={slotForm.event_type}
|
||||||
|
onChange={(e) => setSlotForm({ ...slotForm, event_type: e.target.value })}
|
||||||
|
style={{ width: '100%', marginTop: 4 }}
|
||||||
|
>
|
||||||
|
<option value="">None</option>
|
||||||
|
{EVENT_TYPES.map((t) => (
|
||||||
|
<option key={t.value} value={t.value}>{t.label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{slotForm.event_type === 'job' && (
|
||||||
|
<label style={{ display: 'block', marginBottom: 8 }}>
|
||||||
|
<strong>Job Code</strong>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={slotForm.event_data_code}
|
||||||
|
onChange={(e) => setSlotForm({ ...slotForm, event_data_code: e.target.value })}
|
||||||
|
placeholder="e.g. TASK-42"
|
||||||
|
style={{ width: '100%', marginTop: 4 }}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{slotForm.event_type === 'system_event' && (
|
||||||
|
<label style={{ display: 'block', marginBottom: 8 }}>
|
||||||
|
<strong>System Event</strong>
|
||||||
|
<select
|
||||||
|
value={slotForm.event_data_event}
|
||||||
|
onChange={(e) => setSlotForm({ ...slotForm, event_data_event: e.target.value })}
|
||||||
|
style={{ width: '100%', marginTop: 4 }}
|
||||||
|
>
|
||||||
|
<option value="">Select event</option>
|
||||||
|
<option value="ScheduleToday">Schedule Today</option>
|
||||||
|
<option value="SummaryToday">Summary Today</option>
|
||||||
|
<option value="ScheduledGatewayRestart">Scheduled Gateway Restart</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div style={{ marginTop: 12, display: 'flex', gap: 8 }}>
|
||||||
|
<button
|
||||||
|
className="btn-primary"
|
||||||
|
onClick={handleSaveSlot}
|
||||||
|
disabled={slotSaving}
|
||||||
|
>
|
||||||
|
{slotSaving ? 'Saving...' : 'Save'}
|
||||||
|
</button>
|
||||||
|
<button className="btn-back" onClick={() => setShowSlotModal(false)}>Cancel</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user