// js/abwesenheit/abwesenheit.js — Bereich „Abwesenheiten“ (Tab abwesenheit, // Eriks Wahl „Entwurf A“): oben die Schnellerfassung, darunter die Liste der // gewählten Person, unten die Team-Zeitleiste eines Monats. // // Rechte (Rollen & Rechte, Gruppe „Team“): team.abw (für mich), // team.abwProjekte, team.abwGewerk, team.abwAlle. Der Server rechnet daraus // je Person `darfAbw` (loadGlobalPeople(req.user)) und schickt Abwesenheiten // ANDERER Personen nur als „abwesend“ ohne Art und Notiz (`verdeckt`) — Art // und Notiz sieht nur die Person selbst und der Administrator. Verdeckte Einträge lassen sich // deshalb nicht ändern (die Art ist unbekannt), nur löschen. Diese Datei // verlässt sich darauf, entscheidet selbst also nichts über Rechte. // // Daten: db.globalPeople (aus /load-all bzw. GET /personen). Nach jedem // Speichern lädt personenNachladen() (js/store.js) die Liste neu; andere // Browser bekommen das Live-Ereignis {typ:'personen'}. const ABW_ARTEN = ['Urlaub', 'Krank', 'Fortbildung', 'Sonstiges']; const ABW_KLASSE = { Urlaub: 'abw-u', Krank: 'abw-k', Fortbildung: 'abw-f', Sonstiges: 'abw-s' }; let abwMonat = null; // Date: erster Tag des angezeigten Monats let abwFilter = 'Alle'; // Gewerk-Filter der Zeitleiste let abwFuer = null; // userId der Person, für die eingetragen wird let abwBearbeiten = null; // id der Abwesenheit, die gerade geändert wird let abwVergangen = false; // Liste: vergangene statt kommende let abwLoeschFrage = null; // id, deren Löschen gerade bestätigt werden soll let abwFehler = ''; let abwEntwurf = { start: '', end: '', type: 'Urlaub', note: '' }; // darfIrgendeineAbwesenheit: ob die Rolle überhaupt Abwesenheiten eintragen // darf (Menüpunkt sichtbar). Rückgabe: Boolean. function darfIrgendeineAbwesenheit(){ return ['team.abw', 'team.abwProjekte', 'team.abwGewerk', 'team.abwAlle'].some(r => hatRecht(r)); } function abwPersonen(){ return Array.isArray(db.globalPeople) ? db.globalPeople : []; } function abwIch(){ return abwPersonen().find(p => currentUser && p.userId === currentUser.id) || null; } function abwPerson(userId){ return abwPersonen().find(p => p.userId === userId) || null; } function abwName(p){ return p ? (p.fullName || p.name || '') : ''; } // abwDatum: "2026-11-02" -> "02.11." (mit Jahr, wenn mitJahr). Rückgabe: String. function abwDatum(iso, mitJahr){ const d = parseDateISO(iso); if(!d) return ''; const s = String(d.getDate()).padStart(2, '0') + '.' + String(d.getMonth() + 1).padStart(2, '0') + '.'; return mitJahr ? s + d.getFullYear() : s; } // abwZeitraum: lesbarer Zeitraum einer Abwesenheit. function abwZeitraum(a){ if(a.start === a.end) return abwDatum(a.start, true); const gleichesJahr = a.start.slice(0, 4) === a.end.slice(0, 4); return abwDatum(a.start, !gleichesJahr) + ' – ' + abwDatum(a.end, true); } function abwArbeitstage(start, end){ const a = parseDateISO(start), b = parseDateISO(end); return (a && b) ? workdaysBetween(a, b) : 0; } function abwUeberlappt(a1, e1, a2, e2){ return a1 <= e2 && a2 <= e1; } // ---------- Rendern ---------- // Gestaltung „Variante 2 im Stil der Projektübersicht“ (Eriks Wahl): dieselben // Bausteine wie die Projektliste — Titelzeile mit Legende (.legend), Glas- // Karten (.board-list), Zeilen (.board-row/.board-row-main, .board-nr, // .board-name, .board-status-line, .phase-pill, .board-row-actions) und // Kürzel-Kreise in Fachrollen-Farbe (.board-avatar.role-*). Links „Neue // Abwesenheit“, rechts die Liste der gewählten Person, darunter das Team je // Gewerk in einer eigenen Karte. const ABW_WOCHENTAG = ['So', 'Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa']; const ABW_WOCHENTAG_LANG = ['Sonntag', 'Montag', 'Dienstag', 'Mittwoch', 'Donnerstag', 'Freitag', 'Samstag']; // abwRollen: alle Fachrollen der Person in der festen Reihenfolge von ROLES // (die gespeicherte Reihenfolge ist je Konto zufällig). Rückgabe: Array. function abwRollen(p){ const eigene = Array.isArray(p && p.role) ? p.role : []; return ROLES.filter(r => eigene.includes(r)).concat(eigene.filter(r => !ROLES.includes(r))); } // abwKreis: Kürzel-Kreis wie in der Projektliste, Farbe nach der ersten // Fachrolle der Person (Reihenfolge ROLES). Rückgabe: HTML. function abwKreis(p){ if(!p) return ''; const rollen = abwRollen(p); const rolle = rollen.length ? roleClass(rollen[0]) : ''; return `${esc(p.name || '')}`; } function renderAbwesenheiten(){ const el = document.getElementById('abwesenheit'); if(!el) return; if(!darfIrgendeineAbwesenheit()){ el.innerHTML = '
Abwesenheiten

