b115b644c7
- Einheitlicher blauer Plus-Button unten rechts auf Mobile (tasks, calendar, notes, contacts, budget) — konsistent mit Dashboard-FAB - Toolbar-"Neu"-Buttons auf Mobile versteckt, auf Desktop weiterhin sichtbar - Wiederverwendbare .page-fab CSS-Klasse in layout.css - Dashboard-FAB Position an neue Nav-Höhe angepasst - Service Worker Cache v13 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
979 lines
35 KiB
JavaScript
979 lines
35 KiB
JavaScript
/**
|
||
* 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';
|
||
import { renderRRuleFields, bindRRuleEvents, getRRuleValues } from '/rrule-ui.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) {
|
||
try {
|
||
const res = await api.get(`/calendar?from=${from}&to=${to}`);
|
||
state.events = res.data;
|
||
} catch (err) {
|
||
console.error('[Calendar] loadRange Fehler:', err);
|
||
state.events = [];
|
||
window.oikos?.showToast('Termine konnten nicht geladen werden.', 'danger');
|
||
}
|
||
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 = `
|
||
<div class="calendar-page" id="calendar-page">
|
||
<div class="cal-toolbar" id="cal-toolbar"></div>
|
||
<div id="cal-body" style="flex:1;display:flex;flex-direction:column;overflow:hidden;"></div>
|
||
<button class="page-fab" id="fab-new-event" aria-label="Neuer Termin">
|
||
<i data-lucide="plus" style="width:24px;height:24px"></i>
|
||
</button>
|
||
</div>
|
||
`;
|
||
|
||
const { from, to } = getMonthRange(state.cursor);
|
||
await Promise.all([loadRange(from, to), loadUsers()]);
|
||
|
||
renderToolbar();
|
||
renderView();
|
||
|
||
container.querySelector('#fab-new-event')?.addEventListener('click', () => openEventModal({ mode: 'create' }));
|
||
}
|
||
|
||
// --------------------------------------------------------
|
||
// Toolbar
|
||
// --------------------------------------------------------
|
||
|
||
function renderToolbar() {
|
||
const bar = _container.querySelector('#cal-toolbar');
|
||
if (!bar) return;
|
||
|
||
bar.innerHTML = `
|
||
<div class="cal-toolbar__nav">
|
||
<button class="btn btn--icon" id="cal-prev" aria-label="Zurück">
|
||
<i data-lucide="chevron-left"></i>
|
||
</button>
|
||
</div>
|
||
<button class="cal-toolbar__today" id="cal-today">Heute</button>
|
||
<span class="cal-toolbar__label" id="cal-label"></span>
|
||
<div class="cal-toolbar__views">
|
||
${VIEWS.map((v) => `
|
||
<button class="cal-toolbar__view-btn ${v === state.view ? 'cal-toolbar__view-btn--active' : ''}"
|
||
data-view="${v}">${VIEW_LABELS[v]}</button>
|
||
`).join('')}
|
||
</div>
|
||
<button class="btn btn--primary btn--icon" id="cal-add" aria-label="Termin hinzufügen"
|
||
style="margin-left:auto;">
|
||
<i data-lucide="plus"></i>
|
||
</button>
|
||
<div class="cal-toolbar__nav">
|
||
<button class="btn btn--icon" id="cal-next" aria-label="Weiter">
|
||
<i data-lucide="chevron-right"></i>
|
||
</button>
|
||
</div>
|
||
`;
|
||
|
||
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 = `
|
||
<div class="month-view">
|
||
<div class="month-weekdays">
|
||
${['Mo','Di','Mi','Do','Fr','Sa','So'].map((n) => `<div class="month-weekday">${n}</div>`).join('')}
|
||
</div>
|
||
<div class="month-grid" id="month-grid">
|
||
${days.map(({ date, inMonth }) => renderMonthDay(date, inMonth)).join('')}
|
||
</div>
|
||
</div>
|
||
`;
|
||
|
||
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) => `
|
||
<div class="month-day__event"
|
||
data-id="${ev.id}"
|
||
style="background-color:${escHtml(ev.color)};"
|
||
title="${escHtml(ev.title)}"
|
||
>${escHtml(ev.title)}</div>
|
||
`).join('');
|
||
|
||
return `
|
||
<div class="${classes}" data-date="${date}">
|
||
<div class="month-day__number">${new Date(date + 'T00:00:00').getDate()}</div>
|
||
${evHtml}
|
||
${extra > 0 ? `<div class="month-day__more">+${extra} weitere</div>` : ''}
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
// --------------------------------------------------------
|
||
// 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 = `
|
||
<div class="week-view">
|
||
<div class="week-view__header" id="week-header"
|
||
style="display:grid;grid-template-columns:48px repeat(7,1fr);">
|
||
<div class="week-view__time-gutter"></div>
|
||
${days.map((d) => {
|
||
const dt = new Date(d + 'T00:00:00');
|
||
return `<div class="week-view__day-header">
|
||
<div class="week-view__day-name">${DAY_NAMES_SHORT[(dt.getDay())]}</div>
|
||
<div class="week-view__day-num ${d === state.today ? 'week-view__day-num--today' : ''}">${dt.getDate()}</div>
|
||
</div>`;
|
||
}).join('')}
|
||
</div>
|
||
<!-- Ganztägige Ereignisse -->
|
||
<div class="allday-row" style="display:grid;grid-template-columns:48px repeat(7,1fr);">
|
||
<div style="width:48px;padding:2px;font-size:10px;color:var(--color-text-disabled);text-align:right;padding-right:4px;line-height:24px;">ganztg.</div>
|
||
${days.map((d, i) => `
|
||
<div class="allday-cell">
|
||
${alldayEvs[i].map((ev) => `
|
||
<div class="allday-event" data-id="${ev.id}" style="background-color:${escHtml(ev.color)};"
|
||
title="${escHtml(ev.title)}">${escHtml(ev.title)}</div>
|
||
`).join('')}
|
||
</div>
|
||
`).join('')}
|
||
</div>
|
||
<div class="week-view__scroll" id="week-scroll">
|
||
<div class="week-view__body">
|
||
<div class="week-view__times">
|
||
${Array.from({ length: 24 }, (_, h) => `
|
||
<div class="week-view__time-slot" style="height:${HOUR_HEIGHT}px;">
|
||
<span class="week-view__time-label">${h === 0 ? '' : `${pad(h)}:00`}</span>
|
||
</div>
|
||
`).join('')}
|
||
</div>
|
||
<div class="week-view__columns" id="week-cols"
|
||
style="display:grid;grid-template-columns:repeat(7,1fr);">
|
||
${days.map((d, i) => `
|
||
<div class="week-view__col" data-date="${d}">
|
||
${Array.from({ length: 24 }, (_, h) => `
|
||
<div class="week-view__hour-line" style="top:${h * HOUR_HEIGHT}px;"></div>
|
||
`).join('')}
|
||
${timedEvs[i].map((ev) => renderWeekEvent(ev)).join('')}
|
||
${d === state.today ? `<div class="week-view__now-line" id="now-line" style="top:${nowTop()}px;"></div>` : ''}
|
||
</div>
|
||
`).join('')}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
|
||
// 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 `
|
||
<div class="week-event" data-id="${ev.id}"
|
||
style="top:${top}px;height:${height}px;background-color:${escHtml(ev.color)};">
|
||
<div class="week-event__title">${escHtml(ev.title)}</div>
|
||
<div class="week-event__time">${formatTime(ev.start_datetime)}${ev.end_datetime ? '–' + formatTime(ev.end_datetime) : ''}</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
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 = `
|
||
<div class="day-view">
|
||
<div class="day-view__header">
|
||
<div class="day-view__date-label">${formatDate(state.cursor, { weekday: true, long: true })}</div>
|
||
</div>
|
||
${allday.length ? `
|
||
<div class="allday-row" style="display:grid;grid-template-columns:48px 1fr;">
|
||
<div style="padding:2px 4px 2px 0;font-size:10px;color:var(--color-text-disabled);text-align:right;line-height:24px;">ganztg.</div>
|
||
<div class="allday-cell">
|
||
${allday.map((ev) => `
|
||
<div class="allday-event" data-id="${ev.id}" style="background-color:${escHtml(ev.color)};">
|
||
${escHtml(ev.title)}
|
||
</div>`).join('')}
|
||
</div>
|
||
</div>` : ''}
|
||
<div class="day-view__scroll" id="day-scroll">
|
||
<div class="day-view__body">
|
||
<div class="day-view__times">
|
||
${Array.from({ length: 24 }, (_, h) => `
|
||
<div class="week-view__time-slot" style="height:${HOUR_HEIGHT}px;">
|
||
<span class="week-view__time-label">${h === 0 ? '' : `${pad(h)}:00`}</span>
|
||
</div>
|
||
`).join('')}
|
||
</div>
|
||
<div class="day-view__col" data-date="${state.cursor}" id="day-col">
|
||
${Array.from({ length: 24 }, (_, h) => `
|
||
<div class="week-view__hour-line" style="top:${h * HOUR_HEIGHT}px;"></div>
|
||
`).join('')}
|
||
${timed.map((ev) => renderWeekEvent(ev)).join('')}
|
||
${state.cursor === state.today ? `<div class="week-view__now-line" style="top:${nowTop()}px;"></div>` : ''}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
|
||
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 = `
|
||
<div class="agenda-view" id="agenda-view">
|
||
${groups.length === 0
|
||
? `<div class="agenda-empty">Keine Termine im gewählten Zeitraum.</div>`
|
||
: groups.map(({ date, events }) => `
|
||
<div class="agenda-day">
|
||
<div class="agenda-day__header ${date === state.today ? 'agenda-day__header--today' : ''}">
|
||
<span class="agenda-day__date">${formatDate(date)}</span>
|
||
<span class="agenda-day__weekday">${DAY_NAMES_LONG[new Date(date + 'T00:00:00').getDay()]}</span>
|
||
</div>
|
||
${events.map((ev) => renderAgendaEvent(ev)).join('')}
|
||
</div>
|
||
`).join('')
|
||
}
|
||
</div>
|
||
`;
|
||
|
||
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 `
|
||
<div class="agenda-event" data-id="${ev.id}">
|
||
<div class="agenda-event__color" style="background-color:${escHtml(ev.color)};"></div>
|
||
<div class="agenda-event__body">
|
||
<div class="agenda-event__title">${escHtml(ev.title)}${ev.recurrence_rule ? ' <i data-lucide="repeat" style="width:12px;height:12px;display:inline;vertical-align:middle;opacity:0.5"></i>' : ''}</div>
|
||
<div class="agenda-event__meta">
|
||
<span>${timeStr}</span>
|
||
${ev.location ? `<span>📍 ${escHtml(ev.location)}</span>` : ''}
|
||
${ev.assigned_name ? `
|
||
<span class="agenda-event__assigned">
|
||
<span class="agenda-event__avatar" style="background-color:${escHtml(ev.assigned_color || '#8E8E93')}">${initials}</span>
|
||
${escHtml(ev.assigned_name)}
|
||
</span>` : ''}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
// --------------------------------------------------------
|
||
// 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 = `
|
||
<div class="event-popup__color-bar" style="background-color:${escHtml(ev.color)};"></div>
|
||
<div class="event-popup__title">${escHtml(ev.title)}</div>
|
||
<div class="event-popup__meta">
|
||
<div>${timeStr}</div>
|
||
${ev.location ? `<div>📍 ${escHtml(ev.location)}</div>` : ''}
|
||
${ev.description ? `<div>${escHtml(ev.description)}</div>` : ''}
|
||
${ev.assigned_name ? `<div>👤 ${escHtml(ev.assigned_name)}</div>` : ''}
|
||
</div>
|
||
<div class="event-popup__actions">
|
||
<button class="btn btn--secondary" style="flex:1;" id="popup-edit">Bearbeiten</button>
|
||
<button class="btn btn--danger" id="popup-delete">
|
||
<i data-lucide="trash-2" style="width:16px;height:16px;"></i>
|
||
</button>
|
||
</div>
|
||
`;
|
||
|
||
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();
|
||
|
||
// RRULE-Events binden
|
||
bindRRuleEvents(overlay, 'event');
|
||
|
||
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 = [
|
||
'<option value="">— Niemand —</option>',
|
||
...state.users.map((u) =>
|
||
`<option value="${u.id}" ${isEdit && event.assigned_to === u.id ? 'selected' : ''}>${escHtml(u.display_name)}</option>`
|
||
),
|
||
].join('');
|
||
|
||
return `
|
||
<div class="event-modal" role="dialog" aria-modal="true">
|
||
<div class="event-modal__header">
|
||
<h2 class="event-modal__title">${isEdit ? 'Termin bearbeiten' : 'Neuer Termin'}</h2>
|
||
<button class="event-modal__close" id="modal-close" aria-label="Schließen">
|
||
<i data-lucide="x" style="width:16px;height:16px;"></i>
|
||
</button>
|
||
</div>
|
||
<div class="event-modal__body">
|
||
<div class="form-group">
|
||
<label class="form-label" for="modal-title">Titel *</label>
|
||
<input type="text" class="form-input" id="modal-title"
|
||
placeholder="z.B. Zahnarzt" value="${escHtml(isEdit ? event.title : '')}">
|
||
</div>
|
||
|
||
<div class="form-group">
|
||
<label class="allday-toggle">
|
||
<input type="checkbox" id="modal-allday" ${isEdit && event.all_day ? 'checked' : ''}>
|
||
<span class="allday-toggle__label">Ganztägig</span>
|
||
</label>
|
||
</div>
|
||
|
||
<div id="time-fields">
|
||
<div class="event-modal__row">
|
||
<div class="form-group">
|
||
<label class="form-label" for="modal-start-date">Startdatum</label>
|
||
<input type="date" class="form-input" id="modal-start-date" value="${startDate}">
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label" for="modal-start-time">Startzeit</label>
|
||
<input type="time" class="form-input" id="modal-start-time" value="${startTime}">
|
||
</div>
|
||
</div>
|
||
<div class="event-modal__row">
|
||
<div class="form-group">
|
||
<label class="form-label" for="modal-end-date">Enddatum</label>
|
||
<input type="date" class="form-input" id="modal-end-date" value="${endDate}">
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label" for="modal-end-time">Endzeit</label>
|
||
<input type="time" class="form-input" id="modal-end-time" value="${endTime}">
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Ganztägig: nur Datum -->
|
||
<div id="allday-fields" style="display:none;">
|
||
<div class="event-modal__row">
|
||
<div class="form-group">
|
||
<label class="form-label" for="modal-allday-start">Von</label>
|
||
<input type="date" class="form-input" id="modal-allday-start" value="${startDate}">
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label" for="modal-allday-end">Bis</label>
|
||
<input type="date" class="form-input" id="modal-allday-end" value="${endDate}">
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="form-group">
|
||
<label class="form-label" for="modal-location">Ort</label>
|
||
<input type="text" class="form-input" id="modal-location"
|
||
placeholder="Optional" value="${escHtml(isEdit && event.location ? event.location : '')}">
|
||
</div>
|
||
|
||
<div class="form-group">
|
||
<label class="form-label" for="modal-assigned">Zugewiesen an</label>
|
||
<select class="form-input" id="modal-assigned">${userOpts}</select>
|
||
</div>
|
||
|
||
<div class="form-group">
|
||
<label class="form-label">Farbe</label>
|
||
<div class="color-picker">
|
||
${EVENT_COLORS.map((c) => `
|
||
<div class="color-swatch" data-color="${c}" style="background-color:${c};"
|
||
role="radio" tabindex="0" aria-label="Farbe ${c}"></div>
|
||
`).join('')}
|
||
</div>
|
||
</div>
|
||
|
||
<div class="form-group">
|
||
<label class="form-label" for="modal-description">Beschreibung</label>
|
||
<textarea class="form-input" id="modal-description" rows="2"
|
||
placeholder="Optional…">${escHtml(isEdit && event.description ? event.description : '')}</textarea>
|
||
</div>
|
||
|
||
${renderRRuleFields('event', isEdit ? event.recurrence_rule : null)}
|
||
</div>
|
||
<div class="event-modal__footer">
|
||
${isEdit ? `<button class="btn btn--danger btn--icon" id="modal-delete" title="Löschen">
|
||
<i data-lucide="trash-2" style="width:16px;height:16px;"></i>
|
||
</button>` : '<div></div>'}
|
||
<div class="event-modal__footer-actions">
|
||
<button class="btn btn--secondary" id="modal-cancel">Abbrechen</button>
|
||
<button class="btn btn--primary" id="modal-save">${isEdit ? 'Speichern' : 'Erstellen'}</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
// 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 rrule = getRRuleValues(overlay, 'event');
|
||
const body = {
|
||
title, description, start_datetime, end_datetime,
|
||
all_day: allday ? 1 : 0,
|
||
location, color, assigned_to: assigned_to ? parseInt(assigned_to, 10) : null,
|
||
recurrence_rule: rrule.recurrence_rule,
|
||
};
|
||
|
||
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, '>')
|
||
.replace(/"/g, '"');
|
||
}
|