Initial commit
This commit is contained in:
457
src/components/GanttChart.tsx
Normal file
457
src/components/GanttChart.tsx
Normal file
@@ -0,0 +1,457 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import type { Process, Server } from '../api/baserow'
|
||||
|
||||
const SERVER_ICON = (
|
||||
<svg width="14" height="14" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2m-2-4h.01M17 16h.01" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
type ViewMode = '2H' | '6H' | 'Arbeitszeit' | 'Nachts' | 'Tag' | 'Woche' | 'Monat'
|
||||
|
||||
function getViewBounds(mode: ViewMode, referenceDate: Date): { start: Date, end: Date, spanHours: number } {
|
||||
const d = new Date(referenceDate)
|
||||
let start = new Date(d)
|
||||
let end = new Date(d)
|
||||
let spanHours = 0
|
||||
|
||||
if (mode === '2H') {
|
||||
start = new Date(d.getTime() - 1 * 3600_000)
|
||||
end = new Date(d.getTime() + 1 * 3600_000)
|
||||
spanHours = 2
|
||||
} else if (mode === '6H') {
|
||||
start = new Date(d.getTime() - 3 * 3600_000)
|
||||
end = new Date(d.getTime() + 3 * 3600_000)
|
||||
spanHours = 6
|
||||
} else if (mode === 'Arbeitszeit') {
|
||||
start.setHours(6, 0, 0, 0)
|
||||
end.setHours(18, 0, 0, 0)
|
||||
spanHours = 12
|
||||
} else if (mode === 'Nachts') {
|
||||
if (d.getHours() < 6) d.setDate(d.getDate() - 1)
|
||||
start = new Date(d)
|
||||
start.setHours(18, 0, 0, 0)
|
||||
end = new Date(start.getTime() + 12 * 3600_000)
|
||||
spanHours = 12
|
||||
} else if (mode === 'Tag') {
|
||||
start.setHours(0, 0, 0, 0)
|
||||
end = new Date(start.getTime() + 24 * 3600_000)
|
||||
spanHours = 24
|
||||
} else if (mode === 'Woche') {
|
||||
const day = start.getDay()
|
||||
const diff = start.getDate() - day + (day === 0 ? -6 : 1)
|
||||
start.setDate(diff)
|
||||
start.setHours(0, 0, 0, 0)
|
||||
end = new Date(start.getTime() + 7 * 24 * 3600_000)
|
||||
spanHours = 168
|
||||
} else if (mode === 'Monat') {
|
||||
start.setDate(1)
|
||||
start.setHours(0, 0, 0, 0)
|
||||
end = new Date(start.getTime())
|
||||
end.setMonth(end.getMonth() + 1)
|
||||
spanHours = (end.getTime() - start.getTime()) / 3600_000
|
||||
}
|
||||
|
||||
return { start, end, spanHours }
|
||||
}
|
||||
|
||||
function parseTime(tStr: string | null | undefined): [number, number, number] {
|
||||
if (!tStr) return [12, 0, 0]
|
||||
const [h, m, s] = tStr.split(':').map(Number)
|
||||
return [isNaN(h) ? 12 : h, isNaN(m) ? 0 : m, isNaN(s) ? 0 : s]
|
||||
}
|
||||
|
||||
export default function GanttChart({ processes, servers, onRunningChange }: { processes: Process[], servers?: Server[], onRunningChange?: (count: number) => void }) {
|
||||
const [view, setView] = useState<ViewMode>('Tag')
|
||||
const [now, setNow] = useState(new Date())
|
||||
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => setNow(new Date()), 60_000)
|
||||
return () => clearInterval(interval)
|
||||
}, [])
|
||||
|
||||
// Calculate Running Processes whenever now or processes change
|
||||
useEffect(() => {
|
||||
if (!onRunningChange) return
|
||||
|
||||
let count = 0
|
||||
const nowMs = now.getTime()
|
||||
|
||||
for (const proc of processes) {
|
||||
if (proc.Status?.value !== 'aktiv') continue
|
||||
|
||||
// Minimal version of renderBlocks logic to find if any block overlaps 'now'
|
||||
const rep = proc.Wiederholung?.value
|
||||
let isRunning = false
|
||||
|
||||
// We only need to check today's blocks for 'now'
|
||||
const iter = new Date(now)
|
||||
iter.setHours(0, 0, 0, 0)
|
||||
|
||||
const check = (stMs: number, durSecs: number) => {
|
||||
const edMs = stMs + (durSecs * 1000)
|
||||
return nowMs >= stMs && nowMs <= edMs
|
||||
}
|
||||
|
||||
const dur = parseInt(proc.Dauer || '0') || 60
|
||||
const dayStrings = ['So', 'Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa']
|
||||
const currentDayStr = dayStrings[iter.getDay()]
|
||||
|
||||
// Weekday filter
|
||||
if (proc.Wochentage && proc.Wochentage.trim() !== '') {
|
||||
if (!proc.Wochentage.includes(currentDayStr) && rep !== 'WOCHENENDE' && rep !== 'WERKTAGE') {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
if (rep === 'TAEGLICH') {
|
||||
const [h, m, s] = parseTime(proc.Start)
|
||||
const d = new Date(iter)
|
||||
d.setHours(h, m, s)
|
||||
if (check(d.getTime(), dur)) isRunning = true
|
||||
} else if (rep === 'STUENDLICH') {
|
||||
const d = new Date(iter)
|
||||
d.setHours(now.getHours(), proc['Start Minute'] || 0, 0)
|
||||
if (check(d.getTime(), dur)) isRunning = true
|
||||
} else if (rep === 'WOECHENTLICH') {
|
||||
const [h, m, s] = parseTime(proc.Start)
|
||||
const d = new Date(iter)
|
||||
d.setHours(h, m, s)
|
||||
if (check(d.getTime(), dur)) isRunning = true
|
||||
} else if (rep === 'WERKTAGE') {
|
||||
const day = iter.getDay()
|
||||
if (day >= 1 && day <= 5) {
|
||||
const [h, m, s] = parseTime(proc.Start)
|
||||
const d = new Date(iter)
|
||||
d.setHours(h, m, s)
|
||||
if (check(d.getTime(), dur)) isRunning = true
|
||||
}
|
||||
} else if (rep === 'WOCHENENDE') {
|
||||
const day = iter.getDay()
|
||||
if (day === 0 || day === 6) {
|
||||
const [h, m, s] = parseTime(proc.Start)
|
||||
const d = new Date(iter)
|
||||
d.setHours(h, m, s)
|
||||
if (check(d.getTime(), dur)) isRunning = true
|
||||
}
|
||||
} else if (rep === 'INTERVALL') {
|
||||
const [fh, fm, fs] = parseTime(proc['Erste Ausführung'])
|
||||
const [wh, wm, ws] = parseTime(proc['Ausführung bis'] || '23:59:59')
|
||||
const interval = Math.max(1, parseInt(proc['Intervall zwischen'] || '0') || 60)
|
||||
|
||||
const dayStart = new Date(iter)
|
||||
dayStart.setHours(fh, fm, fs)
|
||||
const dayEnd = new Date(iter)
|
||||
dayEnd.setHours(wh, wm, ws)
|
||||
|
||||
// Find if any interval in this day contains 'now'
|
||||
if (nowMs >= dayStart.getTime() && nowMs <= dayEnd.getTime()) {
|
||||
const diffSeconds = (nowMs - dayStart.getTime()) / 1000
|
||||
const remainder = diffSeconds % interval
|
||||
if (remainder <= dur) isRunning = true
|
||||
}
|
||||
} else if (rep === 'MONATLICH_FESTER_TAG') {
|
||||
if (iter.getDate() === 1) {
|
||||
const [h, m, s] = parseTime(proc.Start)
|
||||
const d = new Date(iter)
|
||||
d.setHours(h, m, s)
|
||||
if (check(d.getTime(), dur)) isRunning = true
|
||||
}
|
||||
}
|
||||
|
||||
if (isRunning) count++
|
||||
}
|
||||
|
||||
onRunningChange(count)
|
||||
}, [now, processes, onRunningChange])
|
||||
|
||||
const { start: viewStart, end: viewEnd, spanHours } = getViewBounds(view, now)
|
||||
const spanMs = viewEnd.getTime() - viewStart.getTime()
|
||||
|
||||
// Generate Hour Markers
|
||||
const markers = []
|
||||
let mkIter = new Date(viewStart)
|
||||
|
||||
// Dynamic Snapping
|
||||
if (view === '2H') {
|
||||
mkIter.setMinutes(Math.floor(mkIter.getMinutes() / 15) * 15, 0, 0)
|
||||
} else if (view === '6H') {
|
||||
mkIter.setMinutes(Math.floor(mkIter.getMinutes() / 30) * 30, 0, 0)
|
||||
} else if (view === 'Monat') {
|
||||
mkIter.setHours(0, 0, 0, 0)
|
||||
} else if (view === 'Woche') {
|
||||
mkIter.setHours(Math.floor(mkIter.getHours() / 6) * 6, 0, 0, 0)
|
||||
} else {
|
||||
mkIter.setMinutes(0, 0, 0)
|
||||
}
|
||||
|
||||
while (mkIter <= viewEnd) {
|
||||
const pct = ((mkIter.getTime() - viewStart.getTime()) / spanMs) * 100
|
||||
let label = ''
|
||||
|
||||
if (view === 'Monat') {
|
||||
label = mkIter.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit' })
|
||||
} else if (view === 'Woche') {
|
||||
if (mkIter.getHours() === 0) {
|
||||
label = mkIter.toLocaleDateString('de-DE', { weekday: 'short', day: '2-digit', month: '2-digit' })
|
||||
} else {
|
||||
label = mkIter.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' })
|
||||
}
|
||||
} else {
|
||||
label = mkIter.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' })
|
||||
}
|
||||
|
||||
if (pct >= 0 && pct <= 100) {
|
||||
markers.push({ key: mkIter.getTime(), pct, label })
|
||||
}
|
||||
|
||||
// Dynamic Increment
|
||||
if (view === '2H') mkIter.setMinutes(mkIter.getMinutes() + 15)
|
||||
else if (view === '6H') mkIter.setMinutes(mkIter.getMinutes() + 30)
|
||||
else if (view === 'Woche') mkIter.setHours(mkIter.getHours() + 6)
|
||||
else if (view === 'Monat') mkIter.setDate(mkIter.getDate() + 1)
|
||||
else mkIter.setHours(mkIter.getHours() + 1)
|
||||
}
|
||||
|
||||
// Now line
|
||||
const nowPct = ((now.getTime() - viewStart.getTime()) / spanMs) * 100
|
||||
const isNowVisible = nowPct >= 0 && nowPct <= 100
|
||||
|
||||
// Group processes by server
|
||||
const serverGroups = new Map<string, Process[]>()
|
||||
for (const p of processes) {
|
||||
// Only render active processes
|
||||
if (p.Status?.value && p.Status.value !== 'aktiv') continue
|
||||
|
||||
// APPLY VISIBILITY MAX VIEW FILTER (CRITICAL MEMORY PRESERVATION)
|
||||
const maxView = p.Sichtbarkeit?.value || 'Immer'
|
||||
if (maxView === 'Max 6 Stunden') {
|
||||
if (view === 'Tag' || view === 'Woche' || view === 'Monat' || view === 'Arbeitszeit' || view === 'Nachts') continue
|
||||
} else if (maxView === 'Max 1 Tag') {
|
||||
if (view === 'Woche' || view === 'Monat') continue
|
||||
}
|
||||
|
||||
const sName = p.Server?.[0]?.value || 'Ohne Server'
|
||||
if (!serverGroups.has(sName)) serverGroups.set(sName, [])
|
||||
serverGroups.get(sName)!.push(p)
|
||||
}
|
||||
|
||||
const renderBlocksForProcess = (proc: Process) => {
|
||||
const rawBlocks: { stMs: number; edMs: number }[] = []
|
||||
const rep = proc.Wiederholung?.value
|
||||
|
||||
let iter = new Date(viewStart)
|
||||
iter.setHours(0, 0, 0, 0)
|
||||
// Add one day padding
|
||||
iter.setDate(iter.getDate() - 1)
|
||||
const iterEnd = new Date(viewEnd)
|
||||
iterEnd.setDate(iterEnd.getDate() + 1)
|
||||
|
||||
const pushRawBlock = (startTimeStr: Date, durationSecs: number) => {
|
||||
const stMs = startTimeStr.getTime()
|
||||
const edMs = stMs + (durationSecs * 1000)
|
||||
if (edMs < viewStart.getTime() || stMs > viewEnd.getTime()) return
|
||||
rawBlocks.push({ stMs, edMs })
|
||||
}
|
||||
|
||||
while (iter < iterEnd) {
|
||||
// Apply weekday filter globally
|
||||
const dayStrings = ['So', 'Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa']
|
||||
const currentDayStr = dayStrings[iter.getDay()]
|
||||
if (proc.Wochentage && proc.Wochentage.trim() !== '') {
|
||||
if (!proc.Wochentage.includes(currentDayStr) && rep !== 'WOCHENENDE' && rep !== 'WERKTAGE') {
|
||||
iter.setDate(iter.getDate() + 1)
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
if (rep === 'TAEGLICH') {
|
||||
const [h, m, s] = parseTime(proc.Start)
|
||||
const d = new Date(iter)
|
||||
d.setHours(h, m, s)
|
||||
pushRawBlock(d, parseInt(proc.Dauer || '0') || 60)
|
||||
} else if (rep === 'STUENDLICH') {
|
||||
for (let hh = 0; hh < 24; hh++) {
|
||||
const d = new Date(iter)
|
||||
d.setHours(hh, proc['Start Minute'] || 0, 0)
|
||||
pushRawBlock(d, Math.max(1, parseInt(proc.Dauer || '0') || 60))
|
||||
}
|
||||
} else if (rep === 'WOECHENTLICH') {
|
||||
const [h, m, s] = parseTime(proc.Start)
|
||||
const d = new Date(iter)
|
||||
d.setHours(h, m, s)
|
||||
pushRawBlock(d, parseInt(proc.Dauer || '0') || 60)
|
||||
} else if (rep === 'WERKTAGE') {
|
||||
const day = iter.getDay()
|
||||
if (day >= 1 && day <= 5) {
|
||||
const [h, m, s] = parseTime(proc.Start)
|
||||
const d = new Date(iter)
|
||||
d.setHours(h, m, s)
|
||||
pushRawBlock(d, parseInt(proc.Dauer || '0') || 60)
|
||||
}
|
||||
} else if (rep === 'WOCHENENDE') {
|
||||
const day = iter.getDay()
|
||||
if (day === 0 || day === 6) {
|
||||
const [h, m, s] = parseTime(proc.Start)
|
||||
const d = new Date(iter)
|
||||
d.setHours(h, m, s)
|
||||
pushRawBlock(d, parseInt(proc.Dauer || '0') || 60)
|
||||
}
|
||||
} else if (rep === 'INTERVALL') {
|
||||
const [fh, fm, fs] = parseTime(proc['Erste Ausführung'])
|
||||
const [wh, wm, ws] = parseTime(proc['Ausführung bis'] || '23:59:59')
|
||||
const interval = Math.max(1, parseInt(proc['Intervall zwischen'] || '0') || 60)
|
||||
const dur = parseInt(proc.Dauer || '0') || 60
|
||||
|
||||
const dayStart = new Date(iter)
|
||||
dayStart.setHours(fh, fm, fs)
|
||||
const dayEnd = new Date(iter)
|
||||
dayEnd.setHours(wh, wm, ws)
|
||||
|
||||
for (let t = dayStart.getTime(); t <= dayEnd.getTime(); t += interval * 1000) {
|
||||
pushRawBlock(new Date(t), dur)
|
||||
}
|
||||
} else if (rep === 'MONATLICH_FESTER_TAG') {
|
||||
if (iter.getDate() === 1) {
|
||||
const [h, m, s] = parseTime(proc.Start)
|
||||
const d = new Date(iter)
|
||||
d.setHours(h, m, s)
|
||||
pushRawBlock(d, parseInt(proc.Dauer || '0') || 60)
|
||||
}
|
||||
} else {
|
||||
const [h, m, s] = parseTime(proc.Start || '12:00:00')
|
||||
const d = new Date(iter)
|
||||
d.setHours(h, m, s)
|
||||
pushRawBlock(d, parseInt(proc.Dauer || '0') || 30)
|
||||
}
|
||||
iter.setDate(iter.getDate() + 1)
|
||||
}
|
||||
|
||||
// Sort blocks by start time
|
||||
rawBlocks.sort((a, b) => a.stMs - b.stMs)
|
||||
|
||||
// Merge blocks that are overlapping or too close to render individually (e.g. gap < 0.2% of screen)
|
||||
const mergedBlocks: { stMs: number; edMs: number }[] = []
|
||||
// 0.2% of total span ms is threshold for merging
|
||||
const mergeThreshold = spanMs * 0.002
|
||||
|
||||
for (const b of rawBlocks) {
|
||||
if (mergedBlocks.length === 0) {
|
||||
mergedBlocks.push({ ...b })
|
||||
} else {
|
||||
const prev = mergedBlocks[mergedBlocks.length - 1]
|
||||
// If start is before or overlaps with prev.end + visual threshold
|
||||
if (b.stMs <= prev.edMs + mergeThreshold) {
|
||||
prev.edMs = Math.max(prev.edMs, b.edMs)
|
||||
} else {
|
||||
mergedBlocks.push({ ...b })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Now map them to standard JSX layout
|
||||
return mergedBlocks.map(b => {
|
||||
const left = Math.max(0, ((b.stMs - viewStart.getTime()) / spanMs) * 100)
|
||||
const rightBounds = Math.min(viewEnd.getTime(), b.edMs)
|
||||
let width = ((rightBounds - Math.max(viewStart.getTime(), b.stMs)) / spanMs) * 100
|
||||
|
||||
if (width < 0.2) width = 0.2
|
||||
|
||||
return (
|
||||
<div
|
||||
key={`${b.stMs}-${b.edMs}`}
|
||||
className="gantt-block"
|
||||
style={{ left: `${left}%`, width: `${width}%`, backgroundColor: proc.Farbe || 'var(--blue)' }}
|
||||
title={`${proc.Name}\nZusammengefasste Laufzeit: ${new Date(b.stMs).toLocaleTimeString('de-DE')} bis ${new Date(b.edMs).toLocaleTimeString('de-DE')}`}
|
||||
>
|
||||
{width > 5 && <span className="gantt-block-label">{proc.Name}</span>}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="gantt-container" style={{ marginTop: 24, padding: 16 }}>
|
||||
|
||||
{/* Zoom controls */}
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 20 }}>
|
||||
<h2 style={{ fontSize: '1.2rem', margin: 0 }}>Timeline Setup</h2>
|
||||
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
|
||||
{(['2H', '6H', 'Arbeitszeit', 'Nachts', 'Tag', 'Woche', 'Monat'] as ViewMode[]).map(v => (
|
||||
<button
|
||||
key={v}
|
||||
className={`btn ${view === v ? 'btn-primary' : 'btn-ghost'} btn-sm`}
|
||||
onClick={() => setView(v)}
|
||||
>
|
||||
{v}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="gantt-scroll-window">
|
||||
{/* Header markers */}
|
||||
<div className="gantt-header">
|
||||
{markers.map(m => (
|
||||
<div key={m.key} className="gantt-tick" style={{ left: `${m.pct}%` }}>
|
||||
<span className="gantt-tick-label">{m.label}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Chart body */}
|
||||
<div className="gantt-body">
|
||||
{isNowVisible && (
|
||||
<div style={{ position: 'absolute', top: 0, bottom: 0, left: 170, right: 0, pointerEvents: 'none', zIndex: 100 }}>
|
||||
<div className="gantt-now-line" style={{ left: `${nowPct}%` }} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{Array.from(serverGroups.entries())
|
||||
.sort(([sNameA], [sNameB]) => {
|
||||
if (!servers) return 0
|
||||
const sA = servers.find(s => s.Name === sNameA)
|
||||
const sB = servers.find(s => s.Name === sNameB)
|
||||
return (sA?.Sortierung ?? 9999) - (sB?.Sortierung ?? 9999)
|
||||
})
|
||||
.map(([serverName, procs]) => {
|
||||
const sortedProcs = [...procs].sort((a, b) => (a.Sortierung ?? 9999) - (b.Sortierung ?? 9999))
|
||||
|
||||
return (
|
||||
<div key={serverName} className="gantt-row">
|
||||
<div className="gantt-row-info" style={{ flexDirection: 'column', alignItems: 'flex-start', justifyContent: 'center', gap: 4 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, fontWeight: 'bold' }}>
|
||||
{SERVER_ICON}
|
||||
<span>{serverName}</span>
|
||||
</div>
|
||||
{sortedProcs.length > 0 && (
|
||||
<div style={{ fontSize: '9px', color: 'var(--fg-muted)', display: 'flex', gap: 4, flexWrap: 'wrap' }}>
|
||||
{sortedProcs.map(p => (
|
||||
<span key={p.id} style={{ display: 'flex', alignItems: 'center', gap: 2 }}>
|
||||
<span className="color-dot" style={{ backgroundColor: p.Farbe || '#3b82f6', width: 6, height: 6 }} />
|
||||
{p.Name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="gantt-bars-area">
|
||||
{sortedProcs.map(p => (
|
||||
<React.Fragment key={p.id}>
|
||||
{renderBlocksForProcess(p)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)})}
|
||||
|
||||
{serverGroups.size === 0 && (
|
||||
<div style={{ padding: 32, textAlign: 'center', color: 'var(--fg-muted)' }}>
|
||||
Keine aktiven Prozesse in dieser Ansicht sichtbar.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user