Initial commit

This commit is contained in:
2026-04-28 10:29:02 +02:00
parent e8636b08be
commit f22a25ce46
26 changed files with 3951 additions and 1 deletions

View 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>
)
}