Compare commits

..
2 Commits
Author SHA1 Message Date
thomasandClaude 7c92f23351 Aktivitäten-Feed: empfangene und gesendete E-Mails einbeziehen
Der Aktivitäten-Feed (Startseite, /aktivitaeten, /statistik) führt Status-
änderungen und E-Mails per UNION zusammen, sortiert nach Erfassungszeit.
E-Mail-Einträge bekommen einen eigenen Farbpunkt (blau = erhalten, sky =
gesendet); ohne verknüpfte Bewerbung wird Absender bzw. Empfänger als
Kopfzeile gezeigt. Das UNION steckt in einem Subselect, weil SQLite ORDER BY
an einem Compound-SELECT keine Ergebnisspalten nach Namen aufloest. Die
Paginierung von /aktivitaeten zaehlt jetzt Statusaenderungen + E-Mails.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-14 15:03:20 +02:00
thomasandClaude fc1b68ebb4 Bewerbungen favorisieren (statt Jobangebote)
Stern zum Merken einzelner Bewerbungen auf der Startseiten-Liste, der
Bewerbungsdetailseite und einer neuen 'Gemerkte Bewerbungen'-Sektion ganz
oben — unabhaengig vom Status. Die bisherigen Jobangebot-Favoriten werden
durchgaengig entfernt (Endpoint, Migration, UI, Startseiten-Sektion), da
nur Bewerbungen gemerkt werden sollen.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-14 14:58:18 +02:00
11 changed files with 221 additions and 177 deletions
+78 -54
View File
@@ -1441,10 +1441,12 @@ async function initializeDatabase() {
// Favoriten-Merkmal (Stern): 0 = nicht gemerkt, 1 = favorisiert. Favoriten // Favoriten-Merkmal (Stern): 0 = nicht gemerkt, 1 = favorisiert. Favoriten
// erscheinen oben auf der Startseite — unabhängig vom Status (offen / // erscheinen oben auf der Startseite — unabhängig vom Status (offen /
// uebernommen / abgelehnt). Spalte wird nur bei Bedarf angelegt. // uebernommen / abgelehnt). Spalte wird nur bei Bedarf angelegt.
// Favoriten-Markierung für Bewerbungen. Erlaubt das Merken einzelner
// Bewerbungen, unabhängig von ihrem Status.
{ {
const jobSpalten = await dbAll('PRAGMA table_info(jobangebote)'); const bewSpalten = await dbAll('PRAGMA table_info(bewerbungen)');
if (!jobSpalten.some((s) => s.name === 'favorit')) { if (!bewSpalten.some((s) => s.name === 'favorit')) {
await exec('ALTER TABLE jobangebote ADD COLUMN favorit INTEGER NOT NULL DEFAULT 0'); await exec('ALTER TABLE bewerbungen ADD COLUMN favorit INTEGER NOT NULL DEFAULT 0');
} }
} }
@@ -1642,6 +1644,51 @@ initializeDatabase().then(async () => {
// files are isolated on disk the same way their DB rows are. // files are isolated on disk the same way their DB rows are.
const userDir = userStorageDir; const userDir = userStorageDir;
// Einheitlicher Aktivitäten-Feed: Statusänderungen UND E-Mails (empfangen wie
// gesendet) werden zusammengeführt und chronologisch nach Erfassungszeit
// sortiert. `typ` ('status' | 'email') unterscheidet die Herkunft für die
// Darstellung; direction/from_addr/to_addr sind nur bei E-Mails gefüllt.
// Das UNION steckt in einem Subselect, weil SQLite ORDER BY an einem Compound-
// SELECT keine Ergebnisspalten nach Namen auflöst — erst der äußere SELECT
// sieht created_at/sort_id als echte Spalten.
const AKTIVITAET_FEED_INNER = `
SELECT 'status' AS typ, sv.id AS sort_id, sv.created_at, sv.datum,
sv.status, sv.kommentar, sv.bewerbung_id,
b.firma, b.stelle,
NULL AS direction, NULL AS subject, NULL AS from_addr, NULL AS to_addr
FROM status_verlauf sv
LEFT JOIN bewerbungen b ON b.id = sv.bewerbung_id AND b.user_id = sv.user_id
WHERE sv.user_id = ?
UNION ALL
SELECT 'email' AS typ, e.id AS sort_id, e.created_at,
COALESCE(e.email_date, e.created_at) AS datum,
CASE WHEN e.direction = 'out' THEN 'E-Mail gesendet' ELSE 'E-Mail erhalten' END AS status,
e.subject AS kommentar, e.bewerbung_id,
b.firma, b.stelle,
e.direction, e.subject, e.from_addr, e.to_addr
FROM emails e
LEFT JOIN bewerbungen b ON b.id = e.bewerbung_id AND b.user_id = e.user_id
WHERE e.user_id = ?
`;
async function loadAktivitaet(U, { limit = null, offset = 0 } = {}) {
const params = [U, U];
let sql = `SELECT * FROM (${AKTIVITAET_FEED_INNER}) AS feed ORDER BY created_at DESC, typ DESC, sort_id DESC`;
if (limit != null) {
sql += ' LIMIT ? OFFSET ?';
params.push(limit, offset);
}
return dbAll(sql, params);
}
// Gesamtzahl der Aktivitäten = Statusänderungen + E-Mails (für die Paginierung
// der /aktivitaeten-Seite).
async function countAktivitaet(U) {
const sv = await dbGet('SELECT COUNT(*) AS n FROM status_verlauf WHERE user_id = ?', [U]);
const em = await dbGet('SELECT COUNT(*) AS n FROM emails WHERE user_id = ?', [U]);
return (sv ? sv.n : 0) + (em ? em.n : 0);
}
// Routes // Routes
app.get('/', async (req, res) => { app.get('/', async (req, res) => {
try { try {
@@ -1677,19 +1724,12 @@ initializeDatabase().then(async () => {
GROUP BY status ORDER BY count DESC GROUP BY status ORDER BY count DESC
`, [U]); `, [U]);
// Letzte Aktivitäten für die Startseite: die jüngsten Statusänderungen, quer // Letzte Aktivitäten für die Startseite: die jüngsten Ereignisse quer über
// über alle Bewerbungen. Sortiert nach Erfassungszeit (created_at), nicht nach // alle Bewerbungen — Statusänderungen ebenso wie empfangene und gesendete
// dem fachlichen Datum — sonst würde ein nachgetragener alter Eintrag oben // E-Mails. Sortiert nach Erfassungszeit (created_at), nicht nach dem
// fachlichen Datum — sonst würde ein nachgetragener alter Eintrag oben
// stehen, obwohl gerade etwas anderes passiert ist. // stehen, obwohl gerade etwas anderes passiert ist.
const aktivitaet = await dbAll(` const aktivitaet = await loadAktivitaet(U, { limit: 8 });
SELECT sv.datum, sv.status, sv.kommentar,
b.id AS bewerbung_id, b.firma, b.stelle
FROM status_verlauf sv
LEFT JOIN bewerbungen b ON b.id = sv.bewerbung_id AND b.user_id = sv.user_id
WHERE sv.user_id = ?
ORDER BY datetime(sv.created_at) DESC, sv.id DESC
LIMIT 8
`, [U]);
// Get available months/years for filter // Get available months/years for filter
const availableMonths = await dbAll(` const availableMonths = await dbAll(`
@@ -1754,14 +1794,13 @@ initializeDatabase().then(async () => {
}; };
} }
// Gemerkte Stellen (Favoriten) für die Sektion ganz oben auf der // Gemerkte Bewerbungen — unabhängig vom Bewerbungs-Status, ganz oben auf
// Startseite — unabhängig vom Angebot-Status (offen/uebernommen/…). // der Startseite.
const favoriten = await dbAll( const bewerbungFavoriten = await dbAll(
`SELECT j.id, j.firma, j.stelle, j.ort, j.status, j.quelle_url, `SELECT b.id, b.firma, b.stelle, b.ort, b.status, b.datum, b.favorit
j.verknuepfte_bewerbung_id, j.gehalt, j.favorit FROM bewerbungen b
FROM jobangebote j WHERE b.user_id = ? AND b.favorit = 1
WHERE j.user_id = ? AND j.favorit = 1 ORDER BY b.updated_at DESC, b.id DESC`,
ORDER BY j.updated_at DESC, j.id DESC`,
[U] [U]
); );
@@ -1782,7 +1821,7 @@ initializeDatabase().then(async () => {
statusOptions: STATUS_OPTIONS, statusOptions: STATUS_OPTIONS,
labelOptions: LABEL_OPTIONS, labelOptions: LABEL_OPTIONS,
setupWizard, setupWizard,
favoriten bewerbungFavoriten
}); });
} catch (error) { } catch (error) {
console.error('Error:', error); console.error('Error:', error);
@@ -1797,8 +1836,7 @@ initializeDatabase().then(async () => {
app.get('/aktivitaeten', async (req, res) => { app.get('/aktivitaeten', async (req, res) => {
try { try {
const U = uid(); const U = uid();
const gesamtRow = await dbGet('SELECT COUNT(*) AS anzahl FROM status_verlauf WHERE user_id = ?', [U]); const gesamt = await countAktivitaet(U);
const gesamt = gesamtRow ? gesamtRow.anzahl : 0;
const seiten = Math.max(1, Math.ceil(gesamt / AKTIVITAETEN_PRO_SEITE)); const seiten = Math.max(1, Math.ceil(gesamt / AKTIVITAETEN_PRO_SEITE));
// Eine Seite außerhalb des Bereichs (getippt oder nach dem Löschen von // Eine Seite außerhalb des Bereichs (getippt oder nach dem Löschen von
@@ -1806,15 +1844,10 @@ initializeDatabase().then(async () => {
const gewuenscht = parseInt(req.query.seite, 10); const gewuenscht = parseInt(req.query.seite, 10);
const seite = Math.min(Math.max(Number.isInteger(gewuenscht) ? gewuenscht : 1, 1), seiten); const seite = Math.min(Math.max(Number.isInteger(gewuenscht) ? gewuenscht : 1, 1), seiten);
const aktivitaet = await dbAll(` const aktivitaet = await loadAktivitaet(U, {
SELECT sv.datum, sv.status, sv.kommentar, limit: AKTIVITAETEN_PRO_SEITE,
b.id AS bewerbung_id, b.firma, b.stelle offset: (seite - 1) * AKTIVITAETEN_PRO_SEITE
FROM status_verlauf sv });
LEFT JOIN bewerbungen b ON b.id = sv.bewerbung_id AND b.user_id = sv.user_id
WHERE sv.user_id = ?
ORDER BY datetime(sv.created_at) DESC, sv.id DESC
LIMIT ? OFFSET ?
`, [U, AKTIVITAETEN_PRO_SEITE, (seite - 1) * AKTIVITAETEN_PRO_SEITE]);
res.render('aktivitaeten', { res.render('aktivitaeten', {
aktivitaet, aktivitaet,
@@ -1933,16 +1966,9 @@ initializeDatabase().then(async () => {
{ label: 'Einstellung', count: ever('Einstellung') } { label: 'Einstellung', count: ever('Einstellung') }
]; ];
// Recent activity: latest status changes with the application they belong to. // Recent activity: latest status changes and emails with the application
const aktivitaet = await dbAll(` // they belong to.
SELECT sv.datum, sv.status, sv.kommentar, b.id as bewerbung_id, const aktivitaet = await loadAktivitaet(U, { limit: 12 });
b.firma, b.stelle
FROM status_verlauf sv
LEFT JOIN bewerbungen b ON b.id = sv.bewerbung_id
WHERE sv.user_id = ?
ORDER BY datetime(sv.created_at) DESC, sv.id DESC
LIMIT 12
`, [U]);
res.render('statistik', { res.render('statistik', {
total, total,
@@ -3624,18 +3650,16 @@ initializeDatabase().then(async () => {
} }
}); });
// Favorit umschalten (Stern). JSON-Antwort, damit der Client den Stern // Bewerbung als Favorit merken/entmerken — unabhängig vom Bewerbungs-Status.
// sofort umschalten kann, ohne die Seite neu zu laden. Favoriten sind app.post('/api/bewerbungen/:id/favorit', async (req, res) => {
// unabhängig vom Angebot-Status sichtbar — auch „uebernommen“ bleibt merkbar.
app.post('/jobangebote/:id/favorit', async (req, res) => {
try { try {
const angebot = await dbGet('SELECT favorit FROM jobangebote WHERE id = ? AND user_id = ?', [req.params.id, uid()]); const bewerbung = await dbGet('SELECT favorit FROM bewerbungen WHERE id = ? AND user_id = ?', [req.params.id, uid()]);
if (!angebot) return res.status(404).json({ error: 'Jobangebot nicht gefunden' }); if (!bewerbung) return res.status(404).json({ error: 'Bewerbung nicht gefunden' });
const neu = angebot.favorit ? 0 : 1; const neu = bewerbung.favorit ? 0 : 1;
await dbRun('UPDATE jobangebote SET favorit = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ? AND user_id = ?', [neu, req.params.id, uid()]); await dbRun('UPDATE bewerbungen SET favorit = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ? AND user_id = ?', [neu, req.params.id, uid()]);
res.json({ success: true, favorit: neu }); res.json({ success: true, favorit: neu });
} catch (error) { } catch (error) {
console.error('Error toggling favorite:', error); console.error('Error toggling bewerbung favorite:', error);
res.status(500).json({ error: 'Serverfehler' }); res.status(500).json({ error: 'Serverfehler' });
} }
}); });
+21 -10
View File
@@ -69,28 +69,39 @@
<% if (aktivitaet.length) { %> <% if (aktivitaet.length) { %>
<ul class="divide-y divide-gray-100 dark:divide-gray-700/60"> <ul class="divide-y divide-gray-100 dark:divide-gray-700/60">
<% aktivitaet.forEach(function (a) { %> <% aktivitaet.forEach(function (a) {
var dotCls = a.typ === 'email'
? (a.direction === 'out' ? 'bg-sky-500' : 'bg-blue-500')
: (statusDot[a.status] || 'bg-gray-300');
var titel = '', titelSub = '';
if (a.bewerbung_id) {
titel = a.firma || '(ohne Firma)';
titelSub = a.stelle || '';
} else if (a.typ === 'email') {
titel = a.direction === 'out' ? ('An: ' + (a.to_addr || '?')) : (a.from_addr || a.subject || 'E-Mail');
} else {
titel = 'Gelöschte Bewerbung';
}
%>
<li> <li>
<% if (a.bewerbung_id) { %> <% if (a.bewerbung_id) { %>
<a href="/bewerbung/<%= a.bewerbung_id %>" class="flex items-start gap-3 px-6 py-3 hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors"> <a href="/bewerbung/<%= a.bewerbung_id %>" class="flex items-start gap-3 px-6 py-3 hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors">
<% } else { %> <% } else { %>
<div class="flex items-start gap-3 px-6 py-3"> <div class="flex items-start gap-3 px-6 py-3">
<% } %> <% } %>
<span class="mt-1.5 inline-block w-2.5 h-2.5 rounded-full shrink-0 <%= statusDot[a.status] || 'bg-gray-300' %>"></span> <span class="mt-1.5 inline-block w-2.5 h-2.5 rounded-full shrink-0 <%= dotCls %>"></span>
<span class="min-w-0 flex-1"> <span class="min-w-0 flex-1">
<span class="flex flex-wrap items-baseline gap-x-2 gap-y-0.5"> <span class="flex flex-wrap items-baseline gap-x-2 gap-y-0.5">
<% if (a.bewerbung_id) { %> <span class="text-sm font-medium <%= a.bewerbung_id ? 'text-gray-900 dark:text-white' : 'text-gray-500 dark:text-gray-400' %> break-words"><%= titel %></span>
<span class="text-sm font-medium text-gray-900 dark:text-white break-words"><%= a.firma || '(ohne Firma)' %></span> <% if (titelSub) { %>
<% if (a.stelle) { %> <span class="text-sm text-gray-500 dark:text-gray-400 break-words">· <%= titelSub %></span>
<span class="text-sm text-gray-500 dark:text-gray-400 break-words">· <%= a.stelle %></span>
<% } %>
<% } else { %>
<span class="text-sm font-medium text-gray-500 dark:text-gray-400">Gelöschte Bewerbung</span>
<% } %> <% } %>
</span> </span>
<span class="mt-0.5 flex flex-wrap items-baseline gap-x-2 text-sm"> <span class="mt-0.5 flex flex-wrap items-baseline gap-x-2 text-sm">
<span class="font-medium text-gray-700 dark:text-gray-200"><%= a.status %></span> <span class="font-medium text-gray-700 dark:text-gray-200"><%= a.status %></span>
<% if (a.datum) { %>
<span class="text-gray-400 dark:text-gray-500 whitespace-nowrap">· <%= new Date(a.datum).toLocaleDateString('de-DE') %></span> <span class="text-gray-400 dark:text-gray-500 whitespace-nowrap">· <%= new Date(a.datum).toLocaleDateString('de-DE') %></span>
<% } %>
<% if (a.kommentar && a.kommentar.trim()) { %> <% if (a.kommentar && a.kommentar.trim()) { %>
<span class="text-gray-500 dark:text-gray-400 break-words">· <%= a.kommentar %></span> <span class="text-gray-500 dark:text-gray-400 break-words">· <%= a.kommentar %></span>
<% } %> <% } %>
@@ -102,7 +113,7 @@
</ul> </ul>
<% } else { %> <% } else { %>
<div class="px-6 py-16 text-center text-sm text-gray-500 dark:text-gray-400"> <div class="px-6 py-16 text-center text-sm text-gray-500 dark:text-gray-400">
Noch keine Statusänderungen protokolliert. Noch keine Aktivitäten — Statusänderungen und E-Mails erscheinen hier.
</div> </div>
<% } %> <% } %>
+3
View File
@@ -43,6 +43,7 @@
<span class="px-3 py-1 rounded-full text-sm font-medium <%= statusColor[aktuellerStatus] || 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300' %>"> <span class="px-3 py-1 rounded-full text-sm font-medium <%= statusColor[aktuellerStatus] || 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300' %>">
<%= aktuellerStatus %> <%= aktuellerStatus %>
</span> </span>
<%- include('partials/favorit-button', { j: application, endpoint: '/api/bewerbungen/' + application.id + '/favorit' }) %>
</div> </div>
<!-- Application data --> <!-- Application data -->
@@ -1022,5 +1023,7 @@
})); }));
})(); })();
</script> </script>
<%- include('partials/favorit-script') %>
</body> </body>
</html> </html>
+34 -14
View File
@@ -8,7 +8,7 @@
<main class="flex-1 container mx-auto px-4 py-8"> <main class="flex-1 container mx-auto px-4 py-8">
<% var zeigeDashboard = !(typeof setupWizard !== 'undefined' && setupWizard && setupWizard.aktiv); %> <% var zeigeDashboard = !(typeof setupWizard !== 'undefined' && setupWizard && setupWizard.aktiv); %>
<%- include('partials/favoriten') %> <%- include('partials/bewerbung-favoriten') %>
<% if (!zeigeDashboard) { %> <% if (!zeigeDashboard) { %>
<%- include('partials/einrichtung') %> <%- include('partials/einrichtung') %>
<% } %> <% } %>
@@ -252,6 +252,7 @@
</span> </span>
</span> </span>
<span class="app-actions flex items-center gap-3"> <span class="app-actions flex items-center gap-3">
<%- include('partials/favorit-button', { j: app, endpoint: '/api/bewerbungen/' + app.id + '/favorit', compact: true }) %>
<a href="/bewerbung/<%= app.id %>" <a href="/bewerbung/<%= app.id %>"
class="text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300" class="text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300"
aria-label="Bearbeiten"> aria-label="Bearbeiten">
@@ -347,27 +348,42 @@
<% if (aktivitaet && aktivitaet.length) { %> <% if (aktivitaet && aktivitaet.length) { %>
<ul class="divide-y divide-gray-100 dark:divide-gray-700/60"> <ul class="divide-y divide-gray-100 dark:divide-gray-700/60">
<% aktivitaet.forEach(function (a) { %> <% aktivitaet.forEach(function (a) {
// E-Mail-Einträge bekommen einen eigenen Farbpunkt; bei Status-
// änderungen greift die Status-Farbpalette.
var dotCls = a.typ === 'email'
? (a.direction === 'out' ? 'bg-sky-500' : 'bg-blue-500')
: (statusDot[a.status] || 'bg-gray-300');
// Kopzeile: verknüpfte Bewerbung -> Firma·Stelle, sonst bei E-Mail
// der Absender/Empfänger, sonst Hinweis auf gelöschte Bewerbung.
var titel = '', titelSub = '';
if (a.bewerbung_id) {
titel = a.firma || '(ohne Firma)';
titelSub = a.stelle || '';
} else if (a.typ === 'email') {
titel = a.direction === 'out' ? ('An: ' + (a.to_addr || '?')) : (a.from_addr || a.subject || 'E-Mail');
titelSub = '';
} else {
titel = 'Gelöschte Bewerbung';
}
%>
<li> <li>
<!-- Eintrag zu einer noch existierenden Bewerbung ist ein Link, einer zu <!-- Eintrag zu einer noch existierenden Bewerbung ist ein Link, einer zu
einer gelöschten bleibt ein Block. Bewusst zwei ausgeschriebene Zweige: einer gelöschten oder einer nicht zugeordneten E-Mail bleibt ein Block.
den Tag-Namen samt href per <%%= %> zusammenzusetzen escaped die Bewusst zwei ausgeschriebene Zweige: den Tag-Namen samt href per <%%= %>
Anführungszeichen und erzeugt Links wie /%22/bewerbung/121%22. --> zusammenzusetzen escaped die Anführungszeichen und erzeugt Links wie
/%22/bewerbung/121%22. -->
<% if (a.bewerbung_id) { %> <% if (a.bewerbung_id) { %>
<a href="/bewerbung/<%= a.bewerbung_id %>" class="flex items-start gap-3 px-6 py-3 hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors"> <a href="/bewerbung/<%= a.bewerbung_id %>" class="flex items-start gap-3 px-6 py-3 hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors">
<% } else { %> <% } else { %>
<div class="flex items-start gap-3 px-6 py-3"> <div class="flex items-start gap-3 px-6 py-3">
<% } %> <% } %>
<span class="mt-1.5 inline-block w-2.5 h-2.5 rounded-full shrink-0 <%= statusDot[a.status] || 'bg-gray-300' %>"></span> <span class="mt-1.5 inline-block w-2.5 h-2.5 rounded-full shrink-0 <%= dotCls %>"></span>
<span class="min-w-0 flex-1"> <span class="min-w-0 flex-1">
<span class="flex flex-wrap items-baseline gap-x-2 gap-y-0.5"> <span class="flex flex-wrap items-baseline gap-x-2 gap-y-0.5">
<% if (a.bewerbung_id) { %> <span class="text-sm font-medium <%= a.bewerbung_id ? 'text-gray-900 dark:text-white' : 'text-gray-500 dark:text-gray-400' %> break-words"><%= titel %></span>
<span class="text-sm font-medium text-gray-900 dark:text-white break-words"><%= a.firma || '(ohne Firma)' %></span> <% if (titelSub) { %>
<% if (a.stelle) { %> <span class="text-sm text-gray-500 dark:text-gray-400 break-words">· <%= titelSub %></span>
<span class="text-sm text-gray-500 dark:text-gray-400 break-words">· <%= a.stelle %></span>
<% } %>
<% } else { %>
<span class="text-sm font-medium text-gray-500 dark:text-gray-400">Gelöschte Bewerbung</span>
<% } %> <% } %>
</span> </span>
<!-- Status, Datum und Kommentar in einer Zeile: ein rechtsbündiges <!-- Status, Datum und Kommentar in einer Zeile: ein rechtsbündiges
@@ -375,7 +391,9 @@
dort ohne Bezug stehen. --> dort ohne Bezug stehen. -->
<span class="mt-0.5 flex flex-wrap items-baseline gap-x-2 text-sm"> <span class="mt-0.5 flex flex-wrap items-baseline gap-x-2 text-sm">
<span class="font-medium text-gray-700 dark:text-gray-200"><%= a.status %></span> <span class="font-medium text-gray-700 dark:text-gray-200"><%= a.status %></span>
<% if (a.datum) { %>
<span class="text-gray-400 dark:text-gray-500 whitespace-nowrap">· <%= new Date(a.datum).toLocaleDateString('de-DE') %></span> <span class="text-gray-400 dark:text-gray-500 whitespace-nowrap">· <%= new Date(a.datum).toLocaleDateString('de-DE') %></span>
<% } %>
<% if (a.kommentar && a.kommentar.trim()) { %> <% if (a.kommentar && a.kommentar.trim()) { %>
<span class="text-gray-500 dark:text-gray-400 break-words">· <%= a.kommentar %></span> <span class="text-gray-500 dark:text-gray-400 break-words">· <%= a.kommentar %></span>
<% } %> <% } %>
@@ -387,7 +405,7 @@
</ul> </ul>
<% } else { %> <% } else { %>
<div class="px-6 py-10 text-center text-sm text-gray-500 dark:text-gray-400"> <div class="px-6 py-10 text-center text-sm text-gray-500 dark:text-gray-400">
Noch keine Statusänderungen protokolliert. Noch keine Aktivitäten — Statusänderungen und E-Mails erscheinen hier.
</div> </div>
<% } %> <% } %>
</section> </section>
@@ -666,6 +684,8 @@
<%- include('partials/footer') %> <%- include('partials/footer') %>
<%- include('partials/favorit-script') %>
<!-- Add/Edit Application Modal --> <!-- Add/Edit Application Modal -->
<div id="applicationModal" class="fixed inset-0 bg-black bg-opacity-50 z-50 hidden"> <div id="applicationModal" class="fixed inset-0 bg-black bg-opacity-50 z-50 hidden">
<div class="flex items-center justify-center min-h-screen p-4"> <div class="flex items-center justify-center min-h-screen p-4">
-3
View File
@@ -139,7 +139,6 @@
<!-- Actions --> <!-- Actions -->
<div class="shrink-0 flex flex-col gap-2 self-start"> <div class="shrink-0 flex flex-col gap-2 self-start">
<%- include('partials/favorit-button', { j: j }) %>
<% if (j.verknuepfte_bewerbung_id) { %> <% if (j.verknuepfte_bewerbung_id) { %>
<a href="/bewerbung/<%= j.verknuepfte_bewerbung_id %>" <a href="/bewerbung/<%= j.verknuepfte_bewerbung_id %>"
class="inline-flex items-center justify-center gap-1.5 px-3 py-1.5 text-xs bg-blue-600 hover:bg-blue-700 text-white rounded-md transition-colors"> class="inline-flex items-center justify-center gap-1.5 px-3 py-1.5 text-xs bg-blue-600 hover:bg-blue-700 text-white rounded-md transition-colors">
@@ -298,7 +297,5 @@
}); });
})(); })();
</script> </script>
<%- include('partials/favorit-script') %>
</body> </body>
</html> </html>
-3
View File
@@ -100,7 +100,6 @@
<!-- Actions --> <!-- Actions -->
<div class="shrink-0 flex flex-col gap-2 self-start"> <div class="shrink-0 flex flex-col gap-2 self-start">
<%- include('partials/favorit-button', { j: j }) %>
<% if (j.verknuepfte_bewerbung_id) { %> <% if (j.verknuepfte_bewerbung_id) { %>
<a href="/bewerbung/<%= j.verknuepfte_bewerbung_id %>" <a href="/bewerbung/<%= j.verknuepfte_bewerbung_id %>"
class="inline-flex items-center justify-center gap-1.5 px-3 py-1.5 text-xs bg-blue-600 hover:bg-blue-700 text-white rounded-md transition-colors"> class="inline-flex items-center justify-center gap-1.5 px-3 py-1.5 text-xs bg-blue-600 hover:bg-blue-700 text-white rounded-md transition-colors">
@@ -141,7 +140,5 @@
if (cy) cy.textContent = new Date().getFullYear(); if (cy) cy.textContent = new Date().getFullYear();
})(); })();
</script> </script>
<%- include('partials/favorit-script') %>
</body> </body>
</html> </html>
+47
View File
@@ -0,0 +1,47 @@
<%
// Gemerkte Bewerbungen — Sektion ganz oben auf der Startseite. Zeigt alle
// favorisierten Bewerbungen unabhängig von ihrem Status. Wird nur gerendert,
// wenn mindestens ein Favorit vorhanden ist.
%>
<% if (bewerbungFavoriten && bewerbungFavoriten.length) { %>
<%- include('favorit-script') %>
<section id="bewerbungFavoritenCard" data-fav-card class="bg-white dark:bg-gray-800 rounded-lg shadow-md mb-8 overflow-hidden">
<div class="flex flex-wrap items-center gap-2 px-6 py-4 border-b border-gray-200 dark:border-gray-700">
<svg class="h-5 w-5 text-amber-500" viewBox="0 0 24 24" fill="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M11.48 3.5l2.6 5.27 5.82.85-4.21 4.1.99 5.79-5.2-2.73-5.2 2.73.99-5.79-4.21-4.1 5.82-.85 2.6-5.27z"/></svg>
<h2 class="text-lg font-semibold text-gray-800 dark:text-white">Gemerkte Bewerbungen</h2>
<span class="ml-auto inline-flex items-center gap-1.5 rounded-full bg-amber-50 px-2.5 py-1 text-xs font-medium text-amber-700 dark:bg-amber-500/10 dark:text-amber-300">
<%= bewerbungFavoriten.length %>
</span>
</div>
<ul class="divide-y divide-gray-100 dark:divide-gray-700/60">
<% bewerbungFavoriten.forEach(function(b){ %>
<li data-fav-item class="flex flex-wrap items-start justify-between gap-3 px-6 py-4">
<div class="min-w-0 flex-1">
<div class="flex flex-wrap items-center gap-2 mb-1">
<% if (b.status) { %>
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-200"><%= b.status %></span>
<% } %>
<% if (b.datum) { %>
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-300"><%= new Date(b.datum).toLocaleDateString('de-DE') %></span>
<% } %>
</div>
<a href="/bewerbung/<%= b.id %>" class="text-base font-semibold text-gray-800 dark:text-gray-100 break-words hover:underline">
<%= b.firma %>
</a>
<p class="text-sm text-gray-700 dark:text-gray-200 break-words"><%= b.stelle %></p>
<% if (b.ort) { %>
<p class="text-sm text-gray-500 dark:text-gray-400 mt-0.5"><%= b.ort %></p>
<% } %>
</div>
<div class="shrink-0 flex flex-col gap-2 self-start items-end">
<a href="/bewerbung/<%= b.id %>"
class="inline-flex items-center justify-center gap-1.5 px-3 py-1.5 text-xs bg-blue-600 hover:bg-blue-700 text-white rounded-md transition-colors">
Öffnen
</a>
<%- include('favorit-button', { j: b, endpoint: '/api/bewerbungen/' + b.id + '/favorit' }) %>
</div>
</li>
<% }); %>
</ul>
</section>
<% } %>
+17 -8
View File
@@ -1,16 +1,25 @@
<% <%
// Stern-Button zum Merken/Entfernen eines Jobangebots. Lokale `j` muss das // Stern-Button zum Merken/Entmerken. Generisch einsetzbar für Jobangebote
// Angebot (mindestens id + favorit) sein. Zustand wird über die Klasse // (Standard-Endpunkt /jobangebote/:id/favorit) und Bewerbungen — dann `endpoint`
// .is-fav gesteuert (definiert in partials/favorit-script). Das Skript dort // mitgeben. Entweder `j` (legacy, braucht id + favorit) oder direkt `id`/`favorit`.
// bedient Klicks per Delegation — also nur einmal pro Seite einbinden. // Zustand wird über die Klasse .is-fav gesteuert (definiert in favorit-script).
var favAktiv = !!(typeof j !== 'undefined' && j && j.favorit); // Mit `compact` wird ein reines Icon-Button (für Listenzeilen). Das Skript aus
// partials/favorit-script bedient Klicks per Delegation — nur einmal pro Seite
// einbinden.
var favItem = (typeof item !== 'undefined' && item) ? item : (typeof j !== 'undefined' ? j : null);
var favId = (typeof id !== 'undefined' && id != null) ? id : (favItem && favItem.id);
var favAktiv = !!(typeof favorit !== 'undefined' ? favorit : (favItem && favItem.favorit));
var favEndpoint = (typeof endpoint !== 'undefined' && endpoint)
? endpoint
: ('/jobangebote/' + encodeURIComponent(favId) + '/favorit');
var favCompact = !!(typeof compact !== 'undefined' && compact);
%> %>
<button type="button" class="fav-star<%= favAktiv ? ' is-fav' : '' %> inline-flex items-center justify-center gap-1.5 rounded-md border px-3 py-1.5 text-xs font-medium transition-colors" <button type="button" class="fav-star<%= favAktiv ? ' is-fav' : '' %> inline-flex items-center justify-center gap-1.5 rounded-md border <%= favCompact ? 'p-1.5' : 'px-3 py-1.5' %> text-xs font-medium transition-colors"
data-favorit="<%= j.id %>" data-aktiv="<%= favAktiv ? 1 : 0 %>" data-favorit="<%= favId %>" data-aktiv="<%= favAktiv ? 1 : 0 %>" data-endpoint="<%= favEndpoint %>"
title="<%= favAktiv ? 'Favorit entfernen' : 'Als Favorit merken' %>" title="<%= favAktiv ? 'Favorit entfernen' : 'Als Favorit merken' %>"
aria-pressed="<%= favAktiv ? 'true' : 'false' %>"> aria-pressed="<%= favAktiv ? 'true' : 'false' %>">
<svg class="fav-ico h-4 w-4" viewBox="0 0 24 24" fill="<%= favAktiv ? 'currentColor' : 'none' %>" stroke="currentColor" stroke-width="2"> <svg class="fav-ico h-4 w-4" viewBox="0 0 24 24" fill="<%= favAktiv ? 'currentColor' : 'none' %>" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M11.48 3.5l2.6 5.27 5.82.85-4.21 4.1.99 5.79-5.2-2.73-5.2 2.73.99-5.79-4.21-4.1 5.82-.85 2.6-5.27z"/> <path stroke-linecap="round" stroke-linejoin="round" d="M11.48 3.5l2.6 5.27 5.82.85-4.21 4.1.99 5.79-5.2-2.73-5.2 2.73.99-5.79-4.21-4.1 5.82-.85 2.6-5.27z"/>
</svg> </svg>
<span class="fav-label"><%= favAktiv ? 'Gemerkt' : 'Merken' %></span> <% if (!favCompact) { %><span class="fav-label"><%= favAktiv ? 'Gemerkt' : 'Merken' %></span><% } else { %><span class="fav-label hidden"><%= favAktiv ? 'Gemerkt' : 'Merken' %></span><% } %>
</button> </button>
+3 -2
View File
@@ -27,11 +27,12 @@
e.preventDefault(); e.preventDefault();
var id = btn.getAttribute('data-favorit'); var id = btn.getAttribute('data-favorit');
var aktiv = btn.getAttribute('data-aktiv') === '1'; var aktiv = btn.getAttribute('data-aktiv') === '1';
var endpoint = btn.getAttribute('data-endpoint') || ('/jobangebote/' + encodeURIComponent(id) + '/favorit');
// Optimistisch umschalten, sofort sichtbar. // Optimistisch umschalten, sofort sichtbar.
setzeBtn(btn, !aktiv); setzeBtn(btn, !aktiv);
fetch('/jobangebote/' + encodeURIComponent(id) + '/favorit', { fetch(endpoint, {
method: 'POST', method: 'POST',
headers: { 'X-Requested-With': 'fetch' } headers: { 'X-Requested-With': 'fetch' }
}) })
@@ -64,7 +65,7 @@
function entferneFavoritZeile(btn) { function entferneFavoritZeile(btn) {
var item = btn.closest('[data-fav-item]'); var item = btn.closest('[data-fav-item]');
if (!item) return; // nicht auf der Startseite -> nur optisch umschalten if (!item) return; // nicht auf der Startseite -> nur optisch umschalten
var card = document.getElementById('favoritenCard'); var card = btn.closest('[data-fav-card]') || document.getElementById('favoritenCard');
item.remove(); item.remove();
if (card && !card.querySelectorAll('[data-fav-item]').length) { if (card && !card.querySelectorAll('[data-fav-item]').length) {
card.style.transition = 'opacity 0.2s ease'; card.style.transition = 'opacity 0.2s ease';
-72
View File
@@ -1,72 +0,0 @@
<%
// Gemerkte Stellen — Sektion ganz oben auf der Startseite. Zeigt alle
// favorisierten Jobangebote unabhängig von ihrem Status (offen/uebernommen/…).
// Wird nur gerendert, wenn mindestens ein Favorit vorhanden ist.
function favStatusBadge(status) {
if (status === 'offen') return 'bg-green-100 text-green-800 dark:bg-green-900/50 dark:text-green-200';
if (status === 'uebernommen') return 'bg-blue-100 text-blue-800 dark:bg-blue-900/50 dark:text-blue-200';
if (status === 'abgelehnt') return 'bg-red-100 text-red-800 dark:bg-red-900/50 dark:text-red-200';
return 'bg-gray-200 text-gray-700 dark:bg-gray-600 dark:text-gray-200';
}
function favStatusLabel(status) {
if (status === 'offen') return 'Offen';
if (status === 'uebernommen') return 'Übernommen';
if (status === 'abgelehnt') return 'Abgelehnt';
return status || '—';
}
%>
<% if (favoriten && favoriten.length) { %>
<%- include('favorit-script') %>
<section id="favoritenCard" class="bg-white dark:bg-gray-800 rounded-lg shadow-md mb-8 overflow-hidden">
<div class="flex flex-wrap items-center gap-2 px-6 py-4 border-b border-gray-200 dark:border-gray-700">
<svg class="h-5 w-5 text-amber-500" viewBox="0 0 24 24" fill="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M11.48 3.5l2.6 5.27 5.82.85-4.21 4.1.99 5.79-5.2-2.73-5.2 2.73.99-5.79-4.21-4.1 5.82-.85 2.6-5.27z"/></svg>
<h2 class="text-lg font-semibold text-gray-800 dark:text-white">Gemerkte Stellen</h2>
<span class="ml-auto inline-flex items-center gap-1.5 rounded-full bg-amber-50 px-2.5 py-1 text-xs font-medium text-amber-700 dark:bg-amber-500/10 dark:text-amber-300">
<%= favoriten.length %>
</span>
</div>
<ul class="divide-y divide-gray-100 dark:divide-gray-700/60">
<% favoriten.forEach(function(j){ %>
<li data-fav-item class="flex flex-wrap items-start justify-between gap-3 px-6 py-4">
<div class="min-w-0 flex-1">
<div class="flex flex-wrap items-center gap-2 mb-1">
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium <%= favStatusBadge(j.status) %>"><%= favStatusLabel(j.status) %></span>
<% if (j.gehalt) { %>
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-300"><%= j.gehalt %></span>
<% } %>
</div>
<% if (j.quelle_url) { %>
<a href="<%= safeUrl(j.quelle_url) %>" target="_blank" rel="noopener noreferrer"
class="text-base font-semibold text-gray-800 dark:text-gray-100 break-words hover:underline">
<%= j.firma %>
</a>
<% } else { %>
<p class="text-base font-semibold text-gray-800 dark:text-gray-100 break-words"><%= j.firma %></p>
<% } %>
<p class="text-sm text-gray-700 dark:text-gray-200 break-words"><%= j.stelle %></p>
<% if (j.ort) { %>
<p class="text-sm text-gray-500 dark:text-gray-400 mt-0.5"><%= j.ort %></p>
<% } %>
<% if (j.verknuepfte_bewerbung_id) { %>
<p class="mt-1.5">
<a href="/bewerbung/<%= j.verknuepfte_bewerbung_id %>" class="inline-flex items-center gap-1 text-xs text-blue-600 dark:text-blue-400 hover:underline">
<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="M13 9l3 3m0 0l-3 3m3-3H8m13 0a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
Zur Bewerbung
</a>
</p>
<% } %>
</div>
<div class="shrink-0 flex flex-col gap-2 self-start items-end">
<% if (j.verknuepfte_bewerbung_id) { %>
<a href="/bewerbung/<%= j.verknuepfte_bewerbung_id %>"
class="inline-flex items-center justify-center gap-1.5 px-3 py-1.5 text-xs bg-blue-600 hover:bg-blue-700 text-white rounded-md transition-colors">
Bewerbung
</a>
<% } %>
<%- include('favorit-button', { j: j }) %>
</div>
</li>
<% }); %>
</ul>
</section>
<% } %>
+16 -9
View File
@@ -309,7 +309,7 @@
<div class="stat-card"> <div class="stat-card">
<h2 id="aktivitaet" class="text-gray-900 dark:text-white">Letzte Aktivität</h2> <h2 id="aktivitaet" class="text-gray-900 dark:text-white">Letzte Aktivität</h2>
<p class="sub mb-5"> <p class="sub mb-5">
Die jüngsten Statusänderungen deiner Bewerbungen. Die jüngsten Statusänderungen und E-Mails deiner Bewerbungen.
<a href="/aktivitaeten" class="text-blue-600 dark:text-sky-400 hover:underline">Alle</a> <a href="/aktivitaeten" class="text-blue-600 dark:text-sky-400 hover:underline">Alle</a>
</p> </p>
<% if (aktivitaet.length) { <% if (aktivitaet.length) {
@@ -319,29 +319,36 @@
'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');
const emailColor = (a) => a.direction === 'out' ? '#0ea5e9' : '#3b82f6';
%> %>
<div class="tl"> <div class="tl">
<% aktivitaet.forEach(a => { %> <% aktivitaet.forEach(a => {
<div class="tl-item" style="--stat-accent:<%= statusColor(a.status) %>"> const accent = a.typ === 'email' ? emailColor(a) : statusColor(a.status);
let titel = '', titelSub = '';
if (a.bewerbung_id) { titel = a.firma || '(ohne Firma)'; titelSub = a.stelle || ''; }
else if (a.typ === 'email') { titel = a.direction === 'out' ? ('An: ' + (a.to_addr || '?')) : (a.from_addr || a.subject || 'E-Mail'); }
else { titel = 'Gelöschte Bewerbung'; }
%>
<div class="tl-item" style="--stat-accent:<%= accent %>">
<div class="flex flex-wrap items-baseline gap-x-2 gap-y-0.5"> <div class="flex flex-wrap items-baseline gap-x-2 gap-y-0.5">
<% if (a.bewerbung_id) { %> <% if (a.bewerbung_id) { %>
<a href="/bewerbung/<%= a.bewerbung_id %>" class="text-sm font-medium text-gray-900 dark:text-white hover:underline"><%= a.firma || '(ohne Firma)' %></a> <a href="/bewerbung/<%= a.bewerbung_id %>" class="text-sm font-medium text-gray-900 dark:text-white hover:underline"><%= titel %></a>
<span class="text-sm text-gray-500 dark:text-slate-400">· <%= a.stelle || '' %></span> <% if (titelSub) { %><span class="text-sm text-gray-500 dark:text-slate-400">· <%= titelSub %></span><% } %>
<% } else { %> <% } else { %>
<span class="text-sm font-medium text-gray-700 dark:text-gray-200">Gelöschte Bewerbung</span> <span class="text-sm font-medium text-gray-700 dark:text-gray-200"><%= titel %></span>
<% } %> <% } %>
</div> </div>
<div class="mt-0.5 flex flex-wrap items-center gap-x-2 text-sm"> <div class="mt-0.5 flex flex-wrap items-center gap-x-2 text-sm">
<span class="inline-flex items-center gap-1.5 font-medium" style="color:<%= statusColor(a.status) %>"> <span class="inline-flex items-center gap-1.5 font-medium" style="color:<%= accent %>">
<span class="swatch" style="margin:0"></span><%= a.status %> <span class="swatch" style="margin:0"></span><%= a.status %>
</span> </span>
<span class="text-gray-400 dark:text-slate-500">· <%= a.datum %></span> <% if (a.datum) { %><span class="text-gray-400 dark:text-slate-500">· <%= a.datum %></span><% } %>
<% if (a.kommentar) { %><span class="text-gray-500 dark:text-slate-400 truncate">· <%= a.kommentar %></span><% } %> <% if (a.kommentar) { %><span class="text-gray-500 dark:text-slate-400 truncate">· <%= a.kommentar %></span><% } %>
</div> </div>
</div> </div>
<% }); %> <% }); %>
</div> </div>
<% } else { %><div class="stat-empty">Noch keine Statusänderungen protokolliert.</div><% } %> <% } else { %><div class="stat-empty">Noch keine Aktivitäten — Statusänderungen und E-Mails erscheinen hier.</div><% } %>
</div> </div>
<% } %> <% } %>