Files
WKVS/www/intern/wk-leitung/kalender.php
2026-04-12 21:25:44 +02:00

530 lines
19 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
$days = 2;
$firstDay = "2026-04-18";
$dates = [];
$startDate = new DateTime($firstDay);
for ($i = 0; $i < $days; $i++) {
$current = clone $startDate;
$current->modify("+{$i} day");
$dates[] = $current->format("Y-m-d");
}
setlocale(LC_TIME, 'de_DE.UTF-8'); // set German locale
?>
<div class="calendar">
<!-- empty top-left corner -->
<div class="corner fBorderRight fBorderBottom"></div>
<!-- headers -->
<?php foreach ($dates as $ind => $d): ?>
<div class="day-header fBorderBottom"><h3><?= strftime("%a", strtotime($d)) ?> Halle</h3></div>
<div class="day-header fBorderBottom"><h3><?= strftime("%a", strtotime($d)) ?> Aula</h3></div>
<?php $notLast = ($ind !== count($dates) - 1) ? 'fBorderRight' : '';?>
<div class="day-header <?= $notLast ?> fBorderBottom"><h3><?= strftime("%a", strtotime($d)) ?> Sonstige</h3></div>
<?php endforeach; ?>
<!-- time column -->
<div class="time-column fBorderRight">
<?php for ($i = 0; $i <= 23; $i++) {
$si = str_pad($i, 2, '0', STR_PAD_LEFT);
echo '<div class="time">'.$si.':00</div>';
} ?>
</div>
<!-- day columns -->
<?php foreach ($dates as $ind => $d): ?>
<div class="day-column" data-day="<?= $d ?>" data-type="halle"></div>
<div class="day-column" data-day="<?= $d ?>" data-type="aula"></div>
<?php $notLast = ($ind !== count($dates) - 1) ? 'fBorderRight' : '';?>
<div class="day-column <?= $notLast ?>" data-day="<?= $d ?>" data-type="sonstige"></div>
<?php endforeach; ?>
</div>
<button id="exportCal">Export Calendar</button>
<style>
.calendar {
display: grid;
grid-template-columns: 80px <?php foreach ($dates as $d) { echo '1fr 1fr 1fr ';}?>;
grid-template-rows: 40px auto;
border: 1px solid #ccc;
font-family: Arial, sans-serif;
position: relative;
}
.corner {
border-right: 1px solid #ccc;
border-bottom: 1px solid #ccc;
}
.day-header {
position: relative;
text-align: center;
font-weight: bold;
border-bottom: 1px solid #ccc;
line-height: 40px;
}
.day-header h3 {
margin: 0;
}
.time-column {
border-right: 1px solid #ccc;
}
.time {
height: 90px;
padding: 5px;
font-size: 12px;
box-sizing: border-box;
border-bottom: 1px solid #eee;
}
.day-column {
position: relative;
border-right: 1px solid #ccc;
}
.event {
position: absolute;
border-radius: 4px;
padding: 4px;
font-size: 12px;
cursor: grab;
transition: left 0.2s, width 0.2s;
}
.event.dragging {
opacity: 0.8;
z-index: 1000;
cursor: grabbing;
}
.event div span {
position: absolute;
top: 6px;
left: 10px;
}
.fBorderRight { border-right: 3px solid #000; }
.fBorderBottom { border-bottom: 3px solid #000; }
.day-column.highlight {
background-color: rgba(255, 255, 0, 0.1);
}
.day-column.highlight-active {
background-color: rgba(255, 255, 0, 0.25);
}
</style>
<script>
const events = [
{ day: "2026-04-18", start: 12.0, end: 13.0, title: "Kampfrichteressen", type: "sonstige", abt: "0", elem: "" },
// Day 1 - 2026-04-18
{ day: "2026-04-18", start: 8.0, end: 8.50, title: "Körperliches Aufwärmen", type: "aula", abt: "1", elem: "15" },
{ day: "2026-04-18", start: 8.50, end: 9.0, title: "Geführtes Einturnen", type: "halle", abt: "1", elem: "15" },
{ day: "2026-04-18", start: 9.0, end: 10.0, title: "Wettkampf", type: "halle", abt: "1", elem: "15" },
{ day: "2026-04-18", start: 10.0, end: 10.50, title: "Rangverkündigung", type: "aula", abt: "1", elem: "15" },
{ day: "2026-04-18", start: 10.50, end: 11.0, title: "Körperliches Aufwärmen", type: "aula", abt: "2", elem: "13" },
{ day: "2026-04-18", start: 11.0, end: 11.50, title: "Geführtes Einturnen", type: "halle", abt: "2", elem: "13" },
{ day: "2026-04-18", start: 11.50, end: 12.50, title: "Wettkampf", type: "halle", abt: "2", elem: "13" },
{ day: "2026-04-18", start: 12.50, end: 13.0, title: "Rangverkündigung", type: "aula", abt: "2", elem: "13" },
{ day: "2026-04-18", start: 13.0, end: 13.50, title: "Körperliches Aufwärmen", type: "aula", abt: "3", elem: "10" },
{ day: "2026-04-18", start: 13.50, end: 14.0, title: "Geführtes Einturnen", type: "halle", abt: "3", elem: "10" },
{ day: "2026-04-18", start: 14.0, end: 15.0, title: "Wettkampf", type: "halle", abt: "3", elem: "10" },
{ day: "2026-04-18", start: 15.0, end: 15.50, title: "Rangverkündigung", type: "aula", abt: "3", elem: "10" },
{ day: "2026-04-18", start: 15.50, end: 16.0, title: "Körperliches Aufwärmen", type: "aula", abt: "4", elem: "12" },
{ day: "2026-04-18", start: 16.0, end: 16.50, title: "Geführtes Einturnen", type: "halle", abt: "4", elem: "12" },
{ day: "2026-04-18", start: 16.50, end: 17.50, title: "Wettkampf", type: "halle", abt: "4", elem: "12" },
{ day: "2026-04-18", start: 17.30, end: 18.0, title: "Rangverkündigung", type: "aula", abt: "4", elem: "12" },
{ day: "2026-04-18", start: 18.0, end: 18.50, title: "Körperliches Aufwärmen", type: "aula", abt: "5", elem: "11" },
{ day: "2026-04-18", start: 18.50, end: 19.0, title: "Geführtes Einturnen", type: "halle", abt: "5", elem: "11" },
{ day: "2026-04-18", start: 19.0, end: 20.0, title: "Wettkampf", type: "halle", abt: "5", elem: "11" },
{ day: "2026-04-18", start: 20.0, end: 20.50, title: "Rangverkündigung", type: "aula", abt: "5", elem: "11" },
{ day: "2026-04-18", start: 20.50, end: 21.0, title: "Körperliches Aufwärmen", type: "aula", abt: "6", elem: "14" },
{ day: "2026-04-18", start: 21.0, end: 21.50, title: "Geführtes Einturnen", type: "halle", abt: "6", elem: "14" },
{ day: "2026-04-18", start: 21.50, end: 22.50, title: "Wettkampf", type: "halle", abt: "6", elem: "14" },
{ day: "2026-04-18", start: 22.50, end: 23.0, title: "Rangverkündigung", type: "aula", abt: "6", elem: "14" },
// Day 2 - 2026-04-19 (same pattern with shifted times)
{ day: "2026-04-19", start: 8.0, end: 8.30, title: "Körperliches Aufwärmen", type: "aula", abt: "1", elem: "15" },
{ day: "2026-04-19", start: 8.30, end: 9.0, title: "Geführtes Einturnen", type: "halle", abt: "1", elem: "15" },
{ day: "2026-04-19", start: 9.0, end: 10.0, title: "Wettkampf", type: "halle", abt: "1", elem: "15" },
{ day: "2026-04-19", start: 10.0, end: 10.30, title: "Rangverkündigung", type: "aula", abt: "1", elem: "15" },
{ day: "2026-04-19", start: 10.30, end: 11.0, title: "Körperliches Aufwärmen", type: "aula", abt: "2", elem: "13" },
{ day: "2026-04-19", start: 11.0, end: 11.30, title: "Geführtes Einturnen", type: "halle", abt: "2", elem: "13" },
{ day: "2026-04-19", start: 11.30, end: 12.30, title: "Wettkampf", type: "halle", abt: "2", elem: "13" },
{ day: "2026-04-19", start: 12.30, end: 13.0, title: "Rangverkündigung", type: "aula", abt: "2", elem: "13" },
// ...repeat for abt 36 with times staggered
];
const pixelsPerHour = 90;
// Convert hour float to Date
function hourFloatToDate(day, hourFloat) {
const d = new Date(day);
const hours = Math.floor(hourFloat);
const minutes = Math.round((hourFloat % 1) * 60);
d.setHours(hours, minutes, 0, 0);
return d;
}
function hoursToHHMM(hours) {
const h = Math.floor(hours);
const m = Math.round((hours - h) * 60);
return `${h.toString().padStart(2,'0')}:${m.toString().padStart(2,'0')}`;
}
// Convert HH:MM string to hours float
function HHMMToHours(str) {
const [h, m] = str.split(':').map(Number);
return h + m/60;
}
// Format Date to hh:mm
function formatTime(date) {
return new Intl.DateTimeFormat(undefined, { hour: '2-digit', minute: '2-digit' }).format(date);
}
// Collision detection
function eventsCollide(a, b) {
return a.start < b.end && a.end > b.start && a.day === b.day && a.type === b.type;
}
// Assign columns with proper overlap tracking
function assignColumns(events) {
events.sort((a,b) => a.start - b.start);
const active = [];
events.forEach(event => {
// Remove finished events
for (let i = active.length-1; i>=0; i--) {
if (active[i].end <= event.start) active.splice(i,1);
}
// Find first free column index
let colIndex = 0;
while(active.some(e => e.columnIndex === colIndex)) colIndex++;
event.columnIndex = colIndex;
active.push(event);
// Update totalColumns for current overlap group
const maxColumns = Math.max(...active.map(e => e.columnIndex + 1));
active.forEach(e => e.totalColumns = maxColumns);
});
}
// Render events into the calendar
function renderEvents(events, colors) {
events.forEach(event => {
const dayColumn = document.querySelector(`.day-column[data-day="${event.day}"][data-type="${event.type}"]`);
const el = document.createElement("div");
el.className = "event";
const startDate = hourFloatToDate(event.day, event.start);
const endDate = hourFloatToDate(event.day, event.end);
const duration = event.end - event.start; // in hours
const lineBreak = duration >= 1 ? "<br>" : " "; // use space if < 1h
if (event.abt != 0){
el.innerHTML = `<div><span><b>${event.title} - Abt. ${event.abt}</b> (${event.elem})${lineBreak}${formatTime(startDate)} - ${formatTime(endDate)}</span></div>`;
} else {
el.innerHTML = `<div><span><b>${event.title}</b>${lineBreak}${formatTime(startDate)} - ${formatTime(endDate)}</span></div>`;
}
el.style.background = colors[event.abt].background;
el.style.color = colors[event.abt].foreground;
el.style.borderTop = `3px solid ${colors[event.abt].borderTop}`;
const widthPercent = 100 / event.totalColumns;
el.style.top = (event.start * pixelsPerHour) + "px";
el.style.height = ((event.end - event.start) * pixelsPerHour - 11) + "px";
el.style.width = `calc(${widthPercent}% - 10px)`;
el.style.left = `${event.columnIndex * widthPercent}%`;
// Store reference for updates
event.el = el;
addDragBehavior(el, event, colors);
dayColumn.appendChild(el);
});
}
// Drag & drop behavior
function addDragBehavior(el, event, colors) {
el.addEventListener("pointerdown", e => {
e.preventDefault();
el.setPointerCapture(e.pointerId);
el.classList.add("dragging");
const startY = e.clientY;
const origTop = parseFloat(el.style.top);
const type = event.type;
const validColumns = Array.from(document.querySelectorAll(`.day-column[data-type="${type}"]`));
validColumns.forEach(c => c.classList.add("highlight"));
function onPointerMove(eMove) {
const deltaY = eMove.clientY - startY;
// Vertical movement (snapped to 15 min)
let newTop = origTop + deltaY;
newTop = Math.max(0, newTop);
newTop = Math.round(newTop / (pixelsPerHour/12)) * (pixelsPerHour/12);
el.style.top = newTop + "px";
// Horizontal movement locked to same type columns
const pointerX = eMove.clientX;
const targetColumn = validColumns.find(c => {
const rect = c.getBoundingClientRect();
return pointerX >= rect.left && pointerX <= rect.right;
});
if (targetColumn && targetColumn !== el.parentElement) {
targetColumn.appendChild(el);
el.style.left = 0;
}
// Update live time
const newStart = newTop / pixelsPerHour;
const newEnd = newStart + (event.end - event.start);
const startDate = hourFloatToDate(event.day, newStart);
const endDate = hourFloatToDate(event.day, newEnd);
const duration = event.end - event.start; // in hours
const lineBreak = duration >= 1 ? "<br>" : " "; // use space if < 1h
if (event.abt != 0){
el.querySelector("span").innerHTML = `<b>${event.title} - Abt. ${event.abt}</b> (${event.elem})${lineBreak}${formatTime(startDate)} - ${formatTime(endDate)}`;
} else {
el.querySelector("span").innerHTML = `<b>${event.title}</b>${lineBreak}${formatTime(startDate)} - ${formatTime(endDate)}`;
}
}
function onPointerUp(eUp) {
el.classList.remove("dragging");
el.releasePointerCapture(eUp.pointerId);
document.removeEventListener("pointermove", onPointerMove);
document.removeEventListener("pointerup", onPointerUp);
validColumns.forEach(c => c.classList.remove("highlight"));
// Update event day based on column
const newColumn = validColumns.find(c => el.parentElement === c);
if(newColumn) event.day = newColumn.dataset.day;
// Update start/end
const newStart = parseFloat(el.style.top) / pixelsPerHour;
const duration = event.end - event.start;
event.start = newStart;
event.end = newStart + duration;
// Recalculate columns for current day/type
const dayEvents = events.filter(ev => ev.day === event.day && ev.type === event.type);
assignColumns(dayEvents);
// Update positions, widths, and displayed time
dayEvents.forEach(ev => {
const widthPercent = 100 / ev.totalColumns;
ev.el.style.width = `calc(${widthPercent}% - 10px)`;
ev.el.style.left = `${ev.columnIndex * widthPercent}%`;
const startDate = hourFloatToDate(ev.day, ev.start);
const endDate = hourFloatToDate(ev.day, ev.end);
const duration = ev.end - ev.start; // in hours
const lineBreak = duration >= 1 ? "<br>" : " "; // use space if < 1h
if (ev.abt != 0){
ev.el.querySelector("span").innerHTML = `<b>${ev.title} - Abt. ${ev.abt}</b> (${ev.elem})${lineBreak}${formatTime(startDate)} - ${formatTime(endDate)}`;
} else {
ev.el.querySelector("span").innerHTML = `<b>${ev.title}</b>${lineBreak}${formatTime(startDate)} - ${formatTime(endDate)}`;
}
});
}
document.addEventListener("pointermove", onPointerMove);
document.addEventListener("pointerup", onPointerUp);
});
el.addEventListener("dblclick", () => {
const durationHours = prompt("Enter new duration in hours:", hoursToHHMM((event.end - event.start).toFixed(2)));
if(durationHours !== null) {
const duration = HHMMToHours(durationHours);
if(!isNaN(duration) && duration > 0) {
event.end = event.start + duration;
// Recalculate columns for the day/type
const dayEvents = events.filter(ev => ev.day === event.day && ev.type === event.type);
assignColumns(dayEvents);
// Update all event positions, widths, and displayed text
dayEvents.forEach(ev => {
const widthPercent = 100 / ev.totalColumns;
ev.el.style.width = `calc(${widthPercent}% - 10px)`;
ev.el.style.left = `${ev.columnIndex * widthPercent}%`;
ev.el.style.top = (ev.start * pixelsPerHour) + "px";
ev.el.style.height = ((ev.end - ev.start) * pixelsPerHour - 11) + "px";
// Update displayed time with conditional line break
const startDate = hourFloatToDate(ev.day, ev.start);
const endDate = hourFloatToDate(ev.day, ev.end);
const dur = ev.end - ev.start;
const lineBreak = dur >= 1 ? "<br>" : " ";
if (ev.abt != 0){
ev.el.querySelector("span").innerHTML = `<b>${ev.title} - Abt. ${ev.abt}</b> (${ev.elem})${lineBreak}${formatTime(startDate)} - ${formatTime(endDate)}`;
} else {
ev.el.querySelector("span").innerHTML = `<b>${ev.title}</b>${lineBreak}${formatTime(startDate)} - ${formatTime(endDate)}`;
}
});
}
}
});
}
// ----------------- USAGE -----------------
// Example colors
const colors = {
"0": {
"background": "rgba(172, 172, 172, 0.18)",
"foreground": "#4b4b4bff",
"borderTop": "#5a5a5aff"
},
"1": {
"background": "rgba(59, 130, 246, 0.18)",
"foreground": "#1e3a8a",
"borderTop": "#1e3a8a"
},
"2": {
"background": "rgba(20, 184, 166, 0.18)",
"foreground": "#065f46",
"borderTop": "#065f46"
},
"3": {
"background": "rgba(34, 197, 94, 0.18)",
"foreground": "#14532d",
"borderTop": "#14532d"
},
"4": {
"background": "rgba(163, 230, 53, 0.20)",
"foreground": "#365314",
"borderTop": "#365314"
},
"5": {
"background": "rgba(245, 158, 11, 0.20)",
"foreground": "#92400e",
"borderTop": "#92400e"
},
"6": {
"background": "rgba(249, 115, 22, 0.20)",
"foreground": "#9a3412",
"borderTop": "#9a3412"
},
"7": {
"background": "rgba(244, 63, 94, 0.18)",
"foreground": "#9f1239",
"borderTop": "#9f1239"
},
"8": {
"background": "rgba(236, 72, 153, 0.18)",
"foreground": "#9d174d",
"borderTop": "#9d174d"
},
"9": {
"background": "rgba(168, 85, 247, 0.18)",
"foreground": "#581c87",
"borderTop": "#581c87"
},
"10": {
"background": "rgba(99, 102, 241, 0.18)",
"foreground": "#312e81",
"borderTop": "#312e81"
},
"11": {
"background": "rgba(100, 116, 139, 0.20)",
"foreground": "#1e293b",
"borderTop": "#1e293b"
},
"12": {
"background": "rgba(6, 182, 212, 0.18)",
"foreground": "#164e63",
"borderTop": "#164e63"
}
};
// Initialize
const grouped = {};
events.forEach(e => {
const key = `${e.day}-${e.type}`;
if(!grouped[key]) grouped[key] = [];
grouped[key].push(e);
});
Object.values(grouped).forEach(arr => assignColumns(arr));
renderEvents(events, colors);
function exportToICS(events) {
let icsContent = `BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//YourApp//Calendar//EN
CALSCALE:GREGORIAN
METHOD:PUBLISH
`;
events.forEach(event => {
const startDate = hourFloatToDate(event.day, event.start);
const endDate = hourFloatToDate(event.day, event.end);
// Format as YYYYMMDDTHHMMSS
function formatICSDate(d) {
return d.toISOString().replace(/[-:]/g,'').split('.')[0] + 'Z';
}
icsContent += `BEGIN:VEVENT
UID:${event.day}-${event.start}-${event.title}
DTSTAMP:${formatICSDate(new Date())}
DTSTART:${formatICSDate(startDate)}
DTEND:${formatICSDate(endDate)}
SUMMARY:${event.title} - Abt. ${event.abt}
DESCRIPTION:Type: ${event.type}, Elem: ${event.elem}
END:VEVENT
`;
});
icsContent += `END:VCALENDAR`;
// Create downloadable link
const blob = new Blob([icsContent], { type: 'text/calendar;charset=utf-8' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'calendar.ics';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
// Attach to button
document.getElementById('exportCal').addEventListener('click', () => exportToICS(events));
</script>