- Studentenplan (eigene/fremde Matrikelnummer) mit Wochenansicht, Semesterübersicht, iCal/CSV-Export - Studiengangs-Pläne nach StG/Semester/Verband mit Gruppen-Filter - Namenslisten mit Suche, Matrikel-Decode und CSV-Export - Studiennummer-Decoder auf der Startseite - Docker (Dockerfile, Compose) und Caddy-Beispiel für Deploy
410 lines
15 KiB
JavaScript
410 lines
15 KiB
JavaScript
/* CIS Enhanced - client logic */
|
|
"use strict";
|
|
|
|
/* ================= helpers ================= */
|
|
const $ = (sel) => document.querySelector(sel);
|
|
const esc = (s) => String(s ?? "").replace(/[&<>"']/g, (c) => ({
|
|
"&": "&", "<": "<", ">": ">", '"': """, "'": "'",
|
|
}[c]));
|
|
|
|
function lvColor(lv) {
|
|
let h = 0;
|
|
for (const c of lv) h = (h * 31 + c.charCodeAt(0)) % 997;
|
|
const hue = (h * 1.375) % 360;
|
|
return `hsl(${hue}, 52%, 42%)`;
|
|
}
|
|
|
|
function fmtTime(d) {
|
|
return d.toTimeString().slice(0, 5);
|
|
}
|
|
function fmtDate(d) {
|
|
const months = ["Jan", "Feb", "Mär", "Apr", "Mai", "Jun", "Jul", "Aug", "Sep", "Okt", "Nov", "Dez"];
|
|
return `${String(d.getDate()).padStart(2, "0")}.${months[d.getMonth()]}`;
|
|
}
|
|
function parseISO(s) {
|
|
const [date, time] = s.split("T");
|
|
const [y, m, d] = date.split("-").map(Number);
|
|
const [hh, mm, ss] = time.split(":").map(Number);
|
|
return new Date(y, m - 1, d, hh, mm, ss || 0);
|
|
}
|
|
function addDays(d, n) {
|
|
const r = new Date(d);
|
|
r.setDate(r.getDate() + n);
|
|
return r;
|
|
}
|
|
function sameDay(a, b) {
|
|
return a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate();
|
|
}
|
|
|
|
/* ================= plan view ================= */
|
|
const PX_PER_HOUR = 48;
|
|
const DAY_START_H = 8;
|
|
let plan = null;
|
|
let weekStart = null;
|
|
let gruppeFilter = "";
|
|
|
|
function visibleEvents() {
|
|
if (!gruppeFilter) return plan.events;
|
|
return plan.events.filter((e) => e.gruppe === gruppeFilter);
|
|
}
|
|
|
|
function setGruppeFilter(v) {
|
|
gruppeFilter = v;
|
|
renderWeek();
|
|
renderSemester();
|
|
}
|
|
|
|
function initPlan() {
|
|
plan = window.__PLAN_DATA__;
|
|
plan.events = plan.events.map((e) => ({ ...e, s: parseISO(e.start), e2: parseISO(e.end) }));
|
|
weekStart = parseISO(plan.initWeek + "T00:00:00");
|
|
if (plan.kind === "stg") {
|
|
const sel = $("#gruppe-filter");
|
|
const groups = [...new Set(plan.events.map((e) => e.gruppe).filter(Boolean))].sort();
|
|
if (groups.length > 1) {
|
|
for (const g of groups) {
|
|
sel.insertAdjacentHTML("beforeend", `<option value="${esc(g)}">${esc(g)}</option>`);
|
|
}
|
|
sel.style.display = "";
|
|
}
|
|
}
|
|
renderWeek();
|
|
renderSemester();
|
|
if (plan.tab === "sem") switchTab("sem");
|
|
}
|
|
|
|
function stundeOf(ev) {
|
|
const mins = ev.s.getHours() * 60 + ev.s.getMinutes();
|
|
let best = 1;
|
|
plan.slots.forEach((slot, i) => {
|
|
const [hh, mm] = slot.split(":").map(Number);
|
|
if (hh * 60 + mm <= mins) best = i + 1;
|
|
});
|
|
return best;
|
|
}
|
|
|
|
function weekEnd() {
|
|
let endH = 18;
|
|
for (const e of visibleEvents()) {
|
|
const h = e.e2.getHours() + (e.e2.getMinutes() / 60);
|
|
if (h > endH) endH = Math.ceil(h + 0.25);
|
|
}
|
|
return endH;
|
|
}
|
|
|
|
function layoutDay(evs) {
|
|
evs.sort((a, b) => (a.s - b.s) || (b.e2 - a.e2));
|
|
const clusters = [];
|
|
let cur = null, curEnd = null;
|
|
for (const e of evs) {
|
|
if (!cur || e.s >= curEnd) {
|
|
cur = [];
|
|
clusters.push(cur);
|
|
curEnd = e.e2;
|
|
} else {
|
|
curEnd = Math.max(curEnd, e.e2);
|
|
}
|
|
cur.push(e);
|
|
}
|
|
for (const cluster of clusters) {
|
|
const cols = [];
|
|
for (const e of cluster) {
|
|
let placed = false;
|
|
for (let i = 0; i < cols.length; i++) {
|
|
if (cols[i][cols[i].length - 1].e2 <= e.s) {
|
|
cols[i].push(e);
|
|
e._col = i;
|
|
placed = true;
|
|
break;
|
|
}
|
|
}
|
|
if (!placed) {
|
|
e._col = cols.length;
|
|
cols.push([e]);
|
|
}
|
|
}
|
|
for (const e of cluster) e._cols = cols.length;
|
|
}
|
|
}
|
|
|
|
function renderWeek() {
|
|
const grid = $("#week-grid");
|
|
if (!grid) return;
|
|
const endH = weekEnd();
|
|
const height = (endH - DAY_START_H) * PX_PER_HOUR;
|
|
const today = new Date();
|
|
const monday = weekStart;
|
|
const sunday = addDays(monday, 6);
|
|
|
|
const MONTHS = ["Jan", "Feb", "Mär", "Apr", "Mai", "Jun", "Jul", "Aug", "Sep", "Okt", "Nov", "Dez"];
|
|
const names = ["Mo", "Di", "Mi", "Do", "Fr", "Sa", "So"];
|
|
let html = `<div class="wg-corner"></div>`;
|
|
for (let i = 0; i < 7; i++) {
|
|
const d = addDays(monday, i);
|
|
const isToday = sameDay(d, today) ? "today" : "";
|
|
const we = i >= 5 ? "weekend" : "";
|
|
const monthLabel = (i === 0 || d.getDate() <= 7) ? ` ${MONTHS[d.getMonth()]}` : "";
|
|
html += `<div class="wg-dayhead ${isToday} ${we}">${names[i]}<span class="d">${d.getDate()}</span>${monthLabel}</div>`;
|
|
}
|
|
html += `<div class="wg-time" style="height:${height}px">`;
|
|
for (let h = DAY_START_H; h <= endH; h++) {
|
|
const top = (h - DAY_START_H) * PX_PER_HOUR;
|
|
html += `<span style="top:${top}px">${String(h).padStart(2, "0")}:00</span>`;
|
|
}
|
|
html += `</div>`;
|
|
for (let i = 0; i < 7; i++) {
|
|
const d = addDays(monday, i);
|
|
const we = i >= 5 ? "weekend" : "";
|
|
html += `<div class="wg-col ${we}" style="height:${height}px" data-day="${i}">`;
|
|
for (let m = 0; m < (endH - DAY_START_H) * 60; m += 30) {
|
|
const top = (m / 60) * PX_PER_HOUR;
|
|
if (m % 60 === 0 && m > 0) continue;
|
|
html += `<div class="wg-slotline" style="top:${top}px"></div>`;
|
|
}
|
|
const dayEvents = visibleEvents().filter((ev) => ev.s >= d && ev.s < addDays(d, 1) && ev.s.getHours() >= DAY_START_H);
|
|
layoutDay(dayEvents);
|
|
for (const ev of dayEvents) {
|
|
const top = ((ev.s.getHours() + ev.s.getMinutes() / 60) - DAY_START_H) * PX_PER_HOUR;
|
|
const dur = (ev.e2 - ev.s) / 3600000;
|
|
const hgt = Math.max(dur * PX_PER_HOUR, 14);
|
|
const w = 100 / (ev._cols || 1);
|
|
const l = (ev._col || 0) * w;
|
|
const pay = { lv: ev.lv, lektor: ev.lektor, gruppe: ev.gruppe, ort: ev.ort,
|
|
start: ev.start, end: ev.end, datum: ev.start.slice(0, 10) };
|
|
html += `<button class="ev" style="top:${top + 1}px;height:${hgt - 2}px;background:${lvColor(ev.lv)};left:calc(${l}% + 1px);width:calc(${w}% - 3px);right:auto"
|
|
onclick='openEvent(${JSON.stringify(pay).replace(/'/g, "'")})'>
|
|
<span class="t">${esc(ev.lv)}</span>
|
|
${hgt > 34 ? `<span class="sub">${esc(ev.ort)}${ev.gruppe ? " · " + esc(ev.gruppe) : ""}</span>` : ""}
|
|
</button>`;
|
|
}
|
|
html += `</div>`;
|
|
}
|
|
grid.style.gridTemplateRows = `auto ${height}px`;
|
|
grid.innerHTML = html;
|
|
|
|
$("#week-label").textContent = `KW ${isoWeek(monday)}`;
|
|
$("#week-range").textContent = `${fmtDate(monday)} - ${fmtDate(sunday)} ${sunday.getFullYear()}`;
|
|
renderLegend();
|
|
}
|
|
|
|
function isoWeek(d) {
|
|
const date = new Date(Date.UTC(d.getFullYear(), d.getMonth(), d.getDate()));
|
|
const dayNum = date.getUTCDay() || 7;
|
|
date.setUTCDate(date.getUTCDate() + 4 - dayNum);
|
|
const yearStart = new Date(Date.UTC(date.getUTCFullYear(), 0, 1));
|
|
return Math.ceil((((date - yearStart) / 86400000) + 1) / 7);
|
|
}
|
|
|
|
function renderLegend() {
|
|
const lvs = [...new Set(visibleEvents().map((e) => e.lv))];
|
|
$("#legend").innerHTML = lvs.map((lv) =>
|
|
`<span class="item"><span class="dot" style="background:${lvColor(lv)}"></span>${esc(lv)}</span>`).join("");
|
|
}
|
|
|
|
function renderSemester() {
|
|
const byLv = {};
|
|
for (const e of visibleEvents()) {
|
|
if (!byLv[e.lv]) byLv[e.lv] = { lv: e.lv, lektors: new Set(), gruppens: new Set(), orte: new Set(), n: 0, hours: 0, first: e.s, last: e.s };
|
|
const g = byLv[e.lv];
|
|
g.lektors.add(e.lektor);
|
|
if (e.gruppe) g.gruppens.add(e.gruppe);
|
|
if (e.ort) g.orte.add(e.ort);
|
|
g.n++;
|
|
g.hours += (e.e2 - e.s) / 3600000;
|
|
if (e.s < g.first) g.first = e.s;
|
|
if (e.s > g.last) g.last = e.s;
|
|
}
|
|
const rows = Object.values(byLv).sort((a, b) => a.lv.localeCompare(b.lv));
|
|
$("#sem-lv").innerHTML = `
|
|
<h3>Lehrveranstaltungen</h3>
|
|
${rows.length ? `<table class="data">
|
|
<thead><tr><th>LV</th><th>LektorIn</th><th>Gruppe</th><th>Orte</th>
|
|
<th class="num">Termine</th><th class="num">Std.</th><th>Zeitraum</th></tr></thead>
|
|
<tbody>
|
|
${rows.map((g) => `<tr>
|
|
<td><span class="dot" style="display:inline-block;width:10px;height:10px;border-radius:3px;background:${lvColor(g.lv)};margin-right:6px"></span><b>${esc(g.lv)}</b></td>
|
|
<td>${esc([...g.lektors].join(", "))}</td>
|
|
<td>${esc([...g.gruppens].join(", ")) || "-"}</td>
|
|
<td>${esc([...g.orte].join(", ")) || "-"}</td>
|
|
<td class="num">${g.n}</td>
|
|
<td class="num">${g.hours.toFixed(1)}</td>
|
|
<td>${fmtDate(g.first)} - ${fmtDate(g.last)} ${g.last.getFullYear()}</td>
|
|
</tr>`).join("")}
|
|
</tbody></table>` : `<div class="empty">Keine Termine in diesem Semester.</div>`}`;
|
|
|
|
const names = ["So", "Mo", "Di", "Mi", "Do", "Fr", "Sa"];
|
|
$("#sem-events").innerHTML = `
|
|
<table class="data">
|
|
<thead><tr><th>Datum</th><th>KW</th><th>Zeit</th><th>LV</th><th>LektorIn</th><th>Gruppe</th><th>Ort</th></tr></thead>
|
|
<tbody>
|
|
${visibleEvents().map((e) => `<tr>
|
|
<td>${fmtDate(e.s)} ${e.s.getFullYear()}</td>
|
|
<td>${names[e.s.getDay()]} / ${isoWeek(e.s)}</td>
|
|
<td>${fmtTime(e.s)} - ${fmtTime(e.e2)}</td>
|
|
<td><b>${esc(e.lv)}</b></td>
|
|
<td>${esc(e.lektor)}</td>
|
|
<td>${esc(e.gruppe) || "-"}</td>
|
|
<td>${esc(e.ort)}</td>
|
|
</tr>`).join("")}
|
|
</tbody></table>`;
|
|
}
|
|
|
|
function shiftWeek(n) {
|
|
weekStart = addDays(weekStart, n * 7);
|
|
renderWeek();
|
|
}
|
|
function goToday() {
|
|
const t = new Date();
|
|
weekStart = addDays(t, -((t.getDay() + 6) % 7));
|
|
renderWeek();
|
|
}
|
|
function switchTab(which) {
|
|
$("#tab-week").classList.toggle("active", which === "week");
|
|
$("#tab-sem").classList.toggle("active", which === "sem");
|
|
$("#pane-week").classList.toggle("active", which === "week");
|
|
$("#pane-sem").classList.toggle("active", which === "sem");
|
|
}
|
|
|
|
/* ---------- detail modal ---------- */
|
|
async function openEvent(ev) {
|
|
const modal = $("#detail-modal");
|
|
modal.classList.add("open");
|
|
const body = $("#detail-body");
|
|
body.innerHTML = `<p style="color:var(--muted)">Lade Details vom CIS...</p>`;
|
|
const stunde = stundeOf({ s: parseISO(ev.start) });
|
|
const params = new URLSearchParams({
|
|
kind: plan.kind, datum: ev.datum, stunde, ort: ev.ort,
|
|
});
|
|
if (plan.kind === "student") {
|
|
params.set("uid", plan.uid);
|
|
} else {
|
|
params.set("kz", plan.kz);
|
|
params.set("sem", plan.sem);
|
|
params.set("ver", plan.ver);
|
|
params.set("grp", plan.grp);
|
|
}
|
|
try {
|
|
const r = await fetch("/api/detail?" + params.toString());
|
|
const j = await r.json();
|
|
if (j.error) throw new Error(j.error);
|
|
const rows = j.rows || {};
|
|
body.innerHTML = `
|
|
<table>
|
|
<tr><td>Datum / Stunde</td><td>${esc(ev.datum)} / Stunde ${esc(stunde)}</td></tr>
|
|
<tr><td>LV-Code</td><td><b>${esc(ev.lv)}</b></td></tr>
|
|
<tr><td>Zeit</td><td>${esc(ev.start.slice(11, 16))} - ${esc(ev.end.slice(11, 16))}</td></tr>
|
|
${rows["LektorIn"] ? `<tr><td>LektorIn</td><td>${esc(rows["LektorIn"])}</td></tr>` : ""}
|
|
${rows["Ort"] ? `<tr><td>Ort</td><td>${esc(rows["Ort"])}</td></tr>` : ""}
|
|
${rows["Lehrfach"] ? `<tr><td>Lehrfach</td><td>${esc(rows["Lehrfach"])}</td></tr>` : ""}
|
|
${rows["Bezeichnung"] ? `<tr><td>Bezeichnung</td><td>${esc(rows["Bezeichnung"])}</td></tr>` : ""}
|
|
${rows["Verband"] ? `<tr><td>Gruppe</td><td>${esc(rows["Verband"])}</td></tr>` : ""}
|
|
${rows["Info"] ? `<tr><td>Info</td><td>${esc(rows["Info"])}</td></tr>` : ""}
|
|
</table>`;
|
|
} catch (e) {
|
|
body.innerHTML = `<p style="color:var(--red)">Details konnten nicht geladen werden: ${esc(e.message)}</p>
|
|
<table>
|
|
<tr><td>Datum</td><td>${esc(ev.datum)}</td></tr>
|
|
<tr><td>Zeit</td><td>${esc(ev.start.slice(11, 16))} - ${esc(ev.end.slice(11, 16))}</td></tr>
|
|
<tr><td>LV</td><td><b>${esc(ev.lv)}</b></td></tr>
|
|
<tr><td>LektorIn</td><td>${esc(ev.lektor)}</td></tr>
|
|
<tr><td>Gruppe</td><td>${esc(ev.gruppe)}</td></tr>
|
|
<tr><td>Ort</td><td>${esc(ev.ort)}</td></tr>
|
|
</table>`;
|
|
}
|
|
}
|
|
function closeDetail() {
|
|
$("#detail-modal").classList.remove("open");
|
|
}
|
|
|
|
/* ---------- CSV ---------- */
|
|
function downloadCSV() {
|
|
const names = ["So", "Mo", "Di", "Mi", "Do", "Fr", "Sa"];
|
|
let csv;
|
|
if (window.__PLAN_DATA__) {
|
|
csv = "Datum;Wochentag;Beginn;Ende;LV;Lektor;Gruppe;Ort\n" + plan.events.map((e) =>
|
|
[e.start.slice(0, 10), names[e.s.getDay()], fmtTime(e.s), fmtTime(e.e2),
|
|
e.lv, e.lektor, e.gruppe, e.ort].map((x) => `"${String(x).replace(/"/g, '""')}"`).join(";")
|
|
).join("\n");
|
|
} else if (window.__LIST_DATA__) {
|
|
csv = "Nr;Nachname;Vorname;Geschlecht;Matrikelnummer;Mail\n" + window.__LIST_DATA__.map((s, i) =>
|
|
[i + 1, s.nachname, s.vorname, s.geschlecht, s.matrikel, s.mail].map((x) => `"${String(x).replace(/"/g, '""')}"`).join(";")
|
|
).join("\n");
|
|
} else return;
|
|
const blob = new Blob(["\uFEFF" + csv], { type: "text/csv;charset=utf-8" });
|
|
const a = document.createElement("a");
|
|
a.href = URL.createObjectURL(blob);
|
|
a.download = (window.__PLAN_DATA__ ? `lvplan_${window.__PLAN_DATA__.semester}` : `namensliste`) + ".csv";
|
|
a.click();
|
|
URL.revokeObjectURL(a.href);
|
|
}
|
|
|
|
/* ================= Namensliste ================= */
|
|
function filterList() {
|
|
const q = ($("#list-search").value || "").toLowerCase().trim();
|
|
let n = 0;
|
|
for (const tr of $("#list-table").querySelectorAll("tbody tr")) {
|
|
const hay = (tr.dataset.search || "").toLowerCase();
|
|
const show = !q || hay.includes(q);
|
|
tr.style.display = show ? "" : "none";
|
|
if (show) n++;
|
|
}
|
|
const c = $("#student-count");
|
|
if (c) c.textContent = q ? `${n}` : window.__LIST_COUNT__ || n;
|
|
}
|
|
|
|
/* ================= Matrikel decoder (home) ================= */
|
|
function decodeMat() {
|
|
const m = $("#decode-input").value.trim();
|
|
const box = $("#decode-result");
|
|
if (!/^\d{9,10}$/.test(m)) {
|
|
box.classList.remove("show");
|
|
return;
|
|
}
|
|
const stgKz = m.slice(-6, -3);
|
|
const nr = m.slice(-3);
|
|
const year = m.slice(0, 2);
|
|
const code = m.slice(2, 4);
|
|
let start = `20${year}`;
|
|
if (code === "10") start = `WS 20${year}`;
|
|
else if (code === "20") start = `SS 20${year}`;
|
|
const stg = (window.__STGS__ || []).find((s) => s.kz === stgKz);
|
|
box.innerHTML = `
|
|
<div class="kv">
|
|
<div class="item" style="background:#fff"><div class="k">Startsemester</div><div class="v">${esc(start)}</div></div>
|
|
<div class="item" style="background:#fff"><div class="k">Studiengang</div>
|
|
<div class="v">${stg ? `${esc(stg.code)} - ${esc(stg.name)}` : `KZ ${esc(stgKz)}`}</div></div>
|
|
<div class="item" style="background:#fff"><div class="k">Fortlaufnummer</div><div class="v">${esc(nr)}</div></div>
|
|
<div class="item" style="background:#fff"><div class="k">Plan ansehen</div>
|
|
<div class="v"><a class="btn btn-sm" href="/plan?uid=${esc(m)}" style="margin-top:2px">Studentenplan →</a></div></div>
|
|
</div>`;
|
|
box.classList.add("show");
|
|
}
|
|
|
|
/* ================= Verband-Selects dynamisch ================= */
|
|
async function reloadVerbands() {
|
|
const sel = $("#ver-select");
|
|
if (!sel) return;
|
|
const kz = $("#stg-select").value;
|
|
const sem = $("#sem-select").value;
|
|
const current = sel.value;
|
|
try {
|
|
const r = await fetch(`/api/verbands?kz=${encodeURIComponent(kz)}&sem=${encodeURIComponent(sem)}`);
|
|
if (!r.ok) return;
|
|
const verbs = await r.json();
|
|
if (!Array.isArray(verbs) || !verbs.length) return;
|
|
const labels = { V: " (Vollzeit)", B: " (berufsbegl.)" };
|
|
sel.innerHTML = verbs.map((v) =>
|
|
`<option value="${esc(v)}"${v === current ? " selected" : ""}>${esc(v)}${labels[v] || ""}</option>`).join("");
|
|
if (current && !verbs.includes(current)) sel.selectedIndex = 0;
|
|
} catch (e) { /* Keep static options */ }
|
|
}
|
|
|
|
/* ================= boot ================= */
|
|
document.addEventListener("DOMContentLoaded", () => {
|
|
if (window.__PLAN_DATA__) initPlan();
|
|
if (window.__STGS__) decodeMat();
|
|
});
|
|
document.addEventListener("keydown", (e) => {
|
|
if (e.key === "Escape") closeDetail();
|
|
});
|