Files
fabdash/frontend/src/components/Calendar/MainCalendar.jsx

197 lines
7.6 KiB
React
Raw Normal View History

2026-03-05 17:00:55 -06:00
import { useRef, useState, useCallback, useEffect } from 'react'
import FullCalendar from '@fullcalendar/react'
import dayGridPlugin from '@fullcalendar/daygrid'
import timeGridPlugin from '@fullcalendar/timegrid'
import interactionPlugin from '@fullcalendar/interaction'
import useProjectStore from '../../store/useProjectStore'
import useFocusStore from '../../store/useFocusStore'
import useUIStore from '../../store/useUIStore'
import useToastStore from '../../store/useToastStore'
import { updateDeliverable, deleteDeliverable } from '../../api/deliverables'
import DeliverableModal from '../Deliverables/DeliverableModal'
import ContextMenu from '../UI/ContextMenu'
import EventTooltip from './EventTooltip'
import WorkloadHeatmap from './WorkloadHeatmap'
export default function MainCalendar({ onCalendarReady }) {
2026-03-05 18:10:56 -06:00
const calRef = useRef(null)
const wrapperRef = useRef(null)
2026-03-05 17:00:55 -06:00
const { projects, updateDeliverable: storeUpdate, removeDeliverable } = useProjectStore()
2026-03-05 18:10:56 -06:00
const openFocus = useFocusStore(s => s.openFocus)
2026-03-05 17:00:55 -06:00
const { showHeatmap, toggleHeatmap } = useUIStore()
2026-03-05 18:10:56 -06:00
const addToast = useToastStore(s => s.addToast)
2026-03-05 17:00:55 -06:00
const [modal, setModal] = useState({ open: false, deliverable: null, defaultDate: '' })
const [contextMenu, setContextMenu] = useState(null)
const [tooltip, setTooltip] = useState(null)
// Expose calendar API to App.jsx for keyboard shortcuts
useEffect(() => {
if (calRef.current && onCalendarReady) {
onCalendarReady(calRef.current.getApi())
}
}, [])
2026-03-05 18:10:56 -06:00
// ResizeObserver: call updateSize() on every frame the container changes width
// during the sidebar CSS transition so FullCalendar reflows smoothly
useEffect(() => {
const el = wrapperRef.current
if (!el) return
const ro = new ResizeObserver(() => {
calRef.current?.getApi().updateSize()
})
ro.observe(el)
return () => ro.disconnect()
}, [])
2026-03-05 17:00:55 -06:00
const events = projects.flatMap(p =>
(p.deliverables || []).map(d => ({
id: String(d.id),
title: `${p.name}: ${d.title}`,
start: d.due_date,
allDay: true,
backgroundColor: p.color,
borderColor: p.color,
extendedProps: { deliverableId: d.id, projectId: p.id },
}))
)
const getCtx = (projectId, deliverableId) => {
const project = projects.find(p => p.id === projectId)
const deliverable = project?.deliverables.find(d => d.id === deliverableId)
return { project, deliverable }
}
const handleEventClick = useCallback(({ event }) => {
const { deliverableId, projectId } = event.extendedProps
openFocus(projectId, deliverableId)
}, [openFocus])
// Drag-and-drop with 30-second undo toast
const handleEventDrop = useCallback(async ({ event, oldEvent }) => {
const { deliverableId } = event.extendedProps
const newDate = event.startStr.substring(0, 10)
const oldDate = oldEvent.startStr.substring(0, 10)
storeUpdate(await updateDeliverable(deliverableId, { due_date: newDate }))
addToast({
message: `Moved to ${newDate}`,
duration: 30,
undoFn: async () => {
storeUpdate(await updateDeliverable(deliverableId, { due_date: oldDate }))
},
})
}, [storeUpdate, addToast])
2026-03-05 18:10:56 -06:00
// Click empty date - open add modal
2026-03-05 17:00:55 -06:00
const handleDateClick = useCallback(({ dateStr }) => {
setModal({ open: true, deliverable: null, defaultDate: dateStr.substring(0, 10) })
}, [])
2026-03-05 18:10:56 -06:00
// Date range drag-select - pre-fill modal with start date
2026-03-05 17:00:55 -06:00
const handleSelect = useCallback(({ startStr }) => {
setModal({ open: true, deliverable: null, defaultDate: startStr.substring(0, 10) })
}, [])
// Attach dblclick + contextmenu + tooltip via eventDidMount
const handleEventDidMount = useCallback(({ event, el }) => {
const { deliverableId, projectId } = event.extendedProps
el.addEventListener('mouseenter', (e) => {
const { project, deliverable } = getCtx(projectId, deliverableId)
setTooltip({ x: e.clientX, y: e.clientY, project, deliverable })
})
el.addEventListener('mouseleave', () => setTooltip(null))
el.addEventListener('mousemove', (e) => {
setTooltip(prev => prev ? { ...prev, x: e.clientX, y: e.clientY } : null)
})
el.addEventListener('dblclick', (e) => {
e.preventDefault(); e.stopPropagation()
setTooltip(null)
const { deliverable } = getCtx(projectId, deliverableId)
if (deliverable) setModal({ open: true, deliverable, defaultDate: '' })
})
el.addEventListener('contextmenu', (e) => {
e.preventDefault(); e.stopPropagation()
setTooltip(null)
const { project, deliverable } = getCtx(projectId, deliverableId)
if (!deliverable) return
setContextMenu({
x: e.clientX, y: e.clientY,
items: [
2026-03-05 18:10:56 -06:00
{ icon: '\u2714\ufe0e', label: 'Edit Deliverable', action: () => setModal({ open: true, deliverable, defaultDate: '' }) },
{ icon: '\u2756', label: 'Open Focus View', action: () => openFocus(projectId, deliverableId) },
...(project?.drive_url ? [{ icon: '\u2b21', label: 'Open Drive Folder', action: () => window.open(project.drive_url, '_blank') }] : []),
2026-03-05 17:00:55 -06:00
{ separator: true },
2026-03-05 18:10:56 -06:00
{ icon: '\u2715', label: 'Delete Deliverable', danger: true,
2026-03-05 17:00:55 -06:00
action: async () => {
if (window.confirm(`Delete "${deliverable.title}"?`)) {
await deleteDeliverable(deliverableId); removeDeliverable(deliverableId)
}
}
},
],
})
})
}, [projects, openFocus])
return (
<div className="h-full flex flex-col bg-surface" onContextMenu={e => e.preventDefault()}>
{/* View toggle toolbar */}
<div className="flex items-center justify-end gap-2 px-4 pt-3 pb-0 flex-shrink-0">
<button onClick={toggleHeatmap}
className={`text-xs px-3 py-1.5 rounded-lg border transition-all font-medium
${showHeatmap
? 'bg-gold text-surface border-gold'
: 'bg-surface-elevated border-surface-border text-text-muted hover:border-gold/40 hover:text-gold'
}`}>
2026-03-05 18:10:56 -06:00
{showHeatmap ? '\u2190 Calendar' : '\u2b21 Heatmap'}
2026-03-05 17:00:55 -06:00
</button>
</div>
{/* Main content area */}
<div className="flex-1 overflow-hidden">
{showHeatmap ? (
<WorkloadHeatmap />
) : (
2026-03-05 18:10:56 -06:00
<div ref={wrapperRef} className="h-full p-4 pt-2">
2026-03-05 17:00:55 -06:00
<FullCalendar
ref={calRef}
plugins={[dayGridPlugin, timeGridPlugin, interactionPlugin]}
initialView="dayGridMonth"
headerToolbar={{ left: 'prev,next today', center: 'title', right: 'dayGridMonth,timeGridWeek,timeGridDay' }}
events={events}
editable={true}
selectable={true}
weekNumbers={true}
eventDrop={handleEventDrop}
eventClick={handleEventClick}
dateClick={handleDateClick}
select={handleSelect}
eventDidMount={handleEventDidMount}
height="100%"
dayMaxEvents={4}
eventDisplay="block"
displayEventTime={false}
/>
</div>
)}
</div>
<DeliverableModal
isOpen={modal.open}
onClose={() => setModal({ open: false, deliverable: null, defaultDate: '' })}
deliverable={modal.deliverable}
defaultDate={modal.defaultDate}
/>
{contextMenu && (
<ContextMenu x={contextMenu.x} y={contextMenu.y} items={contextMenu.items} onClose={() => setContextMenu(null)} />
)}
<EventTooltip tooltip={tooltip} />
</div>
)
}