/** * Modul: Kalender (Calendar) * Zweck: Monats-/Wochen-/Tages-/Agenda-Ansicht mit vollem Termin-CRUD * Abhängigkeiten: /api.js, /router.js (window.oikos) */ import { api } from '/api.js'; // -------------------------------------------------------- // Konstanten // -------------------------------------------------------- const VIEWS = ['month', 'week', 'day', 'agenda']; const VIEW_LABELS = { month: 'Monat', week: 'Woche', day: 'Tag', agenda: 'Agenda' }; const DAY_NAMES_SHORT = ['So', 'Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa']; const DAY_NAMES_LONG = ['Sonntag', 'Montag', 'Dienstag', 'Mittwoch', 'Donnerstag', 'Freitag', 'Samstag']; const MONTH_NAMES = ['Januar', 'Februar', 'März', 'April', 'Mai', 'Juni', 'Juli', 'August', 'September', 'Oktober', 'November', 'Dezember']; const EVENT_COLORS = [ '#007AFF', '#34C759', '#FF9500', '#FF3B30', '#AF52DE', '#FF6B35', '#5AC8FA', '#FFCC00', '#8E8E93', '#30B0C7', ]; const HOUR_HEIGHT = 56; // px pro Stunde in Wochen-/Tagesansicht // -------------------------------------------------------- // State // -------------------------------------------------------- let state = { view: 'month', today: '', cursor: null, // aktuell angezeigte Referenz-Datum (YYYY-MM-DD) events: [], users: [], rangeFrom: '', rangeTo: '', }; let _container = null; // -------------------------------------------------------- // Datumshelfer // -------------------------------------------------------- function pad(n) { return String(n).padStart(2, '0'); } function isoDate(d) { return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`; } function addMonths(dateStr, n) { const d = new Date(dateStr + 'T00:00:00'); d.setMonth(d.getMonth() + n); return isoDate(d); } function addDays(dateStr, n) { const d = new Date(dateStr + 'T00:00:00'); d.setDate(d.getDate() + n); return isoDate(d); } function getMondayOf(dateStr) { const d = new Date(dateStr + 'T00:00:00'); const day = d.getDay(); const diff = (day === 0 ? -6 : 1 - day); d.setDate(d.getDate() + diff); return isoDate(d); } function formatDate(dateStr, { long = false, weekday = false } = {}) { const d = new Date(dateStr + 'T00:00:00'); const day = d.getDate(); const mon = MONTH_NAMES[d.getMonth()]; if (weekday) { const wd = long ? DAY_NAMES_LONG[d.getDay()] : DAY_NAMES_SHORT[d.getDay()]; return `${wd}, ${day}. ${mon}`; } return `${day}. ${mon} ${d.getFullYear()}`; } function formatTime(datetimeStr) { if (!datetimeStr) return ''; const t = datetimeStr.slice(11, 16); return t || ''; } function formatDateTime(datetimeStr) { if (!datetimeStr) return ''; const date = datetimeStr.slice(0, 10); const time = datetimeStr.slice(11, 16); return time ? `${formatDate(date)} ${time} Uhr` : formatDate(date); } function getMonthRange(dateStr) { const d = new Date(dateStr + 'T00:00:00'); const year = d.getFullYear(); const month = d.getMonth(); const from = `${year}-${pad(month + 1)}-01`; // Extra Tage für Kalenderraster (6 Wochen × 7 = 42 Tage) const to = addDays(from, 41); return { from, to }; } function getWeekRange(dateStr) { const monday = getMondayOf(dateStr); return { from: monday, to: addDays(monday, 6) }; } function getAgendaRange(dateStr) { return { from: dateStr, to: addDays(dateStr, 30) }; } function eventsOnDay(dateStr) { return state.events.filter((e) => { const start = e.start_datetime.slice(0, 10); const end = e.end_datetime ? e.end_datetime.slice(0, 10) : start; return start <= dateStr && end >= dateStr; }); } // -------------------------------------------------------- // API // -------------------------------------------------------- async function loadRange(from, to) { const res = await api.get(`/calendar?from=${from}&to=${to}`); state.events = res.data; state.rangeFrom = from; state.rangeTo = to; } async function loadUsers() { try { const res = await api.get('/auth/users'); state.users = res.data; } catch { state.users = []; } } // -------------------------------------------------------- // Entry Point // -------------------------------------------------------- export async function render(container, { user }) { _container = container; state.today = isoDate(new Date()); state.cursor = state.today; state.view = 'month'; container.innerHTML = `
`; const { from, to } = getMonthRange(state.cursor); await Promise.all([loadRange(from, to), loadUsers()]); renderToolbar(); renderView(); } // -------------------------------------------------------- // Toolbar // -------------------------------------------------------- function renderToolbar() { const bar = _container.querySelector('#cal-toolbar'); if (!bar) return; bar.innerHTML = `
${VIEWS.map((v) => ` `).join('')}
`; if (window.lucide) lucide.createIcons(); updateLabel(); bar.querySelector('#cal-prev').addEventListener('click', () => navigate(-1)); bar.querySelector('#cal-next').addEventListener('click', () => navigate(1)); bar.querySelector('#cal-today').addEventListener('click', goToday); bar.querySelector('#cal-add').addEventListener('click', () => openEventModal({ mode: 'create' })); bar.querySelectorAll('[data-view]').forEach((btn) => { btn.addEventListener('click', async () => { if (btn.dataset.view === state.view) return; state.view = btn.dataset.view; bar.querySelectorAll('[data-view]').forEach((b) => b.classList.toggle('cal-toolbar__view-btn--active', b.dataset.view === state.view) ); await reloadForView(); renderView(); }); }); } function updateLabel() { const lbl = _container.querySelector('#cal-label'); if (!lbl) return; const d = new Date(state.cursor + 'T00:00:00'); const year = d.getFullYear(); const mon = MONTH_NAMES[d.getMonth()]; if (state.view === 'month') lbl.textContent = `${mon} ${year}`; if (state.view === 'week') lbl.textContent = `KW ${getWeekNumber(state.cursor)} · ${mon} ${year}`; if (state.view === 'day') lbl.textContent = formatDate(state.cursor, { weekday: true, long: true }); if (state.view === 'agenda') lbl.textContent = `Ab ${formatDate(state.cursor)}`; } function getWeekNumber(dateStr) { const d = new Date(dateStr + 'T00:00:00'); const jan = new Date(d.getFullYear(), 0, 1); return Math.ceil(((d - jan) / 86400000 + jan.getDay() + 1) / 7); } async function navigate(dir) { if (state.view === 'month') { state.cursor = addMonths(state.cursor, dir); } else if (state.view === 'week') { state.cursor = addDays(state.cursor, dir * 7); } else if (state.view === 'day') { state.cursor = addDays(state.cursor, dir); } else if (state.view === 'agenda') { state.cursor = addDays(state.cursor, dir * 30); } await reloadForView(); updateLabel(); renderView(); } async function goToday() { state.cursor = state.today; await reloadForView(); updateLabel(); renderView(); } async function reloadForView() { let from, to; if (state.view === 'month') ({ from, to } = getMonthRange(state.cursor)); if (state.view === 'week') ({ from, to } = getWeekRange(state.cursor)); if (state.view === 'day') { from = state.cursor; to = state.cursor; } if (state.view === 'agenda') ({ from, to } = getAgendaRange(state.cursor)); if (from !== state.rangeFrom || to !== state.rangeTo) { await loadRange(from, to); } } // -------------------------------------------------------- // Ansicht-Dispatcher // -------------------------------------------------------- function renderView() { const body = _container.querySelector('#cal-body'); if (!body) return; body.innerHTML = ''; if (state.view === 'month') renderMonthView(body); if (state.view === 'week') renderWeekView(body); if (state.view === 'day') renderDayView(body); if (state.view === 'agenda') renderAgendaView(body); } // -------------------------------------------------------- // Monatsansicht // -------------------------------------------------------- function renderMonthView(container) { const d = new Date(state.cursor + 'T00:00:00'); const year = d.getFullYear(); const month = d.getMonth(); // Erster Tag des Monats const firstDay = new Date(year, month, 1); // Montag-basiert: 0=Mo … 6=So let startOffset = firstDay.getDay() - 1; if (startOffset < 0) startOffset = 6; // 42 Tage anzeigen (6 Wochen) const startDate = new Date(firstDay); startDate.setDate(startDate.getDate() - startOffset); const days = Array.from({ length: 42 }, (_, i) => { const dt = new Date(startDate); dt.setDate(startDate.getDate() + i); return { date: isoDate(dt), inMonth: dt.getMonth() === month }; }); container.innerHTML = `
${['Mo','Di','Mi','Do','Fr','Sa','So'].map((n) => `
${n}
`).join('')}
${days.map(({ date, inMonth }) => renderMonthDay(date, inMonth)).join('')}
`; container.querySelector('#month-grid').addEventListener('click', (e) => { const evEl = e.target.closest('.month-day__event'); if (evEl) { e.stopPropagation(); const ev = state.events.find((ev) => ev.id === parseInt(evEl.dataset.id, 10)); if (ev) showEventPopup(ev, evEl); return; } const dayEl = e.target.closest('.month-day'); if (dayEl) { openEventModal({ mode: 'create', date: dayEl.dataset.date }); } }); } function renderMonthDay(date, inMonth) { const evs = eventsOnDay(date); const isToday = date === state.today; const classes = [ 'month-day', !inMonth ? 'month-day--outside' : '', isToday ? 'month-day--today' : '', ].filter(Boolean).join(' '); const MAX_SHOW = 3; const shown = evs.slice(0, MAX_SHOW); const extra = evs.length - MAX_SHOW; const evHtml = shown.map((ev) => `
${escHtml(ev.title)}
`).join(''); return `
${new Date(date + 'T00:00:00').getDate()}
${evHtml} ${extra > 0 ? `
+${extra} weitere
` : ''}
`; } // -------------------------------------------------------- // Wochenansicht // -------------------------------------------------------- function renderWeekView(container) { const monday = getMondayOf(state.cursor); const days = Array.from({ length: 7 }, (_, i) => addDays(monday, i)); const alldayEvs = days.map((d) => eventsOnDay(d).filter((e) => e.all_day || !e.start_datetime.includes('T')) ); const timedEvs = days.map((d) => eventsOnDay(d).filter((e) => !e.all_day && e.start_datetime.includes('T')) ); container.innerHTML = `
${days.map((d) => { const dt = new Date(d + 'T00:00:00'); return `
${DAY_NAMES_SHORT[(dt.getDay())]}
${dt.getDate()}
`; }).join('')}
ganztg.
${days.map((d, i) => `
${alldayEvs[i].map((ev) => `
${escHtml(ev.title)}
`).join('')}
`).join('')}
${Array.from({ length: 24 }, (_, h) => `
${h === 0 ? '' : `${pad(h)}:00`}
`).join('')}
${days.map((d, i) => `
${Array.from({ length: 24 }, (_, h) => `
`).join('')} ${timedEvs[i].map((ev) => renderWeekEvent(ev)).join('')} ${d === state.today ? `
` : ''}
`).join('')}
`; // Event-Delegation container.querySelector('#week-cols').addEventListener('click', (e) => { const evEl = e.target.closest('.week-event'); if (evEl) { const ev = state.events.find((ev) => ev.id === parseInt(evEl.dataset.id, 10)); if (ev) showEventPopup(ev, evEl); return; } const col = e.target.closest('[data-date]'); if (col) openEventModal({ mode: 'create', date: col.dataset.date }); }); container.querySelector('.allday-row').addEventListener('click', (e) => { const evEl = e.target.closest('.allday-event'); if (evEl) { const ev = state.events.find((ev) => ev.id === parseInt(evEl.dataset.id, 10)); if (ev) showEventPopup(ev, evEl); } }); // Scrollen zu aktueller Zeit const scroll = container.querySelector('#week-scroll'); if (scroll) { const h = new Date().getHours(); scroll.scrollTop = Math.max(0, h * HOUR_HEIGHT - 80); } } function renderWeekEvent(ev) { const start = timeToMinutes(ev.start_datetime.slice(11, 16)); const end = ev.end_datetime ? timeToMinutes(ev.end_datetime.slice(11, 16)) : start + 60; const duration = Math.max(end - start, 30); const top = (start / 60) * HOUR_HEIGHT; const height = (duration / 60) * HOUR_HEIGHT - 2; return `
${escHtml(ev.title)}
${formatTime(ev.start_datetime)}${ev.end_datetime ? '–' + formatTime(ev.end_datetime) : ''}
`; } function timeToMinutes(timeStr) { if (!timeStr) return 0; const [h, m] = timeStr.split(':').map(Number); return h * 60 + (m || 0); } function nowTop() { const now = new Date(); const minutes = now.getHours() * 60 + now.getMinutes(); return (minutes / 60) * HOUR_HEIGHT; } // -------------------------------------------------------- // Tagesansicht // -------------------------------------------------------- function renderDayView(container) { const dt = new Date(state.cursor + 'T00:00:00'); const dayEvs = eventsOnDay(state.cursor); const allday = dayEvs.filter((e) => e.all_day || !e.start_datetime.includes('T')); const timed = dayEvs.filter((e) => !e.all_day && e.start_datetime.includes('T')); container.innerHTML = `
${formatDate(state.cursor, { weekday: true, long: true })}
${allday.length ? `
ganztg.
${allday.map((ev) => `
${escHtml(ev.title)}
`).join('')}
` : ''}
${Array.from({ length: 24 }, (_, h) => `
${h === 0 ? '' : `${pad(h)}:00`}
`).join('')}
${Array.from({ length: 24 }, (_, h) => `
`).join('')} ${timed.map((ev) => renderWeekEvent(ev)).join('')} ${state.cursor === state.today ? `
` : ''}
`; container.querySelector('#day-col').addEventListener('click', (e) => { const evEl = e.target.closest('.week-event'); if (evEl) { const ev = state.events.find((ev) => ev.id === parseInt(evEl.dataset.id, 10)); if (ev) showEventPopup(ev, evEl); return; } openEventModal({ mode: 'create', date: state.cursor }); }); const scroll = container.querySelector('#day-scroll'); if (scroll) { const h = new Date().getHours(); scroll.scrollTop = Math.max(0, h * HOUR_HEIGHT - 80); } } // -------------------------------------------------------- // Agenda-Ansicht // -------------------------------------------------------- function renderAgendaView(container) { const { from, to } = getAgendaRange(state.cursor); const days = Array.from({ length: 31 }, (_, i) => addDays(from, i)); const groups = days .map((d) => ({ date: d, events: eventsOnDay(d) })) .filter((g) => g.events.length > 0); container.innerHTML = `
${groups.length === 0 ? `
Keine Termine im gewählten Zeitraum.
` : groups.map(({ date, events }) => `
${formatDate(date)} ${DAY_NAMES_LONG[new Date(date + 'T00:00:00').getDay()]}
${events.map((ev) => renderAgendaEvent(ev)).join('')}
`).join('') }
`; container.querySelector('#agenda-view').addEventListener('click', (e) => { const evEl = e.target.closest('.agenda-event'); if (evEl) { const ev = state.events.find((ev) => ev.id === parseInt(evEl.dataset.id, 10)); if (ev) showEventPopup(ev, evEl); } }); } function renderAgendaEvent(ev) { const timeStr = ev.all_day ? 'Ganztägig' : formatTime(ev.start_datetime) + (ev.end_datetime ? ` – ${formatTime(ev.end_datetime)} Uhr` : ' Uhr'); const initials = ev.assigned_name ? ev.assigned_name.split(' ').map((w) => w[0]).join('').toUpperCase().slice(0, 2) : ''; return `
${escHtml(ev.title)}
${timeStr} ${ev.location ? `📍 ${escHtml(ev.location)}` : ''} ${ev.assigned_name ? ` ${initials} ${escHtml(ev.assigned_name)} ` : ''}
`; } // -------------------------------------------------------- // Event-Popup (Detail-Ansicht bei Klick auf Termin) // -------------------------------------------------------- function showEventPopup(ev, anchor) { document.querySelector('#event-popup')?.remove(); const popup = document.createElement('div'); popup.id = 'event-popup'; popup.className = 'event-popup'; const timeStr = ev.all_day ? 'Ganztägig' : formatDateTime(ev.start_datetime) + (ev.end_datetime ? ` – ${formatTime(ev.end_datetime)} Uhr` : ''); popup.innerHTML = `
${escHtml(ev.title)}
${timeStr}
${ev.location ? `
📍 ${escHtml(ev.location)}
` : ''} ${ev.description ? `
${escHtml(ev.description)}
` : ''} ${ev.assigned_name ? `
👤 ${escHtml(ev.assigned_name)}
` : ''}
`; document.body.appendChild(popup); if (window.lucide) lucide.createIcons(); // Positionierung const rect = anchor.getBoundingClientRect(); const top = Math.min(rect.bottom + 8, window.innerHeight - 280); const left = Math.min(rect.left, window.innerWidth - 340); popup.style.top = `${Math.max(8, top)}px`; popup.style.left = `${Math.max(8, left)}px`; popup.querySelector('#popup-edit').addEventListener('click', () => { popup.remove(); openEventModal({ mode: 'edit', event: ev }); }); popup.querySelector('#popup-delete').addEventListener('click', async () => { if (!confirm(`"${ev.title}" wirklich löschen?`)) return; popup.remove(); await deleteEvent(ev.id); }); // Schließen bei Klick außerhalb setTimeout(() => { document.addEventListener('click', function closePopup(e) { if (!popup.contains(e.target)) { popup.remove(); document.removeEventListener('click', closePopup); } }); }, 0); } // -------------------------------------------------------- // Event-Modal (Erstellen / Bearbeiten) // -------------------------------------------------------- function openEventModal({ mode, event = null, date = null }) { document.querySelector('#event-modal-overlay')?.remove(); const overlay = document.createElement('div'); overlay.id = 'event-modal-overlay'; overlay.className = 'event-modal-overlay'; overlay.innerHTML = buildEventModalHTML({ mode, event, date }); document.body.appendChild(overlay); if (window.lucide) lucide.createIcons(); const isEdit = mode === 'edit'; const selectedColor = isEdit ? (event?.color || EVENT_COLORS[0]) : EVENT_COLORS[0]; // Farb-Auswahl overlay.querySelectorAll('.color-swatch').forEach((sw) => { sw.addEventListener('click', () => { overlay.querySelectorAll('.color-swatch').forEach((s) => s.classList.remove('color-swatch--active')); sw.classList.add('color-swatch--active'); }); }); // Initial aktive Farbe markieren overlay.querySelectorAll('.color-swatch').forEach((sw) => { if (sw.dataset.color === selectedColor) sw.classList.add('color-swatch--active'); }); // Ganztägig-Toggle const alldayCheck = overlay.querySelector('#modal-allday'); const timeFields = overlay.querySelector('#time-fields'); alldayCheck.addEventListener('change', () => { timeFields.style.display = alldayCheck.checked ? 'none' : ''; }); if (isEdit && event?.all_day) timeFields.style.display = 'none'; // Schließen overlay.querySelector('#modal-close').addEventListener('click', closeEventModal); overlay.querySelector('#modal-cancel').addEventListener('click', closeEventModal); overlay.addEventListener('click', (e) => { if (e.target === overlay) closeEventModal(); }); // Löschen (nur Edit) overlay.querySelector('#modal-delete')?.addEventListener('click', async () => { if (!confirm(`"${event.title}" wirklich löschen?`)) return; closeEventModal(); await deleteEvent(event.id); }); // Speichern overlay.querySelector('#modal-save').addEventListener('click', () => saveEvent(overlay, mode, event?.id)); overlay.querySelector('#modal-title').focus(); } function buildEventModalHTML({ mode, event, date }) { const isEdit = mode === 'edit'; const today = date || state.today; const startDate = isEdit ? event.start_datetime.slice(0, 10) : today; const startTime = isEdit && event.start_datetime.length > 10 ? event.start_datetime.slice(11, 16) : '09:00'; const endDate = isEdit && event.end_datetime ? event.end_datetime.slice(0, 10) : startDate; const endTime = isEdit && event.end_datetime && event.end_datetime.length > 10 ? event.end_datetime.slice(11, 16) : '10:00'; const userOpts = [ '', ...state.users.map((u) => `` ), ].join(''); return ` `; } // Allday-Toggle: Felder umschalten document.addEventListener('change', (e) => { if (e.target.id !== 'modal-allday') return; const tf = document.querySelector('#time-fields'); const af = document.querySelector('#allday-fields'); if (!tf || !af) return; if (e.target.checked) { tf.style.display = 'none'; af.style.display = ''; } else { tf.style.display = ''; af.style.display = 'none'; } }); function closeEventModal() { document.querySelector('#event-modal-overlay')?.remove(); } async function saveEvent(overlay, mode, eventId) { const saveBtn = overlay.querySelector('#modal-save'); const title = overlay.querySelector('#modal-title').value.trim(); if (!title) { window.oikos?.showToast('Titel ist erforderlich', 'error'); return; } const allday = overlay.querySelector('#modal-allday').checked; const color = overlay.querySelector('.color-swatch--active')?.dataset.color || EVENT_COLORS[0]; const location = overlay.querySelector('#modal-location').value.trim() || null; const assigned_to = overlay.querySelector('#modal-assigned').value || null; const description = overlay.querySelector('#modal-description').value.trim() || null; let start_datetime, end_datetime; if (allday) { start_datetime = overlay.querySelector('#modal-allday-start')?.value || overlay.querySelector('#modal-start-date').value; end_datetime = overlay.querySelector('#modal-allday-end')?.value || overlay.querySelector('#modal-end-date').value; end_datetime = end_datetime || null; } else { const sd = overlay.querySelector('#modal-start-date').value; const st = overlay.querySelector('#modal-start-time').value; const ed = overlay.querySelector('#modal-end-date').value; const et = overlay.querySelector('#modal-end-time').value; start_datetime = st ? `${sd}T${st}` : sd; end_datetime = et ? `${ed}T${et}` : (ed || null); } saveBtn.disabled = true; saveBtn.textContent = '…'; try { const body = { title, description, start_datetime, end_datetime, all_day: allday ? 1 : 0, location, color, assigned_to: assigned_to ? parseInt(assigned_to, 10) : null, }; if (mode === 'create') { const res = await api.post('/calendar', body); state.events.push(res.data); } else { const res = await api.put(`/calendar/${eventId}`, body); const idx = state.events.findIndex((e) => e.id === eventId); if (idx !== -1) state.events[idx] = res.data; } closeEventModal(); renderView(); window.oikos?.showToast(mode === 'create' ? 'Termin erstellt' : 'Termin gespeichert', 'success'); } catch (err) { window.oikos?.showToast(err.data?.error ?? 'Fehler beim Speichern', 'error'); saveBtn.disabled = false; saveBtn.textContent = mode === 'edit' ? 'Speichern' : 'Erstellen'; } } async function deleteEvent(id) { try { await api.delete(`/calendar/${id}`); state.events = state.events.filter((e) => e.id !== id); renderView(); window.oikos?.showToast('Termin gelöscht', 'success'); } catch (err) { window.oikos?.showToast(err.data?.error ?? 'Fehler beim Löschen', 'error'); } } // -------------------------------------------------------- // Hilfsfunktion // -------------------------------------------------------- function escHtml(str) { if (!str) return ''; return String(str) .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"'); }