458 lines
17 KiB
TypeScript
458 lines
17 KiB
TypeScript
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>
|
|
)
|
|
}
|