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/ node_modules/
# Database
/data/
*.db
*.sqlite
*.sqlite3
# Environment
.env .env
.env.local
.env.*.local
# Logs
logs/
*.log *.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) ────────────────── # syntax=docker/dockerfile:1
FROM node:20.19.2-slim AS deps
# ----- Build stage: install (and compile native sqlite3) deps -----
FROM node:20-bookworm-slim AS builder
WORKDIR /app WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends \ # Build toolchain required to compile the native sqlite3 binding
python3 make g++ \ RUN apt-get update \
&& apt-get install -y --no-install-recommends python3 make g++ \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
# Install production dependencies deterministically (cached unless lockfile changes)
COPY package.json package-lock.json ./ COPY package.json package-lock.json ./
RUN npm ci --omit=dev RUN npm ci --omit=dev
# ── Stage 2: Runtime ────────────────────────────────────────────────────────── # ----- Runtime stage: slim image without build tooling -----
FROM node:20.19.2-slim AS runtime FROM node:20-bookworm-slim AS runtime
ENV NODE_ENV=production \
PORT=3000
WORKDIR /app WORKDIR /app
RUN groupadd --system --gid 1001 nodejs \ # Bring in the already-installed (and compiled) dependencies
&& useradd --system --uid 1001 --gid nodejs --no-create-home appuser COPY --from=builder /app/node_modules ./node_modules
COPY --from=deps /app/node_modules ./node_modules # Application source
COPY . . COPY package.json ./
COPY server.js ./
RUN mkdir -p data && chown -R appuser:nodejs data 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"] VOLUME ["/app/data"]
USER appuser USER node
EXPOSE 3000 EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ 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"] CMD ["node", "server.js"]
+148 -64
View File
@@ -1,110 +1,194 @@
# Bewerbungs-Tracker # 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 - **Benutzerprofile**: Speichern Sie Name, Adresse und Jobcenter Kundennummer
- **Monatsansicht** Filter nach Monat und Jahr - **Dunkler Modus**: Vollständige Dark Mode Unterstützung mit lokaler Speicherung
- **Statistiken** Gesamt, Positiv, Absagen, Ausstehend - **CRUD-Operationen**: Komplette Verwaltung von Bewerbungen (Hinzufügen, Bearbeiten, Löschen)
- **PDF-Export** Professionelles Dokument mit Ihren Daten für das Jobcenter - **Filterfunktion**: Filterung nach Monat und Jahr
- **Dunkelmodus** Standard oder per Schalter umschaltbar - **Statistiken**: Übersicht über Gesamtbewerbungen, nach Art und Status
- **Datenschutz** Alle Daten bleiben lokal auf Ihrem Rechner (SQLite) - **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 ## Installation
### Voraussetzungen ### Voraussetzungen
- Node.js ≥ 18 (https://nodejs.org) - Node.js (Version 14 oder höher)
- npm (im Lieferumfang von Node.js) - npm oder yarn
- Build-Tools (für `better-sqlite3`):
- **Linux/Mac:** `build-essential` / Xcode Command Line Tools
- **Windows:** `windows-build-tools` oder Visual Studio Build Tools
### Schritte ### Schritte
1. **Projekt klonen**
```bash ```bash
# 1. In das Projektverzeichnis wechseln
cd bewerbungs-tracker cd bewerbungs-tracker
```
# 2. Abhängigkeiten installieren 2. **Abhängigkeiten installieren**
```bash
npm install 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 npm start
``` ```
Die Anwendung ist dann unter **http://localhost:3000** erreichbar. Für Entwicklung mit automatischem Neuladen:
### Entwicklungsmodus (Auto-Reload)
```bash ```bash
npm run dev npm run dev
``` ```
5. **Anwendung öffnen**
Öffnen Sie Ihren Browser und navigieren Sie zu:
```
http://localhost:3000
```
## Projektstruktur ## Projektstruktur
``` ```
bewerbungs-tracker/ bewerbungs-tracker/
├── server.js # Express-Server mit allen Routen ├── server.js # Express Server mit API-Routen
├── package.json ├── package.json # Projektabhängigkeiten und Skripte
├── views/ ├── views/
│ ├── index.ejs # Übersichtsseite │ ├── index.ejs # Hauptseite
│ ├── einstellungen.ejs # Einstellungsseite
│ └── partials/ │ └── partials/
│ ├── header.ejs # HTML-Head + Navigation │ ├── header.ejs # Kopfzeile mit Dark Mode Toggle
│ └── footer.ejs # Abschlusselemente + Scripts │ └── footer.ejs # Fußzeile
├── public/ ├── public/
│ ├── css/style.css # Tailwind-Utility-Klassen │ ├── css/
│ └── js/main.js # Dark Mode, Modals, PDF-Generierung │ └── styles.css # Benutzerdefinierte Styles
└── data/ │ └── js/
└── bewerbungen.db # SQLite-Datenbank (wird automatisch erstellt) └── main.js # Client-seitige Logik
├── data/
│ └── bewerbungen.db # SQLite Datenbank (wird automatisch erstellt)
└── README.md # Dokumentation
``` ```
## Datenbank-Schema ## Datenbank-Schema
```sql ### Bewerbungen
-- 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
);
-- Benutzerprofil (wird im PDF verwendet) | Feld | Typ | Beschreibung |
CREATE TABLE settings ( |------|-----|--------------|
id INTEGER PRIMARY KEY CHECK (id = 1), | id | INTEGER PRIMARY KEY | Eindeutige ID |
name TEXT, | datum | DATE | Bewerbungsdatum |
adresse TEXT, | firma | TEXT | Firmenname |
kundennummer TEXT | 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 ## PDF-Export
1. Öffnen Sie **Einstellungen** und tragen Sie Ihren Namen, Adresse und Kundennummer ein. Der PDF-Export generiert ein professionelles Dokument mit:
2. Filtern Sie auf der Übersicht den gewünschten Monat. - Benutzerdaten (Name, Adresse, Kundennummer)
3. Klicken Sie auf **„PDF exportieren"**. - Überschrift mit Monat und Jahr
- Zusammenfassung der Bewerbungsaktivitäten
- Tabelle mit allen Bewerbungen
- Bestätigungstext und Datum
Das PDF enthält: ## Browser-Unterstützung
- Briefkopf mit Ihren persönlichen Daten
- Titel „Bewerbungsaktivitäten Monat Jahr"
- Zusammenfassungssatz
- Tabelle aller Bewerbungen des Monats
- Unterschriftszeile mit Datum
## Port ändern - Chrome (empfohlen)
- Firefox
- Safari
- Edge
```bash ## Dark Mode
PORT=8080 npm start
``` 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 ## Lizenz
MIT 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", "name": "bewerbungs-tracker",
"version": "1.0.0", "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", "main": "server.js",
"scripts": { "scripts": {
"start": "node server.js", "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": { "dependencies": {
"better-sqlite3": "^12.10.0", "ejs": "^3.1.9",
"ejs": "^3.1.10", "express": "^4.18.2",
"express": "^4.22.2", "jspdf": "^2.5.1",
"method-override": "^3.0.0" "jspdf-autotable": "^3.8.2",
"sqlite3": "^5.1.6"
}, },
"devDependencies": { "devDependencies": {
"nodemon": "^3.0.2" "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;
}
+586 -229
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', () => { // Modal Elements
const isDark = document.documentElement.classList.toggle('dark'); const settingsModal = document.getElementById('settingsModal');
localStorage.setItem('darkMode', isDark ? 'true' : 'false'); 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() { // Button Elements
const DURATION = 700; const settingsBtn = document.getElementById('settingsBtn');
const addApplicationBtn = document.getElementById('addApplicationBtn');
const exportPdfBtn = document.getElementById('exportPdfBtn');
document.querySelectorAll('.stat-value[data-count]').forEach((el, i) => { // Close Modal Buttons
const target = parseInt(el.dataset.count, 10) || 0; const closeSettingsModal = document.getElementById('closeSettingsModal');
if (target === 0) return; const closeApplicationModal = document.getElementById('closeApplicationModal');
const closeDeleteModal = document.getElementById('closeDeleteModal');
const closePdfModal = document.getElementById('closePdfModal');
const delay = 80 + i * 60; const cancelSettings = document.getElementById('cancelSettings');
setTimeout(() => { const cancelApplication = document.getElementById('cancelApplication');
const start = performance.now(); const cancelDelete = document.getElementById('cancelDelete');
const tick = (now) => { const cancelPdfExport = document.getElementById('cancelPdfExport');
const p = Math.min((now - start) / DURATION, 1); const confirmDelete = document.getElementById('confirmDelete');
// ease-out expo
const eased = p === 1 ? 1 : 1 - Math.pow(2, -10 * p); // Global variables
el.textContent = Math.round(eased * target); let currentApplicationId = null;
if (p < 1) requestAnimationFrame(tick); 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);
});
})();
// ── Load bewerbungen from embedded JSON ─────────────────────────────────────── fetch('/api/settings', {
method: 'POST',
let BEWERBUNGEN = []; headers: {
const dataEl = document.getElementById('bewerbungenData'); 'Content-Type': 'application/json'
if (dataEl) { },
try { BEWERBUNGEN = JSON.parse(dataEl.textContent); } catch (_) {} 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));
} }
// ── Modal system ────────────────────────────────────────────────────────────── // ============================================
// Application Management (CRUD)
// ============================================
function openModal(id) { function openAddApplicationModal() {
const el = document.getElementById(id); resetApplicationForm();
el.classList.add('is-open'); // Set default date to today
document.body.style.overflow = 'hidden'; const today = new Date().toISOString().split('T')[0];
setTimeout(() => { document.getElementById('applicationDatum').value = today;
const first = el.querySelector( showModal(applicationModal);
'input[type="date"], input[type="text"]:not([type="hidden"]), select, textarea'
);
if (first) first.focus();
}, 60);
} }
function closeModal(id) { function openEditApplicationModal(id) {
const el = document.getElementById(id); // Fetch application data
el.classList.remove('is-open'); fetch(`/api/bewerbungen/${id}`)
document.body.style.overflow = ''; .then(response => response.json())
} .then(application => {
currentApplicationId = application.id;
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);
});
});
// ── 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('modalTitle').textContent = 'Bewerbung bearbeiten';
document.getElementById('datum').value = b.datum || ''; document.getElementById('applicationId').value = application.id;
document.getElementById('firma').value = b.firma || ''; document.getElementById('applicationDatum').value = application.datum;
document.getElementById('stelle').value = b.stelle || ''; document.getElementById('applicationFirma').value = application.firma;
document.getElementById('art').value = b.art || ''; document.getElementById('applicationStelle').value = application.stelle;
document.getElementById('status').value = b.status || ''; document.getElementById('applicationArt').value = application.art || '';
document.getElementById('notizen').value = b.notizen || ''; document.getElementById('applicationStatus').value = application.status || '';
document.getElementById('applicationNotizen').value = application.notizen || '';
openModal('bewerbungModal'); showModal(applicationModal);
})
.catch(error => console.error('Error loading application:', error));
} }
// ── Delete modal ────────────────────────────────────────────────────────────── function saveApplication(event) {
event.preventDefault();
function openDeleteModal(id, firma, stelle) { const formData = new FormData(applicationForm);
document.getElementById('deleteInfo').textContent = `${firma} ${stelle}`; const application = {
document.getElementById('deleteForm').action = `/bewerbungen/${id}?_method=DELETE`; datum: formData.get('datum'),
openModal('deleteModal'); 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';
} }
// ── PDF generation ──────────────────────────────────────────────────────────── 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));
}
async function generatePDF(monat, jahr) { function openDeleteModal(id) {
const btn = event.currentTarget; currentDeleteId = id;
const saved = btn.innerHTML; showModal(deleteModal);
btn.disabled = true; }
btn.innerHTML = '<span style="opacity:.7">Wird erstellt …</span>';
try { function deleteApplication() {
const res = await fetch(`/api/pdf-daten?monat=${monat}&jahr=${jahr}`); if (!currentDeleteId) return;
if (!res.ok) throw new Error(`Server ${res.status}`);
buildPDF(await res.json(), monat, jahr); fetch(`/api/bewerbungen/${currentDeleteId}`, {
} catch (err) { method: 'DELETE'
alert('PDF-Erstellung fehlgeschlagen:\n' + err.message); })
} finally { .then(response => response.json())
btn.disabled = false; .then(data => {
btn.innerHTML = saved; 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);
});
}
function generatePDF(event) {
event.preventDefault();
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));
}
// 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;
}
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;
} }
} }
function buildPDF({ bewerbungen, settings }, monat, jahr) { // Header with user data
const { jsPDF } = window.jspdf; doc.setFont('helvetica', 'bold');
const doc = new jsPDF({ orientation: 'portrait', unit: 'mm', format: 'a4' }); doc.setFontSize(16);
const PW = doc.internal.pageSize.getWidth(); // 210 mm doc.text(title, pageWidth / 2, yPos, { align: 'center' });
const PH = doc.internal.pageSize.getHeight(); // 297 mm yPos += 12;
const monatName = MONATE_DE[monat - 1];
const count = bewerbungen.length;
// Accent stripe // User information
doc.setFillColor(224, 123, 0); doc.setFontSize(12);
doc.rect(0, 0, PW, 2.5, 'F'); 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;
}
// ── User info (top right) ────────────────────────────────────────────────── 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.setFontSize(9);
doc.setTextColor(120, 120, 120); doc.setTextColor(90, 90, 90);
let uy = 10; doc.text(`Art: ${art}`, margin + 2, yPos + 5);
const ux = PW - 12; yPos += 9;
if (settings?.name) { // Status-Verlauf timeline (chronological status changes with comments)
const verlauf = Array.isArray(app.verlauf) ? app.verlauf : [];
if (verlauf.length) {
doc.setFont('helvetica', 'bold'); doc.setFont('helvetica', 'bold');
doc.text(settings.name, ux, uy, { align: 'right' });
uy += 5;
doc.setFont('helvetica', 'normal');
}
if (settings?.adresse) {
settings.adresse.split('\n').forEach(line => {
doc.text(line.trim(), ux, uy, { align: 'right' });
uy += 4.5;
});
}
if (settings?.kundennummer) {
uy += 1;
doc.text(`Kundennr.: ${settings.kundennummer}`, ux, uy, { align: 'right' });
}
// ── Title ──────────────────────────────────────────────────────────────────
doc.setFontSize(17);
doc.setFont('helvetica', 'bold');
doc.setTextColor(24, 32, 47);
const titleText = `Bewerbungsaktivitäten ${monatName} ${jahr}`;
doc.text(titleText, 12, 14);
// 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);
// ── Summary ────────────────────────────────────────────────────────────────
doc.setFontSize(10); doc.setFontSize(10);
doc.setFont('helvetica', 'normal'); doc.setTextColor(20, 20, 20);
doc.setTextColor(74, 85, 107); ensureSpace(6);
const pl = count !== 1 ? 'n' : ''; doc.text('Status-Verlauf:', margin + 2, yPos + 4);
doc.text( yPos += 6;
`Im Monat ${monatName} ${jahr} habe ich mich insgesamt auf ${count} Stelle${pl} beworben.`,
12, 25
);
// ── Table ────────────────────────────────────────────────────────────────── doc.setFontSize(9);
doc.autoTable({ verlauf.forEach((v) => {
startY: 31, const vDatum = new Date(v.datum).toLocaleDateString('de-DE');
head: [['Datum', 'Firma / Unternehmen', 'Stelle / Position', 'Art', 'Status', 'Notizen']], doc.setFont('helvetica', 'bold');
body: bewerbungen.map(b => [ doc.setTextColor(50, 50, 50);
formatDateDE(b.datum), ensureSpace(5);
b.firma || '', doc.text(`${vDatum}${v.status || ''}`, margin + 5, yPos + 4);
b.stelle || '', yPos += 5;
b.art || '',
b.status || '', const kommentar = (v.kommentar || '').trim();
b.notizen ? b.notizen.slice(0, 120) : '' if (kommentar) {
]), doc.setFont('helvetica', 'normal');
margin: { left: 12, right: 12 }, doc.setTextColor(0, 0, 0);
styles: { kommentar.split(/\r?\n/).forEach((paragraph) => {
fontSize: 7, const lines = doc.splitTextToSize(paragraph.length ? paragraph : ' ', contentWidth - 12);
cellPadding: 2.8, lines.forEach((line) => {
textColor: [24, 32, 47], ensureSpace(4.5);
lineColor: [221, 226, 239], doc.text(line, margin + 9, yPos + 3.5);
lineWidth: 0.25, yPos += 4.5;
font: 'helvetica' });
}, });
headStyles: { }
fillColor: [224, 123, 0], });
textColor: [255, 255, 255], yPos += 3;
fontStyle: 'bold', }
halign: 'left',
fontSize: 7 // Notizen label
}, doc.setFont('helvetica', 'bold');
alternateRowStyles: { fillColor: [248, 250, 254] }, doc.setFontSize(10);
columnStyles: { doc.setTextColor(20, 20, 20);
0: { cellWidth: 26, overflow: 'hidden' }, doc.text('Notizen:', margin + 2, yPos + 4);
1: { cellWidth: 38 }, yPos += 6;
2: { cellWidth: 38 },
3: { cellWidth: 28 }, // Notizen body — full width, complete, with page breaks line by line
4: { cellWidth: 32 }, doc.setFont('helvetica', 'normal');
5: { cellWidth: 'auto' } doc.setFontSize(10);
}, doc.setTextColor(0, 0, 0);
didDrawPage: ({ pageNumber }) => { const lineHeight = 5;
doc.setFontSize(7.5); const noteText = notizen || '(keine Notizen)';
doc.setTextColor(160, 170, 185); noteText.split(/\r?\n/).forEach((paragraph) => {
doc.text(`Seite ${pageNumber}`, PW / 2, PH - 4.5, { align: 'center' }); 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;
});
// 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);
} }
}); });
// ── Declaration line ─────────────────────────────────────────────────────── // Close modals when clicking outside
const finalY = doc.lastAutoTable.finalY; document.addEventListener('click', (e) => {
const declY = finalY + 10; if (e.target === settingsModal) hideModal(settingsModal);
if (e.target === applicationModal) {
doc.setFontSize(8.5); hideModal(applicationModal);
doc.setTextColor(100, 110, 130); resetApplicationForm();
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`);
} }
if (e.target === deleteModal) hideModal(deleteModal);
if (e.target === pdfExportModal) hideModal(pdfExportModal);
});
// ── Utilities ───────────────────────────────────────────────────────────────── // Close modals with Escape key
document.addEventListener('keydown', (e) => {
function todayISO() { if (e.key === 'Escape') {
const d = new Date(); if (!settingsModal.classList.contains('hidden')) hideModal(settingsModal);
return [ if (!applicationModal.classList.contains('hidden')) {
d.getFullYear(), hideModal(applicationModal);
String(d.getMonth() + 1).padStart(2, '0'), resetApplicationForm();
String(d.getDate()).padStart(2, '0')
].join('-');
} }
if (!deleteModal.classList.contains('hidden')) hideModal(deleteModal);
function formatDateDE(s) { if (!pdfExportModal.classList.contains('hidden')) hideModal(pdfExportModal);
if (!s) return '';
const p = s.split('-');
return p.length === 3 ? `${p[2]}.${p[1]}.${p[0]}` : s;
} }
});
// Set current year in footer
document.getElementById('currentYear').textContent = new Date().getFullYear();
// ============================================
// Initialize
// ============================================
// Initialize dark mode
initializeDarkMode();
console.log('Bewerbungs-Tracker initialized');
+439 -176
View File
@@ -1,23 +1,109 @@
'use strict';
const express = require('express'); const express = require('express');
const sqlite3 = require('sqlite3').verbose();
const path = require('path'); const path = require('path');
const fs = require('fs'); const fs = require('fs');
const methodOverride = require('method-override');
const Database = require('better-sqlite3');
const app = express(); const app = express();
const PORT = process.env.PORT || 3000; 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'); 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')); // Database setup
db.pragma('journal_mode = WAL'); 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 ( CREATE TABLE IF NOT EXISTS bewerbungen (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
datum DATE NOT NULL, datum DATE NOT NULL,
@@ -26,209 +112,386 @@ db.exec(`
art TEXT, art TEXT,
status TEXT, status TEXT,
notizen TEXT, notizen TEXT,
interne_notizen TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP, created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_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 ( CREATE TABLE IF NOT EXISTS settings (
id INTEGER PRIMARY KEY CHECK (id = 1), id INTEGER PRIMARY KEY CHECK (id = 1),
name TEXT, name TEXT,
adresse TEXT, adresse TEXT,
kundennummer TEXT kundennummer TEXT
); )
`, (err) => {
if (err) return reject(err);
INSERT OR IGNORE INTO settings (id) VALUES (1); // 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();
}
);
} else {
resolve();
}
});
});
});
});
});
});
});
}
// Initialize and start server
initializeDatabase().then(() => {
console.log('Database initialized successfully');
// Routes
app.get('/', async (req, res) => {
try {
const { month, year } = req.query;
let query = 'SELECT * FROM bewerbungen ORDER BY datum DESC, created_at DESC';
const params = [];
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);
}
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
`); `);
// ── Constants ───────────────────────────────────────────────────────────────── // Get available months/years for filter
const availableMonths = await dbAll(`
const ART_OPTIONEN = [ SELECT DISTINCT strftime("%Y-%m", datum) as yearmonth,
'E-Mail', 'Online-Portal', 'Indeed', 'StepStone', 'Firmenwebsite', strftime("%m", datum) as month,
'Post', 'Initiativbewerbung', 'Arbeitsagentur', 'Sonstiges' strftime("%Y", datum) as year
]; FROM bewerbungen ORDER BY datum DESC
`);
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);
}
function safeJson(obj) {
return JSON.stringify(obj)
.replace(/</g, '\\u003c')
.replace(/>/g, '\\u003e')
.replace(/&/g, '\\u0026');
}
function getSettings() {
return db.prepare('SELECT * FROM settings WHERE id = 1').get() || {};
}
// ── Middleware ────────────────────────────────────────────────────────────────
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++;
}
// 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);
res.render('index', { res.render('index', {
bewerbungen, applications,
bewerbungenJson: safeJson(bewerbungen), settings,
stats, statistics: {
monat, total: totalCount ? totalCount.count : 0,
jahr, byArt,
monate: MONATE, byStatus
jahre, },
artOptionen: ART_OPTIONEN, availableMonths,
statusOptionen: STATUS_OPTIONEN, currentFilter: { month, year },
settings: getSettings(), artOptions: ART_OPTIONS,
monatName: MONATE[monat - 1], statusOptions: STATUS_OPTIONS
fehler: req.query.fehler || null,
currentPage: 'uebersicht'
}); });
} catch (error) {
console.error('Error:', error);
res.status(500).send('Serverfehler');
}
}); });
// POST /bewerbungen Neu anlegen // Get single application
app.post('/bewerbungen', (req, res) => { 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; const { datum, firma, stelle, art, status, notizen } = req.body;
if (!datum || !firma || !stelle) { await dbRun(
const d = new Date(); 'UPDATE bewerbungen SET datum = ?, firma = ?, stelle = ?, art = ?, status = ?, notizen = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?',
return res.redirect(`/?fehler=pflichtfelder&monat=${d.getMonth() + 1}&jahr=${d.getFullYear()}`); [datum, sanitizeInput(firma), sanitizeInput(stelle),
} sanitizeInput(art), sanitizeInput(status), sanitizeInput(notizen), id]
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)
); );
const d = new Date(datum + 'T00:00:00'); const updatedApplication = await dbGet('SELECT * FROM bewerbungen WHERE id = ?', [id]);
res.redirect(`/?monat=${d.getMonth() + 1}&jahr=${d.getFullYear()}`);
res.json({ success: true, application: updatedApplication });
} catch (error) {
console.error('Error updating application:', error);
res.status(500).json({ error: 'Serverfehler' });
}
}); });
// PUT /bewerbungen/:id Aktualisieren // Delete application
app.put('/bewerbungen/:id', (req, res) => { app.delete('/api/bewerbungen/:id', async (req, res) => {
const id = parseInt(req.params.id); try {
const { datum, firma, stelle, art, status, notizen, monat, jahr } = req.body; const { id } = req.params;
await dbRun('DELETE FROM status_verlauf WHERE bewerbung_id = ?', [id]);
await dbRun('DELETE FROM bewerbungen WHERE id = ?', [id]);
if (!datum || !firma || !stelle) { res.json({ success: true });
return res.redirect(`/?fehler=pflichtfelder&monat=${monat}&jahr=${jahr}`); } 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);
} }
db.prepare(` const applications = await dbAll(query, params);
UPDATE bewerbungen await attachVerlauf(applications);
SET datum=?, firma=?, stelle=?, art=?, status=?, notizen=?, updated_at=CURRENT_TIMESTAMP // Internal notes must never reach the PDF/export
WHERE id=? applications.forEach((a) => { delete a.interne_notizen; });
`).run(
sanitize(datum, 20), res.json(applications);
sanitize(firma), } catch (error) {
sanitize(stelle), console.error('Error exporting applications:', error);
sanitize(art), res.status(500).json({ error: 'Serverfehler' });
sanitize(status), }
sanitize(notizen), });
id
// ----- 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.redirect(`/?monat=${monat || 1}&jahr=${jahr || new Date().getFullYear()}`); 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');
}
}); });
// DELETE /bewerbungen/:id Löschen // Update application core data (status is managed via the timeline)
app.delete('/bewerbungen/:id', (req, res) => { app.post('/bewerbung/:id', async (req, res) => {
db.prepare('DELETE FROM bewerbungen WHERE id=?').run(parseInt(req.params.id)); try {
const { monat, jahr } = req.body; const { id } = req.params;
const now = new Date(); const { datum, firma, stelle, art, notizen, interne_notizen } = req.body;
res.redirect(`/?monat=${monat || now.getMonth() + 1}&jahr=${jahr || now.getFullYear()}`);
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');
}
}); });
// GET /einstellungen // Add a timeline entry (status change with date + comment)
app.get('/einstellungen', (req, res) => { app.post('/bewerbung/:id/verlauf', async (req, res) => {
res.render('einstellungen', { try {
settings: getSettings(), const { id } = req.params;
gespeichert: req.query.gespeichert === '1', const { datum, status, kommentar } = req.body;
currentPage: 'einstellungen'
}); 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');
}
}); });
// POST /einstellungen // Update a timeline entry
app.post('/einstellungen', (req, res) => { app.post('/bewerbung/:id/verlauf/:eintragId', async (req, res) => {
const { name, adresse, kundennummer } = req.body; try {
db.prepare(` const { id, eintragId } = req.params;
INSERT OR REPLACE INTO settings (id, name, adresse, kundennummer) const { datum, status, kommentar } = req.body;
VALUES (1, ?, ?, ?)
`).run(sanitize(name), sanitize(adresse), sanitize(kundennummer)); if (datum && status && status.trim()) {
res.redirect('/einstellungen?gespeichert=1'); 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');
}
}); });
// GET /api/pdf-daten JSON für clientseitige PDF-Generierung // Delete a timeline entry
app.get('/api/pdf-daten', (req, res) => { app.post('/bewerbung/:id/verlauf/:eintragId/delete', async (req, res) => {
const monat = Math.min(12, Math.max(1, parseInt(req.query.monat) || 1)); try {
const jahr = parseInt(req.query.jahr) || new Date().getFullYear(); const { id, eintragId } = req.params;
const ms = String(monat).padStart(2, '0'); await dbRun('DELETE FROM status_verlauf WHERE id = ? AND bewerbung_id = ?', [eintragId, id]);
await syncCurrentStatus(id);
const bewerbungen = db.prepare(` res.redirect('/bewerbung/' + id);
SELECT * FROM bewerbungen } catch (error) {
WHERE datum BETWEEN ? AND ? console.error('Error deleting timeline entry:', error);
ORDER BY datum ASC, id ASC res.status(500).send('Serverfehler');
`).all(`${jahr}-${ms}-01`, `${jahr}-${ms}-31`); }
res.json({ bewerbungen, settings: getSettings(), monat, jahr });
}); });
// ── Start ───────────────────────────────────────────────────────────────────── // Start server
app.listen(PORT, () => { app.listen(PORT, () => {
console.log(`\n✓ Bewerbungs-Tracker läuft auf http://localhost:${PORT}\n`); 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);
});
// Close database on exit
process.on('SIGINT', () => {
db.close();
process.exit();
});
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') %>
+575 -264
View File
@@ -1,298 +1,609 @@
<!DOCTYPE html>
<html lang="de">
<head>
<%- include('partials/head') %>
</head>
<body class="min-h-screen transition-colors duration-300" id="body">
<%- include('partials/header') %> <%- include('partials/header') %>
<%# ── Embed server data for client-side use ──────────────────────────────── %> <main class="container mx-auto px-4 py-8">
<script type="application/json" id="bewerbungenData"><%- bewerbungenJson %></script> <!-- Filter Section -->
<script> <div class="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6 mb-8">
const CURRENT_MONAT = <%= monat %>; <h2 class="text-lg font-semibold text-gray-800 dark:text-white mb-4">Filter</h2>
const CURRENT_JAHR = <%= jahr %>; <form id="filterForm" class="flex flex-wrap gap-4 items-end">
const MONATE_DE = <%- JSON.stringify(monate) %>; <div class="flex-1 min-w-32">
</script> <label for="filterMonth" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Monat
<%# ── Page header ────────────────────────────────────────────────────────── %> </label>
<div class="page-header"> <select
<div> id="filterMonth"
<h1 class="page-title">Bewerbungsübersicht</h1> name="month"
<div class="page-meta"> 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">
<span><%= monatName %> <%= jahr %></span> <option value="">-- Alle Monate --</option>
<% if (settings && settings.kundennummer) { %> <% availableMonths.forEach(m => { %>
<span class="sep">&middot;</span> <option value="<%= m.month %>"><%= m.month %> - <%= m.year %></option>
<span class="tag">KdNr. <%= settings.kundennummer %></span> <% }); %>
<% } %> </select>
</div>
</div> </div>
<%# Filter + PDF %> <div class="flex-1 min-w-32">
<form method="GET" action="/" class="filter-bar"> <label for="filterYear" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
<select name="monat" class="form-select btn-sm" style="width:auto;"> Jahr
<% monate.forEach((m, i) => { %> </label>
<option value="<%= i + 1 %>" <%= (i + 1 === monat) ? 'selected' : '' %>><%= m %></option> <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> </select>
<select name="jahr" class="form-select btn-sm" style="width:auto;"> </div>
<% 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 %>)"> <div class="flex space-x-2">
<svg width="14" height="14" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <button
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" type="submit"
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"/> class="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-md transition-colors">
</svg> Filtern
PDF exportieren
</button> </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>
<div>
<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> </form>
</div> </div>
<%# ── Error banner ───────────────────────────────────────────────────────── %> <!-- Statistics Section -->
<% if (fehler === 'pflichtfelder') { %> <div class="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6 mb-8">
<div class="alert alert-error"> <h2 class="text-lg font-semibold text-gray-800 dark:text-white mb-6">Statistik</h2>
<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="grid grid-cols-1 md:grid-cols-3 gap-6">
<div class="stats-grid"> <!-- Total Applications -->
<div class="stat-card s-total"> <div class="bg-blue-50 dark:bg-gray-700 rounded-lg p-4 text-center">
<div class="stat-label">Gesamt</div> <div class="text-3xl font-bold text-blue-600 dark:text-blue-400"><%= statistics.total %></div>
<div class="stat-value" data-count="<%= stats.gesamt %>"><%= stats.gesamt %></div> <div class="text-gray-600 dark:text-gray-400 mt-2">Gesamtbewerbungen</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> </div>
<%# ── Action bar ─────────────────────────────────────────────────────────── %> <!-- By Application Type -->
<div class="action-bar"> <div class="bg-green-50 dark:bg-gray-700 rounded-lg p-4">
<span class="entry-count"><%= bewerbungen.length %> Eintr<%= bewerbungen.length === 1 ? 'ag' : 'äge' %></span> <h3 class="text-sm font-medium text-gray-700 dark:text-gray-300 mb-3">Nach Bewerbungsart</h3>
<button class="btn btn-primary btn-sm" onclick="openAddModal()"> <div class="space-y-2">
<svg width="14" height="14" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <% if (statistics.byArt.length > 0) { %>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M12 4v16m8-8H4"/> <% statistics.byArt.forEach(item => { %>
</svg> <div class="flex justify-between text-sm">
Neue Bewerbung <span class="text-gray-600 dark:text-gray-400"><%= item.art %></span>
</button> <span class="font-medium text-gray-800 dark:text-white"><%= item.count %></span>
</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>
</div> </div>
<% }); %>
<% } else { %> <% } else { %>
<div class="overflow-x-auto"> <p class="text-sm text-gray-500 dark:text-gray-400">Keine Daten verfügbar</p>
<table class="data-table"> <% } %>
<thead> </div>
</div>
<!-- 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 { %>
<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> <tr>
<th>Datum</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>Firma</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>Stelle</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>Art</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>Status</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="hidden md:table-cell">Notizen</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>
<th style="text-align:right; padding-right:1.25rem;">Aktionen</th>
</tr> </tr>
</thead> </thead>
<tbody> <% if (applications.length > 0) { %>
<% bewerbungen.forEach(b => { %> <%
// 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> <tr>
<td class="td-date"> <td colspan="6" class="px-6 py-3">
<%= b.datum ? b.datum.split('-').reverse().join('.') : '' %> <div class="flex items-center gap-3">
</td> <span class="inline-block w-2.5 h-2.5 rounded-full <%= statusDot[statusKey] || 'bg-gray-300' %>"></span>
<td class="td-firma"><%= b.firma %></td> <span class="text-sm font-semibold uppercase tracking-wider text-gray-700 dark:text-gray-200"><%= statusKey %></span>
<td class="td-stelle"><%= b.stelle %></td> <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>
<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>
</div> </div>
</td> </td>
</tr> </tr>
<% }) %>
</tbody> </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>
<% } %> <% } %>
</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">
<%# Modal: Neue / Bewerbung bearbeiten %> <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>
<div id="bewerbungModal" class="modal-backdrop" onclick="closeOnBackdrop(event,'bewerbungModal')">
<div class="modal-box" role="dialog" aria-modal="true" aria-labelledby="modalTitle">
<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"/>
</svg> </svg>
</button> <span class="text-xs font-semibold uppercase tracking-wider text-blue-600 dark:text-blue-400">Notizen</span>
</div> </div>
<p class="whitespace-pre-wrap break-words text-base leading-relaxed text-gray-800 dark:text-gray-200"><%= app.notizen %></p>
<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>
<% } %>
<div class="form-group"> <% if (hatInterne) { %>
<label class="form-label" for="art">Art der Bewerbung</label> <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">
<select id="art" name="art" class="form-select"> <div class="flex items-center gap-2 mb-2">
<option value="">— bitte wählen —</option> <svg class="w-4 h-4 text-amber-500 dark:text-amber-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<% artOptionen.forEach(a => { %> <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>
<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> </svg>
</button> <span class="text-xs font-semibold uppercase tracking-wider text-amber-600 dark:text-amber-400">Interne Notizen · nicht im PDF</span>
</div> </div>
<p class="whitespace-pre-wrap break-words text-base leading-relaxed text-gray-800 dark:text-gray-200"><%= app.interne_notizen %></p>
<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> </div>
<div> <% } %>
<p style="font-size:.9375rem;font-weight:600;color:var(--text);margin-bottom:.375rem;"> </td>
Sicher löschen? </tr>
</p> <% } %>
<p id="deleteInfo" style="font-size:.875rem;color:var(--text-2);margin-bottom:.5rem;"></p> </tbody>
<p style="font-size:.75rem;color:var(--text-muted);"> <% }); %>
Diese Aktion kann nicht rückgängig gemacht werden. <% }); %>
</p> <% } 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> </div>
</div> </main>
</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>
</form>
</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';
} %>
<%- include('partials/footer') %> <%- include('partials/footer') %>
<!-- 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="settingsForm" class="space-y-4">
<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>
<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>
<!-- Add/Edit Application Modal -->
<div id="applicationModal" class="fixed inset-0 bg-black bg-opacity-50 z-50 hidden">
<div class="flex items-center justify-center min-h-screen p-4">
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-xl w-full max-w-lg p-6">
<div class="flex justify-between items-center mb-6">
<h2 id="modalTitle" class="text-xl font-bold text-gray-800 dark:text-white">Bewerbung hinzufügen</h2>
<button id="closeApplicationModal" class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
</svg>
</button>
</div>
<form id="applicationForm" class="space-y-4">
<input type="hidden" id="applicationId" name="id">
<div>
<label for="applicationDatum" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Datum *
</label>
<input
type="date"
id="applicationDatum"
name="datum"
required
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-800 dark:text-white">
</div>
<div>
<label for="applicationFirma" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Firma *
</label>
<input
type="text"
id="applicationFirma"
name="firma"
required
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-800 dark:text-white"
placeholder="Firmenname">
</div>
<div>
<label for="applicationStelle" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Stelle *
</label>
<input
type="text"
id="applicationStelle"
name="stelle"
required
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-800 dark:text-white"
placeholder="Stellenbezeichnung">
</div>
<div>
<label for="applicationArt" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Art der Bewerbung
</label>
<select
id="applicationArt"
name="art"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-800 dark:text-white">
<option value="">-- Bitte wählen --</option>
<% artOptions.forEach(option => { %>
<option value="<%= option %>"><%= option %></option>
<% }); %>
</select>
</div>
<div>
<label 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>
+7 -8
View File
@@ -1,9 +1,8 @@
</div><%# /page-wrapper %> <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">
<footer class="app-footer"> <p class="text-gray-600 dark:text-gray-400 text-sm">
Bewerbungs-Tracker &mdash; Lokale Bewerbungsverwaltung Bewerbungs-Tracker für Jobcenter Grundsicherung |
<span id="currentYear"></span>
</p>
</div>
</footer> </footer>
<script src="/js/main.js"></script>
</body>
</html>
+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> <header class="bg-gradient-to-r from-blue-700 to-blue-900 dark:from-gray-800 dark:to-gray-900 shadow-lg">
<html lang="de"> <div class="container mx-auto px-4 py-4">
<head> <div class="flex items-center justify-between">
<meta charset="UTF-8"> <a href="/" class="flex items-center space-x-3">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <svg class="w-8 h-8 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<title>Bewerbungs-Tracker</title> <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>
<%# Synchronously apply saved dark mode preference before paint to prevent flash %> <h1 class="text-xl font-bold text-white">Bewerbungs-Tracker</h1>
<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
</a> </a>
<div class="nav-links"> <div class="flex items-center space-x-4">
<a href="/" class="nav-link <%= (typeof currentPage !== 'undefined' && currentPage === 'uebersicht') ? 'active' : '' %>">Übersicht</a> <!-- Dark mode toggle -->
<a href="/einstellungen" class="nav-link <%= (typeof currentPage !== 'undefined' && currentPage === 'einstellungen') ? 'active' : '' %>">Einstellungen</a> <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> </div>
</div>
<button id="darkModeToggle" class="dark-toggle" title="Darkmodus umschalten" aria-label="Darkmodus umschalten"></button> </div>
</nav> </header>
<div class="page-wrapper">