3280 lines
126 KiB
JavaScript
3280 lines
126 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','Aufwand','Aktive Phasen']);
|
||
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) : '');
|
||
arr.push(p.aufwandProLP ? JSON.stringify(p.aufwandProLP) : '');
|
||
arr.push(p.aktuelleLP ? JSON.stringify(p.aktuelleLP) : '');
|
||
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 aufwandIdx = header.indexOf('Aufwand');
|
||
const aktiveIdx = header.indexOf('Aktive Phasen');
|
||
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);
|
||
}
|
||
}
|
||
|
||
let aufwandProLP;
|
||
if(aufwandIdx >= 0 && row[aufwandIdx]){
|
||
try {
|
||
const parsed = JSON.parse(row[aufwandIdx]);
|
||
if(parsed && typeof parsed === 'object') aufwandProLP = parsed;
|
||
} catch(e) {
|
||
console.warn('LP-Aufwand für Projekt konnten nicht geparst werden:', nr, name, e);
|
||
}
|
||
}
|
||
|
||
let aktuelleLP;
|
||
if(aktiveIdx >= 0 && row[aktiveIdx]){
|
||
try {
|
||
const parsed = JSON.parse(row[aktiveIdx]);
|
||
if(Array.isArray(parsed)) aktuelleLP = parsed;
|
||
else if(typeof parsed === 'string') aktuelleLP = [parsed];
|
||
} catch(e) {
|
||
console.warn('Aktive Phasen für Projekt konnten nicht geparst werden:', nr, name, e);
|
||
}
|
||
}
|
||
|
||
projects.push({ id: uid(), nr, name, status, group, lph, assign, order, plan, aufwandProLP, aktuelleLP });
|
||
}
|
||
|
||
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, aufwandProLP: undefined, aktuelleLP: undefined};
|
||
}
|
||
|
||
// ---------- 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;
|
||
// die amtierende Leistungsphase (Tab "Projekte") wird farblich markiert.
|
||
const aktLp = lpActiveList(p)[0];
|
||
LPHS.forEach(l=>{
|
||
const v = (p.lph[l]!==undefined && p.lph[l]!="") ? p.lph[l] : "";
|
||
const aktiv = l === aktLp;
|
||
const inputAttr = locked ? 'readonly tabindex="-1"' : `onchange="editLph('${p.id}','${l}',this.value)"`;
|
||
body += `<td class="lph-cell ${locked ? 'locked-cell' : ''}${aktiv ? ' lp-aktiv' : ''}"${aktiv ? ' title="Aktive Leistungsphase"' : ''}><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) ----------
|
||
|
||
// 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(); }
|
||
}
|
||
|
||
// editProjectActiveLP: setzt/hebt die amtierende Leistungsphase eines Projekts auf.
|
||
function editProjectActiveLP(id, value){
|
||
if(guardWeekLocked()) return;
|
||
const p = currentWeek().projects.find(x=>x.id===id);
|
||
if(!p) return;
|
||
if(value) p.aktuelleLP = [value];
|
||
else delete p.aktuelleLP;
|
||
save(); renderProjects(); renderBoard(); renderLoad();
|
||
}
|
||
|
||
// 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>
|
||
<select onchange="editProjectActiveLP('${p.id}',this.value)" title="Aktive Leistungsphase wählen">
|
||
<option value="" ${lpActiveList(p).length===0?'selected':''}>Aktive LP: keine</option>
|
||
${AUFWAND_LPS.map(l => `<option value="${l}" ${lpActiveList(p)[0]===l?'selected':''}>${l}</option>`).join('')}
|
||
</select>
|
||
<button class="secondary small" onclick="openLpModal('${p.id}')">✎ LP-Aufwand</button>
|
||
<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) ----------
|
||
|
||
// ---------- Aufwand je Leistungsphase (LP) ----------
|
||
//
|
||
// Datenmodell je Projekt (rollen- und leistungsphasenspezifisch):
|
||
// pr.aufwandProLP = {
|
||
// 'LPH 1': { Planung: 0.4, Projektleitung: 0.6, Bauüberwachung: 0.0 },
|
||
// ...
|
||
// } // Gewichtungsfaktor je Rolle (0.0–1.0)
|
||
// pr.aktuelleLP = ['LPH 6'] // aktuell amtierende Phase (max. 1)
|
||
//
|
||
// Es zählt genau eine amtierende Phase je Projekt ("Nur amtierende Einzelphase",
|
||
// Absprache mit dem Auftraggeber). Altdaten mit mehreren aktiven Phasen
|
||
// verwenden deterministisch die erste aktive Phase der Liste.
|
||
//
|
||
// Berechnungslogik:
|
||
// Auslastung(Mitarbeiter) = Σ Faktor der amtierenden LP je Projekt,
|
||
// in dem der Mitarbeiter (in seiner Rolle) zugewiesen ist.
|
||
// Der Faktor zählt 1:1 – die Zuordnungsart (Haupt/Optional) skaliert nicht.
|
||
// Leere Matrix-Felder zählen bei gesetzter aktiver LP mit dem Standard-Faktor
|
||
// 1,0; ohne gesetzte aktive LP zählt das Projekt 0 (nicht eingerechnet).
|
||
// "In Anbahnung" wird nicht eingerechnet.
|
||
//
|
||
// HINWEIS (Fachplaner-Fragen, bewusst NICHT eigenständig entschieden):
|
||
// - Ampel-Schwellen (50 % / 75 % des Maximalwerts) sind PLATZHALTER und müssen
|
||
// fachlich abgestimmt werden.
|
||
// - Mitarbeiterkapazität (Teilzeit/Vollzeit) existiert nicht – offener Punkt.
|
||
//
|
||
// AUFWAND_LPS: Phasen des Aufwandsmodells (LPH 1–8; "W+M" ist bewusst nicht
|
||
// enthalten, entspricht der Entscheidung "LP-Schema: lp1-8").
|
||
const AUFWAND_LPS = LPHS.slice(0, 8);
|
||
|
||
// LP_AUFWAND_VORLAGE: Standardwerte der Maske "Aufwand je Leistungsphase".
|
||
// Vorbefüllung beim Anlegen neuer Projekte sowie beim Öffnen der Maske für
|
||
// bestehende Projekte, die in einzelnen Feldern keine Werte haben – beim
|
||
// Speichern werden sie übernommen. Werte 0.0–1.0 je Rolle; 0 = kein Aufwand.
|
||
// Nicht gelistete Phasen/Rollen bleiben leer und zählen als Standard-Faktor 1,0.
|
||
// Hier im Code anpassen.
|
||
const LP_AUFWAND_VORLAGE = {
|
||
'LPH 1': { Planung: 0.4, Projektleitung: 0.2, Bauüberwachung: 0 },
|
||
'LPH 2': { Planung: 0.4, Projektleitung: 0.2, Bauüberwachung: 0 },
|
||
'LPH 3': { Planung: 1.0, Projektleitung: 0.3, Bauüberwachung: 0 },
|
||
'LPH 4': { Planung: 0.0, Projektleitung: 0.3, Bauüberwachung: 0 },
|
||
'LPH 5': { Planung: 1.0, Projektleitung: 0.4, Bauüberwachung: 0 },
|
||
'LPH 6': { Planung: 0.2, Projektleitung: 0.7, Bauüberwachung: 0.3 },
|
||
'LPH 7': { Planung: 0.1, Projektleitung: 0.3, Bauüberwachung: 0.5 },
|
||
'LPH 8': { Planung: 0, Projektleitung: 0.2, Bauüberwachung: 1.0 }
|
||
};
|
||
|
||
// PLATZHALTER! Ampel-Schwellen als Anteil des Maximalwerts LOAD_BAR_MAX
|
||
// (gelb ab 50 %, rot ab 75 %). Müssen vom Fachplaner bestätigt werden.
|
||
const THRESHOLD_RATIO_CRITICAL = 0.75;
|
||
const THRESHOLD_RATIO_ELEVATED = 0.50;
|
||
|
||
function lpActiveList(pr){
|
||
const a = pr && pr.aktuelleLP;
|
||
if(Array.isArray(a)) return a.filter(l => AUFWAND_LPS.includes(l));
|
||
if(typeof a === 'string' && AUFWAND_LPS.includes(a)) return [a];
|
||
return [];
|
||
}
|
||
|
||
// aufwandMatrixWert: Rollenwert eines Projekts für eine Leistungsphase
|
||
// (Gewichtungsfaktor, 0,0–1,0). Liefert null, wenn keine (rollenspezifische) Matrix
|
||
// bzw. kein Wert für diese Phase/Rolle hinterlegt ist. 0 ist ein gültiger Wert.
|
||
function aufwandMatrixWert(projekt, lp, rolle){
|
||
const m = projekt && projekt.aufwandProLP;
|
||
const entry = (m && typeof m === 'object') ? m[lp] : undefined;
|
||
if(!entry || typeof entry !== 'object' || Array.isArray(entry)) return null;
|
||
const v = parseFloat(entry[rolle]);
|
||
return isNaN(v) ? null : v;
|
||
}
|
||
|
||
// berechneAuslastung: zentrale Auslastungsberechnung einer Zuordnung
|
||
// (Person/Rolle) zu einem Projekt für die amtierende Leistungsphase.
|
||
// Ohne gesetzte aktive LP zählt das Projekt 0 (nicht eingerechnet).
|
||
// Mit aktiver LP: Liegt aufwandProLP[aktuelleLP][rolle] vor, zählt dieser
|
||
// Faktor 1:1 – die Zuordnungsart (Haupt/Optional) skaliert bewusst nicht mehr.
|
||
// Fehlt der Matrix-Wert, gilt der Standard-Faktor 1,0.
|
||
// "In Anbahnung" wird nicht eingerechnet.
|
||
function berechneAuslastung(mitarbeiter, projekt, aktuelleLP, rolle, status){
|
||
if(!projekt || projekt.group === 'In Anbahnung') return { load: 0, anbahnung: true };
|
||
if(!aktuelleLP) return { load: 0 };
|
||
const wert = aufwandMatrixWert(projekt, aktuelleLP, rolle);
|
||
if(wert !== null) return { load: wert };
|
||
return { load: 1.0 };
|
||
}
|
||
|
||
// faktorFormat: formatiert einen Auslastungs-Faktor in deutscher Notation
|
||
// (Komma, max. 2 Nachkommastellen), z. B. 1.1 → "1,10". 1,0 = 100 % Kapazität.
|
||
function faktorFormat(x){
|
||
const n = Math.round((x || 0) * 100) / 100;
|
||
return n.toFixed(2).replace('.', ',');
|
||
}
|
||
|
||
// loadAmpelColor: Ampel-Farbe relativ zum Maximalwert (gelb ab 50 %,
|
||
// rot ab 75 % von LOAD_BAR_MAX; PLATZHALTER).
|
||
function loadAmpelColor(x){
|
||
return x >= LOAD_BAR_MAX * THRESHOLD_RATIO_CRITICAL ? '#c53030' : x >= LOAD_BAR_MAX * THRESHOLD_RATIO_ELEVATED ? '#d69e2e' : '#2f855a';
|
||
}
|
||
function pdfLoadAmpelColor(x){
|
||
return x >= LOAD_BAR_MAX * THRESHOLD_RATIO_CRITICAL ? '#D70015' : x >= LOAD_BAR_MAX * THRESHOLD_RATIO_ELEVATED ? '#B25000' : '#248A3D';
|
||
}
|
||
|
||
// ---------- LP-Aufwand Eingabemaske ----------
|
||
|
||
let lpModalProjectId = null;
|
||
|
||
// lpRowsHtml: erzeugt die Aufwand-Maske als Matrix-Tabelle.
|
||
// Zeilen = Leistungsphasen (LPH 1–8), Spalten = Rollen (Planung/Projektleitung/
|
||
// Bauüberwachung). Die amtierende Leistungsphase wird pro Projekt im Tab
|
||
// "Projekte" gewählt (renderProjects / editProjectActiveLP).
|
||
// prefix: 'lp' = Bearbeiten-Modal, 'np' = Neues-Projekt-Modal (eindeutige IDs).
|
||
// Leere Felder werden mit den Standardwerten aus LP_AUFWAND_VORLAGE vorbefüllt.
|
||
// Die Feld-IDs bleiben identisch, damit collectLpRows() unverändert funkioniert.
|
||
function lpRowsHtml(p, prefix){
|
||
const head = ROLES.map(r => `<th>${esc(r)}</th>`).join('');
|
||
const rows = AUFWAND_LPS.map(l => {
|
||
const idx = l.replace(/[^\d]/g,'');
|
||
const row = (p.aufwandProLP && typeof p.aufwandProLP[l] === 'object') ? p.aufwandProLP[l] : {};
|
||
const cells = ROLES.map((r, ri) => {
|
||
const raw = row[r];
|
||
const vorlage = LP_AUFWAND_VORLAGE && LP_AUFWAND_VORLAGE[l] ? LP_AUFWAND_VORLAGE[l][r] : undefined;
|
||
const base = (raw !== undefined && raw !== null && raw !== '') ? raw
|
||
: (vorlage !== undefined && vorlage !== null && vorlage !== '') ? vorlage : '';
|
||
const val = base !== '' ? String(parseFloat(base)).replace('.', ',') : '';
|
||
return `<td><input type="text" inputmode="decimal" id="${prefix}Val${idx}R${ri}" value="${val}" placeholder="1,0"></td>`;
|
||
}).join('');
|
||
return `<tr><th class="lp-lph">${l}</th>${cells}</tr>`;
|
||
}).join('');
|
||
return `<table class="lp-matrix">
|
||
<thead><tr><th class="lp-lph">Leistungsphase</th>${head}</tr></thead>
|
||
<tbody>${rows}</tbody>
|
||
</table>`;
|
||
}
|
||
|
||
// collectLpRows: liest die Aufwand-Maske aus und validiert die Werte (0–1).
|
||
// Rückgabe: { aufwand, hasValue } oder null bei Eingabefehler.
|
||
function collectLpRows(prefix){
|
||
const aufwand = {};
|
||
let hasValue = false;
|
||
for(const l of AUFWAND_LPS){
|
||
const idx = l.replace(/[^\d]/g,'');
|
||
const entry = {};
|
||
let rowHasValue = false;
|
||
let invalid = false;
|
||
for(let ri = 0; ri < ROLES.length; ri++){
|
||
const r = ROLES[ri];
|
||
const input = document.getElementById(prefix + 'Val' + idx + 'R' + ri);
|
||
if(!input) continue;
|
||
const raw = input.value.trim().replace(',', '.');
|
||
if(raw === '') continue;
|
||
const v = parseFloat(raw);
|
||
if(isNaN(v) || v < 0 || v > 1){
|
||
alert('Bitte Werte zwischen 0 und 1 eintragen (z. B. 0,4) – oder das Feld leer lassen. (' + l + ', ' + r + ')');
|
||
invalid = true;
|
||
break;
|
||
}
|
||
entry[r] = v;
|
||
rowHasValue = true;
|
||
}
|
||
if(invalid) return null;
|
||
if(rowHasValue){ aufwand[l] = entry; hasValue = true; }
|
||
}
|
||
return { aufwand, hasValue };
|
||
}
|
||
|
||
// applyLpRows: übernimmt das Ergebnis von collectLpRows auf ein Projekt.
|
||
// Die Maske ist mit den Standardwerten (LP_AUFWAND_VORLAGE) vorbefüllt;
|
||
// Felder ohne Vorbefüllung, die leer bleiben, zählen bei gesetzter aktiver LP
|
||
// später als Standard-Faktor 1,0; ohne aktive LP zählt das Projekt 0.
|
||
// Die amtierende Leistungsphase wird separat über den Tab "Projekte" gesetzt.
|
||
function applyLpRows(p, lp){
|
||
if(!lp) return false;
|
||
p.aufwandProLP = lp.hasValue ? lp.aufwand : undefined;
|
||
return true;
|
||
}
|
||
|
||
function openLpModal(pid){
|
||
if(guardWeekLocked()) return;
|
||
const p = currentWeek().projects.find(x=>x.id===pid);
|
||
if(!p) return;
|
||
lpModalProjectId = pid;
|
||
const titleEl = document.getElementById('lpModalProject');
|
||
if(titleEl) titleEl.textContent = (p.nr ? p.nr + ' – ' : '') + (p.name || '');
|
||
const rowsEl = document.getElementById('lpModalRows');
|
||
if(rowsEl) rowsEl.innerHTML = lpRowsHtml(p, 'lp');
|
||
document.getElementById('lpModal').style.display = 'flex';
|
||
}
|
||
|
||
function closeLpModal(){
|
||
document.getElementById('lpModal').style.display = 'none';
|
||
lpModalProjectId = null;
|
||
}
|
||
|
||
function saveLpModal(){
|
||
if(guardWeekLocked()) return;
|
||
const p = currentWeek().projects.find(x=>x.id===lpModalProjectId);
|
||
if(!p) return;
|
||
const lp = collectLpRows('lp');
|
||
if(lp === null) return;
|
||
if(applyLpRows(p, lp)) save();
|
||
renderBoard(); renderProjects(); renderLoad();
|
||
closeLpModal();
|
||
}
|
||
|
||
// ---------- Neues Projekt (mit LP-Aufwand beim Anlegen) ----------
|
||
|
||
function openNewProjModal(){
|
||
if(guardWeekLocked()) return;
|
||
document.getElementById('newProjNr').value = '';
|
||
document.getElementById('newProjName').value = '';
|
||
document.getElementById('newProjGroup').value = 'Aktive Projekte';
|
||
const lpSel = document.getElementById('newProjActiveLP');
|
||
if(lpSel){
|
||
lpSel.innerHTML = '<option value="">Keine</option>' + AUFWAND_LPS.map(l => `<option value="${l}">${l}</option>`).join('');
|
||
lpSel.value = '';
|
||
}
|
||
const rowsEl = document.getElementById('newProjLpRows');
|
||
if(rowsEl) rowsEl.innerHTML = lpRowsHtml({ aufwandProLP: {}, aktuelleLP: [] }, 'np');
|
||
document.getElementById('newProjModal').style.display = 'flex';
|
||
}
|
||
|
||
function closeNewProjModal(){
|
||
document.getElementById('newProjModal').style.display = 'none';
|
||
}
|
||
|
||
function saveNewProj(){
|
||
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 activeLP = document.getElementById('newProjActiveLP').value;
|
||
const lp = collectLpRows('np');
|
||
if(lp === null) return;
|
||
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;
|
||
const pr = {id:uid(),nr,name,group,status:'',lph:{},assign:{}, statusState: undefined, order: nextOrder, plan: null};
|
||
if(activeLP) pr.aktuelleLP = [activeLP];
|
||
applyLpRows(pr, lp);
|
||
w.projects.push(pr);
|
||
save(); renderProjects(); renderBoard();
|
||
closeNewProjModal();
|
||
}
|
||
|
||
// LOAD_BAR_MAX: Maximalwert der Balken-Skala im Auslastungs-Tab (und im
|
||
// PDF-Export). 1,0 = 100 % Kapazität; Werte > 1,0 = Überlastung (rot).
|
||
const LOAD_BAR_MAX = 8;
|
||
|
||
// renderLoad: berechnet die Belastung der Personen auf Basis des
|
||
// rollenspezifischen LP-Gewichtungsfaktors je amtierender Phase (0,0–1,0,
|
||
// Standard 1,0). Leere Matrix-Werte zählen bei gesetzter aktiver LP als 1,0;
|
||
// ohne gesetzte aktive LP zählt das Projekt 0 (nicht eingerechnet).
|
||
// Ampel: Schwellen relativ zum Maximalwert LOAD_BAR_MAX
|
||
// (gelb ab 50 %, rot ab 75 % – PLATZHALTER).
|
||
// Balken: Skala auf das feste Maximum LOAD_BAR_MAX (Standard 8).
|
||
function renderLoad(){
|
||
const scope = document.getElementById('loadScope').value;
|
||
const barMax = LOAD_BAR_MAX;
|
||
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)] = {load:0,projects:{},anbahnung:0};
|
||
});
|
||
});
|
||
|
||
weeks.forEach(w=>{
|
||
w.projects.forEach(pr=>{
|
||
const aktivePhase = lpActiveList(pr)[0];
|
||
Object.keys(pr.assign).forEach(aKey=>{
|
||
const s = stats[aKey];
|
||
if(!s) return;
|
||
const st = pr.assign[aKey];
|
||
const rolle = aKey.slice(aKey.indexOf('|') + 1);
|
||
const al = berechneAuslastung(null, pr, aktivePhase, rolle, st);
|
||
if(al.anbahnung){
|
||
if(st===1 || st===2) s.anbahnung++;
|
||
return;
|
||
}
|
||
if(al.load > 0){
|
||
s.load += al.load;
|
||
s.projects[pr.name] = (s.projects[pr.name]||0)+1;
|
||
}
|
||
});
|
||
});
|
||
});
|
||
|
||
// Hochgerechnete Auslastung: durchschnittliche Wochenlast als Faktor
|
||
// der Kapazität (1,0 = 100 %). Werte > 1,0 = Überlastung.
|
||
const nWeeks = Math.max(1, weeks.length);
|
||
const weekAvg = k => stats[k].load / nWeeks;
|
||
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 avg = weekAvg(assignKey(entry.person.id, r));
|
||
// Balken relativ zur einstellbaren Skala (Standard 8); Überlastung > 1,0
|
||
// bleibt über die Ampel-Farbe sichtbar, der Zahlenwert bleibt ungedeckelt.
|
||
const pct = Math.min(100, Math.round(avg / barMax * 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">${faktorFormat(avg)}</div>
|
||
<div class="bar" style="height:10px;margin-top:6px" title="Skala: 0 – ${faktorFormat(barMax)}"><div style="width:${pct}%;background:${loadAmpelColor(avg)}"></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 (pdfmake) ----------
|
||
// Erzeugt ein Vektor-PDF: Deckblatt, Projektübersicht nach Gruppen,
|
||
// Anwesenheit und Auslastung. Format durchgehend A4 quer.
|
||
// Ersetzt die frühere jsPDF/html2canvas-Rasterlösung.
|
||
|
||
const PDF_ROLE_BG = {
|
||
'Planung': '#D4F5E0',
|
||
'Projektleitung': '#FFDBDB',
|
||
'Bauüberwachung': '#CBEEFF'
|
||
};
|
||
const PDF_NEUTRAL_BG = '#F0F0F2';
|
||
const PDF_ALT_ROW_BG = '#FAFAFC';
|
||
const PDF_BORDER = '#a6a6ad';
|
||
const PDF_ASSIGN_MAIN = '#248A3D';
|
||
const PDF_ASSIGN_OPT = '#D18B00';
|
||
|
||
|
||
// --- Seitengeometrie (A3 quer, in pt) ---
|
||
const PDF_PAGE_W = 1190.55; // A3 quer, vorher A4: 841.89
|
||
const PDF_MARGIN_X = 20;
|
||
const PDF_PAD_X = 2;
|
||
const PDF_LINE_W = 0.8;
|
||
const PDF_USABLE_W = PDF_PAGE_W - PDF_MARGIN_X * 2; // 1150,55
|
||
|
||
|
||
const PDF_STYLES = {
|
||
h1: { fontSize: 20, bold: true, color: '#1D1D1F' },
|
||
h2: { fontSize: 10, bold: true, color: '#1D1D1F' },
|
||
th: { fontSize: 7, bold: true, color: '#1D1D1F', fillColor: PDF_NEUTRAL_BG },
|
||
thSub: { fontSize: 6.5, bold: true, color: '#1D1D1F' },
|
||
td: { fontSize: 7, color: '#1D1D1F' },
|
||
tdCenter: { fontSize: 7, color: '#1D1D1F', alignment: 'center' }
|
||
};
|
||
|
||
function pdfRoleBg(role, fallback){
|
||
return PDF_ROLE_BG[role] || fallback || '#FFFFFF';
|
||
}
|
||
|
||
function pdfTableLayout(){
|
||
return {
|
||
hLineWidth: () => PDF_LINE_W,
|
||
vLineWidth: () => PDF_LINE_W,
|
||
hLineColor: () => PDF_BORDER,
|
||
vLineColor: () => PDF_BORDER,
|
||
paddingLeft: () => PDF_PAD_X,
|
||
paddingRight: () => PDF_PAD_X,
|
||
paddingTop: () => 2,
|
||
paddingBottom: () => 2
|
||
};
|
||
}
|
||
|
||
// pdfStatusText: wandelt das Status-HTML in eine pdfmake-Textstruktur um.
|
||
// Erhält Zeilenumbrüche (<br>, Block-Ende von <p>/<div>) sowie die vom
|
||
// Sanitizing erlaubten Auszeichnungen (bold, italic, underline, color).
|
||
// Rückgabe: Objekt mit text-Array, direkt als Zellinhalt verwendbar.
|
||
function pdfStatusText(htmlStatus){
|
||
if(!htmlStatus) return { text: '', style: 'td' };
|
||
|
||
const root = document.createElement('div');
|
||
root.innerHTML = htmlStatus;
|
||
|
||
const parts = [];
|
||
|
||
// pushNewline: fügt einen Umbruch ein, vermeidet aber führende
|
||
// und doppelte Leerzeilen.
|
||
function pushNewline(){
|
||
if(parts.length === 0) return;
|
||
const last = parts[parts.length - 1];
|
||
if(typeof last.text === 'string' && last.text.endsWith('\n')) return;
|
||
parts.push({ text: '\n' });
|
||
}
|
||
|
||
const BLOCK = new Set(['P', 'DIV']);
|
||
|
||
function walk(node, inh){
|
||
for(const child of node.childNodes){
|
||
|
||
// Textknoten
|
||
if(child.nodeType === 3){
|
||
// Nur Zeichen normalisieren, die im HTML keine Bedeutung haben.
|
||
// Echte Umbrüche kommen ausschliesslich aus BR/Blockenden.
|
||
const t = child.nodeValue
|
||
.replace(/\u00A0/g, ' ')
|
||
.replace(/[\r\n\t]+/g, ' ');
|
||
if(t === '') continue;
|
||
// Reine Whitespace-Knoten zwischen Blöcken überspringen
|
||
if(/^\s+$/.test(t) && (parts.length === 0
|
||
|| (typeof parts[parts.length-1].text === 'string'
|
||
&& parts[parts.length-1].text.endsWith('\n')))) continue;
|
||
parts.push(Object.assign({ text: t }, inh));
|
||
continue;
|
||
}
|
||
|
||
if(child.nodeType !== 1) continue;
|
||
|
||
const tag = child.tagName.toUpperCase();
|
||
|
||
if(tag === 'BR'){
|
||
parts.push({ text: '\n' });
|
||
continue;
|
||
}
|
||
|
||
// Auszeichnungen vererben
|
||
const own = Object.assign({}, inh);
|
||
if(tag === 'B' || tag === 'STRONG') own.bold = true;
|
||
if(tag === 'I' || tag === 'EM') own.italics = true;
|
||
if(tag === 'U') own.decoration = 'underline';
|
||
|
||
// Farbe / Fettung aus style bzw. font color
|
||
const st = child.getAttribute && child.getAttribute('style');
|
||
if(st){
|
||
const mCol = st.match(/(?:^|;)\s*color\s*:\s*([^;]+)/i);
|
||
if(mCol){
|
||
const c = pdfNormalizeColor(mCol[1].trim());
|
||
if(c) own.color = c;
|
||
}
|
||
const mFw = st.match(/(?:^|;)\s*font-weight\s*:\s*([^;]+)/i);
|
||
if(mFw){
|
||
const v = mFw[1].trim().toLowerCase();
|
||
if(v === 'bold' || v === 'bolder' || parseInt(v, 10) >= 600) own.bold = true;
|
||
if(v === 'normal' || parseInt(v, 10) < 600) own.bold = false;
|
||
}
|
||
}
|
||
if(tag === 'FONT' && child.getAttribute('color')){
|
||
const c = pdfNormalizeColor(child.getAttribute('color'));
|
||
if(c) own.color = c;
|
||
}
|
||
|
||
if(BLOCK.has(tag)){
|
||
pushNewline();
|
||
walk(child, own);
|
||
pushNewline();
|
||
} else {
|
||
walk(child, own);
|
||
}
|
||
}
|
||
}
|
||
|
||
walk(root, {});
|
||
|
||
// Führende und schliessende Umbrüche entfernen
|
||
while(parts.length && parts[0].text === '\n') parts.shift();
|
||
while(parts.length && parts[parts.length-1].text === '\n') parts.pop();
|
||
|
||
if(parts.length === 0) return { text: '', style: 'td' };
|
||
return { text: parts, style: 'td' };
|
||
}
|
||
|
||
// pdfNormalizeColor: pdfmake akzeptiert Hex und benannte Farben,
|
||
// aber kein rgb(). Wandelt rgb()/rgba() in Hex um.
|
||
function pdfNormalizeColor(v){
|
||
if(!v) return null;
|
||
v = String(v).trim();
|
||
const m = v.match(/^rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/i);
|
||
if(m){
|
||
const hex = n => Math.max(0, Math.min(255, parseInt(n,10)))
|
||
.toString(16).padStart(2,'0');
|
||
return '#' + hex(m[1]) + hex(m[2]) + hex(m[3]);
|
||
}
|
||
if(/^#[0-9a-f]{3}$/i.test(v)){
|
||
return '#' + v[1]+v[1] + v[2]+v[2] + v[3]+v[3];
|
||
}
|
||
if(/^#[0-9a-f]{6}$/i.test(v)) return v;
|
||
// Benannte Farben durchlassen (pdfmake kennt die CSS-Namen)
|
||
if(/^[a-z]+$/i.test(v)) return v.toLowerCase();
|
||
return null;
|
||
}
|
||
|
||
// loadLogoPng: lädt dein_bild.ico und konvertiert es via Canvas nach PNG.
|
||
// pdfmake unterstützt nur JPEG und PNG, kein ICO.
|
||
// Gibt null zurück, wenn Laden oder Dekodieren fehlschlägt.
|
||
async function loadLogoPng(size){
|
||
size = size || 256;
|
||
try {
|
||
const res = await fetch(baseUrl() + '/dein_bild.ico');
|
||
if(!res.ok) return null;
|
||
const blob = await res.blob();
|
||
const url = URL.createObjectURL(blob);
|
||
try {
|
||
const img = await new Promise((resolve, reject) => {
|
||
const i = new Image();
|
||
i.onload = () => resolve(i);
|
||
i.onerror = () => reject(new Error('Icon nicht dekodierbar'));
|
||
i.src = url;
|
||
});
|
||
const cv = document.createElement('canvas');
|
||
cv.width = cv.height = size;
|
||
const ctx = cv.getContext('2d');
|
||
const scale = Math.min(size / img.width, size / img.height);
|
||
const dw = img.width * scale, dh = img.height * scale;
|
||
ctx.drawImage(img, (size - dw) / 2, (size - dh) / 2, dw, dh);
|
||
return cv.toDataURL('image/png');
|
||
} finally {
|
||
URL.revokeObjectURL(url);
|
||
}
|
||
} catch(e){
|
||
console.warn('Logo nicht ladbar:', e);
|
||
return null;
|
||
}
|
||
}
|
||
|
||
// pdfHeaderRows: die zwei Kopfzeilen der Projekttabelle als pdfmake-Zellenarrays.
|
||
// Achtung: Nach colSpan:n folgen exakt n-1 leere {}; unter rowSpan-Zellen
|
||
// steht in der Folgezeile ebenfalls {}. Verstöße brechen das Layout still.
|
||
function pdfHeaderRows(grouped){
|
||
const h1 = [
|
||
{ text: 'Pr.-Nr.', rowSpan: 2, style: 'th' },
|
||
{ text: 'Bezeichnung', rowSpan: 2, style: 'th' },
|
||
{ text: 'Status', rowSpan: 2, style: 'th' }
|
||
];
|
||
const h2 = [ {}, {}, {} ];
|
||
|
||
ROLES.forEach(r => {
|
||
const list = grouped[r] || [];
|
||
const span = Math.max(1, list.length);
|
||
h1.push({ text: r, colSpan: span, style: 'th',
|
||
fillColor: pdfRoleBg(r, PDF_NEUTRAL_BG), alignment: 'center' });
|
||
for(let i = 1; i < span; i++) h1.push({});
|
||
|
||
if(list.length === 0){
|
||
h2.push({ text: '–', style: 'thSub', alignment: 'center',
|
||
fillColor: pdfRoleBg(r, PDF_ALT_ROW_BG) });
|
||
} else {
|
||
list.forEach(e => h2.push({
|
||
text: e.person.name, style: 'thSub', alignment: 'center',
|
||
fillColor: pdfRoleBg(r, PDF_ALT_ROW_BG)
|
||
}));
|
||
}
|
||
});
|
||
|
||
h1.push({ text: 'Leistungsphasen (%)', colSpan: LPHS.length,
|
||
style: 'th', alignment: 'center' });
|
||
for(let i = 1; i < LPHS.length; i++) h1.push({});
|
||
LPHS.forEach(l => h2.push({ text: String(l), style: 'thSub',
|
||
alignment: 'center', fillColor: PDF_ALT_ROW_BG }));
|
||
|
||
return [h1, h2];
|
||
}
|
||
|
||
// pdfProjectRow: eine Projektzeile als pdfmake-Zellenarray.
|
||
function pdfProjectRow(p, allPeople, alt){
|
||
const base = alt ? PDF_ALT_ROW_BG : null;
|
||
const row = [
|
||
{ text: p.nr || '', style: 'td', fillColor: base, noWrap: true },
|
||
{ text: p.name || '', style: 'td', fillColor: base },
|
||
Object.assign(pdfStatusText(p.status), { fillColor: base })
|
||
];
|
||
|
||
allPeople.forEach(entry => {
|
||
const s = (p.assign || {})[assignKey(entry.person.id, entry.role)] || 0;
|
||
const txt = s === 1 ? 'X' : s === 2 ? '(X)' : '';
|
||
const bg = s === 1 ? PDF_ASSIGN_MAIN
|
||
: s === 2 ? PDF_ASSIGN_OPT
|
||
: pdfRoleBg(entry.role, base || '#FFFFFF');
|
||
row.push({
|
||
text: txt, style: 'tdCenter', fillColor: bg,
|
||
color: (s === 1 || s === 2) ? '#FFFFFF' : '#1D1D1F',
|
||
bold: s === 1
|
||
});
|
||
});
|
||
|
||
LPHS.forEach(l => {
|
||
const raw = (p.lph && p.lph[l] !== undefined && p.lph[l] !== '')
|
||
? String(p.lph[l]).replace(/%/g, '') : '';
|
||
row.push({ text: raw ? raw + '%' : '', style: 'tdCenter', fillColor: base });
|
||
});
|
||
|
||
return row;
|
||
}
|
||
|
||
// pdfGroupTitleRow: Gruppenüberschrift über die volle Tabellenbreite.
|
||
function pdfGroupTitleRow(groupName, colCount){
|
||
const row = [{
|
||
text: groupName || '', colSpan: colCount, bold: true,
|
||
fillColor: PDF_NEUTRAL_BG, fontSize: 8, color: '#1D1D1F',
|
||
margin: [3, 3, 3, 3]
|
||
}];
|
||
for(let i = 1; i < colCount; i++) row.push({});
|
||
return row;
|
||
}
|
||
|
||
// pdfColumnWidths: verteilt die tatsächlich verfügbare Breite.
|
||
// Berücksichtigt Padding und Linienstärke, die pdfmake ZUSÄTZLICH
|
||
// zur angegebenen Spaltenbreite rechnet. Gibt ausschliesslich
|
||
// numerische Breiten zurück - kein '*', damit nichts überläuft.
|
||
function pdfColumnWidths(allPeople){
|
||
const nPeople = allPeople.length;
|
||
const nLph = LPHS.length;
|
||
const nCols = 3 + nPeople + nLph;
|
||
|
||
// Overhead, den pdfmake on top der Spaltenbreiten aufschlägt
|
||
const overhead = nCols * PDF_PAD_X * 2 + (nCols + 1) * PDF_LINE_W;
|
||
let budget = PDF_USABLE_W - overhead;
|
||
|
||
// Schmale Spalten: schrumpfen bei Bedarf, aber nicht unter Minimum
|
||
let wNr = 40;
|
||
let wPerson = 15;
|
||
let wLph = 19;
|
||
|
||
const fixed = () => wNr + nPeople * wPerson + nLph * wLph;
|
||
|
||
// Zielraum für Bezeichnung + Status
|
||
const TEXT_MIN = 150;
|
||
|
||
if(budget - fixed() < TEXT_MIN){
|
||
// 1. Personen- und LPH-Spalten stufenweise verschmälern
|
||
while(budget - fixed() < TEXT_MIN && (wPerson > 11 || wLph > 15)){
|
||
if(wLph > 15) wLph--;
|
||
if(wPerson > 11) wPerson -= 0.5;
|
||
}
|
||
if(budget - fixed() < TEXT_MIN && wNr > 30) wNr = 30;
|
||
}
|
||
|
||
let textSpace = budget - fixed();
|
||
if(textSpace < 60){
|
||
console.warn('PDF: Zu viele Spalten für A4 quer. '
|
||
+ 'Verbleibend für Bezeichnung/Status: ' + Math.round(textSpace) + ' pt.');
|
||
textSpace = 60;
|
||
}
|
||
|
||
// Bezeichnung erhält 60 %, Status 40 %
|
||
const wName = Math.floor(textSpace * 0.40);
|
||
const wStatus = Math.floor(textSpace * 0.60);
|
||
|
||
return [wNr, wName, wStatus]
|
||
.concat(new Array(nPeople).fill(wPerson))
|
||
.concat(new Array(nLph).fill(wLph));
|
||
}
|
||
|
||
// pdfCheckWidth: prüft, ob die Tabelle in den Satzspiegel passt.
|
||
function pdfCheckWidth(allPeople){
|
||
const widths = pdfColumnWidths(allPeople);
|
||
const nCols = widths.length;
|
||
const overhead = nCols * PDF_PAD_X * 2 + (nCols + 1) * PDF_LINE_W;
|
||
const total = widths.reduce((a, b) => a + b, 0) + overhead;
|
||
if(total > PDF_USABLE_W + 0.5){
|
||
console.warn('PDF: Tabellenbreite ' + Math.round(total)
|
||
+ ' pt > verfügbar ' + Math.round(PDF_USABLE_W) + ' pt.');
|
||
return false;
|
||
}
|
||
return true;
|
||
}
|
||
|
||
// pdfAttendanceBlock: Anwesenheitsübersicht.
|
||
function pdfAttendanceBlock(w){
|
||
const att = w.attendance || {};
|
||
const fmt = p => p.name + ' (' + (p.fullName || p.name) + ')';
|
||
const present = (w.people || []).filter(p => att[p.id]).map(fmt).join(', ');
|
||
const absent = (w.people || []).filter(p => !att[p.id]).map(fmt).join(', ');
|
||
return [
|
||
{ text: 'Anwesenheit', style: 'h2', margin: [0, 0, 0, 6] },
|
||
{ text: [{ text: 'Anwesend: ', bold: true }, present || '—'],
|
||
fontSize: 9, margin: [0, 0, 0, 3] },
|
||
{ text: [{ text: 'Abwesend: ', bold: true }, absent || '—'], fontSize: 9 }
|
||
];
|
||
}
|
||
|
||
// pdfLoadSection: Auslastungsübersicht inkl. Balken (als Canvas-Rechtecke).
|
||
function pdfLoadSection(w, grouped){
|
||
const stats = {};
|
||
ROLES.forEach(r => (grouped[r] || []).forEach(e => {
|
||
stats[assignKey(e.person.id, r)] = { load:0, anbahnung:0, projects:{} };
|
||
}));
|
||
|
||
(w.projects || []).forEach(pr => {
|
||
const aktivePhase = lpActiveList(pr)[0];
|
||
Object.keys(pr.assign || {}).forEach(aKey => {
|
||
const s = stats[aKey];
|
||
if(!s) return;
|
||
const st = pr.assign[aKey];
|
||
const rolle = aKey.slice(aKey.indexOf('|') + 1);
|
||
const al = berechneAuslastung(null, pr, aktivePhase, rolle, st);
|
||
if(al.anbahnung){
|
||
s.anbahnung++;
|
||
return;
|
||
}
|
||
if(al.load > 0){
|
||
s.load += al.load;
|
||
s.projects[pr.name] = (s.projects[pr.name] || 0) + 1;
|
||
}
|
||
});
|
||
});
|
||
|
||
const BAR_W = 140;
|
||
const barMax = LOAD_BAR_MAX;
|
||
const body = [[
|
||
{ text: 'Rolle', style: 'th' },
|
||
{ text: 'Kürzel', style: 'th' },
|
||
{ text: 'Auslastung', style: 'th' },
|
||
{ text: 'Auslastung (Faktor)',style: 'th' },
|
||
{ text: 'Projektdetails', style: 'th' }
|
||
]];
|
||
|
||
ROLES.forEach(r => (grouped[r] || []).forEach(entry => {
|
||
const s = stats[assignKey(entry.person.id, r)];
|
||
const load = s.load; // aktuelle Sitzung = 1 Woche
|
||
// Balken relativ zur einstellbaren Skala (Standard 8); die Ampel-Farbe
|
||
// und der Zahlenwert bleiben ungedeckelt (Überlastung > 1,0).
|
||
const col = pdfLoadAmpelColor(load);
|
||
const det = Object.keys(s.projects)
|
||
.map(k => k + ' (' + s.projects[k] + '\u00D7)').join(', ') || '—';
|
||
|
||
body.push([
|
||
{ text: r, style: 'td' },
|
||
{ text: entry.person.name, style: 'td', bold: true },
|
||
{ text: faktorFormat(load), style: 'tdCenter' },
|
||
{ stack: [
|
||
{ canvas: [
|
||
{ type:'rect', x:0, y:0, w:BAR_W, h:7, color:'#F0F0F0' },
|
||
{ type:'rect', x:0, y:0, w:Math.max(0, BAR_W * Math.min(1, load / barMax)),
|
||
h:7, color:col }
|
||
]},
|
||
{ text: faktorFormat(load), fontSize: 6, color: '#6E6E73',
|
||
alignment: 'right', width: BAR_W, margin: [0, 1, 0, 0] }
|
||
], margin: [3, 3, 3, 3] },
|
||
{ text: s.anbahnung
|
||
? [ det, { text: '\nIn Anbahnung: ' + s.anbahnung, color: '#0071E3' } ]
|
||
: det,
|
||
style: 'td', fontSize: 6.5 }
|
||
]);
|
||
}));
|
||
|
||
return [
|
||
{ text: 'Auslastung der Projektbeteiligten (aktuelle Sitzung)',
|
||
style: 'h2', margin: [0, 0, 0, 6] },
|
||
{ table: { headerRows: 1, widths: [80, 40, 45, BAR_W + 6, '*'], body },
|
||
layout: pdfTableLayout() },
|
||
{ text: 'Rollenbezogener Gewichtungsfaktor je Leistungsphase. Aktueller Wert: '
|
||
+ faktorFormat(LOAD_BAR_MAX),
|
||
fontSize: 6.5, color: '#6E6E73', margin: [0, 4, 0, 0] }
|
||
];
|
||
}
|
||
|
||
// exportPDF: Hauptfunktion. Erzeugt und lädt das PDF.
|
||
async function exportPDF(){
|
||
if(!window.pdfMake){
|
||
alert('PDF-Export nicht verfügbar: pdfmake wurde nicht geladen. '
|
||
+ 'Bitte Internetverbindung bzw. Skript-Einbindung prüfen.');
|
||
return;
|
||
}
|
||
const w = currentWeek();
|
||
if(!w) return;
|
||
|
||
try {
|
||
const grouped = peopleByRole(w);
|
||
const allPeople = [];
|
||
ROLES.forEach(r => (grouped[r] || []).forEach(e => {
|
||
// role explizit setzen, falls peopleByRole sie nicht mitliefert
|
||
allPeople.push({ person: e.person, role: e.role || r });
|
||
}));
|
||
|
||
pdfCheckWidth(allPeople);
|
||
|
||
const logo = await loadLogoPng();
|
||
const colCount = 3 + allPeople.length + LPHS.length;
|
||
const widths = pdfColumnWidths(allPeople);
|
||
|
||
// --- Deckblatt ---
|
||
const cover = [];
|
||
if(logo){
|
||
cover.push({ image: logo, width: 130, alignment: 'center',
|
||
margin: [0, 0, 0, 24] });
|
||
}
|
||
cover.push(
|
||
{ text: 'Projektübersicht ' + DEPARTMENTS[currentDepartment].label,
|
||
style: 'h1', alignment: 'center', margin: [0, 0, 0, 10] },
|
||
{ text: w.label || '', fontSize: 13, color: '#6E6E73',
|
||
alignment: 'center', margin: [0, 0, 0, 30] }
|
||
);
|
||
if(w.date){
|
||
cover.push({ text: 'Datum: ' + formatWeekDate(w.date), fontSize: 9,
|
||
color: '#6E6E73', alignment: 'center', margin: [0, 0, 0, 8] });
|
||
}
|
||
cover.push({ text: 'Erstellt am ' + new Date().toLocaleDateString('de-DE'),
|
||
fontSize: 8, color: '#6E6E73', alignment: 'center',
|
||
margin: [0, 30, 0, 0] });
|
||
|
||
const content = [{ stack: cover, margin: [0, 170, 0, 0], pageBreak: 'after' }];
|
||
|
||
// --- Projektgruppen ---
|
||
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(groupName => {
|
||
const projects = groups[groupName];
|
||
if(!projects || !projects.length) return;
|
||
|
||
// Kopfzeilen MÜSSEN pro Tabelle neu erzeugt werden:
|
||
// pdfmake mutiert Zellobjekte (rowSpan/colSpan, _span, _inlines).
|
||
// Wiederverwendung derselben Referenzen führt zu leeren Kopfzeilen
|
||
// in allen Tabellen ab der zweiten.
|
||
const header = pdfHeaderRows(grouped);
|
||
|
||
const body = [ pdfGroupTitleRow(groupName, colCount), header[0], header[1] ];
|
||
projects.forEach((p, i) => body.push(pdfProjectRow(p, allPeople, i % 2 === 0)));
|
||
|
||
content.push({
|
||
table: { headerRows: 3, widths: widths, body: body, dontBreakRows: true },
|
||
layout: pdfTableLayout(),
|
||
margin: [0, 0, 0, 14],
|
||
pageBreak: 'after'
|
||
});
|
||
});
|
||
|
||
// --- Anwesenheit / Auslastung ---
|
||
//content.push({ text: '', pageBreak: 'before' });
|
||
pdfAttendanceBlock(w).forEach(b => content.push(b));
|
||
content.push({ text: '', margin: [0, 14, 0, 0] });
|
||
pdfLoadSection(w, grouped).forEach(b => content.push(b));
|
||
|
||
const doc = {
|
||
pageSize: 'A3',
|
||
pageOrientation: 'landscape',
|
||
pageMargins: [PDF_MARGIN_X, 25, PDF_MARGIN_X, 30],
|
||
defaultStyle: { font: 'Roboto', fontSize: 7, color: '#1D1D1F' },
|
||
styles: PDF_STYLES,
|
||
footer: (page, count) => page === 1 ? '' : {
|
||
text: (w.label || '') + ' · Seite ' + page + ' von ' + count,
|
||
alignment: 'right', fontSize: 6.5, color: '#6E6E73',
|
||
margin: [0, 8, PDF_MARGIN_X, 0]
|
||
},
|
||
info: {
|
||
title: 'Projektübersicht ' + (w.label || ''),
|
||
creator: 'Projektboard'
|
||
},
|
||
content: content
|
||
};
|
||
|
||
const filename = 'Projektuebersicht_'
|
||
+ String(w.label || 'Sitzung').replace(/[^\w]/g, '_') + '.pdf';
|
||
pdfMake.createPdf(doc).download(filename);
|
||
|
||
} catch(err){
|
||
console.error('PDF-Export fehlgeschlagen:', err);
|
||
alert('PDF-Export fehlgeschlagen: ' + (err.message || err));
|
||
}
|
||
}
|
||
|
||
// 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);
|
||
}
|