Files
jobbi-bewerbung/views/index.ejs
T
thomasandClaude 1ba615fa80 UI fixes: modal scroll, Bewerbungen-Aktionen, E-Mail-Uhrzeit Berlin
- Modal „Bewerbung hinzufügen" lässt sich auf allen Bildschirmgrößen scrollen
  (nicht nur ≤640px), sonst war Speichern abgeschnitten
- Aktionen-Spalte der Bewerbungen von 4.5rem auf 5.5rem (passt zur
  Cluster-Breite, Icons wandern nicht mehr nach rechts)
- Merken-Stern im Compact-Modus ohne Rahmen
- E-Mail-Uhrzeiten in Postfach, Bewerbung und Reply-Quote in Europe/Berlin

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-20 08:02:37 +02:00

950 lines
62 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 flex flex-col 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="flex-1 container mx-auto px-4 py-8">
<% var zeigeDashboard = !(typeof setupWizard !== 'undefined' && setupWizard && setupWizard.aktiv); %>
<% if (!zeigeDashboard) { %>
<%- include('partials/einrichtung') %>
<% } %>
<!-- Dashboard: Markup bleibt immer im DOM, damit main.js seine Listener
binden kann (auch der "Bewerbung manuell anlegen"-Pfad des Wizards).
Bei aktivem Wizard wird der Block nur ausgeblendet. -->
<div id="dashboardWrap" class="<%= zeigeDashboard ? '' : 'hidden' %>">
<!-- Toolbar: search + actions -->
<div class="flex flex-col gap-3 mb-8 sm:flex-row sm:items-center">
<div class="relative flex-1">
<svg class="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400 dark:text-gray-500 pointer-events-none" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"></path>
</svg>
<input id="firmaSearch" type="search" role="searchbox" autocomplete="off" spellcheck="false"
placeholder="Suchen: Firma, Stelle, Notizen, E-Mails …"
aria-label="Bewerbungen und E-Mails durchsuchen"
aria-controls="searchResults" aria-expanded="false"
class="w-full pl-12 pr-20 py-3 rounded-full border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-800 dark:text-white shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition">
<kbd id="firmaSearchHint"
class="absolute right-3 top-1/2 -translate-y-1/2 hidden sm:inline-block px-1.5 py-0.5 rounded border border-gray-300 dark:border-gray-600 text-[11px] font-sans text-gray-400 dark:text-gray-500 pointer-events-none">/</kbd>
<span id="firmaSearchSpinner"
class="hidden absolute right-10 top-1/2 -translate-y-1/2 w-4 h-4 rounded-full border-2 border-gray-300 dark:border-gray-600 border-t-blue-500 animate-spin"></span>
<button id="firmaSearchClear" type="button" aria-label="Suche zurücksetzen"
class="hidden absolute right-3 top-1/2 -translate-y-1/2 p-1 rounded-full text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700 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="M6 18L18 6M6 6l12 12"></path></svg>
</button>
</div>
<div class="flex gap-2">
<button
type="button"
id="exportPdfBtn"
class="inline-flex items-center gap-2 rounded-full px-4 py-3 text-sm font-medium text-white shadow-sm transition-colors bg-emerald-600 hover:bg-emerald-700">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 16a4 4 0 01-.88-7.9A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13v6m-3-3h6"></path>
</svg>
<span>PDF Export</span>
</button>
<button
type="button"
id="addApplicationBtn"
class="inline-flex items-center gap-2 rounded-full px-4 py-3 text-sm font-medium text-white shadow-sm transition-colors bg-blue-600 hover:bg-blue-700">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6"></path>
</svg>
<span>Bewerbung hinzufügen</span>
</button>
</div>
</div>
<!-- Suchergebnisse (Volltext über Bewerbungen + E-Mails, /api/suche) -->
<div id="searchPanel" class="hidden bg-white dark:bg-gray-800 rounded-lg shadow-md mb-8">
<div class="flex flex-wrap items-baseline justify-between gap-x-4 gap-y-1 px-6 py-3 border-b border-gray-200 dark:border-gray-700">
<p id="searchSummary" class="text-sm text-gray-600 dark:text-gray-300"></p>
<p class="text-xs text-gray-400 dark:text-gray-500">
Durchsucht Firma, Stelle, Notizen, Stellenbeschreibung und die E-Mail-Korrespondenz.
<span class="hidden sm:inline">Verfeinern: <code>firma:bosch</code>, <code>von:müller</code>, <code>"genauer wortlaut"</code>, <code>-zeitarbeit</code></span>
</p>
</div>
<ul id="searchResults" role="listbox" aria-label="Suchergebnisse" class="divide-y divide-gray-100 dark:divide-gray-700/60"></ul>
<div id="searchEmpty" class="hidden px-6 py-12 text-center text-gray-500 dark:text-gray-400">
<p>Nichts gefunden für „<span id="searchEmptyTerm"></span>".</p>
<p class="mt-2 text-sm">Gesucht wird auch in E-Mails prüfe die Schreibweise oder suche nach einem Teilwort.</p>
</div>
<div id="searchError" class="hidden px-6 py-8 text-center text-sm text-red-600 dark:text-red-400"></div>
</div>
<!-- Upcoming appointments (from the application calendar) -->
<% if (typeof kommendeTermine !== 'undefined' && kommendeTermine && kommendeTermine.length) { %>
<div id="termineCard" class="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6 mb-8">
<div class="flex items-center gap-2 mb-4">
<svg class="w-5 h-5 text-blue-600 dark:text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"></path></svg>
<h2 class="text-lg font-semibold text-gray-800 dark:text-white">Anstehende Termine</h2>
</div>
<ul class="space-y-2">
<% kommendeTermine.forEach(function(t){
var when = t.ganztags
? new Date(t.start).toLocaleDateString('de-DE', { timeZone: caldavTz, weekday: 'short', day: '2-digit', month: '2-digit', year: 'numeric' })
: new Date(t.start).toLocaleString('de-DE', { timeZone: caldavTz, weekday: 'short', day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' });
%>
<li class="flex flex-wrap 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-3 py-2">
<div class="min-w-0">
<div class="flex flex-wrap items-center gap-2">
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium <%= t.typ === 'vorstellungsgespraech' ? 'bg-indigo-100 text-indigo-800 dark:bg-indigo-900/50 dark:text-indigo-200' : 'bg-gray-200 text-gray-700 dark:bg-gray-600 dark:text-gray-200' %>"><%= t.typ === 'vorstellungsgespraech' ? 'Gespräch' : 'Termin' %></span>
<span class="text-sm font-medium text-gray-800 dark:text-gray-100 break-words"><%= t.titel %></span>
</div>
<p class="text-sm text-gray-600 dark:text-gray-300 mt-0.5">
<%= when %> Uhr<% if (t.bewerbung_firma) { %> · <span class="text-gray-500 dark:text-gray-400"><%= t.bewerbung_firma %></span><% } %>
</p>
</div>
<% if (t.bewerbung_id) { %>
<a href="/bewerbung/<%= t.bewerbung_id %>#termine" class="shrink-0 inline-flex items-center gap-1 text-xs text-blue-600 dark:text-blue-400 hover:underline">Öffnen
<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="M9 5l7 7-7 7"></path></svg>
</a>
<% } %>
</li>
<% }); %>
</ul>
</div>
<% } %>
<!-- Gemerkte Bewerbungen — direkt unter den anstehenden Terminen -->
<%- include('partials/bewerbung-favoriten') %>
<!-- Applications -->
<style>
/* Shared column grid used by the header labels and every application line */
.app-grid {
display: grid;
grid-template-columns: 1.25rem 5.5rem minmax(7rem, 1.6fr) minmax(8rem, 2fr) 5.5rem 8.5rem 5.5rem;
gap: 0 0.75rem;
align-items: center;
}
/* Smooth height collapse via the grid-template-rows 0fr → 1fr trick */
.collapsible {
display: grid;
grid-template-rows: 0fr;
transition: grid-template-rows 0.32s cubic-bezier(0.4, 0, 0.2, 1);
}
.collapsible > .collapsible-inner {
overflow: hidden;
min-height: 0;
}
.collapsible.open {
grid-template-rows: 1fr;
}
.chevron {
transition: transform 0.25s ease;
}
.chevron.rotate {
transform: rotate(90deg);
}
</style>
<%
// Status-Reihenfolge synchron zu public/js/main.js halten. Steht hier oben,
// weil sowohl die Liste als auch die Aktivitäten weiter unten sie brauchen.
const statusOrder = ['Entwurf', 'Gesendet', 'Eingangsbestätigung',
'In Bearbeitung', 'Interessiert', 'Telefonat', 'Warten auf Rückmeldung', 'Warten auf meine Antwort',
'Vorstellungsgespräch', 'Vertragsverhandlung', 'Einstellung', 'Absage', 'Absage von meiner Seite', 'Keine Rückmeldung'];
const statusDot = {
'Entwurf': 'bg-purple-500',
'Gesendet': 'bg-indigo-500',
'Eingangsbestätigung': 'bg-blue-500',
'In Bearbeitung': 'bg-teal-500',
'Interessiert': 'bg-pink-500',
'Telefonat': 'bg-sky-500',
'Warten auf Rückmeldung': 'bg-orange-500',
'Warten auf meine Antwort': 'bg-fuchsia-500',
'Vorstellungsgespräch': 'bg-yellow-500',
'Vertragsverhandlung': 'bg-lime-500',
'Einstellung': 'bg-green-500',
'Absage': 'bg-red-500',
'Absage von meiner Seite': 'bg-rose-500',
'Keine Rückmeldung': 'bg-gray-400',
'Ohne Status': 'bg-gray-300'
};
%>
<div id="listCard" class="bg-white dark:bg-gray-800 rounded-lg shadow-md overflow-x-auto">
<!-- Kopf der Karte: Gesamtzahl der Bewerbungen. Bewusst außerhalb des
md:min-w-Rahmens unten, damit er auf dem Handy nicht mitscrollt. -->
<div class="flex flex-wrap items-baseline justify-between gap-x-4 gap-y-1 px-6 py-4">
<h2 class="text-lg font-semibold text-gray-800 dark:text-white">Bewerbungen</h2>
<p class="text-sm text-gray-500 dark:text-gray-400">
<span class="font-semibold text-gray-800 dark:text-gray-100"><%= statistics.total %></span>
<%= statistics.total === 1 ? 'Bewerbung' : 'Bewerbungen' %> gesamt
</p>
</div>
<div class="md:min-w-[760px]">
<!-- Column labels (desktop only — on phones the rows become stacked cards) -->
<div class="app-head app-grid px-6 py-3 bg-gray-50 dark:bg-gray-700 text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
<span></span>
<span>Datum</span>
<span>Firma</span>
<span>Stelle</span>
<span>Art</span>
<span>Status</span>
<span>Aktionen</span>
</div>
<% if (applications.length > 0) { %>
<%
const groups = {};
applications.forEach(a => {
const key = (a.status && a.status.trim()) ? a.status.trim() : 'Ohne Status';
(groups[key] = groups[key] || []).push(a);
});
const orderedKeys = [
...statusOrder.filter(s => groups[s]),
...Object.keys(groups).filter(k => !statusOrder.includes(k))
];
%>
<div id="appList">
<% orderedKeys.forEach(statusKey => { %>
<section class="status-group border-t border-gray-200 dark:border-gray-700">
<!-- Klappbarer Status-Kopf (Gesendet, Eingangsbestätigung, …) -->
<button type="button" class="group-toggle w-full flex items-center gap-3 px-6 py-3 bg-gray-100 dark:bg-gray-900/40 hover:bg-gray-200/70 dark:hover:bg-gray-900/60 transition-colors text-left">
<svg class="chevron w-4 h-4 flex-shrink-0 text-gray-400 dark:text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"></path>
</svg>
<span class="inline-block w-2.5 h-2.5 rounded-full flex-shrink-0 <%= statusDot[statusKey] || 'bg-gray-300' %>"></span>
<span class="text-sm font-semibold uppercase tracking-wider text-gray-700 dark:text-gray-200"><%= statusKey %></span>
<span data-group-count class="text-xs font-medium px-2 py-0.5 rounded-full bg-white dark:bg-gray-700 text-gray-600 dark:text-gray-300"><%= groups[statusKey].length %></span>
</button>
<div class="group-body collapsible">
<div class="collapsible-inner">
<% groups[statusKey].forEach(app => { %>
<%
const hatNotizen = app.notizen && app.notizen.trim().length > 0;
const hatInterne = app.interne_notizen && app.interne_notizen.trim().length > 0;
const hatVerlauf = app.verlauf && app.verlauf.length > 0;
const hatDetails = hatNotizen || hatInterne || hatVerlauf;
%>
<div class="app-row border-t border-gray-100 dark:border-gray-700/60 <%= hatDetails ? 'has-details' : '' %>">
<!-- Eine Zeile pro Bewerbung -->
<div class="app-line app-grid px-6 py-3 text-sm transition-colors <%= hatDetails ? 'cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-700/50' : '' %>">
<span class="flex items-center justify-center">
<% if (hatDetails) { %>
<svg class="chevron w-4 h-4 text-gray-400 dark:text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"></path>
</svg>
<% } %>
</span>
<span class="whitespace-nowrap text-gray-900 dark:text-white"><%= new Date(app.datum).toLocaleDateString('de-DE') %></span>
<span class="min-w-0 truncate font-medium text-gray-900 dark:text-white" title="<%= app.firma %>"><%= app.firma %></span>
<span class="min-w-0">
<span class="block truncate text-gray-900 dark:text-white" title="<%= app.stelle %>"><%= app.stelle %></span>
<% if (app.labelsArr && app.labelsArr.length) { %>
<span class="flex flex-wrap gap-1 mt-1"><%- include('partials/labelChips', { labels: app.labelsArr }) %></span>
<% } %>
</span>
<span class="min-w-0 truncate text-gray-500 dark:text-gray-400"><%= app.art || '-' %></span>
<span class="min-w-0">
<span class="inline-block max-w-full truncate align-middle px-2 py-1 rounded-full text-xs font-medium
<%= app.status === 'Einstellung' ? 'bg-green-100 text-green-800 dark:bg-green-800 dark:text-green-100' :
app.status === 'Absage' ? 'bg-red-100 text-red-800 dark:bg-red-800 dark:text-red-100' :
app.status === 'Absage von meiner Seite' ? 'bg-rose-100 text-rose-800 dark:bg-rose-800 dark:text-rose-100' :
app.status === 'Vorstellungsgespräch' ? 'bg-yellow-100 text-yellow-800 dark:bg-yellow-800 dark:text-yellow-100' :
app.status === 'Vertragsverhandlung' ? 'bg-lime-100 text-lime-800 dark:bg-lime-800 dark:text-lime-100' :
app.status === 'In Bearbeitung' ? 'bg-teal-100 text-teal-800 dark:bg-teal-800 dark:text-teal-100' :
app.status === 'Interessiert' ? 'bg-pink-100 text-pink-800 dark:bg-pink-800 dark:text-pink-100' :
app.status === 'Telefonat' ? 'bg-sky-100 text-sky-800 dark:bg-sky-800 dark:text-sky-100' :
app.status === 'Warten auf Rückmeldung' ? 'bg-orange-100 text-orange-800 dark:bg-orange-800 dark:text-orange-100' :
app.status === 'Warten auf meine Antwort' ? 'bg-fuchsia-100 text-fuchsia-800 dark:bg-fuchsia-800 dark:text-fuchsia-100' :
app.status === 'Entwurf' ? 'bg-purple-100 text-purple-800 dark:bg-purple-800 dark:text-purple-100' :
'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300' %>">
<%= app.status || '-' %>
</span>
</span>
<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 %>"
class="text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300"
aria-label="Bearbeiten">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"></path>
</svg>
</a>
<button type="button"
class="delete-btn text-red-600 hover:text-red-800 dark:text-red-400 dark:hover:text-red-300"
data-id="<%= app.id %>" aria-label="Löschen">
<svg class="w-5 h-5" 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>
</span>
</div>
<% if (hatDetails) { %>
<!-- Klappbarer Detailbereich unter der Zeile -->
<div class="app-details collapsible">
<div class="collapsible-inner">
<div class="px-6 pb-5 pt-1 space-y-3">
<% if (hatVerlauf) { %>
<div class="rounded-lg border-l-4 border-indigo-400 dark:border-indigo-500 bg-indigo-50/60 dark:bg-gray-700/40 px-4 py-3">
<div class="flex items-center gap-2 mb-2">
<svg class="w-4 h-4 text-indigo-500 dark:text-indigo-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg>
<span class="text-xs font-semibold uppercase tracking-wider text-indigo-600 dark:text-indigo-400">Status-Verlauf</span>
</div>
<ol class="space-y-1.5">
<% app.verlauf.forEach(v => { %>
<li class="flex flex-wrap gap-x-2 text-sm">
<span class="text-gray-500 dark:text-gray-400 whitespace-nowrap"><%= new Date(v.datum).toLocaleDateString('de-DE') %></span>
<span class="font-semibold text-gray-800 dark:text-gray-100"><%= v.status %></span>
<% if (v.kommentar && v.kommentar.trim()) { %>
<span class="text-gray-600 dark:text-gray-300 whitespace-pre-wrap break-words"> <%= v.kommentar %></span>
<% } %>
</li>
<% }); %>
</ol>
</div>
<% } %>
<% if (hatNotizen) { %>
<div class="rounded-lg border-l-4 border-blue-400 dark:border-blue-500 bg-blue-50/70 dark:bg-gray-700/40 px-4 py-3">
<div class="flex items-center gap-2 mb-2">
<svg class="w-4 h-4 text-blue-500 dark:text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path>
</svg>
<span class="text-xs font-semibold uppercase tracking-wider text-blue-600 dark:text-blue-400">Notizen</span>
</div>
<p class="whitespace-pre-wrap break-words text-base leading-relaxed text-gray-800 dark:text-gray-200"><%= app.notizen %></p>
</div>
<% } %>
<% if (hatInterne) { %>
<div class="rounded-lg border-l-4 border-amber-400 dark:border-amber-500 bg-amber-50/70 dark:bg-gray-700/40 px-4 py-3">
<div class="flex items-center gap-2 mb-2">
<svg class="w-4 h-4 text-amber-500 dark:text-amber-400" 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>
<span class="text-xs font-semibold uppercase tracking-wider text-amber-600 dark:text-amber-400">Interne Notizen · nicht im PDF</span>
</div>
<p class="whitespace-pre-wrap break-words text-base leading-relaxed text-gray-800 dark:text-gray-200"><%= app.interne_notizen %></p>
</div>
<% } %>
</div>
</div>
</div>
<% } %>
</div>
<% }); %>
</div>
</div>
</section>
<% }); %>
</div>
<% } else { %>
<div class="px-6 py-12 text-center text-gray-500 dark:text-gray-400 border-t border-gray-200 dark:border-gray-700">
<p>Keine Bewerbungen gefunden.</p>
<p class="mt-2 text-sm">Klicken Sie auf "Bewerbung hinzufügen", um Ihre erste Bewerbung einzutragen.</p>
</div>
<% } %>
</div>
</div>
<!-- Letzte Aktivitäten: die jüngsten Statusänderungen quer über alle
Bewerbungen — der schnellste Blick darauf, was zuletzt passiert ist,
ohne jede Gruppe aufklappen zu müssen. -->
<section id="aktivitaetCard" class="bg-white dark:bg-gray-800 rounded-lg shadow-md mt-8">
<div class="flex flex-wrap items-baseline justify-between gap-x-4 gap-y-1 px-6 py-4 border-b border-gray-200 dark:border-gray-700">
<h2 class="text-lg font-semibold text-gray-800 dark:text-white">Letzte Aktivitäten</h2>
<a href="/aktivitaeten" class="text-sm text-blue-600 dark:text-blue-400 hover:underline">Alle</a>
</div>
<% if (aktivitaet && aktivitaet.length) { %>
<ul class="divide-y divide-gray-100 dark:divide-gray-700/60">
<% 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>
<!-- Eintrag zu einer noch existierenden Bewerbung ist ein Link, einer zu
einer gelöschten oder einer nicht zugeordneten E-Mail bleibt ein Block.
Bewusst zwei ausgeschriebene Zweige: den Tag-Namen samt href per <%%= %>
zusammenzusetzen escaped die Anführungszeichen und erzeugt Links wie
/%22/bewerbung/121%22. -->
<% 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">
<% } else { %>
<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 <%= dotCls %>"></span>
<span class="min-w-0 flex-1">
<span class="flex flex-wrap items-baseline gap-x-2 gap-y-0.5">
<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>
<% if (titelSub) { %>
<span class="text-sm text-gray-500 dark:text-gray-400 break-words">· <%= titelSub %></span>
<% } %>
</span>
<!-- Status, Datum und Kommentar in einer Zeile: ein rechtsbündiges
Datum würde auf dem Handy in eine eigene Zeile umbrechen und
dort ohne Bezug stehen. -->
<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>
<% if (a.datum) { %>
<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()) { %>
<span class="text-gray-500 dark:text-gray-400 break-words">· <%= a.kommentar %></span>
<% } %>
</span>
</span>
<% if (a.bewerbung_id) { %></a><% } else { %></div><% } %>
</li>
<% }); %>
</ul>
<% } else { %>
<div class="px-6 py-10 text-center text-sm text-gray-500 dark:text-gray-400">
Noch keine Aktivitäten — Statusänderungen und E-Mails erscheinen hier.
</div>
<% } %>
</section>
<script>
(function () {
function qsa(sel, ctx) {
return Array.prototype.slice.call((ctx || document).querySelectorAll(sel));
}
// Öffnet/schließt einen klappbaren Bereich und dreht sein Chevron mit.
function setOpen(collapsible, chevron, open) {
if (!collapsible) return;
collapsible.classList.toggle('open', open);
if (chevron) chevron.classList.toggle('rotate', open);
}
// Status-Gruppen (Gesendet, Eingangsbestätigung …) auf-/zuklappen.
qsa('.group-toggle').forEach(function (btn) {
btn.addEventListener('click', function () {
var body = btn.parentElement.querySelector('.group-body');
var chev = btn.querySelector('.chevron');
setOpen(body, chev, !body.classList.contains('open'));
});
});
// Detailbereich einer einzelnen Bewerbung auf-/zuklappen.
qsa('.app-row.has-details').forEach(function (row) {
var line = row.querySelector('.app-line');
line.addEventListener('click', function (e) {
if (e.target.closest('.app-actions')) return; // Bearbeiten/Löschen nicht abfangen
var details = row.querySelector('.app-details');
var chev = line.querySelector('.chevron');
setOpen(details, chev, !details.classList.contains('open'));
});
});
// ---- Volltextsuche (/api/suche): Bewerbungen + E-Mail-Korrespondenz ----
// Die Liste unten bleibt der Normalzustand; sobald gesucht wird, tritt an
// ihre Stelle die gerankte Trefferliste mit Fundstellen.
var input = document.getElementById('firmaSearch');
if (!input) return;
var clear = document.getElementById('firmaSearchClear');
var hint = document.getElementById('firmaSearchHint');
var spinner = document.getElementById('firmaSearchSpinner');
var panel = document.getElementById('searchPanel');
var results = document.getElementById('searchResults');
var summary = document.getElementById('searchSummary');
var empty = document.getElementById('searchEmpty');
var emptyTerm = document.getElementById('searchEmptyTerm');
var fehler = document.getElementById('searchError');
var listCard = document.getElementById('listCard');
var termineCard = document.getElementById('termineCard');
var bewerbungFavoritenCard = document.getElementById('bewerbungFavoritenCard');
var aktivitaetCard = document.getElementById('aktivitaetCard');
var MIN = 2; // kürzere Eingaben liefern nur Rauschen
var MARK_START = '\u0002'; // Fundstellen-Marker aus lib/suche.js
var MARK_END = '\u0003';
var timer = null;
var laufend = null; // AbortController der offenen Anfrage
var aktiv = -1; // per Tastatur ausgewählter Treffer
function escapeHtml(s) {
return String(s == null ? '' : s)
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}
// Snippet -> HTML: erst escapen (Mailtext ist Fremdinhalt), dann die
// Marker durch <mark> ersetzen. Andersherum wäre es eine XSS-Lücke.
function snippetHtml(s) {
return escapeHtml(s)
.split(escapeHtml(MARK_START)).join('<mark class="bg-yellow-200 dark:bg-yellow-500/40 dark:text-white rounded-sm px-0.5">')
.split(escapeHtml(MARK_END)).join('</mark>');
}
function datumDe(iso) {
if (!iso) return '';
var d = new Date(iso);
return isNaN(d) ? '' : d.toLocaleDateString('de-DE');
}
// "Frau Müller <m.mueller@bosch.de>" -> "Frau Müller"
function absender(v) {
var s = String(v || '').trim();
var m = s.match(/^\s*"?([^"<]+?)"?\s*<[^>]+>\s*$/);
return (m ? m[1].trim() : s) || '';
}
// Woher der Treffer stammt — die Herkunft ist bei einer Mailsuche die
// eigentliche Information ("steht in der Mail von Frau Müller").
function herkunft(f) {
if (f.quelle !== 'email' || !f.email) return f.feld_label || 'Bewerbung';
var von = absender(f.email.richtung === 'out' ? f.email.an : f.email.von);
var d = datumDe(f.email.datum);
return 'E-Mail' + (von ? (f.email.richtung === 'out' ? ' an ' : ' von ') + von : '') + (d ? ', ' + d : '');
}
function trefferZeile(t, i) {
var b = t.bewerbung || {};
var li = document.createElement('li');
li.setAttribute('role', 'option');
li.dataset.index = String(i);
var a = document.createElement('a');
a.href = t.typ === 'email' && !t.bewerbung_id ? '/postfach' : '/bewerbung/' + t.bewerbung_id;
a.className = 'block px-6 py-4 hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors focus:outline-none focus:bg-gray-50 dark:focus:bg-gray-700/50';
var kopf = document.createElement('div');
kopf.className = 'flex flex-wrap items-baseline gap-x-2 gap-y-1';
var titel = document.createElement('span');
titel.className = 'font-medium text-gray-900 dark:text-white';
titel.textContent = t.bewerbung_id ? (b.firma || '(ohne Firma)') : 'E-Mail im Postfach';
kopf.appendChild(titel);
if (b.stelle) {
var stelle = document.createElement('span');
stelle.className = 'text-sm text-gray-600 dark:text-gray-300';
stelle.textContent = '· ' + b.stelle;
kopf.appendChild(stelle);
}
if (b.status) {
var st = document.createElement('span');
st.className = 'px-2 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-200';
st.textContent = b.status;
kopf.appendChild(st);
}
if (b.datum) {
var dt = document.createElement('span');
dt.className = 'ml-auto text-xs text-gray-400 dark:text-gray-500 whitespace-nowrap';
dt.textContent = datumDe(b.datum);
kopf.appendChild(dt);
}
a.appendChild(kopf);
(t.fundstellen || []).forEach(function (f) {
var zeile = document.createElement('p');
zeile.className = 'mt-1.5 text-sm text-gray-600 dark:text-gray-300 flex flex-wrap items-baseline gap-x-2';
var wo = document.createElement('span');
wo.className = 'shrink-0 text-xs uppercase tracking-wide ' +
(f.quelle === 'email' ? 'text-indigo-600 dark:text-indigo-400' : 'text-gray-400 dark:text-gray-500');
wo.textContent = herkunft(f);
var text = document.createElement('span');
text.className = 'min-w-0 break-words';
text.innerHTML = snippetHtml(f.snippet); // vorher escaped, siehe snippetHtml
zeile.appendChild(wo);
zeile.appendChild(text);
a.appendChild(zeile);
});
li.appendChild(a);
return li;
}
function markiere(i) {
var zeilen = qsa('li', results);
if (!zeilen.length) return;
aktiv = (i + zeilen.length) % zeilen.length;
zeilen.forEach(function (li, n) {
li.classList.toggle('bg-blue-50', n === aktiv);
li.classList.toggle('dark:bg-gray-700/60', n === aktiv);
li.setAttribute('aria-selected', n === aktiv ? 'true' : 'false');
});
zeilen[aktiv].scrollIntoView({ block: 'nearest' });
}
function zeigeListe() {
panel.classList.add('hidden');
if (listCard) listCard.classList.remove('hidden');
if (termineCard) termineCard.classList.remove('hidden');
if (bewerbungFavoritenCard) bewerbungFavoritenCard.classList.remove('hidden');
if (aktivitaetCard) aktivitaetCard.classList.remove('hidden');
input.setAttribute('aria-expanded', 'false');
aktiv = -1;
}
function zeigeTreffer(daten) {
var treffer = daten.treffer || [];
results.innerHTML = '';
treffer.forEach(function (t, i) { results.appendChild(trefferZeile(t, i)); });
summary.textContent = treffer.length
? treffer.length + (treffer.length === 1 ? ' Treffer' : ' Treffer') + ' für „' + daten.q.trim() + '"'
: 'Keine Treffer';
empty.classList.toggle('hidden', treffer.length > 0);
if (emptyTerm) emptyTerm.textContent = daten.q.trim();
fehler.classList.add('hidden');
panel.classList.remove('hidden');
if (listCard) listCard.classList.add('hidden');
if (termineCard) termineCard.classList.add('hidden');
if (bewerbungFavoritenCard) bewerbungFavoritenCard.classList.add('hidden');
if (aktivitaetCard) aktivitaetCard.classList.add('hidden');
input.setAttribute('aria-expanded', 'true');
aktiv = -1;
}
function zeigeFehler(msg) {
results.innerHTML = '';
summary.textContent = '';
empty.classList.add('hidden');
fehler.textContent = msg;
fehler.classList.remove('hidden');
panel.classList.remove('hidden');
if (listCard) listCard.classList.add('hidden');
if (termineCard) termineCard.classList.add('hidden');
if (bewerbungFavoritenCard) bewerbungFavoritenCard.classList.add('hidden');
if (aktivitaetCard) aktivitaetCard.classList.add('hidden');
}
async function suchen(q) {
if (laufend) laufend.abort(); // veraltete Antwort darf die neue nicht überholen
laufend = new AbortController();
spinner.classList.remove('hidden');
try {
var res = await fetch('/api/suche?q=' + encodeURIComponent(q), {
headers: { Accept: 'application/json' }, signal: laufend.signal,
});
if (!res.ok) throw new Error('Die Suche ist fehlgeschlagen (' + res.status + ').');
zeigeTreffer(await res.json());
} catch (e) {
if (e.name === 'AbortError') return; // durch den nächsten Tastendruck ersetzt
zeigeFehler(e.message || 'Die Suche ist fehlgeschlagen.');
} finally {
spinner.classList.add('hidden');
}
}
function getippt() {
var q = input.value.trim();
clear.classList.toggle('hidden', !q);
if (hint) hint.classList.toggle('hidden', !!q);
clearTimeout(timer);
if (laufend) { laufend.abort(); laufend = null; }
spinner.classList.add('hidden');
if (q.length < MIN) { zeigeListe(); return; }
// Entprellt: erst tippen lassen, dann eine Anfrage stellen.
timer = setTimeout(function () { suchen(q); }, 180);
}
input.addEventListener('input', getippt);
input.addEventListener('keydown', function (e) {
if (e.key === 'Escape') {
input.value = '';
getippt();
input.blur();
return;
}
if (panel.classList.contains('hidden')) return;
if (e.key === 'ArrowDown') { e.preventDefault(); markiere(aktiv + 1); }
else if (e.key === 'ArrowUp') { e.preventDefault(); markiere(aktiv - 1); }
else if (e.key === 'Enter') {
var zeilen = qsa('li', results);
var ziel = zeilen[aktiv >= 0 ? aktiv : 0];
var link = ziel && ziel.querySelector('a');
if (link) { e.preventDefault(); link.click(); }
}
});
clear.addEventListener('click', function () {
input.value = '';
getippt();
input.focus();
});
// "/" springt ins Suchfeld — solange man nicht ohnehin schon tippt.
document.addEventListener('keydown', function (e) {
if (e.key !== '/' || e.metaKey || e.ctrlKey || e.altKey) return;
var t = e.target;
if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.tagName === 'SELECT' || t.isContentEditable)) return;
e.preventDefault();
input.focus();
input.select();
});
})();
</script>
</div><!-- /dashboardWrap -->
</main>
<%- include('partials/footer') %>
<%- include('partials/favorit-script') %>
<!-- Add/Edit Application Modal -->
<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="bg-white dark:bg-gray-800 rounded-lg shadow-xl w-full max-w-lg p-6">
<div class="flex justify-between items-center mb-6">
<h2 id="modalTitle" class="text-xl font-bold text-gray-800 dark:text-white">Bewerbung hinzufügen</h2>
<button id="closeApplicationModal" class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
</svg>
</button>
</div>
<form id="applicationForm" class="space-y-4">
<input type="hidden" id="applicationId" name="id">
<div>
<label for="applicationDatum" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Datum *
</label>
<input
type="date"
id="applicationDatum"
name="datum"
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">
</div>
<div>
<label for="applicationFirma" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Firma *
</label>
<input
type="text"
id="applicationFirma"
name="firma"
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"
placeholder="Firmenname">
</div>
<div>
<label for="applicationStelle" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Stelle *
</label>
<input
type="text"
id="applicationStelle"
name="stelle"
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"
placeholder="Stellenbezeichnung">
</div>
<div>
<label for="applicationArt" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Art der Bewerbung
</label>
<select
id="applicationArt"
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 %>"><%= option %></option>
<% }); %>
</select>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Labels
</label>
<div id="applicationLabels">
<%- include('partials/labelPicker', { labelOptions: labelOptions, selected: [] }) %>
</div>
</div>
<div>
<label for="applicationStatus" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Status (Anfangsstatus)
</label>
<select
id="applicationStatus"
name="status"
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>
<% statusOptions.forEach(option => { %>
<option value="<%= option %>"><%= option %></option>
<% }); %>
</select>
</div>
<div>
<label for="applicationKommentar" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Kommentar zur Statusänderung
</label>
<textarea
id="applicationKommentar"
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="Optional z.B. „per E-Mail an Frau Müller“"></textarea>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">Weitere Statusänderungen dokumentierst du später auf der Bearbeiten-Seite.</p>
</div>
<div>
<label for="applicationNotizen" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Notizen
</label>
<textarea
id="applicationNotizen"
name="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="Allgemeine Notizen zur Bewerbung..."></textarea>
</div>
<div>
<label for="applicationInterneNotizen" class="block 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
id="applicationInterneNotizen"
name="interne_notizen"
rows="3"
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..."></textarea>
</div>
<div class="flex justify-end space-x-3 pt-4">
<button
type="button"
id="cancelApplication"
class="px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-md text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors">
Abbrechen
</button>
<button
type="submit"
class="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-md transition-colors">
Speichern
</button>
</div>
</form>
</div>
</div>
</div>
<!-- Delete Confirmation Modal -->
<div id="deleteModal" 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="bg-white dark:bg-gray-800 rounded-lg shadow-xl w-full max-w-md p-6">
<div class="flex justify-between items-center mb-6">
<h2 class="text-xl font-bold text-gray-800 dark:text-white">Bewerbung löschen</h2>
<button id="closeDeleteModal" class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
</svg>
</button>
</div>
<p class="text-gray-600 dark:text-gray-400 mb-6">
Sind Sie sicher, dass Sie diese Bewerbung löschen möchten? Dieser Vorgang kann nicht rückgängig gemacht werden.
</p>
<div class="flex justify-end space-x-3">
<button
type="button"
id="cancelDelete"
class="px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-md text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors">
Abbrechen
</button>
<button
type="button"
id="confirmDelete"
class="px-4 py-2 bg-red-600 hover:bg-red-700 text-white rounded-md transition-colors">
Löschen
</button>
</div>
</div>
</div>
</div>
<!-- PDF Export Modal -->
<div id="pdfExportModal" 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="bg-white dark:bg-gray-800 rounded-lg shadow-xl w-full max-w-md p-6">
<div class="flex justify-between items-center mb-6">
<h2 class="text-xl font-bold text-gray-800 dark:text-white">PDF Export</h2>
<button id="closePdfModal" class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
</svg>
</button>
</div>
<form id="pdfExportForm" class="space-y-4">
<div>
<label for="pdfMonth" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Monat
</label>
<select
id="pdfMonth"
name="month"
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="">-- Alle Monate --</option>
<% exportMonths.forEach(m => { %>
<option value="<%= m.year %>-<%= m.month %>"><%= m.month %> - <%= m.year %></option>
<% }); %>
</select>
</div>
<div>
<label for="pdfYear" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Jahr
</label>
<select
id="pdfYear"
name="year"
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="">-- Alle Jahre --</option>
<% const pdfYears = [...new Set(exportMonths.map(m => m.year))].sort().reverse(); %>
<% pdfYears.forEach(y => { %>
<option value="<%= y %>"><%= y %></option>
<% }); %>
</select>
</div>
<div class="flex justify-end space-x-3 pt-4">
<button
type="button"
id="cancelPdfExport"
class="px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-md text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors">
Abbrechen
</button>
<button
type="submit"
class="px-4 py-2 bg-green-600 hover:bg-green-700 text-white rounded-md transition-colors">
PDF generieren
</button>
</div>
</form>
</div>
</div>
</div>
<!-- Load main JavaScript -->
<script src="/js/main.js"></script>
</body>
</html>