Drei Erweiterungen, die sich server.js und lib/documents.js teilen und deshalb zusammen liegen: Jobsuche: Der Feinschliff (Zusatzbegriffe/Ausschluesse) laesst sich per KI aus den eigenen Basis-Unterlagen vorschlagen. Der Rollen-Prompt ist wie die uebrigen unter Vorlagen editierbar; gespeichert wird erst auf Klick. Suche: Startseite und KI-Chat durchsuchen jetzt per SQLite-FTS5 auch die E-Mail-Korrespondenz (Absender, Betreff, Text), Notizen und Stellenbeschreibung - ein Name aus einer Mail findet die Bewerbung. Ranking per bm25, Fundstellen mit Snippet, Feldfilter (firma:, von:), Phrasen, Ausschluesse, Umlaut-Varianten (mueller/muller/mueller). Der Index wird von SQL-Triggern gepflegt, weil Bewerbungen und Mails aus UI, REST-API, IMAP und KI-Import geschrieben werden. E-Mail: Vor dem Generieren laesst sich vorgeben, was in der Antwort stehen soll; die Vorgabe geht als verbindliche Anweisung in den Prompt. Steht schon ein Entwurf im Feld, ueberarbeitet die KI ihn, statt neu anzufangen. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
838 lines
54 KiB
Plaintext
838 lines
54 KiB
Plaintext
<!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">
|
||
<!-- 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>
|
||
<% } %>
|
||
|
||
<!-- 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 4.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>
|
||
<div id="listCard" class="bg-white dark:bg-gray-800 rounded-lg shadow-md overflow-x-auto">
|
||
<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) { %>
|
||
<%
|
||
// Status-Reihenfolge synchron zu public/js/main.js halten
|
||
const statusOrder = ['Entwurf', 'Gesendet', 'Eingangsbestätigung',
|
||
'In Bearbeitung', 'Interessiert', 'Warten auf Rückmeldung', 'Warten auf meine Antwort',
|
||
'Vorstellungsgespräch', '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',
|
||
'Warten auf Rückmeldung': 'bg-orange-500',
|
||
'Warten auf meine Antwort': 'bg-fuchsia-500',
|
||
'Vorstellungsgespräch': 'bg-yellow-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'
|
||
};
|
||
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 === '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 === '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">
|
||
<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>
|
||
|
||
<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 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, '&').replace(/</g, '<').replace(/>/g, '>')
|
||
.replace(/"/g, '"').replace(/'/g, ''');
|
||
}
|
||
|
||
// 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');
|
||
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');
|
||
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');
|
||
}
|
||
|
||
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>
|
||
</main>
|
||
|
||
<%- include('partials/footer') %>
|
||
|
||
<!-- 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>
|