Jobangebote: Favoriten-Funktion mit eigenem "Gemerkt"-Tab

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 <noreply@anthropic.com>
This commit is contained in:
2026-07-14 21:45:04 +02:00
co-authored by Claude Opus 4.8
parent cd30af3d69
commit 75813a38a4
5 changed files with 184 additions and 39 deletions
+8
View File
@@ -944,6 +944,14 @@ function buildOpenApiSpec(baseUrl = '') {
anzeige_datum: { type: 'string', format: 'date', nullable: true, description: 'Datum der eigentlichen Stellenanzeige (vom Drittanbieter übergeben)' }, 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' }, kontakt_email: { type: 'string', format: 'email', nullable: true, description: 'Kontakt-E-Mail-Adresse der Stelle' },
status: { type: 'string', enum: ['offen', 'uebernommen', 'abgelehnt'] }, 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 }, labels: { ...labelsSchema, nullable: true },
verknuepfte_bewerbung_id: { type: 'integer', 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)' }, url_norm: { type: 'string', nullable: true, description: 'Normalisierte URL für die De-Duplizierung (serverseitig gesetzt)' },
+97 -12
View File
@@ -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_url_norm ON jobangebote(url_norm)');
await exec('CREATE INDEX IF NOT EXISTS idx_jobangebote_firma_slug ON jobangebote(firma_slug)'); await exec('CREATE INDEX IF NOT EXISTS idx_jobangebote_firma_slug ON jobangebote(firma_slug)');
// Favoriten-Merkmal (Stern): 0 = nicht gemerkt, 1 = favorisiert. Favoriten // Favoriten-Merkmal (Stern): 0 = nicht gemerkt, 1 = favorisiert. Jobangebote
// erscheinen oben auf der Startseite — unabhängig vom Status (offen / // und Bewerbungen haben je ein eigenes, voneinander unabhängiges Merkmal —
// uebernommen / abgelehnt). Spalte wird nur bei Bedarf angelegt. // 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 // Favoriten-Markierung für Bewerbungen. Erlaubt das Merken einzelner
// Bewerbungen, unabhängig von ihrem Status. // 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 // 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) => { app.get('/jobangebote', async (req, res) => {
try { try {
const jobangebote = await dbAll( const jobangebote = await dbAll(
@@ -3549,14 +3581,15 @@ initializeDatabase().then(async () => {
FROM jobangebote j FROM jobangebote j
LEFT JOIN bewerbungen b ON b.id = j.verknuepfte_bewerbung_id AND b.user_id = j.user_id 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' 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()] [uid()]
); );
jobangebote.forEach((j) => { j.labelsArr = parseLabels(j.labels); }); 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', { res.render('jobangebote', {
jobangebote, jobangebote,
uebernommenCount: uebernommenRow ? uebernommenRow.c : 0, ...zaehler,
ansicht: 'offen',
artOptions: ART_OPTIONS, artOptions: ART_OPTIONS,
statusOptions: STATUS_OPTIONS, statusOptions: STATUS_OPTIONS,
labelOptions: LABEL_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. // Taken-over offers: which offers were turned into applications.
app.get('/jobangebote/uebernommen', async (req, res) => { app.get('/jobangebote/uebernommen', async (req, res) => {
try { try {
@@ -3576,14 +3638,14 @@ initializeDatabase().then(async () => {
FROM jobangebote j FROM jobangebote j
LEFT JOIN bewerbungen b ON b.id = j.verknuepfte_bewerbung_id AND b.user_id = j.user_id 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' 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()] [uid()]
); );
uebernommen.forEach((j) => { j.labelsArr = parseLabels(j.labels); }); 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', { res.render('jobangebote_uebernommen', {
uebernommen, uebernommen,
offenCount: offenRow ? offenRow.c : 0, ...zaehler,
hideSettings: false, hideSettings: false,
}); });
} catch (error) { } 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 // Edit an offer's fields — mainly to paste/adjust the full job description
// (any length) before turning it into an application. // (any length) before turning it into an application.
app.post('/jobangebote/:id/bearbeiten', async (req, res) => { app.post('/jobangebote/:id/bearbeiten', async (req, res) => {
@@ -3697,7 +3765,7 @@ initializeDatabase().then(async () => {
uid(), uid(),
] ]
); );
res.redirect('/jobangebote'); res.redirect(jobangeboteZiel(req));
} catch (error) { } catch (error) {
console.error('Error editing job offer:', error); console.error('Error editing job offer:', error);
res.status(500).send('Serverfehler'); res.status(500).send('Serverfehler');
@@ -3714,13 +3782,30 @@ initializeDatabase().then(async () => {
await autoBlacklistOffer(offer, 'Jobangebot gelöscht (Web-UI)'); await autoBlacklistOffer(offer, 'Jobangebot gelöscht (Web-UI)');
await dbRun('DELETE FROM jobangebote WHERE id = ? AND user_id = ?', [req.params.id, uid()]); await dbRun('DELETE FROM jobangebote WHERE id = ? AND user_id = ?', [req.params.id, uid()]);
} }
res.redirect('/jobangebote'); res.redirect(jobangeboteZiel(req));
} catch (error) { } catch (error) {
console.error('Error deleting job offer:', error); console.error('Error deleting job offer:', error);
res.status(500).send('Serverfehler'); 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. // Bewerbung als Favorit merken/entmerken — unabhängig vom Bewerbungs-Status.
app.post('/api/bewerbungen/:id/favorit', async (req, res) => { app.post('/api/bewerbungen/:id/favorit', async (req, res) => {
try { try {
+41 -17
View File
@@ -15,26 +15,35 @@
Zurück zur Übersicht Zurück zur Übersicht
</a> </a>
<!-- Tabs: open offers vs. taken-over offers --> <!-- Tabs: open offers · favorites · taken-over offers -->
<div class="flex flex-wrap items-center gap-2 mb-6"> <% var favAnsicht = (typeof ansicht !== 'undefined' && ansicht === 'favoriten'); %>
<a href="/jobangebote" class="px-3 py-1.5 rounded-md text-sm font-medium bg-blue-600 text-white shadow-sm"> <%- include('partials/jobangebote-tabs', {
Offen (<%= jobangebote.length %>) aktiv: favAnsicht ? 'favoriten' : 'offen',
</a> offenCount: offenCount,
<a href="/jobangebote/uebernommen" class="px-3 py-1.5 rounded-md text-sm font-medium bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-200 border border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"> favoritenCount: favoritenCount,
Übernommen (<%= uebernommenCount %>) uebernommenCount: uebernommenCount,
</a> }) %>
</div>
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6 mb-8"> <%- include('partials/favorit-script') %>
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6 mb-8"<%= favAnsicht ? ' data-fav-card' : '' %>>
<div class="flex flex-wrap items-start justify-between gap-3 mb-2"> <div class="flex flex-wrap items-start justify-between gap-3 mb-2">
<div> <div>
<h2 class="text-lg font-semibold text-gray-800 dark:text-white">Jobangebote</h2> <h2 class="text-lg font-semibold text-gray-800 dark:text-white"><%= favAnsicht ? 'Gemerkte Jobangebote' : 'Jobangebote' %></h2>
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1"> <p class="text-sm text-gray-500 dark:text-gray-400 mt-1">
Hier landen Stellen, die von Drittanbietersoftware über die <% if (favAnsicht) { %>
<code class="px-1 py-0.5 rounded bg-gray-100 dark:bg-gray-700">POST /api/v1/joboffers</code> Angebote, die du dir mit dem Stern gemerkt hast — unabhängig davon, ob sie
REST-Schnittstelle eingespielt werden. Ein Angebot lässt sich per Klick noch offen oder bereits übernommen sind. Ein erneuter Klick auf den Stern
als Bewerbung-Entwurf übernehmen. Hier siehst du nur <strong>offene</strong> entfernt das Angebot wieder aus dieser Liste. Die Merkliste gilt nur für
Angebote — bereits übernommene findest du im Tab „Übernommen“. Jobangebote und ist von den gemerkten Bewerbungen getrennt.
<% } else { %>
Hier landen Stellen, die von Drittanbietersoftware über die
<code class="px-1 py-0.5 rounded bg-gray-100 dark:bg-gray-700">POST /api/v1/joboffers</code>
REST-Schnittstelle eingespielt werden. Ein Angebot lässt sich per Klick
als Bewerbung-Entwurf übernehmen. Hier siehst du nur <strong>offene</strong>
Angebote — bereits übernommene findest du im Tab „Übernommen“.
Mit dem Stern gemerkte Angebote stehen oben.
<% } %>
</p> </p>
</div> </div>
<div class="shrink-0 flex items-center gap-2"> <div class="shrink-0 flex items-center gap-2">
@@ -52,7 +61,9 @@
<% if (jobangebote && jobangebote.length) { %> <% if (jobangebote && jobangebote.length) { %>
<ul class="mt-4 space-y-4"> <ul class="mt-4 space-y-4">
<% jobangebote.forEach(function(j){ %> <% jobangebote.forEach(function(j){ %>
<li class="rounded-lg border border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-700/40 px-4 py-4"> <li <%= favAnsicht ? 'data-fav-item' : '' %> class="rounded-lg border px-4 py-4 <%= j.favorit
? 'border-amber-300 dark:border-amber-400/40 bg-amber-50/60 dark:bg-amber-500/5'
: 'border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-700/40' %>">
<div class="flex flex-wrap items-start justify-between gap-3"> <div class="flex flex-wrap items-start justify-between gap-3">
<div class="min-w-0 flex-1"> <div class="min-w-0 flex-1">
<div class="flex flex-wrap items-center gap-2 mb-1"> <div class="flex flex-wrap items-center gap-2 mb-1">
@@ -139,6 +150,7 @@
<!-- 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">
@@ -157,6 +169,7 @@
<% } %> <% } %>
<form action="/jobangebote/<%= j.id %>/delete" method="POST" <form action="/jobangebote/<%= j.id %>/delete" method="POST"
onsubmit="return confirm('Jobangebot „<%= j.firma %> · <%= j.stelle %>“ löschen? Es kommt auf die Blacklist und wird nicht erneut eingespielt.');"> onsubmit="return confirm('Jobangebot „<%= j.firma %> · <%= j.stelle %>“ löschen? Es kommt auf die Blacklist und wird nicht erneut eingespielt.');">
<% if (favAnsicht) { %><input type="hidden" name="zurueck" value="favoriten"><% } %>
<button type="submit" <button type="submit"
class="inline-flex items-center justify-center gap-1.5 px-3 py-1.5 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 w-full"> class="inline-flex items-center justify-center gap-1.5 px-3 py-1.5 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 w-full">
<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> <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>
@@ -173,6 +186,7 @@
Beschreibung / Details bearbeiten Beschreibung / Details bearbeiten
</summary> </summary>
<form action="/jobangebote/<%= j.id %>/bearbeiten" method="POST" class="mt-3 space-y-3"> <form action="/jobangebote/<%= j.id %>/bearbeiten" method="POST" class="mt-3 space-y-3">
<% if (favAnsicht) { %><input type="hidden" name="zurueck" value="favoriten"><% } %>
<div class="grid gap-3 sm:grid-cols-2"> <div class="grid gap-3 sm:grid-cols-2">
<label class="block text-xs"> <label class="block text-xs">
<span class="text-gray-500 dark:text-gray-400">Firma</span> <span class="text-gray-500 dark:text-gray-400">Firma</span>
@@ -228,6 +242,16 @@
</li> </li>
<% }); %> <% }); %>
</ul> </ul>
<% } else if (favAnsicht) { %>
<div class="text-center py-12">
<svg class="w-14 h-14 mx-auto text-gray-300 dark:text-gray-600 mb-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<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>
</svg>
<p class="text-sm text-gray-500 dark:text-gray-400 mb-1">Noch keine Jobangebote gemerkt.</p>
<p class="text-xs text-gray-400 dark:text-gray-500">
Klicke bei einem Angebot auf <strong>Merken</strong> — es erscheint dann hier und steht in der offenen Liste ganz oben.
</p>
</div>
<% } else { %> <% } else { %>
<div class="text-center py-12"> <div class="text-center py-12">
<svg class="w-14 h-14 mx-auto text-gray-300 dark:text-gray-600 mb-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-14 h-14 mx-auto text-gray-300 dark:text-gray-600 mb-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
+13 -10
View File
@@ -15,15 +15,15 @@
Zurück zu den Jobangeboten Zurück zu den Jobangeboten
</a> </a>
<!-- Tabs: open offers vs. taken-over offers --> <!-- Tabs: open offers · favorites · taken-over offers -->
<div class="flex flex-wrap items-center gap-2 mb-6"> <%- include('partials/jobangebote-tabs', {
<a href="/jobangebote" class="px-3 py-1.5 rounded-md text-sm font-medium bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-200 border border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"> aktiv: 'uebernommen',
Offen (<%= offenCount %>) offenCount: offenCount,
</a> favoritenCount: favoritenCount,
<a href="/jobangebote/uebernommen" class="px-3 py-1.5 rounded-md text-sm font-medium bg-blue-600 text-white shadow-sm"> uebernommenCount: uebernommenCount,
Übernommen (<%= uebernommen.length %>) }) %>
</a>
</div> <%- include('partials/favorit-script') %>
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6 mb-8"> <div class="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6 mb-8">
<div class="flex flex-wrap items-start justify-between gap-3 mb-2"> <div class="flex flex-wrap items-start justify-between gap-3 mb-2">
@@ -43,7 +43,9 @@
<% if (uebernommen && uebernommen.length) { %> <% if (uebernommen && uebernommen.length) { %>
<ul class="mt-4 space-y-4"> <ul class="mt-4 space-y-4">
<% uebernommen.forEach(function(j){ %> <% uebernommen.forEach(function(j){ %>
<li class="rounded-lg border border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-700/40 px-4 py-4"> <li class="rounded-lg border px-4 py-4 <%= j.favorit
? 'border-amber-300 dark:border-amber-400/40 bg-amber-50/60 dark:bg-amber-500/5'
: 'border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-700/40' %>">
<div class="flex flex-wrap items-start justify-between gap-3"> <div class="flex flex-wrap items-start justify-between gap-3">
<div class="min-w-0 flex-1"> <div class="min-w-0 flex-1">
<div class="flex flex-wrap items-center gap-2 mb-1"> <div class="flex flex-wrap items-center gap-2 mb-1">
@@ -100,6 +102,7 @@
<!-- 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">
+25
View File
@@ -0,0 +1,25 @@
<%
// Tab-Leiste der Jobangebote-Seiten. `aktiv` ist einer von 'offen',
// 'favoriten', 'uebernommen'. Der Favoriten-Tab zeigt gemerkte Angebote quer
// über alle Status und wird nur eingeblendet, wenn es Favoriten gibt (oder er
// gerade offen ist) — leere Tabs braucht niemand.
var tabs = [
{ key: 'offen', href: '/jobangebote', label: 'Offen', count: offenCount },
{ key: 'favoriten', href: '/jobangebote/favoriten', label: 'Gemerkt', count: favoritenCount, stern: true },
{ key: 'uebernommen', href: '/jobangebote/uebernommen', label: 'Übernommen', count: uebernommenCount },
].filter(function (t) { return t.key !== 'favoriten' || t.count > 0 || aktiv === 'favoriten'; });
%>
<div class="flex flex-wrap items-center gap-2 mb-6">
<% tabs.forEach(function (t) { %>
<a href="<%= t.href %>"
<%= aktiv === t.key ? 'aria-current="page"' : '' %>
class="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-md text-sm font-medium transition-colors <%= aktiv === t.key
? 'bg-blue-600 text-white shadow-sm'
: 'bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-200 border border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-700' %>">
<% if (t.stern) { %>
<svg class="h-4 w-4 <%= aktiv === t.key ? 'text-white' : '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>
<% } %>
<%= t.label %> (<%= t.count %>)
</a>
<% }); %>
</div>