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:
zhi
2026-04-01 06:50:03 +00:00
parent 4932974579
commit 978d69eea3

View File

@@ -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, 150)</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 (099)</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>
)
}