- Outlook: Kalender-Abo je Konto (eigene Aufgaben, Meilensteine, Abwesenheiten als .ics), einzurichten unter Einstellungen - Teams: Nachricht in einen Kanal bei neuen Aufgaben, mit Link direkt auf die Aufgabe; einzurichten unter Admin → Teams - Sitzungen nach Bedarf laden: beim Start nur die aktuelle und drei davor, im Verlauf „Weitere laden“ in Fünferschritten - Termin aus Mail: Wahl Aufgabe/Meilenstein, Notiz auch bei Meilensteinen, nur aktiv mit eingerichteter KI - Kalender zeigt die KW, auch statt der Browser-Datumsfelder - Pfeile bei den Abwesenheiten, kleinere Server-Korrekturen Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
246 lines
11 KiB
JavaScript
246 lines
11 KiB
JavaScript
// -----------------------------
|
||
// Projektübersicht — 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 })
|
||
// -----------------------------
|
||
// Diese Datei wird nach js/utils.js von beiden HTML-Einstiegspunkten geladen
|
||
// und stellt den Kalender-Picker (Einzeldatum "openDatePicker" und Bereich
|
||
// "openRangePicker") bereit. Der Zustand des offenen Popups liegt in der
|
||
// Modulvariablen rangePickerState. Genutzt wird der Picker v. a. von
|
||
// js/load/load.js (LP-Bereiche), js/people/people.js (Abwesenheiten) und den
|
||
// Plan-Modulen unter js/plan/* (z. B. GANTT-Datumsangaben in plan-modals.js).
|
||
|
||
// rangePickerState: Zustand des aktuell geöffneten Picker-Popups (Optionen,
|
||
// Start-/Enddatum, Ansichtsmonat); null solange kein Popup offen ist.
|
||
let rangePickerState = null;
|
||
|
||
// openDatePicker: öffnet den Kalender zur Auswahl eines einzelnen Datums
|
||
// Parameter: opts mit inputId, optional anchor, displayId, onApply. Rückgabe: keine.
|
||
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 - 332)) + 'px';
|
||
pop.style.top = Math.min(anchorRect.bottom + 6, window.innerHeight - 340) + 'px';
|
||
|
||
renderRangePicker();
|
||
|
||
setTimeout(() => {
|
||
document.addEventListener('mousedown', rangePickerOutsideClick);
|
||
}, 0);
|
||
}
|
||
|
||
// openRangePicker: öffnet den Kalender zur Auswahl eines Von-Bis-Datumsbereichs
|
||
// (oder eines Einzeldatums bei mode:'single'). Parameter: opts mit startInputId,
|
||
// endInputId, optional anchor, displayId, mode, onApply. Rückgabe: keine.
|
||
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 - 332)) + '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);
|
||
}
|
||
|
||
// rangePickerOutsideClick: schließt das Popup, wenn außerhalb davon geklickt wird
|
||
// Parameter: e (Mousedown-Event). Rückgabe: keine.
|
||
function rangePickerOutsideClick(e){
|
||
const pop = document.getElementById('rangePickerPopup');
|
||
if(pop && !pop.contains(e.target)) closeRangePicker();
|
||
}
|
||
|
||
// closeRangePicker: entfernt das Popup wieder und setzt rangePickerState zurück
|
||
// Parameter: keine. Rückgabe: keine.
|
||
function closeRangePicker(){
|
||
const pop = document.getElementById('rangePickerPopup');
|
||
if(pop) pop.remove();
|
||
document.removeEventListener('mousedown', rangePickerOutsideClick);
|
||
rangePickerState = null;
|
||
}
|
||
|
||
// rangePickerNav: blättert im Kalender um einen Monat vor/zurück und rendert neu
|
||
// Parameter: delta (Number, -1 oder 1). Rückgabe: keine.
|
||
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();
|
||
}
|
||
|
||
// rangePickerSelect: übernimmt einen Klick auf einen Kalendertag; im Bereichsmodus
|
||
// wird beim ersten Klick der Start und beim zweiten das Ende (ggf. getauscht) gesetzt
|
||
// Parameter: iso (String, ausgewähltes Datum). Rückgabe: keine.
|
||
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();
|
||
}
|
||
|
||
// applyRangePicker: schreibt die gewählten Daten in die (versteckten) Inputs bzw.
|
||
// das Anzeige-Element, schließt das Popup und ruft den onApply-Callback auf
|
||
// Parameter: keine. Rückgabe: keine.
|
||
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) : '');
|
||
// Für umgeleitete native Datumsfelder (siehe unten): deren onchange/
|
||
// oninput-Handler sollen wie bei einer Eingabe von Hand laufen.
|
||
[startInput, endInput].forEach(el => {
|
||
if(el && el.type === 'date'){
|
||
el.dispatchEvent(new Event('input', { bubbles: true }));
|
||
el.dispatchEvent(new Event('change', { bubbles: true }));
|
||
}
|
||
});
|
||
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();
|
||
}
|
||
|
||
// renderRangePicker: baut die Kalender-HTML für den aktuellen Ansichtsmonat und
|
||
// zeigt Start/Ende-Auswahl markiert an. Parameter: keine. Rückgabe: keine.
|
||
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)">${icon('chevronLeft')}</button>
|
||
<span class="range-picker-title">${monthNames[st.viewMonth]} ${st.viewYear}</span>
|
||
<button type="button" class="secondary small" onclick="rangePickerNav(1)">${icon('chevronRight')}</button>
|
||
</div>
|
||
<div class="range-picker-hint">${hint}</div>
|
||
<div class="range-picker-grid">`;
|
||
// Erste Spalte: Kalenderwoche (Eriks Wunsch — im Büro wird in KW gedacht).
|
||
html += '<div class="range-picker-weekday range-picker-kw-kopf">KW</div>';
|
||
['Mo','Di','Mi','Do','Fr','Sa','So'].forEach(w => html += `<div class="range-picker-weekday">${w}</div>`);
|
||
const kwZelle = d => `<div class="range-picker-kw">${getWeekNumber(d)}</div>`;
|
||
if(firstWeekday > 0) html += kwZelle(first);
|
||
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);
|
||
if(d.getDay() === 1) html += kwZelle(d);
|
||
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;
|
||
}
|
||
// Native Datumsfelder (<input type="date">) öffnen statt des Browser-
|
||
// Kalenders (kennt keine KW) denselben Kalender wie der Rest der App. Das
|
||
// Feld selbst bleibt — Tippen funktioniert weiter, der Picker schreibt den
|
||
// ISO-Wert hinein und löst input/change aus. Ausnahme: data-nativ.
|
||
document.addEventListener('click', e => {
|
||
const input = e.target && e.target.closest ? e.target.closest('input[type="date"]') : null;
|
||
if(!input || input.disabled || input.readOnly || input.hasAttribute('data-nativ')) return;
|
||
e.preventDefault();
|
||
if(!input.id) input.id = 'datum_' + Math.random().toString(36).slice(2, 9);
|
||
openDatePicker({ anchor: input, inputId: input.id });
|
||
}, true);
|