Deine Rolle darf keine Abwesenheiten eintragen.

'; return; } if(!abwMonat){ const h = new Date(); abwMonat = new Date(h.getFullYear(), h.getMonth(), 1); } if(abwFuer === null || !abwPerson(abwFuer) || !abwPerson(abwFuer).darfAbw) abwFuer = abwStandardFuer(); const erlaubte = abwPersonen().filter(p => p.darfAbw); el.innerHTML = `
${abwFuer !== null ? abwFormularHtml(erlaubte) : abwOhnePersonHtml()}
${abwFuer !== null ? abwListeHtml() : ''}
${abwTeamHtml()}`; } // abwStandardFuer: wer in der Erfassung vorausgewählt ist — immer die Person, // die die Seite öffnet; hat ihr Konto keine Person (bzw. kein Recht für sich), // die erste Person, für die sie eintragen darf. Rückgabe: userId oder null. function abwStandardFuer(){ const ich = abwIch(); if(ich && ich.darfAbw) return ich.userId; const erste = abwPersonen().filter(p => p.darfAbw).sort((a, b) => abwName(a).localeCompare(abwName(b), 'de'))[0]; return erste ? erste.userId : null; } // abwBeimOeffnen: beim Betreten des Bereichs (showTab) — Erfassung leer, für // die eigene Person. function abwBeimOeffnen(){ abwFuer = abwStandardFuer(); abwBearbeiten = null; abwLoeschFrage = null; abwFehler = ''; abwEntwurf = { start: '', end: '', type: 'Urlaub', note: '' }; } // abwOhnePersonHtml: Platzhalter-Karte, wenn niemand wählbar ist. function abwOhnePersonHtml(){ return `
Neue Abwesenheit

Für dein Konto lässt sich keine Abwesenheit eintragen: Es ist keiner Person zugeordnet (Person-ID fehlt). Ein Administrator kann das in der Benutzerverwaltung nachtragen.

