Benachrichtigungsglocke fuer ungelesene E-Mails
Neue Antworten werden meist automatisch einer Bewerbung zugeordnet und tauchten daher nie im Postfach auf - man bemerkte sie nicht. Eine Glocke im Header zeigt jetzt auf jeder Seite die Zahl ungelesener empfangener E-Mails (inkl. zugeordneter Antworten) und listet sie in einem Dropdown mit Absender, Betreff, Auszug und Link zur Bewerbung bzw. zum Postfach. - GET /api/notifications: Anzahl + neueste ungelesene Nachrichten. - POST /api/emails/mark-all-read: alle als gelesen markieren. - Postfach-Ansicht markiert unverknuepfte Mails als gelesen (leert die Glocke), zugeordnete Antworten werden beim Oeffnen der Bewerbung gelesen. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -1446,6 +1446,10 @@ initializeDatabase().then(() => {
|
||||
atts.forEach((a) => { (byEmail[a.email_id] = byEmail[a.email_id] || []).push(a); });
|
||||
emails.forEach((e) => { e.anhaenge = byEmail[e.id] || []; });
|
||||
decorateEmails(emails);
|
||||
// These are read now that they are shown — clears them from the bell.
|
||||
// (The `seen` values above are captured pre-update, so the "Neu" badge
|
||||
// still renders on this view.)
|
||||
await dbRun("UPDATE emails SET seen = 1 WHERE bewerbung_id IS NULL AND direction = 'in' AND seen = 0");
|
||||
}
|
||||
// Applications the user can assign an e-mail to (newest first).
|
||||
const bewerbungen = await dbAll(
|
||||
@@ -1502,7 +1506,7 @@ initializeDatabase().then(() => {
|
||||
}
|
||||
});
|
||||
|
||||
// Count of unlinked e-mails — drives the header badge on every page.
|
||||
// Count of unlinked e-mails — drives the "Postfach" header badge on every page.
|
||||
app.get('/api/emails/unassigned-count', async (req, res) => {
|
||||
try {
|
||||
const row = await dbGet('SELECT COUNT(*) as count FROM emails WHERE bewerbung_id IS NULL');
|
||||
@@ -1512,6 +1516,54 @@ initializeDatabase().then(() => {
|
||||
}
|
||||
});
|
||||
|
||||
// Unread received e-mails — drives the notification bell on every page. Returns
|
||||
// the total unread count plus the newest few as a ready-to-render list. Replies
|
||||
// auto-assigned to an application carry a link to that application; unassigned
|
||||
// mail links to the Postfach.
|
||||
app.get('/api/notifications', async (req, res) => {
|
||||
try {
|
||||
const cntRow = await dbGet(
|
||||
"SELECT COUNT(*) AS count FROM emails WHERE direction = 'in' AND seen = 0"
|
||||
);
|
||||
const rows = await dbAll(
|
||||
`SELECT e.id, e.from_addr, e.subject, e.body_text, e.body_html, e.email_date, e.bewerbung_id,
|
||||
b.firma, b.stelle
|
||||
FROM emails e LEFT JOIN bewerbungen b ON b.id = e.bewerbung_id
|
||||
WHERE e.direction = 'in' AND e.seen = 0
|
||||
ORDER BY datetime(e.email_date) DESC, e.id DESC
|
||||
LIMIT 30`
|
||||
);
|
||||
const items = rows.map((e) => {
|
||||
const fromName = String(e.from_addr || '').replace(/<[^>]*>/, '').replace(/"/g, '').trim()
|
||||
|| String(e.from_addr || '').trim();
|
||||
const snippet = emailPlainText(e).replace(/\s+/g, ' ').trim().slice(0, 140);
|
||||
return {
|
||||
id: e.id,
|
||||
from: fromName || '(unbekannt)',
|
||||
subject: e.subject || '(kein Betreff)',
|
||||
snippet,
|
||||
date: e.email_date || null,
|
||||
bewerbung_id: e.bewerbung_id || null,
|
||||
kontext: e.bewerbung_id ? [e.firma, e.stelle].filter(Boolean).join(' · ') : '',
|
||||
url: e.bewerbung_id ? ('/bewerbung/' + e.bewerbung_id + '#korrespondenz') : '/postfach',
|
||||
};
|
||||
});
|
||||
res.json({ count: cntRow ? cntRow.count : 0, items });
|
||||
} catch (error) {
|
||||
res.status(500).json({ count: 0, items: [] });
|
||||
}
|
||||
});
|
||||
|
||||
// Mark every received e-mail as read (clears the notification bell).
|
||||
app.post('/api/emails/mark-all-read', async (req, res) => {
|
||||
try {
|
||||
await dbRun("UPDATE emails SET seen = 1 WHERE direction = 'in' AND seen = 0");
|
||||
res.json({ ok: true });
|
||||
} catch (error) {
|
||||
res.status(500).json({ ok: false });
|
||||
}
|
||||
});
|
||||
|
||||
// Add a timeline entry (status change with date + comment)
|
||||
app.post('/bewerbung/:id/verlauf', async (req, res) => {
|
||||
try {
|
||||
|
||||
@@ -9,6 +9,26 @@
|
||||
</a>
|
||||
|
||||
<div class="flex items-center space-x-4">
|
||||
<!-- Notification bell: unread received e-mails (incl. auto-assigned replies) -->
|
||||
<div class="relative" id="notifWrap">
|
||||
<button id="notifBtn" type="button"
|
||||
class="relative flex items-center p-2 rounded-full bg-white/20 hover:bg-white/30 transition-colors text-white"
|
||||
aria-label="Benachrichtigungen" title="Ungelesene E-Mails">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9"></path>
|
||||
</svg>
|
||||
<span id="notifBadge" class="hidden absolute -top-1 -right-1 min-w-[18px] h-[18px] px-1 flex items-center justify-center rounded-full bg-red-500 text-white text-[10px] font-bold ring-2 ring-blue-800 dark:ring-gray-900">0</span>
|
||||
</button>
|
||||
<div id="notifPanel" class="hidden absolute right-0 mt-2 w-80 max-w-[calc(100vw-2rem)] max-h-96 overflow-y-auto rounded-lg bg-white dark:bg-gray-800 shadow-2xl ring-1 ring-black/10 dark:ring-white/10 z-50 text-left">
|
||||
<div class="flex items-center justify-between px-4 py-2.5 border-b border-gray-100 dark:border-gray-700 sticky top-0 bg-white dark:bg-gray-800">
|
||||
<span class="text-sm font-semibold text-gray-800 dark:text-white">Benachrichtigungen</span>
|
||||
<button id="notifMarkAll" type="button" class="hidden text-xs text-blue-600 dark:text-blue-400 hover:underline">Alle gelesen</button>
|
||||
</div>
|
||||
<ul id="notifList" class="divide-y divide-gray-100 dark:divide-gray-700"></ul>
|
||||
<div id="notifEmpty" class="px-4 py-8 text-center text-sm text-gray-400 dark:text-gray-500">Keine ungelesenen Nachrichten</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Postfach (unlinked incoming e-mails) link -->
|
||||
<a href="/postfach"
|
||||
class="relative flex items-center gap-1.5 px-3 py-2 rounded-md bg-white/20 hover:bg-white/30 transition-colors text-white text-sm font-medium"
|
||||
@@ -83,6 +103,71 @@
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
// Notification bell: unread received e-mails (new replies + orphan mail).
|
||||
(function () {
|
||||
var btn = document.getElementById('notifBtn');
|
||||
var panel = document.getElementById('notifPanel');
|
||||
var badge = document.getElementById('notifBadge');
|
||||
var list = document.getElementById('notifList');
|
||||
var empty = document.getElementById('notifEmpty');
|
||||
var markAll = document.getElementById('notifMarkAll');
|
||||
if (!btn) return;
|
||||
function esc(s) { var d = document.createElement('div'); d.textContent = (s == null ? '' : String(s)); return d.innerHTML; }
|
||||
function fmtDate(d) {
|
||||
if (!d) return '';
|
||||
try {
|
||||
var dt = new Date(d), diff = (Date.now() - dt.getTime()) / 1000;
|
||||
if (diff < 60) return 'gerade eben';
|
||||
if (diff < 3600) return Math.floor(diff / 60) + ' Min.';
|
||||
if (diff < 86400) return Math.floor(diff / 3600) + ' Std.';
|
||||
return dt.toLocaleDateString('de-DE');
|
||||
} catch (e) { return ''; }
|
||||
}
|
||||
function render(data) {
|
||||
var items = (data && data.items) || [], count = (data && data.count) || 0;
|
||||
if (count > 0) { badge.textContent = count > 99 ? '99+' : count; badge.classList.remove('hidden'); }
|
||||
else { badge.classList.add('hidden'); }
|
||||
list.innerHTML = '';
|
||||
if (!items.length) { empty.classList.remove('hidden'); markAll.classList.add('hidden'); return; }
|
||||
empty.classList.add('hidden'); markAll.classList.remove('hidden');
|
||||
items.forEach(function (it) {
|
||||
var li = document.createElement('li');
|
||||
var a = document.createElement('a');
|
||||
a.href = it.url;
|
||||
a.className = 'block px-4 py-3 hover:bg-gray-50 dark:hover:bg-gray-700/60 transition-colors';
|
||||
a.innerHTML =
|
||||
'<div class="flex items-center justify-between gap-2">'
|
||||
+ '<span class="text-sm font-medium text-gray-800 dark:text-gray-100 truncate">' + esc(it.from) + '</span>'
|
||||
+ '<span class="text-[11px] text-gray-400 shrink-0">' + esc(fmtDate(it.date)) + '</span>'
|
||||
+ '</div>'
|
||||
+ '<div class="text-sm text-gray-700 dark:text-gray-200 truncate">' + esc(it.subject) + '</div>'
|
||||
+ (it.snippet ? '<div class="text-xs text-gray-500 dark:text-gray-400 truncate mt-0.5">' + esc(it.snippet) + '</div>' : '')
|
||||
+ (it.kontext ? '<div class="text-[11px] text-blue-600 dark:text-blue-400 truncate mt-0.5">' + esc(it.kontext) + '</div>' : '');
|
||||
li.appendChild(a); list.appendChild(li);
|
||||
});
|
||||
}
|
||||
function load() {
|
||||
fetch('/api/notifications', { cache: 'no-store' })
|
||||
.then(function (r) { return r.json(); }).then(render).catch(function () { /* ignore */ });
|
||||
}
|
||||
btn.addEventListener('click', function (e) {
|
||||
e.stopPropagation();
|
||||
panel.classList.toggle('hidden');
|
||||
if (!panel.classList.contains('hidden')) load();
|
||||
});
|
||||
document.addEventListener('click', function (e) {
|
||||
if (!panel.classList.contains('hidden') && !panel.contains(e.target) && !btn.contains(e.target)) {
|
||||
panel.classList.add('hidden');
|
||||
}
|
||||
});
|
||||
markAll.addEventListener('click', function (e) {
|
||||
e.preventDefault(); e.stopPropagation();
|
||||
fetch('/api/emails/mark-all-read', { method: 'POST' })
|
||||
.then(function () { render({ count: 0, items: [] }); }).catch(function () { /* ignore */ });
|
||||
});
|
||||
load();
|
||||
setInterval(load, 60000);
|
||||
})();
|
||||
// Populate the Postfach badge with the count of unlinked incoming e-mails.
|
||||
(function () {
|
||||
fetch('/api/emails/unassigned-count').then(function (r) { return r.json(); }).then(function (d) {
|
||||
|
||||
Reference in New Issue
Block a user