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 <noreply@anthropic.com>
This commit is contained in:
2026-07-16 15:27:19 +02:00
co-authored by Claude
parent ce5fa1eab1
commit d9ba8698e0
9 changed files with 184 additions and 14 deletions
+1 -1
View File
@@ -26,7 +26,7 @@ function withLabels(row) {
const STATUS_OPTIONS = [ const STATUS_OPTIONS = [
'Entwurf', 'Gesendet', 'Eingangsbestätigung', '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', 'Vorstellungsgespräch', 'Vertragsverhandlung',
'Absage', 'Absage von meiner Seite', 'Einstellung', 'Keine Rückmeldung', 'Absage', 'Absage von meiner Seite', 'Einstellung', 'Keine Rückmeldung',
]; ];
+11
View File
@@ -76,6 +76,16 @@ function localInputToUtc(dateStr, timeStr) {
return wallToUtc(y, mo, d, h || 0, mi || 0, TZ); 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". // UTC Date -> iCalendar UTC stamp "YYYYMMDDTHHMMSSZ".
function toIcsUtc(date) { function toIcsUtc(date) {
return date.getUTCFullYear() + pad(date.getUTCMonth() + 1) + pad(date.getUTCDate()) return date.getUTCFullYear() + pad(date.getUTCMonth() + 1) + pad(date.getUTCDate())
@@ -335,6 +345,7 @@ module.exports = {
collectionUrl, collectionUrl,
wallToUtc, wallToUtc,
localInputToUtc, localInputToUtc,
utcToWallInputs,
buildICS, buildICS,
parseVEvent, parseVEvent,
getCtag, getCtag,
+1 -1
View File
@@ -9,7 +9,7 @@ const ART_OPTIONS = [
]; ];
const STATUS_OPTIONS = [ const STATUS_OPTIONS = [
'Entwurf', 'Gesendet', 'Eingangsbestätigung', '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', 'Vorstellungsgespräch', 'Vertragsverhandlung',
'Absage', 'Absage von meiner Seite', 'Einstellung', 'Keine Rückmeldung', 'Absage', 'Absage von meiner Seite', 'Einstellung', 'Keine Rückmeldung',
]; ];
+2 -1
View File
@@ -355,7 +355,7 @@ function generatePdfDocument(settings, applications, month, year) {
// ---- Group entries by status (order kept in sync with views/index.ejs) ---- // ---- Group entries by status (order kept in sync with views/index.ejs) ----
const statusOrder = ['Entwurf', 'Gesendet', 'Eingangsbestätigung', 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']; '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 // Accent colour per status (RGB) reused by the chart and the list headings
const statusColors = { const statusColors = {
@@ -364,6 +364,7 @@ function generatePdfDocument(settings, applications, month, year) {
'Eingangsbestätigung': [14, 165, 233], 'Eingangsbestätigung': [14, 165, 233],
'In Bearbeitung': [20, 184, 166], 'In Bearbeitung': [20, 184, 166],
'Interessiert': [236, 72, 153], 'Interessiert': [236, 72, 153],
'Telefonat': [244, 114, 182],
'Warten auf Rückmeldung': [249, 115, 22], 'Warten auf Rückmeldung': [249, 115, 22],
'Warten auf meine Antwort': [217, 70, 239], 'Warten auf meine Antwort': [217, 70, 239],
'Vorstellungsgespräch': [245, 158, 11], 'Vorstellungsgespräch': [245, 158, 11],
+84 -3
View File
@@ -62,7 +62,7 @@ const ART_OPTIONS = [
]; ];
const STATUS_OPTIONS = [ const STATUS_OPTIONS = [
'Entwurf', 'Gesendet', 'Eingangsbestätigung', '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', 'Vorstellungsgespräch', 'Vertragsverhandlung',
'Absage', 'Absage von meiner Seite', 'Einstellung', 'Keine Rückmeldung' '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. // Upcoming appointments (not yet ended), newest first, for the dashboard widget.
async function upcomingTermine(limit = 6) { async function upcomingTermine(limit = 6) {
const now = new Date().toISOString(); 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( return dbAll(
`SELECT t.*, b.firma AS bewerbung_firma, b.stelle AS bewerbung_stelle `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 FROM termine t LEFT JOIN bewerbungen b ON b.id = t.bewerbung_id
@@ -1779,7 +1789,7 @@ initializeDatabase().then(async () => {
await attachVerlauf(applications); await attachVerlauf(applications);
applications.forEach((a) => { a.labelsArr = parseLabels(a.labels); }); applications.forEach((a) => { a.labelsArr = parseLabels(a.labels); });
// Upcoming calendar appointments for the dashboard widget. // Upcoming calendar appointments for the dashboard widget.
const kommendeTermine = await upcomingTermine(6); const kommendeTermine = await upcomingTermine(null);
// Get statistics // Get statistics
const totalCount = await dbGet('SELECT COUNT(*) as count FROM bewerbungen WHERE user_id = ?', [U]); 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". // Any reply at all — everything past "Gesendet" except "Keine Rückmeldung".
const rueckStatus = [ const rueckStatus = [
'Eingangsbestätigung', 'In Bearbeitung', 'Interessiert', 'Eingangsbestätigung', 'In Bearbeitung', 'Interessiert', 'Telefonat',
'Warten auf Rückmeldung', 'Warten auf meine Antwort', 'Warten auf Rückmeldung', 'Warten auf meine Antwort',
'Vorstellungsgespräch', 'Vertragsverhandlung', 'Vorstellungsgespräch', 'Vertragsverhandlung',
'Absage', 'Absage von meiner Seite', 'Einstellung' '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]); 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). // 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]); 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. // E-Mail correspondence (sent + received), oldest first, with attachments.
const emails = await dbAll( 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 // Update a timeline entry
app.post('/bewerbung/:id/verlauf/:eintragId', async (req, res) => { app.post('/bewerbung/:id/verlauf/:eintragId', async (req, res) => {
try { try {
+1
View File
@@ -15,6 +15,7 @@
'Eingangsbestätigung': 'bg-blue-500', 'Eingangsbestätigung': 'bg-blue-500',
'In Bearbeitung': 'bg-teal-500', 'In Bearbeitung': 'bg-teal-500',
'Interessiert': 'bg-pink-500', 'Interessiert': 'bg-pink-500',
'Telefonat': 'bg-sky-500',
'Warten auf Rückmeldung': 'bg-orange-500', 'Warten auf Rückmeldung': 'bg-orange-500',
'Warten auf meine Antwort': 'bg-fuchsia-500', 'Warten auf meine Antwort': 'bg-fuchsia-500',
'Vorstellungsgespräch': 'bg-yellow-500', 'Vorstellungsgespräch': 'bg-yellow-500',
+75 -1
View File
@@ -13,6 +13,7 @@
'Eingangsbestätigung': 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200', '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', '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', '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 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', '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', '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' }) : ''; 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"> <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="min-w-0 flex-1">
<div class="flex flex-wrap items-center gap-2"> <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="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> <span class="text-sm font-medium text-gray-800 dark:text-gray-100 break-words"><%= t.titel %></span>
@@ -739,12 +740,74 @@
<% if (t.ort) { %><p class="text-xs text-gray-500 dark:text-gray-400 mt-0.5">📍 <%= t.ort %></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><% } %> <% if (t.notiz) { %><p class="text-xs text-gray-500 dark:text-gray-400 mt-0.5 whitespace-pre-line"><%= t.notiz %></p><% } %>
</div> </div>
<div class="flex shrink-0 items-center gap-2">
<button type="button" onclick="document.getElementById('terminEdit_<%= t.id %>').open = !document.getElementById('terminEdit_<%= t.id %>').open;" 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="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"></path></svg>
Bearbeiten
</button>
<form action="/bewerbung/<%= application.id %>/termine/<%= t.id %>/delete" method="POST" onsubmit="return confirm('Diesen Termin löschen (auch im Kalender)?');"> <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"> <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> <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 Löschen
</button> </button>
</form> </form>
</div>
<details id="terminEdit_<%= t.id %>" class="mt-2">
<form action="/bewerbung/<%= application.id %>/termine/<%= t.id %>/edit" method="POST" class="rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-3 space-y-3">
<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" class="terminEditTyp 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" <%= t.typ === 'vorstellungsgespraech' ? 'selected' : '' %>>Vorstellungsgespräch</option>
<option value="termin" <%= t.typ === 'termin' ? '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="<%= t.titel %>" 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="<%= t.editDatum %>" 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 terminEditZeit">
<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="<%= t.editVon %>" 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" value="<%= t.editBis %>" 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="<%= t.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">
<% [0,15,30,60,120,1440].forEach(function(m){ %>
<option value="<%= m %>" <%= Number(t.erinnerung_min) === m ? 'selected' : '' %>><%= m === 0 ? 'keine' : (m < 60 ? m + ' Min vorher' : (m < 1440 ? (m/60) + ' Stunde' + (m/60 > 1 ? 'n' : '') + ' vorher' : '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" class="terminEditGanztags rounded border-gray-300 dark:border-gray-600 text-blue-600" <%= t.ganztags ? 'checked' : '' %>>
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"><%= t.notiz || '' %></textarea>
</label>
<div class="flex items-center gap-2">
<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>
Speichern
</button>
<button type="button" onclick="document.getElementById('terminEdit_<%= t.id %>').open = false;" class="px-3 py-2 text-sm 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">Abbrechen</button>
</div>
</form>
</details>
</li> </li>
<% }); %> <% }); %>
</ul> </ul>
@@ -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; }); }; 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(); 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();
});
})(); })();
</script> </script>
</section> </section>
+3 -1
View File
@@ -145,7 +145,7 @@
// Status-Reihenfolge synchron zu public/js/main.js halten. Steht hier oben, // 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. // weil sowohl die Liste als auch die Aktivitäten weiter unten sie brauchen.
const statusOrder = ['Entwurf', 'Gesendet', 'Eingangsbestätigung', 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']; 'Vorstellungsgespräch', 'Vertragsverhandlung', 'Einstellung', 'Absage', 'Absage von meiner Seite', 'Keine Rückmeldung'];
const statusDot = { const statusDot = {
'Entwurf': 'bg-purple-500', 'Entwurf': 'bg-purple-500',
@@ -153,6 +153,7 @@
'Eingangsbestätigung': 'bg-blue-500', 'Eingangsbestätigung': 'bg-blue-500',
'In Bearbeitung': 'bg-teal-500', 'In Bearbeitung': 'bg-teal-500',
'Interessiert': 'bg-pink-500', 'Interessiert': 'bg-pink-500',
'Telefonat': 'bg-sky-500',
'Warten auf Rückmeldung': 'bg-orange-500', 'Warten auf Rückmeldung': 'bg-orange-500',
'Warten auf meine Antwort': 'bg-fuchsia-500', 'Warten auf meine Antwort': 'bg-fuchsia-500',
'Vorstellungsgespräch': 'bg-yellow-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 === '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 === '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 === '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 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 === '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' : app.status === 'Entwurf' ? 'bg-purple-100 text-purple-800 dark:bg-purple-800 dark:text-purple-100' :
+2 -2
View File
@@ -238,7 +238,7 @@
const maxS = Math.max(...byStatus.map(s => s.count)); const maxS = Math.max(...byStatus.map(s => s.count));
const statusColor = (s) => ({ const statusColor = (s) => ({
'Entwurf':'#94a3b8','Gesendet':'#3b82f6','Eingangsbestätigung':'#0ea5e9','In Bearbeitung':'#6366f1', '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', 'Vorstellungsgespräch':'#10b981','Vertragsverhandlung':'#84cc16','Absage':'#f43f5e','Absage von meiner Seite':'#f97316',
'Einstellung':'#059669','Keine Rückmeldung':'#cbd5e1' 'Einstellung':'#059669','Keine Rückmeldung':'#cbd5e1'
}[s] || '#2563eb'); }[s] || '#2563eb');
@@ -315,7 +315,7 @@
<% if (aktivitaet.length) { <% if (aktivitaet.length) {
const statusColor = (s) => ({ const statusColor = (s) => ({
'Entwurf':'#94a3b8','Gesendet':'#3b82f6','Eingangsbestätigung':'#0ea5e9','In Bearbeitung':'#6366f1', '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', 'Vorstellungsgespräch':'#10b981','Vertragsverhandlung':'#84cc16','Absage':'#f43f5e','Absage von meiner Seite':'#f97316',
'Einstellung':'#059669','Keine Rückmeldung':'#cbd5e1' 'Einstellung':'#059669','Keine Rückmeldung':'#cbd5e1'
}[s] || '#2563eb'); }[s] || '#2563eb');