diff --git a/.dockerignore b/.dockerignore
deleted file mode 100644
index fc9d7e0..0000000
--- a/.dockerignore
+++ /dev/null
@@ -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
diff --git a/.gitignore b/.gitignore
index 2e8157a..aaa15f2 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,3 +1,39 @@
+# Dependencies
node_modules/
+
+# Database
+/data/
+*.db
+*.sqlite
+*.sqlite3
+
+# Environment
.env
+.env.local
+.env.*.local
+
+# Logs
+logs/
*.log
+npm-debug.log*
+
+# Build output
+dist/
+build/
+
+# IDE
+.idea/
+.vscode/
+*.swp
+*.swo
+
+# OS
+.DS_Store
+Thumbs.db
+
+# Test coverage
+coverage/
+
+# Temporary files
+tmp/
+temp/
diff --git a/.idea/.gitignore b/.idea/.gitignore
deleted file mode 100644
index 30cf57e..0000000
--- a/.idea/.gitignore
+++ /dev/null
@@ -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
diff --git a/.idea/jobbi-bewerbung.iml b/.idea/jobbi-bewerbung.iml
deleted file mode 100644
index c956989..0000000
--- a/.idea/jobbi-bewerbung.iml
+++ /dev/null
@@ -1,8 +0,0 @@
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/.idea/material_theme_project_new.xml b/.idea/material_theme_project_new.xml
deleted file mode 100644
index 704a6b0..0000000
--- a/.idea/material_theme_project_new.xml
+++ /dev/null
@@ -1,10 +0,0 @@
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/.idea/modules.xml b/.idea/modules.xml
deleted file mode 100644
index f29b82b..0000000
--- a/.idea/modules.xml
+++ /dev/null
@@ -1,8 +0,0 @@
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/Dockerfile b/Dockerfile
index d6284c3..e010d8e 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,35 +1,45 @@
-# ── Stage 1: Dependencies (mit Compiler für better-sqlite3) ──────────────────
-FROM node:20.19.2-slim AS deps
+# syntax=docker/dockerfile:1
+
+# ----- Build stage: install (and compile native sqlite3) deps -----
+FROM node:20-bookworm-slim AS builder
WORKDIR /app
-RUN apt-get update && apt-get install -y --no-install-recommends \
- python3 make g++ \
+# Build toolchain required to compile the native sqlite3 binding
+RUN apt-get update \
+ && apt-get install -y --no-install-recommends python3 make g++ \
&& rm -rf /var/lib/apt/lists/*
+# Install production dependencies deterministically (cached unless lockfile changes)
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
-# ── Stage 2: Runtime ──────────────────────────────────────────────────────────
-FROM node:20.19.2-slim AS runtime
+# ----- Runtime stage: slim image without build tooling -----
+FROM node:20-bookworm-slim AS runtime
+
+ENV NODE_ENV=production \
+ PORT=3000
WORKDIR /app
-RUN groupadd --system --gid 1001 nodejs \
- && useradd --system --uid 1001 --gid nodejs --no-create-home appuser
+# Bring in the already-installed (and compiled) dependencies
+COPY --from=builder /app/node_modules ./node_modules
-COPY --from=deps /app/node_modules ./node_modules
-COPY . .
-
-RUN mkdir -p data && chown -R appuser:nodejs data
+# Application source
+COPY package.json ./
+COPY server.js ./
+COPY views ./views
+COPY public ./public
+# Persisted SQLite data lives here; make it writable for the non-root user
+RUN mkdir -p /app/data && chown -R node:node /app
VOLUME ["/app/data"]
-USER appuser
+USER node
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
- CMD node -e "require('http').get('http://localhost:3000/',r=>process.exit(r.statusCode===200?0:1)).on('error',()=>process.exit(1))"
+ CMD node -e "fetch('http://localhost:'+(process.env.PORT||3000)+'/').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"
CMD ["node", "server.js"]
diff --git a/README.md b/README.md
index a53359e..0d9065b 100644
--- a/README.md
+++ b/README.md
@@ -1,110 +1,194 @@
# Bewerbungs-Tracker
-Lokale Web-Anwendung zur Verwaltung von Stellenbewerbungen – optimiert für die monatliche Nachweispflicht beim Jobcenter (Grundsicherung / Bürgergeld).
+Ein professioneller Job Application Tracker für Jobcenter Grundsicherung Monatsberichte.
-## Funktionen
+## Features
-- **Bewerbungen verwalten** – Anlegen, Bearbeiten, Löschen
-- **Monatsansicht** – Filter nach Monat und Jahr
-- **Statistiken** – Gesamt, Positiv, Absagen, Ausstehend
-- **PDF-Export** – Professionelles Dokument mit Ihren Daten für das Jobcenter
-- **Dunkelmodus** – Standard oder per Schalter umschaltbar
-- **Datenschutz** – Alle Daten bleiben lokal auf Ihrem Rechner (SQLite)
+- **Benutzerprofile**: Speichern Sie Name, Adresse und Jobcenter Kundennummer
+- **Dunkler Modus**: Vollständige Dark Mode Unterstützung mit lokaler Speicherung
+- **CRUD-Operationen**: Komplette Verwaltung von Bewerbungen (Hinzufügen, Bearbeiten, Löschen)
+- **Filterfunktion**: Filterung nach Monat und Jahr
+- **Statistiken**: Übersicht über Gesamtbewerbungen, nach Art und Status
+- **PDF-Export**: Professionelle PDF-Generierung für Jobcenter-Berichte
+- **Responsive Design**: Optimiert für Desktop und Mobile Geräte
+
+## Technologien
+
+- **Backend**: Node.js + Express.js
+- **Datenbank**: SQLite
+- **Frontend**: EJS Template Engine, Tailwind CSS (CDN)
+- **PDF-Generierung**: jsPDF + jspdf-autotable (CDN)
## Installation
### Voraussetzungen
-- Node.js ≥ 18 (https://nodejs.org)
-- npm (im Lieferumfang von Node.js)
-- Build-Tools (für `better-sqlite3`):
- - **Linux/Mac:** `build-essential` / Xcode Command Line Tools
- - **Windows:** `windows-build-tools` oder Visual Studio Build Tools
+- Node.js (Version 14 oder höher)
+- npm oder yarn
### Schritte
+1. **Projekt klonen**
```bash
-# 1. In das Projektverzeichnis wechseln
cd bewerbungs-tracker
+```
-# 2. Abhängigkeiten installieren
+2. **Abhängigkeiten installieren**
+```bash
npm install
+```
-# 3. Server starten
+3. **Datenbank-Verzeichnis erstellen**
+```bash
+mkdir -p data
+```
+
+Die SQLite-Datenbank wird automatisch beim ersten Start erstellt.
+
+4. **Server starten**
+```bash
npm start
```
-Die Anwendung ist dann unter **http://localhost:3000** erreichbar.
-
-### Entwicklungsmodus (Auto-Reload)
-
+Für Entwicklung mit automatischem Neuladen:
```bash
npm run dev
```
+5. **Anwendung öffnen**
+
+Öffnen Sie Ihren Browser und navigieren Sie zu:
+```
+http://localhost:3000
+```
+
## Projektstruktur
```
bewerbungs-tracker/
-├── server.js # Express-Server mit allen Routen
-├── package.json
+├── server.js # Express Server mit API-Routen
+├── package.json # Projektabhängigkeiten und Skripte
├── views/
-│ ├── index.ejs # Übersichtsseite
-│ ├── einstellungen.ejs # Einstellungsseite
+│ ├── index.ejs # Hauptseite
│ └── partials/
-│ ├── header.ejs # HTML-Head + Navigation
-│ └── footer.ejs # Abschlusselemente + Scripts
+│ ├── header.ejs # Kopfzeile mit Dark Mode Toggle
+│ └── footer.ejs # Fußzeile
├── public/
-│ ├── css/style.css # Tailwind-Utility-Klassen
-│ └── js/main.js # Dark Mode, Modals, PDF-Generierung
-└── data/
- └── bewerbungen.db # SQLite-Datenbank (wird automatisch erstellt)
+│ ├── css/
+│ │ └── styles.css # Benutzerdefinierte Styles
+│ └── js/
+│ └── main.js # Client-seitige Logik
+├── data/
+│ └── bewerbungen.db # SQLite Datenbank (wird automatisch erstellt)
+└── README.md # Dokumentation
```
## Datenbank-Schema
-```sql
--- Bewerbungen
-CREATE TABLE bewerbungen (
- id INTEGER PRIMARY KEY AUTOINCREMENT,
- datum DATE NOT NULL,
- firma TEXT NOT NULL,
- stelle TEXT NOT NULL,
- art TEXT,
- status TEXT,
- notizen TEXT,
- created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
- updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
-);
+### Bewerbungen
--- Benutzerprofil (wird im PDF verwendet)
-CREATE TABLE settings (
- id INTEGER PRIMARY KEY CHECK (id = 1),
- name TEXT,
- adresse TEXT,
- kundennummer TEXT
-);
-```
+| Feld | Typ | Beschreibung |
+|------|-----|--------------|
+| id | INTEGER PRIMARY KEY | Eindeutige ID |
+| datum | DATE | Bewerbungsdatum |
+| firma | TEXT | Firmenname |
+| stelle | TEXT | Stellenbezeichnung |
+| art | TEXT | Art der Bewerbung |
+| status | TEXT | Status der Bewerbung |
+| notizen | TEXT | Zusätzliche Notizen |
+| created_at | DATETIME | Erstellungsdatum |
+| updated_at | DATETIME | Letztes Update |
+
+### Einstellungen
+
+| Feld | Typ | Beschreibung |
+|------|-----|--------------|
+| id | INTEGER | Immer 1 (Single Row) |
+| name | TEXT | Benutzername |
+| adresse | TEXT | Benutzeradresse |
+| kundennummer | TEXT | Jobcenter Kundennummer |
+
+## Verwendbare Optionen
+
+### Art der Bewerbung
+- E-Mail
+- Online-Portal
+- Indeed
+- StepStone
+- Firmenwebsite
+- Post
+- Initiativbewerbung
+- Arbeitsagentur
+- Sonstiges
+
+### Status
+- Gesendet
+- Eingangsbestätigung
+- Vorstellungsgespräch
+- Absage
+- Einstellung
+- Keine Rückmeldung
+
+## API-Endpunkte
+
+### GET /
+Hauptseite mit allen Bewerbungen
+
+### GET /api/settings
+Benutzereinstellungen abrufen
+
+### POST /api/settings
+Benutzereinstellungen speichern
+
+### GET /api/bewerbungen
+Alle Bewerbungen abrufen (mit Filter: ?month=MM&year=YYYY)
+
+### POST /api/bewerbungen
+Neue Bewerbung erstellen
+
+### PUT /api/bewerbungen/:id
+Bewerbung aktualisieren
+
+### DELETE /api/bewerbungen/:id
+Bewerbung löschen
+
+### GET /api/bewerbungen/filter
+Bewerbungen mit Filter abrufen
## PDF-Export
-1. Öffnen Sie **Einstellungen** und tragen Sie Ihren Namen, Adresse und Kundennummer ein.
-2. Filtern Sie auf der Übersicht den gewünschten Monat.
-3. Klicken Sie auf **„PDF exportieren"**.
+Der PDF-Export generiert ein professionelles Dokument mit:
+- Benutzerdaten (Name, Adresse, Kundennummer)
+- Überschrift mit Monat und Jahr
+- Zusammenfassung der Bewerbungsaktivitäten
+- Tabelle mit allen Bewerbungen
+- Bestätigungstext und Datum
-Das PDF enthält:
-- Briefkopf mit Ihren persönlichen Daten
-- Titel „Bewerbungsaktivitäten – Monat Jahr"
-- Zusammenfassungssatz
-- Tabelle aller Bewerbungen des Monats
-- Unterschriftszeile mit Datum
+## Browser-Unterstützung
-## Port ändern
+- Chrome (empfohlen)
+- Firefox
+- Safari
+- Edge
-```bash
-PORT=8080 npm start
-```
+## Dark Mode
+
+Der Dark Mode kann manuell über den Toggle-Button in der Kopfzeile aktiviert werden. Die Einstellung wird in localStorage gespeichert und bleibt beim nächsten Besuch erhalten.
+
+## Sicherheit
+
+- Eingabefelder werden gegen XSS geschützt
+- SQL-Injection wird durch parametrisierte Abfragen verhindert
+- Formulare validieren Pflichtfelder
## Lizenz
MIT
+
+## Autor
+
+Bewerbungs-Tracker für Jobcenter Grundsicherung
+
+---
+
+**Hinweis**: Diese Anwendung ist speziell für die Anforderungen des deutschen Jobcenters (Grundsicherung) entwickelt worden. Sie hilft bei der Dokumentation von Bewerbungsaktivitäten für die monatlichen Berichte.
diff --git a/data/.gitkeep b/data/.gitkeep
deleted file mode 100644
index e69de29..0000000
diff --git a/data/bewerbungen.db b/data/bewerbungen.db
deleted file mode 100644
index 73a0692..0000000
Binary files a/data/bewerbungen.db and /dev/null differ
diff --git a/data/bewerbungen.db-shm b/data/bewerbungen.db-shm
deleted file mode 100644
index 642bf8f..0000000
Binary files a/data/bewerbungen.db-shm and /dev/null differ
diff --git a/data/bewerbungen.db-wal b/data/bewerbungen.db-wal
deleted file mode 100644
index 4fd213f..0000000
Binary files a/data/bewerbungen.db-wal and /dev/null differ
diff --git a/docker-compose.yml b/docker-compose.yml
deleted file mode 100644
index 7970b88..0000000
--- a/docker-compose.yml
+++ /dev/null
@@ -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:
diff --git a/package-lock.json b/package-lock.json
index 7cba42e..584b4fa 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -7,16 +7,84 @@
"": {
"name": "bewerbungs-tracker",
"version": "1.0.0",
+ "license": "MIT",
"dependencies": {
- "better-sqlite3": "^12.10.0",
- "ejs": "^3.1.10",
- "express": "^4.22.2",
- "method-override": "^3.0.0"
+ "ejs": "^3.1.9",
+ "express": "^4.18.2",
+ "jspdf": "^2.5.1",
+ "jspdf-autotable": "^3.8.2",
+ "sqlite3": "^5.1.6"
},
"devDependencies": {
"nodemon": "^3.0.2"
}
},
+ "node_modules/@babel/runtime": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz",
+ "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@gar/promisify": {
+ "version": "1.1.3",
+ "resolved": "https://registry.npmjs.org/@gar/promisify/-/promisify-1.1.3.tgz",
+ "integrity": "sha512-k2Ty1JcVojjJFwrg/ThKi2ujJ7XNLYaFGNB/bWT9wGR+oSMJHMa5w+CUq6p/pVrKeNNgA7pCqEcjSnHVoqJQFw==",
+ "license": "MIT",
+ "optional": true
+ },
+ "node_modules/@npmcli/fs": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/@npmcli/fs/-/fs-1.1.1.tgz",
+ "integrity": "sha512-8KG5RD0GVP4ydEzRn/I4BNDuxDtqVbOdm8675T49OIG/NGhaK0pjPX7ZcDlvKYbA+ulvVK3ztfcF4uBdOxuJbQ==",
+ "license": "ISC",
+ "optional": true,
+ "dependencies": {
+ "@gar/promisify": "^1.0.1",
+ "semver": "^7.3.5"
+ }
+ },
+ "node_modules/@npmcli/move-file": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/@npmcli/move-file/-/move-file-1.1.2.tgz",
+ "integrity": "sha512-1SUf/Cg2GzGDyaf15aR9St9TWlb+XvbZXWpDx8YKs7MLzMH/BCeopv+y9vzrzgkfykCGuWOlSu3mZhj2+FQcrg==",
+ "deprecated": "This functionality has been moved to @npmcli/fs",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "mkdirp": "^1.0.4",
+ "rimraf": "^3.0.2"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/@tootallnate/once": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-1.1.2.tgz",
+ "integrity": "sha512-RbzJvlNzmRq5c3O09UipeuXno4tA1FE6ikOjxZK0tuxVv3412l64l5t1W5pj4+rJq9vpkm/kwiR07aZXnsKPxw==",
+ "license": "MIT",
+ "optional": true,
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/@types/raf": {
+ "version": "3.4.3",
+ "resolved": "https://registry.npmjs.org/@types/raf/-/raf-3.4.3.tgz",
+ "integrity": "sha512-c4YAvMedbPZ5tEyxzQdMoOhhJ4RD3rngZIdwC2/qDN3d7JpEhB6fiBRKVY1lg5B7Wk+uPBjn5f39j1/2MY1oOw==",
+ "license": "MIT",
+ "optional": true
+ },
+ "node_modules/abbrev": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz",
+ "integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==",
+ "license": "ISC",
+ "optional": true
+ },
"node_modules/accepts": {
"version": "1.3.8",
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
@@ -30,6 +98,81 @@
"node": ">= 0.6"
}
},
+ "node_modules/agent-base": {
+ "version": "6.0.2",
+ "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz",
+ "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "debug": "4"
+ },
+ "engines": {
+ "node": ">= 6.0.0"
+ }
+ },
+ "node_modules/agent-base/node_modules/debug": {
+ "version": "4.4.3",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
+ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "ms": "^2.1.3"
+ },
+ "engines": {
+ "node": ">=6.0"
+ },
+ "peerDependenciesMeta": {
+ "supports-color": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/agent-base/node_modules/ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "license": "MIT",
+ "optional": true
+ },
+ "node_modules/agentkeepalive": {
+ "version": "4.6.0",
+ "resolved": "https://registry.npmjs.org/agentkeepalive/-/agentkeepalive-4.6.0.tgz",
+ "integrity": "sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ==",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "humanize-ms": "^1.2.1"
+ },
+ "engines": {
+ "node": ">= 8.0.0"
+ }
+ },
+ "node_modules/aggregate-error": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/aggregate-error/-/aggregate-error-3.1.0.tgz",
+ "integrity": "sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA==",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "clean-stack": "^2.0.0",
+ "indent-string": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/ansi-regex": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
+ "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
+ "license": "MIT",
+ "optional": true,
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/anymatch": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz",
@@ -44,6 +187,28 @@
"node": ">= 8"
}
},
+ "node_modules/aproba": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/aproba/-/aproba-2.1.0.tgz",
+ "integrity": "sha512-tLIEcj5GuR2RSTnxNKdkK0dJ/GrC7P38sUkiDmDuHfsHmbagTFAxDVIBltoklXEVIQ/f14IL8IMJ5pn9Hez1Ew==",
+ "license": "ISC",
+ "optional": true
+ },
+ "node_modules/are-we-there-yet": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/are-we-there-yet/-/are-we-there-yet-3.0.1.tgz",
+ "integrity": "sha512-QZW4EDmGwlYur0Yyf/b2uGucHQMa8aFUP7eu9ddR73vvhFyt4V0Vl3QHPcTNJ8l6qYOBdxgXdnBXQrHilfRQBg==",
+ "deprecated": "This package is no longer supported.",
+ "license": "ISC",
+ "optional": true,
+ "dependencies": {
+ "delegates": "^1.0.0",
+ "readable-stream": "^3.6.0"
+ },
+ "engines": {
+ "node": "^12.13.0 || ^14.15.0 || >=16.0.0"
+ }
+ },
"node_modules/array-flatten": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
@@ -56,12 +221,34 @@
"integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==",
"license": "MIT"
},
+ "node_modules/atob": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/atob/-/atob-2.1.2.tgz",
+ "integrity": "sha512-Wm6ukoaOGJi/73p/cl2GvLjTI5JM1k/O14isD73YML8StrH/7/lRFgmg8nICZgD3bZZvjwCGxtMOD3wWNAu8cg==",
+ "license": "(MIT OR Apache-2.0)",
+ "bin": {
+ "atob": "bin/atob.js"
+ },
+ "engines": {
+ "node": ">= 4.5.0"
+ }
+ },
"node_modules/balanced-match": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
"license": "MIT"
},
+ "node_modules/base64-arraybuffer": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz",
+ "integrity": "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==",
+ "license": "MIT",
+ "optional": true,
+ "engines": {
+ "node": ">= 0.6.0"
+ }
+ },
"node_modules/base64-js": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
@@ -82,20 +269,6 @@
],
"license": "MIT"
},
- "node_modules/better-sqlite3": {
- "version": "12.10.0",
- "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.10.0.tgz",
- "integrity": "sha512-CyzaZRQKyHkB2ZInfTTl2nvT33EbDpjkLEbE8/Zck3Ll6O0qqvuGdrJ45HgtH+HykRg88ITY3AdreBGN70aBSQ==",
- "hasInstallScript": true,
- "license": "MIT",
- "dependencies": {
- "bindings": "^1.5.0",
- "prebuild-install": "^7.1.1"
- },
- "engines": {
- "node": "20.x || 22.x || 23.x || 24.x || 25.x || 26.x"
- }
- },
"node_modules/binary-extensions": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
@@ -175,6 +348,18 @@
"node": ">=8"
}
},
+ "node_modules/btoa": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/btoa/-/btoa-1.2.1.tgz",
+ "integrity": "sha512-SB4/MIGlsiVkMcHmT+pSmIPoNDoHg+7cMzmt3Uxt628MTz2487DKSqK/fuhFBrkuqrYv5UCEnACpF4dTFNKc/g==",
+ "license": "(MIT OR Apache-2.0)",
+ "bin": {
+ "btoa": "bin/btoa.js"
+ },
+ "engines": {
+ "node": ">= 0.4.0"
+ }
+ },
"node_modules/buffer": {
"version": "5.7.1",
"resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz",
@@ -208,6 +393,36 @@
"node": ">= 0.8"
}
},
+ "node_modules/cacache": {
+ "version": "15.3.0",
+ "resolved": "https://registry.npmjs.org/cacache/-/cacache-15.3.0.tgz",
+ "integrity": "sha512-VVdYzXEn+cnbXpFgWs5hTT7OScegHVmLhJIR8Ufqk3iFD6A6j5iSX1KuBTfNEv4tdJWE2PzA6IVFtcLC7fN9wQ==",
+ "license": "ISC",
+ "optional": true,
+ "dependencies": {
+ "@npmcli/fs": "^1.0.0",
+ "@npmcli/move-file": "^1.0.1",
+ "chownr": "^2.0.0",
+ "fs-minipass": "^2.0.0",
+ "glob": "^7.1.4",
+ "infer-owner": "^1.0.4",
+ "lru-cache": "^6.0.0",
+ "minipass": "^3.1.1",
+ "minipass-collect": "^1.0.2",
+ "minipass-flush": "^1.0.5",
+ "minipass-pipeline": "^1.2.2",
+ "mkdirp": "^1.0.3",
+ "p-map": "^4.0.0",
+ "promise-inflight": "^1.0.1",
+ "rimraf": "^3.0.2",
+ "ssri": "^8.0.1",
+ "tar": "^6.0.2",
+ "unique-filename": "^1.1.1"
+ },
+ "engines": {
+ "node": ">= 10"
+ }
+ },
"node_modules/call-bind-apply-helpers": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
@@ -237,6 +452,26 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/canvg": {
+ "version": "3.0.11",
+ "resolved": "https://registry.npmjs.org/canvg/-/canvg-3.0.11.tgz",
+ "integrity": "sha512-5ON+q7jCTgMp9cjpu4Jo6XbvfYwSB2Ow3kzHKfIyJfaCAOHLbdKPQqGKgfED/R5B+3TFFfe8pegYA+b423SRyA==",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "@babel/runtime": "^7.12.5",
+ "@types/raf": "^3.4.0",
+ "core-js": "^3.8.3",
+ "raf": "^3.4.1",
+ "regenerator-runtime": "^0.13.7",
+ "rgbcolor": "^1.0.1",
+ "stackblur-canvas": "^2.0.0",
+ "svg-pathdata": "^6.0.3"
+ },
+ "engines": {
+ "node": ">=10.0.0"
+ }
+ },
"node_modules/chokidar": {
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
@@ -263,10 +498,47 @@
}
},
"node_modules/chownr": {
- "version": "1.1.4",
- "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz",
- "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==",
- "license": "ISC"
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz",
+ "integrity": "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/clean-stack": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-2.2.0.tgz",
+ "integrity": "sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A==",
+ "license": "MIT",
+ "optional": true,
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/color-support": {
+ "version": "1.1.3",
+ "resolved": "https://registry.npmjs.org/color-support/-/color-support-1.1.3.tgz",
+ "integrity": "sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg==",
+ "license": "ISC",
+ "optional": true,
+ "bin": {
+ "color-support": "bin.js"
+ }
+ },
+ "node_modules/concat-map": {
+ "version": "0.0.1",
+ "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
+ "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==",
+ "license": "MIT",
+ "optional": true
+ },
+ "node_modules/console-control-strings": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/console-control-strings/-/console-control-strings-1.1.0.tgz",
+ "integrity": "sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ==",
+ "license": "ISC",
+ "optional": true
},
"node_modules/content-disposition": {
"version": "0.5.4",
@@ -304,6 +576,28 @@
"integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==",
"license": "MIT"
},
+ "node_modules/core-js": {
+ "version": "3.49.0",
+ "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.49.0.tgz",
+ "integrity": "sha512-es1U2+YTtzpwkxVLwAFdSpaIMyQaq0PBgm3YD1W3Qpsn1NAmO3KSgZfu+oGSWVu6NvLHoHCV/aYcsE5wiB7ALg==",
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/core-js"
+ }
+ },
+ "node_modules/css-line-break": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/css-line-break/-/css-line-break-2.1.0.tgz",
+ "integrity": "sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "utrie": "^1.0.2"
+ }
+ },
"node_modules/debug": {
"version": "2.6.9",
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
@@ -337,6 +631,13 @@
"node": ">=4.0.0"
}
},
+ "node_modules/delegates": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz",
+ "integrity": "sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ==",
+ "license": "MIT",
+ "optional": true
+ },
"node_modules/depd": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
@@ -365,6 +666,13 @@
"node": ">=8"
}
},
+ "node_modules/dompurify": {
+ "version": "2.5.9",
+ "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-2.5.9.tgz",
+ "integrity": "sha512-i6mvVmWN4xo9LrhCOZrDgSs9noW6nOahbrmzjRbPF36YPyj5Ue5lgok0MHDWkG7xzpWFO2OYttXdzM7rJxHvNA==",
+ "license": "(MPL-2.0 OR Apache-2.0)",
+ "optional": true
+ },
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
@@ -400,6 +708,13 @@
"node": ">=0.10.0"
}
},
+ "node_modules/emoji-regex": {
+ "version": "8.0.0",
+ "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
+ "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
+ "license": "MIT",
+ "optional": true
+ },
"node_modules/encodeurl": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
@@ -409,6 +724,29 @@
"node": ">= 0.8"
}
},
+ "node_modules/encoding": {
+ "version": "0.1.13",
+ "resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.13.tgz",
+ "integrity": "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "iconv-lite": "^0.6.2"
+ }
+ },
+ "node_modules/encoding/node_modules/iconv-lite": {
+ "version": "0.6.3",
+ "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
+ "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "safer-buffer": ">= 2.1.2 < 3.0.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
"node_modules/end-of-stream": {
"version": "1.4.5",
"resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz",
@@ -418,6 +756,23 @@
"once": "^1.4.0"
}
},
+ "node_modules/env-paths": {
+ "version": "2.2.1",
+ "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz",
+ "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==",
+ "license": "MIT",
+ "optional": true,
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/err-code": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/err-code/-/err-code-2.0.3.tgz",
+ "integrity": "sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA==",
+ "license": "MIT",
+ "optional": true
+ },
"node_modules/es-define-property": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
@@ -518,6 +873,12 @@
"url": "https://opencollective.com/express"
}
},
+ "node_modules/fflate": {
+ "version": "0.8.3",
+ "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz",
+ "integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==",
+ "license": "MIT"
+ },
"node_modules/file-uri-to-path": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz",
@@ -588,6 +949,25 @@
"integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==",
"license": "MIT"
},
+ "node_modules/fs-minipass": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-2.1.0.tgz",
+ "integrity": "sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==",
+ "license": "ISC",
+ "dependencies": {
+ "minipass": "^3.0.0"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/fs.realpath": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz",
+ "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==",
+ "license": "ISC",
+ "optional": true
+ },
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
@@ -612,6 +992,27 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/gauge": {
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/gauge/-/gauge-4.0.4.tgz",
+ "integrity": "sha512-f9m+BEN5jkg6a0fZjleidjN51VE1X+mPFQ2DJ0uv1V39oCLCbsGe6yjbBnp7eK7z/+GAon99a3nHuqbuuthyPg==",
+ "deprecated": "This package is no longer supported.",
+ "license": "ISC",
+ "optional": true,
+ "dependencies": {
+ "aproba": "^1.0.3 || ^2.0.0",
+ "color-support": "^1.1.3",
+ "console-control-strings": "^1.1.0",
+ "has-unicode": "^2.0.1",
+ "signal-exit": "^3.0.7",
+ "string-width": "^4.2.3",
+ "strip-ansi": "^6.0.1",
+ "wide-align": "^1.1.5"
+ },
+ "engines": {
+ "node": "^12.13.0 || ^14.15.0 || >=16.0.0"
+ }
+ },
"node_modules/get-intrinsic": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
@@ -655,6 +1056,28 @@
"integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==",
"license": "MIT"
},
+ "node_modules/glob": {
+ "version": "7.2.3",
+ "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz",
+ "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==",
+ "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me",
+ "license": "ISC",
+ "optional": true,
+ "dependencies": {
+ "fs.realpath": "^1.0.0",
+ "inflight": "^1.0.4",
+ "inherits": "2",
+ "minimatch": "^3.1.1",
+ "once": "^1.3.0",
+ "path-is-absolute": "^1.0.0"
+ },
+ "engines": {
+ "node": "*"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
"node_modules/glob-parent": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
@@ -668,6 +1091,30 @@
"node": ">= 6"
}
},
+ "node_modules/glob/node_modules/brace-expansion": {
+ "version": "1.1.15",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz",
+ "integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "balanced-match": "^1.0.0",
+ "concat-map": "0.0.1"
+ }
+ },
+ "node_modules/glob/node_modules/minimatch": {
+ "version": "3.1.5",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
+ "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
+ "license": "ISC",
+ "optional": true,
+ "dependencies": {
+ "brace-expansion": "^1.1.7"
+ },
+ "engines": {
+ "node": "*"
+ }
+ },
"node_modules/gopd": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
@@ -680,6 +1127,13 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/graceful-fs": {
+ "version": "4.2.11",
+ "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
+ "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
+ "license": "ISC",
+ "optional": true
+ },
"node_modules/has-flag": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz",
@@ -702,6 +1156,13 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/has-unicode": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/has-unicode/-/has-unicode-2.0.1.tgz",
+ "integrity": "sha512-8Rf9Y83NBReMnx0gFzA8JImQACstCYWUplepDa9xprwwtmgEZUF0h/i5xSA625zB/I37EtrswSST6OXxwaaIJQ==",
+ "license": "ISC",
+ "optional": true
+ },
"node_modules/hasown": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
@@ -714,6 +1175,27 @@
"node": ">= 0.4"
}
},
+ "node_modules/html2canvas": {
+ "version": "1.4.1",
+ "resolved": "https://registry.npmjs.org/html2canvas/-/html2canvas-1.4.1.tgz",
+ "integrity": "sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "css-line-break": "^2.1.0",
+ "text-segmentation": "^1.0.3"
+ },
+ "engines": {
+ "node": ">=8.0.0"
+ }
+ },
+ "node_modules/http-cache-semantics": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz",
+ "integrity": "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==",
+ "license": "BSD-2-Clause",
+ "optional": true
+ },
"node_modules/http-errors": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
@@ -734,6 +1216,95 @@
"url": "https://opencollective.com/express"
}
},
+ "node_modules/http-proxy-agent": {
+ "version": "4.0.1",
+ "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-4.0.1.tgz",
+ "integrity": "sha512-k0zdNgqWTGA6aeIRVpvfVob4fL52dTfaehylg0Y4UvSySvOq/Y+BOyPrgpUrA7HylqvU8vIZGsRuXmspskV0Tg==",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "@tootallnate/once": "1",
+ "agent-base": "6",
+ "debug": "4"
+ },
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/http-proxy-agent/node_modules/debug": {
+ "version": "4.4.3",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
+ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "ms": "^2.1.3"
+ },
+ "engines": {
+ "node": ">=6.0"
+ },
+ "peerDependenciesMeta": {
+ "supports-color": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/http-proxy-agent/node_modules/ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "license": "MIT",
+ "optional": true
+ },
+ "node_modules/https-proxy-agent": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz",
+ "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "agent-base": "6",
+ "debug": "4"
+ },
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/https-proxy-agent/node_modules/debug": {
+ "version": "4.4.3",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
+ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "ms": "^2.1.3"
+ },
+ "engines": {
+ "node": ">=6.0"
+ },
+ "peerDependenciesMeta": {
+ "supports-color": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/https-proxy-agent/node_modules/ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "license": "MIT",
+ "optional": true
+ },
+ "node_modules/humanize-ms": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/humanize-ms/-/humanize-ms-1.2.1.tgz",
+ "integrity": "sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "ms": "^2.0.0"
+ }
+ },
"node_modules/iconv-lite": {
"version": "0.4.24",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
@@ -773,6 +1344,45 @@
"dev": true,
"license": "ISC"
},
+ "node_modules/imurmurhash": {
+ "version": "0.1.4",
+ "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz",
+ "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==",
+ "license": "MIT",
+ "optional": true,
+ "engines": {
+ "node": ">=0.8.19"
+ }
+ },
+ "node_modules/indent-string": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz",
+ "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==",
+ "license": "MIT",
+ "optional": true,
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/infer-owner": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/infer-owner/-/infer-owner-1.0.4.tgz",
+ "integrity": "sha512-IClj+Xz94+d7irH5qRyfJonOdfTzuDaifE6ZPWfx0N0+/ATZCbuTPq2prFl526urkQd90WyUKIh1DfBQ2hMz9A==",
+ "license": "ISC",
+ "optional": true
+ },
+ "node_modules/inflight": {
+ "version": "1.0.6",
+ "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz",
+ "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==",
+ "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.",
+ "license": "ISC",
+ "optional": true,
+ "dependencies": {
+ "once": "^1.3.0",
+ "wrappy": "1"
+ }
+ },
"node_modules/inherits": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
@@ -785,6 +1395,16 @@
"integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",
"license": "ISC"
},
+ "node_modules/ip-address": {
+ "version": "10.2.0",
+ "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz",
+ "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==",
+ "license": "MIT",
+ "optional": true,
+ "engines": {
+ "node": ">= 12"
+ }
+ },
"node_modules/ipaddr.js": {
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
@@ -817,6 +1437,16 @@
"node": ">=0.10.0"
}
},
+ "node_modules/is-fullwidth-code-point": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
+ "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
+ "license": "MIT",
+ "optional": true,
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/is-glob": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
@@ -830,6 +1460,13 @@
"node": ">=0.10.0"
}
},
+ "node_modules/is-lambda": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/is-lambda/-/is-lambda-1.0.1.tgz",
+ "integrity": "sha512-z7CMFGNrENq5iFB9Bqo64Xk6Y9sg+epq1myIcdHaGnbMTYOxvzsEtdYqQUylB7LxfkvgrrjP32T6Ywciio9UIQ==",
+ "license": "MIT",
+ "optional": true
+ },
"node_modules/is-number": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
@@ -840,6 +1477,13 @@
"node": ">=0.12.0"
}
},
+ "node_modules/isexe": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
+ "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
+ "license": "ISC",
+ "optional": true
+ },
"node_modules/jake": {
"version": "10.9.4",
"resolved": "https://registry.npmjs.org/jake/-/jake-10.9.4.tgz",
@@ -857,6 +1501,74 @@
"node": ">=10"
}
},
+ "node_modules/jspdf": {
+ "version": "2.5.2",
+ "resolved": "https://registry.npmjs.org/jspdf/-/jspdf-2.5.2.tgz",
+ "integrity": "sha512-myeX9c+p7znDWPk0eTrujCzNjT+CXdXyk7YmJq5nD5V7uLLKmSXnlQ/Jn/kuo3X09Op70Apm0rQSnFWyGK8uEQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/runtime": "^7.23.2",
+ "atob": "^2.1.2",
+ "btoa": "^1.2.1",
+ "fflate": "^0.8.1"
+ },
+ "optionalDependencies": {
+ "canvg": "^3.0.6",
+ "core-js": "^3.6.0",
+ "dompurify": "^2.5.4",
+ "html2canvas": "^1.0.0-rc.5"
+ }
+ },
+ "node_modules/jspdf-autotable": {
+ "version": "3.8.4",
+ "resolved": "https://registry.npmjs.org/jspdf-autotable/-/jspdf-autotable-3.8.4.tgz",
+ "integrity": "sha512-rSffGoBsJYX83iTRv8Ft7FhqfgEL2nLpGAIiqruEQQ3e4r0qdLFbPUB7N9HAle0I3XgpisvyW751VHCqKUVOgQ==",
+ "license": "MIT",
+ "peerDependencies": {
+ "jspdf": "^2.5.1"
+ }
+ },
+ "node_modules/lru-cache": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
+ "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==",
+ "license": "ISC",
+ "optional": true,
+ "dependencies": {
+ "yallist": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/make-fetch-happen": {
+ "version": "9.1.0",
+ "resolved": "https://registry.npmjs.org/make-fetch-happen/-/make-fetch-happen-9.1.0.tgz",
+ "integrity": "sha512-+zopwDy7DNknmwPQplem5lAZX/eCOzSvSNNcSKm5eVwTkOBzoktEfXsa9L23J/GIRhxRsaxzkPEhrJEpE2F4Gg==",
+ "license": "ISC",
+ "optional": true,
+ "dependencies": {
+ "agentkeepalive": "^4.1.3",
+ "cacache": "^15.2.0",
+ "http-cache-semantics": "^4.1.0",
+ "http-proxy-agent": "^4.0.1",
+ "https-proxy-agent": "^5.0.0",
+ "is-lambda": "^1.0.1",
+ "lru-cache": "^6.0.0",
+ "minipass": "^3.1.3",
+ "minipass-collect": "^1.0.2",
+ "minipass-fetch": "^1.3.2",
+ "minipass-flush": "^1.0.5",
+ "minipass-pipeline": "^1.2.4",
+ "negotiator": "^0.6.2",
+ "promise-retry": "^2.0.1",
+ "socks-proxy-agent": "^6.0.0",
+ "ssri": "^8.0.0"
+ },
+ "engines": {
+ "node": ">= 10"
+ }
+ },
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
@@ -884,30 +1596,6 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
- "node_modules/method-override": {
- "version": "3.0.0",
- "resolved": "https://registry.npmjs.org/method-override/-/method-override-3.0.0.tgz",
- "integrity": "sha512-IJ2NNN/mSl9w3kzWB92rcdHpz+HjkxhDJWNDBqSlas+zQdP8wBiJzITPg08M/k2uVvMow7Sk41atndNtt/PHSA==",
- "license": "MIT",
- "dependencies": {
- "debug": "3.1.0",
- "methods": "~1.1.2",
- "parseurl": "~1.3.2",
- "vary": "~1.1.2"
- },
- "engines": {
- "node": ">= 0.10"
- }
- },
- "node_modules/method-override/node_modules/debug": {
- "version": "3.1.0",
- "resolved": "https://registry.npmjs.org/debug/-/debug-3.1.0.tgz",
- "integrity": "sha512-OX8XqP7/1a9cqkxYw2yXss15f26NKWBpDXQd0/uK/KPqdQhxbPa994hnzjcE2VqQpDslf55723cKPUOGSmMY3g==",
- "license": "MIT",
- "dependencies": {
- "ms": "2.0.0"
- }
- },
"node_modules/methods": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz",
@@ -983,6 +1671,113 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/minipass": {
+ "version": "3.3.6",
+ "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz",
+ "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==",
+ "license": "ISC",
+ "dependencies": {
+ "yallist": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/minipass-collect": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/minipass-collect/-/minipass-collect-1.0.2.tgz",
+ "integrity": "sha512-6T6lH0H8OG9kITm/Jm6tdooIbogG9e0tLgpY6mphXSm/A9u8Nq1ryBG+Qspiub9LjWlBPsPS3tWQ/Botq4FdxA==",
+ "license": "ISC",
+ "optional": true,
+ "dependencies": {
+ "minipass": "^3.0.0"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/minipass-fetch": {
+ "version": "1.4.1",
+ "resolved": "https://registry.npmjs.org/minipass-fetch/-/minipass-fetch-1.4.1.tgz",
+ "integrity": "sha512-CGH1eblLq26Y15+Azk7ey4xh0J/XfJfrCox5LDJiKqI2Q2iwOLOKrlmIaODiSQS8d18jalF6y2K2ePUm0CmShw==",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "minipass": "^3.1.0",
+ "minipass-sized": "^1.0.3",
+ "minizlib": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ },
+ "optionalDependencies": {
+ "encoding": "^0.1.12"
+ }
+ },
+ "node_modules/minipass-flush": {
+ "version": "1.0.7",
+ "resolved": "https://registry.npmjs.org/minipass-flush/-/minipass-flush-1.0.7.tgz",
+ "integrity": "sha512-TbqTz9cUwWyHS2Dy89P3ocAGUGxKjjLuR9z8w4WUTGAVgEj17/4nhgo2Du56i0Fm3Pm30g4iA8Lcqctc76jCzA==",
+ "license": "BlueOak-1.0.0",
+ "optional": true,
+ "dependencies": {
+ "minipass": "^3.0.0"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/minipass-pipeline": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/minipass-pipeline/-/minipass-pipeline-1.2.4.tgz",
+ "integrity": "sha512-xuIq7cIOt09RPRJ19gdi4b+RiNvDFYe5JH+ggNvBqGqpQXcru3PcRmOZuHBKWK1Txf9+cQ+HMVN4d6z46LZP7A==",
+ "license": "ISC",
+ "optional": true,
+ "dependencies": {
+ "minipass": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/minipass-sized": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/minipass-sized/-/minipass-sized-1.0.3.tgz",
+ "integrity": "sha512-MbkQQ2CTiBMlA2Dm/5cY+9SWFEN8pzzOXi6rlM5Xxq0Yqbda5ZQy9sU75a673FE9ZK0Zsbr6Y5iP6u9nktfg2g==",
+ "license": "ISC",
+ "optional": true,
+ "dependencies": {
+ "minipass": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/minizlib": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-2.1.2.tgz",
+ "integrity": "sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==",
+ "license": "MIT",
+ "dependencies": {
+ "minipass": "^3.0.0",
+ "yallist": "^4.0.0"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/mkdirp": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz",
+ "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==",
+ "license": "MIT",
+ "bin": {
+ "mkdirp": "bin/cmd.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
"node_modules/mkdirp-classic": {
"version": "0.5.3",
"resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz",
@@ -1022,6 +1817,37 @@
"node": ">=10"
}
},
+ "node_modules/node-addon-api": {
+ "version": "7.1.1",
+ "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz",
+ "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==",
+ "license": "MIT"
+ },
+ "node_modules/node-gyp": {
+ "version": "8.4.1",
+ "resolved": "https://registry.npmjs.org/node-gyp/-/node-gyp-8.4.1.tgz",
+ "integrity": "sha512-olTJRgUtAb/hOXG0E93wZDs5YiJlgbXxTwQAFHyNlRsXQnYzUaF2aGgujZbw+hR8aF4ZG/rST57bWMWD16jr9w==",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "env-paths": "^2.2.0",
+ "glob": "^7.1.4",
+ "graceful-fs": "^4.2.6",
+ "make-fetch-happen": "^9.1.0",
+ "nopt": "^5.0.0",
+ "npmlog": "^6.0.0",
+ "rimraf": "^3.0.2",
+ "semver": "^7.3.5",
+ "tar": "^6.1.2",
+ "which": "^2.0.2"
+ },
+ "bin": {
+ "node-gyp": "bin/node-gyp.js"
+ },
+ "engines": {
+ "node": ">= 10.12.0"
+ }
+ },
"node_modules/nodemon": {
"version": "3.1.14",
"resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.14.tgz",
@@ -1115,6 +1941,22 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/nopt": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/nopt/-/nopt-5.0.0.tgz",
+ "integrity": "sha512-Tbj67rffqceeLpcRXrT7vKAN8CwfPeIBgM7E6iBkmKLV7bEMwpGgYLGv0jACUsECaa/vuxP0IjEont6umdMgtQ==",
+ "license": "ISC",
+ "optional": true,
+ "dependencies": {
+ "abbrev": "1"
+ },
+ "bin": {
+ "nopt": "bin/nopt.js"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
"node_modules/normalize-path": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
@@ -1125,6 +1967,23 @@
"node": ">=0.10.0"
}
},
+ "node_modules/npmlog": {
+ "version": "6.0.2",
+ "resolved": "https://registry.npmjs.org/npmlog/-/npmlog-6.0.2.tgz",
+ "integrity": "sha512-/vBvz5Jfr9dT/aFWd0FIRf+T/Q2WBsLENygUaFUqstqsycmZAP/t5BvFJTK0viFmSUxiUKTUplWy5vt+rvKIxg==",
+ "deprecated": "This package is no longer supported.",
+ "license": "ISC",
+ "optional": true,
+ "dependencies": {
+ "are-we-there-yet": "^3.0.0",
+ "console-control-strings": "^1.1.0",
+ "gauge": "^4.0.3",
+ "set-blocking": "^2.0.0"
+ },
+ "engines": {
+ "node": "^12.13.0 || ^14.15.0 || >=16.0.0"
+ }
+ },
"node_modules/object-inspect": {
"version": "1.13.4",
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
@@ -1158,6 +2017,22 @@
"wrappy": "1"
}
},
+ "node_modules/p-map": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/p-map/-/p-map-4.0.0.tgz",
+ "integrity": "sha512-/bjOqmgETBYB5BoEeGVea8dmvHb2m9GLy1E9W43yeyfP6QQCZGFNa+XRceJEuDB6zqr+gKpIAmlLebMpykw/MQ==",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "aggregate-error": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
"node_modules/parseurl": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
@@ -1167,12 +2042,29 @@
"node": ">= 0.8"
}
},
+ "node_modules/path-is-absolute": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz",
+ "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==",
+ "license": "MIT",
+ "optional": true,
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
"node_modules/path-to-regexp": {
"version": "0.1.13",
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz",
"integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==",
"license": "MIT"
},
+ "node_modules/performance-now": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz",
+ "integrity": "sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==",
+ "license": "MIT",
+ "optional": true
+ },
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -1219,6 +2111,27 @@
"node": ">=10"
}
},
+ "node_modules/promise-inflight": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/promise-inflight/-/promise-inflight-1.0.1.tgz",
+ "integrity": "sha512-6zWPyEOFaQBJYcGMHBKTKJ3u6TBsnMFOIZSa6ce1e/ZrrsOlnHRHbabMjLiBYKp+n44X9eUI6VUPaukCXHuG4g==",
+ "license": "ISC",
+ "optional": true
+ },
+ "node_modules/promise-retry": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/promise-retry/-/promise-retry-2.0.1.tgz",
+ "integrity": "sha512-y+WKFlBR8BGXnsNlIHFGPZmyDf3DFMoLhaflAnyZgV6rG6xu+JwesTo2Q9R6XwYmtmwAFCkAk3e35jEdoeh/3g==",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "err-code": "^2.0.2",
+ "retry": "^0.12.0"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
"node_modules/proxy-addr": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
@@ -1264,6 +2177,16 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/raf": {
+ "version": "3.4.1",
+ "resolved": "https://registry.npmjs.org/raf/-/raf-3.4.1.tgz",
+ "integrity": "sha512-Sq4CW4QhwOHE8ucn6J34MqtZCeWFP2aQSmrlroYgqAV1PjStIhJXxYuTgUIfkEk7zTLjmIjLmU5q+fbD1NnOJA==",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "performance-now": "^2.1.0"
+ }
+ },
"node_modules/range-parser": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
@@ -1330,6 +2253,50 @@
"node": ">=8.10.0"
}
},
+ "node_modules/regenerator-runtime": {
+ "version": "0.13.11",
+ "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz",
+ "integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==",
+ "license": "MIT",
+ "optional": true
+ },
+ "node_modules/retry": {
+ "version": "0.12.0",
+ "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz",
+ "integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==",
+ "license": "MIT",
+ "optional": true,
+ "engines": {
+ "node": ">= 4"
+ }
+ },
+ "node_modules/rgbcolor": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/rgbcolor/-/rgbcolor-1.0.1.tgz",
+ "integrity": "sha512-9aZLIrhRaD97sgVhtJOW6ckOEh6/GnvQtdVNfdZ6s67+3/XwLS9lBcQYzEEhYVeUowN7pRzMLsyGhK2i/xvWbw==",
+ "license": "MIT OR SEE LICENSE IN FEEL-FREE.md",
+ "optional": true,
+ "engines": {
+ "node": ">= 0.8.15"
+ }
+ },
+ "node_modules/rimraf": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz",
+ "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==",
+ "deprecated": "Rimraf versions prior to v4 are no longer supported",
+ "license": "ISC",
+ "optional": true,
+ "dependencies": {
+ "glob": "^7.1.3"
+ },
+ "bin": {
+ "rimraf": "bin.js"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
"node_modules/safe-buffer": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
@@ -1413,6 +2380,13 @@
"node": ">= 0.8.0"
}
},
+ "node_modules/set-blocking": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
+ "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==",
+ "license": "ISC",
+ "optional": true
+ },
"node_modules/setprototypeof": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
@@ -1491,6 +2465,13 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/signal-exit": {
+ "version": "3.0.7",
+ "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz",
+ "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==",
+ "license": "ISC",
+ "optional": true
+ },
"node_modules/simple-concat": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz",
@@ -1549,6 +2530,119 @@
"node": ">=10"
}
},
+ "node_modules/smart-buffer": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz",
+ "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==",
+ "license": "MIT",
+ "optional": true,
+ "engines": {
+ "node": ">= 6.0.0",
+ "npm": ">= 3.0.0"
+ }
+ },
+ "node_modules/socks": {
+ "version": "2.8.9",
+ "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.9.tgz",
+ "integrity": "sha512-LJhUYUvItdQ0LkJTmPeaEObWXAqFyfmP85x0tch/ez9cahmhlBBLbIqDFnvBnUJGagb0JbIQrkBs1wJ+yRYpEw==",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "ip-address": "^10.1.1",
+ "smart-buffer": "^4.2.0"
+ },
+ "engines": {
+ "node": ">= 10.0.0",
+ "npm": ">= 3.0.0"
+ }
+ },
+ "node_modules/socks-proxy-agent": {
+ "version": "6.2.1",
+ "resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-6.2.1.tgz",
+ "integrity": "sha512-a6KW9G+6B3nWZ1yB8G7pJwL3ggLy1uTzKAgCb7ttblwqdz9fMGJUuTy3uFzEP48FAs9FLILlmzDlE2JJhVQaXQ==",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "agent-base": "^6.0.2",
+ "debug": "^4.3.3",
+ "socks": "^2.6.2"
+ },
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/socks-proxy-agent/node_modules/debug": {
+ "version": "4.4.3",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
+ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "ms": "^2.1.3"
+ },
+ "engines": {
+ "node": ">=6.0"
+ },
+ "peerDependenciesMeta": {
+ "supports-color": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/socks-proxy-agent/node_modules/ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "license": "MIT",
+ "optional": true
+ },
+ "node_modules/sqlite3": {
+ "version": "5.1.7",
+ "resolved": "https://registry.npmjs.org/sqlite3/-/sqlite3-5.1.7.tgz",
+ "integrity": "sha512-GGIyOiFaG+TUra3JIfkI/zGP8yZYLPQ0pl1bH+ODjiX57sPhrLU5sQJn1y9bDKZUFYkX1crlrPfSYt0BKKdkog==",
+ "hasInstallScript": true,
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "bindings": "^1.5.0",
+ "node-addon-api": "^7.0.0",
+ "prebuild-install": "^7.1.1",
+ "tar": "^6.1.11"
+ },
+ "optionalDependencies": {
+ "node-gyp": "8.x"
+ },
+ "peerDependencies": {
+ "node-gyp": "8.x"
+ },
+ "peerDependenciesMeta": {
+ "node-gyp": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/ssri": {
+ "version": "8.0.1",
+ "resolved": "https://registry.npmjs.org/ssri/-/ssri-8.0.1.tgz",
+ "integrity": "sha512-97qShzy1AiyxvPNIkLWoGua7xoQzzPjQ0HAH4B0rWKo7SZ6USuPcrUiAFrws0UH8RrbWmgq3LMTObhPIHbbBeQ==",
+ "license": "ISC",
+ "optional": true,
+ "dependencies": {
+ "minipass": "^3.1.1"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/stackblur-canvas": {
+ "version": "2.7.0",
+ "resolved": "https://registry.npmjs.org/stackblur-canvas/-/stackblur-canvas-2.7.0.tgz",
+ "integrity": "sha512-yf7OENo23AGJhBriGx0QivY5JP6Y1HbrrDI6WLt6C5auYZXlQrheoY8hD4ibekFKz1HOfE48Ww8kMWMnJD/zcQ==",
+ "license": "MIT",
+ "optional": true,
+ "engines": {
+ "node": ">=0.1.14"
+ }
+ },
"node_modules/statuses": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
@@ -1567,6 +2661,34 @@
"safe-buffer": "~5.2.0"
}
},
+ "node_modules/string-width": {
+ "version": "4.2.3",
+ "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
+ "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "emoji-regex": "^8.0.0",
+ "is-fullwidth-code-point": "^3.0.0",
+ "strip-ansi": "^6.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/strip-ansi": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
+ "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "ansi-regex": "^5.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/strip-json-comments": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz",
@@ -1589,6 +2711,34 @@
"node": ">=4"
}
},
+ "node_modules/svg-pathdata": {
+ "version": "6.0.3",
+ "resolved": "https://registry.npmjs.org/svg-pathdata/-/svg-pathdata-6.0.3.tgz",
+ "integrity": "sha512-qsjeeq5YjBZ5eMdFuUa4ZosMLxgr5RZ+F+Y1OrDhuOCEInRMA3x74XdBtggJcj9kOeInz0WE+LgCPDkZFlBYJw==",
+ "license": "MIT",
+ "optional": true,
+ "engines": {
+ "node": ">=12.0.0"
+ }
+ },
+ "node_modules/tar": {
+ "version": "6.2.1",
+ "resolved": "https://registry.npmjs.org/tar/-/tar-6.2.1.tgz",
+ "integrity": "sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==",
+ "deprecated": "Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me",
+ "license": "ISC",
+ "dependencies": {
+ "chownr": "^2.0.0",
+ "fs-minipass": "^2.0.0",
+ "minipass": "^5.0.0",
+ "minizlib": "^2.1.1",
+ "mkdirp": "^1.0.3",
+ "yallist": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
"node_modules/tar-fs": {
"version": "2.1.4",
"resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz",
@@ -1601,6 +2751,12 @@
"tar-stream": "^2.1.4"
}
},
+ "node_modules/tar-fs/node_modules/chownr": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz",
+ "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==",
+ "license": "ISC"
+ },
"node_modules/tar-stream": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz",
@@ -1617,6 +2773,25 @@
"node": ">=6"
}
},
+ "node_modules/tar/node_modules/minipass": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/minipass/-/minipass-5.0.0.tgz",
+ "integrity": "sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/text-segmentation": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/text-segmentation/-/text-segmentation-1.0.3.tgz",
+ "integrity": "sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw==",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "utrie": "^1.0.2"
+ }
+ },
"node_modules/to-regex-range": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
@@ -1681,6 +2856,26 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/unique-filename": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/unique-filename/-/unique-filename-1.1.1.tgz",
+ "integrity": "sha512-Vmp0jIp2ln35UTXuryvjzkjGdRyf9b2lTXuSYUiPmzRcl3FDtYqAwOnTJkAngD9SWhnoJzDbTKwaOrZ+STtxNQ==",
+ "license": "ISC",
+ "optional": true,
+ "dependencies": {
+ "unique-slug": "^2.0.0"
+ }
+ },
+ "node_modules/unique-slug": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/unique-slug/-/unique-slug-2.0.2.tgz",
+ "integrity": "sha512-zoWr9ObaxALD3DOPfjPSqxt4fnZiWblxHIgeWqW8x7UqDzEtHEQLzji2cuJYQFCU6KmoJikOYAZlrTHHebjx2w==",
+ "license": "ISC",
+ "optional": true,
+ "dependencies": {
+ "imurmurhash": "^0.1.4"
+ }
+ },
"node_modules/unpipe": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
@@ -1705,6 +2900,16 @@
"node": ">= 0.4.0"
}
},
+ "node_modules/utrie": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/utrie/-/utrie-1.0.2.tgz",
+ "integrity": "sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "base64-arraybuffer": "^1.0.2"
+ }
+ },
"node_modules/vary": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
@@ -1714,11 +2919,43 @@
"node": ">= 0.8"
}
},
+ "node_modules/which": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
+ "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
+ "license": "ISC",
+ "optional": true,
+ "dependencies": {
+ "isexe": "^2.0.0"
+ },
+ "bin": {
+ "node-which": "bin/node-which"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/wide-align": {
+ "version": "1.1.5",
+ "resolved": "https://registry.npmjs.org/wide-align/-/wide-align-1.1.5.tgz",
+ "integrity": "sha512-eDMORYaPNZ4sQIuuYPDHdQvf4gyCF9rEEV/yPxGfwPkRodwEgiMUUXTx/dex+Me0wxx53S+NgUHaP7y3MGlDmg==",
+ "license": "ISC",
+ "optional": true,
+ "dependencies": {
+ "string-width": "^1.0.2 || 2 || 3 || 4"
+ }
+ },
"node_modules/wrappy": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
"license": "ISC"
+ },
+ "node_modules/yallist": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
+ "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
+ "license": "ISC"
}
}
}
diff --git a/package.json b/package.json
index c5ecd76..85c67ab 100644
--- a/package.json
+++ b/package.json
@@ -1,19 +1,33 @@
{
"name": "bewerbungs-tracker",
"version": "1.0.0",
- "description": "Bewerbungs-Tracker für die monatliche Jobcenter-Berichterstattung",
+ "description": "Job Application Tracker für Jobcenter Grundsicherung Monatsberichte",
"main": "server.js",
"scripts": {
"start": "node server.js",
- "dev": "nodemon server.js"
+ "dev": "nodemon server.js",
+ "docker:build": "docker build -t git.hackner.dev/thomas/jobbi-bewerbung:latest .",
+ "docker:push": "docker push git.hackner.dev/thomas/jobbi-bewerbung:latest",
+ "docker:deploy": "npm run docker:build && npm run docker:push"
},
"dependencies": {
- "better-sqlite3": "^12.10.0",
- "ejs": "^3.1.10",
- "express": "^4.22.2",
- "method-override": "^3.0.0"
+ "ejs": "^3.1.9",
+ "express": "^4.18.2",
+ "jspdf": "^2.5.1",
+ "jspdf-autotable": "^3.8.2",
+ "sqlite3": "^5.1.6"
},
"devDependencies": {
"nodemon": "^3.0.2"
- }
+ },
+ "keywords": [
+ "job",
+ "application",
+ "tracker",
+ "jobcenter",
+ "grundsicherung",
+ "bewerbung"
+ ],
+ "author": "",
+ "license": "MIT"
}
diff --git a/public/css/style.css b/public/css/style.css
deleted file mode 100644
index 469f0c2..0000000
--- a/public/css/style.css
+++ /dev/null
@@ -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); }
diff --git a/public/css/styles.css b/public/css/styles.css
new file mode 100644
index 0000000..9d384fa
--- /dev/null
+++ b/public/css/styles.css
@@ -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;
+}
diff --git a/public/js/main.js b/public/js/main.js
index 2d50b46..9d64b28 100644
--- a/public/js/main.js
+++ b/public/js/main.js
@@ -1,267 +1,624 @@
-'use strict';
+// ============================================
+// Bewerbungs-Tracker - Client-side JavaScript
+// ============================================
-// ── Dark mode toggle ──────────────────────────────────────────────────────────
+// DOM Elements
+const body = document.getElementById('body');
+const darkModeToggle = document.getElementById('darkModeToggle');
+const sunIcon = document.getElementById('sunIcon');
+const moonIcon = document.getElementById('moonIcon');
-document.getElementById('darkModeToggle').addEventListener('click', () => {
- const isDark = document.documentElement.classList.toggle('dark');
- localStorage.setItem('darkMode', isDark ? 'true' : 'false');
-});
+// Modal Elements
+const settingsModal = document.getElementById('settingsModal');
+const applicationModal = document.getElementById('applicationModal');
+const deleteModal = document.getElementById('deleteModal');
+const pdfExportModal = document.getElementById('pdfExportModal');
-// ── Stat counter animation ────────────────────────────────────────────────────
+// Form Elements
+const settingsForm = document.getElementById('settingsForm');
+const applicationForm = document.getElementById('applicationForm');
+const pdfExportForm = document.getElementById('pdfExportForm');
+const filterForm = document.getElementById('filterForm');
-(function animateStats() {
- const DURATION = 700;
+// Button Elements
+const settingsBtn = document.getElementById('settingsBtn');
+const addApplicationBtn = document.getElementById('addApplicationBtn');
+const exportPdfBtn = document.getElementById('exportPdfBtn');
- document.querySelectorAll('.stat-value[data-count]').forEach((el, i) => {
- const target = parseInt(el.dataset.count, 10) || 0;
- if (target === 0) return;
+// Close Modal Buttons
+const closeSettingsModal = document.getElementById('closeSettingsModal');
+const closeApplicationModal = document.getElementById('closeApplicationModal');
+const closeDeleteModal = document.getElementById('closeDeleteModal');
+const closePdfModal = document.getElementById('closePdfModal');
- const delay = 80 + i * 60;
- setTimeout(() => {
- const start = performance.now();
- const tick = (now) => {
- const p = Math.min((now - start) / DURATION, 1);
- // ease-out expo
- const eased = p === 1 ? 1 : 1 - Math.pow(2, -10 * p);
- el.textContent = Math.round(eased * target);
- if (p < 1) requestAnimationFrame(tick);
- };
- requestAnimationFrame(tick);
- }, delay);
- });
-})();
+const cancelSettings = document.getElementById('cancelSettings');
+const cancelApplication = document.getElementById('cancelApplication');
+const cancelDelete = document.getElementById('cancelDelete');
+const cancelPdfExport = document.getElementById('cancelPdfExport');
+const confirmDelete = document.getElementById('confirmDelete');
-// ── Load bewerbungen from embedded JSON ───────────────────────────────────────
+// Global variables
+let currentApplicationId = null;
+let currentDeleteId = null;
+let pdfLibrariesLoaded = false;
-let BEWERBUNGEN = [];
-const dataEl = document.getElementById('bewerbungenData');
-if (dataEl) {
- try { BEWERBUNGEN = JSON.parse(dataEl.textContent); } catch (_) {}
-}
+// ============================================
+// Dark Mode
+// ============================================
-// ── Modal system ──────────────────────────────────────────────────────────────
-
-function openModal(id) {
- const el = document.getElementById(id);
- el.classList.add('is-open');
- document.body.style.overflow = 'hidden';
- setTimeout(() => {
- const first = el.querySelector(
- 'input[type="date"], input[type="text"]:not([type="hidden"]), select, textarea'
- );
- if (first) first.focus();
- }, 60);
-}
-
-function closeModal(id) {
- const el = document.getElementById(id);
- el.classList.remove('is-open');
- document.body.style.overflow = '';
-}
-
-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('datum').value = b.datum || '';
- document.getElementById('firma').value = b.firma || '';
- document.getElementById('stelle').value = b.stelle || '';
- document.getElementById('art').value = b.art || '';
- document.getElementById('status').value = b.status || '';
- document.getElementById('notizen').value = b.notizen || '';
-
- openModal('bewerbungModal');
-}
-
-// ── Delete modal ──────────────────────────────────────────────────────────────
-
-function openDeleteModal(id, firma, stelle) {
- document.getElementById('deleteInfo').textContent = `${firma} – ${stelle}`;
- document.getElementById('deleteForm').action = `/bewerbungen/${id}?_method=DELETE`;
- openModal('deleteModal');
-}
-
-// ── PDF generation ────────────────────────────────────────────────────────────
-
-async function generatePDF(monat, jahr) {
- const btn = event.currentTarget;
- const saved = btn.innerHTML;
- btn.disabled = true;
- btn.innerHTML = 'Wird erstellt … ';
-
- try {
- const res = await fetch(`/api/pdf-daten?monat=${monat}&jahr=${jahr}`);
- if (!res.ok) throw new Error(`Server ${res.status}`);
- buildPDF(await res.json(), monat, jahr);
- } catch (err) {
- alert('PDF-Erstellung fehlgeschlagen:\n' + err.message);
- } finally {
- btn.disabled = false;
- btn.innerHTML = saved;
- }
-}
-
-function buildPDF({ bewerbungen, settings }, monat, jahr) {
- const { jsPDF } = window.jspdf;
- const doc = new jsPDF({ orientation: 'portrait', unit: 'mm', format: 'a4' });
- const PW = doc.internal.pageSize.getWidth(); // 210 mm
- const PH = doc.internal.pageSize.getHeight(); // 297 mm
- const monatName = MONATE_DE[monat - 1];
- const count = bewerbungen.length;
-
- // Accent stripe
- doc.setFillColor(224, 123, 0);
- doc.rect(0, 0, PW, 2.5, 'F');
-
- // ── User info (top right) ──────────────────────────────────────────────────
- doc.setFontSize(9);
- doc.setTextColor(120, 120, 120);
- let uy = 10;
- const ux = PW - 12;
-
- if (settings?.name) {
- doc.setFont('helvetica', 'bold');
- doc.text(settings.name, ux, uy, { align: 'right' });
- uy += 5;
- doc.setFont('helvetica', 'normal');
- }
- 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.setFont('helvetica', 'normal');
- doc.setTextColor(74, 85, 107);
- const pl = count !== 1 ? 'n' : '';
- doc.text(
- `Im Monat ${monatName} ${jahr} habe ich mich insgesamt auf ${count} Stelle${pl} beworben.`,
- 12, 25
- );
-
- // ── Table ──────────────────────────────────────────────────────────────────
- doc.autoTable({
- startY: 31,
- head: [['Datum', 'Firma / Unternehmen', 'Stelle / Position', 'Art', 'Status', 'Notizen']],
- body: bewerbungen.map(b => [
- formatDateDE(b.datum),
- b.firma || '',
- b.stelle || '',
- b.art || '',
- b.status || '',
- b.notizen ? b.notizen.slice(0, 120) : ''
- ]),
- margin: { left: 12, right: 12 },
- styles: {
- fontSize: 7,
- cellPadding: 2.8,
- textColor: [24, 32, 47],
- lineColor: [221, 226, 239],
- lineWidth: 0.25,
- font: 'helvetica'
- },
- headStyles: {
- fillColor: [224, 123, 0],
- textColor: [255, 255, 255],
- fontStyle: 'bold',
- halign: 'left',
- fontSize: 7
- },
- alternateRowStyles: { fillColor: [248, 250, 254] },
- columnStyles: {
- 0: { cellWidth: 26, overflow: 'hidden' },
- 1: { cellWidth: 38 },
- 2: { cellWidth: 38 },
- 3: { cellWidth: 28 },
- 4: { cellWidth: 32 },
- 5: { cellWidth: 'auto' }
- },
- didDrawPage: ({ pageNumber }) => {
- doc.setFontSize(7.5);
- doc.setTextColor(160, 170, 185);
- doc.text(`Seite ${pageNumber}`, PW / 2, PH - 4.5, { align: 'center' });
+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');
}
- });
-
- // ── Declaration line ───────────────────────────────────────────────────────
- const finalY = doc.lastAutoTable.finalY;
- const declY = finalY + 10;
-
- doc.setFontSize(8.5);
- doc.setTextColor(100, 110, 130);
- doc.setFont('helvetica', 'italic');
- doc.text(
- 'Ich erkläre hiermit, dass die vorstehenden Angaben vollständig und wahrheitsgemäß sind.',
- 12, declY
- );
-
- doc.save(`Bewerbungen_${monatName}_${jahr}.pdf`);
}
-// ── Utilities ─────────────────────────────────────────────────────────────────
-
-function todayISO() {
- const d = new Date();
- return [
- d.getFullYear(),
- String(d.getMonth() + 1).padStart(2, '0'),
- String(d.getDate()).padStart(2, '0')
- ].join('-');
+function 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');
+ }
}
-function formatDateDE(s) {
- if (!s) return '';
- const p = s.split('-');
- return p.length === 3 ? `${p[2]}.${p[1]}.${p[0]}` : s;
+// ============================================
+// 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')
+ };
+
+ fetch('/api/settings', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json'
+ },
+ body: JSON.stringify(settings)
+ })
+ .then(response => response.json())
+ .then(data => {
+ if (data.success) {
+ hideModal(settingsModal);
+ // Refresh page to update settings in header
+ location.reload();
+ }
+ })
+ .catch(error => console.error('Error saving settings:', error));
+}
+
+// ============================================
+// Application Management (CRUD)
+// ============================================
+
+function openAddApplicationModal() {
+ resetApplicationForm();
+ // Set default date to today
+ const today = new Date().toISOString().split('T')[0];
+ document.getElementById('applicationDatum').value = today;
+ showModal(applicationModal);
+}
+
+function openEditApplicationModal(id) {
+ // Fetch application data
+ fetch(`/api/bewerbungen/${id}`)
+ .then(response => response.json())
+ .then(application => {
+ currentApplicationId = application.id;
+ document.getElementById('modalTitle').textContent = 'Bewerbung bearbeiten';
+ document.getElementById('applicationId').value = application.id;
+ document.getElementById('applicationDatum').value = application.datum;
+ document.getElementById('applicationFirma').value = application.firma;
+ document.getElementById('applicationStelle').value = application.stelle;
+ document.getElementById('applicationArt').value = application.art || '';
+ document.getElementById('applicationStatus').value = application.status || '';
+ document.getElementById('applicationNotizen').value = application.notizen || '';
+ showModal(applicationModal);
+ })
+ .catch(error => console.error('Error loading application:', error));
+}
+
+function saveApplication(event) {
+ event.preventDefault();
+
+ const formData = new FormData(applicationForm);
+ const application = {
+ datum: formData.get('datum'),
+ firma: formData.get('firma'),
+ stelle: formData.get('stelle'),
+ art: formData.get('art'),
+ status: formData.get('status'),
+ notizen: formData.get('notizen'),
+ interne_notizen: formData.get('interne_notizen'),
+ kommentar: formData.get('kommentar')
+ };
+
+ let url = '/api/bewerbungen';
+ let method = 'POST';
+
+ if (currentApplicationId) {
+ url = `/api/bewerbungen/${currentApplicationId}`;
+ method = 'PUT';
+ }
+
+ fetch(url, {
+ method: method,
+ headers: {
+ 'Content-Type': 'application/json'
+ },
+ body: JSON.stringify(application)
+ })
+ .then(response => response.json())
+ .then(data => {
+ if (data.success) {
+ hideModal(applicationModal);
+ resetApplicationForm();
+ // Refresh the page to show updated data
+ location.reload();
+ }
+ })
+ .catch(error => console.error('Error saving application:', error));
+}
+
+function openDeleteModal(id) {
+ currentDeleteId = id;
+ showModal(deleteModal);
+}
+
+function deleteApplication() {
+ if (!currentDeleteId) return;
+
+ fetch(`/api/bewerbungen/${currentDeleteId}`, {
+ method: 'DELETE'
+ })
+ .then(response => response.json())
+ .then(data => {
+ if (data.success) {
+ hideModal(deleteModal);
+ currentDeleteId = null;
+ // Refresh the page
+ location.reload();
+ }
+ })
+ .catch(error => console.error('Error deleting application:', error));
+}
+
+// ============================================
+// PDF Export
+// ============================================
+
+function openPdfExportModal() {
+ // Load PDF libraries if not already loaded
+ loadPdfLibraries().then(() => {
+ showModal(pdfExportModal);
+ });
+}
+
+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;
+ }
+ }
+
+ // Header with user data
+ doc.setFont('helvetica', 'bold');
+ doc.setFontSize(16);
+ doc.text(title, pageWidth / 2, yPos, { align: 'center' });
+ yPos += 12;
+
+ // User information
+ doc.setFontSize(12);
+ doc.setFont('helvetica', 'normal');
+ if (settings && settings.name) {
+ doc.text(`Name: ${settings.name}`, margin, yPos);
+ yPos += 7;
+ }
+ if (settings && settings.adresse) {
+ doc.text(`Adresse: ${settings.adresse}`, margin, yPos);
+ yPos += 7;
+ }
+ if (settings && settings.kundennummer) {
+ doc.text(`Kundennummer: ${settings.kundennummer}`, margin, yPos);
+ yPos += 7;
+ }
+
+ yPos += 5;
+
+ // Summary text
+ const totalApplications = applications.length;
+ const summaryText = year
+ ? `Im Monat ${monthName} ${year} habe ich mich insgesamt auf ${totalApplications} Stellen beworben.`
+ : `Insgesamt habe ich mich auf ${totalApplications} Stellen beworben.`;
+
+ doc.setFont('helvetica', 'bold');
+ doc.text(summaryText, pageWidth / 2, yPos, { align: 'center' });
+
+ yPos += 12;
+
+ // Group entries by status (keep this order in sync with views/index.ejs)
+ const statusOrder = ['Gesendet', 'Eingangsbestätigung', 'Vorstellungsgespräch',
+ 'Einstellung', 'Absage', 'Keine Rückmeldung'];
+ const groups = {};
+ applications.forEach((app) => {
+ const key = (app.status && app.status.trim()) ? app.status.trim() : 'Ohne Status';
+ (groups[key] = groups[key] || []).push(app);
+ });
+ const orderedKeys = [
+ ...statusOrder.filter((s) => groups[s]),
+ ...Object.keys(groups).filter((k) => !statusOrder.includes(k))
+ ];
+
+ // One block per application — no table, so the note (which documents the
+ // full Verlauf) gets the entire page width and is shown completely.
+ orderedKeys.forEach((statusKey) => {
+ const group = groups[statusKey];
+
+ // Status group heading (dark bar), kept together with its first entry
+ ensureSpace(40);
+ doc.setFillColor(55, 65, 81);
+ doc.rect(margin, yPos, contentWidth, 10, 'F');
+ doc.setFont('helvetica', 'bold');
+ doc.setFontSize(12);
+ doc.setTextColor(255, 255, 255);
+ doc.text(`${statusKey} (${group.length})`, margin + 3, yPos + 6.8);
+ yPos += 14;
+ doc.setTextColor(0, 0, 0);
+
+ group.forEach((app) => {
+ const datum = new Date(app.datum).toLocaleDateString('de-DE');
+ const firma = app.firma || '—';
+ const stelle = app.stelle || '—';
+ const art = app.art || '—';
+ const notizen = (app.notizen || '').trim();
+
+ // Keep the heading together with the start of its content
+ ensureSpace(28);
+
+ // Heading bar: Firma – Stelle (left), Datum (right)
+ doc.setFillColor(225, 232, 240);
+ doc.rect(margin, yPos, contentWidth, 9, 'F');
+ doc.setFont('helvetica', 'bold');
+ doc.setFontSize(11);
+ doc.setTextColor(20, 20, 20);
+ const heading = doc.splitTextToSize(`${firma} – ${stelle}`, contentWidth - 40)[0];
+ doc.text(heading, margin + 2, yPos + 6);
+ doc.text(datum, pageWidth - margin - 2, yPos + 6, { align: 'right' });
+ yPos += 9;
+
+ // Meta line: Art
+ doc.setFont('helvetica', 'normal');
+ doc.setFontSize(9);
+ doc.setTextColor(90, 90, 90);
+ doc.text(`Art: ${art}`, margin + 2, yPos + 5);
+ yPos += 9;
+
+ // Status-Verlauf timeline (chronological status changes with comments)
+ const verlauf = Array.isArray(app.verlauf) ? app.verlauf : [];
+ if (verlauf.length) {
+ doc.setFont('helvetica', 'bold');
+ doc.setFontSize(10);
+ doc.setTextColor(20, 20, 20);
+ ensureSpace(6);
+ doc.text('Status-Verlauf:', margin + 2, yPos + 4);
+ yPos += 6;
+
+ doc.setFontSize(9);
+ verlauf.forEach((v) => {
+ const vDatum = new Date(v.datum).toLocaleDateString('de-DE');
+ doc.setFont('helvetica', 'bold');
+ doc.setTextColor(50, 50, 50);
+ ensureSpace(5);
+ doc.text(`${vDatum} — ${v.status || ''}`, margin + 5, yPos + 4);
+ yPos += 5;
+
+ const kommentar = (v.kommentar || '').trim();
+ if (kommentar) {
+ doc.setFont('helvetica', 'normal');
+ doc.setTextColor(0, 0, 0);
+ kommentar.split(/\r?\n/).forEach((paragraph) => {
+ const lines = doc.splitTextToSize(paragraph.length ? paragraph : ' ', contentWidth - 12);
+ lines.forEach((line) => {
+ ensureSpace(4.5);
+ doc.text(line, margin + 9, yPos + 3.5);
+ yPos += 4.5;
+ });
+ });
+ }
+ });
+ yPos += 3;
+ }
+
+ // Notizen label
+ doc.setFont('helvetica', 'bold');
+ doc.setFontSize(10);
+ doc.setTextColor(20, 20, 20);
+ doc.text('Notizen:', margin + 2, yPos + 4);
+ yPos += 6;
+
+ // Notizen body — full width, complete, with page breaks line by line
+ doc.setFont('helvetica', 'normal');
+ doc.setFontSize(10);
+ doc.setTextColor(0, 0, 0);
+ const lineHeight = 5;
+ const noteText = notizen || '(keine Notizen)';
+ noteText.split(/\r?\n/).forEach((paragraph) => {
+ const lines = doc.splitTextToSize(paragraph.length ? paragraph : ' ', contentWidth - 4);
+ lines.forEach((line) => {
+ ensureSpace(lineHeight);
+ doc.text(line, margin + 2, yPos + 4);
+ yPos += lineHeight;
+ });
+ });
+
+ // Separator before the next entry
+ yPos += 4;
+ ensureSpace(6);
+ doc.setDrawColor(210, 210, 210);
+ doc.line(margin, yPos, pageWidth - margin, yPos);
+ yPos += 8;
+ });
+
+ // Extra spacing after a status group
+ yPos += 4;
+ });
+
+ // Footer with confirmation
+ ensureSpace(20);
+ yPos += 6;
+ doc.setFont('helvetica', 'italic');
+ doc.setFontSize(10);
+ doc.setTextColor(0, 0, 0);
+ doc.text('Ich versichere, dass die oben genannten Angaben der Wahrheit entsprechen.', pageWidth / 2, yPos, { align: 'center' });
+ yPos += 7;
+ doc.text(`Datum: ${dateStr}`, pageWidth / 2, yPos, { align: 'center' });
+
+ // Save the PDF
+ const fileName = `Bewerbungsaktivitaeten_${monthName}_${year || 'alle'}.pdf`;
+ doc.save(fileName);
+}
+
+// Load PDF libraries dynamically
+function loadPdfLibraries() {
+ return new Promise((resolve) => {
+ if (pdfLibrariesLoaded) {
+ resolve();
+ return;
+ }
+
+ // Check if already loading
+ if (document.getElementById('jspdf-script')) {
+ const checkLoaded = setInterval(() => {
+ if (typeof jsPDF !== 'undefined') {
+ clearInterval(checkLoaded);
+ pdfLibrariesLoaded = true;
+ resolve();
+ }
+ }, 100);
+ return;
+ }
+
+ // Load jsPDF from CDN
+ const script1 = document.createElement('script');
+ script1.id = 'jspdf-script';
+ script1.src = 'https://cdnjs.cloudflare.com/ajax/libs/jspdf/2.5.1/jspdf.umd.min.js';
+ script1.onload = () => {
+ const script2 = document.createElement('script');
+ script2.src = 'https://cdnjs.cloudflare.com/ajax/libs/jspdf-autotable/3.8.2/jspdf.plugin.autotable.min.js';
+ script2.onload = () => {
+ pdfLibrariesLoaded = true;
+ resolve();
+ };
+ document.head.appendChild(script2);
+ };
+ document.head.appendChild(script1);
+ });
+}
+
+// ============================================
+// Event Listeners
+// ============================================
+
+// Dark Mode Toggle
+darkModeToggle.addEventListener('click', toggleDarkMode);
+
+// Settings Modal
+settingsBtn.addEventListener('click', () => {
+ loadSettings();
+ showModal(settingsModal);
+});
+closeSettingsModal.addEventListener('click', () => hideModal(settingsModal));
+cancelSettings.addEventListener('click', () => hideModal(settingsModal));
+settingsForm.addEventListener('submit', saveSettings);
+
+// Application Modal
+addApplicationBtn.addEventListener('click', openAddApplicationModal);
+closeApplicationModal.addEventListener('click', () => {
+ hideModal(applicationModal);
+ resetApplicationForm();
+});
+cancelApplication.addEventListener('click', () => {
+ hideModal(applicationModal);
+ resetApplicationForm();
+});
+applicationForm.addEventListener('submit', saveApplication);
+
+// Delete Modal
+closeDeleteModal.addEventListener('click', () => hideModal(deleteModal));
+cancelDelete.addEventListener('click', () => hideModal(deleteModal));
+confirmDelete.addEventListener('click', deleteApplication);
+
+// PDF Export Modal
+exportPdfBtn.addEventListener('click', openPdfExportModal);
+closePdfModal.addEventListener('click', () => hideModal(pdfExportModal));
+cancelPdfExport.addEventListener('click', () => hideModal(pdfExportModal));
+pdfExportForm.addEventListener('submit', generatePDF);
+
+// Filter Form
+document.getElementById('filterForm').addEventListener('submit', function(e) {
+ e.preventDefault();
+ const month = document.getElementById('filterMonth').value;
+ const year = document.getElementById('filterYear').value;
+ let url = '/';
+ if (month || year) {
+ url += '?';
+ if (month) url += `month=${month}&`;
+ if (year) url += `year=${year}&`;
+ }
+ window.location.href = url;
+});
+
+// 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);
+ }
+});
+
+// Close modals when clicking outside
+document.addEventListener('click', (e) => {
+ if (e.target === settingsModal) hideModal(settingsModal);
+ if (e.target === applicationModal) {
+ hideModal(applicationModal);
+ resetApplicationForm();
+ }
+ if (e.target === deleteModal) hideModal(deleteModal);
+ if (e.target === pdfExportModal) hideModal(pdfExportModal);
+});
+
+// Close modals with Escape key
+document.addEventListener('keydown', (e) => {
+ if (e.key === 'Escape') {
+ if (!settingsModal.classList.contains('hidden')) hideModal(settingsModal);
+ if (!applicationModal.classList.contains('hidden')) {
+ hideModal(applicationModal);
+ resetApplicationForm();
+ }
+ if (!deleteModal.classList.contains('hidden')) hideModal(deleteModal);
+ if (!pdfExportModal.classList.contains('hidden')) hideModal(pdfExportModal);
+ }
+});
+
+// Set current year in footer
+document.getElementById('currentYear').textContent = new Date().getFullYear();
+
+// ============================================
+// Initialize
+// ============================================
+
+// Initialize dark mode
+initializeDarkMode();
+
+console.log('Bewerbungs-Tracker initialized');
diff --git a/server.js b/server.js
index 49341da..5ad644c 100644
--- a/server.js
+++ b/server.js
@@ -1,234 +1,497 @@
-'use strict';
+const express = require('express');
+const sqlite3 = require('sqlite3').verbose();
+const path = require('path');
+const fs = require('fs');
-const express = require('express');
-const path = require('path');
-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;
-// ── Database bootstrap ────────────────────────────────────────────────────────
-
-const dataDir = path.join(__dirname, 'data');
-if (!fs.existsSync(dataDir)) fs.mkdirSync(dataDir, { recursive: true });
-
-const db = new Database(path.join(dataDir, 'bewerbungen.db'));
-db.pragma('journal_mode = WAL');
-
-db.exec(`
- CREATE TABLE IF NOT EXISTS 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
- );
-
- CREATE TABLE IF NOT EXISTS settings (
- id INTEGER PRIMARY KEY CHECK (id = 1),
- name TEXT,
- adresse TEXT,
- kundennummer TEXT
- );
-
- INSERT OR IGNORE INTO settings (id) VALUES (1);
-`);
-
-// ── Constants ─────────────────────────────────────────────────────────────────
-
-const ART_OPTIONEN = [
- 'E-Mail', 'Online-Portal', 'Indeed', 'StepStone', 'Firmenwebsite',
- 'Post', 'Initiativbewerbung', 'Arbeitsagentur', 'Sonstiges'
+// Shared option lists (used in multiple views)
+const ART_OPTIONS = [
+ 'E-Mail', 'Online-Portal', 'Indeed', 'StepStone',
+ 'Firmenwebsite', 'Post', 'Initiativbewerbung',
+ 'Arbeitsagentur', 'Sonstiges'
];
-
-const STATUS_OPTIONEN = [
+const STATUS_OPTIONS = [
'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, '\\u003e')
- .replace(/&/g, '\\u0026');
-}
-
-function getSettings() {
- return db.prepare('SELECT * FROM settings WHERE id = 1').get() || {};
-}
-
-// ── Middleware ────────────────────────────────────────────────────────────────
-
-app.use(express.urlencoded({ extended: true }));
+// Middleware
app.use(express.json());
-app.use(methodOverride('_method'));
+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'));
-// ── Routes ────────────────────────────────────────────────────────────────────
+// Ensure data directory exists
+const dataDir = path.join(__dirname, 'data');
+if (!fs.existsSync(dataDir)) {
+ fs.mkdirSync(dataDir, { recursive: true });
+}
-// 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();
+// Database setup
+const dbPath = path.join(dataDir, 'bewerbungen.db');
+const db = new sqlite3.Database(dbPath);
- const monatStr = String(monat).padStart(2, '0');
- const vonDatum = `${jahr}-${monatStr}-01`;
- const bisDatum = `${jahr}-${monatStr}-31`;
+// Sanitize input to prevent XSS
+function sanitizeInput(input) {
+ if (typeof input !== 'string') return input;
+ return input
+ .replace(//g, '>')
+ .replace(/"/g, '"')
+ .replace(/'/g, ''');
+}
- 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', {
- bewerbungen,
- bewerbungenJson: safeJson(bewerbungen),
- stats,
- monat,
- jahr,
- monate: MONATE,
- jahre,
- artOptionen: ART_OPTIONEN,
- statusOptionen: STATUS_OPTIONEN,
- settings: getSettings(),
- monatName: MONATE[monat - 1],
- fehler: req.query.fehler || null,
- currentPage: 'uebersicht'
+// 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);
+ });
});
-});
+}
-// POST /bewerbungen – Neu anlegen
-app.post('/bewerbungen', (req, res) => {
- const { datum, firma, stelle, art, status, notizen } = req.body;
-
- if (!datum || !firma || !stelle) {
- const d = new Date();
- return res.redirect(`/?fehler=pflichtfelder&monat=${d.getMonth() + 1}&jahr=${d.getFullYear()}`);
- }
-
- db.prepare(`
- INSERT INTO bewerbungen (datum, firma, stelle, art, status, notizen)
- VALUES (?, ?, ?, ?, ?, ?)
- `).run(
- sanitize(datum, 20),
- sanitize(firma),
- sanitize(stelle),
- sanitize(art),
- sanitize(status),
- sanitize(notizen)
- );
-
- const d = new Date(datum + 'T00:00:00');
- res.redirect(`/?monat=${d.getMonth() + 1}&jahr=${d.getFullYear()}`);
-});
-
-// PUT /bewerbungen/:id – Aktualisieren
-app.put('/bewerbungen/:id', (req, res) => {
- const id = parseInt(req.params.id);
- const { datum, firma, stelle, art, status, notizen, monat, jahr } = req.body;
-
- if (!datum || !firma || !stelle) {
- return res.redirect(`/?fehler=pflichtfelder&monat=${monat}&jahr=${jahr}`);
- }
-
- db.prepare(`
- UPDATE bewerbungen
- SET datum=?, firma=?, stelle=?, art=?, status=?, notizen=?, updated_at=CURRENT_TIMESTAMP
- WHERE id=?
- `).run(
- sanitize(datum, 20),
- sanitize(firma),
- sanitize(stelle),
- sanitize(art),
- sanitize(status),
- sanitize(notizen),
- id
- );
-
- res.redirect(`/?monat=${monat || 1}&jahr=${jahr || new Date().getFullYear()}`);
-});
-
-// DELETE /bewerbungen/:id – Löschen
-app.delete('/bewerbungen/:id', (req, res) => {
- db.prepare('DELETE FROM bewerbungen WHERE id=?').run(parseInt(req.params.id));
- const { monat, jahr } = req.body;
- const now = new Date();
- res.redirect(`/?monat=${monat || now.getMonth() + 1}&jahr=${jahr || now.getFullYear()}`);
-});
-
-// GET /einstellungen
-app.get('/einstellungen', (req, res) => {
- res.render('einstellungen', {
- settings: getSettings(),
- gespeichert: req.query.gespeichert === '1',
- currentPage: 'einstellungen'
+function dbAll(sql, params = []) {
+ return new Promise((resolve, reject) => {
+ db.all(sql, params, (err, results) => {
+ if (err) reject(err);
+ else resolve(results);
+ });
});
+}
+
+function dbRun(sql, params = []) {
+ return new Promise((resolve, reject) => {
+ db.run(sql, params, function(err) {
+ if (err) reject(err);
+ else resolve({ lastID: this.lastID, changes: this.changes });
+ });
+ });
+}
+
+// Recompute an application's current status from its latest timeline entry
+async function syncCurrentStatus(bewerbungId) {
+ const latest = await dbGet(
+ 'SELECT status FROM status_verlauf WHERE bewerbung_id = ? ORDER BY date(datum) DESC, id DESC LIMIT 1',
+ [bewerbungId]
+ );
+ await dbRun(
+ 'UPDATE bewerbungen SET status = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?',
+ [latest ? latest.status : '', bewerbungId]
+ );
+}
+
+// Attach the status timeline to each application (single query, grouped in JS)
+async function attachVerlauf(applications) {
+ if (!applications.length) return applications;
+ const all = await dbAll('SELECT * FROM status_verlauf ORDER BY date(datum) ASC, id ASC');
+ const byApp = {};
+ all.forEach((v) => { (byApp[v.bewerbung_id] = byApp[v.bewerbung_id] || []).push(v); });
+ applications.forEach((a) => { a.verlauf = byApp[a.id] || []; });
+ return applications;
+}
+
+// Initialize database - create tables and default settings in one operation
+function initializeDatabase() {
+ return new Promise((resolve, reject) => {
+ db.serialize(() => {
+ db.run('PRAGMA foreign_keys = ON');
+
+ // Create tables
+ db.run(`
+ CREATE TABLE IF NOT EXISTS bewerbungen (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ datum DATE NOT NULL,
+ firma TEXT NOT NULL,
+ stelle TEXT NOT NULL,
+ art TEXT,
+ status TEXT,
+ notizen TEXT,
+ interne_notizen TEXT,
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
+ )
+ `, (err) => {
+ if (err) return reject(err);
+
+ // Migration: add interne_notizen to pre-existing databases (ignore "duplicate column")
+ db.run('ALTER TABLE bewerbungen ADD COLUMN interne_notizen TEXT', () => {
+
+ // Chronological status changes, each with an optional comment
+ db.run(`
+ CREATE TABLE IF NOT EXISTS status_verlauf (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ bewerbung_id INTEGER NOT NULL,
+ datum DATE NOT NULL,
+ status TEXT NOT NULL,
+ kommentar TEXT,
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ FOREIGN KEY (bewerbung_id) REFERENCES bewerbungen(id) ON DELETE CASCADE
+ )
+ `, (err) => {
+ if (err) return reject(err);
+
+ db.run(`
+ CREATE TABLE IF NOT EXISTS settings (
+ id INTEGER PRIMARY KEY CHECK (id = 1),
+ name TEXT,
+ adresse TEXT,
+ kundennummer TEXT
+ )
+ `, (err) => {
+ if (err) return reject(err);
+
+ // Insert default settings if not exists
+ db.get('SELECT COUNT(*) as count FROM settings WHERE id = 1', (err, result) => {
+ if (err) return reject(err);
+
+ if (result && result.count === 0) {
+ db.run(
+ 'INSERT INTO settings (id, name, adresse, kundennummer) VALUES (1, ?, ?, ?)',
+ ['Max Mustermann', 'Musterstraße 1, 12345 Musterstadt', ''],
+ (err) => {
+ if (err) return reject(err);
+ resolve();
+ }
+ );
+ } 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
+ `);
+
+ // Get available months/years for filter
+ const availableMonths = await dbAll(`
+ SELECT DISTINCT strftime("%Y-%m", datum) as yearmonth,
+ strftime("%m", datum) as month,
+ strftime("%Y", datum) as year
+ FROM bewerbungen ORDER BY datum DESC
+ `);
+
+ res.render('index', {
+ applications,
+ settings,
+ statistics: {
+ total: totalCount ? totalCount.count : 0,
+ byArt,
+ byStatus
+ },
+ availableMonths,
+ currentFilter: { month, year },
+ artOptions: ART_OPTIONS,
+ statusOptions: STATUS_OPTIONS
+ });
+ } catch (error) {
+ console.error('Error:', error);
+ res.status(500).send('Serverfehler');
+ }
+ });
+
+ // Get single application
+ app.get('/api/bewerbungen/:id', async (req, res) => {
+ try {
+ const { id } = req.params;
+ const application = await dbGet('SELECT * FROM bewerbungen WHERE id = ?', [id]);
+
+ if (!application) {
+ return res.status(404).json({ error: 'Bewerbung nicht gefunden' });
+ }
+
+ res.json(application);
+ } catch (error) {
+ console.error('Error getting application:', error);
+ res.status(500).json({ error: 'Serverfehler' });
+ }
+ });
+
+ // Get settings
+ app.get('/api/settings', async (req, res) => {
+ try {
+ const settings = await dbGet('SELECT * FROM settings WHERE id = 1');
+ res.json(settings);
+ } catch (error) {
+ console.error('Error getting settings:', error);
+ res.status(500).json({ error: 'Serverfehler' });
+ }
+ });
+
+ // Save settings
+ app.post('/api/settings', async (req, res) => {
+ try {
+ const { name, adresse, kundennummer } = req.body;
+
+ await dbRun(
+ 'UPDATE settings SET name = ?, adresse = ?, kundennummer = ? WHERE id = 1',
+ [sanitizeInput(name), sanitizeInput(adresse), sanitizeInput(kundennummer)]
+ );
+
+ res.json({ success: true });
+ } catch (error) {
+ console.error('Error saving settings:', error);
+ res.status(500).json({ error: 'Serverfehler' });
+ }
+ });
+
+ // Create application
+ app.post('/api/bewerbungen', async (req, res) => {
+ try {
+ const { datum, firma, stelle, art, status, notizen, interne_notizen, kommentar } = req.body;
+
+ const result = await dbRun(
+ 'INSERT INTO bewerbungen (datum, firma, stelle, art, status, notizen, interne_notizen) VALUES (?, ?, ?, ?, ?, ?, ?)',
+ [datum, sanitizeInput(firma), sanitizeInput(stelle),
+ sanitizeInput(art), sanitizeInput(status), sanitizeInput(notizen), sanitizeInput(interne_notizen)]
+ );
+
+ // Record the initial status as the first timeline entry
+ if (status && status.trim()) {
+ await dbRun(
+ 'INSERT INTO status_verlauf (bewerbung_id, datum, status, kommentar) VALUES (?, ?, ?, ?)',
+ [result.lastID, datum, sanitizeInput(status), sanitizeInput(kommentar || '')]
+ );
+ }
+
+ const newApplication = await dbGet('SELECT * FROM bewerbungen WHERE id = ?', [result.lastID]);
+
+ res.json({ success: true, application: newApplication });
+ } catch (error) {
+ console.error('Error creating application:', error);
+ res.status(500).json({ error: 'Serverfehler' });
+ }
+ });
+
+ // Update application
+ app.put('/api/bewerbungen/:id', async (req, res) => {
+ try {
+ const { id } = req.params;
+ const { datum, firma, stelle, art, status, notizen } = req.body;
+
+ await dbRun(
+ 'UPDATE bewerbungen SET datum = ?, firma = ?, stelle = ?, art = ?, status = ?, notizen = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?',
+ [datum, sanitizeInput(firma), sanitizeInput(stelle),
+ sanitizeInput(art), sanitizeInput(status), sanitizeInput(notizen), id]
+ );
+
+ const updatedApplication = await dbGet('SELECT * FROM bewerbungen WHERE id = ?', [id]);
+
+ res.json({ success: true, application: updatedApplication });
+ } catch (error) {
+ console.error('Error updating application:', error);
+ res.status(500).json({ error: 'Serverfehler' });
+ }
+ });
+
+ // Delete application
+ app.delete('/api/bewerbungen/:id', async (req, res) => {
+ try {
+ const { id } = req.params;
+ await dbRun('DELETE FROM status_verlauf WHERE bewerbung_id = ?', [id]);
+ await dbRun('DELETE FROM bewerbungen WHERE id = ?', [id]);
+
+ res.json({ success: true });
+ } catch (error) {
+ console.error('Error deleting application:', error);
+ res.status(500).json({ error: 'Serverfehler' });
+ }
+ });
+
+ // Applications for PDF export (optionally filtered), including the status timeline
+ app.get('/api/export', async (req, res) => {
+ try {
+ const { month, year } = req.query;
+
+ let query = 'SELECT * FROM bewerbungen ORDER BY datum DESC';
+ const params = [];
+
+ if (month && year) {
+ query = 'SELECT * FROM bewerbungen WHERE strftime("%m", datum) = ? AND strftime("%Y", datum) = ? ORDER BY datum DESC';
+ params.push(month.padStart(2, '0'), year);
+ } else if (year) {
+ query = 'SELECT * FROM bewerbungen WHERE strftime("%Y", datum) = ? ORDER BY datum DESC';
+ params.push(year);
+ }
+
+ const applications = await dbAll(query, params);
+ await attachVerlauf(applications);
+ // Internal notes must never reach the PDF/export
+ applications.forEach((a) => { delete a.interne_notizen; });
+
+ res.json(applications);
+ } catch (error) {
+ console.error('Error exporting applications:', error);
+ res.status(500).json({ error: 'Serverfehler' });
+ }
+ });
+
+ // ----- Dedicated edit page + status-timeline management -----
+
+ // Edit page for a single application
+ app.get('/bewerbung/:id', async (req, res) => {
+ try {
+ const { id } = req.params;
+ const application = await dbGet('SELECT * FROM bewerbungen WHERE id = ?', [id]);
+ if (!application) return res.status(404).send('Bewerbung nicht gefunden');
+
+ const verlauf = await dbAll(
+ 'SELECT * FROM status_verlauf WHERE bewerbung_id = ? ORDER BY date(datum) ASC, id ASC',
+ [id]
+ );
+
+ res.render('bewerbung', {
+ application,
+ verlauf,
+ artOptions: ART_OPTIONS,
+ statusOptions: STATUS_OPTIONS,
+ hideSettings: true
+ });
+ } catch (error) {
+ console.error('Error loading edit page:', error);
+ res.status(500).send('Serverfehler');
+ }
+ });
+
+ // Update application core data (status is managed via the timeline)
+ app.post('/bewerbung/:id', async (req, res) => {
+ try {
+ const { id } = req.params;
+ const { datum, firma, stelle, art, notizen, interne_notizen } = req.body;
+
+ await dbRun(
+ 'UPDATE bewerbungen SET datum = ?, firma = ?, stelle = ?, art = ?, notizen = ?, interne_notizen = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?',
+ [datum, sanitizeInput(firma), sanitizeInput(stelle), sanitizeInput(art), sanitizeInput(notizen), sanitizeInput(interne_notizen), id]
+ );
+
+ res.redirect('/bewerbung/' + id);
+ } catch (error) {
+ console.error('Error updating application:', error);
+ res.status(500).send('Serverfehler');
+ }
+ });
+
+ // Add a timeline entry (status change with date + comment)
+ app.post('/bewerbung/:id/verlauf', async (req, res) => {
+ try {
+ const { id } = req.params;
+ const { datum, status, kommentar } = req.body;
+
+ if (datum && status && status.trim()) {
+ await dbRun(
+ 'INSERT INTO status_verlauf (bewerbung_id, datum, status, kommentar) VALUES (?, ?, ?, ?)',
+ [id, datum, sanitizeInput(status), sanitizeInput(kommentar || '')]
+ );
+ await syncCurrentStatus(id);
+ }
+
+ res.redirect('/bewerbung/' + id);
+ } catch (error) {
+ console.error('Error adding timeline entry:', error);
+ res.status(500).send('Serverfehler');
+ }
+ });
+
+ // Update a timeline entry
+ app.post('/bewerbung/:id/verlauf/:eintragId', async (req, res) => {
+ try {
+ const { id, eintragId } = req.params;
+ const { datum, status, kommentar } = req.body;
+
+ if (datum && status && status.trim()) {
+ await dbRun(
+ 'UPDATE status_verlauf SET datum = ?, status = ?, kommentar = ? WHERE id = ? AND bewerbung_id = ?',
+ [datum, sanitizeInput(status), sanitizeInput(kommentar || ''), eintragId, id]
+ );
+ await syncCurrentStatus(id);
+ }
+
+ res.redirect('/bewerbung/' + id);
+ } catch (error) {
+ console.error('Error updating timeline entry:', error);
+ res.status(500).send('Serverfehler');
+ }
+ });
+
+ // Delete a timeline entry
+ app.post('/bewerbung/:id/verlauf/:eintragId/delete', async (req, res) => {
+ try {
+ const { id, eintragId } = req.params;
+ await dbRun('DELETE FROM status_verlauf WHERE id = ? AND bewerbung_id = ?', [eintragId, id]);
+ await syncCurrentStatus(id);
+ res.redirect('/bewerbung/' + id);
+ } catch (error) {
+ console.error('Error deleting timeline entry:', error);
+ res.status(500).send('Serverfehler');
+ }
+ });
+
+ // Start server
+ app.listen(PORT, () => {
+ console.log(`Server läuft auf http://localhost:${PORT}`);
+ });
+
+ // Handle 404
+ app.use((req, res) => {
+ res.status(404).send('Seite nicht gefunden');
+ });
+}).catch((err) => {
+ console.error('Failed to initialize database:', err);
+ process.exit(1);
});
-// POST /einstellungen
-app.post('/einstellungen', (req, res) => {
- const { name, adresse, kundennummer } = req.body;
- db.prepare(`
- INSERT OR REPLACE INTO settings (id, name, adresse, kundennummer)
- VALUES (1, ?, ?, ?)
- `).run(sanitize(name), sanitize(adresse), sanitize(kundennummer));
- res.redirect('/einstellungen?gespeichert=1');
+// Close database on exit
+process.on('SIGINT', () => {
+ db.close();
+ process.exit();
});
-// GET /api/pdf-daten – JSON für clientseitige PDF-Generierung
-app.get('/api/pdf-daten', (req, res) => {
- const monat = Math.min(12, Math.max(1, parseInt(req.query.monat) || 1));
- const jahr = parseInt(req.query.jahr) || new Date().getFullYear();
- const ms = String(monat).padStart(2, '0');
-
- const bewerbungen = db.prepare(`
- SELECT * FROM bewerbungen
- WHERE datum BETWEEN ? AND ?
- ORDER BY datum ASC, id ASC
- `).all(`${jahr}-${ms}-01`, `${jahr}-${ms}-31`);
-
- res.json({ bewerbungen, settings: getSettings(), monat, jahr });
-});
-
-// ── Start ─────────────────────────────────────────────────────────────────────
-
-app.listen(PORT, () => {
- console.log(`\n✓ Bewerbungs-Tracker läuft auf → http://localhost:${PORT}\n`);
+process.on('SIGTERM', () => {
+ db.close();
+ process.exit();
});
diff --git a/views/bewerbung.ejs b/views/bewerbung.ejs
new file mode 100644
index 0000000..8127482
--- /dev/null
+++ b/views/bewerbung.ejs
@@ -0,0 +1,218 @@
+
+
+
+ <%- include('partials/head') %>
+
+
+ <%- 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];
+ %>
+
+
+
+
+
+
+
+ Zurück zur Übersicht
+
+
+
+
+
+ <%= application.firma %> – <%= application.stelle %>
+
+
+ <%= aktuellerStatus %>
+
+
+
+
+
+
+
+
+ Status-Verlauf
+
+ Jede Statusänderung wird mit Datum dokumentiert. Der jüngste Eintrag bestimmt den aktuellen Status.
+
+
+
+ <% if (verlauf.length > 0) { %>
+
+ <% verlauf.forEach(eintrag => { %>
+
+
+
+
+
+ Datum
+
+
+
+ Status
+
+ <% statusOptions.forEach(option => { %>
+ ><%= option %>
+ <% }); %>
+
+
+
+
+ Kommentar
+ <%= eintrag.kommentar || '' %>
+
+
+
+ Speichern
+
+
+
+
+
+ Eintrag löschen
+
+
+
+ <% }); %>
+
+ <% } else { %>
+ Noch keine Statusänderungen dokumentiert.
+ <% } %>
+
+
+
+
Statusänderung hinzufügen
+
+
+
+ Datum
+
+
+
+ Status
+
+ <% statusOptions.forEach(option => { %>
+ <%= option %>
+ <% }); %>
+
+
+
+
+ Kommentar
+
+
+
+
+ Statusänderung hinzufügen
+
+
+
+
+
+
+
+ <%- include('partials/footer') %>
+
+
+
+
diff --git a/views/einstellungen.ejs b/views/einstellungen.ejs
deleted file mode 100644
index 4792d39..0000000
--- a/views/einstellungen.ejs
+++ /dev/null
@@ -1,62 +0,0 @@
-<%- include('partials/header') %>
-
-
-
-
-
- <% if (gespeichert) { %>
-
-
-
-
- Einstellungen wurden gespeichert.
-
- <% } %>
-
-
-
-
-
-
-
-
-
-
-
- Einstellungen speichern
-
-
-
-
-
- PDF-Hinweis
- 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.
-
-
-
-
-<%- include('partials/footer') %>
diff --git a/views/index.ejs b/views/index.ejs
index 9fa4491..170254f 100644
--- a/views/index.ejs
+++ b/views/index.ejs
@@ -1,298 +1,609 @@
-<%- include('partials/header') %>
+
+
+
+ <%- include('partials/head') %>
+
+
+ <%- include('partials/header') %>
-<%# ── Embed server data for client-side use ──────────────────────────────── %>
-
-
-
-<%# ── Page header ────────────────────────────────────────────────────────── %>
-
-
-<%# ── Error banner ───────────────────────────────────────────────────────── %>
-<% if (fehler === 'pflichtfelder') { %>
-
-
-
-
- Bitte alle Pflichtfelder ausfüllen: Datum, Firma und Stelle sind erforderlich.
-
-<% } %>
-
-<%# ── Stats grid ─────────────────────────────────────────────────────────── %>
-
-
-
Gesamt
-
<%= stats.gesamt %>
-
-
-
Positiv
-
<%= stats.positiv %>
-
-
-
Absagen
-
<%= stats.absagen %>
-
-
-
Ausstehend
-
<%= stats.ausstehend %>
-
-
-
-<%# ── Action bar ─────────────────────────────────────────────────────────── %>
-
-
<%= bewerbungen.length %> Eintr<%= bewerbungen.length === 1 ? 'ag' : 'äge' %>
-
-
-
-
- Neue Bewerbung
-
-
-
-<%# ── Data table ─────────────────────────────────────────────────────────── %>
-
- <% if (bewerbungen.length === 0) { %>
-
-
-
Keine Bewerbungen für <%= monatName %> <%= jahr %>
-
Klicken Sie auf „Neue Bewerbung", um Ihren ersten Eintrag hinzuzufügen.
-
- <% } else { %>
-
-
-
-
- Datum
- Firma
- Stelle
- Art
- Status
- Notizen
- Aktionen
-
-
-
- <% bewerbungen.forEach(b => { %>
-
-
- <%= b.datum ? b.datum.split('-').reverse().join('.') : '' %>
-
- <%= b.firma %>
- <%= b.stelle %>
-
- <% if (b.art) { %>
- <%= b.art %>
- <% } %>
-
-
- <% if (b.status) { %>
- <%= b.status %>
- <% } %>
-
- <%= b.notizen || '' %>
-
-
-
-
-
-
-
-
', '<%= b.stelle.replace(/\\/g, '\\\\').replace(/'/g, "\\'") %>')">
-
-
-
-
+
+
+
+
Filter
+
+
+
+ Monat
+
+
+ -- Alle Monate --
+ <% availableMonths.forEach(m => { %>
+ <%= m.month %> - <%= m.year %>
+ <% }); %>
+
-
-
- <% }) %>
-
-
-
- <% } %>
-
-
-<%# ══════════════════════════════════════════════════════════════════════════ %>
-<%# Modal: Neue / Bewerbung bearbeiten %>
-<%# ══════════════════════════════════════════════════════════════════════════ %>
-
-
-
-
-
Neue Bewerbung
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
Statistik
+
+
+
+
+
<%= statistics.total %>
+
Gesamtbewerbungen
+
+
+
+
+
Nach Bewerbungsart
+
+ <% if (statistics.byArt.length > 0) { %>
+ <% statistics.byArt.forEach(item => { %>
+
+ <%= item.art %>
+ <%= item.count %>
+
+ <% }); %>
+ <% } else { %>
+
Keine Daten verfügbar
+ <% } %>
+
+
+
+
+
+
Nach Status
+
+ <% if (statistics.byStatus.length > 0) { %>
+ <% statistics.byStatus.forEach(item => { %>
+
+ <%= item.status %>
+ <%= item.count %>
+
+ <% }); %>
+ <% } else { %>
+
Keine Daten verfügbar
+ <% } %>
+
+
+
+
-
-
+
+
+
+
+
+ Datum
+ Firma
+ Stelle
+ Art
+ Status
+ Aktionen
+
+
+ <% if (applications.length > 0) { %>
+ <%
+ // Status-Reihenfolge synchron zu public/js/main.js halten
+ const statusOrder = ['Gesendet', 'Eingangsbestätigung', 'Vorstellungsgespräch',
+ 'Einstellung', 'Absage', 'Keine Rückmeldung'];
+ const statusDot = {
+ 'Gesendet': 'bg-indigo-500',
+ 'Eingangsbestätigung': 'bg-blue-500',
+ 'Vorstellungsgespräch': 'bg-yellow-500',
+ 'Einstellung': 'bg-green-500',
+ 'Absage': 'bg-red-500',
+ 'Keine Rückmeldung': 'bg-gray-400',
+ 'Ohne Status': 'bg-gray-300'
+ };
+ const groups = {};
+ applications.forEach(a => {
+ const key = (a.status && a.status.trim()) ? a.status.trim() : 'Ohne Status';
+ (groups[key] = groups[key] || []).push(a);
+ });
+ const orderedKeys = [
+ ...statusOrder.filter(s => groups[s]),
+ ...Object.keys(groups).filter(k => !statusOrder.includes(k))
+ ];
+ %>
+ <% orderedKeys.forEach(statusKey => { %>
+
+
+
+
+
+ <%= statusKey %>
+ <%= groups[statusKey].length %>
+
+
+
+
+ <% 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';
+ %>
+
+
+
+ <%= new Date(app.datum).toLocaleDateString('de-DE') %>
+
+
+ <%= app.firma %>
+
+
+ <%= app.stelle %>
+
+
+ <%= app.art || '-' %>
+
+
+
+ <%= app.status || '-' %>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ <% if (hatDetails) { %>
+
+
+ <% if (hatVerlauf) { %>
+
+
+
+ <% app.verlauf.forEach(v => { %>
+
+ <%= new Date(v.datum).toLocaleDateString('de-DE') %>
+ <%= v.status %>
+ <% if (v.kommentar && v.kommentar.trim()) { %>
+ – <%= v.kommentar %>
+ <% } %>
+
+ <% }); %>
+
+
+ <% } %>
+ <% if (hatNotizen) { %>
+
+ <% } %>
+ <% if (hatInterne) { %>
+
+
+
+
+
+
Interne Notizen · nicht im PDF
+
+
<%= app.interne_notizen %>
+
+ <% } %>
+
+
+ <% } %>
+
+ <% }); %>
+ <% }); %>
+ <% } else { %>
+
+
+
+ Keine Bewerbungen gefunden.
+ Klicken Sie auf "Bewerbung hinzufügen", um Ihre erste Bewerbung einzutragen.
+
+
+
+ <% } %>
+
+
+
-<%# ══════════════════════════════════════════════════════════════════════════ %>
-<%# Modal: Löschen bestätigen %>
-<%# ══════════════════════════════════════════════════════════════════════════ %>
-
-
+ <%- include('partials/footer') %>
-
-
Eintrag löschen
-
-
-
-
-
+
+
+
+
+
+
Benutzereinstellungen
+
+
+
+
+
+
+
+
+
+
+ Vollständiger Name *
+
+
+
+
+
+
+ Adresse *
+
+ <%= settings.adresse || '' %>
+
+
+
+
+ Jobcenter Kundennummer
+
+
+
+
+
+
+ Abbrechen
+
+
+ Speichern
+
+
+
+
+
-
-
-
-
-
-
+
+
+
+
+
+
Bewerbung hinzufügen
+
+
+
+
+
+
+
+
+
+
+
+
+ Datum *
+
+
+
+
+
+
+ Firma *
+
+
+
+
+
+
+ Stelle *
+
+
+
+
+
+
+ Art der Bewerbung
+
+
+ -- Bitte wählen --
+ <% artOptions.forEach(option => { %>
+ <%= option %>
+ <% }); %>
+
+
+
+
+
+ Status (Anfangsstatus)
+
+
+ -- Bitte wählen --
+ <% statusOptions.forEach(option => { %>
+ <%= option %>
+ <% }); %>
+
+
+
+
+
+ Kommentar zur Statusänderung
+
+
+
Weitere Statusänderungen dokumentierst du später auf der Bearbeiten-Seite.
+
+
+
+
+ Notizen
+
+
+
+
+
+
+
+
+
+ Interne Notizen (nicht im PDF)
+
+
+
+
+
+
+ Abbrechen
+
+
+ Speichern
+
+
+
+
-
-
- Sicher löschen?
-
-
-
- Diese Aktion kann nicht rückgängig gemacht werden.
-
-
-
-
-
-
-
-
+
+
+
+
+
+
Bewerbung löschen
+
+
+
+
+
+
+
+
+ Sind Sie sicher, dass Sie diese Bewerbung löschen möchten? Dieser Vorgang kann nicht rückgängig gemacht werden.
+
+
+
+
+ Abbrechen
+
+
+ Löschen
+
+
+
+
+
-
-
+
+
+
+
+
+
+
+
+
+ Monat
+
+
+ -- Alle Monate --
+ <% availableMonths.forEach(m => { %>
+ <%= m.month %> - <%= m.year %>
+ <% }); %>
+
+
+
+
+
+ Jahr
+
+
+ -- Alle Jahre --
+ <% const pdfYears = [...new Set(availableMonths.map(m => m.year))].sort().reverse(); %>
+ <% pdfYears.forEach(y => { %>
+ <%= y %>
+ <% }); %>
+
+
+
+
+
+ Abbrechen
+
+
+ PDF generieren
+
+
+
+
+
+
-<%# 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') %>
+
+
+
+
diff --git a/views/partials/footer.ejs b/views/partials/footer.ejs
index f42566a..ae31d2f 100644
--- a/views/partials/footer.ejs
+++ b/views/partials/footer.ejs
@@ -1,9 +1,8 @@
-
<%# /page-wrapper %>
-
-
-
-
-