feat: TKB shift scheduler — personnel shift planning web app
Full rewrite of METRO HMG for TKB tunnel department: - People-based grid (18 TKB + 5 IT), year-long calendar - Color-coded shift values (4/6/8/12/A/B/D/N/U/O) - Drag-and-drop cells, multi-cell selection (click/ctrl/shift/drag) - Right-click context menu with color palette - Tunnel closure + Metro + D8 info rows (toggleable) - Czech holidays highlighted with names - PDF export (2-page A4 landscape, DejaVu font for Czech chars) - Improvement proposals system - Sticky headers (vertical + horizontal scroll) - Cell value filter toggles in legend Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
554
web/src/App.tsx
Normal file
554
web/src/App.tsx
Normal file
@@ -0,0 +1,554 @@
|
||||
import { useState, useRef, useCallback, useEffect } from 'react'
|
||||
import { ScheduleTable } from './ScheduleTable'
|
||||
import type { SelectedCell } from './ScheduleTable'
|
||||
import { Toolbar } from './Toolbar'
|
||||
import { ContextMenu } from './ContextMenu'
|
||||
import { ProposalModal } from './ProposalModal'
|
||||
import { useScheduleState } from './useScheduleState'
|
||||
import { useDragCell } from './useDragCell'
|
||||
import { Login, isAuthenticated } from './Login'
|
||||
import { FileManager } from './FileManager'
|
||||
import fallbackData from './data.json'
|
||||
import type { ScheduleData, ScheduleFileWithData, ContextMenuState } from './types'
|
||||
|
||||
function normalizeDayIndex(data: ScheduleData): ScheduleData {
|
||||
const targetStart = new Date(2026, 0, 1) // January 1
|
||||
const targetEnd = new Date(2026, 11, 31) // December 31
|
||||
const IDX_OFFSET = 0
|
||||
|
||||
const existingByDate = new Map<string, typeof data.dayIndex[0]>()
|
||||
for (const d of data.dayIndex) {
|
||||
existingByDate.set(`${d.year}-${d.month}-${d.day}`, d)
|
||||
}
|
||||
|
||||
const newDayIndex: typeof data.dayIndex = []
|
||||
const current = new Date(targetStart)
|
||||
let idx = IDX_OFFSET
|
||||
|
||||
while (current <= targetEnd) {
|
||||
const day = current.getDate()
|
||||
const month = current.getMonth() + 1
|
||||
const year = current.getFullYear()
|
||||
const key = `${year}-${month}-${day}`
|
||||
const existing = existingByDate.get(key)
|
||||
|
||||
newDayIndex.push(existing ?? {
|
||||
idx,
|
||||
day,
|
||||
month,
|
||||
year,
|
||||
week: getISOWeek(current),
|
||||
weekend: current.getDay() === 0 || current.getDay() === 6,
|
||||
})
|
||||
|
||||
idx++
|
||||
current.setDate(current.getDate() + 1)
|
||||
}
|
||||
|
||||
const idxRemap = new Map<number, number>()
|
||||
for (const d of data.dayIndex) {
|
||||
const key = `${d.year}-${d.month}-${d.day}`
|
||||
const newEntry = newDayIndex.find(n => `${n.year}-${n.month}-${n.day}` === key)
|
||||
if (newEntry && newEntry.idx !== d.idx) {
|
||||
idxRemap.set(d.idx, newEntry.idx)
|
||||
}
|
||||
}
|
||||
|
||||
let people = data.people
|
||||
if (idxRemap.size > 0) {
|
||||
people = JSON.parse(JSON.stringify(data.people))
|
||||
for (const person of people) {
|
||||
const newData: typeof person.data = {}
|
||||
for (const [k, v] of Object.entries(person.data)) {
|
||||
const oldIdx = Number(k)
|
||||
const newIdx = idxRemap.get(oldIdx) ?? oldIdx
|
||||
newData[String(newIdx)] = v
|
||||
}
|
||||
person.data = newData
|
||||
}
|
||||
}
|
||||
|
||||
return { ...data, dayIndex: newDayIndex, people }
|
||||
}
|
||||
|
||||
function getISOWeek(date: Date): number {
|
||||
const d = new Date(date.getTime())
|
||||
d.setHours(0, 0, 0, 0)
|
||||
d.setDate(d.getDate() + 3 - ((d.getDay() + 6) % 7))
|
||||
const week1 = new Date(d.getFullYear(), 0, 4)
|
||||
return 1 + Math.round(((d.getTime() - week1.getTime()) / 86400000 - 3 + ((week1.getDay() + 6) % 7)) / 7)
|
||||
}
|
||||
|
||||
type AppMode = 'login' | 'files' | 'editor'
|
||||
|
||||
function App() {
|
||||
const [authed, setAuthed] = useState(isAuthenticated())
|
||||
const [mode, setMode] = useState<AppMode>(authed ? 'files' : 'login')
|
||||
const [fileId, setFileId] = useState<string | null>(null)
|
||||
const [fileName, setFileName] = useState<string>('')
|
||||
const [fileData, setFileData] = useState<ScheduleData | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [compareFileId, setCompareFileId] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (authed && mode === 'login') setMode('files')
|
||||
}, [authed, mode])
|
||||
|
||||
const handleLogin = useCallback(() => {
|
||||
setAuthed(true)
|
||||
setMode('files')
|
||||
}, [])
|
||||
|
||||
const handleOpenFile = useCallback(async (id: string, compareId?: string) => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await fetch(`/api/files/${id}`)
|
||||
if (!res.ok) throw new Error('Failed to load file')
|
||||
const file: ScheduleFileWithData = await res.json()
|
||||
setFileId(file.id)
|
||||
setFileName(file.name)
|
||||
setFileData(normalizeDayIndex(file.data))
|
||||
setCompareFileId(compareId ?? null)
|
||||
setMode('editor')
|
||||
} catch (err) {
|
||||
alert(`Chyba pri otevirani souboru: ${err}`)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleCompare = useCallback((id1: string, id2: string) => {
|
||||
handleOpenFile(id1, id2)
|
||||
}, [handleOpenFile])
|
||||
|
||||
const handleBackToFiles = useCallback(() => {
|
||||
setMode('files')
|
||||
setFileId(null)
|
||||
setFileName('')
|
||||
setFileData(null)
|
||||
setCompareFileId(null)
|
||||
}, [])
|
||||
|
||||
const handleCreateNew = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const name = prompt('Nazev noveho souboru:', `Plan ${new Date().getFullYear()}`)
|
||||
if (!name) { setLoading(false); return }
|
||||
const data = fallbackData as unknown as ScheduleData
|
||||
const res = await fetch('/api/files', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name, data }),
|
||||
})
|
||||
if (!res.ok) throw new Error('Failed to create file')
|
||||
const result = await res.json()
|
||||
handleOpenFile(result.id)
|
||||
} catch (err) {
|
||||
alert(`Chyba pri vytvareni souboru: ${err}`)
|
||||
setLoading(false)
|
||||
}
|
||||
}, [handleOpenFile])
|
||||
|
||||
if (!authed) {
|
||||
return <Login onLogin={handleLogin} />
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-900 flex items-center justify-center">
|
||||
<div className="text-slate-400 text-sm">Nacitam harmonogram...</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (mode === 'files') {
|
||||
return <FileManager onOpenFile={handleOpenFile} onCompare={handleCompare} onCreateNew={handleCreateNew} />
|
||||
}
|
||||
|
||||
if (mode === 'editor' && fileData && fileId) {
|
||||
return (
|
||||
<ScheduleApp
|
||||
fileId={fileId}
|
||||
fileName={fileName}
|
||||
data={fileData}
|
||||
compareFileId={compareFileId}
|
||||
onBack={handleBackToFiles}
|
||||
onFileNameChange={setFileName}
|
||||
onClearCompare={() => setCompareFileId(null)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return <LegacyLoader onBack={handleBackToFiles} />
|
||||
}
|
||||
|
||||
function LegacyLoader({ onBack }: { onBack: () => void }) {
|
||||
const [data, setData] = useState<ScheduleData | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/schedule')
|
||||
.then(r => {
|
||||
if (!r.ok) throw new Error('Failed to load')
|
||||
return r.json()
|
||||
})
|
||||
.then(d => setData(d as ScheduleData))
|
||||
.catch(() => setData(fallbackData as unknown as ScheduleData))
|
||||
.finally(() => setLoading(false))
|
||||
}, [])
|
||||
|
||||
if (loading || !data) {
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-900 flex items-center justify-center">
|
||||
<div className="text-slate-400 text-sm">Nacitam harmonogram...</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<ScheduleApp
|
||||
fileId={null}
|
||||
fileName="Bez nazvu"
|
||||
data={data}
|
||||
compareFileId={null}
|
||||
onBack={onBack}
|
||||
onFileNameChange={() => {}}
|
||||
onClearCompare={() => {}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
interface ScheduleAppProps {
|
||||
fileId: string | null
|
||||
fileName: string
|
||||
data: ScheduleData
|
||||
compareFileId: string | null
|
||||
onBack: () => void
|
||||
onFileNameChange: (name: string) => void
|
||||
onClearCompare: () => void
|
||||
}
|
||||
|
||||
function ScheduleApp({ fileId, fileName, data, compareFileId, onBack, onFileNameChange, onClearCompare }: ScheduleAppProps) {
|
||||
const {
|
||||
people, tunnelClosures, tunnelColors,
|
||||
metroClosures, metroColors, d8Closures, d8Colors,
|
||||
dayComments, cellComments,
|
||||
setCell, setCellColor, moveCell, setTunnelClosure, setTunnelClosureColor,
|
||||
setMetroClosure, setMetroClosureColor, setD8Closure, setD8ClosureColor,
|
||||
undo, canUndo,
|
||||
addDayComment, removeDayComment,
|
||||
addCellComment, removeCellComment,
|
||||
getSchedulePayload,
|
||||
} = useScheduleState(data.people, data.dayIndex, data.tunnelClosures, data.dayComments, data.cellComments, data.infoRows)
|
||||
|
||||
const [contextMenu, setContextMenu] = useState<ContextMenuState | null>(null)
|
||||
const [saveStatus, setSaveStatus] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle')
|
||||
const [selectedDenDays, setSelectedDenDays] = useState<number[]>([])
|
||||
const [currentFileId, setCurrentFileId] = useState<string | null>(fileId)
|
||||
const [compareData, setCompareData] = useState<ScheduleData | null>(null)
|
||||
const [compareFileName, setCompareFileName] = useState<string | null>(null)
|
||||
const scrollRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// Track active month from left edge of scroll
|
||||
const currentMonth = new Date().getMonth() + 1
|
||||
const [activeMonth, setActiveMonth] = useState<number>(currentMonth)
|
||||
|
||||
// Scroll to current month on initial load
|
||||
useEffect(() => {
|
||||
if (!scrollRef.current) return
|
||||
const idx = data.dayIndex.findIndex(d => d.month === currentMonth)
|
||||
if (idx >= 0) {
|
||||
scrollRef.current.scrollTo({ left: idx * 32 })
|
||||
}
|
||||
}, []) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
useEffect(() => {
|
||||
const el = scrollRef.current
|
||||
if (!el) return
|
||||
const handleScroll = () => {
|
||||
const dayIdx = Math.floor(el.scrollLeft / 32)
|
||||
const clamped = Math.max(0, Math.min(dayIdx, data.dayIndex.length - 1))
|
||||
const month = data.dayIndex[clamped]?.month
|
||||
if (month) setActiveMonth(month)
|
||||
}
|
||||
el.addEventListener('scroll', handleScroll, { passive: true })
|
||||
handleScroll()
|
||||
return () => el.removeEventListener('scroll', handleScroll)
|
||||
}, [data.dayIndex])
|
||||
|
||||
const { dragState, onCellPointerDown } = useDragCell({
|
||||
scrollRef,
|
||||
cellWidth: 32,
|
||||
minDayIdx: data.dayIndex[0].idx,
|
||||
maxDayIdx: data.dayIndex[data.dayIndex.length - 1].idx,
|
||||
onMoveCell: moveCell,
|
||||
})
|
||||
|
||||
// Fetch full comparison file when compareFileId is set
|
||||
useEffect(() => {
|
||||
if (!compareFileId || !fileId) {
|
||||
setCompareData(null)
|
||||
setCompareFileName(null)
|
||||
return
|
||||
}
|
||||
let cancelled = false
|
||||
;(async () => {
|
||||
try {
|
||||
const fileRes = await fetch(`/api/files/${compareFileId}`)
|
||||
if (cancelled) return
|
||||
if (fileRes.ok) {
|
||||
const fileData: ScheduleFileWithData = await fileRes.json()
|
||||
setCompareData(fileData.data)
|
||||
setCompareFileName(fileData.name)
|
||||
}
|
||||
} catch {
|
||||
if (!cancelled) {
|
||||
setCompareData(null)
|
||||
setCompareFileName(null)
|
||||
}
|
||||
}
|
||||
})()
|
||||
return () => { cancelled = true }
|
||||
}, [compareFileId, fileId])
|
||||
|
||||
const handleScrollToMonth = useCallback((month: number) => {
|
||||
if (!scrollRef.current) return
|
||||
const idx = data.dayIndex.findIndex(d => d.month === month)
|
||||
if (idx >= 0) {
|
||||
scrollRef.current.scrollTo({ left: idx * 32, behavior: 'smooth' })
|
||||
}
|
||||
}, [data.dayIndex])
|
||||
|
||||
const [selectedCellsForMenu, setSelectedCellsForMenu] = useState<SelectedCell[]>([])
|
||||
|
||||
const handleContextMenu = useCallback((dayIdx: number, personId: string | null, x: number, y: number, selectedCells: SelectedCell[] = []) => {
|
||||
setContextMenu({ dayIdx, personId, x, y })
|
||||
setSelectedCellsForMenu(selectedCells ?? [])
|
||||
}, [])
|
||||
|
||||
const handleTunnelContextMenu = useCallback((dayIdx: number, x: number, y: number) => {
|
||||
setContextMenu({ dayIdx, personId: null, x, y, isTunnelRow: true })
|
||||
}, [])
|
||||
|
||||
const handleInfoRowContextMenu = useCallback((dayIdx: number, infoRowId: string, x: number, y: number) => {
|
||||
setContextMenu({ dayIdx, personId: null, x, y, infoRowId })
|
||||
}, [])
|
||||
|
||||
const handleDenDaySelect = useCallback((dayIdx: number, shiftKey: boolean) => {
|
||||
setSelectedDenDays(prev => {
|
||||
if (shiftKey && prev.length > 0) {
|
||||
const last = prev[prev.length - 1]
|
||||
const min = Math.min(last, dayIdx)
|
||||
const max = Math.max(last, dayIdx)
|
||||
const range: number[] = []
|
||||
for (const d of data.dayIndex) {
|
||||
if (d.idx >= min && d.idx <= max) range.push(d.idx)
|
||||
}
|
||||
const merged = new Set([...prev, ...range])
|
||||
return Array.from(merged).sort((a, b) => a - b)
|
||||
}
|
||||
if (prev.includes(dayIdx)) {
|
||||
return prev.filter(d => d !== dayIdx)
|
||||
}
|
||||
return [dayIdx]
|
||||
})
|
||||
}, [data.dayIndex])
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
setSaveStatus('saving')
|
||||
try {
|
||||
const payload = getSchedulePayload()
|
||||
if (currentFileId) {
|
||||
const res = await fetch(`/api/files/${currentFileId}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ data: payload }),
|
||||
})
|
||||
if (!res.ok) throw new Error('Save failed')
|
||||
} else {
|
||||
const res = await fetch('/api/schedule', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
if (!res.ok) throw new Error('Save failed')
|
||||
}
|
||||
setSaveStatus('saved')
|
||||
setTimeout(() => setSaveStatus('idle'), 3000)
|
||||
} catch {
|
||||
setSaveStatus('error')
|
||||
setTimeout(() => setSaveStatus('idle'), 4000)
|
||||
}
|
||||
}, [getSchedulePayload, currentFileId])
|
||||
|
||||
const handleSaveAs = useCallback(async () => {
|
||||
const name = prompt('Nazev noveho souboru:', fileName ? `${fileName} - kopie` : '')
|
||||
if (!name) return
|
||||
setSaveStatus('saving')
|
||||
try {
|
||||
const payload = getSchedulePayload()
|
||||
const res = await fetch('/api/files', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name, data: payload }),
|
||||
})
|
||||
if (!res.ok) throw new Error('Save as failed')
|
||||
const result = await res.json()
|
||||
setCurrentFileId(result.id)
|
||||
onFileNameChange(result.name)
|
||||
setSaveStatus('saved')
|
||||
setTimeout(() => setSaveStatus('idle'), 3000)
|
||||
} catch {
|
||||
setSaveStatus('error')
|
||||
setTimeout(() => setSaveStatus('idle'), 4000)
|
||||
}
|
||||
}, [getSchedulePayload, fileName, onFileNameChange])
|
||||
|
||||
const [showProposals, setShowProposals] = useState(false)
|
||||
const [showMetro, setShowMetro] = useState(true)
|
||||
const [showD8, setShowD8] = useState(true)
|
||||
const [hiddenValues, setHiddenValues] = useState<Set<string>>(new Set())
|
||||
|
||||
const handleExportPdf = useCallback(async (month: number) => {
|
||||
const { exportMonthPdf } = await import('./PdfExport')
|
||||
const year = data.dayIndex.find(d => d.month === month)?.year ?? new Date().getFullYear()
|
||||
await exportMonthPdf(month, year, data.dayIndex, people, tunnelClosures, tunnelColors)
|
||||
}, [data.dayIndex, people, tunnelClosures, tunnelColors])
|
||||
|
||||
const contextDayInfo = contextMenu ? data.dayIndex.find(d => d.idx === contextMenu.dayIdx) : null
|
||||
const contextPerson = contextMenu?.personId ? people.find(p => p.id === contextMenu.personId) : null
|
||||
const contextCellData = contextMenu?.personId
|
||||
? people.find(p => p.id === contextMenu.personId)?.data[String(contextMenu.dayIdx)]
|
||||
: undefined
|
||||
const contextCellValue = contextCellData?.v
|
||||
const contextCellColor = contextCellData?.color
|
||||
|
||||
// Determine which info row closures/colors/handlers to pass to context menu
|
||||
const contextInfoRowId = contextMenu?.infoRowId
|
||||
const contextInfoRowClosure = contextMenu
|
||||
? contextInfoRowId === 'metro' ? metroClosures.get(contextMenu.dayIdx)
|
||||
: contextInfoRowId === 'd8' ? d8Closures.get(contextMenu.dayIdx)
|
||||
: undefined
|
||||
: undefined
|
||||
const contextInfoRowColor = contextMenu
|
||||
? contextInfoRowId === 'metro' ? metroColors.get(contextMenu.dayIdx)
|
||||
: contextInfoRowId === 'd8' ? d8Colors.get(contextMenu.dayIdx)
|
||||
: undefined
|
||||
: undefined
|
||||
const handleInfoRowSetColor = useCallback((dayIdx: number, color: string | null) => {
|
||||
if (contextInfoRowId === 'metro') setMetroClosureColor(dayIdx, color)
|
||||
else if (contextInfoRowId === 'd8') setD8ClosureColor(dayIdx, color)
|
||||
}, [contextInfoRowId, setMetroClosureColor, setD8ClosureColor])
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-900">
|
||||
<header className="border-b border-slate-700 bg-slate-900/80 backdrop-blur-sm sticky top-0 z-50 px-6 py-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<button
|
||||
onClick={onBack}
|
||||
className="px-3 py-1.5 rounded text-xs bg-slate-800 text-slate-300 border border-slate-700
|
||||
hover:bg-slate-700 cursor-pointer transition-colors"
|
||||
>
|
||||
← Zpet
|
||||
</button>
|
||||
<h1 className="text-xl font-semibold text-slate-100 tracking-tight">
|
||||
{fileName || 'TKB Plan sluzeb'}
|
||||
<span className="ml-3 text-sm font-normal text-slate-400">
|
||||
Plan sluzeb a pohotovosti
|
||||
</span>
|
||||
</h1>
|
||||
{compareFileName && (
|
||||
<span className="px-2 py-1 rounded text-xs bg-blue-900/50 text-blue-300 border border-blue-800">
|
||||
Porovnani s: {compareFileName}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
<main className="p-4">
|
||||
<Toolbar
|
||||
onUndo={undo}
|
||||
canUndo={canUndo}
|
||||
onSave={handleSave}
|
||||
onSaveAs={handleSaveAs}
|
||||
saveStatus={saveStatus}
|
||||
onScrollToMonth={handleScrollToMonth}
|
||||
activeMonth={activeMonth}
|
||||
showMetro={showMetro}
|
||||
showD8={showD8}
|
||||
onToggleMetro={() => setShowMetro(v => !v)}
|
||||
onToggleD8={() => setShowD8(v => !v)}
|
||||
hiddenValues={hiddenValues}
|
||||
onToggleValue={(code: string) => setHiddenValues(prev => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(code)) next.delete(code); else next.add(code)
|
||||
return next
|
||||
})}
|
||||
diffFileName={compareFileName}
|
||||
onCloseDiff={() => { setCompareData(null); setCompareFileName(null); onClearCompare() }}
|
||||
onExportPdf={handleExportPdf}
|
||||
onShowProposals={() => setShowProposals(true)}
|
||||
/>
|
||||
<ScheduleTable
|
||||
dayIndex={data.dayIndex}
|
||||
people={people}
|
||||
tunnelClosures={tunnelClosures}
|
||||
tunnelColors={tunnelColors}
|
||||
metroClosures={metroClosures}
|
||||
metroColors={metroColors}
|
||||
d8Closures={d8Closures}
|
||||
d8Colors={d8Colors}
|
||||
dayComments={dayComments}
|
||||
cellComments={cellComments}
|
||||
dragState={dragState}
|
||||
onCellPointerDown={onCellPointerDown}
|
||||
onSetCell={setCell}
|
||||
onSetTunnelClosure={setTunnelClosure}
|
||||
onSetMetroClosure={setMetroClosure}
|
||||
onSetD8Closure={setD8Closure}
|
||||
showMetro={showMetro}
|
||||
showD8={showD8}
|
||||
hiddenValues={hiddenValues}
|
||||
scrollRef={scrollRef}
|
||||
onContextMenu={handleContextMenu}
|
||||
onTunnelContextMenu={handleTunnelContextMenu}
|
||||
onInfoRowContextMenu={handleInfoRowContextMenu}
|
||||
compareData={compareData}
|
||||
/>
|
||||
</main>
|
||||
|
||||
{contextMenu && contextDayInfo && (
|
||||
<ContextMenu
|
||||
state={contextMenu}
|
||||
dayInfo={contextDayInfo}
|
||||
personName={contextPerson?.name || null}
|
||||
cellValue={contextCellValue}
|
||||
cellColor={contextCellColor}
|
||||
existingDayComment={dayComments.get(contextMenu.dayIdx)}
|
||||
existingCellComment={contextMenu.personId ? cellComments.get(`${contextMenu.personId}-${contextMenu.dayIdx}`) : undefined}
|
||||
existingTunnelClosure={tunnelClosures.get(contextMenu.dayIdx)}
|
||||
existingTunnelColor={tunnelColors.get(contextMenu.dayIdx)}
|
||||
existingInfoRowClosure={contextInfoRowClosure}
|
||||
existingInfoRowColor={contextInfoRowColor}
|
||||
selectedDays={selectedDenDays}
|
||||
selectedCells={selectedCellsForMenu}
|
||||
onSetCell={setCell}
|
||||
onAddDayComment={addDayComment}
|
||||
onRemoveDayComment={removeDayComment}
|
||||
onAddCellComment={addCellComment}
|
||||
onRemoveCellComment={removeCellComment}
|
||||
onSetTunnelClosure={setTunnelClosure}
|
||||
onSetCellColor={setCellColor}
|
||||
onSetTunnelClosureColor={setTunnelClosureColor}
|
||||
onSetInfoRowColor={handleInfoRowSetColor}
|
||||
onClose={() => { setContextMenu(null); setSelectedDenDays([]) }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showProposals && (
|
||||
<ProposalModal onClose={() => setShowProposals(false)} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default App
|
||||
Reference in New Issue
Block a user