Refactor UI/views, rework Docker build, untrack local data

- Views umstrukturiert: einstellungen.ejs -> bewerbung.ejs, neues
  partials/head.ejs, header/footer/index angepasst
- CSS umbenannt: style.css -> styles.css
- server.js und public/js/main.js ueberarbeitet
- Dockerfile auf schlankes Multi-Stage-Setup umgestellt;
  docker-compose.yml und .dockerignore entfernt
- npm-Scripts docker:build/push/deploy ergaenzt
- SQLite-DB und .idea aus Git entfernt und via .gitignore ignoriert

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-19 04:01:37 +02:00
parent 34cfcecc1e
commit c2a629e2c0
25 changed files with 3693 additions and 1886 deletions
-20
View File
@@ -1,20 +0,0 @@
.git
.gitignore
.github
.env
.env.*
!.env.example
*.pem
*.key
README.md
LICENSE
docs
.idea
.vscode
*.swp
.DS_Store
node_modules
data
Dockerfile
.dockerignore
docker-compose*.yml
+36
View File
@@ -1,3 +1,39 @@
# Dependencies
node_modules/
# Database
/data/
*.db
*.sqlite
*.sqlite3
# Environment
.env
.env.local
.env.*.local
# Logs
logs/
*.log
npm-debug.log*
# Build output
dist/
build/
# IDE
.idea/
.vscode/
*.swp
*.swo
# OS
.DS_Store
Thumbs.db
# Test coverage
coverage/
# Temporary files
tmp/
temp/
-10
View File
@@ -1,10 +0,0 @@
# Default ignored files
/shelf/
/workspace.xml
# Editor-based HTTP Client requests
/httpRequests/
# Ignored default folder with query files
/queries/
# Datasource local storage ignored files
/dataSources/
/dataSources.local.xml
-8
View File
@@ -1,8 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="WEB_MODULE" version="4">
<component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$" />
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>
-10
View File
@@ -1,10 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="MaterialThemeProjectNewConfig">
<option name="metadata">
<MTProjectMetadataState>
<option name="userId" value="49989f6b:19e8e0f7dcd:-7fff" />
</MTProjectMetadataState>
</option>
</component>
</project>
-8
View File
@@ -1,8 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/jobbi-bewerbung.iml" filepath="$PROJECT_DIR$/.idea/jobbi-bewerbung.iml" />
</modules>
</component>
</project>
+24 -14
View File
@@ -1,35 +1,45 @@
# ── Stage 1: Dependencies (mit Compiler für better-sqlite3) ──────────────────
FROM node:20.19.2-slim AS deps
# syntax=docker/dockerfile:1
# ----- Build stage: install (and compile native sqlite3) deps -----
FROM node:20-bookworm-slim AS builder
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends \
python3 make g++ \
# Build toolchain required to compile the native sqlite3 binding
RUN apt-get update \
&& apt-get install -y --no-install-recommends python3 make g++ \
&& rm -rf /var/lib/apt/lists/*
# Install production dependencies deterministically (cached unless lockfile changes)
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
# ── Stage 2: Runtime ──────────────────────────────────────────────────────────
FROM node:20.19.2-slim AS runtime
# ----- Runtime stage: slim image without build tooling -----
FROM node:20-bookworm-slim AS runtime
ENV NODE_ENV=production \
PORT=3000
WORKDIR /app
RUN groupadd --system --gid 1001 nodejs \
&& useradd --system --uid 1001 --gid nodejs --no-create-home appuser
# Bring in the already-installed (and compiled) dependencies
COPY --from=builder /app/node_modules ./node_modules
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN mkdir -p data && chown -R appuser:nodejs data
# Application source
COPY package.json ./
COPY server.js ./
COPY views ./views
COPY public ./public
# Persisted SQLite data lives here; make it writable for the non-root user
RUN mkdir -p /app/data && chown -R node:node /app
VOLUME ["/app/data"]
USER appuser
USER node
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD node -e "require('http').get('http://localhost:3000/',r=>process.exit(r.statusCode===200?0:1)).on('error',()=>process.exit(1))"
CMD node -e "fetch('http://localhost:'+(process.env.PORT||3000)+'/').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"
CMD ["node", "server.js"]
+148 -64
View File
@@ -1,110 +1,194 @@
# Bewerbungs-Tracker
Lokale Web-Anwendung zur Verwaltung von Stellenbewerbungen optimiert für die monatliche Nachweispflicht beim Jobcenter (Grundsicherung / Bürgergeld).
Ein professioneller Job Application Tracker für Jobcenter Grundsicherung Monatsberichte.
## Funktionen
## Features
- **Bewerbungen verwalten** Anlegen, Bearbeiten, Löschen
- **Monatsansicht** Filter nach Monat und Jahr
- **Statistiken** Gesamt, Positiv, Absagen, Ausstehend
- **PDF-Export** Professionelles Dokument mit Ihren Daten für das Jobcenter
- **Dunkelmodus** Standard oder per Schalter umschaltbar
- **Datenschutz** Alle Daten bleiben lokal auf Ihrem Rechner (SQLite)
- **Benutzerprofile**: Speichern Sie Name, Adresse und Jobcenter Kundennummer
- **Dunkler Modus**: Vollständige Dark Mode Unterstützung mit lokaler Speicherung
- **CRUD-Operationen**: Komplette Verwaltung von Bewerbungen (Hinzufügen, Bearbeiten, Löschen)
- **Filterfunktion**: Filterung nach Monat und Jahr
- **Statistiken**: Übersicht über Gesamtbewerbungen, nach Art und Status
- **PDF-Export**: Professionelle PDF-Generierung für Jobcenter-Berichte
- **Responsive Design**: Optimiert für Desktop und Mobile Geräte
## Technologien
- **Backend**: Node.js + Express.js
- **Datenbank**: SQLite
- **Frontend**: EJS Template Engine, Tailwind CSS (CDN)
- **PDF-Generierung**: jsPDF + jspdf-autotable (CDN)
## Installation
### Voraussetzungen
- Node.js ≥ 18 (https://nodejs.org)
- npm (im Lieferumfang von Node.js)
- Build-Tools (für `better-sqlite3`):
- **Linux/Mac:** `build-essential` / Xcode Command Line Tools
- **Windows:** `windows-build-tools` oder Visual Studio Build Tools
- Node.js (Version 14 oder höher)
- npm oder yarn
### Schritte
1. **Projekt klonen**
```bash
# 1. In das Projektverzeichnis wechseln
cd bewerbungs-tracker
```
# 2. Abhängigkeiten installieren
2. **Abhängigkeiten installieren**
```bash
npm install
```
# 3. Server starten
3. **Datenbank-Verzeichnis erstellen**
```bash
mkdir -p data
```
Die SQLite-Datenbank wird automatisch beim ersten Start erstellt.
4. **Server starten**
```bash
npm start
```
Die Anwendung ist dann unter **http://localhost:3000** erreichbar.
### Entwicklungsmodus (Auto-Reload)
Für Entwicklung mit automatischem Neuladen:
```bash
npm run dev
```
5. **Anwendung öffnen**
Öffnen Sie Ihren Browser und navigieren Sie zu:
```
http://localhost:3000
```
## Projektstruktur
```
bewerbungs-tracker/
├── server.js # Express-Server mit allen Routen
├── package.json
├── server.js # Express Server mit API-Routen
├── package.json # Projektabhängigkeiten und Skripte
├── views/
│ ├── index.ejs # Übersichtsseite
│ ├── einstellungen.ejs # Einstellungsseite
│ ├── index.ejs # Hauptseite
│ └── partials/
│ ├── header.ejs # HTML-Head + Navigation
│ └── footer.ejs # Abschlusselemente + Scripts
│ ├── header.ejs # Kopfzeile mit Dark Mode Toggle
│ └── footer.ejs # Fußzeile
├── public/
│ ├── css/style.css # Tailwind-Utility-Klassen
│ └── js/main.js # Dark Mode, Modals, PDF-Generierung
└── data/
└── bewerbungen.db # SQLite-Datenbank (wird automatisch erstellt)
│ ├── css/
│ └── styles.css # Benutzerdefinierte Styles
│ └── js/
└── main.js # Client-seitige Logik
├── data/
│ └── bewerbungen.db # SQLite Datenbank (wird automatisch erstellt)
└── README.md # Dokumentation
```
## Datenbank-Schema
```sql
-- Bewerbungen
CREATE TABLE bewerbungen (
id INTEGER PRIMARY KEY AUTOINCREMENT,
datum DATE NOT NULL,
firma TEXT NOT NULL,
stelle TEXT NOT NULL,
art TEXT,
status TEXT,
notizen TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
### Bewerbungen
-- Benutzerprofil (wird im PDF verwendet)
CREATE TABLE settings (
id INTEGER PRIMARY KEY CHECK (id = 1),
name TEXT,
adresse TEXT,
kundennummer TEXT
);
```
| Feld | Typ | Beschreibung |
|------|-----|--------------|
| id | INTEGER PRIMARY KEY | Eindeutige ID |
| datum | DATE | Bewerbungsdatum |
| firma | TEXT | Firmenname |
| stelle | TEXT | Stellenbezeichnung |
| art | TEXT | Art der Bewerbung |
| status | TEXT | Status der Bewerbung |
| notizen | TEXT | Zusätzliche Notizen |
| created_at | DATETIME | Erstellungsdatum |
| updated_at | DATETIME | Letztes Update |
### Einstellungen
| Feld | Typ | Beschreibung |
|------|-----|--------------|
| id | INTEGER | Immer 1 (Single Row) |
| name | TEXT | Benutzername |
| adresse | TEXT | Benutzeradresse |
| kundennummer | TEXT | Jobcenter Kundennummer |
## Verwendbare Optionen
### Art der Bewerbung
- E-Mail
- Online-Portal
- Indeed
- StepStone
- Firmenwebsite
- Post
- Initiativbewerbung
- Arbeitsagentur
- Sonstiges
### Status
- Gesendet
- Eingangsbestätigung
- Vorstellungsgespräch
- Absage
- Einstellung
- Keine Rückmeldung
## API-Endpunkte
### GET /
Hauptseite mit allen Bewerbungen
### GET /api/settings
Benutzereinstellungen abrufen
### POST /api/settings
Benutzereinstellungen speichern
### GET /api/bewerbungen
Alle Bewerbungen abrufen (mit Filter: ?month=MM&year=YYYY)
### POST /api/bewerbungen
Neue Bewerbung erstellen
### PUT /api/bewerbungen/:id
Bewerbung aktualisieren
### DELETE /api/bewerbungen/:id
Bewerbung löschen
### GET /api/bewerbungen/filter
Bewerbungen mit Filter abrufen
## PDF-Export
1. Öffnen Sie **Einstellungen** und tragen Sie Ihren Namen, Adresse und Kundennummer ein.
2. Filtern Sie auf der Übersicht den gewünschten Monat.
3. Klicken Sie auf **„PDF exportieren"**.
Der PDF-Export generiert ein professionelles Dokument mit:
- Benutzerdaten (Name, Adresse, Kundennummer)
- Überschrift mit Monat und Jahr
- Zusammenfassung der Bewerbungsaktivitäten
- Tabelle mit allen Bewerbungen
- Bestätigungstext und Datum
Das PDF enthält:
- Briefkopf mit Ihren persönlichen Daten
- Titel „Bewerbungsaktivitäten Monat Jahr"
- Zusammenfassungssatz
- Tabelle aller Bewerbungen des Monats
- Unterschriftszeile mit Datum
## Browser-Unterstützung
## Port ändern
- Chrome (empfohlen)
- Firefox
- Safari
- Edge
```bash
PORT=8080 npm start
```
## Dark Mode
Der Dark Mode kann manuell über den Toggle-Button in der Kopfzeile aktiviert werden. Die Einstellung wird in localStorage gespeichert und bleibt beim nächsten Besuch erhalten.
## Sicherheit
- Eingabefelder werden gegen XSS geschützt
- SQL-Injection wird durch parametrisierte Abfragen verhindert
- Formulare validieren Pflichtfelder
## Lizenz
MIT
## Autor
Bewerbungs-Tracker für Jobcenter Grundsicherung
---
**Hinweis**: Diese Anwendung ist speziell für die Anforderungen des deutschen Jobcenters (Grundsicherung) entwickelt worden. Sie hilft bei der Dokumentation von Bewerbungsaktivitäten für die monatlichen Berichte.
View File
Binary file not shown.
Binary file not shown.
Binary file not shown.
-13
View File
@@ -1,13 +0,0 @@
services:
app:
image: git.hackner.dev/thomas/jobbi-bewerbung:latest
restart: unless-stopped
ports:
- "3000:3000"
volumes:
- data:/app/data
environment:
PORT: "3000"
volumes:
data:
+1283 -46
View File
File diff suppressed because it is too large Load Diff
+21 -7
View File
@@ -1,19 +1,33 @@
{
"name": "bewerbungs-tracker",
"version": "1.0.0",
"description": "Bewerbungs-Tracker für die monatliche Jobcenter-Berichterstattung",
"description": "Job Application Tracker für Jobcenter Grundsicherung Monatsberichte",
"main": "server.js",
"scripts": {
"start": "node server.js",
"dev": "nodemon server.js"
"dev": "nodemon server.js",
"docker:build": "docker build -t git.hackner.dev/thomas/jobbi-bewerbung:latest .",
"docker:push": "docker push git.hackner.dev/thomas/jobbi-bewerbung:latest",
"docker:deploy": "npm run docker:build && npm run docker:push"
},
"dependencies": {
"better-sqlite3": "^12.10.0",
"ejs": "^3.1.10",
"express": "^4.22.2",
"method-override": "^3.0.0"
"ejs": "^3.1.9",
"express": "^4.18.2",
"jspdf": "^2.5.1",
"jspdf-autotable": "^3.8.2",
"sqlite3": "^5.1.6"
},
"devDependencies": {
"nodemon": "^3.0.2"
}
},
"keywords": [
"job",
"application",
"tracker",
"jobcenter",
"grundsicherung",
"bewerbung"
],
"author": "",
"license": "MIT"
}
-822
View File
@@ -1,822 +0,0 @@
/* ── Bewerbungs-Tracker — Design System ──────────────────────────────────── */
@import url('https://fonts.googleapis.com/css2?family=Barlow+Semi+Condensed:wght@600;700;800&family=DM+Sans:ital,opsz,wght@0,9..40,400;0,9..40,500;0,9..40,600;1,9..40,400&family=DM+Mono:wght@400;500&display=swap');
/* ── Variables ───────────────────────────────────────────────────────────── */
:root {
--bg: #f3f5fb;
--surface: #ffffff;
--surface-2: #edf0f7;
--surface-3: #e4e9f4;
--border: #dde2ef;
--border-2: #cad2e4;
--text: #18202f;
--text-2: #4a556b;
--text-muted: #8896b0;
--accent: #e07b00;
--accent-dim: rgba(224,123,0,0.1);
--accent-hover: #c96e00;
--green: #16a34a;
--green-dim: rgba(22,163,74,0.1);
--red: #dc2626;
--red-dim: rgba(220,38,38,0.1);
--amber: #d97706;
--amber-dim: rgba(217,119,6,0.1);
--blue: #2563eb;
--blue-dim: rgba(37,99,235,0.1);
--purple: #7c3aed;
--purple-dim: rgba(124,58,237,0.1);
--shadow-sm: 0 1px 3px rgba(0,0,0,0.06), 0 1px 2px rgba(0,0,0,0.04);
--shadow: 0 4px 16px rgba(0,0,0,0.08);
--shadow-xl: 0 20px 60px rgba(0,0,0,0.14);
--radius: 6px;
--radius-lg: 10px;
--radius-xl: 14px;
--font-sans: 'DM Sans', system-ui, -apple-system, sans-serif;
--font-display: 'Barlow Semi Condensed', system-ui, sans-serif;
--font-mono: 'DM Mono', 'Fira Mono', monospace;
}
html.dark {
--bg: #0c0e14;
--surface: #131720;
--surface-2: #192030;
--surface-3: #1f2840;
--border: #232d42;
--border-2: #2c3a56;
--text: #d0d9ea;
--text-2: #7d8ea8;
--text-muted: #435069;
--accent: #f08c00;
--accent-dim: rgba(240,140,0,0.12);
--accent-hover: #e07b00;
--green: #22c55e;
--green-dim: rgba(34,197,94,0.12);
--red: #f87171;
--red-dim: rgba(248,113,113,0.12);
--amber: #fbbf24;
--amber-dim: rgba(251,191,36,0.12);
--blue: #60a5fa;
--blue-dim: rgba(96,165,250,0.12);
--purple: #a78bfa;
--purple-dim: rgba(167,139,250,0.12);
--shadow-sm: 0 1px 3px rgba(0,0,0,0.4);
--shadow: 0 4px 16px rgba(0,0,0,0.5);
--shadow-xl: 0 20px 60px rgba(0,0,0,0.7);
}
/* ── Reset ───────────────────────────────────────────────────────────────── */
*, *::before, *::after { box-sizing: border-box; }
body { margin: 0; }
/* ── Base ────────────────────────────────────────────────────────────────── */
body {
font-family: var(--font-sans);
background: var(--bg);
color: var(--text);
min-height: 100vh;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
transition: background 0.2s, color 0.2s;
}
/* Ambient gradient for dark mode */
html.dark body::before {
content: '';
position: fixed;
inset: 0;
z-index: 0;
pointer-events: none;
background:
radial-gradient(ellipse 70% 50% at 5% 0%, rgba(240,140,0,0.05) 0%, transparent 65%),
radial-gradient(ellipse 50% 40% at 95% 100%, rgba(96,165,250,0.04) 0%, transparent 65%);
}
/* ── Navigation ──────────────────────────────────────────────────────────── */
.app-nav {
position: sticky;
top: 0;
z-index: 40;
height: 56px;
display: flex;
align-items: center;
padding: 0 1.5rem;
gap: 1.5rem;
background: var(--surface);
border-bottom: 1px solid var(--border);
}
.nav-brand {
display: flex;
align-items: center;
gap: 0.5rem;
text-decoration: none;
font-family: var(--font-display);
font-weight: 700;
font-size: 1.05rem;
letter-spacing: 0.01em;
color: var(--text);
flex-shrink: 0;
}
/* Animated dot */
.brand-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--accent);
flex-shrink: 0;
animation: pulse-dot 3s ease-in-out infinite;
}
@keyframes pulse-dot {
0%, 100% { box-shadow: 0 0 0 0 var(--accent-dim); }
50% { box-shadow: 0 0 0 5px transparent; }
}
.nav-links {
display: flex;
align-items: center;
gap: 2px;
flex: 1;
}
.nav-link {
padding: 0.3125rem 0.6875rem;
border-radius: var(--radius);
font-size: 0.875rem;
font-weight: 500;
color: var(--text-2);
text-decoration: none;
transition: color 0.15s, background 0.15s;
}
.nav-link:hover { color: var(--text); background: var(--surface-2); }
.nav-link.active { color: var(--accent); background: var(--accent-dim); }
/* Pill toggle for dark mode */
.dark-toggle {
position: relative;
width: 42px;
height: 24px;
border-radius: 12px;
background: var(--surface-3);
border: 1px solid var(--border-2);
cursor: pointer;
transition: background 0.2s, border-color 0.2s;
flex-shrink: 0;
padding: 0;
outline: none;
}
.dark-toggle:focus-visible {
box-shadow: 0 0 0 2px var(--accent);
}
.dark-toggle::after {
content: '';
position: absolute;
top: 3px;
left: 3px;
width: 16px;
height: 16px;
border-radius: 50%;
background: var(--text-muted);
transition: transform 0.22s cubic-bezier(0.4,0,0.2,1), background 0.2s;
}
html.dark .dark-toggle { background: var(--accent-dim); border-color: var(--accent); }
html.dark .dark-toggle::after {
transform: translateX(18px);
background: var(--accent);
}
/* ── Page wrapper ────────────────────────────────────────────────────────── */
.page-wrapper {
position: relative;
z-index: 1;
max-width: 88rem;
margin: 0 auto;
padding: 2rem 1.5rem 3rem;
}
/* ── Page header ─────────────────────────────────────────────────────────── */
.page-header {
display: flex;
flex-wrap: wrap;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
margin-bottom: 1.75rem;
}
.page-title {
font-family: var(--font-display);
font-weight: 700;
font-size: 1.875rem;
letter-spacing: 0.01em;
line-height: 1.15;
color: var(--text);
}
.page-meta {
font-size: 0.8125rem;
color: var(--text-muted);
margin-top: 0.3rem;
display: flex;
align-items: center;
gap: 0.5rem;
}
.page-meta .sep { opacity: 0.4; }
.page-meta .tag {
font-family: var(--font-mono);
font-size: 0.7rem;
background: var(--surface-2);
border: 1px solid var(--border);
padding: 0.1rem 0.5rem;
border-radius: 4px;
color: var(--text-2);
}
/* ── Filter bar ──────────────────────────────────────────────────────────── */
.filter-bar {
display: flex;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
}
/* ── Stats ───────────────────────────────────────────────────────────────── */
.stats-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 0.875rem;
margin-bottom: 1.5rem;
}
@media (min-width: 560px) {
.stats-grid { grid-template-columns: repeat(4, 1fr); }
}
.stat-card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-xl);
padding: 1.125rem 1.25rem 1rem;
position: relative;
overflow: hidden;
animation: fadeUp 0.45s cubic-bezier(0.22,1,0.36,1) both;
}
.stat-card:nth-child(1) { animation-delay: 0.04s; }
.stat-card:nth-child(2) { animation-delay: 0.09s; }
.stat-card:nth-child(3) { animation-delay: 0.14s; }
.stat-card:nth-child(4) { animation-delay: 0.19s; }
/* Bottom color strip */
.stat-card::after {
content: '';
position: absolute;
bottom: 0; left: 0; right: 0;
height: 2px;
border-radius: 0 0 var(--radius-xl) var(--radius-xl);
}
.stat-card.s-total::after { background: var(--accent); }
.stat-card.s-positiv::after { background: var(--green); }
.stat-card.s-absage::after { background: var(--red); }
.stat-card.s-pending::after { background: var(--amber); }
.stat-label {
font-size: 0.65rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.1em;
color: var(--text-muted);
margin-bottom: 0.375rem;
}
.stat-value {
font-family: var(--font-display);
font-weight: 800;
font-size: 2.5rem;
line-height: 1;
letter-spacing: 0.01em;
color: var(--text);
font-variant-numeric: tabular-nums;
}
.stat-card.s-total .stat-value { color: var(--accent); }
.stat-card.s-positiv .stat-value { color: var(--green); }
.stat-card.s-absage .stat-value { color: var(--red); }
.stat-card.s-pending .stat-value { color: var(--amber); }
/* ── Action bar ──────────────────────────────────────────────────────────── */
.action-bar {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 0.75rem;
}
.entry-count {
font-size: 0.6875rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.1em;
color: var(--text-muted);
}
/* ── Data card ───────────────────────────────────────────────────────────── */
.data-card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-xl);
overflow: hidden;
animation: fadeUp 0.45s 0.24s cubic-bezier(0.22,1,0.36,1) both;
}
/* ── Table ───────────────────────────────────────────────────────────────── */
.data-table {
width: 100%;
border-collapse: collapse;
}
.data-table thead th {
padding: 0.6875rem 1rem;
text-align: left;
font-size: 0.65rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.1em;
color: var(--text-muted);
background: var(--surface-2);
border-bottom: 1px solid var(--border);
white-space: nowrap;
}
.data-table thead th:first-child { border-radius: 0; }
.data-table tbody tr {
border-bottom: 1px solid var(--border);
transition: background 0.1s;
}
.data-table tbody tr:last-child { border-bottom: none; }
.data-table tbody tr:hover { background: var(--surface-2); }
/* Row actions: only visible on hover */
.data-table tbody tr .row-actions { opacity: 0; transition: opacity 0.15s; }
.data-table tbody tr:hover .row-actions { opacity: 1; }
.data-table td {
padding: 0.8125rem 1rem;
font-size: 0.875rem;
vertical-align: middle;
}
.td-date {
font-family: var(--font-mono);
font-size: 0.75rem;
color: var(--text-muted);
white-space: nowrap;
letter-spacing: 0.02em;
}
.td-firma { font-weight: 600; color: var(--text); }
.td-stelle { color: var(--text-2); }
.td-art {
font-family: var(--font-mono);
font-size: 0.7rem;
color: var(--text-muted);
}
.td-notizen {
max-width: 14rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 0.75rem;
color: var(--text-muted);
}
/* ── Status chip ─────────────────────────────────────────────────────────── */
.status-chip {
display: inline-flex;
align-items: center;
gap: 0.375rem;
font-size: 0.8rem;
font-weight: 500;
white-space: nowrap;
}
.status-chip::before {
content: '';
width: 6px;
height: 6px;
border-radius: 50%;
flex-shrink: 0;
}
.st-gesendet { color: var(--blue); }
.st-gesendet::before { background: var(--blue); }
.st-eingang { color: var(--amber); }
.st-eingang::before { background: var(--amber); }
.st-vorstellung { color: var(--purple); }
.st-vorstellung::before { background: var(--purple); }
.st-absage { color: var(--red); }
.st-absage::before { background: var(--red); }
.st-einstellung { color: var(--green); }
.st-einstellung::before { background: var(--green); box-shadow: 0 0 5px var(--green); }
.st-keine { color: var(--text-muted); }
.st-keine::before { background: var(--text-muted); }
/* ── Row action buttons ──────────────────────────────────────────────────── */
.row-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 30px;
height: 30px;
border-radius: var(--radius);
border: none;
background: transparent;
cursor: pointer;
transition: background 0.12s, color 0.12s;
color: var(--text-muted);
}
.row-btn:hover { background: var(--surface-3); color: var(--text); }
.row-btn.btn-edit:hover { background: var(--blue-dim); color: var(--blue); }
.row-btn.btn-del:hover { background: var(--red-dim); color: var(--red); }
/* ── Empty state ─────────────────────────────────────────────────────────── */
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 5rem 2rem;
text-align: center;
}
.empty-icon {
width: 52px;
height: 52px;
border-radius: 50%;
background: var(--surface-2);
border: 1px solid var(--border);
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 1.125rem;
color: var(--text-muted);
}
.empty-title { font-weight: 600; color: var(--text-2); margin-bottom: 0.3rem; font-size: 0.9375rem; }
.empty-sub { font-size: 0.8125rem; color: var(--text-muted); }
/* ── Buttons ─────────────────────────────────────────────────────────────── */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.375rem;
padding: 0.5rem 1rem;
border-radius: var(--radius);
font-family: var(--font-sans);
font-size: 0.875rem;
font-weight: 600;
line-height: 1;
cursor: pointer;
border: 1px solid transparent;
transition: background 0.13s, border-color 0.13s, color 0.13s, opacity 0.13s, transform 0.1s;
text-decoration: none;
white-space: nowrap;
user-select: none;
}
.btn:active { transform: scale(0.975); }
.btn:disabled { opacity: 0.45; cursor: not-allowed; transform: none !important; }
.btn-primary {
background: var(--accent);
color: #0d0d0d;
border-color: var(--accent);
}
.btn-primary:hover:not(:disabled) {
background: var(--accent-hover);
border-color: var(--accent-hover);
}
.btn-secondary {
background: var(--surface-2);
color: var(--text-2);
border-color: var(--border);
}
.btn-secondary:hover:not(:disabled) {
background: var(--surface-3);
border-color: var(--border-2);
}
.btn-danger {
background: var(--red-dim);
color: var(--red);
border-color: transparent;
}
.btn-danger:hover:not(:disabled) {
background: var(--red);
color: #fff;
}
.btn-sm {
padding: 0.375rem 0.75rem;
font-size: 0.8125rem;
}
/* ── Form controls ───────────────────────────────────────────────────────── */
.form-group {
display: flex;
flex-direction: column;
gap: 0.375rem;
}
.form-label {
font-size: 0.8125rem;
font-weight: 600;
color: var(--text-2);
letter-spacing: -0.01em;
}
.form-label .req { color: var(--red); margin-left: 2px; }
.form-hint {
font-size: 0.75rem;
color: var(--text-muted);
margin-top: 0.2rem;
}
.form-input,
.form-select,
.form-textarea {
width: 100%;
padding: 0.5625rem 0.75rem;
font-family: var(--font-sans);
font-size: 0.875rem;
line-height: 1.5;
color: var(--text);
background: var(--surface);
border: 1px solid var(--border-2);
border-radius: var(--radius);
outline: none;
transition: border-color 0.15s, box-shadow 0.15s;
-webkit-appearance: none;
-moz-appearance: none;
}
.form-input::placeholder,
.form-textarea::placeholder { color: var(--text-muted); }
.form-input:focus,
.form-select:focus,
.form-textarea:focus {
border-color: var(--accent);
box-shadow: 0 0 0 3px var(--accent-dim);
}
.form-select {
cursor: pointer;
padding-right: 2.25rem;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24' stroke='%237d8ea8'%3E%3Cpath stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M19 9l-7 7-7-7'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 0.625rem center;
background-size: 1em;
}
.form-textarea { resize: none; line-height: 1.65; }
.form-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
}
.form-grid .span-2 { grid-column: span 2; }
@media (max-width: 500px) {
.form-grid { grid-template-columns: 1fr; }
.form-grid .span-2 { grid-column: span 1; }
}
/* ── Modal ───────────────────────────────────────────────────────────────── */
.modal-backdrop {
position: fixed;
inset: 0;
z-index: 50;
display: flex;
align-items: center;
justify-content: center;
padding: 1rem;
background: rgba(0, 0, 0, 0);
backdrop-filter: blur(0px);
visibility: hidden;
opacity: 0;
transition:
background 0.22s ease,
backdrop-filter 0.22s ease,
opacity 0.22s ease,
visibility 0s 0.22s;
}
.modal-backdrop.is-open {
background: rgba(0,0,0,0.72);
backdrop-filter: blur(5px);
visibility: visible;
opacity: 1;
transition:
background 0.22s ease,
backdrop-filter 0.22s ease,
opacity 0.22s ease,
visibility 0s 0s;
}
.modal-box {
width: 100%;
max-width: 36rem;
background: var(--surface);
border: 1px solid var(--border-2);
border-radius: var(--radius-xl);
box-shadow: var(--shadow-xl);
display: flex;
flex-direction: column;
max-height: 90vh;
overflow-y: auto;
transform: scale(0.94) translateY(14px);
opacity: 0;
transition: transform 0.28s cubic-bezier(0.32,0.72,0,1), opacity 0.22s ease;
}
.modal-backdrop.is-open .modal-box {
transform: scale(1) translateY(0);
opacity: 1;
}
.modal-box.compact { max-width: 26rem; }
.modal-head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1.25rem 1.5rem 1.125rem;
border-bottom: 1px solid var(--border);
flex-shrink: 0;
}
.modal-title {
font-family: var(--font-display);
font-weight: 700;
font-size: 1.125rem;
letter-spacing: 0.01em;
color: var(--text);
}
.modal-body { padding: 1.25rem 1.5rem; }
.modal-foot {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 0.625rem;
padding: 1rem 1.5rem;
border-top: 1px solid var(--border);
flex-shrink: 0;
}
.modal-close {
width: 28px;
height: 28px;
display: flex;
align-items: center;
justify-content: center;
border-radius: var(--radius);
background: transparent;
border: none;
cursor: pointer;
color: var(--text-muted);
transition: background 0.12s, color 0.12s;
padding: 0;
}
.modal-close:hover { background: var(--surface-2); color: var(--text); }
/* ── Alert ───────────────────────────────────────────────────────────────── */
.alert {
display: flex;
align-items: center;
gap: 0.625rem;
padding: 0.75rem 1rem;
border-radius: var(--radius-lg);
font-size: 0.875rem;
margin-bottom: 1.25rem;
animation: fadeUp 0.3s ease-out both;
}
.alert-error {
background: var(--red-dim);
border: 1px solid rgba(248,113,113,0.25);
color: var(--red);
}
.alert-success {
background: var(--green-dim);
border: 1px solid rgba(34,197,94,0.25);
color: var(--green);
}
/* ── Settings ────────────────────────────────────────────────────────────── */
.settings-card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-xl);
padding: 2rem 2rem 1.75rem;
animation: fadeUp 0.4s ease-out both;
}
.settings-form > * + * { margin-top: 1.25rem; }
.info-box {
padding: 1rem 1.125rem;
border-radius: var(--radius-lg);
background: var(--blue-dim);
border: 1px solid rgba(96,165,250,0.2);
font-size: 0.8125rem;
color: var(--blue);
animation: fadeUp 0.4s 0.1s ease-out both;
}
html:not(.dark) .info-box {
background: rgba(37,99,235,0.06);
border-color: rgba(37,99,235,0.18);
color: var(--blue);
}
.info-box strong {
display: block;
font-weight: 600;
margin-bottom: 0.3rem;
font-size: 0.875rem;
}
/* ── Footer ──────────────────────────────────────────────────────────────── */
.app-footer {
position: relative;
text-align: center;
padding: 1.5rem;
font-size: 0.75rem;
color: var(--text-muted);
border-top: 1px solid var(--border);
}
/* ── Animations ──────────────────────────────────────────────────────────── */
@keyframes fadeUp {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
}
/* ── Scrollbar (webkit) ──────────────────────────────────────────────────── */
::-webkit-scrollbar { width: 6px; height: 6px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: var(--border-2); border-radius: 3px; }
::-webkit-scrollbar-thumb:hover { background: var(--text-muted); }
/* ── Selection ───────────────────────────────────────────────────────────── */
::selection { background: var(--accent-dim); color: var(--text); }
+231
View File
@@ -0,0 +1,231 @@
/* ============================================
Bewerbungs-Tracker - Custom Styles
============================================ */
/* Base styles */
* {
box-sizing: border-box;
}
/* Smooth scrolling */
html {
scroll-behavior: smooth;
}
/* Custom scrollbar for dark mode */
.dark ::-webkit-scrollbar {
width: 8px;
height: 8px;
}
.dark ::-webkit-scrollbar-track {
background: #374151;
}
.dark ::-webkit-scrollbar-thumb {
background: #6b7280;
border-radius: 4px;
}
.dark ::-webkit-scrollbar-thumb:hover {
background: #8b94a3;
}
/* Light mode scrollbar */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: #f1f1f1;
}
::-webkit-scrollbar-thumb {
background: #c1c1c1;
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: #a1a1a1;
}
/* Modal backdrop animation */
.fixed.inset-0.bg-black.bg-opacity-50 {
animation: fadeIn 0.2s ease-out;
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
/* Modal content animation */
.fixed.inset-0 > div > div {
animation: slideIn 0.3s ease-out;
}
@keyframes slideIn {
from {
opacity: 0;
transform: translateY(-20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* Table hover effect */
.tbody tr:hover {
transition: background-color 0.15s ease;
}
/* Focus styles for inputs */
input:focus,
textarea:focus,
select:focus {
outline: none;
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.5);
}
/* Dark mode focus */
.dark input:focus,
.dark textarea:focus,
.dark select:focus {
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.3);
}
/* Button focus */
button:focus {
outline: none;
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.5);
}
/* Loading spinner for buttons */
button.loading {
position: relative;
pointer-events: none;
}
button.loading::after {
content: '';
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 16px;
height: 16px;
border: 2px solid transparent;
border-top-color: currentColor;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to {
transform: translate(-50%, -50%) rotate(360deg);
}
}
/* Status badges */
.status-badge {
display: inline-block;
padding: 4px 8px;
border-radius: 9999px;
font-size: 12px;
font-weight: 500;
}
/* Table cell wrapping */
.table-cell-wrap {
white-space: normal;
word-wrap: break-word;
}
/* Responsive adjustments */
@media (max-width: 768px) {
/* Make filter section stack on mobile */
#filterForm {
flex-direction: column;
align-items: stretch;
}
#filterForm > div {
margin-bottom: 1rem;
}
/* Adjust buttons on mobile */
#filterForm .flex.space-x-2,
#filterForm .ml-auto,
#filterForm > div:last-child {
margin-bottom: 0 !important;
}
/* Make table scroll horizontally on mobile */
.overflow-hidden {
overflow-x: auto;
}
table {
min-width: 768px;
}
/* Adjust modal width on mobile */
.max-w-lg {
max-width: 90vw;
margin: 0 5vw;
}
.max-w-md {
max-width: 90vw;
margin: 0 5vw;
}
}
/* Print styles */
@media print {
.no-print {
display: none !important;
}
body {
background: white;
color: black;
}
.dark body {
background: white;
color: black;
}
}
/* Header shadow */
header {
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
}
/* Card shadows */
.bg-white,
.bg-gray-800 {
box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px -1px rgba(0, 0, 0, 0.1);
}
/* Utility classes */
.truncate-2 {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.truncate-3 {
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
+598 -241
View File
@@ -1,267 +1,624 @@
'use strict';
// ============================================
// Bewerbungs-Tracker - Client-side JavaScript
// ============================================
// ── Dark mode toggle ──────────────────────────────────────────────────────────
// DOM Elements
const body = document.getElementById('body');
const darkModeToggle = document.getElementById('darkModeToggle');
const sunIcon = document.getElementById('sunIcon');
const moonIcon = document.getElementById('moonIcon');
document.getElementById('darkModeToggle').addEventListener('click', () => {
const isDark = document.documentElement.classList.toggle('dark');
localStorage.setItem('darkMode', isDark ? 'true' : 'false');
});
// Modal Elements
const settingsModal = document.getElementById('settingsModal');
const applicationModal = document.getElementById('applicationModal');
const deleteModal = document.getElementById('deleteModal');
const pdfExportModal = document.getElementById('pdfExportModal');
// ── Stat counter animation ────────────────────────────────────────────────────
// Form Elements
const settingsForm = document.getElementById('settingsForm');
const applicationForm = document.getElementById('applicationForm');
const pdfExportForm = document.getElementById('pdfExportForm');
const filterForm = document.getElementById('filterForm');
(function animateStats() {
const DURATION = 700;
// Button Elements
const settingsBtn = document.getElementById('settingsBtn');
const addApplicationBtn = document.getElementById('addApplicationBtn');
const exportPdfBtn = document.getElementById('exportPdfBtn');
document.querySelectorAll('.stat-value[data-count]').forEach((el, i) => {
const target = parseInt(el.dataset.count, 10) || 0;
if (target === 0) return;
// Close Modal Buttons
const closeSettingsModal = document.getElementById('closeSettingsModal');
const closeApplicationModal = document.getElementById('closeApplicationModal');
const closeDeleteModal = document.getElementById('closeDeleteModal');
const closePdfModal = document.getElementById('closePdfModal');
const delay = 80 + i * 60;
setTimeout(() => {
const start = performance.now();
const tick = (now) => {
const p = Math.min((now - start) / DURATION, 1);
// ease-out expo
const eased = p === 1 ? 1 : 1 - Math.pow(2, -10 * p);
el.textContent = Math.round(eased * target);
if (p < 1) requestAnimationFrame(tick);
const cancelSettings = document.getElementById('cancelSettings');
const cancelApplication = document.getElementById('cancelApplication');
const cancelDelete = document.getElementById('cancelDelete');
const cancelPdfExport = document.getElementById('cancelPdfExport');
const confirmDelete = document.getElementById('confirmDelete');
// Global variables
let currentApplicationId = null;
let currentDeleteId = null;
let pdfLibrariesLoaded = false;
// ============================================
// Dark Mode
// ============================================
function initializeDarkMode() {
// Check localStorage for dark mode preference
const darkMode = localStorage.getItem('darkMode');
if (darkMode === 'enabled' || (!darkMode && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
body.classList.add('dark');
sunIcon.classList.add('hidden');
moonIcon.classList.remove('hidden');
} else {
body.classList.remove('dark');
sunIcon.classList.remove('hidden');
moonIcon.classList.add('hidden');
}
}
function toggleDarkMode() {
body.classList.toggle('dark');
sunIcon.classList.toggle('hidden');
moonIcon.classList.toggle('hidden');
// Save preference to localStorage
if (body.classList.contains('dark')) {
localStorage.setItem('darkMode', 'enabled');
} else {
localStorage.setItem('darkMode', 'disabled');
}
}
// ============================================
// Modal Functions
// ============================================
function showModal(modal) {
modal.classList.remove('hidden');
body.style.overflow = 'hidden';
}
function hideModal(modal) {
modal.classList.add('hidden');
body.style.overflow = '';
}
function resetApplicationForm() {
applicationForm.reset();
document.getElementById('modalTitle').textContent = 'Bewerbung hinzufügen';
currentApplicationId = null;
}
// ============================================
// Settings Management
// ============================================
function loadSettings() {
fetch('/api/settings')
.then(response => response.json())
.then(settings => {
if (settings) {
document.getElementById('userName').value = settings.name || '';
document.getElementById('userAddress').value = settings.adresse || '';
document.getElementById('customerNumber').value = settings.kundennummer || '';
}
})
.catch(error => console.error('Error loading settings:', error));
}
function saveSettings(event) {
event.preventDefault();
const formData = new FormData(settingsForm);
const settings = {
name: formData.get('name'),
adresse: formData.get('adresse'),
kundennummer: formData.get('kundennummer')
};
requestAnimationFrame(tick);
}, delay);
fetch('/api/settings', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(settings)
})
.then(response => response.json())
.then(data => {
if (data.success) {
hideModal(settingsModal);
// Refresh page to update settings in header
location.reload();
}
})
.catch(error => console.error('Error saving settings:', error));
}
// ============================================
// Application Management (CRUD)
// ============================================
function openAddApplicationModal() {
resetApplicationForm();
// Set default date to today
const today = new Date().toISOString().split('T')[0];
document.getElementById('applicationDatum').value = today;
showModal(applicationModal);
}
function openEditApplicationModal(id) {
// Fetch application data
fetch(`/api/bewerbungen/${id}`)
.then(response => response.json())
.then(application => {
currentApplicationId = application.id;
document.getElementById('modalTitle').textContent = 'Bewerbung bearbeiten';
document.getElementById('applicationId').value = application.id;
document.getElementById('applicationDatum').value = application.datum;
document.getElementById('applicationFirma').value = application.firma;
document.getElementById('applicationStelle').value = application.stelle;
document.getElementById('applicationArt').value = application.art || '';
document.getElementById('applicationStatus').value = application.status || '';
document.getElementById('applicationNotizen').value = application.notizen || '';
showModal(applicationModal);
})
.catch(error => console.error('Error loading application:', error));
}
function saveApplication(event) {
event.preventDefault();
const formData = new FormData(applicationForm);
const application = {
datum: formData.get('datum'),
firma: formData.get('firma'),
stelle: formData.get('stelle'),
art: formData.get('art'),
status: formData.get('status'),
notizen: formData.get('notizen'),
interne_notizen: formData.get('interne_notizen'),
kommentar: formData.get('kommentar')
};
let url = '/api/bewerbungen';
let method = 'POST';
if (currentApplicationId) {
url = `/api/bewerbungen/${currentApplicationId}`;
method = 'PUT';
}
fetch(url, {
method: method,
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(application)
})
.then(response => response.json())
.then(data => {
if (data.success) {
hideModal(applicationModal);
resetApplicationForm();
// Refresh the page to show updated data
location.reload();
}
})
.catch(error => console.error('Error saving application:', error));
}
function openDeleteModal(id) {
currentDeleteId = id;
showModal(deleteModal);
}
function deleteApplication() {
if (!currentDeleteId) return;
fetch(`/api/bewerbungen/${currentDeleteId}`, {
method: 'DELETE'
})
.then(response => response.json())
.then(data => {
if (data.success) {
hideModal(deleteModal);
currentDeleteId = null;
// Refresh the page
location.reload();
}
})
.catch(error => console.error('Error deleting application:', error));
}
// ============================================
// PDF Export
// ============================================
function openPdfExportModal() {
// Load PDF libraries if not already loaded
loadPdfLibraries().then(() => {
showModal(pdfExportModal);
});
})();
// ── Load bewerbungen from embedded JSON ───────────────────────────────────────
let BEWERBUNGEN = [];
const dataEl = document.getElementById('bewerbungenData');
if (dataEl) {
try { BEWERBUNGEN = JSON.parse(dataEl.textContent); } catch (_) {}
}
// ── Modal system ──────────────────────────────────────────────────────────────
function generatePDF(event) {
event.preventDefault();
function openModal(id) {
const el = document.getElementById(id);
el.classList.add('is-open');
document.body.style.overflow = 'hidden';
setTimeout(() => {
const first = el.querySelector(
'input[type="date"], input[type="text"]:not([type="hidden"]), select, textarea'
);
if (first) first.focus();
}, 60);
const month = document.getElementById('pdfMonth').value;
const year = document.getElementById('pdfYear').value;
// Fetch data for PDF
let url = '/api/export?';
const params = [];
if (month) params.push(`month=${month}`);
if (year) params.push(`year=${year}`);
if (params.length > 0) {
url += params.join('&') + '&';
}
Promise.all([
fetch('/api/settings').then(res => res.json()),
fetch(url).then(res => res.json())
])
.then(([settings, applications]) => {
generatePdfDocument(settings, applications, month, year);
hideModal(pdfExportModal);
})
.catch(error => console.error('Error generating PDF:', error));
}
function closeModal(id) {
const el = document.getElementById(id);
el.classList.remove('is-open');
document.body.style.overflow = '';
}
// PDF Generation with jsPDF
function generatePdfDocument(settings, applications, month, year) {
// Check if jsPDF is loaded
if (typeof jsPDF === 'undefined') {
console.error('jsPDF not loaded, please wait for libraries to load');
alert('Bitte warten Sie einen Moment und versuchen Sie es erneut.');
return;
}
function closeOnBackdrop(event, id) {
if (event.target === event.currentTarget) closeModal(id);
}
document.addEventListener('keydown', (e) => {
if (e.key !== 'Escape') return;
['bewerbungModal', 'deleteModal'].forEach(id => {
const el = document.getElementById(id);
if (el && el.classList.contains('is-open')) closeModal(id);
const doc = new jsPDF({
orientation: 'portrait',
unit: 'mm',
format: 'a4'
});
const monthNames = ['Januar', 'Februar', 'März', 'April', 'Mai', 'Juni',
'Juli', 'August', 'September', 'Oktober', 'November', 'Dezember'];
const monthName = month ? monthNames[parseInt(month) - 1] : 'alle';
const title = `Bewerbungsaktivitäten - ${monthName} ${year || 'Jahre'}`;
const dateStr = new Date().toLocaleDateString('de-DE');
// Page geometry
const pageWidth = doc.internal.pageSize.getWidth(); // 210 mm
const pageHeight = doc.internal.pageSize.getHeight(); // 297 mm
const margin = 15;
const contentWidth = pageWidth - margin * 2; // 180 mm
const bottomLimit = pageHeight - margin;
let yPos = 20;
// Start a new page if the next block would not fit
function ensureSpace(needed) {
if (yPos + needed > bottomLimit) {
doc.addPage();
yPos = margin;
}
}
// Header with user data
doc.setFont('helvetica', 'bold');
doc.setFontSize(16);
doc.text(title, pageWidth / 2, yPos, { align: 'center' });
yPos += 12;
// User information
doc.setFontSize(12);
doc.setFont('helvetica', 'normal');
if (settings && settings.name) {
doc.text(`Name: ${settings.name}`, margin, yPos);
yPos += 7;
}
if (settings && settings.adresse) {
doc.text(`Adresse: ${settings.adresse}`, margin, yPos);
yPos += 7;
}
if (settings && settings.kundennummer) {
doc.text(`Kundennummer: ${settings.kundennummer}`, margin, yPos);
yPos += 7;
}
yPos += 5;
// Summary text
const totalApplications = applications.length;
const summaryText = year
? `Im Monat ${monthName} ${year} habe ich mich insgesamt auf ${totalApplications} Stellen beworben.`
: `Insgesamt habe ich mich auf ${totalApplications} Stellen beworben.`;
doc.setFont('helvetica', 'bold');
doc.text(summaryText, pageWidth / 2, yPos, { align: 'center' });
yPos += 12;
// Group entries by status (keep this order in sync with views/index.ejs)
const statusOrder = ['Gesendet', 'Eingangsbestätigung', 'Vorstellungsgespräch',
'Einstellung', 'Absage', 'Keine Rückmeldung'];
const groups = {};
applications.forEach((app) => {
const key = (app.status && app.status.trim()) ? app.status.trim() : 'Ohne Status';
(groups[key] = groups[key] || []).push(app);
});
const orderedKeys = [
...statusOrder.filter((s) => groups[s]),
...Object.keys(groups).filter((k) => !statusOrder.includes(k))
];
// One block per application — no table, so the note (which documents the
// full Verlauf) gets the entire page width and is shown completely.
orderedKeys.forEach((statusKey) => {
const group = groups[statusKey];
// Status group heading (dark bar), kept together with its first entry
ensureSpace(40);
doc.setFillColor(55, 65, 81);
doc.rect(margin, yPos, contentWidth, 10, 'F');
doc.setFont('helvetica', 'bold');
doc.setFontSize(12);
doc.setTextColor(255, 255, 255);
doc.text(`${statusKey} (${group.length})`, margin + 3, yPos + 6.8);
yPos += 14;
doc.setTextColor(0, 0, 0);
group.forEach((app) => {
const datum = new Date(app.datum).toLocaleDateString('de-DE');
const firma = app.firma || '—';
const stelle = app.stelle || '—';
const art = app.art || '—';
const notizen = (app.notizen || '').trim();
// Keep the heading together with the start of its content
ensureSpace(28);
// Heading bar: Firma Stelle (left), Datum (right)
doc.setFillColor(225, 232, 240);
doc.rect(margin, yPos, contentWidth, 9, 'F');
doc.setFont('helvetica', 'bold');
doc.setFontSize(11);
doc.setTextColor(20, 20, 20);
const heading = doc.splitTextToSize(`${firma} ${stelle}`, contentWidth - 40)[0];
doc.text(heading, margin + 2, yPos + 6);
doc.text(datum, pageWidth - margin - 2, yPos + 6, { align: 'right' });
yPos += 9;
// Meta line: Art
doc.setFont('helvetica', 'normal');
doc.setFontSize(9);
doc.setTextColor(90, 90, 90);
doc.text(`Art: ${art}`, margin + 2, yPos + 5);
yPos += 9;
// Status-Verlauf timeline (chronological status changes with comments)
const verlauf = Array.isArray(app.verlauf) ? app.verlauf : [];
if (verlauf.length) {
doc.setFont('helvetica', 'bold');
doc.setFontSize(10);
doc.setTextColor(20, 20, 20);
ensureSpace(6);
doc.text('Status-Verlauf:', margin + 2, yPos + 4);
yPos += 6;
doc.setFontSize(9);
verlauf.forEach((v) => {
const vDatum = new Date(v.datum).toLocaleDateString('de-DE');
doc.setFont('helvetica', 'bold');
doc.setTextColor(50, 50, 50);
ensureSpace(5);
doc.text(`${vDatum}${v.status || ''}`, margin + 5, yPos + 4);
yPos += 5;
const kommentar = (v.kommentar || '').trim();
if (kommentar) {
doc.setFont('helvetica', 'normal');
doc.setTextColor(0, 0, 0);
kommentar.split(/\r?\n/).forEach((paragraph) => {
const lines = doc.splitTextToSize(paragraph.length ? paragraph : ' ', contentWidth - 12);
lines.forEach((line) => {
ensureSpace(4.5);
doc.text(line, margin + 9, yPos + 3.5);
yPos += 4.5;
});
});
}
});
yPos += 3;
}
// Notizen label
doc.setFont('helvetica', 'bold');
doc.setFontSize(10);
doc.setTextColor(20, 20, 20);
doc.text('Notizen:', margin + 2, yPos + 4);
yPos += 6;
// Notizen body — full width, complete, with page breaks line by line
doc.setFont('helvetica', 'normal');
doc.setFontSize(10);
doc.setTextColor(0, 0, 0);
const lineHeight = 5;
const noteText = notizen || '(keine Notizen)';
noteText.split(/\r?\n/).forEach((paragraph) => {
const lines = doc.splitTextToSize(paragraph.length ? paragraph : ' ', contentWidth - 4);
lines.forEach((line) => {
ensureSpace(lineHeight);
doc.text(line, margin + 2, yPos + 4);
yPos += lineHeight;
});
});
// Separator before the next entry
yPos += 4;
ensureSpace(6);
doc.setDrawColor(210, 210, 210);
doc.line(margin, yPos, pageWidth - margin, yPos);
yPos += 8;
});
// Extra spacing after a status group
yPos += 4;
});
// Footer with confirmation
ensureSpace(20);
yPos += 6;
doc.setFont('helvetica', 'italic');
doc.setFontSize(10);
doc.setTextColor(0, 0, 0);
doc.text('Ich versichere, dass die oben genannten Angaben der Wahrheit entsprechen.', pageWidth / 2, yPos, { align: 'center' });
yPos += 7;
doc.text(`Datum: ${dateStr}`, pageWidth / 2, yPos, { align: 'center' });
// Save the PDF
const fileName = `Bewerbungsaktivitaeten_${monthName}_${year || 'alle'}.pdf`;
doc.save(fileName);
}
// Load PDF libraries dynamically
function loadPdfLibraries() {
return new Promise((resolve) => {
if (pdfLibrariesLoaded) {
resolve();
return;
}
// Check if already loading
if (document.getElementById('jspdf-script')) {
const checkLoaded = setInterval(() => {
if (typeof jsPDF !== 'undefined') {
clearInterval(checkLoaded);
pdfLibrariesLoaded = true;
resolve();
}
}, 100);
return;
}
// Load jsPDF from CDN
const script1 = document.createElement('script');
script1.id = 'jspdf-script';
script1.src = 'https://cdnjs.cloudflare.com/ajax/libs/jspdf/2.5.1/jspdf.umd.min.js';
script1.onload = () => {
const script2 = document.createElement('script');
script2.src = 'https://cdnjs.cloudflare.com/ajax/libs/jspdf-autotable/3.8.2/jspdf.plugin.autotable.min.js';
script2.onload = () => {
pdfLibrariesLoaded = true;
resolve();
};
document.head.appendChild(script2);
};
document.head.appendChild(script1);
});
}
// ============================================
// Event Listeners
// ============================================
// Dark Mode Toggle
darkModeToggle.addEventListener('click', toggleDarkMode);
// Settings Modal
settingsBtn.addEventListener('click', () => {
loadSettings();
showModal(settingsModal);
});
closeSettingsModal.addEventListener('click', () => hideModal(settingsModal));
cancelSettings.addEventListener('click', () => hideModal(settingsModal));
settingsForm.addEventListener('submit', saveSettings);
// Application Modal
addApplicationBtn.addEventListener('click', openAddApplicationModal);
closeApplicationModal.addEventListener('click', () => {
hideModal(applicationModal);
resetApplicationForm();
});
cancelApplication.addEventListener('click', () => {
hideModal(applicationModal);
resetApplicationForm();
});
applicationForm.addEventListener('submit', saveApplication);
// Delete Modal
closeDeleteModal.addEventListener('click', () => hideModal(deleteModal));
cancelDelete.addEventListener('click', () => hideModal(deleteModal));
confirmDelete.addEventListener('click', deleteApplication);
// PDF Export Modal
exportPdfBtn.addEventListener('click', openPdfExportModal);
closePdfModal.addEventListener('click', () => hideModal(pdfExportModal));
cancelPdfExport.addEventListener('click', () => hideModal(pdfExportModal));
pdfExportForm.addEventListener('submit', generatePDF);
// Filter Form
document.getElementById('filterForm').addEventListener('submit', function(e) {
e.preventDefault();
const month = document.getElementById('filterMonth').value;
const year = document.getElementById('filterYear').value;
let url = '/';
if (month || year) {
url += '?';
if (month) url += `month=${month}&`;
if (year) url += `year=${year}&`;
}
window.location.href = url;
});
// ── Add modal ─────────────────────────────────────────────────────────────────
function openAddModal() {
const form = document.getElementById('bewerbungForm');
form.reset();
form.action = '/bewerbungen';
document.getElementById('modalTitle').textContent = 'Neue Bewerbung';
document.getElementById('datum').value = todayISO();
openModal('bewerbungModal');
}
// ── Edit modal ────────────────────────────────────────────────────────────────
function openEditModal(id) {
const b = BEWERBUNGEN.find(x => x.id === id);
if (!b) return;
const form = document.getElementById('bewerbungForm');
form.action = `/bewerbungen/${id}?_method=PUT`;
document.getElementById('modalTitle').textContent = 'Bewerbung bearbeiten';
document.getElementById('datum').value = b.datum || '';
document.getElementById('firma').value = b.firma || '';
document.getElementById('stelle').value = b.stelle || '';
document.getElementById('art').value = b.art || '';
document.getElementById('status').value = b.status || '';
document.getElementById('notizen').value = b.notizen || '';
openModal('bewerbungModal');
}
// ── Delete modal ──────────────────────────────────────────────────────────────
function openDeleteModal(id, firma, stelle) {
document.getElementById('deleteInfo').textContent = `${firma} ${stelle}`;
document.getElementById('deleteForm').action = `/bewerbungen/${id}?_method=DELETE`;
openModal('deleteModal');
}
// ── PDF generation ────────────────────────────────────────────────────────────
async function generatePDF(monat, jahr) {
const btn = event.currentTarget;
const saved = btn.innerHTML;
btn.disabled = true;
btn.innerHTML = '<span style="opacity:.7">Wird erstellt …</span>';
try {
const res = await fetch(`/api/pdf-daten?monat=${monat}&jahr=${jahr}`);
if (!res.ok) throw new Error(`Server ${res.status}`);
buildPDF(await res.json(), monat, jahr);
} catch (err) {
alert('PDF-Erstellung fehlgeschlagen:\n' + err.message);
} finally {
btn.disabled = false;
btn.innerHTML = saved;
// Delete buttons (event delegation). Editing opens its own page (/bewerbung/:id).
document.addEventListener('click', (e) => {
if (e.target.closest('.delete-btn')) {
const id = e.target.closest('.delete-btn').dataset.id;
openDeleteModal(id);
}
}
});
function buildPDF({ bewerbungen, settings }, monat, jahr) {
const { jsPDF } = window.jspdf;
const doc = new jsPDF({ orientation: 'portrait', unit: 'mm', format: 'a4' });
const PW = doc.internal.pageSize.getWidth(); // 210 mm
const PH = doc.internal.pageSize.getHeight(); // 297 mm
const monatName = MONATE_DE[monat - 1];
const count = bewerbungen.length;
// Accent stripe
doc.setFillColor(224, 123, 0);
doc.rect(0, 0, PW, 2.5, 'F');
// ── User info (top right) ──────────────────────────────────────────────────
doc.setFontSize(9);
doc.setTextColor(120, 120, 120);
let uy = 10;
const ux = PW - 12;
if (settings?.name) {
doc.setFont('helvetica', 'bold');
doc.text(settings.name, ux, uy, { align: 'right' });
uy += 5;
doc.setFont('helvetica', 'normal');
// Close modals when clicking outside
document.addEventListener('click', (e) => {
if (e.target === settingsModal) hideModal(settingsModal);
if (e.target === applicationModal) {
hideModal(applicationModal);
resetApplicationForm();
}
if (settings?.adresse) {
settings.adresse.split('\n').forEach(line => {
doc.text(line.trim(), ux, uy, { align: 'right' });
uy += 4.5;
});
if (e.target === deleteModal) hideModal(deleteModal);
if (e.target === pdfExportModal) hideModal(pdfExportModal);
});
// Close modals with Escape key
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
if (!settingsModal.classList.contains('hidden')) hideModal(settingsModal);
if (!applicationModal.classList.contains('hidden')) {
hideModal(applicationModal);
resetApplicationForm();
}
if (settings?.kundennummer) {
uy += 1;
doc.text(`Kundennr.: ${settings.kundennummer}`, ux, uy, { align: 'right' });
if (!deleteModal.classList.contains('hidden')) hideModal(deleteModal);
if (!pdfExportModal.classList.contains('hidden')) hideModal(pdfExportModal);
}
});
// ── Title ──────────────────────────────────────────────────────────────────
doc.setFontSize(17);
doc.setFont('helvetica', 'bold');
doc.setTextColor(24, 32, 47);
const titleText = `Bewerbungsaktivitäten ${monatName} ${jahr}`;
doc.text(titleText, 12, 14);
// Set current year in footer
document.getElementById('currentYear').textContent = new Date().getFullYear();
// Divider — only under the title, never touching the address block on the right
const titleWidth = doc.getTextWidth(titleText);
doc.setDrawColor(220, 226, 237);
doc.setLineWidth(0.35);
doc.line(12, 18.5, 12 + titleWidth + 6, 18.5);
// ============================================
// Initialize
// ============================================
// ── Summary ────────────────────────────────────────────────────────────────
doc.setFontSize(10);
doc.setFont('helvetica', 'normal');
doc.setTextColor(74, 85, 107);
const pl = count !== 1 ? 'n' : '';
doc.text(
`Im Monat ${monatName} ${jahr} habe ich mich insgesamt auf ${count} Stelle${pl} beworben.`,
12, 25
);
// Initialize dark mode
initializeDarkMode();
// ── Table ──────────────────────────────────────────────────────────────────
doc.autoTable({
startY: 31,
head: [['Datum', 'Firma / Unternehmen', 'Stelle / Position', 'Art', 'Status', 'Notizen']],
body: bewerbungen.map(b => [
formatDateDE(b.datum),
b.firma || '',
b.stelle || '',
b.art || '',
b.status || '',
b.notizen ? b.notizen.slice(0, 120) : ''
]),
margin: { left: 12, right: 12 },
styles: {
fontSize: 7,
cellPadding: 2.8,
textColor: [24, 32, 47],
lineColor: [221, 226, 239],
lineWidth: 0.25,
font: 'helvetica'
},
headStyles: {
fillColor: [224, 123, 0],
textColor: [255, 255, 255],
fontStyle: 'bold',
halign: 'left',
fontSize: 7
},
alternateRowStyles: { fillColor: [248, 250, 254] },
columnStyles: {
0: { cellWidth: 26, overflow: 'hidden' },
1: { cellWidth: 38 },
2: { cellWidth: 38 },
3: { cellWidth: 28 },
4: { cellWidth: 32 },
5: { cellWidth: 'auto' }
},
didDrawPage: ({ pageNumber }) => {
doc.setFontSize(7.5);
doc.setTextColor(160, 170, 185);
doc.text(`Seite ${pageNumber}`, PW / 2, PH - 4.5, { align: 'center' });
}
});
// ── Declaration line ───────────────────────────────────────────────────────
const finalY = doc.lastAutoTable.finalY;
const declY = finalY + 10;
doc.setFontSize(8.5);
doc.setTextColor(100, 110, 130);
doc.setFont('helvetica', 'italic');
doc.text(
'Ich erkläre hiermit, dass die vorstehenden Angaben vollständig und wahrheitsgemäß sind.',
12, declY
);
doc.save(`Bewerbungen_${monatName}_${jahr}.pdf`);
}
// ── Utilities ─────────────────────────────────────────────────────────────────
function todayISO() {
const d = new Date();
return [
d.getFullYear(),
String(d.getMonth() + 1).padStart(2, '0'),
String(d.getDate()).padStart(2, '0')
].join('-');
}
function formatDateDE(s) {
if (!s) return '';
const p = s.split('-');
return p.length === 3 ? `${p[2]}.${p[1]}.${p[0]}` : s;
}
console.log('Bewerbungs-Tracker initialized');
+446 -183
View File
@@ -1,23 +1,109 @@
'use strict';
const express = require('express');
const sqlite3 = require('sqlite3').verbose();
const path = require('path');
const fs = require('fs');
const methodOverride = require('method-override');
const Database = require('better-sqlite3');
const app = express();
const PORT = process.env.PORT || 3000;
// ── Database bootstrap ────────────────────────────────────────────────────────
// Shared option lists (used in multiple views)
const ART_OPTIONS = [
'E-Mail', 'Online-Portal', 'Indeed', 'StepStone',
'Firmenwebsite', 'Post', 'Initiativbewerbung',
'Arbeitsagentur', 'Sonstiges'
];
const STATUS_OPTIONS = [
'Gesendet', 'Eingangsbestätigung', 'Vorstellungsgespräch',
'Absage', 'Einstellung', 'Keine Rückmeldung'
];
// Middleware
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
app.use(express.static(path.join(__dirname, 'public')));
// Set EJS as template engine
app.set('view engine', 'ejs');
app.set('views', path.join(__dirname, 'views'));
// Ensure data directory exists
const dataDir = path.join(__dirname, 'data');
if (!fs.existsSync(dataDir)) fs.mkdirSync(dataDir, { recursive: true });
if (!fs.existsSync(dataDir)) {
fs.mkdirSync(dataDir, { recursive: true });
}
const db = new Database(path.join(dataDir, 'bewerbungen.db'));
db.pragma('journal_mode = WAL');
// Database setup
const dbPath = path.join(dataDir, 'bewerbungen.db');
const db = new sqlite3.Database(dbPath);
db.exec(`
// Sanitize input to prevent XSS
function sanitizeInput(input) {
if (typeof input !== 'string') return input;
return input
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
// Promise wrapper for db operations
function dbGet(sql, params = []) {
return new Promise((resolve, reject) => {
db.get(sql, params, (err, result) => {
if (err) reject(err);
else resolve(result);
});
});
}
function dbAll(sql, params = []) {
return new Promise((resolve, reject) => {
db.all(sql, params, (err, results) => {
if (err) reject(err);
else resolve(results);
});
});
}
function dbRun(sql, params = []) {
return new Promise((resolve, reject) => {
db.run(sql, params, function(err) {
if (err) reject(err);
else resolve({ lastID: this.lastID, changes: this.changes });
});
});
}
// Recompute an application's current status from its latest timeline entry
async function syncCurrentStatus(bewerbungId) {
const latest = await dbGet(
'SELECT status FROM status_verlauf WHERE bewerbung_id = ? ORDER BY date(datum) DESC, id DESC LIMIT 1',
[bewerbungId]
);
await dbRun(
'UPDATE bewerbungen SET status = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?',
[latest ? latest.status : '', bewerbungId]
);
}
// Attach the status timeline to each application (single query, grouped in JS)
async function attachVerlauf(applications) {
if (!applications.length) return applications;
const all = await dbAll('SELECT * FROM status_verlauf ORDER BY date(datum) ASC, id ASC');
const byApp = {};
all.forEach((v) => { (byApp[v.bewerbung_id] = byApp[v.bewerbung_id] || []).push(v); });
applications.forEach((a) => { a.verlauf = byApp[a.id] || []; });
return applications;
}
// Initialize database - create tables and default settings in one operation
function initializeDatabase() {
return new Promise((resolve, reject) => {
db.serialize(() => {
db.run('PRAGMA foreign_keys = ON');
// Create tables
db.run(`
CREATE TABLE IF NOT EXISTS bewerbungen (
id INTEGER PRIMARY KEY AUTOINCREMENT,
datum DATE NOT NULL,
@@ -26,209 +112,386 @@ db.exec(`
art TEXT,
status TEXT,
notizen TEXT,
interne_notizen TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
)
`, (err) => {
if (err) return reject(err);
// Migration: add interne_notizen to pre-existing databases (ignore "duplicate column")
db.run('ALTER TABLE bewerbungen ADD COLUMN interne_notizen TEXT', () => {
// Chronological status changes, each with an optional comment
db.run(`
CREATE TABLE IF NOT EXISTS status_verlauf (
id INTEGER PRIMARY KEY AUTOINCREMENT,
bewerbung_id INTEGER NOT NULL,
datum DATE NOT NULL,
status TEXT NOT NULL,
kommentar TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (bewerbung_id) REFERENCES bewerbungen(id) ON DELETE CASCADE
)
`, (err) => {
if (err) return reject(err);
db.run(`
CREATE TABLE IF NOT EXISTS settings (
id INTEGER PRIMARY KEY CHECK (id = 1),
name TEXT,
adresse TEXT,
kundennummer TEXT
)
`, (err) => {
if (err) return reject(err);
// Insert default settings if not exists
db.get('SELECT COUNT(*) as count FROM settings WHERE id = 1', (err, result) => {
if (err) return reject(err);
if (result && result.count === 0) {
db.run(
'INSERT INTO settings (id, name, adresse, kundennummer) VALUES (1, ?, ?, ?)',
['Max Mustermann', 'Musterstraße 1, 12345 Musterstadt', ''],
(err) => {
if (err) return reject(err);
resolve();
}
);
INSERT OR IGNORE INTO settings (id) VALUES (1);
`);
// ── Constants ─────────────────────────────────────────────────────────────────
const ART_OPTIONEN = [
'E-Mail', 'Online-Portal', 'Indeed', 'StepStone', 'Firmenwebsite',
'Post', 'Initiativbewerbung', 'Arbeitsagentur', 'Sonstiges'
];
const STATUS_OPTIONEN = [
'Gesendet', 'Eingangsbestätigung', 'Vorstellungsgespräch',
'Absage', 'Einstellung', 'Keine Rückmeldung'
];
const MONATE = [
'Januar', 'Februar', 'März', 'April', 'Mai', 'Juni',
'Juli', 'August', 'September', 'Oktober', 'November', 'Dezember'
];
// ── Helpers ───────────────────────────────────────────────────────────────────
function sanitize(val, maxLen = 2000) {
if (val === null || val === undefined) return '';
return String(val).trim().slice(0, maxLen);
} else {
resolve();
}
});
});
});
});
});
});
});
}
function safeJson(obj) {
return JSON.stringify(obj)
.replace(/</g, '\\u003c')
.replace(/>/g, '\\u003e')
.replace(/&/g, '\\u0026');
}
// Initialize and start server
initializeDatabase().then(() => {
console.log('Database initialized successfully');
function getSettings() {
return db.prepare('SELECT * FROM settings WHERE id = 1').get() || {};
}
// Routes
app.get('/', async (req, res) => {
try {
const { month, year } = req.query;
// ── Middleware ────────────────────────────────────────────────────────────────
let query = 'SELECT * FROM bewerbungen ORDER BY datum DESC, created_at DESC';
const params = [];
app.use(express.urlencoded({ extended: true }));
app.use(express.json());
app.use(methodOverride('_method'));
app.use(express.static(path.join(__dirname, 'public')));
app.set('view engine', 'ejs');
app.set('views', path.join(__dirname, 'views'));
// ── Routes ────────────────────────────────────────────────────────────────────
// GET / Übersicht
app.get('/', (req, res) => {
const now = new Date();
const monat = Math.min(12, Math.max(1, parseInt(req.query.monat) || (now.getMonth() + 1)));
const jahr = parseInt(req.query.jahr) || now.getFullYear();
const monatStr = String(monat).padStart(2, '0');
const vonDatum = `${jahr}-${monatStr}-01`;
const bisDatum = `${jahr}-${monatStr}-31`;
const bewerbungen = db.prepare(`
SELECT * FROM bewerbungen
WHERE datum BETWEEN ? AND ?
ORDER BY datum DESC, id DESC
`).all(vonDatum, bisDatum);
// Statistics
const stats = { gesamt: bewerbungen.length, positiv: 0, absagen: 0, ausstehend: 0 };
for (const b of bewerbungen) {
if (b.status === 'Vorstellungsgespräch' || b.status === 'Einstellung') stats.positiv++;
else if (b.status === 'Absage') stats.absagen++;
else stats.ausstehend++;
if (month && year) {
query = 'SELECT * FROM bewerbungen WHERE strftime("%m", datum) = ? AND strftime("%Y", datum) = ? ORDER BY datum DESC, created_at DESC';
params.push(month.padStart(2, '0'), year);
} else if (year) {
query = 'SELECT * FROM bewerbungen WHERE strftime("%Y", datum) = ? ORDER BY datum DESC, created_at DESC';
params.push(year);
}
// Years for filter dropdown
const dbJahre = db.prepare(
`SELECT DISTINCT strftime('%Y', datum) AS j FROM bewerbungen ORDER BY j DESC`
).all().map(r => parseInt(r.j)).filter(Boolean);
const jahre = [...new Set([...dbJahre, now.getFullYear()])].sort((a, b) => b - a);
const applications = await dbAll(query, params);
await attachVerlauf(applications);
const settings = await dbGet('SELECT * FROM settings WHERE id = 1');
// Get statistics
const totalCount = await dbGet('SELECT COUNT(*) as count FROM bewerbungen');
const byArt = await dbAll(`
SELECT art, COUNT(*) as count FROM bewerbungen
WHERE art IS NOT NULL AND art != ''
GROUP BY art ORDER BY count DESC
`);
const byStatus = await dbAll(`
SELECT status, COUNT(*) as count FROM bewerbungen
WHERE status IS NOT NULL AND status != ''
GROUP BY status ORDER BY count DESC
`);
// Get available months/years for filter
const availableMonths = await dbAll(`
SELECT DISTINCT strftime("%Y-%m", datum) as yearmonth,
strftime("%m", datum) as month,
strftime("%Y", datum) as year
FROM bewerbungen ORDER BY datum DESC
`);
res.render('index', {
bewerbungen,
bewerbungenJson: safeJson(bewerbungen),
stats,
monat,
jahr,
monate: MONATE,
jahre,
artOptionen: ART_OPTIONEN,
statusOptionen: STATUS_OPTIONEN,
settings: getSettings(),
monatName: MONATE[monat - 1],
fehler: req.query.fehler || null,
currentPage: 'uebersicht'
applications,
settings,
statistics: {
total: totalCount ? totalCount.count : 0,
byArt,
byStatus
},
availableMonths,
currentFilter: { month, year },
artOptions: ART_OPTIONS,
statusOptions: STATUS_OPTIONS
});
} catch (error) {
console.error('Error:', error);
res.status(500).send('Serverfehler');
}
});
});
// POST /bewerbungen Neu anlegen
app.post('/bewerbungen', (req, res) => {
// Get single application
app.get('/api/bewerbungen/:id', async (req, res) => {
try {
const { id } = req.params;
const application = await dbGet('SELECT * FROM bewerbungen WHERE id = ?', [id]);
if (!application) {
return res.status(404).json({ error: 'Bewerbung nicht gefunden' });
}
res.json(application);
} catch (error) {
console.error('Error getting application:', error);
res.status(500).json({ error: 'Serverfehler' });
}
});
// Get settings
app.get('/api/settings', async (req, res) => {
try {
const settings = await dbGet('SELECT * FROM settings WHERE id = 1');
res.json(settings);
} catch (error) {
console.error('Error getting settings:', error);
res.status(500).json({ error: 'Serverfehler' });
}
});
// Save settings
app.post('/api/settings', async (req, res) => {
try {
const { name, adresse, kundennummer } = req.body;
await dbRun(
'UPDATE settings SET name = ?, adresse = ?, kundennummer = ? WHERE id = 1',
[sanitizeInput(name), sanitizeInput(adresse), sanitizeInput(kundennummer)]
);
res.json({ success: true });
} catch (error) {
console.error('Error saving settings:', error);
res.status(500).json({ error: 'Serverfehler' });
}
});
// Create application
app.post('/api/bewerbungen', async (req, res) => {
try {
const { datum, firma, stelle, art, status, notizen, interne_notizen, kommentar } = req.body;
const result = await dbRun(
'INSERT INTO bewerbungen (datum, firma, stelle, art, status, notizen, interne_notizen) VALUES (?, ?, ?, ?, ?, ?, ?)',
[datum, sanitizeInput(firma), sanitizeInput(stelle),
sanitizeInput(art), sanitizeInput(status), sanitizeInput(notizen), sanitizeInput(interne_notizen)]
);
// Record the initial status as the first timeline entry
if (status && status.trim()) {
await dbRun(
'INSERT INTO status_verlauf (bewerbung_id, datum, status, kommentar) VALUES (?, ?, ?, ?)',
[result.lastID, datum, sanitizeInput(status), sanitizeInput(kommentar || '')]
);
}
const newApplication = await dbGet('SELECT * FROM bewerbungen WHERE id = ?', [result.lastID]);
res.json({ success: true, application: newApplication });
} catch (error) {
console.error('Error creating application:', error);
res.status(500).json({ error: 'Serverfehler' });
}
});
// Update application
app.put('/api/bewerbungen/:id', async (req, res) => {
try {
const { id } = req.params;
const { datum, firma, stelle, art, status, notizen } = req.body;
if (!datum || !firma || !stelle) {
const d = new Date();
return res.redirect(`/?fehler=pflichtfelder&monat=${d.getMonth() + 1}&jahr=${d.getFullYear()}`);
}
db.prepare(`
INSERT INTO bewerbungen (datum, firma, stelle, art, status, notizen)
VALUES (?, ?, ?, ?, ?, ?)
`).run(
sanitize(datum, 20),
sanitize(firma),
sanitize(stelle),
sanitize(art),
sanitize(status),
sanitize(notizen)
await dbRun(
'UPDATE bewerbungen SET datum = ?, firma = ?, stelle = ?, art = ?, status = ?, notizen = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?',
[datum, sanitizeInput(firma), sanitizeInput(stelle),
sanitizeInput(art), sanitizeInput(status), sanitizeInput(notizen), id]
);
const d = new Date(datum + 'T00:00:00');
res.redirect(`/?monat=${d.getMonth() + 1}&jahr=${d.getFullYear()}`);
});
const updatedApplication = await dbGet('SELECT * FROM bewerbungen WHERE id = ?', [id]);
// PUT /bewerbungen/:id Aktualisieren
app.put('/bewerbungen/:id', (req, res) => {
const id = parseInt(req.params.id);
const { datum, firma, stelle, art, status, notizen, monat, jahr } = req.body;
if (!datum || !firma || !stelle) {
return res.redirect(`/?fehler=pflichtfelder&monat=${monat}&jahr=${jahr}`);
res.json({ success: true, application: updatedApplication });
} catch (error) {
console.error('Error updating application:', error);
res.status(500).json({ error: 'Serverfehler' });
}
db.prepare(`
UPDATE bewerbungen
SET datum=?, firma=?, stelle=?, art=?, status=?, notizen=?, updated_at=CURRENT_TIMESTAMP
WHERE id=?
`).run(
sanitize(datum, 20),
sanitize(firma),
sanitize(stelle),
sanitize(art),
sanitize(status),
sanitize(notizen),
id
);
res.redirect(`/?monat=${monat || 1}&jahr=${jahr || new Date().getFullYear()}`);
});
// DELETE /bewerbungen/:id Löschen
app.delete('/bewerbungen/:id', (req, res) => {
db.prepare('DELETE FROM bewerbungen WHERE id=?').run(parseInt(req.params.id));
const { monat, jahr } = req.body;
const now = new Date();
res.redirect(`/?monat=${monat || now.getMonth() + 1}&jahr=${jahr || now.getFullYear()}`);
});
// GET /einstellungen
app.get('/einstellungen', (req, res) => {
res.render('einstellungen', {
settings: getSettings(),
gespeichert: req.query.gespeichert === '1',
currentPage: 'einstellungen'
});
// Delete application
app.delete('/api/bewerbungen/:id', async (req, res) => {
try {
const { id } = req.params;
await dbRun('DELETE FROM status_verlauf WHERE bewerbung_id = ?', [id]);
await dbRun('DELETE FROM bewerbungen WHERE id = ?', [id]);
res.json({ success: true });
} catch (error) {
console.error('Error deleting application:', error);
res.status(500).json({ error: 'Serverfehler' });
}
});
// Applications for PDF export (optionally filtered), including the status timeline
app.get('/api/export', async (req, res) => {
try {
const { month, year } = req.query;
let query = 'SELECT * FROM bewerbungen ORDER BY datum DESC';
const params = [];
if (month && year) {
query = 'SELECT * FROM bewerbungen WHERE strftime("%m", datum) = ? AND strftime("%Y", datum) = ? ORDER BY datum DESC';
params.push(month.padStart(2, '0'), year);
} else if (year) {
query = 'SELECT * FROM bewerbungen WHERE strftime("%Y", datum) = ? ORDER BY datum DESC';
params.push(year);
}
const applications = await dbAll(query, params);
await attachVerlauf(applications);
// Internal notes must never reach the PDF/export
applications.forEach((a) => { delete a.interne_notizen; });
res.json(applications);
} catch (error) {
console.error('Error exporting applications:', error);
res.status(500).json({ error: 'Serverfehler' });
}
});
// ----- Dedicated edit page + status-timeline management -----
// Edit page for a single application
app.get('/bewerbung/:id', async (req, res) => {
try {
const { id } = req.params;
const application = await dbGet('SELECT * FROM bewerbungen WHERE id = ?', [id]);
if (!application) return res.status(404).send('Bewerbung nicht gefunden');
const verlauf = await dbAll(
'SELECT * FROM status_verlauf WHERE bewerbung_id = ? ORDER BY date(datum) ASC, id ASC',
[id]
);
res.render('bewerbung', {
application,
verlauf,
artOptions: ART_OPTIONS,
statusOptions: STATUS_OPTIONS,
hideSettings: true
});
} catch (error) {
console.error('Error loading edit page:', error);
res.status(500).send('Serverfehler');
}
});
// Update application core data (status is managed via the timeline)
app.post('/bewerbung/:id', async (req, res) => {
try {
const { id } = req.params;
const { datum, firma, stelle, art, notizen, interne_notizen } = req.body;
await dbRun(
'UPDATE bewerbungen SET datum = ?, firma = ?, stelle = ?, art = ?, notizen = ?, interne_notizen = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?',
[datum, sanitizeInput(firma), sanitizeInput(stelle), sanitizeInput(art), sanitizeInput(notizen), sanitizeInput(interne_notizen), id]
);
res.redirect('/bewerbung/' + id);
} catch (error) {
console.error('Error updating application:', error);
res.status(500).send('Serverfehler');
}
});
// Add a timeline entry (status change with date + comment)
app.post('/bewerbung/:id/verlauf', async (req, res) => {
try {
const { id } = req.params;
const { datum, status, kommentar } = req.body;
if (datum && status && status.trim()) {
await dbRun(
'INSERT INTO status_verlauf (bewerbung_id, datum, status, kommentar) VALUES (?, ?, ?, ?)',
[id, datum, sanitizeInput(status), sanitizeInput(kommentar || '')]
);
await syncCurrentStatus(id);
}
res.redirect('/bewerbung/' + id);
} catch (error) {
console.error('Error adding timeline entry:', error);
res.status(500).send('Serverfehler');
}
});
// Update a timeline entry
app.post('/bewerbung/:id/verlauf/:eintragId', async (req, res) => {
try {
const { id, eintragId } = req.params;
const { datum, status, kommentar } = req.body;
if (datum && status && status.trim()) {
await dbRun(
'UPDATE status_verlauf SET datum = ?, status = ?, kommentar = ? WHERE id = ? AND bewerbung_id = ?',
[datum, sanitizeInput(status), sanitizeInput(kommentar || ''), eintragId, id]
);
await syncCurrentStatus(id);
}
res.redirect('/bewerbung/' + id);
} catch (error) {
console.error('Error updating timeline entry:', error);
res.status(500).send('Serverfehler');
}
});
// Delete a timeline entry
app.post('/bewerbung/:id/verlauf/:eintragId/delete', async (req, res) => {
try {
const { id, eintragId } = req.params;
await dbRun('DELETE FROM status_verlauf WHERE id = ? AND bewerbung_id = ?', [eintragId, id]);
await syncCurrentStatus(id);
res.redirect('/bewerbung/' + id);
} catch (error) {
console.error('Error deleting timeline entry:', error);
res.status(500).send('Serverfehler');
}
});
// Start server
app.listen(PORT, () => {
console.log(`Server läuft auf http://localhost:${PORT}`);
});
// Handle 404
app.use((req, res) => {
res.status(404).send('Seite nicht gefunden');
});
}).catch((err) => {
console.error('Failed to initialize database:', err);
process.exit(1);
});
// POST /einstellungen
app.post('/einstellungen', (req, res) => {
const { name, adresse, kundennummer } = req.body;
db.prepare(`
INSERT OR REPLACE INTO settings (id, name, adresse, kundennummer)
VALUES (1, ?, ?, ?)
`).run(sanitize(name), sanitize(adresse), sanitize(kundennummer));
res.redirect('/einstellungen?gespeichert=1');
// Close database on exit
process.on('SIGINT', () => {
db.close();
process.exit();
});
// GET /api/pdf-daten JSON für clientseitige PDF-Generierung
app.get('/api/pdf-daten', (req, res) => {
const monat = Math.min(12, Math.max(1, parseInt(req.query.monat) || 1));
const jahr = parseInt(req.query.jahr) || new Date().getFullYear();
const ms = String(monat).padStart(2, '0');
const bewerbungen = db.prepare(`
SELECT * FROM bewerbungen
WHERE datum BETWEEN ? AND ?
ORDER BY datum ASC, id ASC
`).all(`${jahr}-${ms}-01`, `${jahr}-${ms}-31`);
res.json({ bewerbungen, settings: getSettings(), monat, jahr });
});
// ── Start ─────────────────────────────────────────────────────────────────────
app.listen(PORT, () => {
console.log(`\n✓ Bewerbungs-Tracker läuft auf → http://localhost:${PORT}\n`);
process.on('SIGTERM', () => {
db.close();
process.exit();
});
+218
View File
@@ -0,0 +1,218 @@
<!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" id="body">
<%- include('partials/header', { hideSettings: true }) %>
<%
const statusColor = {
'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>
<!-- 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 () {
const body = document.getElementById('body');
const dm = localStorage.getItem('darkMode');
if (dm === 'enabled' || (!dm && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
body.classList.add('dark');
}
const toggle = document.getElementById('darkModeToggle');
const sun = document.getElementById('sunIcon');
const moon = document.getElementById('moonIcon');
function sync() {
const d = body.classList.contains('dark');
if (sun) sun.classList.toggle('hidden', d);
if (moon) moon.classList.toggle('hidden', !d);
}
sync();
if (toggle) toggle.addEventListener('click', () => {
body.classList.toggle('dark');
localStorage.setItem('darkMode', body.classList.contains('dark') ? 'enabled' : 'disabled');
sync();
});
const cy = document.getElementById('currentYear');
if (cy) cy.textContent = new Date().getFullYear();
})();
</script>
</body>
</html>
-62
View File
@@ -1,62 +0,0 @@
<%- include('partials/header') %>
<div style="max-width:42rem;">
<div class="page-header" style="margin-bottom:1.5rem;">
<div>
<h1 class="page-title">Einstellungen</h1>
<p class="page-meta">Diese Daten erscheinen automatisch in jedem PDF-Export.</p>
</div>
</div>
<% if (gespeichert) { %>
<div class="alert alert-success">
<svg width="16" height="16" fill="none" stroke="currentColor" viewBox="0 0 24 24" style="flex-shrink:0">
<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"/>
</svg>
Einstellungen wurden gespeichert.
</div>
<% } %>
<div class="settings-card">
<form method="POST" action="/einstellungen" class="settings-form">
<div class="form-group">
<label class="form-label" for="name">Vollständiger Name</label>
<input type="text" id="name" name="name" maxlength="500" class="form-input"
value="<%= settings.name || '' %>"
placeholder="Vorname Nachname">
<p class="form-hint">Erscheint als Absender und in der Unterschriftszeile des PDFs.</p>
</div>
<div class="form-group">
<label class="form-label" for="adresse">Adresse</label>
<textarea id="adresse" name="adresse" rows="3" maxlength="1000" class="form-textarea"
placeholder="Musterstraße 1&#10;12345 Musterstadt"><%= settings.adresse || '' %></textarea>
<p class="form-hint">Mehrzeilig erscheint im Briefkopf des PDFs.</p>
</div>
<div class="form-group">
<label class="form-label" for="kundennummer">Jobcenter-Kundennummer</label>
<input type="text" id="kundennummer" name="kundennummer" maxlength="100" class="form-input"
value="<%= settings.kundennummer || '' %>"
placeholder="z.B. BG-12345678">
<p class="form-hint">Ihre Kundennummer beim Jobcenter / der Agentur für Arbeit.</p>
</div>
<div style="display:flex;justify-content:flex-end;padding-top:.5rem;">
<button type="submit" class="btn btn-primary">Einstellungen speichern</button>
</div>
</form>
</div>
<div class="info-box" style="margin-top:1rem;">
<strong>PDF-Hinweis</strong>
Das exportierte PDF enthält Ihren Briefkopf mit den obigen Daten, einen Zusammenfassungssatz,
eine vollständige Bewerbungstabelle sowie eine Bestätigungs- und Unterschriftszeile.
Starten Sie den Export über „PDF exportieren" auf der Übersichtsseite.
</div>
</div>
<%- include('partials/footer') %>
+576 -265
View File
@@ -1,298 +1,609 @@
<%- include('partials/header') %>
<!DOCTYPE html>
<html lang="de">
<head>
<%- include('partials/head') %>
</head>
<body class="min-h-screen transition-colors duration-300" id="body">
<%- include('partials/header') %>
<%# ── Embed server data for client-side use ──────────────────────────────── %>
<script type="application/json" id="bewerbungenData"><%- bewerbungenJson %></script>
<script>
const CURRENT_MONAT = <%= monat %>;
const CURRENT_JAHR = <%= jahr %>;
const MONATE_DE = <%- JSON.stringify(monate) %>;
</script>
<main class="container mx-auto px-4 py-8">
<!-- Filter Section -->
<div 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">Filter</h2>
<form id="filterForm" class="flex flex-wrap gap-4 items-end">
<div class="flex-1 min-w-32">
<label for="filterMonth" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Monat
</label>
<select
id="filterMonth"
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>
<% availableMonths.forEach(m => { %>
<option value="<%= m.month %>"><%= m.month %> - <%= m.year %></option>
<% }); %>
</select>
</div>
<div class="flex-1 min-w-32">
<label for="filterYear" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Jahr
</label>
<select
id="filterYear"
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 years = [...new Set(availableMonths.map(m => m.year))].sort().reverse(); %>
<% years.forEach(y => { %>
<option value="<%= y %>"><%= y %></option>
<% }); %>
</select>
</div>
<div class="flex space-x-2">
<button
type="submit"
class="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-md transition-colors">
Filtern
</button>
<a
href="/"
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">
Zurücksetzen
</a>
</div>
<div class="ml-auto">
<button
id="exportPdfBtn"
class="px-4 py-2 bg-green-600 hover:bg-green-700 text-white rounded-md transition-colors flex items-center space-x-2">
<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="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>PDF Export</span>
</button>
</div>
<%# ── Page header ────────────────────────────────────────────────────────── %>
<div class="page-header">
<div>
<h1 class="page-title">Bewerbungsübersicht</h1>
<div class="page-meta">
<span><%= monatName %> <%= jahr %></span>
<% if (settings && settings.kundennummer) { %>
<span class="sep">&middot;</span>
<span class="tag">KdNr. <%= settings.kundennummer %></span>
<button
id="addApplicationBtn"
class="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-md transition-colors flex items-center space-x-2">
<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>
</form>
</div>
<!-- Statistics Section -->
<div 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-6">Statistik</h2>
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
<!-- Total Applications -->
<div class="bg-blue-50 dark:bg-gray-700 rounded-lg p-4 text-center">
<div class="text-3xl font-bold text-blue-600 dark:text-blue-400"><%= statistics.total %></div>
<div class="text-gray-600 dark:text-gray-400 mt-2">Gesamtbewerbungen</div>
</div>
<!-- By Application Type -->
<div class="bg-green-50 dark:bg-gray-700 rounded-lg p-4">
<h3 class="text-sm font-medium text-gray-700 dark:text-gray-300 mb-3">Nach Bewerbungsart</h3>
<div class="space-y-2">
<% if (statistics.byArt.length > 0) { %>
<% statistics.byArt.forEach(item => { %>
<div class="flex justify-between text-sm">
<span class="text-gray-600 dark:text-gray-400"><%= item.art %></span>
<span class="font-medium text-gray-800 dark:text-white"><%= item.count %></span>
</div>
<% }); %>
<% } else { %>
<p class="text-sm text-gray-500 dark:text-gray-400">Keine Daten verfügbar</p>
<% } %>
</div>
</div>
<%# Filter + PDF %>
<form method="GET" action="/" class="filter-bar">
<select name="monat" class="form-select btn-sm" style="width:auto;">
<% monate.forEach((m, i) => { %>
<option value="<%= i + 1 %>" <%= (i + 1 === monat) ? 'selected' : '' %>><%= m %></option>
<% }) %>
</select>
<select name="jahr" class="form-select btn-sm" style="width:auto;">
<% jahre.forEach(j => { %>
<option value="<%= j %>" <%= (j === jahr) ? 'selected' : '' %>><%= j %></option>
<% }) %>
</select>
<button type="submit" class="btn btn-secondary btn-sm">Filtern</button>
<button type="button" class="btn btn-primary btn-sm" onclick="generatePDF(<%= monat %>, <%= jahr %>)">
<svg width="14" height="14" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 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"/>
</svg>
PDF exportieren
</button>
</form>
</div>
<%# ── Error banner ───────────────────────────────────────────────────────── %>
<% if (fehler === 'pflichtfelder') { %>
<div class="alert alert-error">
<svg width="16" height="16" fill="none" stroke="currentColor" viewBox="0 0 24 24" style="flex-shrink:0">
<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"/>
</svg>
Bitte alle Pflichtfelder ausfüllen: Datum, Firma und Stelle sind erforderlich.
</div>
<% } %>
<%# ── Stats grid ─────────────────────────────────────────────────────────── %>
<div class="stats-grid">
<div class="stat-card s-total">
<div class="stat-label">Gesamt</div>
<div class="stat-value" data-count="<%= stats.gesamt %>"><%= stats.gesamt %></div>
</div>
<div class="stat-card s-positiv">
<div class="stat-label">Positiv</div>
<div class="stat-value" data-count="<%= stats.positiv %>"><%= stats.positiv %></div>
</div>
<div class="stat-card s-absage">
<div class="stat-label">Absagen</div>
<div class="stat-value" data-count="<%= stats.absagen %>"><%= stats.absagen %></div>
</div>
<div class="stat-card s-pending">
<div class="stat-label">Ausstehend</div>
<div class="stat-value" data-count="<%= stats.ausstehend %>"><%= stats.ausstehend %></div>
</div>
</div>
<%# ── Action bar ─────────────────────────────────────────────────────────── %>
<div class="action-bar">
<span class="entry-count"><%= bewerbungen.length %> Eintr<%= bewerbungen.length === 1 ? 'ag' : 'äge' %></span>
<button class="btn btn-primary btn-sm" onclick="openAddModal()">
<svg width="14" height="14" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M12 4v16m8-8H4"/>
</svg>
Neue Bewerbung
</button>
</div>
<%# ── Data table ─────────────────────────────────────────────────────────── %>
<div class="data-card">
<% if (bewerbungen.length === 0) { %>
<div class="empty-state">
<div class="empty-icon">
<svg width="22" height="22" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"
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"/>
</svg>
</div>
<p class="empty-title">Keine Bewerbungen für <%= monatName %> <%= jahr %></p>
<p class="empty-sub">Klicken Sie auf „Neue Bewerbung", um Ihren ersten Eintrag hinzuzufügen.</p>
<!-- By Status -->
<div class="bg-purple-50 dark:bg-gray-700 rounded-lg p-4">
<h3 class="text-sm font-medium text-gray-700 dark:text-gray-300 mb-3">Nach Status</h3>
<div class="space-y-2">
<% if (statistics.byStatus.length > 0) { %>
<% statistics.byStatus.forEach(item => { %>
<div class="flex justify-between text-sm">
<span class="text-gray-600 dark:text-gray-400"><%= item.status %></span>
<span class="font-medium text-gray-800 dark:text-white"><%= item.count %></span>
</div>
<% }); %>
<% } else { %>
<div class="overflow-x-auto">
<table class="data-table">
<thead>
<p class="text-sm text-gray-500 dark:text-gray-400">Keine Daten verfügbar</p>
<% } %>
</div>
</div>
</div>
</div>
<!-- Applications Table -->
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-md overflow-hidden">
<table class="w-full">
<thead class="bg-gray-50 dark:bg-gray-700">
<tr>
<th>Datum</th>
<th>Firma</th>
<th>Stelle</th>
<th>Art</th>
<th>Status</th>
<th class="hidden md:table-cell">Notizen</th>
<th style="text-align:right; padding-right:1.25rem;">Aktionen</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">Datum</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">Firma</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">Stelle</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">Art</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">Status</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">Aktionen</th>
</tr>
</thead>
<tbody>
<% bewerbungen.forEach(b => { %>
<% if (applications.length > 0) { %>
<%
// Status-Reihenfolge synchron zu public/js/main.js halten
const statusOrder = ['Gesendet', 'Eingangsbestätigung', 'Vorstellungsgespräch',
'Einstellung', 'Absage', 'Keine Rückmeldung'];
const statusDot = {
'Gesendet': 'bg-indigo-500',
'Eingangsbestätigung': 'bg-blue-500',
'Vorstellungsgespräch': 'bg-yellow-500',
'Einstellung': 'bg-green-500',
'Absage': 'bg-red-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))
];
%>
<% orderedKeys.forEach(statusKey => { %>
<tbody class="bg-gray-100 dark:bg-gray-900/40 border-b border-gray-200 dark:border-gray-700">
<tr>
<td class="td-date">
<%= b.datum ? b.datum.split('-').reverse().join('.') : '' %>
</td>
<td class="td-firma"><%= b.firma %></td>
<td class="td-stelle"><%= b.stelle %></td>
<td>
<% if (b.art) { %>
<span class="td-art"><%= b.art %></span>
<% } %>
</td>
<td>
<% if (b.status) { %>
<span class="status-chip <%= statusClass(b.status) %>"><%= b.status %></span>
<% } %>
</td>
<td class="td-notizen hidden md:table-cell"><%= b.notizen || '' %></td>
<td>
<div class="row-actions flex items-center justify-end gap-1" style="padding-right:0.25rem;">
<button class="row-btn btn-edit" title="Bearbeiten"
onclick="openEditModal(<%= b.id %>)">
<svg width="15" height="15" 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"/>
</svg>
</button>
<button class="row-btn btn-del" title="Löschen"
onclick="openDeleteModal(<%= b.id %>, '<%= b.firma.replace(/\\/g, '\\\\').replace(/'/g, "\\'") %>', '<%= b.stelle.replace(/\\/g, '\\\\').replace(/'/g, "\\'") %>')">
<svg width="15" height="15" 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"/>
</svg>
</button>
<td colspan="6" class="px-6 py-3">
<div class="flex items-center gap-3">
<span class="inline-block w-2.5 h-2.5 rounded-full <%= 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 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>
</div>
</td>
</tr>
<% }) %>
</tbody>
</table>
<% 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;
const padB = hatDetails ? 'pb-2' : 'pb-4';
%>
<tbody class="bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700">
<tr class="hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors">
<td class="px-6 pt-4 <%= padB %> whitespace-nowrap text-sm text-gray-900 dark:text-white">
<%= new Date(app.datum).toLocaleDateString('de-DE') %>
</td>
<td class="px-6 pt-4 <%= padB %> whitespace-nowrap text-sm font-medium text-gray-900 dark:text-white">
<%= app.firma %>
</td>
<td class="px-6 pt-4 <%= padB %> whitespace-nowrap text-sm text-gray-900 dark:text-white">
<%= app.stelle %>
</td>
<td class="px-6 pt-4 <%= padB %> whitespace-nowrap text-sm text-gray-500 dark:text-gray-400">
<%= app.art || '-' %>
</td>
<td class="px-6 pt-4 <%= padB %> whitespace-nowrap text-sm">
<span class="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 === 'Vorstellungsgespräch' ? 'bg-yellow-100 text-yellow-800 dark:bg-yellow-800 dark:text-yellow-100' :
'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300' %>">
<%= app.status || '-' %>
</span>
</td>
<td class="px-6 pt-4 <%= padB %> whitespace-nowrap text-sm align-top">
<a href="/bewerbung/<%= app.id %>"
class="inline-block align-middle text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300 mr-3"
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
class="delete-btn align-middle text-red-600 hover:text-red-800 dark:text-red-400 dark:hover:text-red-300"
data-id="<%= app.id %>">
<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>
</td>
</tr>
<% if (hatDetails) { %>
<tr class="hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors">
<td colspan="6" class="px-6 pb-5 pt-0 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>
<% } %>
</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>
<% } %>
</td>
</tr>
<% } %>
</tbody>
<% }); %>
<% }); %>
<% } else { %>
<tbody class="bg-white dark:bg-gray-800">
<tr>
<td colspan="6" class="px-6 py-12 text-center text-gray-500 dark:text-gray-400">
<p>Keine Bewerbungen gefunden.</p>
<p class="mt-2 text-sm">Klicken Sie auf "Bewerbung hinzufügen", um Ihre erste Bewerbung einzutragen.</p>
</td>
</tr>
</tbody>
<% } %>
</table>
</div>
</main>
<%# ══════════════════════════════════════════════════════════════════════════ %>
<%# Modal: Neue / Bewerbung bearbeiten %>
<%# ══════════════════════════════════════════════════════════════════════════ %>
<div id="bewerbungModal" class="modal-backdrop" onclick="closeOnBackdrop(event,'bewerbungModal')">
<div class="modal-box" role="dialog" aria-modal="true" aria-labelledby="modalTitle">
<%- include('partials/footer') %>
<div class="modal-head">
<h2 id="modalTitle" class="modal-title">Neue Bewerbung</h2>
<button class="modal-close" onclick="closeModal('bewerbungModal')" aria-label="Schließen">
<svg width="16" height="16" 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"/>
<!-- Settings Modal -->
<div id="settingsModal" 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">Benutzereinstellungen</h2>
<button id="closeSettingsModal" 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="bewerbungForm" method="POST" action="/bewerbungen">
<input type="hidden" name="monat" value="<%= monat %>">
<input type="hidden" name="jahr" value="<%= jahr %>">
<div class="modal-body">
<div class="form-grid">
<div class="form-group">
<label class="form-label" for="datum">Datum <span class="req">*</span></label>
<input type="date" id="datum" name="datum" required class="form-input">
</div>
<div class="form-group">
<label class="form-label" for="art">Art der Bewerbung</label>
<select id="art" name="art" class="form-select">
<option value="">— bitte wählen —</option>
<% artOptionen.forEach(a => { %>
<option value="<%= a %>"><%= a %></option>
<% }) %>
</select>
</div>
<div class="form-group span-2">
<label class="form-label" for="firma">Firma / Unternehmen <span class="req">*</span></label>
<input type="text" id="firma" name="firma" required maxlength="500" class="form-input"
placeholder="z.B. Muster GmbH">
</div>
<div class="form-group span-2">
<label class="form-label" for="stelle">Stelle / Position <span class="req">*</span></label>
<input type="text" id="stelle" name="stelle" required maxlength="500" class="form-input"
placeholder="z.B. Fachinformatiker / Softwareentwickler">
</div>
<div class="form-group span-2">
<label class="form-label" for="status">Status</label>
<select id="status" name="status" class="form-select">
<option value="">— bitte wählen —</option>
<% statusOptionen.forEach(s => { %>
<option value="<%= s %>"><%= s %></option>
<% }) %>
</select>
</div>
<div class="form-group span-2">
<label class="form-label" for="notizen">Notizen</label>
<textarea id="notizen" name="notizen" rows="3" maxlength="2000" class="form-textarea"
placeholder="Ansprechpartner, Referenznummer, Gehaltsvorstellung …"></textarea>
</div>
</div>
</div>
<div class="modal-foot">
<button type="button" class="btn btn-secondary" onclick="closeModal('bewerbungModal')">Abbrechen</button>
<button type="submit" class="btn btn-primary">Speichern</button>
</div>
</form>
</div>
</div>
<%# ══════════════════════════════════════════════════════════════════════════ %>
<%# Modal: Löschen bestätigen %>
<%# ══════════════════════════════════════════════════════════════════════════ %>
<div id="deleteModal" class="modal-backdrop" onclick="closeOnBackdrop(event,'deleteModal')">
<div class="modal-box compact" role="dialog" aria-modal="true">
<div class="modal-head">
<h2 class="modal-title">Eintrag löschen</h2>
<button class="modal-close" onclick="closeModal('deleteModal')" aria-label="Schließen">
<svg width="16" height="16" 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"/>
</svg>
</button>
</div>
<div class="modal-body">
<div class="flex gap-3">
<div class="flex-shrink-0 flex items-center justify-center w-10 h-10 rounded-full"
style="background:var(--red-dim);">
<svg width="18" height="18" fill="none" stroke="var(--red)" 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"/>
</svg>
</div>
<form id="settingsForm" class="space-y-4">
<div>
<p style="font-size:.9375rem;font-weight:600;color:var(--text);margin-bottom:.375rem;">
Sicher löschen?
</p>
<p id="deleteInfo" style="font-size:.875rem;color:var(--text-2);margin-bottom:.5rem;"></p>
<p style="font-size:.75rem;color:var(--text-muted);">
Diese Aktion kann nicht rückgängig gemacht werden.
</p>
</div>
</div>
<label for="userName" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Vollständiger Name *
</label>
<input
type="text"
id="userName"
name="name"
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="Max Mustermann"
value="<%= settings.name || '' %>">
</div>
<form id="deleteForm" method="POST">
<input type="hidden" name="monat" value="<%= monat %>">
<input type="hidden" name="jahr" value="<%= jahr %>">
<div class="modal-foot">
<button type="button" class="btn btn-secondary" onclick="closeModal('deleteModal')">Abbrechen</button>
<button type="submit" class="btn btn-danger">Endgültig löschen</button>
<div>
<label for="userAddress" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Adresse *
</label>
<textarea
id="userAddress"
name="adresse"
required
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"
placeholder="Musterstraße 1, 12345 Musterstadt"><%= settings.adresse || '' %></textarea>
</div>
<div>
<label for="customerNumber" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Jobcenter Kundennummer
</label>
<input
type="text"
id="customerNumber"
name="kundennummer"
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="z.B. 123456789"
value="<%= settings.kundennummer || '' %>">
</div>
<div class="flex justify-end space-x-3 pt-4">
<button
type="button"
id="cancelSettings"
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>
</div>
<%# Server-side helper: map status string → CSS class ─────────────────────── %>
<% function statusClass(s) {
const map = {
'Gesendet': 'st-gesendet',
'Eingangsbestätigung': 'st-eingang',
'Vorstellungsgespräch': 'st-vorstellung',
'Absage': 'st-absage',
'Einstellung': 'st-einstellung',
'Keine Rückmeldung': 'st-keine'
};
return map[s] || 'st-keine';
} %>
<!-- 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>
<%- include('partials/footer') %>
<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 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>
<% availableMonths.forEach(m => { %>
<option value="<%= 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(availableMonths.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>
+8 -9
View File
@@ -1,9 +1,8 @@
</div><%# /page-wrapper %>
<footer class="app-footer">
Bewerbungs-Tracker &mdash; Lokale Bewerbungsverwaltung
</footer>
<script src="/js/main.js"></script>
</body>
</html>
<footer class="bg-gray-100 dark:bg-gray-800 border-t border-gray-200 dark:border-gray-700 mt-12 py-6">
<div class="container mx-auto px-4 text-center">
<p class="text-gray-600 dark:text-gray-400 text-sm">
Bewerbungs-Tracker für Jobcenter Grundsicherung |
<span id="currentYear"></span>
</p>
</div>
</footer>
+7
View File
@@ -0,0 +1,7 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Bewerbungs-Tracker</title>
<!-- Tailwind CSS CDN -->
<script src="https://cdn.tailwindcss.com"></script>
<!-- Custom CSS -->
<link rel="stylesheet" href="/css/styles.css">
+39 -46
View File
@@ -1,49 +1,42 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Bewerbungs-Tracker</title>
<%# Synchronously apply saved dark mode preference before paint to prevent flash %>
<script>
(function () {
if (localStorage.getItem('darkMode') !== 'false') {
document.documentElement.classList.add('dark');
}
})();
</script>
<%# Tailwind CDN — used for layout utilities (flex, grid, overflow, responsive) %>
<script src="https://cdn.tailwindcss.com"></script>
<script>
tailwind.config = {
darkMode: 'class',
corePlugins: { preflight: false }
};
</script>
<%# jsPDF + autoTable for client-side PDF generation %>
<script src="https://cdnjs.cloudflare.com/ajax/libs/jspdf/2.5.1/jspdf.umd.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/jspdf-autotable/3.8.2/jspdf.plugin.autotable.min.js"></script>
<link rel="stylesheet" href="/css/style.css">
</head>
<body>
<nav class="app-nav">
<a href="/" class="nav-brand">
<span class="brand-dot"></span>
Bewerbungs-Tracker
<header class="bg-gradient-to-r from-blue-700 to-blue-900 dark:from-gray-800 dark:to-gray-900 shadow-lg">
<div class="container mx-auto px-4 py-4">
<div class="flex items-center justify-between">
<a href="/" class="flex items-center space-x-3">
<svg class="w-8 h-8 text-white" 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>
<h1 class="text-xl font-bold text-white">Bewerbungs-Tracker</h1>
</a>
<div class="nav-links">
<a href="/" class="nav-link <%= (typeof currentPage !== 'undefined' && currentPage === 'uebersicht') ? 'active' : '' %>">Übersicht</a>
<a href="/einstellungen" class="nav-link <%= (typeof currentPage !== 'undefined' && currentPage === 'einstellungen') ? 'active' : '' %>">Einstellungen</a>
<div class="flex items-center space-x-4">
<!-- Dark mode toggle -->
<button
id="darkModeToggle"
class="p-2 rounded-full bg-white/20 hover:bg-white/30 transition-colors text-white"
aria-label="Dark Mode umschalten"
>
<svg id="sunIcon" 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 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z"></path>
</svg>
<svg id="moonIcon" class="w-5 h-5 hidden" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z"></path>
</svg>
</button>
<!-- Settings button -->
<% if (typeof hideSettings === 'undefined' || !hideSettings) { %>
<button
id="settingsBtn"
class="p-2 rounded-full bg-white/20 hover:bg-white/30 transition-colors text-white"
aria-label="Einstellungen"
>
<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="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"></path>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path>
</svg>
</button>
<% } %>
</div>
<button id="darkModeToggle" class="dark-toggle" title="Darkmodus umschalten" aria-label="Darkmodus umschalten"></button>
</nav>
<div class="page-wrapper">
</div>
</div>
</header>