Split job offers into Open and Taken-over pages

/jobangebote now lists only open offers; taken-over offers move to a
dedicated /jobangebote/uebernommen page showing which applications were
created (with link, status and date). A tab bar switches between both.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-04 14:43:12 +02:00
co-authored by Claude Opus 4.8
parent bc112e2627
commit 6a5bdfa6f8
3 changed files with 190 additions and 1 deletions
+27
View File
@@ -1661,16 +1661,21 @@ initializeDatabase().then(() => {
} }
}); });
// Main list: only OPEN offers (still to be decided on). Taken-over offers
// live on their own page (/jobangebote/uebernommen).
app.get('/jobangebote', async (req, res) => { app.get('/jobangebote', async (req, res) => {
try { try {
const jobangebote = await dbAll( const jobangebote = await dbAll(
`SELECT j.*, b.datum AS bewerbung_datum `SELECT j.*, b.datum AS bewerbung_datum
FROM jobangebote j FROM jobangebote j
LEFT JOIN bewerbungen b ON b.id = j.verknuepfte_bewerbung_id LEFT JOIN bewerbungen b ON b.id = j.verknuepfte_bewerbung_id
WHERE j.status = 'offen'
ORDER BY j.created_at DESC, j.id DESC` ORDER BY j.created_at DESC, j.id DESC`
); );
const uebernommenRow = await dbGet("SELECT COUNT(*) AS c FROM jobangebote WHERE status = 'uebernommen'");
res.render('jobangebote', { res.render('jobangebote', {
jobangebote, jobangebote,
uebernommenCount: uebernommenRow ? uebernommenRow.c : 0,
artOptions: ART_OPTIONS, artOptions: ART_OPTIONS,
statusOptions: STATUS_OPTIONS, statusOptions: STATUS_OPTIONS,
hideSettings: false, hideSettings: false,
@@ -1681,6 +1686,28 @@ initializeDatabase().then(() => {
} }
}); });
// Taken-over offers: which offers were turned into applications.
app.get('/jobangebote/uebernommen', async (req, res) => {
try {
const uebernommen = await dbAll(
`SELECT j.*, b.datum AS bewerbung_datum, b.status AS bewerbung_status
FROM jobangebote j
LEFT JOIN bewerbungen b ON b.id = j.verknuepfte_bewerbung_id
WHERE j.status = 'uebernommen'
ORDER BY j.updated_at DESC, j.id DESC`
);
const offenRow = await dbGet("SELECT COUNT(*) AS c FROM jobangebote WHERE status = 'offen'");
res.render('jobangebote_uebernommen', {
uebernommen,
offenCount: offenRow ? offenRow.c : 0,
hideSettings: false,
});
} catch (error) {
console.error('Error listing taken-over offers:', error);
res.status(500).send('Serverfehler');
}
});
// Convert a job offer into a Bewerbung draft (mirrors the Indeed import flow: // Convert a job offer into a Bewerbung draft (mirrors the Indeed import flow:
// creates a bewerbung with status "Entwurf", records the initial timeline entry, // creates a bewerbung with status "Entwurf", records the initial timeline entry,
// and links the offer back to it). // and links the offer back to it).
+12 -1
View File
@@ -15,6 +15,16 @@
Zurück zur Übersicht Zurück zur Übersicht
</a> </a>
<!-- Tabs: open offers vs. taken-over offers -->
<div class="flex flex-wrap items-center gap-2 mb-6">
<a href="/jobangebote" class="px-3 py-1.5 rounded-md text-sm font-medium bg-blue-600 text-white shadow-sm">
Offen (<%= jobangebote.length %>)
</a>
<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">
Übernommen (<%= uebernommenCount %>)
</a>
</div>
<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">
<div> <div>
@@ -23,7 +33,8 @@
Hier landen Stellen, die von Drittanbietersoftware über die 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> <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 REST-Schnittstelle eingespielt werden. Ein Angebot lässt sich per Klick
als Bewerbung-Entwurf übernehmen. als Bewerbung-Entwurf übernehmen. Hier siehst du nur <strong>offene</strong>
Angebote — bereits übernommene findest du im Tab „Übernommen“.
</p> </p>
</div> </div>
<div class="shrink-0 flex items-center gap-2"> <div class="shrink-0 flex items-center gap-2">
+151
View File
@@ -0,0 +1,151 @@
<!DOCTYPE html>
<html lang="de">
<head>
<%- include('partials/head') %>
</head>
<body class="min-h-screen transition-colors duration-300 bg-gray-50 dark:bg-gray-900 text-gray-900 dark:text-gray-100" id="body">
<%- include('partials/header') %>
<main class="container mx-auto px-4 py-8 max-w-5xl">
<!-- Back link -->
<a href="/jobangebote" class="inline-flex items-center gap-2 text-sm text-blue-600 dark:text-blue-400 hover:underline mb-6">
<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="M10 19l-7-7m0 0l7-7m-7 7h18"></path>
</svg>
Zurück zu den Jobangeboten
</a>
<!-- Tabs: open offers vs. taken-over offers -->
<div class="flex flex-wrap items-center gap-2 mb-6">
<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">
Offen (<%= offenCount %>)
</a>
<a href="/jobangebote/uebernommen" class="px-3 py-1.5 rounded-md text-sm font-medium bg-blue-600 text-white shadow-sm">
Übernommen (<%= uebernommen.length %>)
</a>
</div>
<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>
<h2 class="text-lg font-semibold text-gray-800 dark:text-white">Übernommene Jobangebote</h2>
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1 max-w-2xl">
Diese Angebote wurden bereits als Bewerbung übernommen. Über „Zur Bewerbung“
gelangst du direkt zur jeweiligen Bewerbung.
</p>
</div>
<span class="shrink-0 inline-flex items-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-medium bg-blue-50 dark:bg-blue-900/40 text-blue-700 dark:text-blue-300 border border-blue-200 dark:border-blue-800">
<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="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"></path></svg>
<%= uebernommen.length %> übernommen
</span>
</div>
<% if (uebernommen && uebernommen.length) { %>
<ul class="mt-4 space-y-4">
<% 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">
<div class="flex flex-wrap items-start justify-between gap-3">
<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 bg-blue-100 text-blue-800 dark:bg-blue-900/50 dark:text-blue-200">Übernommen</span>
<% if (j.bewerbung_status) { %>
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-gray-200 text-gray-700 dark:bg-gray-700 dark:text-gray-200">Status: <%= j.bewerbung_status %></span>
<% } %>
<% if (j.quelle && j.quelle !== 'drittanbieter') { %>
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-gray-200 text-gray-700 dark:bg-gray-700 dark:text-gray-200">Quelle: <%= j.quelle %></span>
<% } %>
</div>
<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 || j.gehalt) { %>
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">
<% if (j.ort) { %><%= j.ort %><% } %>
<% if (j.ort && j.gehalt) { %> · <% } %>
<% if (j.gehalt) { %><%= j.gehalt %><% } %>
</p>
<% } %>
<% if (j.quelle_url) { %>
<a href="<%= j.quelle_url %>" target="_blank" rel="noopener noreferrer"
class="inline-flex items-center gap-1 text-xs text-blue-600 dark:text-blue-400 hover:underline mt-2 break-all">
<svg class="w-3.5 h-3.5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"></path></svg>
<span class="truncate"><%= j.quelle_url %></span>
</a>
<% } %>
<p class="text-xs text-gray-400 dark:text-gray-500 mt-2 flex flex-wrap gap-x-3 gap-y-1">
<% if (j.verknuepfte_bewerbung_id && j.bewerbung_datum) { %>
<span title="Datum der aus diesem Angebot erstellten Bewerbung">
Bewerbung vom: <span class="text-gray-500 dark:text-gray-400"><%= new Date(j.bewerbung_datum).toLocaleDateString('de-DE') %></span>
</span>
<% } %>
<span title="Wann das Angebot über die REST-API eingespielt wurde">
Einspielung: <span class="text-gray-500 dark:text-gray-400"><%= j.created_at ? new Date(j.created_at + 'Z').toLocaleString('de-DE') : '' %></span>
</span>
<span title="Wann das Angebot übernommen wurde">
Übernommen: <span class="text-gray-500 dark:text-gray-400"><%= j.updated_at ? new Date(j.updated_at + 'Z').toLocaleString('de-DE') : '' %></span>
</span>
</p>
</div>
<!-- Actions -->
<div class="shrink-0 flex flex-col gap-2 self-start">
<% 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">
<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="M13 9l3 3m0 0l-3 3m3-3H8m13 0a9 9 0 11-18 0 9 9 0 0118 0z"></path></svg>
Zur Bewerbung #<%= j.verknuepfte_bewerbung_id %>
</a>
<% } else { %>
<span class="inline-flex items-center justify-center gap-1.5 px-3 py-1.5 text-xs rounded-md bg-amber-50 dark:bg-amber-900/40 text-amber-700 dark:text-amber-300 border border-amber-200 dark:border-amber-800" title="Die verknüpfte Bewerbung wurde gelöscht">
<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="M12 9v2m0 4h.01M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z"></path></svg>
Bewerbung gelöscht
</span>
<% } %>
</div>
</div>
</li>
<% }); %>
</ul>
<% } else { %>
<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">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg>
<p class="text-sm text-gray-500 dark:text-gray-400 mb-1">Noch keine Angebote übernommen.</p>
<p class="text-xs text-gray-400 dark:text-gray-500">
Übernimm ein offenes Angebot im Tab
<a href="/jobangebote" class="text-blue-600 dark:text-blue-400 hover:underline">„Offen“</a>.
</p>
</div>
<% } %>
</div>
</main>
<%- include('partials/footer') %>
<script>
(function () {
const root = document.documentElement;
const dm = localStorage.getItem('darkMode');
if (dm === 'enabled' || (!dm && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
root.classList.add('dark');
}
const toggle = document.getElementById('darkModeToggle');
const sun = document.getElementById('sunIcon');
const moon = document.getElementById('moonIcon');
function sync() {
const d = root.classList.contains('dark');
if (sun) sun.classList.toggle('hidden', d);
if (moon) moon.classList.toggle('hidden', !d);
}
sync();
if (toggle) toggle.addEventListener('click', () => {
root.classList.toggle('dark');
localStorage.setItem('darkMode', root.classList.contains('dark') ? 'enabled' : 'disabled');
sync();
});
const cy = document.getElementById('currentYear');
if (cy) cy.textContent = new Date().getFullYear();
})();
</script>
</body>
</html>