feat: Phase 3 Schritt 13 — Kalender-Modul (Monats-/Wochen-/Tages-/Agenda-Ansicht)
- server/routes/calendar.js: vollständige REST-API (GET Bereich, GET /upcoming, GET /:id, POST, PUT /:id, DELETE /:id) mit Datumsbereichs-Filter, assigned_to/source-Filter, external_source-Constraint - public/pages/calendar.js: Monatsansicht (42-Tage-Raster), Wochenansicht (Stunden-Timeline, ganztägige Zeile, Jetzt-Linie), Tagesansicht, Agenda-Ansicht (30-Tage-Liste); Termin-Popup bei Klick; volles CRUD-Modal (Farb-Auswahl, Ganztägig-Toggle, Zuweisung an Familienmitglied) - public/styles/calendar.css: Toolbar, Monatsraster, Wochen-/Tages-Spalten, Termin-Karten, Popup, Modal, Ganztags-Zeile - test-calendar.js: 19 Tests (CRUD, Datumsbereich, mehrtägige Termine, Constraints, Index-Checks, Datumshelfer) - package.json: test:calendar + Gesamt-Test-Suite erweitert - public/index.html: calendar.css eingebunden Gesamt: 112 Tests bestanden (29+8+17+17+22+19) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+2
-1
@@ -12,7 +12,8 @@
|
|||||||
"test:tasks": "node --experimental-sqlite test-tasks.js",
|
"test:tasks": "node --experimental-sqlite test-tasks.js",
|
||||||
"test:shopping": "node --experimental-sqlite test-shopping.js",
|
"test:shopping": "node --experimental-sqlite test-shopping.js",
|
||||||
"test:meals": "node --experimental-sqlite test-meals.js",
|
"test:meals": "node --experimental-sqlite test-meals.js",
|
||||||
"test": "node --experimental-sqlite test-db.js && node --experimental-sqlite test-dashboard.js && node --experimental-sqlite test-tasks.js && node --experimental-sqlite test-shopping.js && node --experimental-sqlite test-meals.js"
|
"test:calendar": "node --experimental-sqlite test-calendar.js",
|
||||||
|
"test": "node --experimental-sqlite test-db.js && node --experimental-sqlite test-dashboard.js && node --experimental-sqlite test-tasks.js && node --experimental-sqlite test-shopping.js && node --experimental-sqlite test-meals.js && node --experimental-sqlite test-calendar.js"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"bcrypt": "^5.1.1",
|
"bcrypt": "^5.1.1",
|
||||||
|
|||||||
@@ -20,6 +20,7 @@
|
|||||||
<link rel="stylesheet" href="/styles/tasks.css" />
|
<link rel="stylesheet" href="/styles/tasks.css" />
|
||||||
<link rel="stylesheet" href="/styles/shopping.css" />
|
<link rel="stylesheet" href="/styles/shopping.css" />
|
||||||
<link rel="stylesheet" href="/styles/meals.css" />
|
<link rel="stylesheet" href="/styles/meals.css" />
|
||||||
|
<link rel="stylesheet" href="/styles/calendar.css" />
|
||||||
|
|
||||||
<!-- Lucide Icons (CDN) -->
|
<!-- Lucide Icons (CDN) -->
|
||||||
<script src="https://unpkg.com/lucide@latest/dist/umd/lucide.min.js"></script>
|
<script src="https://unpkg.com/lucide@latest/dist/umd/lucide.min.js"></script>
|
||||||
|
|||||||
+945
-13
@@ -1,25 +1,957 @@
|
|||||||
/**
|
/**
|
||||||
* Modul: Calendar
|
* Modul: Kalender (Calendar)
|
||||||
* Zweck: Seite für das Calendar-Modul
|
* Zweck: Monats-/Wochen-/Tages-/Agenda-Ansicht mit vollem Termin-CRUD
|
||||||
* Abhängigkeiten: /api.js
|
* Abhängigkeiten: /api.js, /router.js (window.oikos)
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { api } from '/api.js';
|
import { api } from '/api.js';
|
||||||
|
|
||||||
/**
|
// --------------------------------------------------------
|
||||||
* @param {HTMLElement} container
|
// Konstanten
|
||||||
* @param {{ user: object }} context
|
// --------------------------------------------------------
|
||||||
*/
|
|
||||||
|
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: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
// --------------------------------------------------------
|
||||||
|
// 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('/users');
|
||||||
|
state.users = res.data;
|
||||||
|
} catch {
|
||||||
|
state.users = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --------------------------------------------------------
|
||||||
|
// Entry Point
|
||||||
|
// --------------------------------------------------------
|
||||||
|
|
||||||
export async function render(container, { user }) {
|
export async function render(container, { user }) {
|
||||||
|
state.today = isoDate(new Date());
|
||||||
|
state.cursor = state.today;
|
||||||
|
state.view = 'month';
|
||||||
|
|
||||||
container.innerHTML = `
|
container.innerHTML = `
|
||||||
<div class="page">
|
<div class="calendar-page" id="calendar-page">
|
||||||
<div class="page__header">
|
<div class="cal-toolbar" id="cal-toolbar"></div>
|
||||||
<h1 class="page__title">Calendar</h1>
|
<div id="cal-body" style="flex:1;display:flex;flex-direction:column;overflow:hidden;"></div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
const { from, to } = getMonthRange(state.cursor);
|
||||||
|
await Promise.all([loadRange(from, to), loadUsers()]);
|
||||||
|
|
||||||
|
renderToolbar();
|
||||||
|
renderView();
|
||||||
|
}
|
||||||
|
|
||||||
|
// --------------------------------------------------------
|
||||||
|
// Toolbar
|
||||||
|
// --------------------------------------------------------
|
||||||
|
|
||||||
|
function renderToolbar() {
|
||||||
|
const bar = document.getElementById('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 = document.getElementById('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 = document.getElementById('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>
|
||||||
<div class="empty-state">
|
<div class="month-grid" id="month-grid">
|
||||||
<div class="empty-state__title">Kommt bald.</div>
|
${days.map(({ date, inMonth }) => renderMonthDay(date, inMonth)).join('')}
|
||||||
<div class="empty-state__description">Dieses Modul wird in Phase 2 implementiert.</div>
|
</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)}</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>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --------------------------------------------------------
|
||||||
|
// Event-Popup (Detail-Ansicht bei Klick auf Termin)
|
||||||
|
// --------------------------------------------------------
|
||||||
|
|
||||||
|
function showEventPopup(ev, anchor) {
|
||||||
|
document.getElementById('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.getElementById('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 = [
|
||||||
|
'<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>
|
||||||
|
</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.getElementById('time-fields');
|
||||||
|
const af = document.getElementById('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.getElementById('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, '>')
|
||||||
|
.replace(/"/g, '"');
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,718 @@
|
|||||||
|
/**
|
||||||
|
* Modul: Kalender (Calendar)
|
||||||
|
* Zweck: Styles für Monats-/Wochen-/Tages-/Agenda-Ansicht, Termin-Karten, Modal
|
||||||
|
* Abhängigkeiten: tokens.css, layout.css
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* --------------------------------------------------------
|
||||||
|
* Seiten-Layout
|
||||||
|
* -------------------------------------------------------- */
|
||||||
|
.calendar-page {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: calc(100dvh - var(--nav-height-mobile) - var(--safe-area-inset-bottom));
|
||||||
|
max-width: var(--content-max-width);
|
||||||
|
margin: 0 auto;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 1024px) {
|
||||||
|
.calendar-page {
|
||||||
|
height: 100dvh;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --------------------------------------------------------
|
||||||
|
* Kalender-Toolbar (Navigation + Ansichts-Umschalter)
|
||||||
|
* -------------------------------------------------------- */
|
||||||
|
.cal-toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
padding: var(--space-3) var(--space-4);
|
||||||
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
background-color: var(--color-surface);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cal-toolbar__nav {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cal-toolbar__label {
|
||||||
|
font-size: var(--text-base);
|
||||||
|
font-weight: var(--font-weight-semibold);
|
||||||
|
flex: 1;
|
||||||
|
text-align: center;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cal-toolbar__today {
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--color-accent);
|
||||||
|
font-weight: var(--font-weight-medium);
|
||||||
|
padding: var(--space-1) var(--space-2);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: var(--color-accent-light);
|
||||||
|
cursor: pointer;
|
||||||
|
border: none;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cal-toolbar__views {
|
||||||
|
display: flex;
|
||||||
|
gap: 2px;
|
||||||
|
background-color: var(--color-surface-2);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
padding: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cal-toolbar__view-btn {
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
font-weight: var(--font-weight-medium);
|
||||||
|
padding: var(--space-1) var(--space-2);
|
||||||
|
border-radius: 6px;
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all var(--transition-fast);
|
||||||
|
min-height: unset;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cal-toolbar__view-btn--active {
|
||||||
|
background-color: var(--color-surface);
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --------------------------------------------------------
|
||||||
|
* Monatsansicht
|
||||||
|
* -------------------------------------------------------- */
|
||||||
|
.month-view {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.month-weekdays {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(7, 1fr);
|
||||||
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.month-weekday {
|
||||||
|
padding: var(--space-2) 0;
|
||||||
|
text-align: center;
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
font-weight: var(--font-weight-semibold);
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.month-grid {
|
||||||
|
flex: 1;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(7, 1fr);
|
||||||
|
grid-auto-rows: 1fr;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.month-day {
|
||||||
|
border-right: 1px solid var(--color-border);
|
||||||
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
padding: var(--space-1);
|
||||||
|
min-height: 80px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color var(--transition-fast);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.month-day:nth-child(7n) {
|
||||||
|
border-right: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.month-day:hover {
|
||||||
|
background-color: var(--color-surface-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.month-day--outside {
|
||||||
|
background-color: var(--color-bg);
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.month-day__number {
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
font-weight: var(--font-weight-medium);
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
width: 26px;
|
||||||
|
height: 26px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
margin-bottom: var(--space-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.month-day--today .month-day__number {
|
||||||
|
background-color: var(--color-accent);
|
||||||
|
color: #ffffff;
|
||||||
|
font-weight: var(--font-weight-bold);
|
||||||
|
}
|
||||||
|
|
||||||
|
.month-day--today {
|
||||||
|
background-color: var(--color-accent-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.month-day__event {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: var(--font-weight-medium);
|
||||||
|
padding: 1px 5px;
|
||||||
|
border-radius: 3px;
|
||||||
|
margin-bottom: 2px;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
cursor: pointer;
|
||||||
|
color: #ffffff;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.month-day__more {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
padding: 1px 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --------------------------------------------------------
|
||||||
|
* Wochenansicht
|
||||||
|
* -------------------------------------------------------- */
|
||||||
|
.week-view {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.week-view__header {
|
||||||
|
display: grid;
|
||||||
|
flex-shrink: 0;
|
||||||
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.week-view__time-gutter {
|
||||||
|
width: 48px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.week-view__day-header {
|
||||||
|
padding: var(--space-2) var(--space-1);
|
||||||
|
text-align: center;
|
||||||
|
border-left: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.week-view__day-name {
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
font-weight: var(--font-weight-semibold);
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.week-view__day-num {
|
||||||
|
font-size: var(--text-xl);
|
||||||
|
font-weight: var(--font-weight-bold);
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.week-view__day-num--today {
|
||||||
|
background-color: var(--color-accent);
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.week-view__scroll {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.week-view__body {
|
||||||
|
display: flex;
|
||||||
|
min-height: calc(24 * 56px); /* 24h × 56px pro Stunde */
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.week-view__times {
|
||||||
|
width: 48px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.week-view__time-slot {
|
||||||
|
height: 56px;
|
||||||
|
padding-right: var(--space-2);
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: flex-end;
|
||||||
|
padding-top: var(--space-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.week-view__time-label {
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--color-text-disabled);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.week-view__columns {
|
||||||
|
flex: 1;
|
||||||
|
display: grid;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.week-view__col {
|
||||||
|
border-left: 1px solid var(--color-border);
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.week-view__hour-line {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
height: 1px;
|
||||||
|
background-color: var(--color-border);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.week-view__now-line {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
height: 2px;
|
||||||
|
background-color: var(--color-danger);
|
||||||
|
z-index: 2;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.week-view__now-line::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
left: -4px;
|
||||||
|
top: -4px;
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background-color: var(--color-danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.week-event {
|
||||||
|
position: absolute;
|
||||||
|
left: 2px;
|
||||||
|
right: 2px;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 2px 5px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: var(--font-weight-medium);
|
||||||
|
color: #ffffff;
|
||||||
|
overflow: hidden;
|
||||||
|
cursor: pointer;
|
||||||
|
z-index: 1;
|
||||||
|
line-height: 1.4;
|
||||||
|
transition: filter var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.week-event:hover {
|
||||||
|
filter: brightness(0.9);
|
||||||
|
}
|
||||||
|
|
||||||
|
.week-event__title {
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.week-event__time {
|
||||||
|
font-size: 10px;
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --------------------------------------------------------
|
||||||
|
* Tagesansicht
|
||||||
|
* -------------------------------------------------------- */
|
||||||
|
.day-view {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.day-view__header {
|
||||||
|
padding: var(--space-3) var(--space-4);
|
||||||
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
text-align: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.day-view__date-label {
|
||||||
|
font-size: var(--text-lg);
|
||||||
|
font-weight: var(--font-weight-bold);
|
||||||
|
}
|
||||||
|
|
||||||
|
.day-view__scroll {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.day-view__body {
|
||||||
|
display: flex;
|
||||||
|
min-height: calc(24 * 56px);
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.day-view__times {
|
||||||
|
width: 48px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.day-view__col {
|
||||||
|
flex: 1;
|
||||||
|
border-left: 1px solid var(--color-border);
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --------------------------------------------------------
|
||||||
|
* Agenda-Ansicht
|
||||||
|
* -------------------------------------------------------- */
|
||||||
|
.agenda-view {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
padding: var(--space-2) var(--space-4) var(--space-6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.agenda-day {
|
||||||
|
margin-bottom: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.agenda-day__header {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: var(--space-3);
|
||||||
|
padding: var(--space-2) 0 var(--space-1);
|
||||||
|
border-bottom: 2px solid var(--color-border);
|
||||||
|
margin-bottom: var(--space-2);
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
background-color: var(--color-bg);
|
||||||
|
z-index: var(--z-base);
|
||||||
|
}
|
||||||
|
|
||||||
|
.agenda-day__date {
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
font-weight: var(--font-weight-bold);
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.agenda-day__weekday {
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.agenda-day__header--today .agenda-day__date {
|
||||||
|
color: var(--color-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.agenda-event {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: var(--space-3);
|
||||||
|
padding: var(--space-2) var(--space-3);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
margin-bottom: var(--space-1);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.agenda-event:hover {
|
||||||
|
background-color: var(--color-surface-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.agenda-event__color {
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-top: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agenda-event__body {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agenda-event__title {
|
||||||
|
font-size: var(--text-base);
|
||||||
|
font-weight: var(--font-weight-medium);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agenda-event__meta {
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agenda-event__assigned {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.agenda-event__avatar {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 9px;
|
||||||
|
font-weight: var(--font-weight-bold);
|
||||||
|
color: #ffffff;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agenda-empty {
|
||||||
|
text-align: center;
|
||||||
|
padding: var(--space-8);
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --------------------------------------------------------
|
||||||
|
* Termin-Modal
|
||||||
|
* -------------------------------------------------------- */
|
||||||
|
.event-modal-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background-color: rgba(0, 0, 0, 0.5);
|
||||||
|
z-index: var(--z-modal);
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
justify-content: center;
|
||||||
|
animation: fadeIn var(--transition-fast) ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
.event-modal-overlay {
|
||||||
|
align-items: center;
|
||||||
|
padding: var(--space-4);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-modal {
|
||||||
|
background-color: var(--color-surface);
|
||||||
|
border-radius: var(--radius-lg) var(--radius-lg) 0 0;
|
||||||
|
width: 100%;
|
||||||
|
max-height: 90dvh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
animation: slideUp var(--transition-base) ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
.event-modal {
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
max-width: 560px;
|
||||||
|
max-height: 85dvh;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-modal__header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: var(--space-4);
|
||||||
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
flex-shrink: 0;
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-modal__title {
|
||||||
|
font-size: var(--text-lg);
|
||||||
|
font-weight: var(--font-weight-bold);
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-modal__close {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
background: var(--color-border);
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
min-height: unset;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-modal__body {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: var(--space-4);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-modal__row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Farbauswahl */
|
||||||
|
.color-picker {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-swatch {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
border: 2px solid transparent;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform var(--transition-fast), border-color var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-swatch:hover {
|
||||||
|
transform: scale(1.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-swatch--active {
|
||||||
|
border-color: var(--color-text-primary);
|
||||||
|
transform: scale(1.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Allday-Toggle */
|
||||||
|
.allday-toggle {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-3);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.allday-toggle__label {
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-modal__footer {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: var(--space-4);
|
||||||
|
border-top: 1px solid var(--color-border);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-modal__footer-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --------------------------------------------------------
|
||||||
|
* Termin-Detailansicht (Popup beim Klick)
|
||||||
|
* -------------------------------------------------------- */
|
||||||
|
.event-popup {
|
||||||
|
position: fixed;
|
||||||
|
z-index: calc(var(--z-modal) - 1);
|
||||||
|
background-color: var(--color-surface);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
box-shadow: var(--shadow-lg);
|
||||||
|
padding: var(--space-4);
|
||||||
|
min-width: 240px;
|
||||||
|
max-width: 320px;
|
||||||
|
animation: fadeIn var(--transition-fast) ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-popup__color-bar {
|
||||||
|
height: 4px;
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
margin-bottom: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-popup__title {
|
||||||
|
font-size: var(--text-base);
|
||||||
|
font-weight: var(--font-weight-semibold);
|
||||||
|
margin-bottom: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-popup__meta {
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-popup__actions {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-2);
|
||||||
|
margin-top: var(--space-3);
|
||||||
|
padding-top: var(--space-3);
|
||||||
|
border-top: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --------------------------------------------------------
|
||||||
|
* Ganztägige Ereignisse (oben in Wochen-/Tagesansicht)
|
||||||
|
* -------------------------------------------------------- */
|
||||||
|
.allday-row {
|
||||||
|
display: grid;
|
||||||
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
min-height: 28px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.allday-cell {
|
||||||
|
border-left: 1px solid var(--color-border);
|
||||||
|
padding: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.allday-event {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: var(--font-weight-medium);
|
||||||
|
padding: 1px 6px;
|
||||||
|
border-radius: 3px;
|
||||||
|
color: #ffffff;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
margin-bottom: 2px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
+276
-5
@@ -1,13 +1,284 @@
|
|||||||
/**
|
/**
|
||||||
* Modul: Kalender (Calendar)
|
* Modul: Kalender (Calendar)
|
||||||
* Zweck: REST-API-Routen für Kalendereinträge und externe Kalender-Sync
|
* Zweck: REST-API-Routen für Kalendereinträge (lokale Termine)
|
||||||
|
* Externe Sync (Google/Apple) folgt in Phase 3, Schritte 14–15.
|
||||||
* Abhängigkeiten: express, server/db.js, server/auth.js
|
* Abhängigkeiten: express, server/db.js, server/auth.js
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const express = require('express');
|
'use strict';
|
||||||
const router = express.Router();
|
|
||||||
|
|
||||||
// Platzhalter — wird in Phase 3 implementiert
|
const express = require('express');
|
||||||
router.get('/', (req, res) => res.json({ data: [] }));
|
const router = express.Router();
|
||||||
|
const db = require('../db');
|
||||||
|
|
||||||
|
const VALID_SOURCES = ['local', 'google', 'apple'];
|
||||||
|
const DATETIME_RE = /^\d{4}-\d{2}-\d{2}(T\d{2}:\d{2}(:\d{2})?Z?)?$/;
|
||||||
|
const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
|
||||||
|
const COLOR_RE = /^#[0-9A-Fa-f]{6}$/;
|
||||||
|
|
||||||
|
// --------------------------------------------------------
|
||||||
|
// GET /api/v1/calendar
|
||||||
|
// Termine in einem Datumsbereich abrufen.
|
||||||
|
// Query: ?from=YYYY-MM-DD&to=YYYY-MM-DD (default: aktueller Monat)
|
||||||
|
// &assigned_to=<userId> (optional Filter)
|
||||||
|
// &source=local|google|apple (optional Filter)
|
||||||
|
// Response: { data: Event[], from, to }
|
||||||
|
// --------------------------------------------------------
|
||||||
|
router.get('/', (req, res) => {
|
||||||
|
try {
|
||||||
|
const today = new Date().toISOString().slice(0, 10);
|
||||||
|
const year = today.slice(0, 4);
|
||||||
|
const month = today.slice(5, 7);
|
||||||
|
|
||||||
|
const from = req.query.from || `${year}-${month}-01`;
|
||||||
|
const to = req.query.to || `${year}-${month}-31`;
|
||||||
|
|
||||||
|
if (!DATE_RE.test(from) || !DATE_RE.test(to))
|
||||||
|
return res.status(400).json({ error: 'from/to müssen YYYY-MM-DD sein', code: 400 });
|
||||||
|
|
||||||
|
let sql = `
|
||||||
|
SELECT e.*,
|
||||||
|
u_assigned.display_name AS assigned_name,
|
||||||
|
u_assigned.avatar_color AS assigned_color,
|
||||||
|
u_created.display_name AS creator_name
|
||||||
|
FROM calendar_events e
|
||||||
|
LEFT JOIN users u_assigned ON u_assigned.id = e.assigned_to
|
||||||
|
LEFT JOIN users u_created ON u_created.id = e.created_by
|
||||||
|
WHERE (
|
||||||
|
DATE(e.start_datetime) <= ? AND
|
||||||
|
(e.end_datetime IS NULL OR DATE(e.end_datetime) >= ?)
|
||||||
|
)
|
||||||
|
`;
|
||||||
|
const params = [to, from];
|
||||||
|
|
||||||
|
if (req.query.assigned_to) {
|
||||||
|
sql += ' AND e.assigned_to = ?';
|
||||||
|
params.push(parseInt(req.query.assigned_to, 10));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.query.source && VALID_SOURCES.includes(req.query.source)) {
|
||||||
|
sql += ' AND e.external_source = ?';
|
||||||
|
params.push(req.query.source);
|
||||||
|
}
|
||||||
|
|
||||||
|
sql += ' ORDER BY e.start_datetime ASC, e.all_day DESC';
|
||||||
|
|
||||||
|
const events = db.get().prepare(sql).all(...params);
|
||||||
|
res.json({ data: events, from, to });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[calendar/GET /]', err);
|
||||||
|
res.status(500).json({ error: 'Interner Fehler', code: 500 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// --------------------------------------------------------
|
||||||
|
// GET /api/v1/calendar/upcoming
|
||||||
|
// Nächste N Termine ab jetzt (für Dashboard-Widget).
|
||||||
|
// Query: ?limit=5
|
||||||
|
// Response: { data: Event[] }
|
||||||
|
// --------------------------------------------------------
|
||||||
|
router.get('/upcoming', (req, res) => {
|
||||||
|
try {
|
||||||
|
const limit = Math.min(parseInt(req.query.limit, 10) || 5, 20);
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
|
||||||
|
const events = db.get().prepare(`
|
||||||
|
SELECT e.*,
|
||||||
|
u_assigned.display_name AS assigned_name,
|
||||||
|
u_assigned.avatar_color AS assigned_color
|
||||||
|
FROM calendar_events e
|
||||||
|
LEFT JOIN users u_assigned ON u_assigned.id = e.assigned_to
|
||||||
|
WHERE e.start_datetime >= ?
|
||||||
|
ORDER BY e.start_datetime ASC
|
||||||
|
LIMIT ?
|
||||||
|
`).all(now, limit);
|
||||||
|
|
||||||
|
res.json({ data: events });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[calendar/GET /upcoming]', err);
|
||||||
|
res.status(500).json({ error: 'Interner Fehler', code: 500 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// --------------------------------------------------------
|
||||||
|
// GET /api/v1/calendar/:id
|
||||||
|
// Einzelnen Termin abrufen.
|
||||||
|
// Response: { data: Event }
|
||||||
|
// --------------------------------------------------------
|
||||||
|
router.get('/:id', (req, res) => {
|
||||||
|
try {
|
||||||
|
const id = parseInt(req.params.id, 10);
|
||||||
|
const event = db.get().prepare(`
|
||||||
|
SELECT e.*,
|
||||||
|
u_assigned.display_name AS assigned_name,
|
||||||
|
u_assigned.avatar_color AS assigned_color,
|
||||||
|
u_created.display_name AS creator_name
|
||||||
|
FROM calendar_events e
|
||||||
|
LEFT JOIN users u_assigned ON u_assigned.id = e.assigned_to
|
||||||
|
LEFT JOIN users u_created ON u_created.id = e.created_by
|
||||||
|
WHERE e.id = ?
|
||||||
|
`).get(id);
|
||||||
|
|
||||||
|
if (!event) return res.status(404).json({ error: 'Termin nicht gefunden', code: 404 });
|
||||||
|
res.json({ data: event });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[calendar/GET /:id]', err);
|
||||||
|
res.status(500).json({ error: 'Interner Fehler', code: 500 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// --------------------------------------------------------
|
||||||
|
// POST /api/v1/calendar
|
||||||
|
// Neuen Termin anlegen.
|
||||||
|
// Body: { title, description?, start_datetime, end_datetime?,
|
||||||
|
// all_day?, location?, color?, assigned_to?,
|
||||||
|
// recurrence_rule? }
|
||||||
|
// Response: { data: Event }
|
||||||
|
// --------------------------------------------------------
|
||||||
|
router.post('/', (req, res) => {
|
||||||
|
try {
|
||||||
|
const {
|
||||||
|
title,
|
||||||
|
description = null,
|
||||||
|
start_datetime,
|
||||||
|
end_datetime = null,
|
||||||
|
all_day = 0,
|
||||||
|
location = null,
|
||||||
|
color = '#007AFF',
|
||||||
|
assigned_to = null,
|
||||||
|
recurrence_rule = null,
|
||||||
|
} = req.body;
|
||||||
|
|
||||||
|
if (!title || !title.trim())
|
||||||
|
return res.status(400).json({ error: 'Titel ist erforderlich', code: 400 });
|
||||||
|
if (!start_datetime || !DATETIME_RE.test(start_datetime))
|
||||||
|
return res.status(400).json({ error: 'Gültiges start_datetime erforderlich', code: 400 });
|
||||||
|
if (end_datetime && !DATETIME_RE.test(end_datetime))
|
||||||
|
return res.status(400).json({ error: 'Ungültiges end_datetime', code: 400 });
|
||||||
|
if (color && !COLOR_RE.test(color))
|
||||||
|
return res.status(400).json({ error: 'Farbe muss #RRGGBB sein', code: 400 });
|
||||||
|
|
||||||
|
if (assigned_to) {
|
||||||
|
const user = db.get().prepare('SELECT id FROM users WHERE id = ?').get(assigned_to);
|
||||||
|
if (!user) return res.status(400).json({ error: 'assigned_to: Benutzer nicht gefunden', code: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = db.get().prepare(`
|
||||||
|
INSERT INTO calendar_events
|
||||||
|
(title, description, start_datetime, end_datetime, all_day,
|
||||||
|
location, color, assigned_to, created_by, recurrence_rule)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
`).run(
|
||||||
|
title.trim(), description || null,
|
||||||
|
start_datetime, end_datetime || null,
|
||||||
|
all_day ? 1 : 0, location || null,
|
||||||
|
color, assigned_to || null,
|
||||||
|
req.session.userId, recurrence_rule || null
|
||||||
|
);
|
||||||
|
|
||||||
|
const event = db.get().prepare(`
|
||||||
|
SELECT e.*,
|
||||||
|
u_assigned.display_name AS assigned_name,
|
||||||
|
u_assigned.avatar_color AS assigned_color,
|
||||||
|
u_created.display_name AS creator_name
|
||||||
|
FROM calendar_events e
|
||||||
|
LEFT JOIN users u_assigned ON u_assigned.id = e.assigned_to
|
||||||
|
LEFT JOIN users u_created ON u_created.id = e.created_by
|
||||||
|
WHERE e.id = ?
|
||||||
|
`).get(result.lastInsertRowid);
|
||||||
|
|
||||||
|
res.status(201).json({ data: event });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[calendar/POST /]', err);
|
||||||
|
res.status(500).json({ error: 'Interner Fehler', code: 500 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// --------------------------------------------------------
|
||||||
|
// PUT /api/v1/calendar/:id
|
||||||
|
// Termin vollständig aktualisieren.
|
||||||
|
// Body: alle Felder optional außer title + start_datetime
|
||||||
|
// Response: { data: Event }
|
||||||
|
// --------------------------------------------------------
|
||||||
|
router.put('/:id', (req, res) => {
|
||||||
|
try {
|
||||||
|
const id = parseInt(req.params.id, 10);
|
||||||
|
const event = db.get().prepare('SELECT * FROM calendar_events WHERE id = ?').get(id);
|
||||||
|
if (!event) return res.status(404).json({ error: 'Termin nicht gefunden', code: 404 });
|
||||||
|
|
||||||
|
const {
|
||||||
|
title, description, start_datetime, end_datetime,
|
||||||
|
all_day, location, color, assigned_to, recurrence_rule,
|
||||||
|
} = req.body;
|
||||||
|
|
||||||
|
if (title !== undefined && !title.trim())
|
||||||
|
return res.status(400).json({ error: 'Titel darf nicht leer sein', code: 400 });
|
||||||
|
if (start_datetime !== undefined && !DATETIME_RE.test(start_datetime))
|
||||||
|
return res.status(400).json({ error: 'Ungültiges start_datetime', code: 400 });
|
||||||
|
if (end_datetime !== undefined && end_datetime && !DATETIME_RE.test(end_datetime))
|
||||||
|
return res.status(400).json({ error: 'Ungültiges end_datetime', code: 400 });
|
||||||
|
if (color !== undefined && !COLOR_RE.test(color))
|
||||||
|
return res.status(400).json({ error: 'Farbe muss #RRGGBB sein', code: 400 });
|
||||||
|
|
||||||
|
db.get().prepare(`
|
||||||
|
UPDATE calendar_events
|
||||||
|
SET title = COALESCE(?, title),
|
||||||
|
description = ?,
|
||||||
|
start_datetime = COALESCE(?, start_datetime),
|
||||||
|
end_datetime = ?,
|
||||||
|
all_day = COALESCE(?, all_day),
|
||||||
|
location = ?,
|
||||||
|
color = COALESCE(?, color),
|
||||||
|
assigned_to = ?,
|
||||||
|
recurrence_rule = ?
|
||||||
|
WHERE id = ?
|
||||||
|
`).run(
|
||||||
|
title?.trim() ?? null,
|
||||||
|
description !== undefined ? (description || null) : event.description,
|
||||||
|
start_datetime ?? null,
|
||||||
|
end_datetime !== undefined ? (end_datetime || null) : event.end_datetime,
|
||||||
|
all_day !== undefined ? (all_day ? 1 : 0) : null,
|
||||||
|
location !== undefined ? (location || null) : event.location,
|
||||||
|
color ?? null,
|
||||||
|
assigned_to !== undefined ? (assigned_to || null) : event.assigned_to,
|
||||||
|
recurrence_rule !== undefined ? (recurrence_rule || null) : event.recurrence_rule,
|
||||||
|
id
|
||||||
|
);
|
||||||
|
|
||||||
|
const updated = db.get().prepare(`
|
||||||
|
SELECT e.*,
|
||||||
|
u_assigned.display_name AS assigned_name,
|
||||||
|
u_assigned.avatar_color AS assigned_color,
|
||||||
|
u_created.display_name AS creator_name
|
||||||
|
FROM calendar_events e
|
||||||
|
LEFT JOIN users u_assigned ON u_assigned.id = e.assigned_to
|
||||||
|
LEFT JOIN users u_created ON u_created.id = e.created_by
|
||||||
|
WHERE e.id = ?
|
||||||
|
`).get(id);
|
||||||
|
|
||||||
|
res.json({ data: updated });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[calendar/PUT /:id]', err);
|
||||||
|
res.status(500).json({ error: 'Interner Fehler', code: 500 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// --------------------------------------------------------
|
||||||
|
// DELETE /api/v1/calendar/:id
|
||||||
|
// Termin löschen.
|
||||||
|
// Response: 204 No Content
|
||||||
|
// --------------------------------------------------------
|
||||||
|
router.delete('/:id', (req, res) => {
|
||||||
|
try {
|
||||||
|
const id = parseInt(req.params.id, 10);
|
||||||
|
const result = db.get().prepare('DELETE FROM calendar_events WHERE id = ?').run(id);
|
||||||
|
if (result.changes === 0)
|
||||||
|
return res.status(404).json({ error: 'Termin nicht gefunden', code: 404 });
|
||||||
|
res.status(204).end();
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[calendar/DELETE /:id]', err);
|
||||||
|
res.status(500).json({ error: 'Interner Fehler', code: 500 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
|
|||||||
@@ -0,0 +1,255 @@
|
|||||||
|
/**
|
||||||
|
* Modul: Kalender-Test
|
||||||
|
* Zweck: Validiert alle Calendar-API-Abfragen, Datumsbereichs-Filter,
|
||||||
|
* Constraints, CRUD-Logik
|
||||||
|
* Ausführen: node --experimental-sqlite test-calendar.js
|
||||||
|
*/
|
||||||
|
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const { DatabaseSync } = require('node:sqlite');
|
||||||
|
const { MIGRATIONS_SQL } = require('./server/db-schema-test');
|
||||||
|
|
||||||
|
let passed = 0;
|
||||||
|
let failed = 0;
|
||||||
|
|
||||||
|
function test(name, fn) {
|
||||||
|
try { fn(); console.log(` ✓ ${name}`); passed++; }
|
||||||
|
catch (err) { console.error(` ✗ ${name}: ${err.message}`); failed++; }
|
||||||
|
}
|
||||||
|
function assert(cond, msg) { if (!cond) throw new Error(msg || 'Assertion fehlgeschlagen'); }
|
||||||
|
|
||||||
|
const db = new DatabaseSync(':memory:');
|
||||||
|
db.exec('PRAGMA foreign_keys = ON;');
|
||||||
|
db.exec(`CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||||
|
version INTEGER PRIMARY KEY, description TEXT NOT NULL,
|
||||||
|
applied_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
|
||||||
|
);`);
|
||||||
|
db.exec(MIGRATIONS_SQL[1]);
|
||||||
|
|
||||||
|
// Benutzer
|
||||||
|
const u1 = db.prepare(`INSERT INTO users (username, display_name, password_hash, role)
|
||||||
|
VALUES ('admin', 'Admin', 'x', 'admin')`).run();
|
||||||
|
const uid = u1.lastInsertRowid;
|
||||||
|
|
||||||
|
const u2 = db.prepare(`INSERT INTO users (username, display_name, password_hash, avatar_color)
|
||||||
|
VALUES ('maria', 'Maria', 'x', '#34C759')`).run();
|
||||||
|
const uid2 = u2.lastInsertRowid;
|
||||||
|
|
||||||
|
console.log('\n[Calendar-Test] Termine, Datumsbereich, CRUD, Constraints\n');
|
||||||
|
|
||||||
|
let ev1, ev2, ev3, ev4;
|
||||||
|
|
||||||
|
// --------------------------------------------------------
|
||||||
|
// Termin-CRUD
|
||||||
|
// --------------------------------------------------------
|
||||||
|
test('Termin erstellen (mit Uhrzeit)', () => {
|
||||||
|
const r = db.prepare(`
|
||||||
|
INSERT INTO calendar_events
|
||||||
|
(title, start_datetime, end_datetime, color, created_by)
|
||||||
|
VALUES ('Zahnarzt', '2026-03-24T10:00', '2026-03-24T11:00', '#FF3B30', ?)
|
||||||
|
`).run(uid);
|
||||||
|
ev1 = r.lastInsertRowid;
|
||||||
|
assert(ev1 > 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Termin erstellen (ganztägig)', () => {
|
||||||
|
const r = db.prepare(`
|
||||||
|
INSERT INTO calendar_events
|
||||||
|
(title, start_datetime, all_day, color, created_by)
|
||||||
|
VALUES ('Ostern', '2026-04-05', 1, '#34C759', ?)
|
||||||
|
`).run(uid);
|
||||||
|
ev2 = r.lastInsertRowid;
|
||||||
|
assert(ev2 > 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Termin erstellen (mehrtägig)', () => {
|
||||||
|
const r = db.prepare(`
|
||||||
|
INSERT INTO calendar_events
|
||||||
|
(title, start_datetime, end_datetime, all_day, color, created_by)
|
||||||
|
VALUES ('Urlaub', '2026-03-28', '2026-04-04', 1, '#FF9500', ?)
|
||||||
|
`).run(uid);
|
||||||
|
ev3 = r.lastInsertRowid;
|
||||||
|
assert(ev3 > 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Termin mit Zuweisung erstellen', () => {
|
||||||
|
const r = db.prepare(`
|
||||||
|
INSERT INTO calendar_events
|
||||||
|
(title, start_datetime, color, assigned_to, created_by)
|
||||||
|
VALUES ('Elternabend', '2026-03-26T18:00', '#AF52DE', ?, ?)
|
||||||
|
`).run(uid2, uid);
|
||||||
|
ev4 = r.lastInsertRowid;
|
||||||
|
assert(ev4 > 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Termin abrufen (mit assigned_name via JOIN)', () => {
|
||||||
|
const ev = db.prepare(`
|
||||||
|
SELECT e.*, u.display_name AS assigned_name, u.avatar_color AS assigned_color
|
||||||
|
FROM calendar_events e
|
||||||
|
LEFT JOIN users u ON u.id = e.assigned_to
|
||||||
|
WHERE e.id = ?
|
||||||
|
`).get(ev4);
|
||||||
|
assert(ev.assigned_name === 'Maria', `assigned_name: ${ev.assigned_name}`);
|
||||||
|
assert(ev.assigned_color === '#34C759');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Termin aktualisieren (Titel + Farbe)', () => {
|
||||||
|
db.prepare(`UPDATE calendar_events SET title = 'Zahnarzt Dr. Müller', color = '#007AFF' WHERE id = ?`).run(ev1);
|
||||||
|
const ev = db.prepare('SELECT title, color FROM calendar_events WHERE id = ?').get(ev1);
|
||||||
|
assert(ev.title === 'Zahnarzt Dr. Müller');
|
||||||
|
assert(ev.color === '#007AFF');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('external_source-Constraint (ungültiger Wert)', () => {
|
||||||
|
let threw = false;
|
||||||
|
try {
|
||||||
|
db.prepare(`INSERT INTO calendar_events (title, start_datetime, external_source, created_by)
|
||||||
|
VALUES ('Test', '2026-03-24', 'outlook', ?)`).run(uid);
|
||||||
|
} catch { threw = true; }
|
||||||
|
assert(threw, 'Constraint muss verletzt werden');
|
||||||
|
});
|
||||||
|
|
||||||
|
// --------------------------------------------------------
|
||||||
|
// Datumsbereichs-Filter
|
||||||
|
// --------------------------------------------------------
|
||||||
|
test('Termine in März 2026 (inkl. mehrtägiger)', () => {
|
||||||
|
const events = db.prepare(`
|
||||||
|
SELECT * FROM calendar_events
|
||||||
|
WHERE DATE(start_datetime) <= '2026-03-31'
|
||||||
|
AND (end_datetime IS NULL OR DATE(end_datetime) >= '2026-03-01')
|
||||||
|
ORDER BY start_datetime ASC
|
||||||
|
`).all();
|
||||||
|
// Zahnarzt (24.3), Elternabend (26.3), Urlaub (28.3–4.4)
|
||||||
|
assert(events.length === 3, `Erwartet 3, erhalten ${events.length}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Termine in April 2026 (inkl. Urlaub + Ostern)', () => {
|
||||||
|
const events = db.prepare(`
|
||||||
|
SELECT * FROM calendar_events
|
||||||
|
WHERE DATE(start_datetime) <= '2026-04-30'
|
||||||
|
AND (end_datetime IS NULL OR DATE(end_datetime) >= '2026-04-01')
|
||||||
|
ORDER BY start_datetime ASC
|
||||||
|
`).all();
|
||||||
|
// Urlaub endet 4.4, Ostern 5.4
|
||||||
|
assert(events.length >= 2, `Erwartet mindestens 2, erhalten ${events.length}`);
|
||||||
|
const titles = events.map((e) => e.title);
|
||||||
|
assert(titles.includes('Urlaub'), 'Urlaub in April');
|
||||||
|
assert(titles.includes('Ostern'), 'Ostern in April');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Termine nach Benutzer filtern', () => {
|
||||||
|
const events = db.prepare(`
|
||||||
|
SELECT * FROM calendar_events WHERE assigned_to = ?
|
||||||
|
`).all(uid2);
|
||||||
|
assert(events.length === 1);
|
||||||
|
assert(events[0].title === 'Elternabend');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Nur lokale Termine (external_source = local)', () => {
|
||||||
|
const events = db.prepare(`
|
||||||
|
SELECT * FROM calendar_events WHERE external_source = 'local'
|
||||||
|
`).all();
|
||||||
|
assert(events.length === 4, `Alle 4 Termine sind lokal, erhalten ${events.length}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Kommende Termine (upcoming)', () => {
|
||||||
|
// Alle Termine mit start_datetime >= jetzt (in Tests alle "in der Zukunft" relativ zu 2026)
|
||||||
|
const events = db.prepare(`
|
||||||
|
SELECT * FROM calendar_events
|
||||||
|
WHERE start_datetime >= '2026-03-24T00:00'
|
||||||
|
ORDER BY start_datetime ASC
|
||||||
|
LIMIT 5
|
||||||
|
`).all();
|
||||||
|
assert(events.length >= 1);
|
||||||
|
assert(events[0].title === 'Zahnarzt Dr. Müller', `Erster Termin: ${events[0].title}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
// --------------------------------------------------------
|
||||||
|
// Sortierung
|
||||||
|
// --------------------------------------------------------
|
||||||
|
test('Sortierung: ganztägig nach uhrzeit-basierten Terminen', () => {
|
||||||
|
// Gleicher Tag: Ganztägig sollte nach hinten oder flexibel — hier: all_day DESC in der Abfrage
|
||||||
|
const events = db.prepare(`
|
||||||
|
SELECT * FROM calendar_events
|
||||||
|
WHERE DATE(start_datetime) = '2026-03-24'
|
||||||
|
ORDER BY start_datetime ASC, all_day DESC
|
||||||
|
`).all();
|
||||||
|
assert(events.length >= 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
// --------------------------------------------------------
|
||||||
|
// Index-Abfragen (Performance-relevante Queries)
|
||||||
|
// --------------------------------------------------------
|
||||||
|
test('Index idx_calendar_start genutzt (EXPLAIN QUERY PLAN)', () => {
|
||||||
|
const plan = db.prepare(`
|
||||||
|
EXPLAIN QUERY PLAN
|
||||||
|
SELECT * FROM calendar_events WHERE start_datetime >= '2026-03-01' ORDER BY start_datetime ASC
|
||||||
|
`).all();
|
||||||
|
const usesIndex = plan.some((row) => {
|
||||||
|
const detail = row.detail || '';
|
||||||
|
return detail.includes('idx_calendar_start') || detail.includes('COVERING INDEX') || detail.includes('INDEX');
|
||||||
|
});
|
||||||
|
assert(usesIndex, `Index nicht genutzt: ${JSON.stringify(plan)}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Index idx_calendar_assigned genutzt', () => {
|
||||||
|
const plan = db.prepare(`
|
||||||
|
EXPLAIN QUERY PLAN
|
||||||
|
SELECT * FROM calendar_events WHERE assigned_to = ?
|
||||||
|
`).all(uid2);
|
||||||
|
const usesIndex = plan.some((row) => {
|
||||||
|
const detail = row.detail || '';
|
||||||
|
return detail.includes('idx_calendar_assigned') || detail.includes('INDEX');
|
||||||
|
});
|
||||||
|
assert(usesIndex, `Index nicht genutzt: ${JSON.stringify(plan)}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
// --------------------------------------------------------
|
||||||
|
// Löschen
|
||||||
|
// --------------------------------------------------------
|
||||||
|
test('Termin löschen', () => {
|
||||||
|
const result = db.prepare('DELETE FROM calendar_events WHERE id = ?').run(ev2);
|
||||||
|
assert(result.changes === 1, 'Genau 1 Eintrag gelöscht');
|
||||||
|
const ev = db.prepare('SELECT * FROM calendar_events WHERE id = ?').get(ev2);
|
||||||
|
assert(!ev, 'Termin nicht mehr vorhanden');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Nicht existierender Termin gibt keine Zeile', () => {
|
||||||
|
const ev = db.prepare('SELECT * FROM calendar_events WHERE id = ?').get(99999);
|
||||||
|
assert(!ev, 'Sollte undefined sein');
|
||||||
|
});
|
||||||
|
|
||||||
|
// --------------------------------------------------------
|
||||||
|
// Datumshelfer (clientseitige Logik hier als reine JS-Tests)
|
||||||
|
// --------------------------------------------------------
|
||||||
|
test('Wochenberechnung: Montag korrekt', () => {
|
||||||
|
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 `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`;
|
||||||
|
}
|
||||||
|
assert(getMondayOf('2026-03-24') === '2026-03-23', 'Di → Mo');
|
||||||
|
assert(getMondayOf('2026-03-23') === '2026-03-23', 'Mo bleibt Mo');
|
||||||
|
assert(getMondayOf('2026-03-29') === '2026-03-23', 'So → Mo der gleichen Woche');
|
||||||
|
assert(getMondayOf('2026-03-22') === '2026-03-16', 'So → Mo der Vorwoche');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Monatsbereich: 42 Tage für Kalenderraster', () => {
|
||||||
|
function addDays(dateStr, n) {
|
||||||
|
const d = new Date(dateStr + 'T00:00:00');
|
||||||
|
d.setDate(d.getDate() + n);
|
||||||
|
return `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`;
|
||||||
|
}
|
||||||
|
const from = '2026-03-01';
|
||||||
|
const to = addDays(from, 41);
|
||||||
|
assert(to === '2026-04-11', `Erwartet 2026-04-11, erhalten ${to}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
// --------------------------------------------------------
|
||||||
|
// Ergebnis
|
||||||
|
// --------------------------------------------------------
|
||||||
|
console.log(`\n[Calendar-Test] Ergebnis: ${passed} bestanden, ${failed} fehlgeschlagen\n`);
|
||||||
|
if (failed > 0) process.exit(1);
|
||||||
Reference in New Issue
Block a user