From 75813a38a4eb492d04e60f8820d40bcad83809f8 Mon Sep 17 00:00:00 2001 From: Thomas Hackner Date: Tue, 14 Jul 2026 21:45:04 +0200 Subject: [PATCH] Jobangebote: Favoriten-Funktion mit eigenem "Gemerkt"-Tab MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Jobangebote lassen sich per Stern merken — eigene Spalte `favorit`, getrennt von den Bewerbungs-Favoriten. Gemerkte Angebote stehen oben in der offenen Liste und haben einen eigenen Tab, der Favoriten quer über alle Status zeigt und ausgeblendet bleibt, solange es keine gibt. Der Stern wird nur in der Weboberfläche gesetzt; ein erneutes Einspielen desselben Angebots über die REST-API überschreibt ihn nicht. Co-Authored-By: Claude Opus 4.8 --- lib/openapi.js | 8 ++ server.js | 109 +++++++++++++++++++++++++--- views/jobangebote.ejs | 58 ++++++++++----- views/jobangebote_uebernommen.ejs | 23 +++--- views/partials/jobangebote-tabs.ejs | 25 +++++++ 5 files changed, 184 insertions(+), 39 deletions(-) create mode 100644 views/partials/jobangebote-tabs.ejs diff --git a/lib/openapi.js b/lib/openapi.js index 2aa0e97..444c5f2 100644 --- a/lib/openapi.js +++ b/lib/openapi.js @@ -944,6 +944,14 @@ function buildOpenApiSpec(baseUrl = '') { anzeige_datum: { type: 'string', format: 'date', nullable: true, description: 'Datum der eigentlichen Stellenanzeige (vom Drittanbieter übergeben)' }, kontakt_email: { type: 'string', format: 'email', nullable: true, description: 'Kontakt-E-Mail-Adresse der Stelle' }, status: { type: 'string', enum: ['offen', 'uebernommen', 'abgelehnt'] }, + favorit: { + type: 'integer', + enum: [0, 1], + readOnly: true, + description: 'Vom Nutzer in der Weboberfläche gemerktes Angebot (Stern). Wird nur dort gesetzt ' + + 'und beim erneuten Einspielen desselben Angebots nicht überschrieben. Unabhängig vom ' + + 'Favoriten-Merkmal einer Bewerbung.', + }, labels: { ...labelsSchema, nullable: true }, verknuepfte_bewerbung_id: { type: 'integer', nullable: true }, url_norm: { type: 'string', nullable: true, description: 'Normalisierte URL für die De-Duplizierung (serverseitig gesetzt)' }, diff --git a/server.js b/server.js index fa8998b..39fb51a 100644 --- a/server.js +++ b/server.js @@ -1486,9 +1486,22 @@ async function initializeDatabase() { await exec('CREATE INDEX IF NOT EXISTS idx_jobangebote_url_norm ON jobangebote(url_norm)'); await exec('CREATE INDEX IF NOT EXISTS idx_jobangebote_firma_slug ON jobangebote(firma_slug)'); - // Favoriten-Merkmal (Stern): 0 = nicht gemerkt, 1 = favorisiert. Favoriten - // erscheinen oben auf der Startseite — unabhängig vom Status (offen / - // uebernommen / abgelehnt). Spalte wird nur bei Bedarf angelegt. + // Favoriten-Merkmal (Stern): 0 = nicht gemerkt, 1 = favorisiert. Jobangebote + // und Bewerbungen haben je ein eigenes, voneinander unabhängiges Merkmal — + // ein gemerktes Angebot wird also nicht zur gemerkten Bewerbung (und umgekehrt). + // Beide Spalten werden nur bei Bedarf nachgerüstet. + + // Jobangebote: Favoriten erscheinen oben in der Liste und im eigenen Tab + // „Favoriten“ auf /jobangebote — unabhängig vom Status (offen / uebernommen / + // abgelehnt). Die Favoriten bleiben auf die Jobangebote-Seite beschränkt. + { + const jobSpalten = await dbAll('PRAGMA table_info(jobangebote)'); + if (!jobSpalten.some((s) => s.name === 'favorit')) { + await exec('ALTER TABLE jobangebote ADD COLUMN favorit INTEGER NOT NULL DEFAULT 0'); + } + } + await exec('CREATE INDEX IF NOT EXISTS idx_jobangebote_favorit ON jobangebote(user_id, favorit)'); + // Favoriten-Markierung für Bewerbungen. Erlaubt das Merken einzelner // Bewerbungen, unabhängig von ihrem Status. { @@ -3540,8 +3553,27 @@ initializeDatabase().then(async () => { } }); + // Zähler für die Tab-Leiste der Jobangebote-Seiten (offen / gemerkt / + // übernommen). Die Favoriten zählen über alle Status hinweg. + async function jobangeboteZaehler() { + const row = await dbGet( + `SELECT + COUNT(*) FILTER (WHERE status = 'offen') AS offen, + COUNT(*) FILTER (WHERE status = 'uebernommen') AS uebernommen, + COUNT(*) FILTER (WHERE favorit = 1) AS favoriten + FROM jobangebote WHERE user_id = ?`, + [uid()] + ); + return { + offenCount: row ? row.offen : 0, + uebernommenCount: row ? row.uebernommen : 0, + favoritenCount: row ? row.favoriten : 0, + }; + } + // Main list: only OPEN offers (still to be decided on). Taken-over offers - // live on their own page (/jobangebote/uebernommen). + // live on their own page (/jobangebote/uebernommen). Gemerkte Angebote stehen + // hier oben und haben zusätzlich einen eigenen Tab (/jobangebote/favoriten). app.get('/jobangebote', async (req, res) => { try { const jobangebote = await dbAll( @@ -3549,14 +3581,15 @@ initializeDatabase().then(async () => { FROM jobangebote j LEFT JOIN bewerbungen b ON b.id = j.verknuepfte_bewerbung_id AND b.user_id = j.user_id WHERE j.user_id = ? AND j.status = 'offen' - ORDER BY j.created_at DESC, j.id DESC`, + ORDER BY j.favorit DESC, j.created_at DESC, j.id DESC`, [uid()] ); jobangebote.forEach((j) => { j.labelsArr = parseLabels(j.labels); }); - const uebernommenRow = await dbGet("SELECT COUNT(*) AS c FROM jobangebote WHERE user_id = ? AND status = 'uebernommen'", [uid()]); + const zaehler = await jobangeboteZaehler(); res.render('jobangebote', { jobangebote, - uebernommenCount: uebernommenRow ? uebernommenRow.c : 0, + ...zaehler, + ansicht: 'offen', artOptions: ART_OPTIONS, statusOptions: STATUS_OPTIONS, labelOptions: LABEL_OPTIONS, @@ -3568,6 +3601,35 @@ initializeDatabase().then(async () => { } }); + // Gemerkte Angebote — quer über alle Status (offen, übernommen, abgelehnt). + // Nutzt dieselbe Ansicht wie die offene Liste, nur mit anderer Auswahl. + app.get('/jobangebote/favoriten', async (req, res) => { + try { + const jobangebote = await dbAll( + `SELECT j.*, b.datum AS bewerbung_datum + FROM jobangebote j + LEFT JOIN bewerbungen b ON b.id = j.verknuepfte_bewerbung_id AND b.user_id = j.user_id + WHERE j.user_id = ? AND j.favorit = 1 + ORDER BY j.updated_at DESC, j.id DESC`, + [uid()] + ); + jobangebote.forEach((j) => { j.labelsArr = parseLabels(j.labels); }); + const zaehler = await jobangeboteZaehler(); + res.render('jobangebote', { + jobangebote, + ...zaehler, + ansicht: 'favoriten', + artOptions: ART_OPTIONS, + statusOptions: STATUS_OPTIONS, + labelOptions: LABEL_OPTIONS, + hideSettings: false, + }); + } catch (error) { + console.error('Error listing favorite job offers:', error); + res.status(500).send('Serverfehler'); + } + }); + // Taken-over offers: which offers were turned into applications. app.get('/jobangebote/uebernommen', async (req, res) => { try { @@ -3576,14 +3638,14 @@ initializeDatabase().then(async () => { FROM jobangebote j LEFT JOIN bewerbungen b ON b.id = j.verknuepfte_bewerbung_id AND b.user_id = j.user_id WHERE j.user_id = ? AND j.status = 'uebernommen' - ORDER BY j.updated_at DESC, j.id DESC`, + ORDER BY j.favorit DESC, j.updated_at DESC, j.id DESC`, [uid()] ); uebernommen.forEach((j) => { j.labelsArr = parseLabels(j.labels); }); - const offenRow = await dbGet("SELECT COUNT(*) AS c FROM jobangebote WHERE user_id = ? AND status = 'offen'", [uid()]); + const zaehler = await jobangeboteZaehler(); res.render('jobangebote_uebernommen', { uebernommen, - offenCount: offenRow ? offenRow.c : 0, + ...zaehler, hideSettings: false, }); } catch (error) { @@ -3666,6 +3728,12 @@ initializeDatabase().then(async () => { } }); + // Nach Bearbeiten/Löschen zurück auf den Tab, von dem die Aktion kam + // (Formularfeld `zurueck`) — sonst auf die offene Liste. + function jobangeboteZiel(req) { + return (req.body && req.body.zurueck === 'favoriten') ? '/jobangebote/favoriten' : '/jobangebote'; + } + // Edit an offer's fields — mainly to paste/adjust the full job description // (any length) before turning it into an application. app.post('/jobangebote/:id/bearbeiten', async (req, res) => { @@ -3697,7 +3765,7 @@ initializeDatabase().then(async () => { uid(), ] ); - res.redirect('/jobangebote'); + res.redirect(jobangeboteZiel(req)); } catch (error) { console.error('Error editing job offer:', error); res.status(500).send('Serverfehler'); @@ -3714,13 +3782,30 @@ initializeDatabase().then(async () => { await autoBlacklistOffer(offer, 'Jobangebot gelöscht (Web-UI)'); await dbRun('DELETE FROM jobangebote WHERE id = ? AND user_id = ?', [req.params.id, uid()]); } - res.redirect('/jobangebote'); + res.redirect(jobangeboteZiel(req)); } catch (error) { console.error('Error deleting job offer:', error); res.status(500).send('Serverfehler'); } }); + // Jobangebot merken/entmerken (Stern). Toggle, antwortet mit dem neuen Zustand + // — der Stern-Button in partials/favorit-button spricht diesen Endpunkt an. + // Getrennt von den Bewerbungs-Favoriten: das Merken eines Angebots berührt + // eine daraus entstandene Bewerbung nicht. + app.post('/jobangebote/:id/favorit', async (req, res) => { + try { + const angebot = await dbGet('SELECT favorit FROM jobangebote WHERE id = ? AND user_id = ?', [req.params.id, uid()]); + if (!angebot) return res.status(404).json({ error: 'Jobangebot nicht gefunden' }); + const neu = angebot.favorit ? 0 : 1; + await dbRun('UPDATE jobangebote SET favorit = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ? AND user_id = ?', [neu, req.params.id, uid()]); + res.json({ success: true, favorit: neu }); + } catch (error) { + console.error('Error toggling job offer favorite:', error); + res.status(500).json({ error: 'Serverfehler' }); + } + }); + // Bewerbung als Favorit merken/entmerken — unabhängig vom Bewerbungs-Status. app.post('/api/bewerbungen/:id/favorit', async (req, res) => { try { diff --git a/views/jobangebote.ejs b/views/jobangebote.ejs index d4774f1..9668768 100644 --- a/views/jobangebote.ejs +++ b/views/jobangebote.ejs @@ -15,26 +15,35 @@ Zurück zur Übersicht - - + + <% var favAnsicht = (typeof ansicht !== 'undefined' && ansicht === 'favoriten'); %> + <%- include('partials/jobangebote-tabs', { + aktiv: favAnsicht ? 'favoriten' : 'offen', + offenCount: offenCount, + favoritenCount: favoritenCount, + uebernommenCount: uebernommenCount, + }) %> -
+ <%- include('partials/favorit-script') %> + +
>
-

Jobangebote

+

<%= favAnsicht ? 'Gemerkte Jobangebote' : 'Jobangebote' %>

- Hier landen Stellen, die von Drittanbietersoftware über die - POST /api/v1/joboffers - REST-Schnittstelle eingespielt werden. Ein Angebot lässt sich per Klick - als Bewerbung-Entwurf übernehmen. Hier siehst du nur offene - Angebote — bereits übernommene findest du im Tab „Übernommen“. + <% if (favAnsicht) { %> + Angebote, die du dir mit dem Stern gemerkt hast — unabhängig davon, ob sie + noch offen oder bereits übernommen sind. Ein erneuter Klick auf den Stern + entfernt das Angebot wieder aus dieser Liste. Die Merkliste gilt nur für + Jobangebote und ist von den gemerkten Bewerbungen getrennt. + <% } else { %> + Hier landen Stellen, die von Drittanbietersoftware über die + POST /api/v1/joboffers + REST-Schnittstelle eingespielt werden. Ein Angebot lässt sich per Klick + als Bewerbung-Entwurf übernehmen. Hier siehst du nur offene + Angebote — bereits übernommene findest du im Tab „Übernommen“. + Mit dem Stern gemerkte Angebote stehen oben. + <% } %>

@@ -52,7 +61,9 @@ <% if (jobangebote && jobangebote.length) { %> + <% } else if (favAnsicht) { %> +
+ + + +

Noch keine Jobangebote gemerkt.

+

+ Klicke bei einem Angebot auf Merken — es erscheint dann hier und steht in der offenen Liste ganz oben. +

+
<% } else { %>
diff --git a/views/jobangebote_uebernommen.ejs b/views/jobangebote_uebernommen.ejs index 9aa825e..96171d6 100644 --- a/views/jobangebote_uebernommen.ejs +++ b/views/jobangebote_uebernommen.ejs @@ -15,15 +15,15 @@ Zurück zu den Jobangeboten - - + + <%- include('partials/jobangebote-tabs', { + aktiv: 'uebernommen', + offenCount: offenCount, + favoritenCount: favoritenCount, + uebernommenCount: uebernommenCount, + }) %> + + <%- include('partials/favorit-script') %>
@@ -43,7 +43,9 @@ <% if (uebernommen && uebernommen.length) { %>