Files
jobbi-bewerbung/views/bewerbung.ejs
T
thomasandClaude Opus 4.8 cbdbbc07e3 Manual generation, LLM notes with company address, and static attachments
- Static extra attachments: upload files (e.g. Zeugnisse) once on the Vorlagen
  page; they are attached to every generated application and listed under
  "Anlagen" in the cover letter (via multer upload + basis_anhaenge table).
- Import no longer auto-generates: an imported job is saved as a draft
  ("nicht_gestartet"); generation is triggered manually on the application page.
- Per-application "LLM-Notizen" field: free text (company address, contact
  person, extra context) that is fed to the model at generation time. Saving the
  notes and (re)generating happens in one action.
- Cover letter recipient block is now a structured empfaenger (firma, address,
  city, contact person) the model fills from the job + notes; the salutation
  adapts to a named contact. Falls back to the imported company + city.
- Raise default OLLAMA_TIMEOUT_MS to 300s for slower cloud models.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 11:22:14 +02:00

369 lines
26 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!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', { hideSettings: true }) %>
<%
const statusColor = {
'Entwurf': 'bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200',
'Gesendet': 'bg-indigo-100 text-indigo-800 dark:bg-indigo-900 dark:text-indigo-200',
'Eingangsbestätigung': 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200',
'Vorstellungsgespräch': 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200',
'Einstellung': 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200',
'Absage': 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200',
'Keine Rückmeldung': 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300'
};
const aktuellerStatus = (application.status && application.status.trim()) ? application.status.trim() : 'Ohne Status';
const today = new Date().toISOString().split('T')[0];
%>
<main class="container mx-auto px-4 py-8 max-w-3xl">
<!-- Back link -->
<a href="/" 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 zur Übersicht
</a>
<!-- Title -->
<div class="flex flex-wrap items-center gap-3 mb-6">
<h1 class="text-2xl font-bold text-gray-800 dark:text-white">
<%= application.firma %> <span class="text-gray-400 dark:text-gray-500"></span> <%= application.stelle %>
</h1>
<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 %>
</span>
</div>
<!-- Application data -->
<section class="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6 mb-8">
<h2 class="text-lg font-semibold text-gray-800 dark:text-white mb-4">Bewerbungsdaten</h2>
<form action="/bewerbung/<%= application.id %>" method="POST" class="space-y-4">
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Datum *</label>
<input type="date" name="datum" required value="<%= application.datum %>"
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">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Art der Bewerbung</label>
<select name="art" 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">
<option value="">-- Bitte wählen --</option>
<% artOptions.forEach(option => { %>
<option value="<%= option %>" <%= application.art === option ? 'selected' : '' %>><%= option %></option>
<% }); %>
</select>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Firma *</label>
<input type="text" name="firma" required value="<%= application.firma %>"
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">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Stelle *</label>
<input type="text" name="stelle" required value="<%= application.stelle %>"
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">
</div>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Notizen</label>
<textarea name="notizen" rows="6"
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 leading-relaxed"
placeholder="Allgemeine Notizen zur Bewerbung..."><%= application.notizen || '' %></textarea>
</div>
<div>
<label class="text-sm font-medium text-amber-700 dark:text-amber-400 mb-2 flex items-center gap-1.5">
<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 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"></path>
</svg>
Interne Notizen (nicht im PDF)
</label>
<textarea name="interne_notizen" rows="4"
class="w-full px-3 py-2 border border-amber-300 dark:border-amber-700 rounded-md bg-amber-50 dark:bg-gray-700 text-gray-800 dark:text-white leading-relaxed"
placeholder="Nur für dich erscheint nicht im Export..."><%= application.interne_notizen || '' %></textarea>
</div>
<div class="flex justify-end">
<button type="submit" class="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-md transition-colors">
Bewerbungsdaten speichern
</button>
</div>
</form>
</section>
<!-- Bewerbungsunterlagen (KI-generiert) -->
<% const genStatus = application.generierung_status; %>
<% if (genStatus || (anhaenge && anhaenge.length) || (application.stellenbeschreibung && application.stellenbeschreibung.trim())) { %>
<section id="unterlagen" class="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6 mb-8"
data-status="<%= genStatus || '' %>">
<div class="flex flex-wrap items-center justify-between gap-3 mb-4">
<h2 class="text-lg font-semibold text-gray-800 dark:text-white">Bewerbungsunterlagen</h2>
</div>
<!-- Generation status banner -->
<div id="genBanner">
<% if (!genStatus || genStatus === 'nicht_gestartet') { %>
<div class="flex items-center gap-3 rounded-lg bg-gray-50 dark:bg-gray-700/40 border border-gray-200 dark:border-gray-700 px-4 py-3 mb-4">
<svg class="w-5 h-5 text-gray-500 dark:text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg>
<span class="text-sm text-gray-700 dark:text-gray-300">Noch keine Unterlagen generiert. Ergänze bei Bedarf unten Notizen (z. B. die vollständige Firmenanschrift) und starte die Generierung.</span>
</div>
<% } else if (genStatus === 'ausstehend') { %>
<div class="flex items-center gap-3 rounded-lg bg-blue-50 dark:bg-blue-900/30 border border-blue-200 dark:border-blue-800 px-4 py-3 mb-4">
<svg class="w-5 h-5 text-blue-600 dark:text-blue-400 animate-spin" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
</svg>
<span class="text-sm text-blue-800 dark:text-blue-200">Die KI erstellt gerade individuelle Bewerbungsunterlagen … Diese Seite aktualisiert sich automatisch.</span>
</div>
<% } else if (genStatus === 'fehler') { %>
<div class="rounded-lg bg-red-50 dark:bg-red-900/30 border border-red-200 dark:border-red-800 px-4 py-3 mb-4">
<div class="flex items-center gap-2 mb-1">
<svg class="w-5 h-5 text-red-600 dark:text-red-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"></path>
</svg>
<span class="text-sm font-semibold text-red-800 dark:text-red-200">Generierung fehlgeschlagen</span>
</div>
<p class="text-sm text-red-700 dark:text-red-300 ml-7"><%= application.generierung_fehler || 'Unbekannter Fehler.' %></p>
</div>
<% } else if (genStatus === 'fertig') { %>
<div class="flex items-center gap-2 rounded-lg bg-green-50 dark:bg-green-900/30 border border-green-200 dark:border-green-800 px-4 py-3 mb-4">
<svg class="w-5 h-5 text-green-600 dark:text-green-400" 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>
<span class="text-sm text-green-800 dark:text-green-200">Unterlagen wurden erstellt. Bitte vor dem Versand prüfen und ggf. anpassen.</span>
</div>
<% } %>
</div>
<!-- Attachments -->
<div id="anhaengeListe">
<% if (anhaenge && anhaenge.length) { %>
<ul class="space-y-2">
<% anhaenge.forEach(a => { %>
<li class="flex items-center justify-between gap-3 rounded-lg border border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-700/40 px-4 py-3">
<div class="flex items-center gap-3 min-w-0">
<svg class="w-6 h-6 text-red-500 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z"></path>
</svg>
<div class="min-w-0">
<p class="text-sm font-medium text-gray-800 dark:text-gray-100 truncate"><%= a.name || a.dateiname %></p>
<p class="text-xs text-gray-500 dark:text-gray-400 truncate"><%= a.dateiname %></p>
</div>
</div>
<div class="flex items-center gap-3 shrink-0">
<a href="/anhaenge/<%= a.id %>/download"
class="inline-flex items-center gap-1 text-sm text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300 hover:underline">
<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="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"></path>
</svg>
Download
</a>
<form action="/bewerbung/<%= application.id %>/anhaenge/<%= a.id %>/delete" method="POST"
onsubmit="return confirm('Diesen Anhang löschen?');">
<button type="submit" class="text-red-500 hover:text-red-700 dark:text-red-400" aria-label="Anhang löschen">
<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>
</button>
</form>
</div>
</li>
<% }); %>
</ul>
<% } else if (genStatus !== 'ausstehend') { %>
<p class="text-sm text-gray-500 dark:text-gray-400">Noch keine generierten Unterlagen vorhanden.</p>
<% } %>
</div>
<!-- LLM notes + generate/regenerate -->
<form action="/bewerbung/<%= application.id %>/generieren" method="POST"
class="mt-6 border-t border-gray-200 dark:border-gray-700 pt-5"
<% if (genStatus === 'fertig') { %>onsubmit="return confirm('Unterlagen neu generieren? Vorhandene generierte Dateien werden ersetzt.');"<% } %>>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Notizen für die KI
</label>
<p class="text-xs text-gray-500 dark:text-gray-400 mb-2">
Zusätzliche Infos für die Generierung z. B. die vollständige Firmenanschrift (Straße, PLZ, Ort),
ein Ansprechpartner oder besondere Hinweise zur Bewerbung. Die KI berücksichtigt diese Angaben.
</p>
<textarea name="llm_notizen" rows="4"
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 leading-relaxed"
placeholder="z. B.&#10;Musterfirma GmbH&#10;Musterstraße 12&#10;45128 Essen&#10;Ansprechpartner: Frau Müller"><%= application.llm_notizen || '' %></textarea>
<div class="flex justify-end mt-3">
<button type="submit" <%= genStatus === 'ausstehend' ? 'disabled' : '' %>
class="inline-flex items-center gap-1.5 px-4 py-2 text-sm bg-blue-600 hover:bg-blue-700 disabled:opacity-50 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 10V3L4 14h7v7l9-11h-7z"></path>
</svg>
<%= genStatus === 'ausstehend' ? 'Wird generiert…' : (genStatus === 'fertig' ? 'Notizen speichern & neu generieren' : 'Unterlagen generieren') %>
</button>
</div>
</form>
<!-- Job description from the import -->
<% if (application.stellenbeschreibung && application.stellenbeschreibung.trim()) { %>
<details class="mt-6 rounded-lg border border-gray-200 dark:border-gray-700">
<summary class="cursor-pointer select-none px-4 py-3 text-sm font-medium text-gray-700 dark:text-gray-300">
Importierte Stellenbeschreibung anzeigen
</summary>
<div class="px-4 pb-4">
<% if (application.quelle_url) { %>
<a href="<%= application.quelle_url %>" target="_blank" rel="noopener"
class="inline-block mb-3 text-sm text-blue-600 dark:text-blue-400 hover:underline break-all"><%= application.quelle_url %></a>
<% } %>
<p class="whitespace-pre-wrap break-words text-sm leading-relaxed text-gray-700 dark:text-gray-300"><%= application.stellenbeschreibung %></p>
</div>
</details>
<% } %>
</section>
<% } %>
<!-- Status timeline -->
<section class="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6">
<h2 class="text-lg font-semibold text-gray-800 dark:text-white mb-1">Status-Verlauf</h2>
<p class="text-sm text-gray-500 dark:text-gray-400 mb-6">
Jede Statusänderung wird mit Datum dokumentiert. Der jüngste Eintrag bestimmt den aktuellen Status.
</p>
<!-- Existing entries -->
<% if (verlauf.length > 0) { %>
<ol class="relative border-l border-gray-200 dark:border-gray-600 ml-2 space-y-6 mb-8">
<% verlauf.forEach(eintrag => { %>
<li class="ml-6">
<span class="absolute -left-1.5 mt-2 w-3 h-3 rounded-full bg-blue-500 ring-4 ring-white dark:ring-gray-800"></span>
<form action="/bewerbung/<%= application.id %>/verlauf/<%= eintrag.id %>" method="POST"
class="bg-gray-50 dark:bg-gray-700/40 rounded-lg p-4 border border-gray-200 dark:border-gray-700">
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3 mb-3">
<div>
<label class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Datum</label>
<input type="date" name="datum" required value="<%= eintrag.datum %>"
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">
</div>
<div>
<label class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Status</label>
<select name="status" required
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">
<% statusOptions.forEach(option => { %>
<option value="<%= option %>" <%= eintrag.status === option ? 'selected' : '' %>><%= option %></option>
<% }); %>
</select>
</div>
</div>
<div class="mb-3">
<label class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Kommentar</label>
<textarea name="kommentar" rows="2"
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 leading-relaxed"
placeholder="Was ist passiert?"><%= eintrag.kommentar || '' %></textarea>
</div>
<div class="flex justify-end gap-2">
<button type="submit"
class="px-3 py-1.5 text-sm bg-blue-600 hover:bg-blue-700 text-white rounded-md transition-colors">
Speichern
</button>
</div>
</form>
<form action="/bewerbung/<%= application.id %>/verlauf/<%= eintrag.id %>/delete" method="POST" class="mt-1 text-right"
onsubmit="return confirm('Diesen Verlaufseintrag löschen?');">
<button type="submit"
class="text-xs text-red-600 hover:text-red-800 dark:text-red-400 dark:hover:text-red-300 hover:underline">
Eintrag löschen
</button>
</form>
</li>
<% }); %>
</ol>
<% } else { %>
<p class="text-sm text-gray-500 dark:text-gray-400 mb-8">Noch keine Statusänderungen dokumentiert.</p>
<% } %>
<!-- Add new entry -->
<div class="border-t border-gray-200 dark:border-gray-700 pt-6">
<h3 class="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-3">Statusänderung hinzufügen</h3>
<form action="/bewerbung/<%= application.id %>/verlauf" method="POST" class="space-y-3">
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div>
<label class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Datum</label>
<input type="date" name="datum" required value="<%= today %>"
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">
</div>
<div>
<label class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Status</label>
<select name="status" required
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">
<% statusOptions.forEach(option => { %>
<option value="<%= option %>"><%= option %></option>
<% }); %>
</select>
</div>
</div>
<div>
<label class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Kommentar</label>
<textarea name="kommentar" rows="2"
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 leading-relaxed"
placeholder="Was ist passiert?"></textarea>
</div>
<div class="flex justify-end">
<button type="submit" class="px-4 py-2 bg-green-600 hover:bg-green-700 text-white rounded-md transition-colors">
Statusänderung hinzufügen
</button>
</div>
</form>
</div>
</section>
</main>
<%- include('partials/footer') %>
<script>
(function () {
// The `dark` class lives on <html> so Tailwind's `.dark .dark:*`
// selectors also style the <body> itself.
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();
// While documents are being generated, poll for completion and
// reload the page once the status changes.
const unterlagen = document.getElementById('unterlagen');
if (unterlagen && unterlagen.dataset.status === 'ausstehend') {
const appId = <%= application.id %>;
const poll = setInterval(async () => {
try {
const res = await fetch('/api/bewerbungen/' + appId + '/generierung', { cache: 'no-store' });
if (!res.ok) return;
const data = await res.json();
if (data.status && data.status !== 'ausstehend') {
clearInterval(poll);
window.location.reload();
}
} catch (e) { /* keep polling */ }
}, 4000);
}
})();
</script>
</body>
</html>