From d9ba8698e0da9a482b2fbb5bef8c67f7e93e52b5 Mon Sep 17 00:00:00 2001 From: Thomas Hackner Date: Thu, 16 Jul 2026 15:27:19 +0200 Subject: [PATCH] Termine: alle anstehenden auf Startseite + Bearbeiten; Status Telefonat - Startseite zeigt alle anstehenden Termine (vorher auf 6 begrenzt) - Termine lassen sich inline bearbeiten (CalDAV-Update, Neu-Anlage bei 404/412) - Neuer Bewerbungsstatus Telefonat (Listen, Farben, API, PDF, Statistik) Co-Authored-By: Claude --- lib/api.js | 2 +- lib/caldav.js | 11 ++++++ lib/openapi.js | 2 +- public/js/main.js | 3 +- server.js | 87 ++++++++++++++++++++++++++++++++++++++++-- views/aktivitaeten.ejs | 1 + views/bewerbung.ejs | 84 +++++++++++++++++++++++++++++++++++++--- views/index.ejs | 4 +- views/statistik.ejs | 4 +- 9 files changed, 184 insertions(+), 14 deletions(-) diff --git a/lib/api.js b/lib/api.js index 2e3e714..cefacda 100644 --- a/lib/api.js +++ b/lib/api.js @@ -26,7 +26,7 @@ function withLabels(row) { const STATUS_OPTIONS = [ 'Entwurf', 'Gesendet', 'Eingangsbestätigung', - 'In Bearbeitung', 'Interessiert', 'Warten auf Rückmeldung', 'Warten auf meine Antwort', + 'In Bearbeitung', 'Interessiert', 'Telefonat', 'Warten auf Rückmeldung', 'Warten auf meine Antwort', 'Vorstellungsgespräch', 'Vertragsverhandlung', 'Absage', 'Absage von meiner Seite', 'Einstellung', 'Keine Rückmeldung', ]; diff --git a/lib/caldav.js b/lib/caldav.js index 46fb27a..878bf8f 100644 --- a/lib/caldav.js +++ b/lib/caldav.js @@ -76,6 +76,16 @@ function localInputToUtc(dateStr, timeStr) { return wallToUtc(y, mo, d, h || 0, mi || 0, TZ); } +// UTC Date -> Berliner Wanduhr-Bestandteile { datum: 'YYYY-MM-DD', zeit: 'HH:MM' } +// (für vorausgefüllte Bearbeiten-Formulare). +function utcToWallInputs(date) { + const p = new Intl.DateTimeFormat('en-CA', { + timeZone: TZ, year: 'numeric', month: '2-digit', day: '2-digit', + hour: '2-digit', minute: '2-digit', hour12: false, + }).formatToParts(date).reduce((a, x) => (a[x.type] = x.value, a), {}); + return { datum: `${p.year}-${p.month}-${p.day}`, zeit: `${p.hour}:${p.minute}` }; +} + // UTC Date -> iCalendar UTC stamp "YYYYMMDDTHHMMSSZ". function toIcsUtc(date) { return date.getUTCFullYear() + pad(date.getUTCMonth() + 1) + pad(date.getUTCDate()) @@ -335,6 +345,7 @@ module.exports = { collectionUrl, wallToUtc, localInputToUtc, + utcToWallInputs, buildICS, parseVEvent, getCtag, diff --git a/lib/openapi.js b/lib/openapi.js index 1c5629c..c6901d2 100644 --- a/lib/openapi.js +++ b/lib/openapi.js @@ -9,7 +9,7 @@ const ART_OPTIONS = [ ]; const STATUS_OPTIONS = [ 'Entwurf', 'Gesendet', 'Eingangsbestätigung', - 'In Bearbeitung', 'Interessiert', 'Warten auf Rückmeldung', 'Warten auf meine Antwort', + 'In Bearbeitung', 'Interessiert', 'Telefonat', 'Warten auf Rückmeldung', 'Warten auf meine Antwort', 'Vorstellungsgespräch', 'Vertragsverhandlung', 'Absage', 'Absage von meiner Seite', 'Einstellung', 'Keine Rückmeldung', ]; diff --git a/public/js/main.js b/public/js/main.js index 47a30df..1b2ad75 100644 --- a/public/js/main.js +++ b/public/js/main.js @@ -355,7 +355,7 @@ function generatePdfDocument(settings, applications, month, year) { // ---- Group entries by status (order kept in sync with views/index.ejs) ---- const statusOrder = ['Entwurf', 'Gesendet', 'Eingangsbestätigung', - 'In Bearbeitung', 'Interessiert', 'Warten auf Rückmeldung', 'Warten auf meine Antwort', + 'In Bearbeitung', 'Interessiert', 'Telefonat', 'Warten auf Rückmeldung', 'Warten auf meine Antwort', 'Vorstellungsgespräch', 'Vertragsverhandlung', 'Einstellung', 'Absage', 'Absage von meiner Seite', 'Keine Rückmeldung']; // Accent colour per status (RGB) – reused by the chart and the list headings const statusColors = { @@ -364,6 +364,7 @@ function generatePdfDocument(settings, applications, month, year) { 'Eingangsbestätigung': [14, 165, 233], 'In Bearbeitung': [20, 184, 166], 'Interessiert': [236, 72, 153], + 'Telefonat': [244, 114, 182], 'Warten auf Rückmeldung': [249, 115, 22], 'Warten auf meine Antwort': [217, 70, 239], 'Vorstellungsgespräch': [245, 158, 11], diff --git a/server.js b/server.js index 9f4748e..b4b60bf 100644 --- a/server.js +++ b/server.js @@ -62,7 +62,7 @@ const ART_OPTIONS = [ ]; const STATUS_OPTIONS = [ 'Entwurf', 'Gesendet', 'Eingangsbestätigung', - 'In Bearbeitung', 'Interessiert', 'Warten auf Rückmeldung', 'Warten auf meine Antwort', + 'In Bearbeitung', 'Interessiert', 'Telefonat', 'Warten auf Rückmeldung', 'Warten auf meine Antwort', 'Vorstellungsgespräch', 'Vertragsverhandlung', 'Absage', 'Absage von meiner Seite', 'Einstellung', 'Keine Rückmeldung' ]; @@ -862,6 +862,16 @@ async function autoBlacklistOffer(offer, grund) { // Upcoming appointments (not yet ended), newest first, for the dashboard widget. async function upcomingTermine(limit = 6) { const now = new Date().toISOString(); + // limit = null/0 ⇒ kein Limit, alle anstehenden Termine (z. B. Startseite). + if (!limit) { + return dbAll( + `SELECT t.*, b.firma AS bewerbung_firma, b.stelle AS bewerbung_stelle + FROM termine t LEFT JOIN bewerbungen b ON b.id = t.bewerbung_id + WHERE t.user_id = ? AND COALESCE(t.ende, t.start) >= ? + ORDER BY t.start ASC`, + [currentUserId(), now] + ); + } return dbAll( `SELECT t.*, b.firma AS bewerbung_firma, b.stelle AS bewerbung_stelle FROM termine t LEFT JOIN bewerbungen b ON b.id = t.bewerbung_id @@ -1779,7 +1789,7 @@ initializeDatabase().then(async () => { await attachVerlauf(applications); applications.forEach((a) => { a.labelsArr = parseLabels(a.labels); }); // Upcoming calendar appointments for the dashboard widget. - const kommendeTermine = await upcomingTermine(6); + const kommendeTermine = await upcomingTermine(null); // Get statistics const totalCount = await dbGet('SELECT COUNT(*) as count FROM bewerbungen WHERE user_id = ?', [U]); @@ -2013,7 +2023,7 @@ initializeDatabase().then(async () => { // Any reply at all — everything past "Gesendet" except "Keine Rückmeldung". const rueckStatus = [ - 'Eingangsbestätigung', 'In Bearbeitung', 'Interessiert', + 'Eingangsbestätigung', 'In Bearbeitung', 'Interessiert', 'Telefonat', 'Warten auf Rückmeldung', 'Warten auf meine Antwort', 'Vorstellungsgespräch', 'Vertragsverhandlung', 'Absage', 'Absage von meiner Seite', 'Einstellung' @@ -2532,6 +2542,13 @@ initializeDatabase().then(async () => { const basisAnhaenge = await dbAll('SELECT id, name, dateiname FROM basis_anhaenge WHERE user_id = ? ORDER BY id ASC', [U]); // Calendar appointments for this application (mirrored to SOGo). const termine = await dbAll('SELECT * FROM termine WHERE bewerbung_id = ? AND user_id = ? ORDER BY start ASC', [id, U]); + // Vorausgefüllte Werte für die Bearbeiten-Formulare (Berliner Wanduhr). + termine.forEach((t) => { + const s = caldav.utcToWallInputs(new Date(t.start)); + t.editDatum = s.datum; + t.editVon = t.ganztags ? '09:00' : s.zeit; + t.editBis = (!t.ganztags && t.ende) ? caldav.utcToWallInputs(new Date(t.ende)).zeit : ''; + }); // E-Mail correspondence (sent + received), oldest first, with attachments. const emails = await dbAll( @@ -3019,6 +3036,70 @@ initializeDatabase().then(async () => { } }); + // Termin bearbeiten: aktualisiert den Kalender-Termin (CalDAV) und das + // lokale Spiegelbild. Legt einen neuen CalDAV-Eintrag an, falls der alte + // entfernt wurde (z. B. remote gelöscht). + app.post('/bewerbung/:id/termine/:tid/edit', async (req, res) => { + const { id, tid } = req.params; + try { + const t = await dbGet('SELECT * FROM termine WHERE id = ? AND bewerbung_id = ? AND user_id = ?', [tid, id, uid()]); + if (!t) return res.redirect('/bewerbung/' + id + '#termine'); + if (!caldav.isConfigured()) { + return res.redirect('/bewerbung/' + id + '?terminerror=' + encodeURIComponent('Kalender ist nicht konfiguriert.') + '#termine'); + } + const b = req.body || {}; + if (!b.datum) { + return res.redirect('/bewerbung/' + id + '?terminerror=' + encodeURIComponent('Bitte ein Datum angeben.') + '#termine'); + } + const ganztags = b.ganztags === 'on' || b.ganztags === '1' || b.ganztags === 'true'; + const typ = b.typ === 'vorstellungsgespraech' ? 'vorstellungsgespraech' : 'termin'; + const titel = (b.titel || '').trim() || (typ === 'vorstellungsgespraech' ? 'Vorstellungsgespräch' : 'Termin'); + const erinnerung = Math.max(0, parseInt(b.erinnerung_min, 10) || 0); + + let start, ende = null; + if (ganztags) { + const [y, mo, d] = String(b.datum).split('-').map(Number); + start = caldav.wallToUtc(y, mo, d, 0, 0); + } else { + start = caldav.localInputToUtc(b.datum, b.von || '09:00'); + if (b.bis) ende = caldav.localInputToUtc(b.datum, b.bis); + } + + const evt = { + summary: titel, location: b.ort || '', description: b.notiz || '', + start, end: ende, allDay: ganztags, alarmMin: erinnerung, + }; + + let href = t.caldav_href, etag = t.caldav_etag, caldavUid = t.caldav_uid; + if (href) { + const upd = await caldav.updateEvent({ href, etag, ...evt }).catch((e) => { + // 412/404 bedeutet: der entfernte Termin wurde zwischenzeitlich + // geändert oder gelöscht — dann legen wir ihn neu an. + if (/HTTP (404|412)/.test(e.message)) return null; + throw e; + }); + if (upd) { etag = upd.etag; caldavUid = upd.uid; } + else { const created = await caldav.createEvent(evt); href = created.href; etag = created.etag; caldavUid = created.uid; } + } else { + const created = await caldav.createEvent(evt); + href = created.href; etag = created.etag; caldavUid = created.uid; + } + + await dbRun( + `UPDATE termine SET typ = ?, titel = ?, ort = ?, notiz = ?, start = ?, ende = ?, ganztags = ?, + erinnerung_min = ?, caldav_uid = ?, caldav_href = ?, caldav_etag = ?, updated_at = CURRENT_TIMESTAMP + WHERE id = ? AND user_id = ?`, + [typ, sanitizeInput(titel), sanitizeInput(b.ort || ''), sanitizeInput(b.notiz || ''), + start.toISOString(), ende ? ende.toISOString() : null, ganztags ? 1 : 0, erinnerung, + caldavUid, href, etag, tid, uid()] + ); + res.redirect('/bewerbung/' + id + '?terminok=1#termine'); + } catch (error) { + console.error('Error editing termin:', error); + res.redirect('/bewerbung/' + id + '?terminerror=' + encodeURIComponent(error.message || 'Termin konnte nicht aktualisiert werden.') + '#termine'); + } + }); + // Update a timeline entry app.post('/bewerbung/:id/verlauf/:eintragId', async (req, res) => { try { diff --git a/views/aktivitaeten.ejs b/views/aktivitaeten.ejs index 4c64136..c47fca5 100644 --- a/views/aktivitaeten.ejs +++ b/views/aktivitaeten.ejs @@ -15,6 +15,7 @@ 'Eingangsbestätigung': 'bg-blue-500', 'In Bearbeitung': 'bg-teal-500', 'Interessiert': 'bg-pink-500', + 'Telefonat': 'bg-sky-500', 'Warten auf Rückmeldung': 'bg-orange-500', 'Warten auf meine Antwort': 'bg-fuchsia-500', 'Vorstellungsgespräch': 'bg-yellow-500', diff --git a/views/bewerbung.ejs b/views/bewerbung.ejs index 8f5514d..d02f2b0 100644 --- a/views/bewerbung.ejs +++ b/views/bewerbung.ejs @@ -13,6 +13,7 @@ 'Eingangsbestätigung': 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200', 'In Bearbeitung': 'bg-teal-100 text-teal-800 dark:bg-teal-900 dark:text-teal-200', 'Interessiert': 'bg-pink-100 text-pink-800 dark:bg-pink-900 dark:text-pink-200', + 'Telefonat': 'bg-sky-100 text-sky-800 dark:bg-sky-900 dark:text-sky-200', 'Warten auf Rückmeldung': 'bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-200', 'Warten auf meine Antwort': 'bg-fuchsia-100 text-fuchsia-800 dark:bg-fuchsia-900 dark:text-fuchsia-200', 'Vorstellungsgespräch': 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200', @@ -730,7 +731,7 @@ var bis = (!t.ganztags && t.ende) ? new Date(t.ende).toLocaleTimeString('de-DE', { timeZone: caldavTz, hour: '2-digit', minute: '2-digit' }) : ''; %>
  • -
    +
    <%= t.typ === 'vorstellungsgespraech' ? 'Vorstellungsgespräch' : 'Termin' %> <%= t.titel %> @@ -739,12 +740,74 @@ <% if (t.ort) { %>

    📍 <%= t.ort %>

    <% } %> <% if (t.notiz) { %>

    <%= t.notiz %>

    <% } %>
    -
    - + + +
    +
    +
    +
    +
    + + + + + + +
    + + +
    + + +
    +
  • <% }); %> @@ -820,6 +883,17 @@ var sync = function () { wrap.style.opacity = chk.checked ? '0.4' : '1'; wrap.querySelectorAll('input').forEach(function (i) { i.disabled = chk.checked; }); }; chk.addEventListener('change', sync); sync(); } + // Bearbeiten-Formulare: „Ganztägig" deaktiviert die Uhrzeit-Felder. + document.querySelectorAll('input.terminEditGanztags').forEach(function (g) { + var form = g.closest('form'); + if (!form) return; + var zeit = form.querySelector('.terminEditZeit'); + var sync = function () { + zeit.style.opacity = g.checked ? '0.4' : '1'; + zeit.querySelectorAll('input').forEach(function (i) { i.disabled = g.checked; }); + }; + g.addEventListener('change', sync); sync(); + }); })(); diff --git a/views/index.ejs b/views/index.ejs index 89431c5..3e006cf 100644 --- a/views/index.ejs +++ b/views/index.ejs @@ -145,7 +145,7 @@ // Status-Reihenfolge synchron zu public/js/main.js halten. Steht hier oben, // weil sowohl die Liste als auch die Aktivitäten weiter unten sie brauchen. const statusOrder = ['Entwurf', 'Gesendet', 'Eingangsbestätigung', - 'In Bearbeitung', 'Interessiert', 'Warten auf Rückmeldung', 'Warten auf meine Antwort', + 'In Bearbeitung', 'Interessiert', 'Telefonat', 'Warten auf Rückmeldung', 'Warten auf meine Antwort', 'Vorstellungsgespräch', 'Vertragsverhandlung', 'Einstellung', 'Absage', 'Absage von meiner Seite', 'Keine Rückmeldung']; const statusDot = { 'Entwurf': 'bg-purple-500', @@ -153,6 +153,7 @@ 'Eingangsbestätigung': 'bg-blue-500', 'In Bearbeitung': 'bg-teal-500', 'Interessiert': 'bg-pink-500', + 'Telefonat': 'bg-sky-500', 'Warten auf Rückmeldung': 'bg-orange-500', 'Warten auf meine Antwort': 'bg-fuchsia-500', 'Vorstellungsgespräch': 'bg-yellow-500', @@ -246,6 +247,7 @@ app.status === 'Vertragsverhandlung' ? 'bg-lime-100 text-lime-800 dark:bg-lime-800 dark:text-lime-100' : app.status === 'In Bearbeitung' ? 'bg-teal-100 text-teal-800 dark:bg-teal-800 dark:text-teal-100' : app.status === 'Interessiert' ? 'bg-pink-100 text-pink-800 dark:bg-pink-800 dark:text-pink-100' : + app.status === 'Telefonat' ? 'bg-sky-100 text-sky-800 dark:bg-sky-800 dark:text-sky-100' : app.status === 'Warten auf Rückmeldung' ? 'bg-orange-100 text-orange-800 dark:bg-orange-800 dark:text-orange-100' : app.status === 'Warten auf meine Antwort' ? 'bg-fuchsia-100 text-fuchsia-800 dark:bg-fuchsia-800 dark:text-fuchsia-100' : app.status === 'Entwurf' ? 'bg-purple-100 text-purple-800 dark:bg-purple-800 dark:text-purple-100' : diff --git a/views/statistik.ejs b/views/statistik.ejs index 76be2d3..0831743 100644 --- a/views/statistik.ejs +++ b/views/statistik.ejs @@ -238,7 +238,7 @@ const maxS = Math.max(...byStatus.map(s => s.count)); const statusColor = (s) => ({ 'Entwurf':'#94a3b8','Gesendet':'#3b82f6','Eingangsbestätigung':'#0ea5e9','In Bearbeitung':'#6366f1', - 'Interessiert':'#8b5cf6','Warten auf Rückmeldung':'#a3a3a3','Warten auf meine Antwort':'#a3a3a3', + 'Interessiert':'#8b5cf6','Telefonat':'#f472b6','Warten auf Rückmeldung':'#a3a3a3','Warten auf meine Antwort':'#a3a3a3', 'Vorstellungsgespräch':'#10b981','Vertragsverhandlung':'#84cc16','Absage':'#f43f5e','Absage von meiner Seite':'#f97316', 'Einstellung':'#059669','Keine Rückmeldung':'#cbd5e1' }[s] || '#2563eb'); @@ -315,7 +315,7 @@ <% if (aktivitaet.length) { const statusColor = (s) => ({ 'Entwurf':'#94a3b8','Gesendet':'#3b82f6','Eingangsbestätigung':'#0ea5e9','In Bearbeitung':'#6366f1', - 'Interessiert':'#8b5cf6','Warten auf Rückmeldung':'#a3a3a3','Warten auf meine Antwort':'#a3a3a3', + 'Interessiert':'#8b5cf6','Telefonat':'#f472b6','Warten auf Rückmeldung':'#a3a3a3','Warten auf meine Antwort':'#a3a3a3', 'Vorstellungsgespräch':'#10b981','Vertragsverhandlung':'#84cc16','Absage':'#f43f5e','Absage von meiner Seite':'#f97316', 'Einstellung':'#059669','Keine Rückmeldung':'#cbd5e1' }[s] || '#2563eb');