Delete e-mails from the Postfach; searchable assignment picker

Each Postfach e-mail gets a Delete button (POST /postfach/:id/delete),
which also removes stored attachment files. The plain assignment
dropdown is replaced by a searchable combobox that filters applications
by company, position and city, with keyboard navigation.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-04 18:55:23 +02:00
co-authored by Claude Opus 4.8
parent 26872ece08
commit 89c8c6db97
2 changed files with 130 additions and 18 deletions
+17
View File
@@ -1341,6 +1341,23 @@ initializeDatabase().then(() => {
} }
}); });
// Delete an e-mail from the Postfach (removes its stored attachment files too).
app.post('/postfach/:emailId/delete', async (req, res) => {
try {
const emailId = req.params.emailId;
const atts = await dbAll('SELECT pfad FROM email_anhaenge WHERE email_id = ?', [emailId]);
for (const a of atts) {
fs.promises.unlink(path.join(emailAnhaengeDir, a.pfad)).catch(() => {});
}
await dbRun('DELETE FROM email_anhaenge WHERE email_id = ?', [emailId]);
await dbRun('DELETE FROM emails WHERE id = ?', [emailId]);
res.redirect('/postfach?mailok=' + encodeURIComponent('E-Mail wurde gelöscht.'));
} catch (error) {
console.error('Error deleting e-mail:', error);
res.redirect('/postfach?mailerror=' + encodeURIComponent('Löschen fehlgeschlagen: ' + (error.message || 'Unbekannter Fehler')));
}
});
// Count of unlinked e-mails — drives the header badge on every page. // Count of unlinked e-mails — drives the header badge on every page.
app.get('/api/emails/unassigned-count', async (req, res) => { app.get('/api/emails/unassigned-count', async (req, res) => {
try { try {
+113 -18
View File
@@ -77,24 +77,32 @@
</div> </div>
<% } %> <% } %>
<!-- Assign to an existing application --> <!-- Assign to an existing application (searchable) + delete -->
<form action="/postfach/<%= e.id %>/zuweisen" method="POST" class="mt-3 border-t border-gray-200 dark:border-gray-600 pt-3 flex flex-wrap items-end gap-2"> <div class="mt-3 border-t border-gray-200 dark:border-gray-600 pt-3 flex flex-wrap items-end gap-2">
<div class="flex-1 min-w-52"> <form action="/postfach/<%= e.id %>/zuweisen" method="POST" class="flex-1 min-w-52 flex flex-wrap items-end gap-2" data-assign-form>
<label class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Bewerbung zuweisen</label> <div class="flex-1 min-w-52 relative" data-combo>
<select name="bewerbung_id" required <label class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Bewerbung zuweisen</label>
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-800 dark:text-white text-sm"> <input type="text" class="combo-input w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-800 dark:text-white text-sm"
<option value="" disabled selected>— Bewerbung auswählen —</option> placeholder="Firma, Stelle oder Ort suchen…" autocomplete="off"
<% bewerbungen.forEach(function(b){ %> role="combobox" aria-expanded="false" aria-autocomplete="list">
<option value="<%= b.id %>"><%= b.firma %><% if (b.stelle) { %> · <%= b.stelle %><% } %><% if (b.datum) { %> (<%= new Date(b.datum).toLocaleDateString('de-DE') %>)<% } %></option> <input type="hidden" name="bewerbung_id" class="combo-value">
<% }); %> <ul class="combo-list hidden absolute z-20 left-0 right-0 mt-1 max-h-56 overflow-auto rounded-md border border-gray-200 dark:border-gray-600 bg-white dark:bg-gray-700 shadow-lg text-sm"></ul>
</select> </div>
</div> <button type="submit"
<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">
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>
<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> Zuweisen
Zuweisen </button>
</button> </form>
</form> <form action="/postfach/<%= e.id %>/delete" method="POST"
onsubmit="return confirm('Diese E-Mail endgültig aus dem Postfach löschen?');">
<button type="submit"
class="inline-flex items-center gap-1.5 px-4 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">
<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="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"></path></svg>
Löschen
</button>
</form>
</div>
</li> </li>
<% }); %> <% }); %>
</ul> </ul>
@@ -111,6 +119,9 @@
<%- include('partials/footer') %> <%- include('partials/footer') %>
<script>
window.__bewerbungen = <%- JSON.stringify((bewerbungen || []).map(function (b) { return { id: b.id, firma: b.firma, stelle: b.stelle, ort: b.ort, datum: b.datum }; })).replace(/</g, '\\u003c') %>;
</script>
<script> <script>
(function () { (function () {
const root = document.documentElement; const root = document.documentElement;
@@ -147,6 +158,90 @@
btn.textContent = expanded ? 'Vollständig anzeigen' : 'Weniger anzeigen'; btn.textContent = expanded ? 'Vollständig anzeigen' : 'Weniger anzeigen';
}); });
}); });
// --- Searchable application picker (combobox) for assigning e-mails ---
var apps = Array.isArray(window.__bewerbungen) ? window.__bewerbungen : [];
function unesc(s) {
return String(s == null ? '' : s)
.replace(/&lt;/g, '<').replace(/&gt;/g, '>')
.replace(/&quot;/g, '"').replace(/&#39;/g, "'").replace(/&amp;/g, '&');
}
function fmtDate(d) { if (!d) return ''; try { return new Date(d).toLocaleDateString('de-DE'); } catch (e) { return ''; } }
apps.forEach(function (b) {
b._label = unesc(b.firma) + (b.stelle ? ' · ' + unesc(b.stelle) : '');
b._sub = [unesc(b.ort || ''), fmtDate(b.datum)].filter(Boolean).join(' · ');
b._search = (b._label + ' ' + unesc(b.ort || '') + ' ' + fmtDate(b.datum)).toLowerCase();
});
document.querySelectorAll('[data-combo]').forEach(function (combo) {
var input = combo.querySelector('.combo-input');
var hidden = combo.querySelector('.combo-value');
var list = combo.querySelector('.combo-list');
var items = [], active = -1;
function open() { list.classList.remove('hidden'); input.setAttribute('aria-expanded', 'true'); }
function close() { list.classList.add('hidden'); input.setAttribute('aria-expanded', 'false'); active = -1; }
function choose(b) { hidden.value = b.id; input.value = b._label; close(); }
function highlight() {
Array.prototype.forEach.call(list.children, function (li, i) {
li.classList.toggle('bg-blue-100', i === active);
li.classList.toggle('dark:bg-gray-600', i === active);
if (i === active && li.scrollIntoView) li.scrollIntoView({ block: 'nearest' });
});
}
function render(q) {
var tokens = q.toLowerCase().split(/\s+/).filter(Boolean);
items = apps.filter(function (b) {
return tokens.every(function (t) { return b._search.indexOf(t) > -1; });
}).slice(0, 50);
list.innerHTML = '';
if (!items.length) {
var empty = document.createElement('li');
empty.className = 'px-3 py-2 text-gray-400';
empty.textContent = apps.length ? 'Keine Treffer' : 'Noch keine Bewerbungen vorhanden';
list.appendChild(empty);
open(); return;
}
items.forEach(function (b) {
var li = document.createElement('li');
li.className = 'combo-item cursor-pointer px-3 py-2 hover:bg-blue-50 dark:hover:bg-gray-600';
var top = document.createElement('div');
top.className = 'font-medium text-gray-800 dark:text-gray-100';
top.textContent = b._label;
var sub = document.createElement('div');
sub.className = 'text-xs text-gray-500 dark:text-gray-400';
sub.textContent = b._sub;
li.appendChild(top); li.appendChild(sub);
li.addEventListener('mousedown', function (ev) { ev.preventDefault(); choose(b); });
list.appendChild(li);
});
active = -1; open();
}
input.addEventListener('input', function () { hidden.value = ''; render(input.value); });
input.addEventListener('focus', function () { render(input.value); });
input.addEventListener('keydown', function (ev) {
if (list.classList.contains('hidden')) return;
if (ev.key === 'ArrowDown') { ev.preventDefault(); active = Math.min(active + 1, items.length - 1); highlight(); }
else if (ev.key === 'ArrowUp') { ev.preventDefault(); active = Math.max(active - 1, 0); highlight(); }
else if (ev.key === 'Enter') { if (active > -1 && items[active]) { ev.preventDefault(); choose(items[active]); } }
else if (ev.key === 'Escape') { close(); }
});
document.addEventListener('click', function (ev) { if (!combo.contains(ev.target)) close(); });
});
// Block assignment submits until an application has actually been picked.
document.querySelectorAll('[data-assign-form]').forEach(function (f) {
f.addEventListener('submit', function (ev) {
var hidden = f.querySelector('.combo-value');
if (!hidden || !hidden.value) {
ev.preventDefault();
var inp = f.querySelector('.combo-input');
if (inp) inp.focus();
alert('Bitte zuerst eine Bewerbung aus der Liste auswählen.');
}
});
});
})(); })();
</script> </script>
</body> </body>