2864 lines
114 KiB
JavaScript
2864 lines
114 KiB
JavaScript
// -----------------------------
|
||
// Projektübersicht — kommentierter Quellcode
|
||
// Autor: (kommentiert automatisch)
|
||
// Hinweis: Kommentare erklären die Struktur und Funktion der wichtigsten
|
||
// Teile. Funktionaler Code wurde nicht verändert.
|
||
// -----------------------------
|
||
|
||
// Konstanten: Leistungsphasen, Rollen und LocalStorage-Key
|
||
const LPHS = ["LPH 1","LPH 2","LPH 3","LPH 4","LPH 5","LPH 6","LPH 7","LPH 8", "W+M"]; // Spalten für Leistungsphasen
|
||
const ROLES = ["Planung","Projektleitung","Bauüberwachung"]; // Gruppierung der Personen
|
||
const GROUP_ORDER = ["Aktive Projekte","Kleine Projekte","In Anbahnung","Allgemein"]; // Reihenfolge der Projektgruppen
|
||
const STORAGE = "projuebersicht_v1"; // Durch lokale UI-State-Persistenz genutzter Key
|
||
const DEFAULT_DEPARTMENT = 'ELT';
|
||
const DEFAULT_TAB = 'board';
|
||
|
||
// Farbpalette für GANTT-Balken (eine pro Projekt, wiederholend)
|
||
const GANTT_COLORS = ['#0071e3','#248a3d','#b25000','#7c3aed','#d70015','#0ca678','#d9480f','#5f3dc4'];
|
||
|
||
// Passwortschutz: SHA256-Hash des erlaubten Passworts.
|
||
// Wenn Sie das Passwort ändern möchten, generieren Sie den SHA256-Hash (https://emn178.github.io/online-tools/sha256.html)
|
||
// des neuen Passworts und ersetzen Sie den Wert hier.
|
||
const PROTECTED_PASSWORD_HASH = "107bab579e8500e459690cf48ba77de32be43cadb8421bbe79fad20a55b39b6f";
|
||
|
||
// requestedAction speichert die Aktion, die nach erfolgreicher Passwort-Eingabe ausgeführt wird.
|
||
let requestedAction = null;
|
||
let saveSyncTimer = null;
|
||
let isAuthenticated = false;
|
||
|
||
// In-memory Datenbank: Sitzungen (weeks) und aktuell ausgewählte Sitzung
|
||
let db = { weeks: [], currentWeek: null, globalPeople: {} };
|
||
|
||
// Aktuelle Abteilung (ELT, HLS, ARCH). Daten werden in sessions/<DEPT>/ getrennt gespeichert.
|
||
let currentDepartment = DEFAULT_DEPARTMENT;
|
||
let currentTab = DEFAULT_TAB;
|
||
const DEPARTMENTS = {
|
||
ELT: { label: 'Elektro', icon: '' },
|
||
HLS: { label: 'HLS', icon: '' },
|
||
ARCH: { label: 'Architektur', icon: '' }
|
||
};
|
||
|
||
// departmentParam: gibt ein Query-String-Fragment fuer die aktuelle Abteilung zurueck.
|
||
function departmentParam(){
|
||
return '?department=' + encodeURIComponent(currentDepartment);
|
||
}
|
||
|
||
function saveUiState(){
|
||
try {
|
||
const state = { department: currentDepartment, tab: currentTab };
|
||
window.localStorage.setItem(STORAGE, JSON.stringify(state));
|
||
} catch (err) {
|
||
console.warn('UI-State kann nicht gespeichert werden:', err);
|
||
}
|
||
}
|
||
|
||
function loadUiState(){
|
||
try {
|
||
const state = JSON.parse(window.localStorage.getItem(STORAGE) || '{}');
|
||
if(state.department && DEPARTMENTS[state.department]) currentDepartment = state.department;
|
||
// Tab wird nicht aus dem localStorage wiederhergestellt, damit nach dem
|
||
// Neuladen immer das Projektboard angezeigt wird.
|
||
} catch (err) {
|
||
console.warn('UI-State kann nicht geladen werden:', err);
|
||
}
|
||
}
|
||
|
||
// ---------- Datumshilfsfunktionen (GANTT) ----------
|
||
|
||
function parseDateISO(d){
|
||
if(!d) return null;
|
||
const m = String(d).match(/^(\d{4})-(\d{2})-(\d{2})$/);
|
||
if(!m) return null;
|
||
const date = new Date(parseInt(m[1]), parseInt(m[2])-1, parseInt(m[3]));
|
||
if(isNaN(date.getTime())) return null;
|
||
return date;
|
||
}
|
||
|
||
function formatDateISO(d){
|
||
if(!d || isNaN(d.getTime())) return '';
|
||
const y = d.getFullYear();
|
||
const m = String(d.getMonth()+1).padStart(2,'0');
|
||
const day = String(d.getDate()).padStart(2,'0');
|
||
return `${y}-${m}-${day}`;
|
||
}
|
||
|
||
function addDays(d, n){
|
||
const res = new Date(d.getTime());
|
||
res.setDate(res.getDate() + n);
|
||
return res;
|
||
}
|
||
|
||
function daysBetween(a, b){
|
||
const ms = b.getTime() - a.getTime();
|
||
return Math.round(ms / (1000 * 60 * 60 * 24));
|
||
}
|
||
|
||
// workdaysBetween: Anzahl der Werktage (Mo–Fr) im Zeitraum inkl. Start- und Endtag.
|
||
// Wochenendtage (Sa/So) werden nicht mitgezählt – z.B. für die Auslastung.
|
||
function workdaysBetween(a, b){
|
||
if(!a || !b || b < a) return 0;
|
||
let count = 0;
|
||
let current = new Date(a.getTime());
|
||
while(current <= b){
|
||
const day = current.getDay();
|
||
if(day !== 0 && day !== 6) count++;
|
||
current = addDays(current, 1);
|
||
}
|
||
return count;
|
||
}
|
||
|
||
function formatDateDE(d){
|
||
if(!d || isNaN(d.getTime())) return '';
|
||
return `${String(d.getDate()).padStart(2,'0')}.${String(d.getMonth()+1).padStart(2,'0')}.${d.getFullYear()}`;
|
||
}
|
||
|
||
// ISO-Kalenderwoche nach https://en.wikipedia.org/wiki/ISO_week_date
|
||
function getWeekNumber(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);
|
||
}
|
||
|
||
// ---------- Datumsbereich-Picker ----------
|
||
// Ein Kalender-Popup für Von+Bis: erster Klick setzt das Startdatum,
|
||
// zweiter Klick das Enddatum (bei Klick vor dem Start wird getauscht).
|
||
// Schreibt ISO-Werte in zwei (versteckte) Inputs und aktualisiert optional
|
||
// ein Anzeige-Element. Verwendung: openRangePicker({ anchor, startInputId, endInputId, displayId, onApply })
|
||
|
||
let rangePickerState = null;
|
||
|
||
function openDatePicker(opts){
|
||
closeRangePicker();
|
||
const input = document.getElementById(opts.inputId);
|
||
if(!input) return;
|
||
const preValue = parseDateISO(input.value);
|
||
const today = new Date();
|
||
rangePickerState = {
|
||
opts: Object.assign({}, opts, { mode: 'single' }),
|
||
start: preValue,
|
||
end: preValue,
|
||
mode: 'single',
|
||
// Ansichtsmonat: vorhandener Wert, sonst heutiger Monat
|
||
viewYear: (preValue || today).getFullYear(),
|
||
viewMonth: (preValue || today).getMonth()
|
||
};
|
||
|
||
const pop = document.createElement('div');
|
||
pop.id = 'rangePickerPopup';
|
||
pop.className = 'range-picker';
|
||
document.body.appendChild(pop);
|
||
|
||
const anchorRect = opts.anchor ? opts.anchor.getBoundingClientRect() : { left: window.innerWidth/2 - 140, bottom: window.innerHeight/2 - 160 };
|
||
pop.style.left = Math.max(8, Math.min(anchorRect.left, window.innerWidth - 296)) + 'px';
|
||
pop.style.top = Math.min(anchorRect.bottom + 6, window.innerHeight - 340) + 'px';
|
||
|
||
renderRangePicker();
|
||
|
||
setTimeout(() => {
|
||
document.addEventListener('mousedown', rangePickerOutsideClick);
|
||
}, 0);
|
||
}
|
||
|
||
function openRangePicker(opts){
|
||
closeRangePicker();
|
||
const isSingle = (opts.mode || 'range') === 'single';
|
||
const startInput = document.getElementById(isSingle ? (opts.inputId || opts.startInputId) : opts.startInputId);
|
||
const endInput = isSingle ? null : document.getElementById(opts.endInputId);
|
||
if(!startInput || (!isSingle && !endInput)) return;
|
||
|
||
const preStart = parseDateISO(startInput.value);
|
||
const preEnd = isSingle ? preStart : parseDateISO(endInput.value);
|
||
const today = new Date();
|
||
rangePickerState = {
|
||
opts: Object.assign({}, opts, { mode: isSingle ? 'single' : 'range' }),
|
||
start: preStart,
|
||
end: preEnd,
|
||
mode: isSingle ? 'single' : 'range',
|
||
// Ansichtsmonat: vorhandener Start, sonst heutiger Monat
|
||
viewYear: (preStart || today).getFullYear(),
|
||
viewMonth: (preStart || today).getMonth()
|
||
};
|
||
|
||
const pop = document.createElement('div');
|
||
pop.id = 'rangePickerPopup';
|
||
pop.className = 'range-picker';
|
||
document.body.appendChild(pop);
|
||
|
||
// Position: unterhalb des Ankers, innerhalb des Viewports halten
|
||
const anchorRect = opts.anchor ? opts.anchor.getBoundingClientRect() : { left: window.innerWidth/2 - 140, bottom: window.innerHeight/2 - 160 };
|
||
pop.style.left = Math.max(8, Math.min(anchorRect.left, window.innerWidth - 296)) + 'px';
|
||
pop.style.top = Math.min(anchorRect.bottom + 6, window.innerHeight - 340) + 'px';
|
||
|
||
renderRangePicker();
|
||
|
||
// Klick außerhalb schließt das Popup (ohne Übernahme)
|
||
setTimeout(() => {
|
||
document.addEventListener('mousedown', rangePickerOutsideClick);
|
||
}, 0);
|
||
}
|
||
|
||
function rangePickerOutsideClick(e){
|
||
const pop = document.getElementById('rangePickerPopup');
|
||
if(pop && !pop.contains(e.target)) closeRangePicker();
|
||
}
|
||
|
||
function closeRangePicker(){
|
||
const pop = document.getElementById('rangePickerPopup');
|
||
if(pop) pop.remove();
|
||
document.removeEventListener('mousedown', rangePickerOutsideClick);
|
||
rangePickerState = null;
|
||
}
|
||
|
||
function rangePickerNav(delta){
|
||
if(!rangePickerState) return;
|
||
let m = rangePickerState.viewMonth + delta;
|
||
let y = rangePickerState.viewYear;
|
||
if(m < 0){ m = 11; y--; }
|
||
if(m > 11){ m = 0; y++; }
|
||
rangePickerState.viewMonth = m;
|
||
rangePickerState.viewYear = y;
|
||
renderRangePicker();
|
||
}
|
||
|
||
function rangePickerSelect(iso){
|
||
if(!rangePickerState) return;
|
||
const d = parseDateISO(iso);
|
||
if(!d) return;
|
||
const st = rangePickerState;
|
||
if(st.mode === 'single'){
|
||
st.start = d;
|
||
st.end = d;
|
||
applyRangePicker();
|
||
return;
|
||
}
|
||
if(!st.start || (st.start && st.end)){
|
||
// Neuen Bereich beginnen
|
||
st.start = d;
|
||
st.end = null;
|
||
} else {
|
||
// Zweiter Klick: Ende setzen, bei Bedarf tauschen
|
||
if(d < st.start){ st.end = st.start; st.start = d; }
|
||
else st.end = d;
|
||
applyRangePicker();
|
||
return;
|
||
}
|
||
renderRangePicker();
|
||
}
|
||
|
||
function applyRangePicker(){
|
||
if(!rangePickerState) return;
|
||
const { opts, start, end } = rangePickerState;
|
||
const isSingle = (opts.mode || 'range') === 'single';
|
||
const startInput = document.getElementById(isSingle ? (opts.inputId || opts.startInputId) : opts.startInputId);
|
||
const endInput = isSingle ? null : document.getElementById(opts.endInputId);
|
||
const selected = start || end;
|
||
if(startInput) startInput.value = isSingle ? (selected ? formatDateISO(selected) : '') : (start ? formatDateISO(start) : '');
|
||
if(endInput) endInput.value = end ? formatDateISO(end) : (start ? formatDateISO(start) : '');
|
||
if(opts.displayId){
|
||
const disp = document.getElementById(opts.displayId);
|
||
if(disp){
|
||
const from = start ? formatDateDE(start) : '';
|
||
const to = end ? formatDateDE(end) : from;
|
||
disp.value = isSingle ? (from || '') : (from ? (from + ' – ' + to) : '');
|
||
}
|
||
}
|
||
const cb = opts.onApply;
|
||
closeRangePicker();
|
||
if(typeof cb === 'function') cb();
|
||
}
|
||
|
||
function renderRangePicker(){
|
||
const pop = document.getElementById('rangePickerPopup');
|
||
if(!pop || !rangePickerState) return;
|
||
const st = rangePickerState;
|
||
const monthNames = ['Januar','Februar','März','April','Mai','Juni','Juli','August','September','Oktober','November','Dezember'];
|
||
const first = new Date(st.viewYear, st.viewMonth, 1);
|
||
// Wochentag des Monatsersten, Montag = 0
|
||
const firstWeekday = (first.getDay() + 6) % 7;
|
||
const daysInMonth = new Date(st.viewYear, st.viewMonth + 1, 0).getDate();
|
||
const today = new Date();
|
||
const todayMid = new Date(today.getFullYear(), today.getMonth(), today.getDate());
|
||
|
||
let hint;
|
||
if(st.mode === 'single') hint = 'Datum wählen';
|
||
else if(!st.start || (st.start && st.end)) hint = 'Startdatum wählen';
|
||
else hint = 'Enddatum wählen (Start: ' + formatDateDE(st.start) + ')';
|
||
|
||
let html = `<div class="range-picker-head">
|
||
<button type="button" class="secondary small" onclick="rangePickerNav(-1)">‹</button>
|
||
<span class="range-picker-title">${monthNames[st.viewMonth]} ${st.viewYear}</span>
|
||
<button type="button" class="secondary small" onclick="rangePickerNav(1)">›</button>
|
||
</div>
|
||
<div class="range-picker-hint">${hint}</div>
|
||
<div class="range-picker-grid">`;
|
||
['Mo','Di','Mi','Do','Fr','Sa','So'].forEach(w => html += `<div class="range-picker-weekday">${w}</div>`);
|
||
for(let i = 0; i < firstWeekday; i++) html += '<div></div>';
|
||
for(let day = 1; day <= daysInMonth; day++){
|
||
const d = new Date(st.viewYear, st.viewMonth, day);
|
||
const iso = formatDateISO(d);
|
||
const classes = ['range-picker-day'];
|
||
if(d.getDay() === 0 || d.getDay() === 6) classes.push('weekend');
|
||
if(d.getTime() === todayMid.getTime()) classes.push('today');
|
||
if(st.start && d.getTime() === st.start.getTime()) classes.push('sel-start');
|
||
if(st.end && d.getTime() === st.end.getTime()) classes.push('sel-end');
|
||
if(st.start && st.end && d > st.start && d < st.end) classes.push('in-range');
|
||
html += `<div class="${classes.join(' ')}" onclick="rangePickerSelect('${iso}')">${day}</div>`;
|
||
}
|
||
html += '</div>';
|
||
html += `<div class="range-picker-footer">
|
||
<button type="button" class="secondary small" onclick="closeRangePicker()">Abbrechen</button>
|
||
${st.mode !== 'single' && st.start && !st.end ? '<button type="button" class="small" onclick="rangePickerState.end = rangePickerState.start; applyRangePicker()">Nur dieser Tag</button>' : ''}
|
||
</div>`;
|
||
pop.innerHTML = html;
|
||
}
|
||
|
||
// ---------- Kollabierbarer Header ----------
|
||
|
||
const HEADER_COLLAPSED_KEY = 'projuebersicht-header-collapsed';
|
||
|
||
function loadHeaderState(){
|
||
let collapsed = true; // Default: Menü ist beim ersten Start geschlossen
|
||
try {
|
||
const stored = window.localStorage.getItem(HEADER_COLLAPSED_KEY);
|
||
if(stored !== null){
|
||
collapsed = stored === 'true';
|
||
}
|
||
} catch (err) {}
|
||
setHeaderCollapsed(collapsed, false);
|
||
}
|
||
|
||
function saveHeaderState(collapsed){
|
||
try {
|
||
window.localStorage.setItem(HEADER_COLLAPSED_KEY, collapsed ? 'true' : 'false');
|
||
} catch (err) {}
|
||
}
|
||
|
||
function setHeaderCollapsed(collapsed, persist){
|
||
const menu = document.getElementById('headerMenu');
|
||
const btn = document.querySelector('.header-bar .header-toggle');
|
||
const icon = document.querySelector('.header-bar .header-toggle-icon');
|
||
if(!menu) return;
|
||
if(collapsed){
|
||
menu.classList.remove('open');
|
||
menu.setAttribute('aria-hidden', 'true');
|
||
if(icon) icon.textContent = '☰';
|
||
if(btn) btn.setAttribute('aria-expanded', 'false');
|
||
} else {
|
||
menu.classList.add('open');
|
||
menu.setAttribute('aria-hidden', 'false');
|
||
if(icon) icon.textContent = '✕';
|
||
if(btn) btn.setAttribute('aria-expanded', 'true');
|
||
}
|
||
if(persist) saveHeaderState(collapsed);
|
||
}
|
||
|
||
function toggleHeader(){
|
||
const menu = document.getElementById('headerMenu');
|
||
const isOpen = menu && menu.classList.contains('open');
|
||
setHeaderCollapsed(isOpen, true);
|
||
}
|
||
|
||
// ---------- Theme (Light/Dark) ----------
|
||
|
||
function applyThemeIcon(theme){
|
||
const icon = document.getElementById('themeIcon');
|
||
if(!icon) return;
|
||
icon.textContent = theme === 'dark' ? '☀️' : '🌙';
|
||
}
|
||
|
||
function loadTheme(){
|
||
let theme = 'auto';
|
||
try {
|
||
theme = window.localStorage.getItem('projuebersicht-theme') || 'auto';
|
||
} catch (err) {
|
||
console.warn('Theme kann nicht geladen werden:', err);
|
||
}
|
||
applyTheme(theme, false);
|
||
}
|
||
|
||
function saveTheme(theme){
|
||
try {
|
||
window.localStorage.setItem('projuebersicht-theme', theme);
|
||
} catch (err) {
|
||
console.warn('Theme kann nicht gespeichert werden:', err);
|
||
}
|
||
}
|
||
|
||
function effectiveTheme(theme){
|
||
if(theme === 'dark') return 'dark';
|
||
if(theme === 'light') return 'light';
|
||
return window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
||
}
|
||
|
||
function applyTheme(theme, persist){
|
||
const html = document.documentElement;
|
||
const eff = effectiveTheme(theme);
|
||
if(theme === 'dark') html.setAttribute('data-theme', 'dark');
|
||
else if(theme === 'light') html.setAttribute('data-theme', 'light');
|
||
else html.removeAttribute('data-theme');
|
||
applyThemeIcon(eff);
|
||
if(persist) saveTheme(theme);
|
||
}
|
||
|
||
function toggleTheme(){
|
||
const html = document.documentElement;
|
||
const currentAttr = html.getAttribute('data-theme');
|
||
const currentTheme = currentAttr || 'auto';
|
||
const currentEffective = effectiveTheme(currentTheme);
|
||
const next = currentEffective === 'dark' ? 'light' : 'dark';
|
||
applyTheme(next, true);
|
||
}
|
||
|
||
// Bei Änderung der Systemeinstellung im "Auto"-Modus sofort anpassen
|
||
if(window.matchMedia){
|
||
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', function(){
|
||
let theme = 'auto';
|
||
try {
|
||
theme = window.localStorage.getItem('projuebersicht-theme') || 'auto';
|
||
} catch (err) {}
|
||
applyTheme(theme, false);
|
||
});
|
||
}
|
||
|
||
// ---------- Persistence (CSV-only via localhost server) ----------
|
||
|
||
// save: synchronisiert die aktuellen Daten als CSV-Dateien zum Server.
|
||
// Jede Sitzung wird als eigene CSV-Datei gespeichert. Personen und Rollen werden
|
||
// in der Sitzungscsv selbst abgelegt.
|
||
function save(){
|
||
scheduleSessionSync();
|
||
}
|
||
|
||
function scheduleSessionSync(){
|
||
if(saveSyncTimer){ clearTimeout(saveSyncTimer); }
|
||
saveSyncTimer = setTimeout(() => { syncAllToServer(); saveSyncTimer = null; }, 500);
|
||
}
|
||
|
||
function syncAllToServer(){
|
||
// Zuerst globale Personenliste speichern
|
||
saveGlobalPeopleToServer();
|
||
// Dann jede Sitzung speichern
|
||
db.weeks.forEach(w => {
|
||
const filename = filenameForWeek(w.label);
|
||
const csv = weekToCsv(w);
|
||
sendCsvToServer(filename, csv)
|
||
.then(res => { if(res && res.ok) console.log('Sitzung gespeichert:', filename); })
|
||
.catch(err => { console.error('Sitzung konnte nicht gespeichert werden:', filename, err.message || err); });
|
||
});
|
||
}
|
||
|
||
function saveGlobalPeopleToServer(){
|
||
const people = db.globalPeople[currentDepartment] || [];
|
||
if(people.length === 0) return;
|
||
const csv = globalPeopleToCsv(people);
|
||
sendCsvToServer('_people.csv', csv)
|
||
.then(res => { if(res && res.ok) console.log('Globale Personenliste gespeichert'); })
|
||
.catch(err => { console.error('Globale Personenliste konnte nicht gespeichert werden:', err.message || err); });
|
||
}
|
||
|
||
// baseUrl: dynamische Server-URL, damit die App auch von anderen Geraeten im Netzwerk erreichbar ist.
|
||
// Fallback auf localhost:3003, wenn die Seite als lokale Datei geladen wurde.
|
||
function baseUrl(){
|
||
const origin = window.location.origin;
|
||
if(typeof origin === 'string' && /^https?:\/\//.test(origin)){
|
||
return origin;
|
||
}
|
||
return 'http://localhost:3003';
|
||
}
|
||
|
||
// switchDepartment: wechselt die aktive Abteilung und lädt deren Daten neu.
|
||
function switchDepartment(dept){
|
||
if(!DEPARTMENTS[dept]) return;
|
||
currentDepartment = dept;
|
||
saveUiState();
|
||
|
||
// UI-Status der Abteilungs-Tabs aktualisieren
|
||
document.querySelectorAll('.dept-tab').forEach(btn => {
|
||
btn.classList.toggle('active', btn.dataset.dept === dept);
|
||
});
|
||
|
||
// Seitentitel anpassen
|
||
const info = DEPARTMENTS[dept];
|
||
document.title = 'Projektübersicht ' + info.label;
|
||
const titleEl = document.getElementById('pageTitle');
|
||
if(titleEl) titleEl.textContent = info.icon + ' Projektübersicht ' + info.label;
|
||
|
||
// Datenbank leeren und neu laden
|
||
db = { weeks: [], currentWeek: null, globalPeople: db.globalPeople || {} };
|
||
load();
|
||
}
|
||
|
||
// load: liest alle CSV-Dateien vom Server ein und baut daraus die Datenbank auf.
|
||
function load(){
|
||
if(!window.fetch){
|
||
alert('Ihr Browser unterstützt keine Netzwerk-Anfragen. Bitte verwenden Sie einen aktuellen Browser.');
|
||
seed(); return;
|
||
}
|
||
const url = baseUrl() + '/load-all' + departmentParam();
|
||
console.log('Loading from', url);
|
||
fetch(url)
|
||
.then(r => { if(!r.ok) throw new Error('Server HTTP '+r.status); return r.json(); })
|
||
.then(data => {
|
||
// Globale Personenliste laden
|
||
db.globalPeople[currentDepartment] = parseGlobalPeopleCsv(data.people);
|
||
|
||
const parsedSessions = (data.sessions || []).map(s => ({ filename: s.filename, rows: parseCsvText(s.content) }));
|
||
|
||
db.weeks = [];
|
||
parsedSessions.forEach(s => {
|
||
const w = parseSessionCsv(s.filename, s.rows);
|
||
if(w) db.weeks.push(w);
|
||
});
|
||
|
||
if(db.weeks.length === 0){
|
||
console.warn('Server lieferte 0 Sitzungsdateien. Es werden Beispieldaten (seed) angezeigt. Pruefen Sie, ob der Ordner sessions/ mitkopiert wurde.');
|
||
seed();
|
||
}
|
||
else {
|
||
db.weeks.sort((a,b) => weekSortKey(a.label).localeCompare(weekSortKey(b.label)));
|
||
applyAutoLock();
|
||
const latest = db.weeks[db.weeks.length - 1];
|
||
if(!db.currentWeek || !db.weeks.some(w => w.id === db.currentWeek)){
|
||
db.currentWeek = latest.id;
|
||
}
|
||
console.log(`Server-Load erfolgreich: ${db.weeks.length} Sitzung(en).`);
|
||
}
|
||
// Geschützte Tabs dürfen nicht ohne Passwort angezeigt werden, auch wenn
|
||
// sie im UI-State gespeichert waren.
|
||
if(currentTab === 'projects' || currentTab === 'people') currentTab = DEFAULT_TAB;
|
||
renderAll();
|
||
showTab(currentTab, true);
|
||
})
|
||
.catch(err => {
|
||
console.error('Laden vom Server fehlgeschlagen:', err);
|
||
const serverUrl = baseUrl();
|
||
const msg = 'Server unter ' + serverUrl + ' nicht erreichbar.\n\n' +
|
||
'Mögliche Ursachen:\n' +
|
||
'- Der Server wurde nicht gestartet\n' +
|
||
'- Der Server läuft auf einem anderen Port (Standard: 3003)\n' +
|
||
'- Firewall/Antivirus blockiert ' + serverUrl + '\n\n' +
|
||
'Es werden jetzt die Beispieldaten angezeigt.';
|
||
alert(msg);
|
||
seed(); renderAll();
|
||
});
|
||
}
|
||
|
||
// CSV-Hilfsfunktionen ----------------------------------------
|
||
|
||
function filenameForWeek(label){
|
||
return (label.replace(/[^\w]/g,'_'))+'.csv';
|
||
}
|
||
|
||
function csvEscape(v){
|
||
const s = (v === null || v === undefined ? '' : String(v));
|
||
if(/[;"\n\r]/.test(s)) return '"' + s.replace(/"/g,'""') + '"';
|
||
return s;
|
||
}
|
||
|
||
function globalPeopleToCsv(people){
|
||
const rows = [['NAME', 'Rollen', 'Voller Name', 'E-Mail', 'Abwesenheiten']];
|
||
people.slice().sort((a,b) => a.name.localeCompare(b.name)).forEach(p => {
|
||
const absences = Array.isArray(p.absences) ? JSON.stringify(p.absences) : '';
|
||
rows.push([p.name, (p.role || []).join(','), p.fullName || '', p.email || '', absences]);
|
||
});
|
||
return '\ufeff' + rows.map(r => r.map(csvEscape).join(';')).join('\n');
|
||
}
|
||
|
||
function parseGlobalPeopleCsv(text){
|
||
if(!text) return [];
|
||
const rows = parseCsvText(text);
|
||
if(!rows.length) return [];
|
||
const people = [];
|
||
rows.slice(1).forEach(row => {
|
||
const name = (row[0] || '').trim();
|
||
if(!name) return;
|
||
const roles = String(row[1] || 'Planung').split(',').map(x => x.trim()).filter(Boolean);
|
||
const fullName = (row[2] || '').trim() || name;
|
||
const email = (row[3] || '').trim();
|
||
let absences = [];
|
||
if(row[4]){
|
||
try {
|
||
const parsed = JSON.parse(row[4]);
|
||
if(Array.isArray(parsed)) absences = parsed;
|
||
} catch(e) {
|
||
// Ignoriere fehlerhafte JSON-Daten
|
||
}
|
||
}
|
||
people.push({ id: personIdForName(name), name, fullName, email, role: roles, absences });
|
||
});
|
||
return people;
|
||
}
|
||
|
||
// Persistente ID fuer eine Person aus dem Kuerzel, damit Anwesenheit und
|
||
// Zuordnungen auch nach einem Neuladen stabil bleiben.
|
||
function personIdForName(name){
|
||
return 'p_' + String(name).trim().toLowerCase().replace(/[^a-z0-9]/g,'_');
|
||
}
|
||
|
||
function weekToCsv(w){
|
||
// allPeople enthält für das Board jede (Person, Rolle)-Kombination einmal.
|
||
const grouped = peopleByRole(w);
|
||
const allPeople = [];
|
||
ROLES.forEach(r => grouped[r].forEach(entry => allPeople.push(entry)));
|
||
const rows = [];
|
||
// Metadaten-Zeile: Label, Datum und Sperrstatus der Sitzung
|
||
rows.push(['WOCHE', w.label || '', w.date || '', w.locked ? '1' : '']);
|
||
// PERSONEN-Zeile: Pro Spalte Name|Rolle|Voller Name, damit beim Laden
|
||
// die Zuordnung pro Rolle eindeutig bleibt und der volle Name erhalten ist.
|
||
// Der volle Name wird immer als dritter Teil gespeichert (auch leer),
|
||
// damit er beim Bearbeiten nicht versehentlich verloren geht.
|
||
const peopleMeta = allPeople.map(e => `${e.person.name}|${e.role}|${e.person.fullName || ''}`);
|
||
rows.push(['PERSONEN', '', '', ...peopleMeta, ...LPHS.map(() => ''), '','']);
|
||
rows.push(['Pr.-Nr.','Bezeichnung','Status', ...allPeople.map(e => e.person.name), ...LPHS, 'Gruppe','Index','Plan']);
|
||
rows.push(['ANWESENHEIT','', ...allPeople.map(e => w.attendance && w.attendance[e.person.id] ? 'X' : ''), ...LPHS.map(() => ''),'','','']);
|
||
w.projects.forEach(p => {
|
||
const arr = [p.nr || '', p.name || '', p.status || ''];
|
||
allPeople.forEach(entry => {
|
||
const s = p.assign[assignKey(entry.person.id, entry.role)];
|
||
arr.push(s === 1 ? 'X' : s === 2 ? '(X)' : '');
|
||
});
|
||
LPHS.forEach(l => {
|
||
const v = p.lph[l];
|
||
arr.push(v !== undefined && v !== '' ? String(v).replace(/%/g,'') + '%' : '');
|
||
});
|
||
arr.push(p.group || '');
|
||
arr.push(p.order !== undefined ? String(p.order) : '');
|
||
arr.push(p.plan ? JSON.stringify(p.plan) : '');
|
||
rows.push(arr);
|
||
});
|
||
return '\ufeff' + rows.map(r => r.map(csvEscape).join(';')).join('\n');
|
||
}
|
||
|
||
|
||
function parseCsvText(text){
|
||
// Manche ältere CSV-Dateien enthalten ein UTF-8-BOM gefolgt von einem
|
||
// literalen "" (dreifach kodiert). Beides am Anfang entfernen.
|
||
return text.replace(/^[\ufeff\u00EF\u00BB\u00BF]+/,'').split(/\r?\n/).filter(line => line.trim() !== '').map(parseCsvLine);
|
||
}
|
||
|
||
// Toleranter CSV-Zeilenparser. Versteht sowohl sauberes CSV als auch
|
||
// das ältere Format, bei dem Felder nur ein öffnendes Anführungszeichen
|
||
// hatten und das Semikolon innerhalb eines quoted Feldes gleichzeitig
|
||
// als Trennzeichen diente.
|
||
function parseCsvLine(line){
|
||
const result = [];
|
||
let current = '';
|
||
let inQuotes = false;
|
||
let hasUnclosedQuotes = false;
|
||
let i = 0;
|
||
while(i < line.length){
|
||
const c = line[i];
|
||
const n = line[i+1];
|
||
if(inQuotes){
|
||
if(c === '"' && n === '"'){
|
||
current += '"'; i += 2;
|
||
} else if(c === ';' && n === '"'){
|
||
// Altes Trennzeichen \";\\" innerhalb eines quoted Feldes
|
||
result.push(current);
|
||
current = '';
|
||
inQuotes = false;
|
||
i += 1;
|
||
} else if(c === '"' && (n === ';' || n === undefined)){
|
||
result.push(current);
|
||
current = '';
|
||
inQuotes = false;
|
||
i += (n === ';' ? 2 : 1);
|
||
} else {
|
||
current += c; i++;
|
||
}
|
||
} else {
|
||
if(c === ';'){
|
||
result.push(current);
|
||
current = ''; i++;
|
||
} else if(c === '"'){
|
||
inQuotes = true; i++;
|
||
} else {
|
||
current += c; i++;
|
||
}
|
||
}
|
||
}
|
||
if(inQuotes) hasUnclosedQuotes = true;
|
||
result.push(current);
|
||
if(hasUnclosedQuotes){
|
||
console.warn('CSV-Zeile enthält nicht geschlossene Anführungszeichen:', line.slice(0,80));
|
||
}
|
||
return result;
|
||
}
|
||
|
||
function decodeWeekLabel(filename){
|
||
const base = filename.replace(/\.csv$/i,'');
|
||
let label = base.replace(/_+/g,' ').trim();
|
||
if(/^KW \d+ \d+$/.test(label)){
|
||
label = label.replace(/^KW (\d+) (\d+)$/, 'KW $1 / $2');
|
||
}
|
||
return label || base;
|
||
}
|
||
|
||
// Erzeugt einen Sortierschlüssel aus einem Wochenlabel, sodass z.B.
|
||
// "KW 9 / 2026" vor "KW 10 / 2026" kommt.
|
||
function weekSortKey(label){
|
||
const m = String(label || '').match(/KW\s*(\d+)[^\d]+(\d{4})/i);
|
||
if(m){
|
||
const kw = m[1].padStart(2,'0');
|
||
return `${m[2]}-${kw}`;
|
||
}
|
||
return String(label || '').toLowerCase();
|
||
}
|
||
|
||
|
||
function parseSessionCsv(filename, rows){
|
||
if(!rows || rows.length === 0) return null;
|
||
let label, date, headerRowIdx = 0, attendanceRowIdx = 1, projectStartIdx = 2;
|
||
let locked = false;
|
||
let metaPeopleRow = null;
|
||
if(rows[0][0] === 'WOCHE'){
|
||
label = rows[0][1] || decodeWeekLabel(filename);
|
||
date = rows[0][2] || '';
|
||
locked = (rows[0][3] || '').trim() === '1';
|
||
if(rows[1] && rows[1][0] === 'PERSONEN'){
|
||
metaPeopleRow = rows[1];
|
||
headerRowIdx = 2; attendanceRowIdx = 3;
|
||
} else {
|
||
headerRowIdx = 1; attendanceRowIdx = 2;
|
||
}
|
||
} else {
|
||
label = decodeWeekLabel(filename);
|
||
date = '';
|
||
}
|
||
projectStartIdx = attendanceRowIdx + 1;
|
||
|
||
const header = rows[headerRowIdx];
|
||
if(!header || header.length < 4) return { id: uid(), label, date, people: [], projects: [], attendance: {} };
|
||
|
||
const lphStart = header.indexOf('LPH 1');
|
||
const groupIdx = header.indexOf('Gruppe');
|
||
const orderIdx = header.indexOf('Index');
|
||
const planIdx = header.indexOf('Plan');
|
||
const peopleCols = header.slice(3, lphStart === -1 ? header.length : lphStart);
|
||
|
||
const people = [];
|
||
const colMap = [];
|
||
const personMeta = metaPeopleRow ? metaPeopleRow.slice(3, lphStart === -1 ? metaPeopleRow.length : lphStart) : [];
|
||
const personByName = {};
|
||
const nameOccurrences = {};
|
||
peopleCols.forEach((name, idx) => {
|
||
const n = (name || '').trim();
|
||
if(!n){ colMap.push(null); return; }
|
||
|
||
let roles = ['Planung'];
|
||
let fullName = n;
|
||
if(personMeta[idx]){
|
||
const parts = String(personMeta[idx]).split('|');
|
||
const rawRoles = parts[1] || '';
|
||
const parsedRoles = rawRoles.split(',').map(x => x.trim()).filter(x => ROLES.includes(x));
|
||
if(parsedRoles.length > 0) roles = parsedRoles;
|
||
// Voller Name ist alles ab dem zweiten Pipe; damit sind auch Pipe-Zeichen
|
||
// im Namen möglich. Leere Strings werden bewusst übernommen.
|
||
if(parts.length >= 3) fullName = parts.slice(2).join('|').trim();
|
||
} else {
|
||
const occurrence = nameOccurrences[n] || 0;
|
||
roles = [ROLES[Math.min(occurrence, ROLES.length - 1)]];
|
||
nameOccurrences[n] = occurrence + 1;
|
||
}
|
||
|
||
// Gleiche Person (gleiches Kürzel) wird im Board pro Rolle mehrfach
|
||
// dargestellt, soll aber im Datenmodell nur einmal existieren.
|
||
let person = personByName[n];
|
||
if(!person){
|
||
person = { id: personIdForName(n), name: n, fullName: fullName, role: [] };
|
||
personByName[n] = person;
|
||
people.push(person);
|
||
}
|
||
// Überschreibe den vollen Namen mit dem Wert aus der CSV; die CSV ist
|
||
// hier die führende Datenquelle für den vollständigen Namen.
|
||
person.fullName = fullName;
|
||
// Sammle alle bekannten Rollen der Person; die aktuelle Spaltenrolle
|
||
// wird für die Zuordnung über colMap verwendet.
|
||
roles.forEach(r => { if(!person.role.includes(r)) person.role.push(r); });
|
||
|
||
colMap.push({ person, role: roles[0] });
|
||
});
|
||
|
||
const projects = [];
|
||
for(let rIdx = projectStartIdx; rIdx < rows.length; rIdx++){
|
||
const row = rows[rIdx];
|
||
if(row.length < 4) continue;
|
||
const nr = row[0] || '';
|
||
const name = row[1] || '';
|
||
const status = row[2] || '';
|
||
if(!nr.trim() && !name.trim()) continue;
|
||
|
||
const assign = {};
|
||
peopleCols.forEach((_, i) => {
|
||
const entry = colMap[i];
|
||
if(!entry) return;
|
||
const v = (row[3+i] || '').trim();
|
||
const key = assignKey(entry.person.id, entry.role);
|
||
if(v === 'X') assign[key] = 1;
|
||
else if(v === '(X)') assign[key] = 2;
|
||
});
|
||
|
||
const lph = {};
|
||
if(lphStart !== -1){
|
||
LPHS.forEach((l, j) => {
|
||
const raw = row[lphStart + j];
|
||
if(raw !== undefined && raw !== ''){
|
||
const num = String(raw).replace(/%/g,'');
|
||
if(!isNaN(parseFloat(num)) && isFinite(num)) lph[l] = num;
|
||
}
|
||
});
|
||
}
|
||
|
||
let group = 'Aktive Projekte';
|
||
if(groupIdx >= 0 && row[groupIdx]) group = row[groupIdx].trim() || group;
|
||
else if(row.length > 3) group = row[row.length - 1].trim() || group;
|
||
|
||
let order = rIdx - projectStartIdx;
|
||
if(orderIdx >= 0 && row[orderIdx] !== undefined && row[orderIdx] !== ''){
|
||
const parsed = parseInt(row[orderIdx], 10);
|
||
if(!isNaN(parsed)) order = parsed;
|
||
}
|
||
|
||
let plan = null;
|
||
if(planIdx >= 0 && row[planIdx]){
|
||
try {
|
||
const parsed = JSON.parse(row[planIdx]);
|
||
if(parsed && typeof parsed === 'object') plan = parsed;
|
||
} catch(e) {
|
||
console.warn('Plan-Daten für Projekt konnten nicht geparst werden:', nr, name, e);
|
||
}
|
||
}
|
||
|
||
projects.push({ id: uid(), nr, name, status, group, lph, assign, order, plan });
|
||
}
|
||
|
||
const attendance = {};
|
||
const attRow = rows[attendanceRowIdx];
|
||
if(attRow && attRow[0] === 'ANWESENHEIT'){
|
||
peopleCols.forEach((_, i) => {
|
||
const entry = colMap[i];
|
||
if(entry && (attRow[2+i] || '').trim() === 'X'){
|
||
attendance[entry.person.id] = true;
|
||
}
|
||
});
|
||
}
|
||
|
||
return { id: uid(), label, date, people, projects, attendance, locked };
|
||
}
|
||
|
||
// uid: generiert eine einfache, ausreichend eindeutige ID
|
||
function uid(){ return Date.now().toString(36)+Math.random().toString(36).slice(2,7); }
|
||
|
||
// assignKey: Schlüssel für Projektzuordnungen (Person + Rolle getrennt)
|
||
function assignKey(personId, role){ return personId + '|' + role; }
|
||
|
||
// sortGroups: sortiert Gruppen-Namen nach GROUP_ORDER, unbekannte ans Ende
|
||
function sortGroups(arr){
|
||
return arr.sort((a,b)=>{
|
||
const ia=GROUP_ORDER.indexOf(a), ib=GROUP_ORDER.indexOf(b);
|
||
return (ia===-1?999:ia)-(ib===-1?999:ib);
|
||
});
|
||
}
|
||
|
||
// seed: initialisiert Beispiel-Daten (Personen + eine Woche mit Projekten)
|
||
function seed(){
|
||
const people = [
|
||
{id:personIdForName('AP'), name:"AP", fullName:"Anton Peters", role:["Planung"]},
|
||
{id:personIdForName('SK'), name:"SK", fullName:"Stefan Krause", role:["Planung"]},
|
||
{id:personIdForName('AK'), name:"AK", fullName:"Alexander Klein", role:["Planung"]},
|
||
{id:personIdForName('ED'), name:"ED", fullName:"Eva Dreyer", role:["Planung"]},
|
||
{id:personIdForName('SE'), name:"SE", fullName:"Stefan Eschweiler", role:["Planung"]},
|
||
{id:personIdForName('THK'), name:"THK", fullName:"Thorsten H. Klein", role:["Projektleitung"]},
|
||
{id:personIdForName('SP'), name:"SP", fullName:"Sven Peschke", role:["Projektleitung"]},
|
||
{id:personIdForName('SG'), name:"SG", fullName:"Sandra Gloag", role:["Projektleitung"]},
|
||
{id:personIdForName('TW'), name:"TW", fullName:"Thorsten Wörster", role:["Projektleitung"]},
|
||
{id:personIdForName('RR'), name:"RR", fullName:"Robert Rahn", role:["Bauüberwachung"]},
|
||
{id:personIdForName('MK'), name:"MK", fullName:"Martin Kluge", role:["Bauüberwachung"]},
|
||
{id:personIdForName('HR'), name:"HR", fullName:"Hartwig Rauch", role:["Bauüberwachung"]},
|
||
];
|
||
db.globalPeople[currentDepartment] = JSON.parse(JSON.stringify(people));
|
||
|
||
const wid = uid();
|
||
db.weeks = [{
|
||
id: wid, label:"KW 30 / 2026", date:"2026-07-24", attendance:{}, people, projects: [
|
||
demoProj("18-4007","Janusz-Korczak-Gesamtsch. Bottrop","Aktive Projekte","LPH5 als Vorabzug raus. NW-Räume vorauss. August.",{"LPH 1":100,"LPH 2":100,"LPH 3":100,"LPH 5":100,"LPH 6":100,"LPH 7":100,"LPH 8":95}),
|
||
demoProj("21-4030","NSHV Köln Immobilienprüfung in Hof","Aktive Projekte","Thorsten W. klärt und übernimmt Schriftverkehr. LV liegt zur Sichtung bei T.W.",{"LPH 1":100,"LPH 2":100,"LPH 3":100,"LPH 5":100,"LPH 6":100}),
|
||
demoProj("22-4056","Gymnasium Hemer","Aktive Projekte","",{"LPH 1":100,"LPH 2":100,"LPH 3":100,"LPH 5":100,"LPH 6":100,"LPH 7":100,"LPH 8":90}),
|
||
demoProj("22-4066","DSW 21 – Umbau E-Mobilität","Aktive Projekte","Elt-Montagen ab KW 33.",{"LPH 1":100,"LPH 2":100,"LPH 3":100,"LPH 5":100,"LPH 6":100,"LPH 7":100,"LPH 8":20}),
|
||
demoProj("24-4043","S-K ODL-Heim Sanierung Krefeld","Aktive Projekte","Voraussichtlich LV-Abgabe.",{"LPH 1":100,"LPH 2":100,"LPH 3":100,"LPH 5":100}),
|
||
]
|
||
}];
|
||
db.currentWeek = wid;
|
||
save();
|
||
}
|
||
|
||
// demoProj: Helfer zum Erzeugen eines Projekt-Objekts (für seed/Tests)
|
||
function demoProj(nr,name,group,status,lph){
|
||
return {id:uid(), nr, name, group, status, lph: {...lph}, assign:{}, plan: null};
|
||
}
|
||
|
||
// ---------- Week management ----------
|
||
|
||
// refreshWeekSelect: aktualisiert das Dropdown mit verfügbaren Sitzungen
|
||
function refreshWeekSelect(){
|
||
const sel = document.getElementById('weekSelect');
|
||
sel.innerHTML = db.weeks.map(w=>`<option value="${w.id}" ${w.id===db.currentWeek?'selected':''}>${w.label}</option>`).join('');
|
||
}
|
||
|
||
function formatWeekDate(dateString){
|
||
if(!dateString) return '';
|
||
const parts = dateString.split('-');
|
||
if(parts.length!==3) return dateString;
|
||
return `${parts[2]}.${parts[1]}.${parts[0]}`;
|
||
}
|
||
|
||
function updateWeekDate(){
|
||
const disp = document.getElementById('weekDateDisplay');
|
||
const w = currentWeek();
|
||
if(!disp) return;
|
||
disp.textContent = w?.date ? `Datum: ${formatWeekDate(w.date)}` : '';
|
||
}
|
||
|
||
// currentWeek: liefert das Woche-Objekt zur aktuellen Auswahl (oder undefined)
|
||
function currentWeek(){ return db.weeks.find(w=>w.id===db.currentWeek); }
|
||
|
||
// switchWeek: setzt die aktuelle Woche und rendert die Ansicht neu
|
||
function switchWeek(id){ db.currentWeek = id; save(); renderAll(); }
|
||
|
||
// newWeek: Dialog zur Erzeugung einer neuen Sitzung
|
||
function newWeek(){
|
||
const label = prompt("Bezeichnung der neuen Sitzung (z.B. KW 31 / 2026):","KW / 2026");
|
||
if(!label) return;
|
||
const date = prompt("Datum (JJJJ-MM-TT):", new Date().toISOString().slice(0,10)) || "";
|
||
// Neue Woche übernimmt die aktuell globale Personenliste
|
||
const people = JSON.parse(JSON.stringify(db.globalPeople[currentDepartment] || []));
|
||
const w = {id:uid(), label, date, people, projects:[], attendance:{}, locked:false};
|
||
db.weeks.push(w);
|
||
applyAutoLock();
|
||
db.currentWeek=w.id; save(); renderAll();
|
||
}
|
||
|
||
// cloneWeek: dupliziert die aktuelle Sitzung inkl. Projekten
|
||
function cloneWeek(){
|
||
const cur = currentWeek(); if(!cur) return;
|
||
const label = prompt("Bezeichnung der duplizierten Sitzung:", cur.label+" (Kopie)");
|
||
if(!label) return;
|
||
const date = prompt("Datum (JJJJ-MM-TT):", new Date().toISOString().slice(0,10)) || "";
|
||
const w = JSON.parse(JSON.stringify(cur));
|
||
w.id=uid(); w.label=label; w.date=date; w.attendance={}; w.locked=false;
|
||
// Neue IDs für duplizierte Projekte und Kennzeichnung
|
||
w.projects.forEach(p=>{
|
||
p.id = uid();
|
||
if(p.status && p.status.trim() !== "") p.statusState = 'duplicated';
|
||
else delete p.statusState;
|
||
});
|
||
db.weeks.push(w);
|
||
applyAutoLock();
|
||
db.currentWeek=w.id; save(); renderAll();
|
||
}
|
||
|
||
// applyAutoLock: sperrt alle Sitzungen bis auf die chronologisch
|
||
// letzte (aktuellste). Wird beim Laden, Erstellen und Duplizieren verwendet,
|
||
// damit historische Sitzungen nicht mehr unbeabsichtigt bearbeitet werden.
|
||
function applyAutoLock(){
|
||
if(db.weeks.length === 0) return;
|
||
const sorted = [...db.weeks].sort((a,b) => weekSortKey(a.label).localeCompare(weekSortKey(b.label)));
|
||
const latestId = sorted[sorted.length - 1].id;
|
||
db.weeks.forEach(w => {
|
||
w.locked = (w.id !== latestId);
|
||
});
|
||
}
|
||
|
||
// isWeekLocked: prüft, ob die aktuell ausgewählte Sitzung gesperrt ist
|
||
function isWeekLocked(w){
|
||
return !!w && !!w.locked;
|
||
}
|
||
|
||
// guardWeekLocked: zeigt eine Warnung, wenn die aktuelle Sitzung gesperrt ist.
|
||
// Wird von Bearbeitungsfunktionen verwendet, um versehentliche Änderungen an
|
||
// historischen Sitzungen zu verhindern.
|
||
function guardWeekLocked(){
|
||
const w = currentWeek();
|
||
if(isWeekLocked(w)){
|
||
alert('Diese Sitzung ist gesperrt. Bitte entsperren Sie sie zuerst.');
|
||
return true;
|
||
}
|
||
return false;
|
||
}
|
||
|
||
// unlockCurrentWeek: Entsperrt die aktuelle Sitzung nach Passwortprüfung
|
||
function unlockCurrentWeek(skipAuth = false){
|
||
const w = currentWeek();
|
||
if(!w || !w.locked) return;
|
||
if(!skipAuth && !isAuthenticated){
|
||
requestedAction = { type: 'unlockWeek' };
|
||
document.getElementById('passwordModal').style.display='flex';
|
||
document.getElementById('passwordInput').value='';
|
||
document.getElementById('passwordInput').focus();
|
||
return;
|
||
}
|
||
w.locked = false;
|
||
save(); renderAll();
|
||
requestedAction = null;
|
||
}
|
||
|
||
// deleteWeek: löscht die aktuell ausgewählte Sitzung (wenn mehr als 1 vorhanden)
|
||
function deleteWeek(skipAuth = false){
|
||
if(db.weeks.length<=1){ alert("Mindestens eine Sitzung muss bestehen bleiben."); return; }
|
||
if(!skipAuth && !isAuthenticated){
|
||
requestedAction = { type: 'deleteWeek' };
|
||
document.getElementById('passwordModal').style.display='flex';
|
||
document.getElementById('passwordInput').value='';
|
||
document.getElementById('passwordInput').focus();
|
||
return;
|
||
}
|
||
if(!confirm("Diese Sitzung wirklich löschen?")) { requestedAction = null; return; }
|
||
const current = currentWeek();
|
||
const filename = current ? filenameForWeek(current.label) : null;
|
||
if(filename){
|
||
console.log('Lösche CSV-Datei auf Server:', filename);
|
||
deleteCsvFromServer(filename).then(res=>{
|
||
if(res && res.ok){ console.log('CSV-Datei gelöscht:', filename); }
|
||
else { alert('CSV-Löschung fehlgeschlagen: ' + (res && res.error ? res.error : 'unbekannter Fehler')); }
|
||
}).catch(err=>{ alert('CSV-Löschung fehlgeschlagen: ' + (err.message || err)); });
|
||
}
|
||
db.weeks = db.weeks.filter(w=>w.id!==db.currentWeek);
|
||
db.weeks.sort((a,b) => weekSortKey(a.label).localeCompare(weekSortKey(b.label)));
|
||
db.currentWeek = db.weeks[db.weeks.length - 1].id;
|
||
applyAutoLock();
|
||
save();
|
||
renderAll();
|
||
requestedAction = null;
|
||
}
|
||
|
||
function deleteCsvFromServer(filename){
|
||
if(!window.fetch) return Promise.reject(new Error('fetch nicht verfügbar'));
|
||
return fetch(baseUrl() + '/delete-csv', {
|
||
method: 'POST',
|
||
headers: {'Content-Type':'application/json; charset=utf-8'},
|
||
body: JSON.stringify({ filename, department: currentDepartment })
|
||
}).then(r=>{ if(!r.ok) throw new Error('Server HTTP '+r.status); return r.json(); });
|
||
}
|
||
|
||
let currentStatusEditor = null;
|
||
let currentStatusPid = null;
|
||
|
||
function statusFocus(el, pid){
|
||
currentStatusEditor = el;
|
||
currentStatusPid = pid;
|
||
}
|
||
|
||
function statusBlur(el, pid){
|
||
const val = statusValueFromHtml(el.innerHTML);
|
||
editProj(pid, 'status', val);
|
||
if(currentStatusEditor === el){ currentStatusEditor = null; currentStatusPid = null; }
|
||
}
|
||
|
||
function statusValueFromHtml(html){
|
||
const tmp = document.createElement('div');
|
||
tmp.innerHTML = html;
|
||
const text = (tmp.textContent || tmp.innerText || '').replace(/\u00A0/g, ' ');
|
||
return text.trim() === '' ? '' : html;
|
||
}
|
||
|
||
function formatStatus(command, value){
|
||
if(!currentStatusEditor) return;
|
||
currentStatusEditor.focus();
|
||
document.execCommand(command, false, value || null);
|
||
}
|
||
|
||
// ---------- Board (Haupttabelle) ----------
|
||
|
||
// peopleByRole: gruppiert Personen nach Rolle zurück in ein Objekt
|
||
function peopleByRole(target){
|
||
const m = {}; ROLES.forEach(r=>m[r]=[]);
|
||
const people = [];
|
||
if(Array.isArray(target)){
|
||
target.forEach(w => { if(w && Array.isArray(w.people)) people.push(...w.people); });
|
||
} else if(target && Array.isArray(target.people)){
|
||
people.push(...target.people);
|
||
} else if(Array.isArray(currentWeek()?.people)){
|
||
people.push(...currentWeek().people);
|
||
}
|
||
const seen = new Set();
|
||
people.forEach(p=>{
|
||
const personRoles = Array.isArray(p.role) ? p.role : [p.role];
|
||
personRoles.forEach(role=>{
|
||
if(!m[role]) m[role]=[];
|
||
const key = `${p.id}|${role}`;
|
||
if(!seen.has(key)){
|
||
seen.add(key);
|
||
m[role].push({person:p, role});
|
||
}
|
||
});
|
||
});
|
||
return m;
|
||
}
|
||
|
||
function roleClass(role){
|
||
if(Array.isArray(role)){
|
||
return role.map(r=>'role-'+r.toLowerCase().replace(/\s+/g,'-')).join(' ');
|
||
}
|
||
return 'role-' + role.toLowerCase().replace(/\s+/g,'-');
|
||
}
|
||
|
||
// renderBoard: baut die HTML-Tabelle für das Board auf (Kopf, Zeilen, Zellen)
|
||
function renderBoard(){
|
||
const w = currentWeek();
|
||
const locked = isWeekLocked(w);
|
||
const table = document.getElementById('boardTable');
|
||
const grouped = peopleByRole(w);
|
||
|
||
// Kopfzeilen: Rollen und Leistungsphasen
|
||
let h1 = `<tr><th rowspan="2">Pr.-Nr.</th><th rowspan="2">Bezeichnung</th><th rowspan="2">Status / Kommentar</th>`;
|
||
ROLES.forEach(r=>{ h1 += `<th class="${roleClass(r)}" colspan="${Math.max(1,grouped[r].length)}">${r}</th>`; });
|
||
h1 += `<th colspan="${LPHS.length}">Leistungsphasen (%)</th><th rowspan="2"></th></tr>`;
|
||
|
||
let h2 = `<tr>`;
|
||
ROLES.forEach(r=>{
|
||
if(grouped[r].length===0) h2 += `<th class="rot ${roleClass(r)}">–</th>`;
|
||
grouped[r].forEach(entry=> h2 += `<th class="rot ${roleClass(entry.role)}">${entry.person.name}</th>`);
|
||
});
|
||
LPHS.forEach(l=> h2 += `<th class="rot">${l}</th>`);
|
||
h2 += `</tr>`;
|
||
|
||
// Projekte nach Gruppen (z.B. "Aktive Projekte") zusammenfassen
|
||
const groups = {};
|
||
w.projects.forEach(p=>{ (groups[p.group]||(groups[p.group]=[])).push(p); });
|
||
|
||
// Projekte nach ihrer Reihenfolge sortieren
|
||
Object.keys(groups).forEach(g=>{
|
||
groups[g].sort((a, b) => (a.order || 0) - (b.order || 0));
|
||
});
|
||
|
||
let body = "";
|
||
const allPeople = [];
|
||
ROLES.forEach(r=> grouped[r].forEach(entry=>allPeople.push(entry)));
|
||
|
||
// Jede Gruppe als Abschnitt, dann Zeilen für Projekte
|
||
sortGroups(Object.keys(groups)).forEach(g=>{
|
||
const colspan = 3 + allPeople.length + LPHS.length + 1; // Spaltenanzahl für Gruppentitel
|
||
body += `<tr class="group-header"><td colspan="${colspan}">${esc(g)}</td></tr>`;
|
||
groups[g].forEach(p=>{
|
||
body += `<tr>`;
|
||
body += `<td class="left">${esc(p.nr)}</td>`;
|
||
body += `<td class="left proj-name" style="cursor:pointer" onclick="openProjectPlan('${p.id}')" title="Projektplan öffnen">${esc(p.name)}</td>`;
|
||
// Status-Editor: contenteditable mit kleiner Toolbar (Fett, Farben, Absätze mit Enter)
|
||
const statusClass = p.statusState === 'new' ? 'status-new' : p.statusState === 'duplicated' ? 'status-duplicated' : '';
|
||
const statusEditable = locked ? 'false' : 'true';
|
||
const statusToolbar = locked ? '' : `
|
||
<div class="status-toolbar" tabindex="-1">
|
||
<button type="button" title="Fett" onmousedown="event.preventDefault();formatStatus('bold')"><b>B</b></button>
|
||
<button type="button" title="Rot" class="color-red" onmousedown="event.preventDefault();formatStatus('foreColor','#c53030')">A</button>
|
||
<button type="button" title="Grün" class="color-green" onmousedown="event.preventDefault();formatStatus('foreColor','#2f855a')">A</button>
|
||
<button type="button" title="Blau" class="color-blue" onmousedown="event.preventDefault();formatStatus('foreColor','#2b6cb0')">A</button>
|
||
<button type="button" title="Schwarz" class="color-black" onmousedown="event.preventDefault();formatStatus('foreColor','#1a1a1a')">A</button>
|
||
<button type="button" title="Absatz" onmousedown="event.preventDefault();formatStatus('formatBlock','P')">¶</button>
|
||
</div>`;
|
||
const statusClick = locked ? '' : `onfocus="statusFocus(this,'${p.id}')" onblur="statusBlur(this,'${p.id}')"`;
|
||
const safeStatus = sanitizeStatusHtml(p.status || '');
|
||
body += `<td class="status ${locked ? 'locked-cell' : ''}">
|
||
${statusToolbar}
|
||
<div class="status-edit ${statusClass}" contenteditable="${statusEditable}" ${statusClick}>${safeStatus}</div>
|
||
</td>`;
|
||
|
||
// Personen-Spalten: Klick wechselt Zustand (nicht, optional, Hauptverantwortung)
|
||
allPeople.forEach(entry=>{
|
||
const state = p.assign[assignKey(entry.person.id, entry.role)] || 0; // 0=keine,1=Haupt,2=optional
|
||
const cls = state===1?'on':state===2?'optional':'';
|
||
const txt = state===1?'X':state===2?'(X)':'';
|
||
const click = locked ? '' : `onclick="cycleAssign('${p.id}','${entry.person.id}','${entry.role}')"`;
|
||
body += `<td class="person-cell ${roleClass(entry.role)} ${cls} ${locked ? 'locked-cell' : ''}" ${click}>${txt}</td>`;
|
||
});
|
||
|
||
// LPH-Spalten mit Prozentwerten und Fortschrittsbalken
|
||
LPHS.forEach(l=>{
|
||
const v = (p.lph[l]!==undefined && p.lph[l]!="") ? p.lph[l] : "";
|
||
const inputAttr = locked ? 'readonly tabindex="-1"' : `onchange="editLph('${p.id}','${l}',this.value)"`;
|
||
body += `<td class="lph-cell ${locked ? 'locked-cell' : ''}"><input type="text" value="${v}" ${inputAttr} placeholder="-">`;
|
||
if(v!=""){ const pct = Math.min(100, parseFloat(v)||0);
|
||
body += `<div class="bar"><div style="width:${pct}%;background:${getProgressColor(pct)}"></div></div>`; }
|
||
body += `</td>`;
|
||
});
|
||
body += `</tr>`;
|
||
});
|
||
|
||
});
|
||
|
||
let lockNotice = '';
|
||
if(locked){
|
||
lockNotice = `<div class="lock-notice" style="margin-bottom:10px;padding:8px 12px;background:#fff5f5;border:1px solid #fc8181;border-radius:6px;color:#c53030;font-weight:bold;font-size:12px;">
|
||
Diese Sitzung ist gesperrt. Bearbeiten nur nach Passworteingabe möglich.
|
||
<button type="button" class="secondary small" style="margin-left:12px;" onclick="unlockCurrentWeek()">Entsperren</button>
|
||
</div>`;
|
||
}
|
||
|
||
table.innerHTML = `<thead>${h1}${h2}</thead><tbody>${body}</tbody>`;
|
||
|
||
// Legende mit vollständigen Namen und Anwesenheitstracking
|
||
let legendeHtml = lockNotice + `<div style="margin-bottom:8px; font-weight:bold; color:var(--accent);">Legende Projektkürzel${locked ? '' : ' (klick für Anwesenheit)'}:</div>`;
|
||
const weekAttendance = w?.attendance || {};
|
||
const legendeItems = [];
|
||
allPeople.forEach(entry=>{
|
||
if(!legendeItems.some(item=>item.id===entry.person.id)){
|
||
legendeItems.push({id:entry.person.id, name:entry.person.name, fullName:entry.person.fullName||entry.person.name});
|
||
}
|
||
});
|
||
legendeHtml += `<div class="legend-list">` + legendeItems.map(item=>{
|
||
const isPresent = weekAttendance[item.id];
|
||
const cursor = locked ? 'not-allowed' : 'pointer';
|
||
const click = locked ? '' : `onclick="toggleAttendance('${item.id}')"`;
|
||
return `<div class="legend-item${isPresent?' present':''}${locked ? ' locked-legend-item' : ''}" ${click}><span>${esc(item.name)}</span> = ${esc(item.fullName)}</div>`;
|
||
}).join('') + `</div>`;
|
||
document.getElementById('boardLegend').innerHTML = legendeHtml;
|
||
adjustStickyOffsets();
|
||
}
|
||
|
||
// Sanitizing: Erlaubt nur eine kleine Whitelist an HTML-Tags/Attributen,
|
||
// damit Formatierung funktioniert, aber keine Skripte eingeschleust werden.
|
||
function sanitizeStatusHtml(html){
|
||
if(!html) return '';
|
||
const div = document.createElement('div');
|
||
div.innerHTML = html;
|
||
const allowedTags = new Set(['B','STRONG','I','EM','U','P','BR','DIV','SPAN','FONT']);
|
||
const allowedAttrs = new Set(['style']);
|
||
const walk = (node) => {
|
||
for(let i = node.childNodes.length - 1; i >= 0; i--){
|
||
const child = node.childNodes[i];
|
||
if(child.nodeType === 3) continue;
|
||
if(child.nodeType === 1){
|
||
if(!allowedTags.has(child.tagName.toUpperCase())){
|
||
while(child.firstChild) node.insertBefore(child.firstChild, child);
|
||
node.removeChild(child);
|
||
continue;
|
||
}
|
||
for(let j = child.attributes.length - 1; j >= 0; j--){
|
||
const attr = child.attributes[j].name;
|
||
if(!allowedAttrs.has(attr.toLowerCase())) child.removeAttribute(attr);
|
||
}
|
||
if(child.hasAttribute('style')){
|
||
// Erlaubt nur color und font-weight im style-Attribut
|
||
const style = child.getAttribute('style');
|
||
const safe = (style || '').split(';').map(s => s.trim()).filter(s => {
|
||
const prop = s.split(':')[0].trim().toLowerCase();
|
||
return prop === 'color' || prop === 'font-weight';
|
||
}).join('; ');
|
||
if(safe) child.setAttribute('style', safe);
|
||
else child.removeAttribute('style');
|
||
}
|
||
walk(child);
|
||
}
|
||
}
|
||
};
|
||
walk(div);
|
||
return div.innerHTML;
|
||
}
|
||
|
||
|
||
// getProgressColor: erzeugt eine Farbe basierend auf Prozent (grün→rot)
|
||
function getProgressColor(pct){
|
||
const hue = Math.round(Math.max(0, Math.min(100, pct)) * 1.2);
|
||
return `hsl(${hue},70%,40%)`;
|
||
}
|
||
|
||
// adjustStickyOffsets: berechnet die reale Höhe der ersten Kopfzeile
|
||
// und setzt den top-Wert der zweiten Kopfzeile entsprechend, damit
|
||
// beide sticky-Zeilen exakt aneinander anschließen (keine Lücke/Überlappung)
|
||
function adjustStickyOffsets(){
|
||
const firstRow = document.querySelector('#boardTable thead tr:nth-child(1)');
|
||
if(!firstRow) return;
|
||
const h = firstRow.getBoundingClientRect().height;
|
||
document.querySelectorAll('#boardTable thead tr:nth-child(2) th').forEach(th=>{
|
||
th.style.top = h + 'px';
|
||
});
|
||
}
|
||
|
||
// toggleAttendance: togglet den Anwesenheitsstatus eines Projektbeteiligten für die aktuelle Sitzung
|
||
function toggleAttendance(personId){
|
||
if(guardWeekLocked()) return;
|
||
const w = currentWeek();
|
||
if(!w) return;
|
||
if(!w.attendance) w.attendance = {};
|
||
w.attendance[personId] = !w.attendance[personId];
|
||
save();
|
||
renderBoard();
|
||
}
|
||
|
||
// esc: einfache Escape-Funktion für Ausgabe in HTML
|
||
function esc(s){ return (s||"").replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"'); }
|
||
|
||
// cycleAssign: wechselt die Zuweisung einer Person zu einem Projekt (0→1→2→0)
|
||
function cycleAssign(pid, personId, role){
|
||
if(guardWeekLocked()) return;
|
||
const p = currentWeek().projects.find(x=>x.id===pid);
|
||
const key = assignKey(personId, role);
|
||
const cur = p.assign[key]||0;
|
||
const next = (cur+1)%3;
|
||
if(next===0) delete p.assign[key]; else p.assign[key]=next;
|
||
save(); renderBoard();
|
||
}
|
||
|
||
// editLph: setzt oder entfernt den Prozentwert einer Leistungsphase für ein Projekt
|
||
function editLph(pid,lph,val){
|
||
if(guardWeekLocked()) return;
|
||
const p = currentWeek().projects.find(x=>x.id===pid);
|
||
val = val.trim();
|
||
if(val==="") delete p.lph[lph];
|
||
else p.lph[lph]=val.replace('%','');
|
||
save(); renderBoard();
|
||
}
|
||
|
||
// editProj: aktualisiert Felder eines Projekts; für Status wird zusätzlich
|
||
// ein flag `statusState` gesetzt, wenn ein neuer Kommentar angelegt wird
|
||
function editProj(pid,field,val){
|
||
if(guardWeekLocked()) return;
|
||
const p = currentWeek().projects.find(x=>x.id===pid);
|
||
if(!p) return;
|
||
if(field==='status'){
|
||
const before = (p.status||"").trim();
|
||
const after = val.trim();
|
||
if(before === "" && after !== ""){
|
||
p.statusState = 'new';
|
||
} else if(after === ""){
|
||
delete p.statusState;
|
||
}
|
||
}
|
||
p[field]=val; save();
|
||
}
|
||
|
||
// delProj: löscht ein Projekt aus der aktuellen Sitzung
|
||
function delProj(pid){
|
||
if(guardWeekLocked()) return;
|
||
if(!confirm("Projekt aus dieser Sitzung löschen?")) return;
|
||
const w = currentWeek();
|
||
w.projects = w.projects.filter(x=>x.id!==pid);
|
||
save(); renderBoard();
|
||
}
|
||
|
||
// ---------- Personen-Verwaltung (global pro Abteilung) ----------
|
||
|
||
// addPerson: fügt eine neue Person der globalen Liste hinzu und
|
||
// synchronisiert sie in die aktuelle Sitzung.
|
||
function addPerson(){
|
||
if(guardWeekLocked()) return;
|
||
const w = currentWeek();
|
||
if(!w) return;
|
||
const name = document.getElementById('newPersonName').value.trim();
|
||
const fullName = document.getElementById('newPersonFullName').value.trim();
|
||
const email = document.getElementById('newPersonEmail').value.trim();
|
||
if(!name){ alert("Kürzel eingeben."); return; }
|
||
const roles = [...document.querySelectorAll('input[name="newPersonRoles"]:checked')].map(el=>el.value);
|
||
if(roles.length===0){ alert("Bitte mindestens eine Rolle auswählen."); return; }
|
||
|
||
const person = {id:personIdForName(name), name, fullName:fullName||name, email:email, role:roles};
|
||
|
||
// Globale Liste aktualisieren
|
||
if(!Array.isArray(db.globalPeople[currentDepartment])) db.globalPeople[currentDepartment] = [];
|
||
const existing = db.globalPeople[currentDepartment].find(p => p.id === person.id);
|
||
if(existing){
|
||
alert('Eine Person mit diesem Kürzel existiert bereits.');
|
||
return;
|
||
}
|
||
db.globalPeople[currentDepartment].push(person);
|
||
|
||
// Aktuelle Sitzung aktualisieren
|
||
if(!Array.isArray(w.people)) w.people = [];
|
||
if(!w.people.find(p => p.id === person.id)){
|
||
w.people.push(JSON.parse(JSON.stringify(person)));
|
||
}
|
||
|
||
document.getElementById('newPersonName').value='';
|
||
document.getElementById('newPersonFullName').value='';
|
||
document.getElementById('newPersonEmail').value='';
|
||
document.querySelectorAll('input[name="newPersonRoles"]').forEach(el=>el.checked=false);
|
||
save(); renderPeople(); renderBoard();
|
||
}
|
||
|
||
// delPerson: entfernt eine Person aus der globalen Liste. Bereits in
|
||
// historischen Sitzungen verwendete Personen bleiben dort sichtbar, da
|
||
// jede Sitzung ihre eigene Kopie der Personendaten speichert.
|
||
function delPerson(id){
|
||
if(!confirm("Person global löschen? Sie bleibt in bereits existierenden Sitzungen sichtbar, wird aber in neuen Sitzungen nicht mehr angeboten.")) return;
|
||
|
||
// Aus globaler Liste entfernen
|
||
if(Array.isArray(db.globalPeople[currentDepartment])){
|
||
db.globalPeople[currentDepartment] = db.globalPeople[currentDepartment].filter(p=>p.id!==id);
|
||
}
|
||
|
||
// Aus aktueller Sitzung entfernen (nur wenn nicht in Zuordnungen genutzt)
|
||
const w = currentWeek();
|
||
if(w){
|
||
const inUse = w.projects.some(p => Object.keys(p.assign).some(k => k.startsWith(id + '|')));
|
||
if(inUse){
|
||
alert('Die Person ist in der aktuellen Sitzung noch Projekten zugeordnet. Bitte zuerst die Zuordnungen entfernen.');
|
||
return;
|
||
}
|
||
w.people = (w.people || []).filter(p=>p.id!==id);
|
||
}
|
||
|
||
save(); renderPeople(); renderBoard();
|
||
}
|
||
|
||
// editPerson: ändert Name/Vollname/Rolle sowohl global als auch in der
|
||
// aktuellen Sitzung. Historische Sitzungen bleiben unverändert.
|
||
function editPerson(id,field,val){
|
||
if(guardWeekLocked()) return;
|
||
const w = currentWeek();
|
||
if(!w) return;
|
||
|
||
// Global aktualisieren
|
||
const globalPeople = db.globalPeople[currentDepartment] || [];
|
||
const globalPerson = globalPeople.find(p => p.id === id);
|
||
|
||
if(field === 'name'){
|
||
const newName = val.trim();
|
||
if(!newName) return;
|
||
const newId = personIdForName(newName);
|
||
if(newId !== id && globalPeople.some(p => p.id === newId)){
|
||
alert('Dieses Kürzel wird bereits verwendet.');
|
||
return;
|
||
}
|
||
if(globalPerson){
|
||
globalPerson.name = newName;
|
||
globalPerson.id = newId;
|
||
}
|
||
// Aktuelle Sitzung aktualisieren und Zuordnungen migrieren
|
||
const localPerson = (w.people || []).find(p => p.id === id);
|
||
if(localPerson){
|
||
localPerson.name = newName;
|
||
localPerson.id = newId;
|
||
}
|
||
w.projects.forEach(p => {
|
||
const newAssign = {};
|
||
Object.keys(p.assign).forEach(k => {
|
||
const [oldId, role] = k.split('|');
|
||
if(oldId === id){
|
||
newAssign[assignKey(newId, role)] = p.assign[k];
|
||
} else {
|
||
newAssign[k] = p.assign[k];
|
||
}
|
||
});
|
||
p.assign = newAssign;
|
||
if(p.plan && Array.isArray(p.plan.people)){
|
||
p.plan.people = p.plan.people.map(pid => pid === id ? newId : pid);
|
||
}
|
||
});
|
||
if(w.attendance){
|
||
if(w.attendance[id]){
|
||
w.attendance[newId] = true;
|
||
delete w.attendance[id];
|
||
}
|
||
}
|
||
save(); renderPeople(); renderBoard();
|
||
return;
|
||
}
|
||
|
||
if(field === 'role'){
|
||
const roles = Array.isArray(val) ? [...new Set(val)] : val ? [val] : [];
|
||
if(globalPerson) globalPerson.role = roles;
|
||
const localPerson = (w.people || []).find(p => p.id === id);
|
||
if(localPerson) localPerson.role = roles;
|
||
} else {
|
||
if(globalPerson) globalPerson[field] = val;
|
||
const localPerson = (w.people || []).find(p => p.id === id);
|
||
if(localPerson) localPerson[field] = val;
|
||
}
|
||
save(); renderPeople(); renderBoard();
|
||
}
|
||
|
||
function togglePersonRole(id, role, checked){
|
||
if(guardWeekLocked()) return;
|
||
const w = currentWeek();
|
||
if(!w) return;
|
||
|
||
const globalPeople = db.globalPeople[currentDepartment] || [];
|
||
const globalPerson = globalPeople.find(p => p.id === id);
|
||
const localPerson = (w.people || []).find(p => p.id === id);
|
||
|
||
function updateRoles(person){
|
||
person.role = Array.isArray(person.role) ? [...person.role] : person.role ? [person.role] : [];
|
||
if(checked){
|
||
if(!person.role.includes(role)) person.role.push(role);
|
||
} else {
|
||
person.role = person.role.filter(r=>r!==role);
|
||
}
|
||
}
|
||
|
||
if(globalPerson) updateRoles(globalPerson);
|
||
if(localPerson) updateRoles(localPerson);
|
||
save(); renderPeople(); renderBoard();
|
||
}
|
||
|
||
// renderPeople: baut die Liste der Personen für die UI
|
||
function renderPeople(){
|
||
const people = db.globalPeople[currentDepartment] || [];
|
||
const locked = isWeekLocked(currentWeek());
|
||
let html = `<div class="people-sortable">`;
|
||
people.forEach(p=>{
|
||
const isOpen = personAbsenceOpen[p.id];
|
||
html += `<div class="person-card${isOpen ? ' open' : ''}" data-person-id="${p.id}">`;
|
||
html += `<div class="person-header" onclick="togglePersonAbsences('${p.id}')">
|
||
<span class="drag-handle" style="cursor:grab;color:#999;margin-right:8px" onclick="event.stopPropagation()" ondragstart="handlePersonDragStart(event)" draggable="true">⋮⋮</span>
|
||
<span class="person-toggle">${isOpen ? '▼' : '▶'}</span>
|
||
<input type="text" value="${esc(p.name)}" onclick="event.stopPropagation()" onchange="editPerson('${p.id}','name',this.value)" style="width:80px;" placeholder="Kürzel">
|
||
<input type="text" value="${esc(p.fullName||p.name)}" onclick="event.stopPropagation()" onchange="editPerson('${p.id}','fullName',this.value)" style="width:180px;" placeholder="Voller Name">
|
||
<input type="text" value="${esc(p.email)}" onclick="event.stopPropagation()" onchange="editPerson('${p.id}','email',this.value)" style="width:180px;" placeholder="E-Mail">
|
||
<div class="role-checkboxes" onclick="event.stopPropagation()">${ROLES.map(rr=>`<label><input type="checkbox" onchange="togglePersonRole('${p.id}','${rr}',this.checked)" value="${rr}" ${p.role.includes(rr)?'checked':''}> ${rr}</label>`).join('')}</div>
|
||
<button class="danger small" onclick="event.stopPropagation(); delPerson('${p.id}')">Löschen</button>
|
||
</div>`;
|
||
|
||
// Aufklappbare Abwesenheiten
|
||
html += `<div class="person-absences" style="display:${isOpen ? 'block' : 'none'}">`;
|
||
const absences = p.absences || [];
|
||
if(absences.length > 0){
|
||
html += `<div class="absence-list">`;
|
||
absences.forEach(a => {
|
||
html += `<div class="absence-chip">
|
||
<span>${esc(a.type || 'Abwesenheit')}: ${formatDateDE(parseDateISO(a.start))}–${formatDateDE(parseDateISO(a.end))}</span>
|
||
${a.note ? `<small>${esc(a.note)}</small>` : ''}
|
||
${locked ? '' : `<button class="danger small" onclick="deleteAbsence('${p.id}','${a.id}')">×</button>`}
|
||
</div>`;
|
||
});
|
||
html += `</div>`;
|
||
}
|
||
if(!locked){
|
||
html += `<div class="absence-form">
|
||
<input type="text" id="absenceRange_${p.id}" class="range-picker-display" placeholder="Zeitraum wählen…" readonly
|
||
onclick="event.stopPropagation(); openRangePicker({ anchor: this, startInputId: 'absenceStart_${p.id}', endInputId: 'absenceEnd_${p.id}', displayId: 'absenceRange_${p.id}' })">
|
||
<input type="hidden" id="absenceStart_${p.id}">
|
||
<input type="hidden" id="absenceEnd_${p.id}">
|
||
<select id="absenceType_${p.id}">
|
||
<option>Urlaub</option>
|
||
<option>Krank</option>
|
||
<option>Fortbildung</option>
|
||
<option>Sonstiges</option>
|
||
</select>
|
||
<input type="text" id="absenceNote_${p.id}" placeholder="Notiz (optional)" style="width:140px">
|
||
<button class="secondary small" onclick="addAbsence('${p.id}')">+ Abwesenheit</button>
|
||
</div>`;
|
||
}
|
||
html += `</div></div>`;
|
||
});
|
||
html += `</div>`;
|
||
if(people.length===0) html = '<div class="hint">Keine Personen vorhanden.</div>';
|
||
document.getElementById('peopleList').innerHTML=html;
|
||
}
|
||
|
||
let personAbsenceOpen = {};
|
||
|
||
function togglePersonAbsences(personId){
|
||
personAbsenceOpen[personId] = !personAbsenceOpen[personId];
|
||
renderPeople();
|
||
}
|
||
|
||
let draggedPerson = null;
|
||
|
||
function getPersonCard(el){
|
||
return el && el.closest ? el.closest('.person-card') : null;
|
||
}
|
||
|
||
function handlePersonDragStart(event){
|
||
draggedPerson = getPersonCard(event.target);
|
||
if(!draggedPerson) return;
|
||
event.dataTransfer.effectAllowed = 'move';
|
||
draggedPerson.style.opacity = '0.5';
|
||
}
|
||
|
||
function handlePersonDragOver(event){
|
||
event.preventDefault();
|
||
event.dataTransfer.dropEffect = 'move';
|
||
}
|
||
|
||
function handlePersonDrop(event){
|
||
event.preventDefault();
|
||
const targetCard = getPersonCard(event.target);
|
||
if(!draggedPerson || draggedPerson === targetCard) return;
|
||
const sortable = document.querySelector('.people-sortable');
|
||
if(!sortable) return;
|
||
const afterElement = getDragAfterElement(sortable, event.clientY);
|
||
if(afterElement == null){
|
||
sortable.appendChild(draggedPerson);
|
||
} else {
|
||
sortable.insertBefore(draggedPerson, afterElement);
|
||
}
|
||
reorderPeople();
|
||
}
|
||
|
||
function handlePersonDragEnd(event){
|
||
const card = getPersonCard(event.target);
|
||
if(card) card.style.opacity = '1';
|
||
draggedPerson = null;
|
||
}
|
||
|
||
function reorderPeople(){
|
||
if(guardWeekLocked()) return;
|
||
const sortable = document.querySelector('.people-sortable');
|
||
if(!sortable) return;
|
||
const people = db.globalPeople[currentDepartment] || [];
|
||
if(people.length === 0) return;
|
||
const newPeople = [];
|
||
[...sortable.querySelectorAll('[data-person-id]')].forEach(item => {
|
||
const person = people.find(p => p.id === item.dataset.personId);
|
||
if(person) newPeople.push(person);
|
||
});
|
||
if(newPeople.length === people.length){
|
||
db.globalPeople[currentDepartment] = newPeople;
|
||
save();
|
||
}
|
||
}
|
||
|
||
// ---------- Projekte (Listen-Ansicht) ----------
|
||
|
||
// addProjectToList: fügt ein Projekt über Eingabefelder in die aktuelle Sitzung
|
||
function addProjectToList(){
|
||
if(guardWeekLocked()) return;
|
||
const nr = document.getElementById('newProjNr').value.trim();
|
||
const name = document.getElementById('newProjName').value.trim();
|
||
if(!name){ alert("Bitte Projektbezeichnung eingeben."); return; }
|
||
const group = document.getElementById('newProjGroup').value;
|
||
const w = currentWeek();
|
||
const nextOrder = w.projects
|
||
.filter(p=>p.group===group)
|
||
.reduce((max,p)=> Math.max(max, p.order!==undefined ? p.order : 0), -1) + 1;
|
||
w.projects.push({id:uid(),nr,name,group,status:'',lph:{},assign:{}, statusState: undefined, order: nextOrder, plan: null});
|
||
document.getElementById('newProjNr').value='';
|
||
document.getElementById('newProjName').value='';
|
||
save(); renderProjects(); renderBoard();
|
||
}
|
||
|
||
// delProjectFromList: löscht ein Projekt in der Listen-Ansicht
|
||
function delProjectFromList(id){
|
||
if(guardWeekLocked()) return;
|
||
if(!confirm("Projekt aus dieser Sitzung löschen?")) return;
|
||
const w = currentWeek();
|
||
w.projects = w.projects.filter(p=>p.id!==id);
|
||
save(); renderProjects(); renderBoard();
|
||
}
|
||
|
||
// editProjectField: ändert ein Feld eines Projekts (z.B. Name oder Gruppe)
|
||
function editProjectField(id,field,val){
|
||
if(guardWeekLocked()) return;
|
||
const p = currentWeek().projects.find(x=>x.id===id);
|
||
if(p){ p[field]=val; save(); renderProjects(); renderBoard(); }
|
||
}
|
||
|
||
// renderProjects: baut die HTML-Liste der Projekte (gruppiert nach group, mit Drag-and-Drop)
|
||
function renderProjects(){
|
||
const w = currentWeek();
|
||
if(!w) return;
|
||
let html="";
|
||
const groups = {};
|
||
w.projects.forEach(p=>{ (groups[p.group]||(groups[p.group]=[])).push(p); });
|
||
Object.keys(groups).forEach(g=>{
|
||
groups[g].sort((a,b)=>(a.order||0)-(b.order||0));
|
||
});
|
||
|
||
sortGroups(Object.keys(groups)).forEach(g=>{
|
||
html += `<div class="section-title">${esc(g)}</div>`;
|
||
html += `<div class="projects-sortable" data-group="${g}">`;
|
||
groups[g].forEach(p=>{
|
||
html += `<div class="person-list-item" draggable="true" data-project-id="${p.id}" ondragstart="handleDragStart(event)" ondragover="handleDragOver(event)" ondrop="handleDrop(event)" ondragend="handleDragEnd(event)">
|
||
<span class="drag-handle" style="cursor:grab;color:#999;margin-right:8px">⋮⋮</span>
|
||
<input type="text" value="${esc(p.nr)}" onchange="editProjectField('${p.id}','nr',this.value)" style="width:100px" placeholder="Proj-Nr">
|
||
<input type="text" value="${esc(p.name)}" onchange="editProjectField('${p.id}','name',this.value)" style="width:200px" placeholder="Bezeichnung">
|
||
<select onchange="editProjectField('${p.id}','group',this.value)">
|
||
<option ${p.group==='Aktive Projekte'?'selected':''}>Aktive Projekte</option>
|
||
<option ${p.group==='Kleine Projekte'?'selected':''}>Kleine Projekte</option>
|
||
<option ${p.group==='In Anbahnung'?'selected':''}>In Anbahnung</option>
|
||
<option ${p.group==='Allgemein'?'selected':''}>Allgemein</option>
|
||
</select>
|
||
<button class="danger small" onclick="delProjectFromList('${p.id}')">Löschen</button>
|
||
</div>`;
|
||
});
|
||
html += `</div>`;
|
||
if(groups[g].length===0) html+=`<div class="hint">Keine Projekte.</div>`;
|
||
});
|
||
document.getElementById('projectsList').innerHTML=html;
|
||
}
|
||
|
||
// Drag-and-Drop Handling für Projekte
|
||
let draggedProject = null;
|
||
|
||
function handleDragStart(event){
|
||
draggedProject = event.currentTarget;
|
||
event.dataTransfer.effectAllowed = 'move';
|
||
event.currentTarget.style.opacity = '0.5';
|
||
}
|
||
|
||
function handleDragOver(event){
|
||
event.preventDefault();
|
||
event.dataTransfer.dropEffect = 'move';
|
||
}
|
||
|
||
function handleDrop(event){
|
||
event.preventDefault();
|
||
if(!draggedProject || draggedProject === event.currentTarget) return;
|
||
const sortable = event.currentTarget.parentElement;
|
||
const afterElement = getDragAfterElement(sortable, event.clientY);
|
||
if(afterElement == null){
|
||
sortable.appendChild(draggedProject);
|
||
} else {
|
||
sortable.insertBefore(draggedProject, afterElement);
|
||
}
|
||
reorderProjects();
|
||
}
|
||
|
||
function handleDragEnd(event){
|
||
event.currentTarget.style.opacity = '1';
|
||
draggedProject = null;
|
||
}
|
||
|
||
function getDragAfterElement(container, y){
|
||
const draggableElements = [...container.querySelectorAll('.person-list-item:not([style*="opacity"])')];
|
||
return draggableElements.reduce((closest, child) => {
|
||
const box = child.getBoundingClientRect();
|
||
const offset = y - box.top - box.height / 2;
|
||
if(offset < 0 && offset > closest.offset){
|
||
return { offset: offset, element: child };
|
||
} else {
|
||
return closest;
|
||
}
|
||
}, { offset: Number.NEGATIVE_INFINITY }).element;
|
||
}
|
||
|
||
function reorderProjects(){
|
||
if(guardWeekLocked()) return;
|
||
const w = currentWeek();
|
||
const sortables = document.querySelectorAll('.projects-sortable');
|
||
sortables.forEach(sortable => {
|
||
const group = sortable.dataset.group;
|
||
const items = sortable.querySelectorAll('[data-project-id]');
|
||
items.forEach((item, index) => {
|
||
const projectId = item.dataset.projectId;
|
||
const project = w.projects.find(p => p.id === projectId);
|
||
if(project){
|
||
project.order = index;
|
||
project.group = group;
|
||
}
|
||
});
|
||
});
|
||
save();
|
||
renderProjects();
|
||
renderBoard();
|
||
}
|
||
|
||
// ---------- Auslastung (Load) ----------
|
||
|
||
// renderLoad: berechnet die Belastung der Personen (Haupt=1, Optional=0,5)
|
||
// und zeigt eine einfache Visualisierung pro Rolle
|
||
function renderLoad(){
|
||
const scope = document.getElementById('loadScope').value;
|
||
const selectedWeek = currentWeek();
|
||
const weeks = scope==='all' ? db.weeks : (selectedWeek ? [selectedWeek] : []);
|
||
if(weeks.length === 0){
|
||
document.getElementById('loadGrid').innerHTML = '<div class="hint">Keine Sitzung ausgewählt oder die Daten werden noch geladen.</div>';
|
||
return;
|
||
}
|
||
const grouped = scope==='all' ? peopleByRole(db.weeks) : peopleByRole(selectedWeek);
|
||
const stats = {};
|
||
ROLES.forEach(r=>{
|
||
grouped[r].forEach(entry=>{
|
||
stats[assignKey(entry.person.id, r)] = {main:0,opt:0,kleine:0,projects:{},anbahnung:0};
|
||
});
|
||
});
|
||
|
||
weeks.forEach(w=>{
|
||
w.projects.forEach(pr=>{
|
||
const isAnbahnung = pr.group === 'In Anbahnung';
|
||
const isKleine = pr.group === 'Kleine Projekte';
|
||
Object.keys(pr.assign).forEach(aKey=>{
|
||
if(!stats[aKey]) return;
|
||
if(isAnbahnung){
|
||
if(pr.assign[aKey]===1 || pr.assign[aKey]===2) stats[aKey].anbahnung++;
|
||
return;
|
||
}
|
||
const st = pr.assign[aKey];
|
||
if(isKleine){
|
||
if(st===1 || st===2){
|
||
stats[aKey].kleine = (stats[aKey].kleine||0) + 1;
|
||
const key = pr.name;
|
||
stats[aKey].projects[key]=(stats[aKey].projects[key]||0)+1;
|
||
}
|
||
return;
|
||
}
|
||
if(st===1) stats[aKey].main++;
|
||
else if(st===2) stats[aKey].opt++;
|
||
const key = pr.name;
|
||
stats[aKey].projects[key]=(stats[aKey].projects[key]||0)+1;
|
||
});
|
||
});
|
||
});
|
||
|
||
// Bestimme Maximalwert für Skalierung
|
||
let maxLoad=1;
|
||
Object.keys(stats).forEach(k=>{ const l=stats[k].main+stats[k].opt*0.5+(stats[k].kleine||0)*0.3; if(l>maxLoad)maxLoad=l; });
|
||
|
||
let html="";
|
||
ROLES.forEach(r=>{
|
||
html += `<div class="load-row"><div class="load-row-title">${r}</div>`;
|
||
if(grouped[r].length===0){
|
||
html += `<div class="hint">Keine Personen.</div>`;
|
||
}
|
||
grouped[r].forEach(entry=>{
|
||
const s = stats[assignKey(entry.person.id, r)];
|
||
const load = s.main + s.opt*0.5 + (s.kleine||0)*0.3;
|
||
const pct = Math.round(load/maxLoad*100);
|
||
const detail = Object.keys(s.projects).map(k=>`<div><span>${esc(k)}</span><b>${s.projects[k]}×</b></div>`).join('') || '<div class="hint">keine Zuordnung</div>';
|
||
const anbahnungBadge = (s.anbahnung && s.anbahnung>0) ? `<span style="font-size:11px;color:#3182ce;font-weight:normal;margin-left:0px">(${s.anbahnung} in Anbahnung)</span>` : '';
|
||
html += `<div class="load-card">
|
||
<h3>${esc(entry.person.fullName)} <span style="font-size:11px;color:#888;font-weight:normal">(${r})</span></h3>
|
||
<h3>${anbahnungBadge}</h3>
|
||
<div class="load-num">${load.toString().replace('.',',')}</div>
|
||
<div class="bar" style="height:10px;margin-top:6px"><div style="width:${pct}%;background:${pct>75?'#c53030':pct>45?'#d69e2e':'#2f855a'}"></div></div>
|
||
<div class="load-detail">${detail}</div>
|
||
</div>`;
|
||
});
|
||
html += `</div>`;
|
||
});
|
||
document.getElementById('loadGrid').innerHTML=html;
|
||
}
|
||
|
||
// ---------- GANTT / Projektplan ----------
|
||
|
||
let ganttModalProjectId = null;
|
||
|
||
function ensurePlan(p){
|
||
if(!p.plan) p.plan = { start:'', end:'', todos:[], milestones:[], people:[], progress:0 };
|
||
// Alte Meilensteine bleiben erhalten; neu sind todos die primären Planungselemente.
|
||
if(!Array.isArray(p.plan.todos)) p.plan.todos = [];
|
||
if(!Array.isArray(p.plan.milestones)) p.plan.milestones = [];
|
||
return p.plan;
|
||
}
|
||
|
||
// Beteiligte eines Projekts aus dem Board ableiten (Haupt + Optional)
|
||
// week optional: wenn die Funktion nicht im Kontext der aktuellen Hauptseite
|
||
// aufgerufen wird (z.B. Projektplan-Seite), muss die passende Woche übergeben
|
||
// werden, damit die Personenliste korrekt aufgelöst wird.
|
||
function projectAssignees(p, week = null){
|
||
const w = week || currentWeek();
|
||
const people = w ? (w.people || []) : [];
|
||
const globalList = db.globalPeople[currentDepartment] || [];
|
||
const result = [];
|
||
people.forEach(person => {
|
||
const globalPerson = globalList.find(gp => gp.id === person.id);
|
||
const email = globalPerson ? globalPerson.email : '';
|
||
|
||
(person.role || ['Planung']).forEach(role => {
|
||
const key = assignKey(person.id, role);
|
||
if(p.assign && p.assign[key]){
|
||
result.push({ person, role, state: p.assign[key], email});
|
||
}
|
||
});
|
||
});
|
||
return result;
|
||
}
|
||
|
||
function projectColor(p){
|
||
const idx = Math.abs(String(p.id).split('').reduce((a,c)=>a+c.charCodeAt(0),0)) % GANTT_COLORS.length;
|
||
return GANTT_COLORS[idx];
|
||
}
|
||
|
||
function ganttRangeFromProjects(projects, padDays = 7){
|
||
let min = null, max = null;
|
||
projects.forEach(p => {
|
||
const plan = p.plan;
|
||
if(!plan) return;
|
||
[plan.start, plan.end].forEach(d => {
|
||
const date = parseDateISO(d);
|
||
if(!date) return;
|
||
if(!min || date < min) min = date;
|
||
if(!max || date > max) max = date;
|
||
});
|
||
(plan.milestones || []).forEach(m => {
|
||
const date = parseDateISO(m.date);
|
||
if(!date) return;
|
||
if(!min || date < min) min = date;
|
||
if(!max || date > max) max = date;
|
||
});
|
||
(plan.todos || []).forEach(t => {
|
||
[t.start, t.end].forEach(d => {
|
||
const date = parseDateISO(d);
|
||
if(!date) return;
|
||
if(!min || date < min) min = date;
|
||
if(!max || date > max) max = date;
|
||
});
|
||
});
|
||
});
|
||
// Urlaub berücksichtigen
|
||
(db.globalPeople[currentDepartment] || []).forEach(person => {
|
||
(person.absences || []).forEach(a => {
|
||
const s = parseDateISO(a.start), e = parseDateISO(a.end);
|
||
if(s){ if(!min || s < min) min = s; if(!max || s > max) max = s; }
|
||
if(e){ if(!min || e < min) min = e; if(!max || e > max) max = e; }
|
||
});
|
||
});
|
||
if(!min || !max){
|
||
const today = new Date();
|
||
min = new Date(today.getFullYear(), today.getMonth(), 1);
|
||
max = addDays(min, 90);
|
||
}
|
||
return { start: addDays(min, -padDays), end: addDays(max, padDays) };
|
||
}
|
||
|
||
function fitGanttRange(){
|
||
const w = currentWeek(); if(!w) return;
|
||
const range = ganttRangeFromProjects(w.projects, 7);
|
||
document.getElementById('ganttStart').value = formatDateISO(range.start);
|
||
document.getElementById('ganttEnd').value = formatDateISO(range.end);
|
||
renderGantt();
|
||
}
|
||
|
||
function renderGantt(){
|
||
const w = currentWeek();
|
||
const container = document.getElementById('ganttContainer');
|
||
if(!w || !container){
|
||
if(container) container.innerHTML = '<div class="hint">Keine Sitzung ausgewählt.</div>';
|
||
return;
|
||
}
|
||
let start = parseDateISO(document.getElementById('ganttStart').value);
|
||
let end = parseDateISO(document.getElementById('ganttEnd').value);
|
||
if(!start || !end){
|
||
const range = ganttRangeFromProjects(w.projects, 7);
|
||
start = range.start; end = range.end;
|
||
document.getElementById('ganttStart').value = formatDateISO(start);
|
||
document.getElementById('ganttEnd').value = formatDateISO(end);
|
||
}
|
||
const showVacation = document.getElementById('ganttShowVacation').checked;
|
||
container.innerHTML = buildGanttHtml(w.projects, start, end, { showVacation, editable: !isWeekLocked(w) });
|
||
}
|
||
|
||
function buildGanttHtml(projects, start, end, options = {}){
|
||
const { showVacation = true, editable = false, highlightProjectId = null, pxPerDay = null } = options;
|
||
const totalDays = Math.max(1, daysBetween(start, end) + 1);
|
||
|
||
function timeline(content){
|
||
return `<div class="gantt-timeline">${content}</div>`;
|
||
}
|
||
|
||
function dragAttrs(type, attrs){
|
||
if(!editable) return '';
|
||
let s = ` data-gantt-type="${type}" draggable="false"`;
|
||
for(const [k,v] of Object.entries(attrs)){
|
||
s += ` data-gantt-${k}="${esc(String(v))}"`;
|
||
}
|
||
return s;
|
||
}
|
||
|
||
function resizeHandles(){
|
||
if(!editable) return '';
|
||
return `<span class="gantt-resize gantt-resize-left" data-resize="left"></span><span class="gantt-resize gantt-resize-right" data-resize="right"></span>`;
|
||
}
|
||
|
||
// Kopfzeile: Monate und Tage
|
||
let headerMonths = '<div class="gantt-row gantt-header">';
|
||
headerMonths += '<div class="gantt-label">Projekt</div>';
|
||
let monthsHtml = '';
|
||
let current = new Date(start.getTime());
|
||
let lastMonth = -1;
|
||
let monthCells = [];
|
||
for(let i = 0; i < totalDays; i++){
|
||
if(current.getMonth() !== lastMonth){
|
||
monthCells.push({ month: current.getMonth(), year: current.getFullYear(), startIdx: i });
|
||
lastMonth = current.getMonth();
|
||
}
|
||
current = addDays(current, 1);
|
||
}
|
||
monthCells.forEach((m, idx) => {
|
||
const next = monthCells[idx + 1];
|
||
const span = next ? next.startIdx - m.startIdx : totalDays - m.startIdx;
|
||
const names = ['Jan','Feb','Mär','Apr','Mai','Jun','Jul','Aug','Sep','Okt','Nov','Dez'];
|
||
monthsHtml += `<div class="gantt-month" style="left:${(m.startIdx/totalDays*100).toFixed(4)}%;width:${(span/totalDays*100).toFixed(4)}%">${names[m.month]} ${m.year}</div>`;
|
||
});
|
||
headerMonths += timeline(monthsHtml) + '</div>';
|
||
|
||
let headerWeeks = '<div class="gantt-row gantt-days">';
|
||
headerWeeks += '<div class="gantt-label"></div>';
|
||
let weeksHtml = '';
|
||
current = new Date(start.getTime());
|
||
let lastWeek = -1;
|
||
let lastYear = -1;
|
||
let weekStartIdx = 0;
|
||
const weekCells = [];
|
||
for(let i = 0; i < totalDays; i++){
|
||
const cw = getWeekNumber(current);
|
||
const cy = current.getFullYear();
|
||
if(cw !== lastWeek || cy !== lastYear){
|
||
if(lastWeek !== -1){
|
||
weekCells.push({ week: lastWeek, year: lastYear, startIdx: weekStartIdx, endIdx: i - 1 });
|
||
}
|
||
weekStartIdx = i;
|
||
lastWeek = cw;
|
||
lastYear = cy;
|
||
}
|
||
current = addDays(current, 1);
|
||
}
|
||
if(lastWeek !== -1){
|
||
weekCells.push({ week: lastWeek, year: lastYear, startIdx: weekStartIdx, endIdx: totalDays - 1 });
|
||
}
|
||
weekCells.forEach(w => {
|
||
const span = w.endIdx - w.startIdx + 1;
|
||
weeksHtml += `<div class="gantt-day gantt-week" style="left:${(w.startIdx/totalDays*100).toFixed(4)}%;width:${(span/totalDays*100).toFixed(4)}%"><span>KW ${w.week}</span></div>`;
|
||
});
|
||
headerWeeks += timeline(weeksHtml) + '</div>';
|
||
|
||
// Raster-Overlay: Wochenenden schattieren und Heute-Linie markieren.
|
||
// Liegt als eine Ebene hinter allen Zeilen (erste Chart-Kindnode),
|
||
// damit nicht jede Zeile eigene Tageszellen braucht.
|
||
let gridHtml = '<div class="gantt-grid-overlay">';
|
||
current = new Date(start.getTime());
|
||
let weekendStartIdx = null;
|
||
for(let i = 0; i <= totalDays; i++){
|
||
const isWeekend = i < totalDays && (current.getDay() === 0 || current.getDay() === 6);
|
||
if(isWeekend && weekendStartIdx === null) weekendStartIdx = i;
|
||
if(!isWeekend && weekendStartIdx !== null){
|
||
const span = i - weekendStartIdx;
|
||
gridHtml += `<div class="gantt-grid-weekend" style="left:${(weekendStartIdx/totalDays*100).toFixed(4)}%;width:${(span/totalDays*100).toFixed(4)}%"></div>`;
|
||
weekendStartIdx = null;
|
||
}
|
||
current = addDays(current, 1);
|
||
}
|
||
const today = new Date();
|
||
const todayMid = new Date(today.getFullYear(), today.getMonth(), today.getDate());
|
||
if(todayMid >= start && todayMid <= end){
|
||
const todayIdx = daysBetween(start, todayMid);
|
||
gridHtml += `<div class="gantt-grid-today" style="left:${((todayIdx + 0.5)/totalDays*100).toFixed(4)}%" title="Heute: ${formatDateDE(todayMid)}"></div>`;
|
||
}
|
||
gridHtml += '</div>';
|
||
|
||
let body = gridHtml + headerMonths + headerWeeks;
|
||
|
||
// Urlaubszeilen
|
||
if(showVacation){
|
||
let allPeople = db.globalPeople[currentDepartment] || [];
|
||
// Optional: nur Urlaub bestimmter Personen anzeigen
|
||
if(options.vacationPeople && Array.isArray(options.vacationPeople)){
|
||
const allowed = new Set(options.vacationPeople);
|
||
allPeople = allPeople.filter(person => allowed.has(person.id));
|
||
}
|
||
// Pro Person eine Zeile: alle Abwesenheiten als Balken in derselben Timeline
|
||
allPeople.forEach(person => {
|
||
let barsHtml = '';
|
||
(person.absences || []).forEach(a => {
|
||
const s = parseDateISO(a.start), e = parseDateISO(a.end);
|
||
if(!s || !e || e < start || s > end) return;
|
||
const rowStart = s < start ? start : s;
|
||
const rowEnd = e > end ? end : e;
|
||
const offset = daysBetween(start, rowStart);
|
||
const span = daysBetween(rowStart, rowEnd) + 1;
|
||
barsHtml += `<div class="gantt-bar vacation"${dragAttrs('vacation',{person: person.id, absence: a.id, start: formatDateISO(s), end: formatDateISO(e)})} style="left:${(offset/totalDays*100).toFixed(4)}%;width:${(span/totalDays*100).toFixed(4)}%" title="${esc(a.type || 'Abwesenheit')}: ${formatDateDE(s)}–${formatDateDE(e)}${a.note ? ' – ' + esc(a.note) : ''}">${resizeHandles()}</div>`;
|
||
});
|
||
if(!barsHtml) return; // keine Abwesenheit im sichtbaren Zeitraum -> keine Zeile
|
||
body += `<div class="gantt-row gantt-vacation">`;
|
||
body += `<div class="gantt-label" title="${esc(person.fullName || person.name)}"><span class="gantt-vacation-dot"></span> ${esc(person.name)}</div>`;
|
||
body += timeline(barsHtml);
|
||
body += `</div>`;
|
||
});
|
||
}
|
||
|
||
// Projektzeilen
|
||
projects.slice().sort((a,b) => (a.order||0)-(b.order||0)).forEach(p => {
|
||
const plan = p.plan;
|
||
const color = projectColor(p);
|
||
const milestones = plan && Array.isArray(plan.milestones) ? plan.milestones : [];
|
||
body += `<div class="gantt-row gantt-project${highlightProjectId === p.id ? ' highlighted' : ''}">`;
|
||
body += `<div class="gantt-label">${esc(p.nr ? p.nr + ' ' : '')}${esc(p.name)}</div>`;
|
||
|
||
let rowContent = '';
|
||
|
||
// Projekt-Balken
|
||
const s = plan ? parseDateISO(plan.start) : null, e = plan ? parseDateISO(plan.end) : null;
|
||
if(s && e){
|
||
const barStart = s < start ? start : s;
|
||
const barEnd = e > end ? end : e;
|
||
if(barEnd >= start && barStart <= end){
|
||
const offset = daysBetween(start, barStart);
|
||
const span = daysBetween(barStart, barEnd) + 1;
|
||
const progress = Math.max(0, Math.min(100, plan && parseFloat(plan.progress) ? parseFloat(plan.progress) : 0));
|
||
rowContent += `<div class="gantt-bar project"${dragAttrs('project',{project: p.id, start: formatDateISO(s), end: formatDateISO(e)})} style="left:${(offset/totalDays*100).toFixed(4)}%;width:${(span/totalDays*100).toFixed(4)}%;background:${color}">`;
|
||
rowContent += `${resizeHandles()}<div class="gantt-progress" style="width:${progress}%"></div>`;
|
||
rowContent += `<span class="gantt-bar-label">${esc(p.name)}</span>`;
|
||
rowContent += `</div>`;
|
||
}
|
||
}
|
||
|
||
// Meilensteine: nur die nicht positionierten (keine position) in der Projektzeile anzeigen.
|
||
// Positionierte Meilensteine werden vom Aufrufer (z.B. Projektplan) als separate Zeilen eingefügt.
|
||
milestones.forEach((m, mIdx) => {
|
||
if(options.showTodos && m.position) return;
|
||
const d = parseDateISO(m.date);
|
||
if(!d || d < start || d > end) return;
|
||
const offset = daysBetween(start, d);
|
||
rowContent += `<div class="gantt-milestone"${dragAttrs('milestone',{project: p.id, index: mIdx, date: formatDateISO(d)})} style="left:${(offset/totalDays*100).toFixed(4)}%" title="${esc(m.name)}: ${formatDateDE(d)}"></div>`;
|
||
rowContent += `<div class="gantt-milestone-label" style="left:${(offset/totalDays*100).toFixed(4)}%">${esc(m.name || '')}</div>`;
|
||
});
|
||
|
||
body += timeline(rowContent) + `</div>`;
|
||
});
|
||
|
||
// pxPerDay setzt eine feste Breite pro Tag, damit das Diagramm bei hohem
|
||
// Zoom breiter als der Container wird und horizontal gescrollt werden kann.
|
||
let chartStyle = '';
|
||
if(pxPerDay){
|
||
chartStyle = ` style="min-width:${(220 + Math.ceil(totalDays * pxPerDay)).toFixed(0)}px"`;
|
||
}
|
||
return `<div class="gantt-chart"${chartStyle}>${body}</div>`;
|
||
}
|
||
|
||
// ---------- Projektplan-Seite (TODOs + GANTT) ----------
|
||
|
||
function openProjectPlan(pid){
|
||
const w = currentWeek(); if(!w) return;
|
||
const p = w.projects.find(x => x.id === pid); if(!p) return;
|
||
// IDs sind nicht persistent (werden bei jedem CSV-Laden neu erzeugt).
|
||
// Daher werden stabile Werte (Label, Projekt-Nr., Name) in der URL übergeben.
|
||
const url = new URL('project-plan.html', window.location.href);
|
||
url.searchParams.set('department', currentDepartment);
|
||
url.searchParams.set('weekLabel', w.label);
|
||
url.searchParams.set('projNr', p.nr || '');
|
||
url.searchParams.set('projName', p.name || '');
|
||
window.open(url.toString(), '_blank');
|
||
}
|
||
|
||
// Diese Funktion wird von project-plan.html aufgerufen, um das aktuelle
|
||
// Wochen-/Projekt-Objekt im Hauptfenster zu finden.
|
||
function findProjectForPlan(department, weekId, projectId){
|
||
if(currentDepartment !== department){
|
||
const targetWeek = db.weeks.find(w => w.id === weekId);
|
||
if(!targetWeek) return null;
|
||
// temporär umschalten ist unpraktisch, daher versuchen wir über load-all
|
||
return null;
|
||
}
|
||
const w = db.weeks.find(x => x.id === weekId);
|
||
if(!w) return null;
|
||
return { week: w, project: w.projects.find(x => x.id === projectId) };
|
||
}
|
||
|
||
// ---------- Urlaub / Abwesenheiten ----------
|
||
|
||
function addAbsence(personId){
|
||
if(guardWeekLocked()) return;
|
||
const people = db.globalPeople[currentDepartment] || [];
|
||
const person = people.find(p => p.id === personId);
|
||
if(!person) return;
|
||
const start = document.getElementById('absenceStart_' + personId).value;
|
||
const end = document.getElementById('absenceEnd_' + personId).value;
|
||
const type = document.getElementById('absenceType_' + personId).value;
|
||
const note = document.getElementById('absenceNote_' + personId).value.trim();
|
||
if(!start || !end){ alert('Bitte Start- und Enddatum angeben.'); return; }
|
||
if(parseDateISO(end) < parseDateISO(start)){ alert('Enddatum liegt vor Startdatum.'); return; }
|
||
if(!Array.isArray(person.absences)) person.absences = [];
|
||
person.absences.push({ id: uid(), start, end, type, note });
|
||
person.absences.sort((a,b) => parseDateISO(a.start) - parseDateISO(b.start));
|
||
save();
|
||
renderPeople();
|
||
if(currentTab === 'gantt') renderGantt();
|
||
}
|
||
|
||
function deleteAbsence(personId, absenceId){
|
||
if(guardWeekLocked()) return;
|
||
const people = db.globalPeople[currentDepartment] || [];
|
||
const person = people.find(p => p.id === personId);
|
||
if(!person || !person.absences) return;
|
||
person.absences = person.absences.filter(a => a.id !== absenceId);
|
||
save();
|
||
renderPeople();
|
||
if(currentTab === 'gantt') renderGantt();
|
||
}
|
||
|
||
// ---------- Tabs (Navigation) ----------
|
||
|
||
// showTab: wechselt zwischen Ansichten; für sensible Tabs wird ein Passwort verlangt
|
||
function showTab(t, skipAuth = false){
|
||
// Für die Tabs 'projects' und 'people' ist ein Passwortschutz vorgesehen
|
||
if((t==='projects' || t==='people') && !skipAuth && !isAuthenticated){
|
||
requestedAction = { type: 'tab', tab: t };
|
||
document.getElementById('passwordModal').style.display='flex';
|
||
document.getElementById('passwordInput').value='';
|
||
document.getElementById('passwordInput').focus();
|
||
return;
|
||
}
|
||
currentTab = t;
|
||
saveUiState();
|
||
|
||
['board','projects','load','people'].forEach(x=>{
|
||
const el = document.getElementById(x);
|
||
if(el) el.style.display=(x===t?'block':'none');
|
||
});
|
||
document.querySelectorAll('.tab').forEach(el=>el.classList.toggle('active',el.dataset.tab===t));
|
||
if(t==='board' && currentWeek()) renderBoard();
|
||
if(t==='projects') renderProjects();
|
||
if(t==='load') renderLoad();
|
||
if(t==='people') renderPeople();
|
||
}
|
||
|
||
// ---------- Passwort-Schutz Implementierung ----------
|
||
|
||
// confirmPassword: prüft das eingegebene Passwort gegen den gespeicherten Hash
|
||
function confirmPassword(){
|
||
const pwd = document.getElementById('passwordInput').value;
|
||
if(!pwd) { alert("Bitte Passwort eingeben!"); return; }
|
||
|
||
const pwdHash = CryptoJS.SHA256(pwd).toString();
|
||
|
||
if(pwdHash === PROTECTED_PASSWORD_HASH){
|
||
const action = requestedAction;
|
||
requestedAction = null;
|
||
document.getElementById('passwordModal').style.display='none';
|
||
setTimeout(() => {
|
||
if(action?.type==='tab') showTab(action.tab, true);
|
||
else if(action?.type==='deleteWeek') deleteWeek(true);
|
||
else if(action?.type==='unlockWeek') unlockCurrentWeek(true);
|
||
}, 100);
|
||
} else {
|
||
alert("Falsches Passwort!");
|
||
document.getElementById('passwordInput').value='';
|
||
document.getElementById('passwordInput').focus();
|
||
}
|
||
}
|
||
function closePasswordModal(){
|
||
document.getElementById('passwordModal').style.display='none';
|
||
requestedAction = null;
|
||
}
|
||
|
||
// ---------- Export PDF ----------
|
||
|
||
// exportPDF: erzeugt ein PDF im Format A4 hochkant (Deckblatt) und im Querformat (Tabelle,
|
||
// Anwesenheit und Auslastung auf mehreren Seiten, falls nötig).
|
||
async function exportPDF(){
|
||
const { jsPDF } = window.jspdf;
|
||
if(!jsPDF || !window.html2canvas){
|
||
alert('PDF-Export nicht verfügbar. Bitte prüfen Sie die Internetverbindung (erforderlich für html2canvas / jsPDF).');
|
||
return;
|
||
}
|
||
const w = currentWeek();
|
||
if(!w) return;
|
||
|
||
// Temporären Container in unsichtbarem Bereich anlegen
|
||
let container = document.getElementById('pdfExportContainer');
|
||
if(container) container.remove();
|
||
container = document.createElement('div');
|
||
container.id = 'pdfExportContainer';
|
||
container.style.position = 'fixed';
|
||
container.style.top = '-10000px';
|
||
container.style.left = '0';
|
||
container.style.width = '1200px';
|
||
container.style.background = '#fff';
|
||
container.style.padding = '20px';
|
||
container.style.fontFamily = '-apple-system, BlinkMacSystemFont, "SF Pro Text", "Segoe UI", Roboto, Helvetica, Arial, sans-serif';
|
||
container.style.fontSize = '10px';
|
||
container.style.color = 'var(--text)';
|
||
document.body.appendChild(container);
|
||
|
||
const grouped = peopleByRole(w);
|
||
const allPeople = [];
|
||
ROLES.forEach(r => grouped[r].forEach(entry => allPeople.push(entry)));
|
||
|
||
// --- Teilnehmerlisten ---
|
||
const att = w.attendance || {};
|
||
const presentList = (w.people || []).filter(p => att[p.id]).map(p => `${esc(p.name)} (${esc(p.fullName || p.name)})`).join(', ');
|
||
const absentList = (w.people || []).filter(p => !att[p.id]).map(p => `${esc(p.name)} (${esc(p.fullName || p.name)})`).join(', ');
|
||
|
||
// Ico als Bild laden und in Base64 umwandeln, damit html2canvas es korrekt rendern kann
|
||
let logoDataUrl = null;
|
||
try {
|
||
const icoResponse = await fetch(baseUrl() + '/dein_bild.ico');
|
||
if(icoResponse.ok){
|
||
const icoBlob = await icoResponse.blob();
|
||
logoDataUrl = await blobToDataUrl(icoBlob);
|
||
}
|
||
} catch(e){
|
||
console.warn('Konnte Logo nicht laden:', e);
|
||
}
|
||
|
||
// Deckblatt: A4 hochkant, einfacher Titel und Übersicht
|
||
const cover = document.createElement('div');
|
||
cover.style.width = '595px';
|
||
cover.style.minHeight = '842px';
|
||
cover.style.padding = '80px 60px';
|
||
cover.style.boxSizing = 'border-box';
|
||
cover.style.background = '#fff';
|
||
cover.style.display = 'flex';
|
||
cover.style.flexDirection = 'column';
|
||
cover.style.justifyContent = 'center';
|
||
cover.style.textAlign = 'center';
|
||
cover.style.fontFamily = '-apple-system, BlinkMacSystemFont, "SF Pro Text", "Segoe UI", Roboto, Helvetica, Arial, sans-serif';
|
||
const logoHtml = logoDataUrl
|
||
? `<div style="margin-bottom:28px;"><img src="${logoDataUrl}" alt="Logo" style="width:200px; height:200px; object-fit:contain;"></div>`
|
||
: `<div style="font-size:80px; margin-bottom:28px;">⚡</div>`;
|
||
cover.innerHTML = `
|
||
${logoHtml}
|
||
<h1 style="font-size:32px; margin:0 0 16px 0; color:#1d1d1f;">Projektübersicht ${DEPARTMENTS[currentDepartment].label}</h1>
|
||
<div style="font-size:18px; color:#6e6e73; margin-bottom:48px;">${esc(w.label)}</div>
|
||
${w.date ? `<div style="font-size:14px; color:#6e6e73; margin-bottom:12px;">Datum: ${esc(formatWeekDate(w.date))}</div>` : ''}
|
||
<div style="font-size:12px; color:#6e6e73; margin-top:60px;">Erstellt am ${new Date().toLocaleDateString('de-DE')}</div>
|
||
`;
|
||
container.appendChild(cover);
|
||
|
||
// Inhaltsseiten: A4 quer (Container bleibt leer, Blöcke werden einzeln gerendert)
|
||
const content = document.createElement('div');
|
||
content.style.width = '1123px'; // A4 Quer bei 96 DPI
|
||
content.style.background = '#fff';
|
||
content.style.padding = '20px';
|
||
content.style.boxSizing = 'border-box';
|
||
content.innerHTML = buildPdfAttendanceBlock(presentList, absentList)
|
||
+ buildPdfLoadSection(allPeople);
|
||
container.appendChild(content);
|
||
|
||
const pdf = new jsPDF({ unit: 'px', format: 'a4' });
|
||
const pageWidthPx = pdf.internal.pageSize.getWidth();
|
||
const pageHeightPx = pdf.internal.pageSize.getHeight();
|
||
const marginPx = 32;
|
||
const landscapePageW = pageHeightPx - marginPx * 2;
|
||
const landscapePageH = pageWidthPx - marginPx * 2;
|
||
|
||
// JPEG quality (0.1 - 1.0) - lower = smaller file
|
||
const JPEG_QUALITY = 1;
|
||
|
||
async function addImageToLandscapePage(canvas){
|
||
const imgW = landscapePageW;
|
||
const imgH = (canvas.height * imgW) / canvas.width;
|
||
pdf.addPage([pageHeightPx, pageWidthPx], 'landscape');
|
||
pdf.addImage(canvas.toDataURL('image/jpeg', JPEG_QUALITY), 'JPEG', marginPx, marginPx, imgW, imgH);
|
||
}
|
||
|
||
async function drawBlockOnLandscapePage(blockHTML){
|
||
const wrapper = document.createElement('div');
|
||
wrapper.style.width = '1123px';
|
||
wrapper.style.padding = '20px';
|
||
wrapper.style.boxSizing = 'border-box';
|
||
wrapper.style.background = '#fff';
|
||
wrapper.innerHTML = blockHTML;
|
||
container.appendChild(wrapper);
|
||
const canvas = await html2canvas(wrapper, { scale: 1.5, useCORS: true, backgroundColor: '#ffffff', width: 1123 });
|
||
const imgH = (canvas.height * landscapePageW) / canvas.width;
|
||
wrapper.remove();
|
||
return { canvas, imgH };
|
||
}
|
||
|
||
try {
|
||
// Deckblatt (Hochkant) zeichnen
|
||
const coverCanvas = await html2canvas(cover, { scale: 1.5, useCORS: true, backgroundColor: '#ffffff', width: 595, height: 842 });
|
||
if(pdf.internal.getNumberOfPages() > 0) pdf.addPage();
|
||
const coverW = pageWidthPx - marginPx * 2;
|
||
const coverH = (coverCanvas.height * coverW) / coverCanvas.width;
|
||
const coverX = marginPx + (coverW - (coverCanvas.width * coverW / coverCanvas.width)) / 2;
|
||
pdf.addImage(coverCanvas.toDataURL('image/jpeg', JPEG_QUALITY), 'JPEG', marginPx, marginPx + ((pageHeightPx - marginPx * 2 - coverH) / 2), coverW, coverH);
|
||
|
||
const headerRows = buildPdfTableHeaderRows(allPeople);
|
||
|
||
// Projektgruppen vorbereiten
|
||
const groups = {};
|
||
w.projects.forEach(p => { (groups[p.group] || (groups[p.group] = [])).push(p); });
|
||
Object.keys(groups).forEach(g => groups[g].sort((a,b) => (a.order || 0) - (b.order || 0)));
|
||
|
||
// Anwesenheit und Auslastung als HTML-Strings vorbereiten
|
||
const extraBlocks = [buildPdfAttendanceBlock(presentList, absentList), buildPdfLoadSection(allPeople)];
|
||
|
||
// Hilfsfunktion: misst Höhe einer Tabelle mit gegebenen Zeilen
|
||
async function measureBlockWithRows(titleRow, bodyRows){
|
||
const wrapper = renderSinglePdfBlock(container, headerRows, titleRow, bodyRows);
|
||
const canvas = await html2canvas(wrapper, { scale: 1.5, useCORS: true, backgroundColor: '#ffffff', width: 1123 });
|
||
const imgH = (canvas.height * landscapePageW) / canvas.width;
|
||
wrapper.remove();
|
||
return { canvas, imgH };
|
||
}
|
||
|
||
const groupBlocks = [];
|
||
|
||
for(const groupName of sortGroups(Object.keys(groups))){
|
||
const projects = groups[groupName];
|
||
if(!projects || projects.length === 0) continue;
|
||
|
||
const titleRow = buildPdfGroupTitleRow(groupName, allPeople);
|
||
|
||
let currentRows = '';
|
||
let currentStart = 0;
|
||
|
||
for(let i = 0; i < projects.length; i++){
|
||
const rowHTML = buildPdfProjectRow(projects[i], allPeople);
|
||
// Alternating row background to match project board design
|
||
const rowBg = i % 2 === 0 ? ' style="background:#fafafc"' : '';
|
||
const rowHTMLWithBg = rowHTML.replace('<tr>', `<tr${rowBg}>`);
|
||
const testRows = currentRows + rowHTMLWithBg;
|
||
const { imgH: testImgH } = await measureBlockWithRows(titleRow, testRows);
|
||
|
||
// Wenn die aktuelle Seite überschritten würde und wir bereits Zeilen haben,
|
||
// schließen wir den aktuellen Block ab und beginnen auf der nächsten Seite neu.
|
||
if(testImgH > landscapePageH && currentRows !== ''){
|
||
groupBlocks.push({ titleRow, bodyRows: currentRows, isContinuation: currentStart > 0 });
|
||
currentRows = rowHTML;
|
||
currentStart = i;
|
||
} else {
|
||
currentRows = testRows;
|
||
}
|
||
}
|
||
if(currentRows !== ''){
|
||
groupBlocks.push({ titleRow, bodyRows: currentRows, isContinuation: currentStart > 0 });
|
||
}
|
||
}
|
||
|
||
// Projektboard-Blöcke zeichnen
|
||
for(const block of groupBlocks){
|
||
const wrapper = renderSinglePdfBlock(container, headerRows, block.titleRow, block.bodyRows);
|
||
const canvas = await html2canvas(wrapper, { scale: 1.5, useCORS: true, backgroundColor: '#ffffff', width: 1123 });
|
||
await addImageToLandscapePage(canvas);
|
||
wrapper.remove();
|
||
}
|
||
|
||
// Anwesenheit und Auslastung zeichnen
|
||
for(const blockHTML of extraBlocks){
|
||
const { canvas } = await drawBlockOnLandscapePage(blockHTML);
|
||
await addImageToLandscapePage(canvas);
|
||
}
|
||
|
||
// Erste (automatisch angelegte) leere Startseite entfernen, da Deckblatt manuell hinzugefügt wurde
|
||
if(pdf.internal.getNumberOfPages() > 1){
|
||
try { pdf.deletePage(1); } catch(e){}
|
||
}
|
||
|
||
const filename = 'Projektuebersicht_' + (w.label.replace(/[^\w]/g,'_')) + '.pdf';
|
||
pdf.save(filename);
|
||
} catch(err){
|
||
console.error('PDF-Export fehlgeschlagen:', err);
|
||
alert('PDF-Export fehlgeschlagen: ' + (err.message || err));
|
||
} finally {
|
||
container.remove();
|
||
}
|
||
}
|
||
|
||
// buildPdfTableHeaderRows: baut die Kopfzeilen der Projektübersichts-Tabelle als HTML-String (zwei Zeilen).
|
||
function buildPdfTableHeaderRows(allPeople){
|
||
let h1 = `<tr><td rowspan="2" style="border:1px solid #e8e8ed;background:#f0f0f2;color:#1d1d1f;padding:6px;font-weight:bold;">Pr.-Nr.</td><td rowspan="2" style="border:1px solid #e8e8ed;background:#f0f0f2;color:#1d1d1f;padding:6px;font-weight:bold;">Bezeichnung</td><td rowspan="2" style="border:1px solid #e8e8ed;background:#f0f0f2;color:#1d1d1f;padding:6px;font-weight:bold;">Status</td>`;
|
||
const grouped = peopleByRole(currentWeek());
|
||
ROLES.forEach(r => {
|
||
const count = Math.max(1, grouped[r].length);
|
||
// Rollen-spezifische Hintergrundfarben aus dem Projektboard
|
||
let roleBg;
|
||
if(r === 'Planung') roleBg = 'rgb(212, 245, 224)';
|
||
else if(r === 'Projektleitung') roleBg = 'rgb(255, 219, 219)';
|
||
else if(r === 'Bauüberwachung') roleBg = 'rgb(203, 238, 255)';
|
||
else roleBg = '#f0f0f2';
|
||
h1 += `<td class="role-header role-${r.toLowerCase()}" colspan="${count}" style="border:1px solid #e8e8ed;background:${roleBg};color:#1d1d1f;padding:6px;font-weight:bold;">${r}</td>`;
|
||
});
|
||
h1 += `<td colspan="${LPHS.length}" style="border:1px solid #e8e8ed;background:#f0f0f2;color:#1d1d1f;padding:6px;font-weight:bold;">Leistungsphasen (%)</td></tr>`;
|
||
|
||
let h2 = `<tr>`;
|
||
ROLES.forEach(r => {
|
||
let roleBg;
|
||
if(r === 'Planung') roleBg = 'rgb(212, 245, 224)';
|
||
else if(r === 'Projektleitung') roleBg = 'rgb(255, 219, 219)';
|
||
else if(r === 'Bauüberwachung') roleBg = 'rgb(203, 238, 255)';
|
||
else roleBg = '#fafafc';
|
||
if(grouped[r].length === 0){
|
||
h2 += `<td style="border:1px solid #e8e8ed;background:${roleBg};padding:4px;font-weight:bold;">–</td>`;
|
||
} else {
|
||
grouped[r].forEach(entry => {
|
||
h2 += `<td style="border:1px solid #e8e8ed;background:${roleBg};padding:4px;font-weight:bold;">${esc(entry.person.name)}</td>`;
|
||
});
|
||
}
|
||
});
|
||
LPHS.forEach(l => h2 += `<td style="border:1px solid #e8e8ed;background:#fafafc;padding:4px;font-weight:bold;">${l}</td>`);
|
||
h2 += `</tr>`;
|
||
return h1 + h2;
|
||
}
|
||
|
||
// buildPdfProjectRow: baut eine einzelne Projektzeile als HTML-String.
|
||
function buildPdfProjectRow(p, allPeople){
|
||
let body = `<tr>`;
|
||
body += `<td style="border:1px solid #e8e8ed;padding:4px;white-space:nowrap;">${esc(p.nr)}</td>`;
|
||
body += `<td style="border:1px solid #e8e8ed;padding:4px;">${esc(p.name)}</td>`;
|
||
const statusText = pdfStatusText(p.status);
|
||
body += `<td style="border:1px solid #e8e8ed;padding:4px;">${statusText}</td>`;
|
||
allPeople.forEach(entry => {
|
||
const s = p.assign[assignKey(entry.person.id, entry.role)] || 0;
|
||
const txt = s === 1 ? 'X' : s === 2 ? '(X)' : '';
|
||
let roleBg = '#fff';
|
||
if(entry.role === 'Planung') roleBg = 'rgb(212, 245, 224)';
|
||
else if(entry.role === 'Projektleitung') roleBg = 'rgb(255, 219, 219)';
|
||
else if(entry.role === 'Bauüberwachung') roleBg = 'rgb(203, 238, 255)';
|
||
const textColor = (s === 1 || s === 2) ? '#fff' : '#1d1d1f';
|
||
const cellBg = s === 1 ? '#248a3d' : s === 2 ? '#d18b00' : roleBg;
|
||
body += `<td style="border:1px solid #e8e8ed;padding:4px;text-align:center;background:${cellBg};color:${textColor};">${txt}</td>`;
|
||
});
|
||
LPHS.forEach(l => {
|
||
const v = (p.lph[l] !== undefined && p.lph[l] !== '') ? String(p.lph[l]).replace('%','') : '';
|
||
body += `<td style="border:1px solid #e8e8ed;padding:4px;text-align:center;">${esc(v ? v + '%' : '')}</td>`;
|
||
});
|
||
body += `</tr>`;
|
||
return body;
|
||
}
|
||
|
||
// Build PDF group body rows with alternating background colors
|
||
function buildPdfGroupBodyRows(projectRows, allPeople) {
|
||
let html = '';
|
||
projectRows.forEach((pr, i) => {
|
||
const s = pr.assign ? Object.values(pr.assign)[0] || 0 : 0;
|
||
const isEven = i % 2 === 0;
|
||
const rowBg = isEven ? '#fafafc' : '#fff';
|
||
html += `<tr style="background:${rowBg}">`;
|
||
html += `<td style="border:1px solid #e8e8ed;padding:4px;white-space:nowrap;">${esc(pr.nr)}</td>`;
|
||
html += `<td style="border:1px solid #e8e8ed;padding:4px;">${esc(pr.name)}</td>`;
|
||
const statusText = pdfStatusText(pr.status);
|
||
html += `<td style="border:1px solid #e8e8ed;padding:4px;">${statusText}</td>`;
|
||
allPeople.forEach(entry => {
|
||
const assignKey = `${entry.person.id}-${entry.role}`;
|
||
const assign = pr.assign ? pr.assign[assignKey] : 0;
|
||
const txt = assign === 1 ? 'X' : assign === 2 ? '(X)' : '';
|
||
const bg = assign === 1 ? '#248a3d' : assign === 2 ? '#d18b00' : '#fff';
|
||
html += `<td style="border:1px solid #e8e8ed;padding:4px;text-align:center;background:${bg};color:${assign===1||assign===2?'#fff':'#1d1d1f'}";">${txt}</td>`;
|
||
});
|
||
LPHS.forEach(l => {
|
||
const v = (pr.lph && pr.lph[l] !== undefined && pr.lph[l] !== '') ? String(pr.lph[l]).replace('%','') : '';
|
||
html += `<td style="border:1px solid #e8e8ed;padding:4px;text-align:center;">${esc(v ? v + '%' : '')}</td>`;
|
||
});
|
||
html += `</tr>`;
|
||
});
|
||
return html;
|
||
}
|
||
|
||
// buildPdfGroupTitleRow: baut die Zeile mit dem Gruppentitel.
|
||
function buildPdfGroupTitleRow(groupName, allPeople){
|
||
const colCount = 3 + allPeople.length + LPHS.length;
|
||
return `<tr><td colspan="${colCount}" style="border:1px solid #e8e8ed;background:#f0f0f2;font-weight:bold;padding:6px;color:#1d1d1f;">${esc(groupName)}</td></tr>`;
|
||
}
|
||
|
||
// renderSinglePdfBlock: erzeugt einen DOM-Wrapper mit einer einzelnen Tabelle
|
||
// für den PDF-Export (hilft bei der Höhenmessung).
|
||
function renderSinglePdfBlock(container, headerRows, titleRow, bodyRows){
|
||
const wrapper = document.createElement('div');
|
||
wrapper.style.width = '1123px';
|
||
wrapper.style.padding = '20px';
|
||
wrapper.style.boxSizing = 'border-box';
|
||
wrapper.style.background = '#fff';
|
||
wrapper.innerHTML = `<div class="pdf-block" style="margin-bottom:16px;"><table style="border-collapse:collapse;width:100%;"><tbody>${titleRow}${headerRows}${bodyRows}</tbody></table></div>`;
|
||
container.appendChild(wrapper);
|
||
return wrapper;
|
||
}
|
||
|
||
// buildPdfAttendanceBlock: baut den Anwesenheits-Block als HTML-String.
|
||
function buildPdfAttendanceBlock(presentList, absentList){
|
||
return `<div class="pdf-block" style="margin-bottom:16px;">
|
||
<div style="margin-bottom:8px; font-weight:bold; color:#1d1d1f; font-size:12px;">Anwesenheit</div>
|
||
<div style="margin-bottom:4px;"><b>Anwesend:</b> ${presentList || '—'}</div>
|
||
<div><b>Abwesend:</b> ${absentList || '—'}</div>
|
||
</div>`;
|
||
}
|
||
|
||
// pdfStatusText: konvertiert HTML-Status in reinen Text mit Zeilenumbrüchen.
|
||
function pdfStatusText(htmlStatus){
|
||
if(!htmlStatus) return '';
|
||
const div = document.createElement('div');
|
||
div.innerHTML = htmlStatus;
|
||
const text = (div.textContent || div.innerText || '').replace(/\u00A0/g, ' ').trim();
|
||
return esc(text);
|
||
}
|
||
|
||
// buildPdfLoadSection: baut die Auslastungsübersicht für das PDF.
|
||
function buildPdfLoadSection(allPeople){
|
||
const w = currentWeek();
|
||
const grouped = peopleByRole(w);
|
||
const stats = {};
|
||
ROLES.forEach(r => {
|
||
grouped[r].forEach(entry => {
|
||
stats[assignKey(entry.person.id, r)] = {main:0, opt:0, kleine:0, anbahnung:0, projects:{}};
|
||
});
|
||
});
|
||
|
||
w.projects.forEach(pr => {
|
||
const isAnbahnung = pr.group === 'In Anbahnung';
|
||
const isKleine = pr.group === 'Kleine Projekte';
|
||
Object.keys(pr.assign).forEach(aKey => {
|
||
if(!stats[aKey]) return;
|
||
if(isAnbahnung){
|
||
if(pr.assign[aKey] === 1 || pr.assign[aKey] === 2) stats[aKey].anbahnung++;
|
||
return;
|
||
}
|
||
const st = pr.assign[aKey];
|
||
if(isKleine){
|
||
if(st === 1 || st === 2){
|
||
stats[aKey].kleine++;
|
||
stats[aKey].projects[pr.name] = (stats[aKey].projects[pr.name] || 0) + 1;
|
||
}
|
||
return;
|
||
}
|
||
if(st === 1) stats[aKey].main++;
|
||
else if(st === 2) stats[aKey].opt++;
|
||
stats[aKey].projects[pr.name] = (stats[aKey].projects[pr.name] || 0) + 1;
|
||
});
|
||
});
|
||
|
||
let maxLoad = 1;
|
||
Object.keys(stats).forEach(k => {
|
||
const l = stats[k].main + stats[k].opt * 0.5 + stats[k].kleine * 0.3;
|
||
if(l > maxLoad) maxLoad = l;
|
||
});
|
||
|
||
let html = `<div class="pdf-block"><div style="margin-top:24px; margin-bottom:8px; font-weight:bold; color:#1d1d1f; font-size:12px;">Auslastung der Projektbeteiligten (aktuelle Sitzung)</div>`;
|
||
html += '<table style="border-collapse:collapse;width:100%;"><tbody><tr><td style="border:1px solid #e8e8ed;background:#f0f0f2;color:#1d1d1f;padding:6px;font-weight:bold;">Rolle</td><td style="border:1px solid #e8e8ed;background:#f0f0f2;color:#1d1d1f;padding:6px;font-weight:bold;">Kürzel</td><td style="border:1px solid #e8e8ed;background:#f0f0f2;color:#1d1d1f;padding:6px;font-weight:bold;">Punkte</td><td style="border:1px solid #e8e8ed;background:#f0f0f2;color:#1d1d1f;padding:6px;font-weight:bold;">Balken</td><td style="border:1px solid #e8e8ed;background:#f0f0f2;color:#1d1d1f;padding:6px;font-weight:bold;">Projektdetails</td></tr>';
|
||
|
||
ROLES.forEach(r => {
|
||
grouped[r].forEach(entry => {
|
||
const s = stats[assignKey(entry.person.id, r)];
|
||
const load = s.main + s.opt * 0.5 + s.kleine * 0.3;
|
||
const pct = Math.round(load / maxLoad * 100);
|
||
const color = pct > 75 ? '#d70015' : pct > 45 ? '#b25000' : '#248a3d';
|
||
const detail = Object.keys(s.projects).map(k => `${esc(k)} (${s.projects[k]}×)`).join(', ') || '—';
|
||
html += `<tr>
|
||
<td style="border:1px solid #e8e8ed;padding:4px;">${r}</td>
|
||
<td style="border:1px solid #e8e8ed;padding:4px;font-weight:bold;">${esc(entry.person.name)}</td>
|
||
<td style="border:1px solid #e8e8ed;padding:4px;text-align:center;">${load.toString().replace('.',',')}</td>
|
||
<td style="border:1px solid #e8e8ed;padding:4px;">
|
||
<div style="background:#f0f0f0;height:10px;border-radius:3px;overflow:hidden;">
|
||
<div style="width:${pct}%;background:${color};height:100%;"></div>
|
||
</div>
|
||
<div style="font-size:9px;color:#6e6e73;text-align:right;">${pct}%</div>
|
||
</td>
|
||
<td style="border:1px solid #e8e8ed;padding:4px;font-size:9px;">${detail}${s.anbahnung ? `<br><span style="color:#0071e3;">In Anbahnung: ${s.anbahnung}</span>` : ''}</td>
|
||
</tr>`;
|
||
});
|
||
});
|
||
|
||
html += '</tbody></table>';
|
||
html += '<div style="margin-top:4px;font-size:9px;color:#6e6e73;">Haupt=1 · Optional=0,5 · Kleine Projekte=0,3 · In Anbahnung wird nicht in der Punktzahl gezählt.</div>';
|
||
html += '</div>';
|
||
return html;
|
||
}
|
||
|
||
// sendCsvToServer: POSTet CSV an <baseUrl>/save-csv
|
||
function blobToDataUrl(blob){
|
||
return new Promise((resolve, reject) => {
|
||
const reader = new FileReader();
|
||
reader.onloadend = () => resolve(reader.result);
|
||
reader.onerror = reject;
|
||
reader.readAsDataURL(blob);
|
||
});
|
||
}
|
||
|
||
function sendCsvToServer(filename, csvContent){
|
||
if(!window.fetch) return Promise.reject(new Error('fetch nicht verfügbar'));
|
||
return fetch(baseUrl() + '/save-csv',{
|
||
method:'POST',
|
||
headers:{'Content-Type':'application/json; charset=utf-8'},
|
||
body:JSON.stringify({filename,csv:csvContent,department:currentDepartment})
|
||
}).then(r=>{ if(!r.ok) throw new Error('Server HTTP '+r.status); return r.json(); });
|
||
}
|
||
|
||
// ---------- Show Mode ----------
|
||
|
||
let showModeActive = false;
|
||
let showModeTimer = null;
|
||
let showModeResumeTimer = null;
|
||
let showModePaused = false;
|
||
let showModeOriginal = { department: DEFAULT_DEPARTMENT, tab: DEFAULT_TAB };
|
||
const SHOW_MODE_SLIDE_DURATION = 10000; // 10 Sekunden pro Slide
|
||
const SHOW_MODE_PAUSE_DURATION = 60000; // 1 Minute Pause
|
||
const SHOW_MODE_SEQUENCE = [
|
||
{ dept: 'ELT', view: 'board' },
|
||
{ dept: 'ELT', view: 'load' },
|
||
{ dept: 'HLS', view: 'board' },
|
||
{ dept: 'HLS', view: 'load' },
|
||
{ dept: 'ARCH', view: 'board' },
|
||
{ dept: 'ARCH', view: 'load' }
|
||
];
|
||
let showModeIndex = 0;
|
||
|
||
function startShowMode(){
|
||
if(showModeActive) return;
|
||
showModeActive = true;
|
||
showModePaused = false;
|
||
showModeOriginal = { department: currentDepartment, tab: currentTab };
|
||
showModeIndex = 0;
|
||
|
||
const overlay = document.getElementById('showModeOverlay');
|
||
if(overlay) overlay.style.display = 'flex';
|
||
|
||
// Menü schließen, falls offen
|
||
setHeaderCollapsed(true, false);
|
||
|
||
showModeNextSlide();
|
||
}
|
||
|
||
function stopShowMode(){
|
||
showModeActive = false;
|
||
showModePaused = false;
|
||
clearTimeout(showModeTimer);
|
||
clearTimeout(showModeResumeTimer);
|
||
|
||
const overlay = document.getElementById('showModeOverlay');
|
||
if(overlay) overlay.style.display = 'none';
|
||
|
||
// Ursprünglichen Zustand wiederherstellen
|
||
currentTab = showModeOriginal.tab;
|
||
if(currentDepartment !== showModeOriginal.department){
|
||
switchDepartment(showModeOriginal.department);
|
||
} else {
|
||
showTab(currentTab, true);
|
||
renderAll();
|
||
}
|
||
}
|
||
|
||
function pauseShowMode(){
|
||
if(!showModeActive) return;
|
||
if(showModePaused){
|
||
// bereits pausiert -> sofort fortsetzen
|
||
resumeShowMode();
|
||
return;
|
||
}
|
||
showModePaused = true;
|
||
clearTimeout(showModeTimer);
|
||
clearTimeout(showModeResumeTimer);
|
||
const pausedEl = document.getElementById('showModePaused');
|
||
if(pausedEl) pausedEl.style.display = 'block';
|
||
showModeResumeTimer = setTimeout(() => {
|
||
if(showModeActive && showModePaused) resumeShowMode();
|
||
}, SHOW_MODE_PAUSE_DURATION);
|
||
}
|
||
|
||
function resumeShowMode(){
|
||
if(!showModeActive) return;
|
||
showModePaused = false;
|
||
clearTimeout(showModeResumeTimer);
|
||
const pausedEl = document.getElementById('showModePaused');
|
||
if(pausedEl) pausedEl.style.display = 'none';
|
||
showModeNextSlide();
|
||
}
|
||
|
||
function showModeNextSlide(){
|
||
if(!showModeActive || showModePaused) return;
|
||
|
||
const slide = SHOW_MODE_SEQUENCE[showModeIndex % SHOW_MODE_SEQUENCE.length];
|
||
showModeIndex++;
|
||
|
||
const titleEl = document.getElementById('showModeTitle');
|
||
if(titleEl){
|
||
const deptLabel = DEPARTMENTS[slide.dept] ? DEPARTMENTS[slide.dept].label : slide.dept;
|
||
titleEl.textContent = `${deptLabel} – ${slide.view === 'load' ? 'Auslastung' : 'Projektboard'}`;
|
||
}
|
||
|
||
// Ladehinweis anzeigen, damit der alte Inhalt nicht kurz sichtbar flackert
|
||
const content = document.getElementById('showModeContent');
|
||
if(content){
|
||
content.innerHTML = '<div class="show-mode-loading">Lädt…</div>';
|
||
}
|
||
|
||
// Wechsle Department und View in der Hauptansicht, dann kopiere ins Overlay.
|
||
// Da switchDepartment asynchron lädt, müssen wir auf den Abschluss warten.
|
||
if(currentDepartment !== slide.dept){
|
||
// Temporär currentTab setzen, damit nach dem Laden der richtige Tab aktiv ist.
|
||
currentTab = slide.view;
|
||
switchDepartment(slide.dept);
|
||
// Warte bis Daten geladen sind, dann rendern und kopieren
|
||
waitForLoadThenRender(slide.view);
|
||
} else {
|
||
showTab(slide.view, true);
|
||
renderAll();
|
||
requestAnimationFrame(() => {
|
||
requestAnimationFrame(() => {
|
||
copyCurrentViewToShowMode();
|
||
scheduleNextShowModeSlide();
|
||
});
|
||
});
|
||
}
|
||
}
|
||
|
||
function waitForLoadThenRender(view){
|
||
let attempts = 0;
|
||
const check = setInterval(() => {
|
||
attempts++;
|
||
// Wenn Wochen geladen sind oder nach Seed gefallen ist
|
||
if(db.weeks.length > 0 || attempts > 30){
|
||
clearInterval(check);
|
||
showTab(view, true);
|
||
renderAll();
|
||
// Kurze Verzögerung, damit das DOM aktualisiert ist, bevor kopiert wird
|
||
requestAnimationFrame(() => {
|
||
requestAnimationFrame(() => {
|
||
copyCurrentViewToShowMode();
|
||
scheduleNextShowModeSlide();
|
||
});
|
||
});
|
||
}
|
||
}, 200);
|
||
}
|
||
|
||
function scheduleNextShowModeSlide(){
|
||
if(!showModeActive || showModePaused) return;
|
||
clearTimeout(showModeTimer);
|
||
showModeTimer = setTimeout(() => {
|
||
showModeNextSlide();
|
||
}, SHOW_MODE_SLIDE_DURATION);
|
||
}
|
||
|
||
function copyCurrentViewToShowMode(){
|
||
const content = document.getElementById('showModeContent');
|
||
const sourceId = currentTab === 'load' ? 'load' : 'board';
|
||
const source = document.getElementById(sourceId);
|
||
if(content && source){
|
||
content.innerHTML = source.innerHTML;
|
||
// Im Show Mode sollen in der Auslastung Überschrift und Steuerelemente ausgeblendet werden
|
||
if(currentTab === 'load'){
|
||
content.querySelectorAll('.section-title, .toolbar').forEach(el => el.remove());
|
||
}
|
||
}
|
||
}
|
||
|
||
// ESC beendet Show Mode
|
||
document.addEventListener('keydown', function(e){
|
||
if(showModeActive && e.key === 'Escape'){
|
||
stopShowMode();
|
||
}
|
||
});
|
||
|
||
|
||
// ---------- Init / Start ----------
|
||
|
||
// renderAll: Convenience um UI-Elemente zu initialisieren
|
||
function renderAll(){
|
||
refreshWeekSelect();
|
||
updateWeekDate();
|
||
renderBoard();
|
||
const activeTab = document.querySelector('.tab.active')?.dataset.tab || currentTab;
|
||
if(activeTab === 'load') renderLoad();
|
||
else if(activeTab === 'projects') renderProjects();
|
||
else if(activeTab === 'people') renderPeople();
|
||
}
|
||
|
||
function initUiState(){
|
||
loadUiState();
|
||
loadTheme();
|
||
loadHeaderState();
|
||
document.querySelectorAll('.dept-tab').forEach(btn => {
|
||
btn.classList.toggle('active', btn.dataset.dept === currentDepartment);
|
||
});
|
||
const info = DEPARTMENTS[currentDepartment];
|
||
document.title = 'Projektübersicht ' + info.label;
|
||
const titleEl = document.getElementById('pageTitle');
|
||
if(titleEl) titleEl.textContent = info.icon + ' Projektübersicht ' + info.label;
|
||
|
||
const tab = ['board','load','projects','people'].includes(currentTab) ? currentTab : DEFAULT_TAB;
|
||
// Beim initialen Start niemals einen passwortgeschützten Tab direkt anzeigen.
|
||
const safeTab = (tab === 'projects' || tab === 'people') ? DEFAULT_TAB : tab;
|
||
showTab(safeTab, true);
|
||
}
|
||
|
||
function syncRangeDisplay(displayId, startId, endId){
|
||
const disp = document.getElementById(displayId);
|
||
if(!disp) return;
|
||
const sEl = document.getElementById(startId);
|
||
const eEl = document.getElementById(endId);
|
||
const s = sEl ? parseDateISO(sEl.value) : null;
|
||
const e = eEl ? parseDateISO(eEl.value) : null;
|
||
const from = s ? formatDateDE(s) : '';
|
||
const to = e ? formatDateDE(e) : from;
|
||
disp.value = from ? (from + ' – ' + to) : '';
|
||
}
|
||
|
||
// Automatische Initialisierung nur auf der Hauptseite (Projektboard).
|
||
// Andere Seiten (z.B. Projektplan) laden main.v2.js nur für die Hilfsfunktionen.
|
||
if(document.getElementById('board')){
|
||
initUiState();
|
||
switchDepartment(currentDepartment);
|
||
}
|