`; } // abwFormularHtml: „Neue Abwesenheit“ (bzw. „Abwesenheit ändern“) als Karte. function abwFormularHtml(erlaubte){ const p = abwPerson(abwFuer); const e = abwEntwurf; const ich = abwIch(); const istIch = p === ich; const fuerAuswahl = erlaubte.length > 1 && !abwBearbeiten ? `` : ''; const kachel = (titel, iso, unter) => { const d = parseDateISO(iso); return ``; }; const tage = e.start ? abwArbeitstage(e.start, e.end) : 0; const warnungen = e.start ? abwWarnungen(p, e.start, e.end) : []; return `
${abwBearbeiten ? 'Abwesenheit ändern' : 'Neue Abwesenheit'}
${abwKreis(p)} ${istIch ? 'Für mich' : 'Für ' + esc(abwName(p))}${istIch ? ' · ' + esc(abwName(p)) : (p && p.gewerk ? ' · ' + esc(p.gewerk) : '')} ${fuerAuswahl}
${kachel('Von', e.start, '')} ${kachel('Bis', e.end || e.start, e.start ? ' · ' + tage + (tage === 1 ? ' Arbeitstag' : ' Arbeitstage') : '')}
${ABW_ARTEN.map(a => ``).join('')}
${abwBearbeiten ? '' : ''}
${abwFehler ? `` : ''} ${warnungen.map(w => `
${w}
`).join('')}
`; } // abwWarnungen: Hinweise zum gewählten Zeitraum — Überschneidung mit einer // eigenen Abwesenheit der Person, ihre offenen Aufgaben im Zeitraum und // Kollegen desselben Gewerks, die ebenfalls fehlen. Rückgabe: HTML-Strings. function abwWarnungen(p, start, end){ if(!p) return []; const w = []; const doppelt = (p.absences || []).filter(a => a.id !== abwBearbeiten && abwUeberlappt(start, end, a.start, a.end)); if(doppelt.length) w.push('Überschneidet sich mit ' + doppelt.map(a => esc((a.verdeckt ? 'abwesend' : a.type) + ' ' + abwZeitraum(a))).join(', ') + '.'); const wk = typeof currentWeek === 'function' ? currentWeek() : null; const aufgaben = []; ((wk && wk.projects) || []).forEach(pr => { ((pr.plan && pr.plan.todos) || []).forEach(t => { if(t.done || !Array.isArray(t.assignees) || !t.assignees.includes(p.id)) return; const tEnde = t.end || t.start; if(!tEnde) return; if(abwUeberlappt(start, end, t.start || tEnde, tEnde)) aufgaben.push(`„${esc(t.title || 'Aufgabe')}“ (${esc(pr.name || '')}, ${abwDatum(tEnde)})`); }); }); if(aufgaben.length){ const zuviel = aufgaben.length - 3; w.push(`In diesen Zeitraum ${aufgaben.length === 1 ? 'fällt 1 offene Aufgabe' : 'fallen ' + aufgaben.length + ' offene Aufgaben'}: ${aufgaben.slice(0, 3).join(', ')}${zuviel > 0 ? ' und ' + zuviel + ' weitere' : ''}.`); } if(p.gewerk){ const andere = abwPersonen().filter(x => x !== p && x.gewerk === p.gewerk && (x.absences || []).some(a => abwUeberlappt(start, end, a.start, a.end))); if(andere.length) w.push(`${andere.map(x => esc(abwName(x))).join(', ')} (${esc(p.gewerk)}) ${andere.length === 1 ? 'ist' : 'sind'} in dieser Zeit ebenfalls weg.`); } return w; } // abwListeHtml: Abwesenheiten der gewählten Person als Zeilen wie in der // Projektliste — Startdatum in der Nummernspalte, Kalenderwoche als Kapsel. function abwListeHtml(){ const p = abwPerson(abwFuer); if(!p) return ''; const heute = heuteIso(); const alle = (p.absences || []).slice().sort((a, b) => a.start.localeCompare(b.start)); const kommend = alle.filter(a => a.end >= heute); const vergangen = alle.filter(a => a.end < heute).reverse(); const liste = abwVergangen ? vergangen : kommend; const titel = p === abwIch() ? 'Meine Abwesenheiten' : 'Abwesenheiten von ' + abwName(p); const zeilen = liste.map(a => { const tage = abwArbeitstage(a.start, a.end); const ds = parseDateISO(a.start), de = parseDateISO(a.end); const kwA = getWeekNumber(ds), kwE = getWeekNumber(de); const zr = ABW_WOCHENTAG[ds.getDay()] + ' ' + abwDatum(a.start, a.start.slice(0, 4) !== a.end.slice(0, 4)) + (a.start === a.end ? a.start.slice(0, 4) : ' – ' + ABW_WOCHENTAG[de.getDay()] + ' ' + abwDatum(a.end, true)); const fragt = abwLoeschFrage === a.id; const verdeckt = !!a.verdeckt; const aendern = verdeckt ? '' : ` onclick="abwAendern('${esc(a.id)}')"`; return `
${abwDatum(a.start)}
${verdeckt ? 'abwesend' : esc(a.type)}${tage} ${tage === 1 ? 'Arbeitstag' : 'Arbeitstage'}
${esc(zr)}${!verdeckt && a.note ? ' · ' + esc(a.note) : ''}
${verdeckt ? '' : ``}
KW ${kwA}${kwE !== kwA ? '–' + kwE : ''}
`; }).join(''); return `
${esc(titel)}
${zeilen || `
${abwVergangen ? 'Keine vergangenen Abwesenheiten.' : 'Keine kommenden Abwesenheiten.'}
`}
`; } // abwTeamHtml: Kopf des Team-Monats und je Gewerk eine Karte (wie die // Projektgruppen), Personen ohne Gewerk zuletzt. function abwTeamHtml(){ const monatName = abwMonat.toLocaleDateString('de-DE', { month: 'long', year: 'numeric' }); const gewerke = Object.keys(DEPARTMENTS); const sichtbar = abwFilter === 'Alle' ? gewerke.concat(['']) : [abwFilter]; const karten = sichtbar.map(g => { const ps = abwPersonen().filter(p => (p.gewerk || '') === g); return ps.length ? abwGewerkKarteHtml(g, ps) : ''; }).join(''); return `
Team im ${esc(abwMonat.toLocaleDateString('de-DE', { month: 'long' }))}
${esc(monatName)} ${['Alle'].concat(gewerke).map(g => ``).join('')}
${karten || '

Keine Personen in diesem Gewerk.

'}`; } // abwGewerkKarteHtml: eine Karte je Gewerk — Tageskopf, dann eine Zeile je // Person mit Kürzel-Kreis und je Abwesenheit ein Balken. function abwGewerkKarteHtml(gewerk, personen){ const jahr = abwMonat.getFullYear(), monat = abwMonat.getMonth(); const tage = new Date(jahr, monat + 1, 0).getDate(); const heute = heuteIso(); const ich = abwIch(); const iso = d => jahr + '-' + String(monat + 1).padStart(2, '0') + '-' + String(d).padStart(2, '0'); const ersterIso = iso(1), letzterIso = iso(tage); const sortiert = personen.slice().sort((a, b) => (a === ich ? -1 : b === ich ? 1 : abwName(a).localeCompare(abwName(b), 'de'))); const darfIrgendwen = sortiert.some(p => p.darfAbw); let h = `
${sortiert.length} ${sortiert.length === 1 ? 'Person' : 'Personen'}
`; for(let d = 1; d <= tage; d++){ const dt = new Date(jahr, monat, d); h += `
${ABW_WOCHENTAG[dt.getDay()]}${d}
`; } sortiert.forEach(p => { const sichtbar = (p.absences || []).filter(a => abwUeberlappt(a.start, a.end, ersterIso, letzterIso)); let tageImMonat = 0; if(p.darfAbw) sichtbar.forEach(a => { tageImMonat += abwArbeitstage(a.start < ersterIso ? ersterIso : a.start, a.end > letzterIso ? letzterIso : a.end); }); const rolle = abwRollen(p).join(', '); const meta = p === ich ? 'Ich' + (rolle ? ' · ' + rolle : '') : (p.darfAbw ? (rolle || 'ohne Fachrolle') : 'nur „abwesend“'); const waehlen = p.darfAbw ? ` ist-klickbar" role="button" tabindex="0" title="Abwesenheiten von ${esc(abwName(p))} anzeigen und eintragen" onclick="abwPersonAusTabelle(${p.userId})" onkeydown="if(event.key==='Enter'||event.key===' '){event.preventDefault();abwPersonAusTabelle(${p.userId})}` : ''; h += `
${abwKreis(p)} ${esc(abwName(p))} ${esc(meta)}${tageImMonat ? ' · ' + tageImMonat + (tageImMonat === 1 ? ' Tag' : ' Tage') + ' im ' + abwMonat.toLocaleDateString('de-DE', { month: 'short' }).replace('.', '') + '.' : ''}
`; for(let d = 1; d <= tage; d++){ const dt = new Date(jahr, monat, d); const tagIso = iso(d); let balken = ''; sichtbar.forEach(a => { const beginn = a.start < ersterIso ? ersterIso : a.start; if(beginn !== tagIso) return; const ende = a.end > letzterIso ? letzterIso : a.end; const len = Number(ende.slice(8)) - d + 1; const verdeckt = a.verdeckt || !p.darfAbw; const text = verdeckt ? 'abwesend' : a.type; const titel = abwName(p) + ': ' + text + ' ' + abwZeitraum(a) + (!verdeckt && a.note ? ' — ' + a.note : ''); balken += `${len > 1 && !verdeckt ? esc(text) : ''}`; }); const klick = p.darfAbw ? ` onclick="abwTagGewaehlt(${p.userId}, '${tagIso}')"` : ''; h += `
${balken}
`; } }); return `
${esc(gewerk || 'Ohne Gewerk')}
${darfIrgendwen ? '' : 'nur Ansicht'}
${h}
`; } // ---------- Bedienung ---------- function abwFuerWaehlen(userId){ abwFuer = Number(userId); abwLoeschFrage = null; abwFehler = ''; renderAbwesenheiten(); } // abwPersonAusTabelle: Klick auf eine Person in der Team-Tabelle — sie wird // oben in Erfassung und Liste gewählt (eine laufende Änderung wird verworfen). // Die Seite bleibt, wo sie ist (Eriks Wunsch, kein Springen nach oben). function abwPersonAusTabelle(userId){ abwBearbeiten = null; abwEntwurf = { start: '', end: '', type: abwEntwurf.type || 'Urlaub', note: '' }; abwFuerWaehlen(userId); } // abwZeitraumOeffnen: Von- oder Bis-Kachel öffnet den Bereichs-Kalender. function abwZeitraumOeffnen(anker){ openRangePicker({ anchor: anker, startInputId: 'abwStart', endInputId: 'abwEnde', onApply: abwZeitraumGewaehlt }); } function abwArtWaehlen(art){ abwEntwurf.type = art; renderAbwesenheiten(); } function abwZeitraumGewaehlt(){ abwEntwurf.start = document.getElementById('abwStart').value; abwEntwurf.end = document.getElementById('abwEnde').value || abwEntwurf.start; abwFehler = ''; renderAbwesenheiten(); } function abwVergangenZeigen(v){ abwVergangen = !!v; abwLoeschFrage = null; renderAbwesenheiten(); } function abwFilterSetzen(g){ abwFilter = g; renderAbwesenheiten(); } function abwMonatWechseln(n){ if(n === 0){ const h = new Date(); abwMonat = new Date(h.getFullYear(), h.getMonth(), 1); } else abwMonat = new Date(abwMonat.getFullYear(), abwMonat.getMonth() + n, 1); renderAbwesenheiten(); } // abwTagGewaehlt: Klick auf einen leeren Tag der Zeitleiste — Schnellerfassung // für diese Person und diesen Tag vorbelegen. function abwTagGewaehlt(userId, tagIso){ abwFuer = userId; abwBearbeiten = null; abwLoeschFrage = null; abwFehler = ''; abwEntwurf = { start: tagIso, end: tagIso, type: abwEntwurf.type || 'Urlaub', note: '' }; renderAbwesenheiten(); const f = document.querySelector('#abwesenheit .abw-datum'); if(f){ f.scrollIntoView({ block: 'center', behavior: 'smooth' }); } } function abwAendernFuer(userId, id){ abwFuer = userId; abwAendern(id); } function abwAendern(id){ const p = abwPerson(abwFuer); const a = p && (p.absences || []).find(x => x.id === id); if(!a || a.verdeckt) return; abwBearbeiten = id; abwLoeschFrage = null; abwFehler = ''; abwEntwurf = { start: a.start, end: a.end, type: ABW_ARTEN.includes(a.type) ? a.type : 'Sonstiges', note: a.note || '' }; renderAbwesenheiten(); const f = document.querySelector('#abwesenheit .abw-datum'); if(f) f.scrollIntoView({ block: 'center', behavior: 'smooth' }); } function abwAbbrechen(){ abwBearbeiten = null; abwFehler = ''; abwFuer = abwStandardFuer(); abwEntwurf = { start: '', end: '', type: 'Urlaub', note: '' }; renderAbwesenheiten(); } // abwAnfrage: Aufruf der Abwesenheits-Routen. Rückgabe: Promise<{ok, json}>. function abwAnfrage(methode, pfad, daten){ return fetch(baseUrl() + pfad, { method: methode, credentials: 'same-origin', headers: { 'Content-Type': 'application/json; charset=utf-8' }, body: daten ? JSON.stringify(daten) : undefined }).then(r => r.json().catch(() => ({})).then(json => ({ ok: r.ok && json && json.ok, json }))); } function abwSpeichern(){ const e = abwEntwurf; e.note = ((document.getElementById('abwNotiz') || {}).value || '').trim(); if(!e.start){ abwFehler = 'Bitte einen Zeitraum wählen.'; renderAbwesenheiten(); return; } const pfad = '/users/' + abwFuer + '/absences' + (abwBearbeiten ? '/' + encodeURIComponent(abwBearbeiten) : ''); abwAnfrage(abwBearbeiten ? 'PATCH' : 'POST', pfad, { start: e.start, end: e.end || e.start, type: e.type, note: e.note }) .then(({ ok, json }) => { if(!ok){ abwFehler = (json && json.error) || 'Die Abwesenheit konnte nicht gespeichert werden.'; renderAbwesenheiten(); return; } abwBearbeiten = null; abwFehler = ''; abwFuer = abwStandardFuer(); abwEntwurf = { start: '', end: '', type: 'Urlaub', note: '' }; return personenNachladen().then(renderAbwesenheiten); }) .catch(() => { abwFehler = 'Keine Verbindung zum Server.'; renderAbwesenheiten(); }); } // abwLoeschen: zweistufig im Knopf („Löschen“ → „Wirklich löschen?“). function abwLoeschen(id){ if(abwLoeschFrage !== id){ abwLoeschFrage = id; renderAbwesenheiten(); return; } abwLoeschFrage = null; abwAnfrage('DELETE', '/users/' + abwFuer + '/absences/' + encodeURIComponent(id)) .then(({ ok, json }) => { if(!ok){ abwFehler = (json && json.error) || 'Die Abwesenheit konnte nicht gelöscht werden.'; renderAbwesenheiten(); return; } if(abwBearbeiten === id) abwAbbrechen(); return personenNachladen().then(renderAbwesenheiten); }) .catch(() => { abwFehler = 'Keine Verbindung zum Server.'; renderAbwesenheiten(); }); }