Add a two-way application calendar (CalDAV / SOGo)
New lib/caldav.js speaks CalDAV over Basic auth (shared mail account): reads events in a window, and creates/updates/deletes iCalendar VEVENTs with a reminder alarm. DST-correct Europe/Berlin <-> UTC handling. Appointments (Vorstellungsgespräch / general) are managed per application in a new "Termine" section and mirrored to the SOGo calendar; recording a "Vorstellungsgespräch" status suggests a prefilled calendar entry (confirm + click). A dashboard widget lists upcoming appointments. A background poller reconciles remote edits/deletions via the collection ctag. Config: CALDAV_URL (+ CALDAV_ALARM_MIN, CALDAV_POLL_MS); disabled gracefully when unset. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -521,6 +521,130 @@
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Termine (mirrored to the SOGo calendar via CalDAV) -->
|
||||
<% if (caldavConfigured) { %>
|
||||
<section id="termine" class="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6 mb-8">
|
||||
<div class="flex items-center gap-2 mb-4">
|
||||
<svg class="w-5 h-5 text-blue-600 dark:text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"></path></svg>
|
||||
<h2 class="text-lg font-semibold text-gray-800 dark:text-white">Termine</h2>
|
||||
</div>
|
||||
|
||||
<% if (terminOk) { %>
|
||||
<div class="mb-3 rounded-md bg-green-50 dark:bg-green-900/30 border border-green-200 dark:border-green-800 px-3 py-2 text-sm text-green-700 dark:text-green-300">Termin wurde im Kalender angelegt.</div>
|
||||
<% } %>
|
||||
<% if (terminError) { %>
|
||||
<div class="mb-3 rounded-md bg-red-50 dark:bg-red-900/30 border border-red-200 dark:border-red-800 px-3 py-2 text-sm text-red-700 dark:text-red-300"><%= terminError %></div>
|
||||
<% } %>
|
||||
<% if (terminVorschlag) { %>
|
||||
<div class="mb-3 rounded-md bg-blue-50 dark:bg-blue-900/30 border border-blue-200 dark:border-blue-800 px-3 py-2 text-sm text-blue-700 dark:text-blue-300">
|
||||
Vorstellungsgespräch erfasst – möchtest du den Termin in den Kalender aufnehmen? Zeit prüfen und speichern.
|
||||
</div>
|
||||
<% } %>
|
||||
|
||||
<% if (termine && termine.length) { %>
|
||||
<ul class="space-y-2 mb-4">
|
||||
<% termine.forEach(function(t){
|
||||
var when = t.ganztags
|
||||
? new Date(t.start).toLocaleDateString('de-DE', { timeZone: caldavTz, day: '2-digit', month: 'long', year: 'numeric' })
|
||||
: new Date(t.start).toLocaleString('de-DE', { timeZone: caldavTz, weekday: 'short', day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit' });
|
||||
var bis = (!t.ganztags && t.ende) ? new Date(t.ende).toLocaleTimeString('de-DE', { timeZone: caldavTz, hour: '2-digit', minute: '2-digit' }) : '';
|
||||
%>
|
||||
<li class="flex flex-wrap items-start justify-between gap-2 rounded-lg border border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-700/40 px-3 py-2">
|
||||
<div class="min-w-0">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium <%= t.typ === 'vorstellungsgespraech' ? 'bg-indigo-100 text-indigo-800 dark:bg-indigo-900/50 dark:text-indigo-200' : 'bg-gray-200 text-gray-700 dark:bg-gray-600 dark:text-gray-200' %>"><%= t.typ === 'vorstellungsgespraech' ? 'Vorstellungsgespräch' : 'Termin' %></span>
|
||||
<span class="text-sm font-medium text-gray-800 dark:text-gray-100 break-words"><%= t.titel %></span>
|
||||
</div>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-300 mt-0.5"><%= when %><% if (bis) { %>–<%= bis %> Uhr<% } %></p>
|
||||
<% if (t.ort) { %><p class="text-xs text-gray-500 dark:text-gray-400 mt-0.5">📍 <%= t.ort %></p><% } %>
|
||||
<% if (t.notiz) { %><p class="text-xs text-gray-500 dark:text-gray-400 mt-0.5 whitespace-pre-line"><%= t.notiz %></p><% } %>
|
||||
</div>
|
||||
<form action="/bewerbung/<%= application.id %>/termine/<%= t.id %>/delete" method="POST" onsubmit="return confirm('Diesen Termin löschen (auch im Kalender)?');">
|
||||
<button type="submit" class="inline-flex items-center gap-1 px-2 py-1 text-xs border border-gray-300 dark:border-gray-600 text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-md transition-colors">
|
||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path></svg>
|
||||
Löschen
|
||||
</button>
|
||||
</form>
|
||||
</li>
|
||||
<% }); %>
|
||||
</ul>
|
||||
<% } else { %>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400 mb-4">Noch keine Termine für diese Bewerbung.</p>
|
||||
<% } %>
|
||||
|
||||
<details <%= terminVorschlag ? 'open' : '' %> class="border-t border-gray-200 dark:border-gray-700 pt-3">
|
||||
<summary class="cursor-pointer inline-flex items-center gap-1.5 text-sm font-medium text-blue-600 dark:text-blue-400 hover:underline select-none">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"></path></svg>
|
||||
Termin hinzufügen
|
||||
</summary>
|
||||
<form action="/bewerbung/<%= application.id %>/termine" method="POST" class="mt-3 space-y-3" id="terminForm">
|
||||
<div class="grid gap-3 sm:grid-cols-2">
|
||||
<label class="block text-xs">
|
||||
<span class="text-gray-500 dark:text-gray-400">Art</span>
|
||||
<select name="typ" id="terminTyp" class="mt-1 w-full rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 px-2 py-1.5 text-sm">
|
||||
<option value="vorstellungsgespraech" <%= terminVorschlag ? 'selected' : '' %>>Vorstellungsgespräch</option>
|
||||
<option value="termin" <%= terminVorschlag ? '' : 'selected' %>>Allgemeiner Termin</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="block text-xs">
|
||||
<span class="text-gray-500 dark:text-gray-400">Titel</span>
|
||||
<input name="titel" value="<%= terminVorschlag ? ('Vorstellungsgespräch – ' + application.firma) : '' %>" placeholder="z. B. Vorstellungsgespräch" class="mt-1 w-full rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 px-2 py-1.5 text-sm">
|
||||
</label>
|
||||
<label class="block text-xs">
|
||||
<span class="text-gray-500 dark:text-gray-400">Datum</span>
|
||||
<input name="datum" type="date" required value="<%= terminVorschlag ? terminVorschlag.datum : '' %>" class="mt-1 w-full rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 px-2 py-1.5 text-sm">
|
||||
</label>
|
||||
<label class="block text-xs" id="zeitWrap">
|
||||
<span class="text-gray-500 dark:text-gray-400">Uhrzeit (von / bis)</span>
|
||||
<span class="mt-1 flex items-center gap-2">
|
||||
<input name="von" type="time" value="09:00" class="w-full rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 px-2 py-1.5 text-sm">
|
||||
<span class="text-gray-400">–</span>
|
||||
<input name="bis" type="time" class="w-full rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 px-2 py-1.5 text-sm">
|
||||
</span>
|
||||
</label>
|
||||
<label class="block text-xs">
|
||||
<span class="text-gray-500 dark:text-gray-400">Ort</span>
|
||||
<input name="ort" value="<%= application.ort || '' %>" placeholder="Adresse oder Ort" class="mt-1 w-full rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 px-2 py-1.5 text-sm">
|
||||
</label>
|
||||
<label class="block text-xs">
|
||||
<span class="text-gray-500 dark:text-gray-400">Erinnerung</span>
|
||||
<select name="erinnerung_min" class="mt-1 w-full rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 px-2 py-1.5 text-sm">
|
||||
<option value="0">keine</option>
|
||||
<option value="15">15 Min vorher</option>
|
||||
<option value="30">30 Min vorher</option>
|
||||
<option value="60" selected>1 Stunde vorher</option>
|
||||
<option value="120">2 Stunden vorher</option>
|
||||
<option value="1440">1 Tag vorher</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<label class="flex items-center gap-2 text-xs text-gray-600 dark:text-gray-300">
|
||||
<input type="checkbox" name="ganztags" value="1" id="ganztagsChk" class="rounded border-gray-300 dark:border-gray-600 text-blue-600">
|
||||
Ganztägig
|
||||
</label>
|
||||
<label class="block text-xs">
|
||||
<span class="text-gray-500 dark:text-gray-400">Notiz</span>
|
||||
<textarea name="notiz" rows="2" class="mt-1 w-full rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 px-3 py-2 text-sm"></textarea>
|
||||
</label>
|
||||
<button type="submit" class="inline-flex items-center gap-1.5 px-4 py-2 text-sm bg-blue-600 hover:bg-blue-700 text-white rounded-md transition-colors">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path></svg>
|
||||
In den Kalender aufnehmen
|
||||
</button>
|
||||
</form>
|
||||
</details>
|
||||
<script>
|
||||
(function () {
|
||||
var chk = document.getElementById('ganztagsChk');
|
||||
var wrap = document.getElementById('zeitWrap');
|
||||
if (chk && wrap) {
|
||||
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();
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
</section>
|
||||
<% } %>
|
||||
</main>
|
||||
|
||||
<%- include('partials/footer') %>
|
||||
|
||||
@@ -96,6 +96,40 @@
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Upcoming appointments (from the application calendar) -->
|
||||
<% if (typeof kommendeTermine !== 'undefined' && kommendeTermine && kommendeTermine.length) { %>
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6 mb-8">
|
||||
<div class="flex items-center gap-2 mb-4">
|
||||
<svg class="w-5 h-5 text-blue-600 dark:text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"></path></svg>
|
||||
<h2 class="text-lg font-semibold text-gray-800 dark:text-white">Anstehende Termine</h2>
|
||||
</div>
|
||||
<ul class="space-y-2">
|
||||
<% kommendeTermine.forEach(function(t){
|
||||
var when = t.ganztags
|
||||
? new Date(t.start).toLocaleDateString('de-DE', { timeZone: caldavTz, weekday: 'short', day: '2-digit', month: '2-digit', year: 'numeric' })
|
||||
: new Date(t.start).toLocaleString('de-DE', { timeZone: caldavTz, weekday: 'short', day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' });
|
||||
%>
|
||||
<li class="flex flex-wrap items-center justify-between gap-3 rounded-lg border border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-700/40 px-3 py-2">
|
||||
<div class="min-w-0">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium <%= t.typ === 'vorstellungsgespraech' ? 'bg-indigo-100 text-indigo-800 dark:bg-indigo-900/50 dark:text-indigo-200' : 'bg-gray-200 text-gray-700 dark:bg-gray-600 dark:text-gray-200' %>"><%= t.typ === 'vorstellungsgespraech' ? 'Gespräch' : 'Termin' %></span>
|
||||
<span class="text-sm font-medium text-gray-800 dark:text-gray-100 break-words"><%= t.titel %></span>
|
||||
</div>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-300 mt-0.5">
|
||||
<%= when %> Uhr<% if (t.bewerbung_firma) { %> · <span class="text-gray-500 dark:text-gray-400"><%= t.bewerbung_firma %></span><% } %>
|
||||
</p>
|
||||
</div>
|
||||
<% if (t.bewerbung_id) { %>
|
||||
<a href="/bewerbung/<%= t.bewerbung_id %>#termine" class="shrink-0 inline-flex items-center gap-1 text-xs text-blue-600 dark:text-blue-400 hover:underline">Öffnen
|
||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"></path></svg>
|
||||
</a>
|
||||
<% } %>
|
||||
</li>
|
||||
<% }); %>
|
||||
</ul>
|
||||
</div>
|
||||
<% } %>
|
||||
|
||||
<!-- Statistics Section -->
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6 mb-8">
|
||||
<h2 class="text-lg font-semibold text-gray-800 dark:text-white mb-6">Statistik</h2>
|
||||
|
||||
Reference in New Issue
Block a user