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:
@@ -25,6 +25,15 @@ MAIL_FROM=name@example.com
|
|||||||
MAIL_IMAP_MAILBOX=INBOX
|
MAIL_IMAP_MAILBOX=INBOX
|
||||||
MAIL_POLL_MS=180000
|
MAIL_POLL_MS=180000
|
||||||
|
|
||||||
|
# --- Bewerbungskalender (CalDAV, z. B. SOGo) ---
|
||||||
|
# Voll-URL der Kalender-Sammlung (mit abschließendem /). Auth läuft über die
|
||||||
|
# oben gesetzten MAIL_USER/MAIL_PASSWORD (gleiches Konto). Ohne CALDAV_URL sind
|
||||||
|
# die Kalender-Funktionen deaktiviert.
|
||||||
|
# CALDAV_URL=https://mail.example.com/SOGo/dav/name@example.com/Calendar/XXXX/
|
||||||
|
# Standard-Erinnerung (Minuten vor Terminbeginn) und Sync-Intervall (ms).
|
||||||
|
CALDAV_ALARM_MIN=60
|
||||||
|
CALDAV_POLL_MS=300000
|
||||||
|
|
||||||
# --- REST-API für Drittanbietersoftware (/api/v1) ---
|
# --- REST-API für Drittanbietersoftware (/api/v1) ---
|
||||||
# Ist dieser Schlüssel gesetzt, ist die API aktiv und erwartet den Wert im
|
# Ist dieser Schlüssel gesetzt, ist die API aktiv und erwartet den Wert im
|
||||||
# Header "X-API-Key" jedes Requests. Ohne Schlüssel antwortet die API (bis auf
|
# Header "X-API-Key" jedes Requests. Ohne Schlüssel antwortet die API (bis auf
|
||||||
|
|||||||
+329
@@ -0,0 +1,329 @@
|
|||||||
|
// Minimal CalDAV client for the application calendar (SOGo).
|
||||||
|
//
|
||||||
|
// Two-way sync: reads events in a time window (REPORT calendar-query) and
|
||||||
|
// creates / updates / deletes events (PUT / DELETE with iCalendar VEVENTs).
|
||||||
|
// Auth is HTTP Basic over HTTPS using the same mail account as lib/mailer.
|
||||||
|
//
|
||||||
|
// Config (env):
|
||||||
|
// CALDAV_URL full URL of the calendar collection (must end with /)
|
||||||
|
// MAIL_USER login (shared with mail)
|
||||||
|
// MAIL_PASSWORD password (shared with mail)
|
||||||
|
// CALDAV_ALARM_MIN default reminder lead time in minutes (default 60)
|
||||||
|
//
|
||||||
|
// Times are handled as UTC instants internally; the UI renders them in
|
||||||
|
// Europe/Berlin. Wall-clock input from the UI is converted with wallToUtc().
|
||||||
|
|
||||||
|
const crypto = require('crypto');
|
||||||
|
|
||||||
|
const TZ = 'Europe/Berlin';
|
||||||
|
|
||||||
|
function cfg() {
|
||||||
|
return {
|
||||||
|
url: (process.env.CALDAV_URL || '').trim(),
|
||||||
|
user: process.env.MAIL_USER || '',
|
||||||
|
pass: process.env.MAIL_PASSWORD || '',
|
||||||
|
alarmMin: Number(process.env.CALDAV_ALARM_MIN) || 60,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function isConfigured() {
|
||||||
|
const c = cfg();
|
||||||
|
return !!(c.url && c.user && c.pass);
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectionUrl() {
|
||||||
|
let u = cfg().url;
|
||||||
|
if (u && !u.endsWith('/')) u += '/';
|
||||||
|
return u;
|
||||||
|
}
|
||||||
|
|
||||||
|
function authHeader() {
|
||||||
|
const c = cfg();
|
||||||
|
return 'Basic ' + Buffer.from(`${c.user}:${c.pass}`).toString('base64');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Time helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const pad = (n) => String(n).padStart(2, '0');
|
||||||
|
|
||||||
|
// Offset (ms) of a time zone at a given instant: local(tz) - UTC.
|
||||||
|
function tzOffsetMs(timeZone, date) {
|
||||||
|
const utc = new Date(date.toLocaleString('en-US', { timeZone: 'UTC' }));
|
||||||
|
const loc = new Date(date.toLocaleString('en-US', { timeZone }));
|
||||||
|
return loc.getTime() - utc.getTime();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Interpret (y, mo[1-12], d, h, mi) as wall-clock time in `timeZone` and return
|
||||||
|
// the corresponding UTC Date (DST-correct, including boundary re-check).
|
||||||
|
function wallToUtc(y, mo, d, h, mi, timeZone = TZ) {
|
||||||
|
const asIfUtc = new Date(Date.UTC(y, mo - 1, d, h, mi, 0));
|
||||||
|
const off1 = tzOffsetMs(timeZone, asIfUtc);
|
||||||
|
let result = new Date(asIfUtc.getTime() - off1);
|
||||||
|
const off2 = tzOffsetMs(timeZone, result);
|
||||||
|
if (off2 !== off1) result = new Date(asIfUtc.getTime() - off2);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// "YYYY-MM-DDTHH:MM" (local Berlin wall time from a form) -> UTC Date.
|
||||||
|
function localInputToUtc(dateStr, timeStr) {
|
||||||
|
const [y, mo, d] = String(dateStr).split('-').map(Number);
|
||||||
|
const [h, mi] = String(timeStr || '00:00').split(':').map(Number);
|
||||||
|
return wallToUtc(y, mo, d, h || 0, mi || 0, TZ);
|
||||||
|
}
|
||||||
|
|
||||||
|
// UTC Date -> iCalendar UTC stamp "YYYYMMDDTHHMMSSZ".
|
||||||
|
function toIcsUtc(date) {
|
||||||
|
return date.getUTCFullYear() + pad(date.getUTCMonth() + 1) + pad(date.getUTCDate())
|
||||||
|
+ 'T' + pad(date.getUTCHours()) + pad(date.getUTCMinutes()) + pad(date.getUTCSeconds()) + 'Z';
|
||||||
|
}
|
||||||
|
|
||||||
|
// UTC Date -> iCalendar DATE "YYYYMMDD" in Europe/Berlin (for all-day events).
|
||||||
|
function toIcsDate(date) {
|
||||||
|
const parts = new Intl.DateTimeFormat('en-CA', { timeZone: TZ, year: 'numeric', month: '2-digit', day: '2-digit' })
|
||||||
|
.formatToParts(date).reduce((a, p) => (a[p.type] = p.value, a), {});
|
||||||
|
return `${parts.year}${parts.month}${parts.day}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse an iCalendar DTSTART/DTEND value (+params) to { date: Date, allDay }.
|
||||||
|
function icsToDate(value, params) {
|
||||||
|
const v = String(value).trim();
|
||||||
|
const tzid = (params && params.TZID) || null;
|
||||||
|
if (/^\d{8}$/.test(v)) {
|
||||||
|
const y = +v.slice(0, 4), mo = +v.slice(4, 6), d = +v.slice(6, 8);
|
||||||
|
return { date: wallToUtc(y, mo, d, 0, 0, tzid || TZ), allDay: true };
|
||||||
|
}
|
||||||
|
const m = v.match(/^(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})(Z)?$/);
|
||||||
|
if (!m) return { date: null, allDay: false };
|
||||||
|
const [, y, mo, d, h, mi, s, z] = m;
|
||||||
|
if (z) return { date: new Date(Date.UTC(+y, +mo - 1, +d, +h, +mi, +s)), allDay: false };
|
||||||
|
// TZID or floating -> treat as wall time in that zone (Berlin fallback).
|
||||||
|
return { date: wallToUtc(+y, +mo, +d, +h, +mi, tzid || TZ), allDay: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// iCalendar generation
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function escapeText(s) {
|
||||||
|
return String(s == null ? '' : s)
|
||||||
|
.replace(/\\/g, '\\\\')
|
||||||
|
.replace(/;/g, '\\;')
|
||||||
|
.replace(/,/g, '\\,')
|
||||||
|
.replace(/\r?\n/g, '\\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
function unescapeText(s) {
|
||||||
|
return String(s == null ? '' : s)
|
||||||
|
.replace(/\\n/gi, '\n')
|
||||||
|
.replace(/\\,/g, ',')
|
||||||
|
.replace(/\\;/g, ';')
|
||||||
|
.replace(/\\\\/g, '\\');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fold a content line to <=75 octets per RFC 5545 (CRLF + single space).
|
||||||
|
function fold(line) {
|
||||||
|
if (Buffer.byteLength(line, 'utf8') <= 75) return line;
|
||||||
|
const out = [];
|
||||||
|
let cur = '';
|
||||||
|
for (const ch of line) {
|
||||||
|
if (Buffer.byteLength(cur + ch, 'utf8') > 73) { out.push(cur); cur = ' ' + ch; }
|
||||||
|
else cur += ch;
|
||||||
|
}
|
||||||
|
if (cur) out.push(cur);
|
||||||
|
return out.join('\r\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build a full VCALENDAR/VEVENT document.
|
||||||
|
// evt: { uid, summary, location, description, start(Date), end(Date), allDay, alarmMin }
|
||||||
|
function buildICS(evt) {
|
||||||
|
const uid = evt.uid || (crypto.randomUUID() + '@bewerbungs-tracker');
|
||||||
|
const lines = [
|
||||||
|
'BEGIN:VCALENDAR',
|
||||||
|
'VERSION:2.0',
|
||||||
|
'PRODID:-//Bewerbungs-Tracker//Kalender//DE',
|
||||||
|
'CALSCALE:GREGORIAN',
|
||||||
|
'BEGIN:VEVENT',
|
||||||
|
'UID:' + uid,
|
||||||
|
'DTSTAMP:' + toIcsUtc(new Date()),
|
||||||
|
];
|
||||||
|
if (evt.allDay) {
|
||||||
|
lines.push('DTSTART;VALUE=DATE:' + toIcsDate(evt.start));
|
||||||
|
const endDate = evt.end || new Date(evt.start.getTime() + 24 * 3600 * 1000);
|
||||||
|
lines.push('DTEND;VALUE=DATE:' + toIcsDate(endDate));
|
||||||
|
} else {
|
||||||
|
lines.push('DTSTART:' + toIcsUtc(evt.start));
|
||||||
|
const end = evt.end || new Date(evt.start.getTime() + 60 * 60 * 1000);
|
||||||
|
lines.push('DTEND:' + toIcsUtc(end));
|
||||||
|
}
|
||||||
|
lines.push('SUMMARY:' + escapeText(evt.summary || 'Termin'));
|
||||||
|
if (evt.location) lines.push('LOCATION:' + escapeText(evt.location));
|
||||||
|
if (evt.description) lines.push('DESCRIPTION:' + escapeText(evt.description));
|
||||||
|
const alarm = Number(evt.alarmMin);
|
||||||
|
if (!evt.allDay && alarm > 0) {
|
||||||
|
lines.push('BEGIN:VALARM', 'ACTION:DISPLAY', 'DESCRIPTION:' + escapeText(evt.summary || 'Termin'),
|
||||||
|
'TRIGGER:-PT' + Math.round(alarm) + 'M', 'END:VALARM');
|
||||||
|
}
|
||||||
|
lines.push('END:VEVENT', 'END:VCALENDAR');
|
||||||
|
return { uid, body: lines.map(fold).join('\r\n') + '\r\n' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// iCalendar parsing (single VEVENT out of a VCALENDAR)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function unfold(ics) {
|
||||||
|
const raw = String(ics).split(/\r\n|\n|\r/);
|
||||||
|
const out = [];
|
||||||
|
for (const line of raw) {
|
||||||
|
if ((line.startsWith(' ') || line.startsWith('\t')) && out.length) out[out.length - 1] += line.slice(1);
|
||||||
|
else out.push(line);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseVEvent(ics) {
|
||||||
|
const lines = unfold(ics);
|
||||||
|
let inEvent = false, nested = 0;
|
||||||
|
const ev = {};
|
||||||
|
for (const line of lines) {
|
||||||
|
if (line === 'BEGIN:VEVENT') { inEvent = true; continue; }
|
||||||
|
if (line === 'END:VEVENT') break;
|
||||||
|
if (!inEvent) continue;
|
||||||
|
// Skip nested components (e.g. VALARM) so their fields don't leak in.
|
||||||
|
if (line.startsWith('BEGIN:')) { nested++; continue; }
|
||||||
|
if (line.startsWith('END:')) { if (nested > 0) nested--; continue; }
|
||||||
|
if (nested > 0) continue;
|
||||||
|
const idx = line.indexOf(':');
|
||||||
|
if (idx === -1) continue;
|
||||||
|
const namePart = line.slice(0, idx);
|
||||||
|
const value = line.slice(idx + 1);
|
||||||
|
const [name, ...paramParts] = namePart.split(';');
|
||||||
|
const params = {};
|
||||||
|
paramParts.forEach((p) => { const [k, v] = p.split('='); if (k) params[k.toUpperCase()] = v; });
|
||||||
|
const key = name.toUpperCase();
|
||||||
|
if (key === 'UID') ev.uid = value.trim();
|
||||||
|
else if (key === 'SUMMARY') ev.summary = unescapeText(value);
|
||||||
|
else if (key === 'LOCATION') ev.location = unescapeText(value);
|
||||||
|
else if (key === 'DESCRIPTION') ev.description = unescapeText(value);
|
||||||
|
else if (key === 'DTSTART') { const r = icsToDate(value, params); ev.start = r.date; ev.allDay = r.allDay; }
|
||||||
|
else if (key === 'DTEND') { const r = icsToDate(value, params); ev.end = r.date; }
|
||||||
|
}
|
||||||
|
return ev.uid || ev.start ? ev : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// HTTP / CalDAV
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async function request(method, url, { headers = {}, body } = {}) {
|
||||||
|
const res = await fetch(url, {
|
||||||
|
method,
|
||||||
|
headers: { Authorization: authHeader(), ...headers },
|
||||||
|
body,
|
||||||
|
});
|
||||||
|
const text = await res.text().catch(() => '');
|
||||||
|
if (res.status === 401) throw new Error('CalDAV-Anmeldung fehlgeschlagen (401) — Zugangsdaten prüfen.');
|
||||||
|
return { status: res.status, ok: res.ok, headers: res.headers, text };
|
||||||
|
}
|
||||||
|
|
||||||
|
function decodeXml(s) {
|
||||||
|
return String(s).replace(/</g, '<').replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"').replace(/'/g, "'").replace(/ /g, '\r').replace(/&/g, '&');
|
||||||
|
}
|
||||||
|
|
||||||
|
function tagContent(xml, tag) {
|
||||||
|
const m = xml.match(new RegExp('<[^>]*' + tag + '[^>]*>([\\s\\S]*?)</[^>]*' + tag + '>', 'i'));
|
||||||
|
return m ? m[1] : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Change tag of the collection — cheap way to detect any change.
|
||||||
|
async function getCtag() {
|
||||||
|
if (!isConfigured()) return null;
|
||||||
|
const r = await request('PROPFIND', collectionUrl(), {
|
||||||
|
headers: { Depth: '0', 'Content-Type': 'application/xml; charset=utf-8' },
|
||||||
|
body: '<?xml version="1.0"?><d:propfind xmlns:d="DAV:" xmlns:cs="http://calendarserver.org/ns/"><d:prop><cs:getctag/></d:prop></d:propfind>',
|
||||||
|
});
|
||||||
|
return tagContent(r.text, 'getctag');
|
||||||
|
}
|
||||||
|
|
||||||
|
// List events overlapping [from, to] (Date objects). Returns parsed events with
|
||||||
|
// href/etag so they can be updated/deleted later.
|
||||||
|
async function listEvents({ from, to }) {
|
||||||
|
if (!isConfigured()) return [];
|
||||||
|
const body =
|
||||||
|
'<?xml version="1.0"?><c:calendar-query xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">' +
|
||||||
|
'<d:prop><d:getetag/><c:calendar-data/></d:prop>' +
|
||||||
|
'<c:filter><c:comp-filter name="VCALENDAR"><c:comp-filter name="VEVENT">' +
|
||||||
|
`<c:time-range start="${toIcsUtc(from)}" end="${toIcsUtc(to)}"/>` +
|
||||||
|
'</c:comp-filter></c:comp-filter></c:filter></c:calendar-query>';
|
||||||
|
const r = await request('REPORT', collectionUrl(), {
|
||||||
|
headers: { Depth: '1', 'Content-Type': 'application/xml; charset=utf-8' },
|
||||||
|
body,
|
||||||
|
});
|
||||||
|
const events = [];
|
||||||
|
const responses = r.text.match(/<[^>]*:?response[^>]*>[\s\S]*?<\/[^>]*:?response>/gi) || [];
|
||||||
|
for (const resp of responses) {
|
||||||
|
const href = tagContent(resp, 'href');
|
||||||
|
const etag = tagContent(resp, 'getetag');
|
||||||
|
const calData = tagContent(resp, 'calendar-data');
|
||||||
|
if (!calData) continue;
|
||||||
|
const ev = parseVEvent(decodeXml(calData));
|
||||||
|
if (!ev || !ev.start) continue;
|
||||||
|
ev.href = href ? new URL(decodeXml(href).trim(), collectionUrl()).toString() : null;
|
||||||
|
ev.etag = etag ? etag.replace(/^["']|["']$/g, '').trim() : null;
|
||||||
|
events.push(ev);
|
||||||
|
}
|
||||||
|
return events;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createEvent(evt) {
|
||||||
|
if (!isConfigured()) throw new Error('CalDAV ist nicht konfiguriert.');
|
||||||
|
const built = buildICS(evt);
|
||||||
|
const url = collectionUrl() + encodeURIComponent(built.uid) + '.ics';
|
||||||
|
const r = await request('PUT', url, {
|
||||||
|
headers: { 'Content-Type': 'text/calendar; charset=utf-8', 'If-None-Match': '*' },
|
||||||
|
body: built.body,
|
||||||
|
});
|
||||||
|
if (!r.ok) throw new Error(`Termin konnte nicht angelegt werden (HTTP ${r.status}).`);
|
||||||
|
return { uid: built.uid, href: url, etag: (r.headers.get('etag') || '').replace(/^["']|["']$/g, '') || null };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateEvent({ href, etag, ...evt }) {
|
||||||
|
if (!isConfigured()) throw new Error('CalDAV ist nicht konfiguriert.');
|
||||||
|
const built = buildICS(evt);
|
||||||
|
const headers = { 'Content-Type': 'text/calendar; charset=utf-8' };
|
||||||
|
if (etag) headers['If-Match'] = `"${etag}"`;
|
||||||
|
const r = await request('PUT', href, { headers, body: built.body });
|
||||||
|
if (!r.ok) throw new Error(`Termin konnte nicht aktualisiert werden (HTTP ${r.status}).`);
|
||||||
|
return { uid: evt.uid || built.uid, href, etag: (r.headers.get('etag') || '').replace(/^["']|["']$/g, '') || null };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteEvent({ href, etag }) {
|
||||||
|
if (!isConfigured() || !href) return;
|
||||||
|
const headers = {};
|
||||||
|
if (etag) headers['If-Match'] = `"${etag}"`;
|
||||||
|
const r = await request('DELETE', href, { headers });
|
||||||
|
// 404/412 are non-fatal: the event is already gone or changed remotely.
|
||||||
|
if (!r.ok && r.status !== 404 && r.status !== 412) {
|
||||||
|
throw new Error(`Termin konnte nicht gelöscht werden (HTTP ${r.status}).`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
TZ,
|
||||||
|
isConfigured,
|
||||||
|
collectionUrl,
|
||||||
|
wallToUtc,
|
||||||
|
localInputToUtc,
|
||||||
|
buildICS,
|
||||||
|
parseVEvent,
|
||||||
|
getCtag,
|
||||||
|
listEvents,
|
||||||
|
createEvent,
|
||||||
|
updateEvent,
|
||||||
|
deleteEvent,
|
||||||
|
// exported for tests
|
||||||
|
_internals: { toIcsUtc, toIcsDate, icsToDate, escapeText, unescapeText, fold, unfold },
|
||||||
|
};
|
||||||
@@ -32,6 +32,7 @@ const mailer = require('./lib/mailer');
|
|||||||
const { createExternalApi } = require('./lib/api');
|
const { createExternalApi } = require('./lib/api');
|
||||||
const { buildOpenApiSpec } = require('./lib/openapi');
|
const { buildOpenApiSpec } = require('./lib/openapi');
|
||||||
const blacklist = require('./lib/blacklist');
|
const blacklist = require('./lib/blacklist');
|
||||||
|
const caldav = require('./lib/caldav');
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
const PORT = process.env.PORT || 3000;
|
const PORT = process.env.PORT || 3000;
|
||||||
@@ -269,6 +270,58 @@ async function autoBlacklistOffer(offer, grund) {
|
|||||||
await insertBlacklistEntry(blacklist.buildAutoEntry(offer, grund));
|
await insertBlacklistEntry(blacklist.buildAutoEntry(offer, grund));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Application calendar (CalDAV) helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// Upcoming appointments (not yet ended), newest first, for the dashboard widget.
|
||||||
|
async function upcomingTermine(limit = 6) {
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
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 COALESCE(t.ende, t.start) >= ?
|
||||||
|
ORDER BY t.start ASC LIMIT ?`,
|
||||||
|
[now, limit]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reconcile our tracked appointments with the SOGo calendar: reflect remote
|
||||||
|
// edits and drop entries deleted remotely. Cheap ctag check first. Best-effort.
|
||||||
|
async function refreshCaldav() {
|
||||||
|
if (!caldav.isConfigured()) return;
|
||||||
|
const ctag = await caldav.getCtag().catch(() => null);
|
||||||
|
if (ctag) {
|
||||||
|
const prev = await getState('caldav_ctag');
|
||||||
|
if (prev && prev === ctag) return;
|
||||||
|
}
|
||||||
|
const from = new Date(Date.now() - 24 * 3600 * 1000);
|
||||||
|
const to = new Date(Date.now() + 180 * 24 * 3600 * 1000);
|
||||||
|
const remote = await caldav.listEvents({ from, to });
|
||||||
|
const byUid = new Map(remote.map((e) => [e.uid, e]));
|
||||||
|
const local = await dbAll(
|
||||||
|
'SELECT * FROM termine WHERE caldav_uid IS NOT NULL AND start >= ? AND start <= ?',
|
||||||
|
[from.toISOString(), to.toISOString()]
|
||||||
|
);
|
||||||
|
for (const t of local) {
|
||||||
|
const r = byUid.get(t.caldav_uid);
|
||||||
|
if (!r) {
|
||||||
|
await dbRun('DELETE FROM termine WHERE id = ?', [t.id]);
|
||||||
|
} else {
|
||||||
|
await dbRun(
|
||||||
|
`UPDATE termine SET titel = ?, ort = ?, notiz = ?, start = ?, ende = ?, ganztags = ?,
|
||||||
|
caldav_etag = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?`,
|
||||||
|
[
|
||||||
|
sanitizeInput(r.summary || t.titel), sanitizeInput(r.location || ''), sanitizeInput(r.description || ''),
|
||||||
|
(r.start || new Date(t.start)).toISOString(), r.end ? r.end.toISOString() : null,
|
||||||
|
r.allDay ? 1 : 0, r.etag || t.caldav_etag, t.id,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (ctag) await setState('caldav_ctag', ctag);
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// E-Mail correspondence: IMAP polling, storing & matching incoming replies
|
// E-Mail correspondence: IMAP polling, storing & matching incoming replies
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -702,6 +755,29 @@ function initializeDatabase() {
|
|||||||
// Remember the last recipient address per application (prefill).
|
// Remember the last recipient address per application (prefill).
|
||||||
db.run('ALTER TABLE bewerbungen ADD COLUMN email_empfaenger TEXT', () => {});
|
db.run('ALTER TABLE bewerbungen ADD COLUMN email_empfaenger TEXT', () => {});
|
||||||
|
|
||||||
|
// Application calendar appointments, mirrored to the SOGo CalDAV
|
||||||
|
// calendar (see lib/caldav). Times are stored as UTC ISO strings.
|
||||||
|
db.run(`
|
||||||
|
CREATE TABLE IF NOT EXISTS termine (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
bewerbung_id INTEGER,
|
||||||
|
typ TEXT NOT NULL DEFAULT 'termin',
|
||||||
|
titel TEXT NOT NULL,
|
||||||
|
ort TEXT,
|
||||||
|
notiz TEXT,
|
||||||
|
start TEXT NOT NULL,
|
||||||
|
ende TEXT,
|
||||||
|
ganztags INTEGER DEFAULT 0,
|
||||||
|
erinnerung_min INTEGER DEFAULT 60,
|
||||||
|
caldav_uid TEXT,
|
||||||
|
caldav_href TEXT,
|
||||||
|
caldav_etag TEXT,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (bewerbung_id) REFERENCES bewerbungen(id) ON DELETE SET NULL
|
||||||
|
)
|
||||||
|
`, () => {});
|
||||||
|
|
||||||
db.run(`
|
db.run(`
|
||||||
CREATE TABLE IF NOT EXISTS settings (
|
CREATE TABLE IF NOT EXISTS settings (
|
||||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||||
@@ -771,6 +847,8 @@ initializeDatabase().then(() => {
|
|||||||
const applications = await dbAll(query, params);
|
const applications = await dbAll(query, params);
|
||||||
await attachVerlauf(applications);
|
await attachVerlauf(applications);
|
||||||
const settings = await dbGet('SELECT * FROM settings WHERE id = 1');
|
const settings = await dbGet('SELECT * FROM settings WHERE id = 1');
|
||||||
|
// Upcoming calendar appointments for the dashboard widget.
|
||||||
|
const kommendeTermine = await upcomingTermine(6);
|
||||||
|
|
||||||
// Get statistics
|
// Get statistics
|
||||||
const totalCount = await dbGet('SELECT COUNT(*) as count FROM bewerbungen');
|
const totalCount = await dbGet('SELECT COUNT(*) as count FROM bewerbungen');
|
||||||
@@ -803,6 +881,8 @@ initializeDatabase().then(() => {
|
|||||||
},
|
},
|
||||||
availableMonths,
|
availableMonths,
|
||||||
currentFilter: { month, year },
|
currentFilter: { month, year },
|
||||||
|
kommendeTermine,
|
||||||
|
caldavTz: caldav.TZ,
|
||||||
artOptions: ART_OPTIONS,
|
artOptions: ART_OPTIONS,
|
||||||
statusOptions: STATUS_OPTIONS
|
statusOptions: STATUS_OPTIONS
|
||||||
});
|
});
|
||||||
@@ -1085,6 +1165,8 @@ initializeDatabase().then(() => {
|
|||||||
const basisCountRow = await dbGet('SELECT COUNT(*) as count FROM basis_dokumente');
|
const basisCountRow = await dbGet('SELECT COUNT(*) as count FROM basis_dokumente');
|
||||||
// Available static attachments (Zeugnisse etc.) to optionally enclose.
|
// Available static attachments (Zeugnisse etc.) to optionally enclose.
|
||||||
const basisAnhaenge = await dbAll('SELECT id, name, dateiname FROM basis_anhaenge ORDER BY id ASC');
|
const basisAnhaenge = await dbAll('SELECT id, name, dateiname FROM basis_anhaenge ORDER BY id ASC');
|
||||||
|
// Calendar appointments for this application (mirrored to SOGo).
|
||||||
|
const termine = await dbAll('SELECT * FROM termine WHERE bewerbung_id = ? ORDER BY start ASC', [id]);
|
||||||
|
|
||||||
// E-Mail correspondence (sent + received), oldest first, with attachments.
|
// E-Mail correspondence (sent + received), oldest first, with attachments.
|
||||||
const emails = await dbAll(
|
const emails = await dbAll(
|
||||||
@@ -1120,6 +1202,12 @@ initializeDatabase().then(() => {
|
|||||||
mailOk: req.query.mailok ? String(req.query.mailok) : '',
|
mailOk: req.query.mailok ? String(req.query.mailok) : '',
|
||||||
basisCount: basisCountRow ? basisCountRow.count : 0,
|
basisCount: basisCountRow ? basisCountRow.count : 0,
|
||||||
basisAnhaenge,
|
basisAnhaenge,
|
||||||
|
termine,
|
||||||
|
caldavConfigured: caldav.isConfigured(),
|
||||||
|
caldavTz: caldav.TZ,
|
||||||
|
terminVorschlag: req.query.vorschlag === 'vg' ? { datum: String(req.query.vdatum || '') } : null,
|
||||||
|
terminOk: !!req.query.terminok,
|
||||||
|
terminError: req.query.terminerror ? String(req.query.terminerror) : '',
|
||||||
artOptions: ART_OPTIONS,
|
artOptions: ART_OPTIONS,
|
||||||
statusOptions: STATUS_OPTIONS,
|
statusOptions: STATUS_OPTIONS,
|
||||||
hideSettings: true
|
hideSettings: true
|
||||||
@@ -1380,6 +1468,11 @@ initializeDatabase().then(() => {
|
|||||||
[id, datum, sanitizeInput(status), sanitizeInput(kommentar || '')]
|
[id, datum, sanitizeInput(status), sanitizeInput(kommentar || '')]
|
||||||
);
|
);
|
||||||
await syncCurrentStatus(id);
|
await syncCurrentStatus(id);
|
||||||
|
|
||||||
|
// Suggest a calendar entry when an interview was recorded (confirm + click).
|
||||||
|
if (caldav.isConfigured() && /vorstellungsgespr/i.test(status)) {
|
||||||
|
return res.redirect('/bewerbung/' + id + '?vorschlag=vg&vdatum=' + encodeURIComponent(datum) + '#termine');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
res.redirect('/bewerbung/' + id);
|
res.redirect('/bewerbung/' + id);
|
||||||
@@ -1389,6 +1482,67 @@ initializeDatabase().then(() => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// --- Application calendar appointments (mirrored to SOGo via CalDAV) ---
|
||||||
|
app.post('/bewerbung/:id/termine', async (req, res) => {
|
||||||
|
const { id } = req.params;
|
||||||
|
try {
|
||||||
|
const bewerbung = await dbGet('SELECT id FROM bewerbungen WHERE id = ?', [id]);
|
||||||
|
if (!bewerbung) return res.status(404).send('Bewerbung nicht gefunden');
|
||||||
|
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 created = await caldav.createEvent({
|
||||||
|
summary: titel, location: b.ort || '', description: b.notiz || '',
|
||||||
|
start, end: ende, allDay: ganztags, alarmMin: erinnerung,
|
||||||
|
});
|
||||||
|
|
||||||
|
await dbRun(
|
||||||
|
`INSERT INTO termine (bewerbung_id, typ, titel, ort, notiz, start, ende, ganztags, erinnerung_min, caldav_uid, caldav_href, caldav_etag)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
[id, typ, sanitizeInput(titel), sanitizeInput(b.ort || ''), sanitizeInput(b.notiz || ''),
|
||||||
|
start.toISOString(), ende ? ende.toISOString() : null, ganztags ? 1 : 0, erinnerung,
|
||||||
|
created.uid, created.href, created.etag]
|
||||||
|
);
|
||||||
|
res.redirect('/bewerbung/' + id + '?terminok=1#termine');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error creating termin:', error);
|
||||||
|
res.redirect('/bewerbung/' + id + '?terminerror=' + encodeURIComponent(error.message || 'Termin konnte nicht angelegt werden.') + '#termine');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/bewerbung/:id/termine/:tid/delete', async (req, res) => {
|
||||||
|
const { id, tid } = req.params;
|
||||||
|
try {
|
||||||
|
const t = await dbGet('SELECT * FROM termine WHERE id = ? AND bewerbung_id = ?', [tid, id]);
|
||||||
|
if (t) {
|
||||||
|
await caldav.deleteEvent({ href: t.caldav_href, etag: t.caldav_etag }).catch((e) => console.warn('CalDAV delete:', e.message));
|
||||||
|
await dbRun('DELETE FROM termine WHERE id = ?', [tid]);
|
||||||
|
}
|
||||||
|
res.redirect('/bewerbung/' + id + '#termine');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error deleting termin:', error);
|
||||||
|
res.redirect('/bewerbung/' + id + '#termine');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Update a timeline entry
|
// Update a timeline entry
|
||||||
app.post('/bewerbung/:id/verlauf/:eintragId', async (req, res) => {
|
app.post('/bewerbung/:id/verlauf/:eintragId', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
@@ -1996,6 +2150,16 @@ initializeDatabase().then(() => {
|
|||||||
console.log('E-Mail nicht konfiguriert (MAIL_HOST/MAIL_USER/MAIL_PASSWORD fehlen) - Versand/Empfang deaktiviert.');
|
console.log('E-Mail nicht konfiguriert (MAIL_HOST/MAIL_USER/MAIL_PASSWORD fehlen) - Versand/Empfang deaktiviert.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Calendar: reconcile our appointments with the SOGo CalDAV calendar.
|
||||||
|
if (caldav.isConfigured()) {
|
||||||
|
console.log(`Kalender aktiv: CalDAV ${caldav.collectionUrl()}`);
|
||||||
|
const calPoll = Math.max(60000, Number(process.env.CALDAV_POLL_MS) || 300000);
|
||||||
|
setTimeout(() => { refreshCaldav().catch(() => {}); }, 10000); // initial sync after boot
|
||||||
|
setInterval(() => { refreshCaldav().catch(() => {}); }, calPoll); // periodic reconcile
|
||||||
|
} else {
|
||||||
|
console.log('Kalender nicht konfiguriert (CALDAV_URL fehlt) - Kalender-Funktionen deaktiviert.');
|
||||||
|
}
|
||||||
|
|
||||||
// Handle 404
|
// Handle 404
|
||||||
app.use((req, res) => {
|
app.use((req, res) => {
|
||||||
res.status(404).send('Seite nicht gefunden');
|
res.status(404).send('Seite nicht gefunden');
|
||||||
|
|||||||
@@ -521,6 +521,130 @@
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</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>
|
</main>
|
||||||
|
|
||||||
<%- include('partials/footer') %>
|
<%- include('partials/footer') %>
|
||||||
|
|||||||
@@ -96,6 +96,40 @@
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</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 -->
|
<!-- Statistics Section -->
|
||||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6 mb-8">
|
<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>
|
<h2 class="text-lg font-semibold text-gray-800 dark:text-white mb-6">Statistik</h2>
|
||||||
|
|||||||
Reference in New Issue
Block a user