FE-CAL-004/005: Add slot create/edit/cancel modals and status warnings
- Create slot modal with type, time, duration, priority, event type/data fields - Edit slot modal (pre-populates from existing slot) - Cancel slot with confirmation dialog - Cancel plan with confirmation dialog - Overlap error display in modal - Workload warnings banner after save - Deferred slots notice banner - Edit/Cancel buttons on modifiable slots (not_started/deferred, non-past dates) - Past date detection prevents new slot creation
This commit is contained in:
@@ -3,19 +3,23 @@ import api from '@/services/api'
|
||||
import dayjs from 'dayjs'
|
||||
|
||||
// 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 {
|
||||
slot_id: string // real int id or "plan-{planId}-{date}" for virtual
|
||||
date: string
|
||||
slot_type: 'work' | 'on_call' | 'entertainment' | 'system'
|
||||
slot_type: SlotType
|
||||
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_type: EventType | null
|
||||
event_data: Record<string, any> | null
|
||||
priority: number
|
||||
status: 'not_started' | 'ongoing' | 'deferred' | 'skipped' | 'paused' | 'finished' | 'aborted'
|
||||
status: SlotStatus
|
||||
plan_id: number | null
|
||||
is_virtual: boolean
|
||||
created_at: string | null
|
||||
@@ -24,7 +28,7 @@ interface TimeSlotResponse {
|
||||
|
||||
interface SchedulePlanResponse {
|
||||
id: number
|
||||
slot_type: 'work' | 'on_call' | 'entertainment' | 'system'
|
||||
slot_type: SlotType
|
||||
estimated_duration: number
|
||||
event_type: string | null
|
||||
event_data: Record<string, any> | null
|
||||
@@ -37,6 +41,32 @@ interface SchedulePlanResponse {
|
||||
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> = {
|
||||
work: '💼',
|
||||
on_call: '📞',
|
||||
@@ -62,6 +92,21 @@ export default function CalendarPage() {
|
||||
const [activeTab, setActiveTab] = useState<'daily' | 'plans'>('daily')
|
||||
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) => {
|
||||
setLoading(true)
|
||||
setError('')
|
||||
@@ -100,17 +145,9 @@ export default function CalendarPage() {
|
||||
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 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}`]
|
||||
@@ -120,6 +157,121 @@ export default function CalendarPage() {
|
||||
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 (
|
||||
<div className="calendar-page">
|
||||
<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>}
|
||||
|
||||
{/* 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' && (
|
||||
<>
|
||||
{/* Date navigation */}
|
||||
@@ -157,8 +321,18 @@ export default function CalendarPage() {
|
||||
/>
|
||||
<button className="btn-secondary" onClick={goNext}>▶</button>
|
||||
<button className="btn-transition" onClick={goToday}>Today</button>
|
||||
{!isPastDate && (
|
||||
<button className="btn-primary" onClick={openCreateSlotModal} style={{ marginLeft: 'auto' }}>+ New Slot</button>
|
||||
)}
|
||||
</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 */}
|
||||
{loading ? (
|
||||
<div className="loading">Loading...</div>
|
||||
@@ -196,6 +370,25 @@ export default function CalendarPage() {
|
||||
📣 {slot.event_data.event}
|
||||
</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>
|
||||
@@ -223,11 +416,135 @@ export default function CalendarPage() {
|
||||
<div>⏱ {plan.estimated_duration} min</div>
|
||||
{plan.event_type && <div>📌 {plan.event_type.replace('_', ' ')}</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>
|
||||
)}
|
||||
|
||||
{/* 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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user