Compare commits
104
Commits
a44b319e7e
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
472259f189 | ||
|
|
9ac946bc9e | ||
|
|
ddf909502a | ||
|
|
1e9618c920 | ||
|
|
a6a2fabf05 | ||
|
|
811188f492 | ||
|
|
1c913b2b28 | ||
|
|
fa07dbf90a | ||
|
|
ada69eb482 | ||
|
|
7d5dc582e4 | ||
|
|
1ba615fa80 | ||
|
|
96fd0e282d | ||
|
|
d9ba8698e0 | ||
|
|
ce5fa1eab1 | ||
|
|
8405b8c057 | ||
|
|
ec2927cac5 | ||
|
|
bd7d21bfba | ||
|
|
1dd4807ca6 | ||
|
|
d20a265ebc | ||
|
|
75813a38a4 | ||
|
|
cd30af3d69 | ||
|
|
0301a1c430 | ||
|
|
21608b8ca7 | ||
|
|
ffaaafed7e | ||
|
|
1f2e299c55 | ||
|
|
9a40f395c7 | ||
|
|
ce844412e5 | ||
|
|
7c92f23351 | ||
|
|
fc1b68ebb4 | ||
|
|
3a108d3fec | ||
|
|
2566dcc98d | ||
|
|
1bc0188fcc | ||
|
|
92ade10a37 | ||
|
|
090228479a | ||
|
|
67b8e5735f | ||
|
|
4b3e66b4bd | ||
|
|
4a036fe215 | ||
|
|
9fc4225204 | ||
|
|
7b60fe734a | ||
|
|
ab62399f6f | ||
|
|
e462fa18ad | ||
|
|
5ce835ff3a | ||
|
|
2b90e4bbad | ||
|
|
d86e40a6ff | ||
|
|
3c7f5d7c74 | ||
|
|
295c447d58 | ||
|
|
69e6a29d87 | ||
|
|
167960b91a | ||
|
|
e0733666ae | ||
|
|
fd1db3970f | ||
|
|
da0b497972 | ||
|
|
bf789af62a | ||
|
|
cc2df90653 | ||
|
|
fc28bee55f | ||
|
|
17d438d102 | ||
|
|
9f000e2719 | ||
|
|
98a644524b | ||
|
|
4fd843318f | ||
|
|
1aa902bdce | ||
|
|
9418750061 | ||
|
|
1e14738258 | ||
|
|
b6cb94fdb5 | ||
|
|
b2e884d9b3 | ||
|
|
0371aa85a5 | ||
|
|
97b48f9841 | ||
|
|
41887dd56c | ||
|
|
0c866805d3 | ||
|
|
e97d1b3687 | ||
|
|
d1116d522d | ||
|
|
66366659d2 | ||
|
|
0e1d9b3322 | ||
|
|
9f7349af4e | ||
|
|
65a5943993 | ||
|
|
0a0b0b4724 | ||
|
|
0c7502cfd7 | ||
|
|
306026b0a1 | ||
|
|
0b44f04456 | ||
|
|
40b4de12e1 | ||
|
|
26a61aa302 | ||
|
|
d2545c4faf | ||
|
|
61b67e0843 | ||
|
|
cf6cf9ee33 | ||
|
|
cc397e8f71 | ||
|
|
c389d3d5ec | ||
|
|
8656e92f3c | ||
|
|
ead70efa49 | ||
|
|
17df46cb6c | ||
|
|
4e1043897d | ||
|
|
7e0c27341a | ||
|
|
22f967c753 | ||
|
|
f935e00b53 | ||
|
|
4e5933890f | ||
|
|
3b694edac6 | ||
|
|
30f83d433a | ||
|
|
4003279453 | ||
|
|
d99c8885fc | ||
|
|
7dddbe18b1 | ||
|
|
c2c51f9fca | ||
|
|
03760d293f | ||
|
|
d1c6743d2f | ||
|
|
78b1ea78d0 | ||
|
|
b269dcaecc | ||
|
|
82431490a9 | ||
|
|
7cc527b013 |
@@ -1,6 +1,5 @@
|
|||||||
node_modules
|
node_modules
|
||||||
data
|
data
|
||||||
extension
|
|
||||||
indeed_example.txt
|
indeed_example.txt
|
||||||
.env
|
.env
|
||||||
.env.*
|
.env.*
|
||||||
|
|||||||
+7
-4
@@ -1,11 +1,14 @@
|
|||||||
# Kopiere diese Datei nach .env und trage deinen Schlüssel ein.
|
# HINWEIS: Diese Werte werden seit dem Umzug auf die Datenbank-basierte
|
||||||
# .env wird NICHT eingecheckt (siehe .gitignore).
|
# Konfiguration primär in der Weboberfläche unter „Einstellungen“ (/einstellungen)
|
||||||
|
# gepflegt. Eine bestehende .env-Datei wird beim ersten Start einmalig in die
|
||||||
|
# Datenbank migriert; danach ist die Datenbank die einzige Quelle und .env wird
|
||||||
|
# nicht mehr benötigt. Diese Datei dient nur noch als Vorlage/Referenz.
|
||||||
|
|
||||||
# Ollama Cloud API-Schlüssel – https://ollama.com/settings/keys
|
# Ollama Cloud API-Schlüssel – https://ollama.com/settings/keys
|
||||||
OLLAMA_API_KEY=
|
OLLAMA_API_KEY=
|
||||||
|
|
||||||
# Optional – Cloud-Modell (Standard: gpt-oss:120b)
|
# Optional – Cloud-Modell (Standard: glm-5.2:cloud)
|
||||||
# OLLAMA_MODEL=gpt-oss:120b
|
# OLLAMA_MODEL=glm-5.2:cloud
|
||||||
|
|
||||||
# Optional – anderer Host (z. B. lokale Ollama-Instanz: http://localhost:11434)
|
# Optional – anderer Host (z. B. lokale Ollama-Instanz: http://localhost:11434)
|
||||||
# OLLAMA_HOST=https://ollama.com
|
# OLLAMA_HOST=https://ollama.com
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ COPY server.js ./
|
|||||||
COPY lib ./lib
|
COPY lib ./lib
|
||||||
COPY views ./views
|
COPY views ./views
|
||||||
COPY public ./public
|
COPY public ./public
|
||||||
|
COPY extension ./extension
|
||||||
|
|
||||||
# Persisted SQLite data lives here; make it writable for the non-root user
|
# Persisted SQLite data lives here; make it writable for the non-root user
|
||||||
RUN mkdir -p /app/data && chown -R node:node /app
|
RUN mkdir -p /app/data && chown -R node:node /app
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# Bewerbungs-Tracker
|
# NextJobs
|
||||||
|
|
||||||
Ein professioneller Job Application Tracker für Jobcenter Grundsicherung Monatsberichte.
|
Ein professioneller Job Application Tracker für Jobcenter Grundsicherung Monatsberichte.
|
||||||
|
|
||||||
@@ -23,27 +23,28 @@ Ablauf:
|
|||||||
Standard-Anschreiben und den Lebenslauf als Text einfügen. Diese Basis-Unterlagen
|
Standard-Anschreiben und den Lebenslauf als Text einfügen. Diese Basis-Unterlagen
|
||||||
sind die Faktengrundlage; die KI erfindet nichts hinzu.
|
sind die Faktengrundlage; die KI erfindet nichts hinzu.
|
||||||
2. **Stelle importieren** – Mit der Browser-Erweiterung (`extension/`) auf Indeed den
|
2. **Stelle importieren** – Mit der Browser-Erweiterung (`extension/`) auf Indeed den
|
||||||
Button **„An Bewerbungs-Tracker senden"** klicken.
|
Button **„An NextJobs senden"** klicken.
|
||||||
3. **Automatische Verarbeitung** – Der Tracker legt die Bewerbung mit Status
|
3. **Automatische Verarbeitung** – Der Tracker legt die Bewerbung mit Status
|
||||||
**Entwurf** an und generiert im Hintergrund über die **Ollama Cloud API**
|
**Entwurf** an und generiert im Hintergrund über die **Ollama Cloud API**
|
||||||
zugeschnittene Bewerbungsunterlagen, die als PDF-Anhänge an der Bewerbung hängen.
|
zugeschnittene Bewerbungsunterlagen, die als PDF-Anhänge an der Bewerbung hängen.
|
||||||
|
|
||||||
### Konfiguration
|
### Konfiguration
|
||||||
|
|
||||||
|
Alle Einstellungen (Ollama, E-Mail, Kalender, REST-API) werden in der Weboberfläche
|
||||||
|
unter **„Einstellungen“** (`/einstellungen`, Zahnrad-Symbol im Header) gepflegt und
|
||||||
|
in der Datenbank gespeichert. Änderungen wirken sofort, ein Neustart ist nicht nötig.
|
||||||
|
|
||||||
Für die KI-Generierung wird ein Ollama-Cloud-API-Schlüssel benötigt
|
Für die KI-Generierung wird ein Ollama-Cloud-API-Schlüssel benötigt
|
||||||
([ollama.com/settings/keys](https://ollama.com/settings/keys)). Am einfachsten über
|
([ollama.com/settings/keys](https://ollama.com/settings/keys)) – trage ihn nach dem
|
||||||
eine (nicht eingecheckte) `.env`-Datei – kopiere `.env.example` nach `.env`:
|
ersten Start unter **Einstellungen → Ollama Cloud** ein.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cp .env.example .env
|
|
||||||
# in .env eintragen:
|
|
||||||
# OLLAMA_API_KEY=...
|
|
||||||
# OLLAMA_MODEL=gpt-oss:120b # optional, Standard
|
|
||||||
# OLLAMA_HOST=https://ollama.com # optional; z. B. http://localhost:11434 für lokale Ollama
|
|
||||||
npm start
|
npm start
|
||||||
|
# dann im Browser http://localhost:3000/einstellungen öffnen
|
||||||
```
|
```
|
||||||
|
|
||||||
Alternativ als Umgebungsvariable: `export OLLAMA_API_KEY=...`.
|
Bestehende Installationen, die bisher eine `.env`-Datei nutzen, werden beim ersten
|
||||||
|
Start einmalig in die Datenbank migriert; danach wird `.env` nicht mehr benötigt.
|
||||||
|
|
||||||
Ohne Schlüssel wird die Bewerbung trotzdem als Entwurf angelegt; die Generierung
|
Ohne Schlüssel wird die Bewerbung trotzdem als Entwurf angelegt; die Generierung
|
||||||
schlägt dann kontrolliert mit einem Hinweis fehl und kann später per
|
schlägt dann kontrolliert mit einem Hinweis fehl und kann später per
|
||||||
@@ -161,10 +162,17 @@ bewerbungs-tracker/
|
|||||||
- Sonstiges
|
- Sonstiges
|
||||||
|
|
||||||
### Status
|
### Status
|
||||||
|
- Entwurf
|
||||||
- Gesendet
|
- Gesendet
|
||||||
- Eingangsbestätigung
|
- Eingangsbestätigung
|
||||||
|
- In Bearbeitung
|
||||||
|
- Interessiert
|
||||||
|
- Warten auf Rückmeldung
|
||||||
|
- Warten auf meine Antwort
|
||||||
- Vorstellungsgespräch
|
- Vorstellungsgespräch
|
||||||
|
- Vertragsverhandlung
|
||||||
- Absage
|
- Absage
|
||||||
|
- Absage von meiner Seite
|
||||||
- Einstellung
|
- Einstellung
|
||||||
- Keine Rückmeldung
|
- Keine Rückmeldung
|
||||||
|
|
||||||
@@ -217,10 +225,10 @@ das Rohdokument unter **`/swagger.json`**.
|
|||||||
### Authentifizierung
|
### Authentifizierung
|
||||||
|
|
||||||
Jeder Endpunkt (außer `GET /api/v1/health`) erfordert einen API-Key im Header
|
Jeder Endpunkt (außer `GET /api/v1/health`) erfordert einen API-Key im Header
|
||||||
`X-API-Key`. Der Schlüssel wird über die Umgebungsvariable `API_TOKEN` konfiguriert
|
`X-API-Key`. Der Schlüssel wird unter **Einstellungen → REST-API** (`API_TOKEN`)
|
||||||
(z. B. in `.env`, siehe `.env.example`). Ist `API_TOKEN` nicht gesetzt, antwortet
|
konfiguriert. Ist `API_TOKEN` nicht gesetzt, antwortet die API mit `503` – sie gibt
|
||||||
die API mit `503` – sie gibt nie ungeschützt Daten heraus. Swagger/UI sind
|
nie ungeschützt Daten heraus. Swagger/UI sind auch ohne Token erreichbar (die
|
||||||
auch ohne Token erreichbar (die Dokumentation enthält keine sensiblen Daten).
|
Dokumentation enthält keine sensiblen Daten).
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -H "X-API-Key: $API_TOKEN" http://localhost:3000/api/v1/applications
|
curl -H "X-API-Key: $API_TOKEN" http://localhost:3000/api/v1/applications
|
||||||
@@ -288,7 +296,7 @@ MIT
|
|||||||
|
|
||||||
## Autor
|
## Autor
|
||||||
|
|
||||||
Bewerbungs-Tracker für Jobcenter Grundsicherung
|
NextJobs für Jobcenter Grundsicherung
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
# syntax=docker/dockerfile:1
|
||||||
|
#
|
||||||
|
# Agent-Image für die isolierte, pro-Benutzer-Jobsuche.
|
||||||
|
#
|
||||||
|
# Anders als das App-Image (Dockerfile im Repo-Root) enthält dieses Image KEINE
|
||||||
|
# Anwendung, sondern ausschließlich die Werkzeuge, die der headless Such-Agent
|
||||||
|
# braucht: Claude Code, curl/python3/jq für die Skill-Skripte (Arbeitsagentur-API,
|
||||||
|
# bt.sh) sowie git. Der eigentliche Suchlauf wird vom Host-Runner pro Lauf als
|
||||||
|
# `docker run --rm` gestartet, mit dem jeweiligen Benutzer-Home als Volume —
|
||||||
|
# dadurch landen Memories und Session-Contexte getrennt pro Benutzer.
|
||||||
|
|
||||||
|
FROM node:20-bookworm-slim
|
||||||
|
|
||||||
|
ENV DEBIAN_FRONTEND=noninteractive
|
||||||
|
|
||||||
|
# Werkzeuge für die Skill-Skripte: arbeitsagentur.sh = curl+python3, bt.sh =
|
||||||
|
# curl+jq, plus git/flock/bash/coreutils für die Skripte selbst.
|
||||||
|
RUN apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends \
|
||||||
|
ca-certificates curl git python3 jq util-linux bash coreutils \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Claude Code auf einem systemweiten Pfad, damit es unabhängig vom zur Laufzeit
|
||||||
|
# gemounteten HOME gefunden wird. Version an den Host-CLI (2.1.209) angeglichen;
|
||||||
|
# DISABLE_AUTOUPDATER verhindert, dass jeder Benutzer sein eigenes Update zieht.
|
||||||
|
RUN npm install -g @anthropic-ai/claude-code@2.1.209
|
||||||
|
ENV DISABLE_AUTOUPDATER=1
|
||||||
|
|
||||||
|
# Nicht-Root-Benutzer, als der der Such-Agent läuft. Das node:20-Image bringt
|
||||||
|
# bereits uid/gid 1000 (»node«) mit — wir verwenden es unverändert, damit der
|
||||||
|
# Container dieselbe uid hat wie das vom Host-Runner gechownte pro-Benutzer-Home.
|
||||||
|
# COPY/Zielordner müssen für uid 1000 schreibbar sein.
|
||||||
|
|
||||||
|
# Geteilte Skills (Shared-Code, lesbar für alle); der Entrypoint verlinkt sie
|
||||||
|
# pro Benutzer nach ~/.claude/skills.
|
||||||
|
COPY skills /opt/skills
|
||||||
|
|
||||||
|
COPY entrypoint.sh /entrypoint.sh
|
||||||
|
RUN chmod +x /entrypoint.sh
|
||||||
|
|
||||||
|
# Schreibbarer Arbeitsordner (cwd während des Suchlaufs); Projekt-Key von Claude
|
||||||
|
# Code wird -work, also liegen Memory/Contexte unter ~/.claude/projects/-work.
|
||||||
|
# /home/agent wird im Image angelegt (uid 1000), damit der Entrypoint auch ohne
|
||||||
|
# gemountetes Volume (z. B. `docker run … claude --version`) läuft; im echten
|
||||||
|
# Lauf legt der Host-Runner das pro-Benutzer-Volume genau dortüber.
|
||||||
|
RUN mkdir -p /work /home/agent && chown -R 1000:1000 /work /home/agent
|
||||||
|
|
||||||
|
ENV HOME=/home/agent
|
||||||
|
|
||||||
|
WORKDIR /work
|
||||||
|
USER 1000:1000
|
||||||
|
|
||||||
|
ENTRYPOINT ["/entrypoint.sh"]
|
||||||
|
CMD ["claude", "--version"]
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
set -e
|
||||||
|
# Der Container läuft mit einem pro Benutzer gemounteten HOME (anfangs leer).
|
||||||
|
# Claude Code schreibt seine Memories und Session-Contexte nach ~/.claude; die
|
||||||
|
# drei Such-Skills sind geteilter Code aus dem Image und werden hier nur
|
||||||
|
# verlinkt, statt pro Benutzer kopiert zu werden.
|
||||||
|
mkdir -p "$HOME/.claude"
|
||||||
|
ln -sfn /opt/skills "$HOME/.claude/skills"
|
||||||
|
exec "$@"
|
||||||
@@ -0,0 +1,331 @@
|
|||||||
|
---
|
||||||
|
name: bewerbungs-tracker
|
||||||
|
description: Read and manage job applications ("Bewerbungen") via the local Bewerbungs-Tracker REST-API. Use whenever the user wants to list, create, update or delete Bewerbungen, manage their Statusverlauf/Timeline, download generated Unterlagen (PDF attachments), read E-Mail correspondence, trigger or check KI document generation, read/write Settings, get Statistics or an Export, browse Templates (Vorlagen), trigger document generation with optional static attachments (Anlagen), or manage JobOffers (Jobangebote). Trigger phrases: "Bewerbung(en)", "Bewerbungs-Tracker", "Statusverlauf", "Jobangebot(e)", "Bewerbungsstatistik", "Anschreiben generieren", "Anlagen beilegen".
|
||||||
|
---
|
||||||
|
|
||||||
|
# Bewerbungs-Tracker REST-API
|
||||||
|
|
||||||
|
REST-API zum Lesen und Verwalten von Bewerbungen, Statusverlauf, generierten
|
||||||
|
Unterlagen (PDFs), E-Mail-Korrespondenz, Einstellungen, Statistiken, Vorlagen
|
||||||
|
und Jobangeboten.
|
||||||
|
|
||||||
|
## Konfiguration
|
||||||
|
|
||||||
|
- **Base-URL:** `http://localhost:4327/api/v1` (override: env `BEWERBUNG_API_URL`)
|
||||||
|
- **Auth:** Header `X-API-Key: <token>` bei allen Endpunkten außer `/health`.
|
||||||
|
- **Key-Quelle:** env `BEWERBUNG_API_KEY`, sonst der Token des Benutzers
|
||||||
|
`BEWERBUNG_USER` (Default `admin`) aus der Tracker-DB.
|
||||||
|
|
||||||
|
### Multi-User — der Key entscheidet, wessen Daten du siehst
|
||||||
|
|
||||||
|
Der Tracker ist eine Plattform mit **mehreren Benutzern**. Jeder Benutzer hat
|
||||||
|
**seinen eigenen API-Token** (App → Einstellungen → REST-API → „Neu generieren"):
|
||||||
|
|
||||||
|
- Der `X-API-Key` **identifiziert den Benutzer**. Jeder Endpunkt liest und schreibt
|
||||||
|
**ausschließlich dessen eigene Daten** — fremde Bewerbungen, E-Mails, Anhänge,
|
||||||
|
Jobangebote und Einstellungen sind nicht erreichbar, auch nicht über eine fremde
|
||||||
|
`id` (die API antwortet dann `404`, als gäbe es den Datensatz nicht).
|
||||||
|
- Ein unbekannter, leerer oder mehrdeutiger Token ergibt `401`.
|
||||||
|
- **Es gibt keinen globalen Key mehr.** Der frühere `API_TOKEN` aus
|
||||||
|
`/opt/jobbi-bewerbung/.env` wird **nicht mehr benutzt**: er entsprach zufällig dem
|
||||||
|
Token des Admins (wirkte also still als Admin) und wird ungültig, sobald der Key in
|
||||||
|
der Oberfläche neu erzeugt wird.
|
||||||
|
- Willst du im Namen eines anderen Benutzers arbeiten, setze `BEWERBUNG_USER=<name>`
|
||||||
|
(oder direkt `BEWERBUNG_API_KEY`). Der Jobsuche-Runner setzt `BEWERBUNG_API_KEY`
|
||||||
|
automatisch je Benutzer.
|
||||||
|
|
||||||
|
> Der Key wird nicht im Skill gespeichert. Sende ihn **nie** an externe Dienste.
|
||||||
|
|
||||||
|
## Helper-Script (bevorzugt)
|
||||||
|
|
||||||
|
`scripts/bt.sh` kapselt curl inkl. Auth und gibt Body + `<http STATUS>` aus:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
scripts/bt.sh GET /applications
|
||||||
|
scripts/bt.sh GET "/applications?status=Gesendet&year=2026&limit=20"
|
||||||
|
scripts/bt.sh POST /applications '{"datum":"2026-07-03","firma":"ACME GmbH","stelle":"DevOps Engineer","art":"E-Mail"}'
|
||||||
|
scripts/bt.sh PUT /applications/12 @body.json
|
||||||
|
scripts/bt.sh DELETE /joboffers/5
|
||||||
|
```
|
||||||
|
|
||||||
|
Roh mit curl geht auch:
|
||||||
|
```bash
|
||||||
|
curl -sS -H "X-API-Key: $BEWERBUNG_API_KEY" http://localhost:4327/api/v1/statistics
|
||||||
|
```
|
||||||
|
|
||||||
|
## Endpunkte
|
||||||
|
|
||||||
|
### System
|
||||||
|
| Method | Pfad | Beschreibung |
|
||||||
|
|--------|------|--------------|
|
||||||
|
| GET | `/health` | Verfügbarkeit (ohne Auth). |
|
||||||
|
|
||||||
|
### Applications (Bewerbungen)
|
||||||
|
| Method | Pfad | Beschreibung |
|
||||||
|
|--------|------|--------------|
|
||||||
|
| GET | `/applications` | Auflisten. Query: `month`(01–12), `year`(YYYY), `status`, `art`, `label` (`Regional`\|`Remote-Deutschlandweit`\|`Homeoffice-Deutschlandweit`), `search` (Firma/Stelle), `limit`(1–500, def 100), `offset`. |
|
||||||
|
| POST | `/applications` | Anlegen. Duplicate-Guard → **409** bei gleicher Firma+Stelle; mit `"force":true` trotzdem anlegen. |
|
||||||
|
| GET | `/applications/{id}` | Einzelne Bewerbung (inkl. `verlauf`). |
|
||||||
|
| PUT | `/applications/{id}` | Aktualisieren (volle Ressource, `datum`/`firma`/`stelle` erforderlich). |
|
||||||
|
| DELETE | `/applications/{id}` | Löschen. |
|
||||||
|
| GET | `/applications/{id}/timeline` | Statusverlauf abrufen. |
|
||||||
|
| POST | `/applications/{id}/timeline` | Statusverlauf-Eintrag hinzufügen. |
|
||||||
|
| DELETE | `/applications/{id}/timeline/{eintragId}` | Verlaufseintrag löschen. |
|
||||||
|
|
||||||
|
### Attachments (generierte PDFs)
|
||||||
|
| Method | Pfad | Beschreibung |
|
||||||
|
|--------|------|--------------|
|
||||||
|
| GET | `/applications/{id}/attachments` | Anhänge auflisten. |
|
||||||
|
| GET | `/applications/{id}/attachments/{attachmentId}` | Anhang herunterladen (`application/octet-stream` → mit `-o datei` speichern). |
|
||||||
|
|
||||||
|
### Emails
|
||||||
|
| Method | Pfad | Beschreibung |
|
||||||
|
|--------|------|--------------|
|
||||||
|
| GET | `/applications/{id}/emails` | E-Mail-Korrespondenz (in/out) inkl. Anhang-Metadaten. |
|
||||||
|
| GET | `/emails/{emailId}/attachments/{attachmentId}` | E-Mail-Anhang herunterladen (binär). |
|
||||||
|
|
||||||
|
### Generation (KI-Unterlagen)
|
||||||
|
| Method | Pfad | Beschreibung |
|
||||||
|
|--------|------|--------------|
|
||||||
|
| POST | `/applications/{id}/generate` | Generierung anstoßen. Body optional: `{"llm_notizen":"...","anlagen":[2]}`. `anlagen` = IDs statischer Basis-Anlagen (siehe „Anlagen bei der Generierung"). |
|
||||||
|
| GET | `/applications/{id}/generation-status` | Status: `nicht_gestartet`\|`ausstehend`\|`fertig`\|`fehler` (+ `anhaenge`). |
|
||||||
|
|
||||||
|
### Settings / Statistics / Export / Templates
|
||||||
|
| Method | Pfad | Beschreibung |
|
||||||
|
|--------|------|--------------|
|
||||||
|
| GET | `/settings` | Persönliche Angaben des eigenen Benutzers: `name`, `adresse`, `kundennummer`, `email`, `telefon`, `ort`, `webseite`, `geburtsdatum`. Noch nichts gespeichert → `{}` (gültiger Zustand). |
|
||||||
|
| PUT | `/settings` | Persönliche Angaben speichern. Es werden **nur die Felder geändert, die im Body stehen** — weggelassene behalten ihren Wert. Antwort enthält den neuen Stand (`{success, settings}`). |
|
||||||
|
| GET | `/statistics` | `total`, `byArt[]`, `byStatus[]` — nur eigene Bewerbungen. |
|
||||||
|
| GET | `/export` | Export (ohne `interne_notizen`). Query: `month`, `year`. |
|
||||||
|
| GET | `/templates` | Basis-Unterlagen / Vorlagen des Benutzers. Enthält den **Lebenslauf** — daraus leitet die Stellensuche Rolle und Skills ab. |
|
||||||
|
|
||||||
|
### JobOffers (Jobangebote)
|
||||||
|
| Method | Pfad | Beschreibung |
|
||||||
|
|--------|------|--------------|
|
||||||
|
| GET | `/joboffers` | Auflisten (neueste zuerst). |
|
||||||
|
| POST | `/joboffers` | Upsert (per `(quelle, external_id)`, sonst normalisierte `quelle_url`) → Response `action: created\|updated`. **409** wenn geblacklistet (siehe unten). |
|
||||||
|
| GET | `/joboffers/{id}` | Einzelnes Jobangebot. |
|
||||||
|
| DELETE | `/joboffers/{id}` | Löschen. **Setzt das Angebot standardmäßig zuvor auf die Blacklist** (Typ `auto`), damit dieselbe Stelle nie erneut eingespielt wird. Mit `?blacklist=false` hart löschen ohne Blockierung. Response `{success, blacklisted}`. |
|
||||||
|
|
||||||
|
### Blacklist (blockierte Jobangebote)
|
||||||
|
| Method | Pfad | Beschreibung |
|
||||||
|
|--------|------|--------------|
|
||||||
|
| GET | `/joboffers/blacklist` | Blacklist-Einträge auflisten (neueste zuerst). |
|
||||||
|
| POST | `/joboffers/blacklist` | Block anlegen (per URL, Domain, Firma oder Firma+Stelle) → **201** `{success, entry}`. |
|
||||||
|
| DELETE | `/joboffers/blacklist/{id}` | Block entfernen — betroffene Angebote sind danach wieder einspielbar. |
|
||||||
|
|
||||||
|
## JobOffers einspielen (Drittanbieter → Upsert)
|
||||||
|
|
||||||
|
`POST /joboffers` ist der Einspiel-Endpunkt für Drittanbietersoftware (CRM,
|
||||||
|
Scraper, Job-Board-Sync). Er **upsertet** anhand `(quelle, external_id)`, und
|
||||||
|
falls keine `external_id` passt, anhand der **normalisierten `quelle_url`**:
|
||||||
|
|
||||||
|
- Treffer vorhanden → Datensatz wird aktualisiert → Response
|
||||||
|
`"action":"updated"`, HTTP **200**.
|
||||||
|
- kein Treffer → neuer Datensatz → `"action":"created"`, HTTP **201**.
|
||||||
|
- Angebot steht auf der **Blacklist** → HTTP **409**, `BlacklistConflict`-Body,
|
||||||
|
Angebot wird **nicht** aufgenommen (siehe Abschnitt „Blacklist“).
|
||||||
|
|
||||||
|
> Damit ist der Import **idempotent**: dieselbe Quelle kann beliebig oft
|
||||||
|
> eingespielt werden, ohne Dubletten zu erzeugen — Voraussetzung ist eine
|
||||||
|
> stabile `external_id` pro Quelle.
|
||||||
|
|
||||||
|
> ⚠️ **Upsert ersetzt, er merged nicht.** Beim Update wird der Datensatz mit dem
|
||||||
|
> gesendeten Body überschrieben; weggelassene optionale Felder werden **geleert**.
|
||||||
|
> Beim Re-Import daher immer den **vollständigen** Datensatz senden, nicht nur die
|
||||||
|
> geänderten Felder.
|
||||||
|
|
||||||
|
### Felder
|
||||||
|
|
||||||
|
| Feld | Pflicht | Beschreibung |
|
||||||
|
|------|:---:|--------------|
|
||||||
|
| `firma` | ✅ | Arbeitgeber. |
|
||||||
|
| `stelle` | ✅ | Stellenbezeichnung. |
|
||||||
|
| `firma_slug` | ✅ | **Pflicht.** Firmenname als `lowercase` + `kebab-case` (z. B. `Acme GmbH` → `acme-gmbh`). Dient der eindeutigen Identifizierung beim Blacklist-Abgleich. |
|
||||||
|
| `external_id` | – | ID des Angebots in der Quellsoftware. **Upsert-Schlüssel** — für idempotenten Import immer setzen. |
|
||||||
|
| `quelle` | – | Herkunftssystem/Kanal (z. B. `mein-crm`). Default serverseitig `drittanbieter`. |
|
||||||
|
| `ort` | – | Arbeitsort. |
|
||||||
|
| `adresse` | – | Anschrift des Arbeitgebers (Straße, Hausnummer, PLZ, Ort). |
|
||||||
|
| `ansprechpartner` | – | Name des Ansprechpartners für die Bewerbung. |
|
||||||
|
| `gehalt` | – | Freitext (z. B. `55.000 €`). |
|
||||||
|
| `beschreibung` | – | Volltext der Stellenanzeige. |
|
||||||
|
| `quelle_url` | – | Deeplink zum Original-Inserat. |
|
||||||
|
| `art` | – | Bewerbungsart-Enum (siehe unten). |
|
||||||
|
| `status` | – | `offen` \| `uebernommen` \| `abgelehnt`. Default `offen`. |
|
||||||
|
| `anzeige_datum` | – | Veröffentlichungsdatum des Inserats (`YYYY-MM-DD`). Wird gespeichert. |
|
||||||
|
| `kontakt_email` | – | Kontakt-/Bewerbungs-E-Mail-Adresse der Stelle. |
|
||||||
|
| `labels` | – | Array von Arbeitsort-Labels (Enum, mehrere möglich): `Regional`, `Remote-Deutschlandweit`, `Homeoffice-Deutschlandweit`. Kennzeichnet den Arbeitsort-Typ der Stelle. |
|
||||||
|
| ~~`created_at`~~ | ⛔ | **Nicht mitsenden** — Einspiel-Zeitpunkt wird serverseitig gesetzt; ein mitgesendeter Wert wird ignoriert. |
|
||||||
|
|
||||||
|
### Beispiel
|
||||||
|
|
||||||
|
```bash
|
||||||
|
scripts/bt.sh POST /joboffers '{
|
||||||
|
"external_id": "job-12345",
|
||||||
|
"quelle": "mein-crm",
|
||||||
|
"firma": "Acme GmbH",
|
||||||
|
"stelle": "Softwareentwickler (m/w/d)",
|
||||||
|
"firma_slug": "acme-gmbh",
|
||||||
|
"ort": "Berlin",
|
||||||
|
"adresse": "Musterstraße 1, 10115 Berlin",
|
||||||
|
"gehalt": "55.000 €",
|
||||||
|
"beschreibung": "…",
|
||||||
|
"quelle_url": "https://acme.example/job/12345",
|
||||||
|
"art": "Online-Portal",
|
||||||
|
"anzeige_datum": "2026-07-01",
|
||||||
|
"kontakt_email": "bewerbung@acme.example",
|
||||||
|
"labels": ["Regional"]
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
Roh mit curl:
|
||||||
|
```bash
|
||||||
|
curl -X POST "$BEWERBUNG_API_URL/joboffers" \
|
||||||
|
-H "X-API-Key: $BEWERBUNG_API_KEY" -H "Content-Type: application/json" \
|
||||||
|
-d '{"external_id":"job-12345","quelle":"mein-crm","firma":"Acme GmbH","stelle":"Softwareentwickler (m/w/d)","firma_slug":"acme-gmbh","art":"Online-Portal","anzeige_datum":"2026-07-01"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
> JSON erlaubt **keine Kommentare** — die `// created_at …`-Zeile aus Notizen
|
||||||
|
> vor dem Absenden entfernen, sonst schlägt das Parsen fehl (HTTP 400).
|
||||||
|
|
||||||
|
### Batch-Einspielung
|
||||||
|
|
||||||
|
Kein Bulk-Endpunkt: pro Angebot ein `POST`. Für viele Angebote in einer Schleife
|
||||||
|
importieren und `action` je Antwort auswerten:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# offers.json = JSON-Array von JobOfferCreate-Objekten
|
||||||
|
jq -c '.[]' offers.json | while read -r offer; do
|
||||||
|
scripts/bt.sh POST /joboffers "$offer" | head -1
|
||||||
|
done
|
||||||
|
```
|
||||||
|
|
||||||
|
## Blacklist (Angebote dauerhaft blockieren)
|
||||||
|
|
||||||
|
Über `/joboffers/blacklist` lassen sich Angebote sperren. Ein geblacklistetes
|
||||||
|
Angebot wird beim `POST /joboffers` **nicht** aufgenommen, sondern mit **409**
|
||||||
|
und `BlacklistConflict`-Body abgelehnt (`blacklisted:true`, `matched_by`,
|
||||||
|
`blacklist_entry`). Der Filter greift auf **künftige** Einspielungen; bereits
|
||||||
|
importierte Angebote werden nicht rückwirkend gelöscht.
|
||||||
|
|
||||||
|
### Block-Typen (`typ`)
|
||||||
|
|
||||||
|
| `typ` | Body-Felder | Blockiert |
|
||||||
|
|-------|-------------|-----------|
|
||||||
|
| `url` | `wert` = konkrete URL | genau dieses Inserat (URL wird normalisiert, Tracking-Parameter entfernt). |
|
||||||
|
| `domain` | `wert` = Domain (z. B. `stellenbetrug.example`) | alle Angebote dieser Domain. |
|
||||||
|
| `firma` | `wert` = Firmenname | alle Angebote dieser Firma. |
|
||||||
|
| `firma_stelle` | `firma` + `stelle` (+ optional `ort`) | eine bestimmte Firma-Stelle-Kombination. |
|
||||||
|
|
||||||
|
`grund` (Freitext) ist überall optional. Firma/Stelle/URL werden serverseitig
|
||||||
|
normalisiert, der Vergleich ist also unabhängig von Groß-/Kleinschreibung und
|
||||||
|
Tracking-Parametern. GET-Antworten enthalten die normalisierten Vergleichsfelder
|
||||||
|
zusätzlich: `url_norm`, `domain`, `firma_norm`, `firma_slug`, `stelle_norm`,
|
||||||
|
`ort_norm` (nur lesbar, serverseitig berechnet — nicht mitsenden). (Der Typ `auto`
|
||||||
|
in Antworten markiert automatisch erzeugte Einträge — aus einer **Ablehnung** oder
|
||||||
|
aus dem **Löschen eines Jobangebots** (`DELETE /joboffers/{id}`); er kann nicht
|
||||||
|
direkt angelegt werden.)
|
||||||
|
|
||||||
|
### Beispiele
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Ganze Domain sperren
|
||||||
|
scripts/bt.sh POST /joboffers/blacklist '{"typ":"domain","wert":"stellenbetrug.example","grund":"Fake-Inserate"}'
|
||||||
|
|
||||||
|
# Eine Firma sperren
|
||||||
|
scripts/bt.sh POST /joboffers/blacklist '{"typ":"firma","wert":"Dubios GmbH"}'
|
||||||
|
|
||||||
|
# Konkrete Firma+Stelle sperren
|
||||||
|
scripts/bt.sh POST /joboffers/blacklist '{"typ":"firma_stelle","firma":"Acme GmbH","stelle":"Vertrieb (m/w/d)","ort":"Berlin"}'
|
||||||
|
|
||||||
|
# Auflisten und wieder entfernen
|
||||||
|
scripts/bt.sh GET /joboffers/blacklist
|
||||||
|
scripts/bt.sh DELETE /joboffers/blacklist/3
|
||||||
|
```
|
||||||
|
|
||||||
|
> ⚠️ Vor dem `POST /joboffers` (v. a. Batch-Import) kann ein 409 auftreten, weil
|
||||||
|
> das Angebot geblacklistet ist. In Schleifen den Statuscode auswerten und solche
|
||||||
|
> Angebote überspringen statt abzubrechen.
|
||||||
|
|
||||||
|
## Anlagen bei der Generierung (statische Basis-Anlagen)
|
||||||
|
|
||||||
|
Beim Anstoßen der KI-Generierung (`POST /applications/{id}/generate`) lassen sich
|
||||||
|
optional **statische Basis-Anlagen** (`basis_anhaenge`) beilegen — z. B. Zeugnisse,
|
||||||
|
Zertifikate. Feld **`anlagen`**: ein Array von **Integer-IDs** dieser Basis-Anlagen.
|
||||||
|
|
||||||
|
- **Default: keine.** Fehlt `anlagen` oder ist das Array leer, werden **keine**
|
||||||
|
statischen Anlagen beigelegt.
|
||||||
|
- Die gewählten Anlagen erscheinen zusätzlich **im Anschreiben unter „Anlagen"**.
|
||||||
|
- IDs verweisen auf die im Tracker hinterlegten Basis-Anlagen (`basis_anhaenge`).
|
||||||
|
Für die aktuell verfügbaren IDs/Namen im Tracker nachsehen (die Basis-Anlagen
|
||||||
|
werden dort verwaltet); nur belegte IDs senden, keine raten.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Mit einer statischen Anlage (z. B. Abschlusszeugnis = ID 2) generieren
|
||||||
|
scripts/bt.sh POST /applications/12/generate '{"anlagen":[2]}'
|
||||||
|
|
||||||
|
# Mehrere Anlagen + KI-Kontext
|
||||||
|
scripts/bt.sh POST /applications/12/generate '{"llm_notizen":"…","anlagen":[2,5]}'
|
||||||
|
|
||||||
|
# Ohne Anlagen (Default) — Feld einfach weglassen
|
||||||
|
scripts/bt.sh POST /applications/12/generate '{}'
|
||||||
|
```
|
||||||
|
|
||||||
|
## Enums
|
||||||
|
|
||||||
|
- **art:** `E-Mail`, `Online-Portal`, `Indeed`, `StepStone`, `Firmenwebsite`, `Post`, `Initiativbewerbung`, `Arbeitsagentur`, `Sonstiges`
|
||||||
|
- **status (Bewerbung/Timeline):** `Entwurf`, `Gesendet`, `Eingangsbestätigung`, `In Bearbeitung`, `Interessiert`, `Warten auf Rückmeldung`, `Warten auf meine Antwort`, `Vorstellungsgespräch`, `Absage`, `Einstellung`, `Keine Rückmeldung`
|
||||||
|
- **joboffer.status:** `offen`, `uebernommen`, `abgelehnt`
|
||||||
|
- **labels (Arbeitsort, Array, mehrere möglich):** `Regional`, `Remote-Deutschlandweit`, `Homeoffice-Deutschlandweit` — auf `JobOffer` **und** `Application`; als `?label=…`-Filter auf `GET /applications` nutzbar.
|
||||||
|
- **blacklist.typ (anlegen):** `url`, `domain`, `firma`, `firma_stelle` (Antworten können zusätzlich `auto` enthalten)
|
||||||
|
- **email.direction:** `in`, `out`
|
||||||
|
- **generation.status:** `nicht_gestartet`, `ausstehend`, `fertig`, `fehler`
|
||||||
|
|
||||||
|
## Feld-Referenz (wichtigste)
|
||||||
|
|
||||||
|
**ApplicationCreate** — erforderlich `datum` (YYYY-MM-DD), `firma`, `stelle`;
|
||||||
|
optional `art`, `status`, `notizen`, `interne_notizen`, `ort`,
|
||||||
|
`stellenbeschreibung`, `quelle_url`, `llm_notizen`, `kommentar`, `force`,
|
||||||
|
`labels` (Array: `Regional`\|`Remote-Deutschlandweit`\|`Homeoffice-Deutschlandweit`;
|
||||||
|
auch als `?label=`-Filter beim Auflisten). `ApplicationUpdate` kennt `labels` ebenso.
|
||||||
|
|
||||||
|
**TimelineEntryCreate** — erforderlich `status`; optional `datum`, `kommentar`.
|
||||||
|
|
||||||
|
**GenerateRequest** (`POST /applications/{id}/generate`) — alle Felder optional:
|
||||||
|
`llm_notizen` (Freitext-Kontext für die KI), `anlagen` (Array von Integer-IDs
|
||||||
|
statischer Basis-Anlagen; Default **keine**, siehe Abschnitt „Anlagen bei der
|
||||||
|
Generierung“).
|
||||||
|
|
||||||
|
**JobOfferCreate** — erforderlich `firma`, `stelle`, `firma_slug` (Firmenname als
|
||||||
|
`lowercase` + `kebab-case`, z. B. `acme-gmbh` — eindeutige ID für den
|
||||||
|
Blacklist-Abgleich); optional `external_id`, `quelle`, `ort`, `adresse`,
|
||||||
|
`ansprechpartner`, `gehalt`, `beschreibung`, `quelle_url`, `art`, `status`,
|
||||||
|
`anzeige_datum`, `kontakt_email`, `labels` (Array:
|
||||||
|
`Regional`\|`Remote-Deutschlandweit`\|`Homeoffice-Deutschlandweit`, mehrere
|
||||||
|
möglich) (siehe Abschnitt „JobOffers einspielen“).
|
||||||
|
`created_at` wird **serverseitig** gesetzt und darf nicht mitgesendet werden.
|
||||||
|
|
||||||
|
**BlacklistEntryCreate** — erforderlich `typ` (`url`\|`domain`\|`firma`\|
|
||||||
|
`firma_stelle`); bei `url`/`domain`/`firma` das Feld `wert`; bei `firma_stelle`
|
||||||
|
die Felder `firma`+`stelle` (optional `ort`); `grund` überall optional (siehe
|
||||||
|
Abschnitt „Blacklist“).
|
||||||
|
|
||||||
|
## Hinweise / Fehlerbehandlung
|
||||||
|
|
||||||
|
- **401** `{"error":"Ungültiger oder fehlender API-Key ..."}` → Key falsch, leer oder
|
||||||
|
keinem Benutzer zugeordnet. Der Key liegt **in der Datenbank** (pro Benutzer), nicht
|
||||||
|
mehr in der `.env` — ein Serverneustart ist nach einer Key-Änderung **nicht** nötig,
|
||||||
|
ein neuer Key gilt sofort und macht den alten ungültig. Häufigste Ursache: Der Token
|
||||||
|
wurde in der Oberfläche neu generiert, die Umgebung nutzt noch den alten.
|
||||||
|
- **404 obwohl die ID existiert** → Die ID gehört einem **anderen Benutzer**. Die API
|
||||||
|
zeigt fremde Datensätze grundsätzlich als „nicht vorhanden". Prüfe, mit wessen Key du
|
||||||
|
arbeitest (`BEWERBUNG_USER` / `BEWERBUNG_API_KEY`).
|
||||||
|
- **409** bei `POST /applications` → mögliche Dublette (`matches[]`); mit `force:true` erzwingen.
|
||||||
|
- **409** bei `POST /joboffers` → Angebot steht auf der **Blacklist**
|
||||||
|
(`BlacklistConflict`: `blacklisted`, `matched_by`, `blacklist_entry`); nicht
|
||||||
|
erzwingbar — erst den Blacklist-Eintrag entfernen (`DELETE /joboffers/blacklist/{id}`).
|
||||||
|
- **404** → ID existiert nicht.
|
||||||
|
- Binär-Downloads (Attachments) mit `curl ... -o datei` speichern, nicht ins Terminal.
|
||||||
|
- Vor Löschungen (DELETE) beim User rückversichern.
|
||||||
|
|
||||||
|
Vollständige OpenAPI-Definition: `http://localhost:4327/swagger.json`.
|
||||||
+63
@@ -0,0 +1,63 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Helper for the Bewerbungs-Tracker REST-API.
|
||||||
|
# Usage:
|
||||||
|
# bt.sh GET /applications
|
||||||
|
# bt.sh GET "/applications?status=Gesendet&limit=20"
|
||||||
|
# bt.sh POST /applications '{"datum":"2026-07-03","firma":"ACME","stelle":"DevOps"}'
|
||||||
|
# bt.sh PUT /applications/12 @/path/to/body.json
|
||||||
|
# bt.sh DELETE /joboffers/5
|
||||||
|
#
|
||||||
|
# Config via env (with sensible defaults):
|
||||||
|
# BEWERBUNG_API_URL base URL (default http://localhost:4327/api/v1)
|
||||||
|
# BEWERBUNG_API_KEY API key (X-API-Key header)
|
||||||
|
# BEWERBUNG_USER username whose key to use (default: admin)
|
||||||
|
#
|
||||||
|
# MULTI-USER: the tracker has one API key PER USER; the key decides whose data you
|
||||||
|
# see and change. There is no global key any more. The old fallback (API_TOKEN from
|
||||||
|
# /opt/jobbi-bewerbung/.env) is gone on purpose: it happened to hold the admin's
|
||||||
|
# token, so it silently acted as admin — and it goes stale the moment a key is
|
||||||
|
# rotated in the UI (Einstellungen -> "Neu generieren").
|
||||||
|
#
|
||||||
|
# Key resolution:
|
||||||
|
# 1. $BEWERBUNG_API_KEY if set (this is what the Jobsuche-Runner passes per user)
|
||||||
|
# 2. otherwise: look up the key of $BEWERBUNG_USER (default admin) in the tracker DB
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
BASE="${BEWERBUNG_API_URL:-http://localhost:4327/api/v1}"
|
||||||
|
DB="${BEWERBUNG_DB:-/opt/jobbi-bewerbung/data/bewerbungen.db}"
|
||||||
|
USER_NAME="${BEWERBUNG_USER:-admin}"
|
||||||
|
KEY="${BEWERBUNG_API_KEY:-}"
|
||||||
|
|
||||||
|
if [[ -z "$KEY" && -f "$DB" ]]; then
|
||||||
|
KEY="$(node -e "
|
||||||
|
const s = require('/opt/jobbi-bewerbung/source/node_modules/sqlite3');
|
||||||
|
const db = new s.Database(process.argv[1]);
|
||||||
|
db.get(
|
||||||
|
\"SELECT a.value FROM app_state a JOIN users u ON u.id = a.user_id \" +
|
||||||
|
\"WHERE u.username = ? AND a.key = 'cfg:API_TOKEN' AND a.value != ''\",
|
||||||
|
[process.argv[2]],
|
||||||
|
(e, r) => process.stdout.write(r && r.value ? r.value : '')
|
||||||
|
);
|
||||||
|
" "$DB" "$USER_NAME" 2>/dev/null || true)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -z "$KEY" ]]; then
|
||||||
|
echo "bt.sh: kein API-Key für Benutzer '$USER_NAME'." >&2
|
||||||
|
echo " -> In der App unter Einstellungen -> REST-API einen Token erzeugen," >&2
|
||||||
|
echo " oder BEWERBUNG_API_KEY / BEWERBUNG_USER setzen." >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
|
||||||
|
METHOD="${1:?method required (GET/POST/PUT/DELETE)}"
|
||||||
|
PATH_ARG="${2:?path required, e.g. /applications}"
|
||||||
|
BODY="${3:-}"
|
||||||
|
|
||||||
|
url="${BASE}${PATH_ARG}"
|
||||||
|
args=(-sS -X "$METHOD" -H "X-API-Key: ${KEY}" -w $'\n<http %{http_code}>\n')
|
||||||
|
|
||||||
|
if [[ -n "$BODY" ]]; then
|
||||||
|
args+=(-H "Content-Type: application/json" --data-binary "$BODY")
|
||||||
|
fi
|
||||||
|
|
||||||
|
curl "${args[@]}" "$url"
|
||||||
@@ -0,0 +1,548 @@
|
|||||||
|
---
|
||||||
|
name: it-stellensuche-remote
|
||||||
|
description: Search the web for NEW 100% remote / homeoffice IT / system administrator jobs anywhere in Germany (Deutschland-weit, ortsunabhängig) that the user has NOT applied to yet, then present them and optionally import them as JobOffers into the Bewerbungs-Tracker. Use whenever the user wants to find new fully-remote IT/sysadmin postings, "Remote-Stellensuche", "Homeoffice-Jobs suchen", "100% Remote finden", "ortsunabhängige Systemadministrator-Stellen", or refresh open remote job leads. Deduplicates against existing Bewerbungen and Jobangebote via the bewerbungs-tracker skill.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Stellensuche REMOTE (100 % Homeoffice, deutschlandweit)
|
||||||
|
|
||||||
|
Findet **neue** Stellen im Web, die **100 % remote / vollständig im Homeoffice** und
|
||||||
|
**deutschlandweit ortsunabhängig** ausübbar sind, filtert alle raus, für die sich der
|
||||||
|
Nutzer schon beworben hat **oder die auf der Blacklist stehen**, und spielt sie auf
|
||||||
|
Wunsch als JobOffers in den Bewerbungs-Tracker ein.
|
||||||
|
|
||||||
|
> **Multi-User (WICHTIG):** Der Tracker hat mehrere Benutzer, jeder mit **eigenem
|
||||||
|
> Suchprofil und eigenem API-Key**. Der Remote-Modus ist inzwischen Teil des
|
||||||
|
> Suchprofils (`/jobsuche` → Modus „100 % Remote" bzw. „Regional + Remote"), und der
|
||||||
|
> Jobsuche-Runner ruft dafür den Skill `it-stellensuche` mit einem entsprechenden
|
||||||
|
> Prompt auf. Dieser Skill hier ist der **manuelle Einstieg** für eine reine
|
||||||
|
> Remote-Suche.
|
||||||
|
>
|
||||||
|
> Rolle und Technologien **nicht raten**: Sie kommen aus dem Lebenslauf des Benutzers,
|
||||||
|
> dem der `BEWERBUNG_API_KEY` gehört (`GET /templates`, typ `Lebenslauf`) — bzw. aus dem
|
||||||
|
> Profil, das der Prompt mitliefert. Die Rollen-/Buzzword-Listen unten sind **Beispiele
|
||||||
|
> für ein IT-Infrastruktur-Profil**, kein Default für jeden Benutzer.
|
||||||
|
|
||||||
|
> **Abgrenzung zum regionalen Skill:** Dieser Skill sucht **ausschließlich
|
||||||
|
> ortsunabhängige 100-%-Remote-Stellen deutschlandweit** — **ohne** Bindung an die
|
||||||
|
> sechs Städte. Der **Arbeitsort/Firmensitz spielt keine Rolle**, solange die
|
||||||
|
> Tätigkeit vollständig aus dem Homeoffice irgendwo in Deutschland erbracht werden
|
||||||
|
> kann. Regionale, ortsgebundene Stellen sind Sache des Skills `it-stellensuche`
|
||||||
|
> (sechs Städte) und werden hier **nicht** behandelt.
|
||||||
|
|
||||||
|
> **Blacklist-Prinzip (zentral):** Jeder erfolgreich importierte Job wird
|
||||||
|
> **unmittelbar danach geblacklistet**. So taucht dieselbe Stelle in späteren
|
||||||
|
> Suchläufen nie wieder auf — die Dublett-Vermeidung bleibt bestehen, selbst wenn
|
||||||
|
> das JobOffer später gelöscht oder in eine Bewerbung überführt wird. Umgekehrt
|
||||||
|
> werden Kandidaten, die schon auf der Blacklist stehen, gar nicht erst gelistet
|
||||||
|
> oder importiert. Die Blacklist/Dedup ist **gemeinsam** mit dem regionalen Skill —
|
||||||
|
> derselbe Job wird also über beide Suchen hinweg nicht doppelt erfasst.
|
||||||
|
|
||||||
|
## Suchprofil (Default)
|
||||||
|
|
||||||
|
**Rolle:** breit fassen — dieselbe Tätigkeit läuft je nach Firma unter vielen Titeln.
|
||||||
|
Alle folgenden Bezeichnungen als Query-Varianten und für die Relevanzbewertung nutzen
|
||||||
|
(zutreffend, sobald der Schwerpunkt auf IT-Infrastruktur/Systembetrieb liegt):
|
||||||
|
|
||||||
|
- **Kern (deckt sich mit dem CV: 2nd-Level-Sysadmin + IT-Consultant):**
|
||||||
|
Systemadministrator, IT-Administrator, IT-Systemadministrator, IT-Systemadministration,
|
||||||
|
IT-Systemadministrator 2nd Level, 2nd-Level-Administrator, System-Administrator (m/w/d),
|
||||||
|
Fachinformatiker Systemintegration, Administrator (m/w/d).
|
||||||
|
- **Support/Betrieb:** 1st/2nd/3rd-Level-Support, 2nd-Level-Support, IT-Support,
|
||||||
|
IT-Supporter, IT-Techniker, IT-Systemtechniker, Systemtechniker, IT-Systembetreuer,
|
||||||
|
IT-Betreuer, IT-Allrounder, IT-Koordinator, IT-Mitarbeiter, IT-Operations / IT-Betrieb,
|
||||||
|
Remote-Support, Remote-Systemadministrator, Service-Desk, Application Manager (remote).
|
||||||
|
(Rein ortsgebundene Onsite-/Field-Service-Rollen entfallen hier — sie sind nicht
|
||||||
|
remote ausübbar.)
|
||||||
|
- **Infrastruktur/Netz:** System Engineer, Systems Engineer, Infrastructure Engineer,
|
||||||
|
IT-Infrastruktur, Infrastruktur-Administrator, Netzwerkadministrator, Network Engineer,
|
||||||
|
Virtualisierungsadministrator, Storage-/Backup-Administrator.
|
||||||
|
- **Cloud/Modern Workplace:** Cloud-Administrator, Cloud Engineer, Cloud-Operations-Engineer,
|
||||||
|
Microsoft-365-Administrator, M365-/Modern-Workplace-Administrator,
|
||||||
|
Azure-/Entra-Administrator, DevOps-Engineer (Infrastruktur-lastig),
|
||||||
|
Site Reliability Engineer (SRE), Platform Engineer, Linux-Administrator,
|
||||||
|
Linux System Engineer, Windows-Administrator.
|
||||||
|
- **Managed Services / Hosting / Betrieb (passt zu Hetzner/OVH im CV):**
|
||||||
|
IT-Consultant (remote), IT-Berater (technisch), IT-Systemberater,
|
||||||
|
Managed-Services-Engineer, MSP-Engineer, NOC-Engineer, Cloud Operations,
|
||||||
|
Hosting-Administrator, Hosting Engineer, Systemadministrator Rechenzentrum (remote).
|
||||||
|
|
||||||
|
Ausschließen bleiben reine Softwareentwickler-, Data-Science-, Vertriebs- und
|
||||||
|
SAP-only-Stellen, außer sie passen klar zum Infrastruktur-Profil.
|
||||||
|
|
||||||
|
> **Ex-Arbeitgeber IMMER ausschließen (harte Regel, unabhängig von der Schreibweise):**
|
||||||
|
> Stellen der **„IT-Problemlöser"** (Essen) — voller Firmenname u. a.
|
||||||
|
> **„IT Problemlöser Verwaltungs- und Handels GmbH"**, auch **„IT-Problemlöser GmbH"**,
|
||||||
|
> **„IT Problemlöser"** o. Ä. — werden **nie** gelistet oder importiert. Es ist der
|
||||||
|
> frühere Arbeitgeber des Nutzers (siehe Lebenslauf). Diese Firma taucht immer wieder
|
||||||
|
> mit **wechselnden Stellentiteln** auf; die `firma_stelle`-Blacklist greift dann nicht,
|
||||||
|
> weil der Titel abweicht. Deshalb **jeden Treffer verwerfen, dessen Firmenname `IT
|
||||||
|
> Problemlöser` / `IT-Problemlöser` (in jeder Rechtsform-/Schreibvariante) enthält** —
|
||||||
|
> ohne auf die Blacklist zu warten. (Firmenweite `firma`-Blacklist-Einträge greifen
|
||||||
|
> inzwischen schreibweisen-robust über `firma_slug` inkl. Präfix-Match — diese
|
||||||
|
> Modell-Regel bleibt trotzdem als zusätzliche Sicherung bestehen.)
|
||||||
|
|
||||||
|
**Arbeitsmodell (harter Filter — das Kernkriterium dieses Skills):**
|
||||||
|
Nur **100 % Remote / vollständig ortsunabhängige** Stellen, die **deutschlandweit
|
||||||
|
aus dem Homeoffice** erbracht werden können.
|
||||||
|
|
||||||
|
- **Zulässig** (behalten): Anzeigen, die klar als „100 % Remote", „Full Remote",
|
||||||
|
„vollständig remote", „remote-first", „ortsunabhängig", „deutschlandweit im
|
||||||
|
Homeoffice", „remote (Deutschland)" o. Ä. ausgeschrieben sind — die Tätigkeit ist
|
||||||
|
im Alltag komplett aus dem Homeoffice möglich. **Gelegentliche** Vor-Ort-Termine
|
||||||
|
(Onboarding, wenige Team-Events/Kick-offs pro Quartal/Jahr) sind unschädlich.
|
||||||
|
- **Verwerfen** (nicht listen, nicht importieren):
|
||||||
|
- **Reine Vor-Ort-/Präsenz-Stellen** (kein oder nur „nach Absprache"-Homeoffice).
|
||||||
|
- **Klassische Hybrid-Modelle mit festen Präsenz-/Bürotagen** (z. B. „2–3 Tage/
|
||||||
|
Woche vor Ort", „überwiegend Präsenz", „Homeoffice anteilig/tageweise möglich") —
|
||||||
|
das ist **nicht** 100 % remote.
|
||||||
|
- Stellen, die **Wohnsitz/Anwesenheit in einem bestimmten Ort oder Umkreis**
|
||||||
|
verlangen („Einsatzort …", „Wohnsitz im Raum …", „regelmäßig beim Kunden vor
|
||||||
|
Ort", „Rufbereitschaft vor Ort", Onsite-Field-Service).
|
||||||
|
- Stellen ohne **Anstellung in Deutschland** (z. B. „remote nur aus <anderes
|
||||||
|
Land>", Arbeitsvertrag/Steueransässigkeit außerhalb DE). Der Job muss für einen
|
||||||
|
in **Deutschland ansässigen** Bewerber als reguläre Anstellung offenstehen.
|
||||||
|
- **Kein geografischer Prioritäts-Ranking** (deutschlandweit gleichwertig) — statt
|
||||||
|
nach Ort **nach Relevanz** (Profil-/Buzzword-Passung, s. u.) und **Klarheit des
|
||||||
|
100-%-Remote-Versprechens** priorisieren.
|
||||||
|
|
||||||
|
> Bei Unsicherheit, ob eine Stelle wirklich 100 % remote ist, die Anzeige per
|
||||||
|
> `WebFetch` genau lesen (Abschnitte „Arbeitsmodell", „Das bieten wir", „Standort").
|
||||||
|
> Steht dort nur „Homeoffice möglich"/„flexibel" ohne klares 100-%-Remote, im Zweifel
|
||||||
|
> **verwerfen** — dieser Skill nimmt ausschließlich eindeutig vollständig remote
|
||||||
|
> Stellen auf.
|
||||||
|
|
||||||
|
**Skills/Buzzwords** (für Query-Varianten & Relevanzbewertung): erste Gruppe stammt
|
||||||
|
direkt aus dem Lebenslauf (Kernkompetenzen, höchste Gewichtung), die weiteren sind
|
||||||
|
naheliegende, zum Profil passende Technologien — als Suchbegriffe und zum Erkennen
|
||||||
|
passender Anzeigen nutzen, auch wenn sie nicht wörtlich im CV stehen:
|
||||||
|
|
||||||
|
- **Kern (aus dem CV, höchste Gewichtung):** Windows Server, Active Directory,
|
||||||
|
Exchange, Microsoft 365 / MS365, Linux-Administration (Debian/Ubuntu),
|
||||||
|
Docker/Container, Proxmox/Virtualisierung, Netzwerk (OPNsense, pfSense,
|
||||||
|
VPN: WireGuard/OpenVPN/IPsec, LAN/WAN, TCP/IP), Firewall, Monitoring
|
||||||
|
(Prometheus, Grafana, Beszel, Uptime Kuma), Hetzner/OVH Cloud, Self-Hosting,
|
||||||
|
Automatisierung, AI/Claude/Agent-Workflows.
|
||||||
|
- **Microsoft-/Windows-Umfeld:** Entra ID / Azure AD, Intune, Endpoint Manager,
|
||||||
|
Group Policy / GPO, WSUS, PowerShell, Hyper-V, SharePoint, Teams, Windows 10/11,
|
||||||
|
Client-Management, MDM.
|
||||||
|
- **Virtualisierung/Server:** VMware vSphere / ESXi, Hyper-V, Citrix, KVM,
|
||||||
|
Terminalserver / RDS, Server-Hardware (Dell/HPE/Lenovo).
|
||||||
|
- **Backup/Storage:** Veeam, Backup & Recovery, Datensicherung, NAS/SAN, TrueNAS,
|
||||||
|
ZFS, Ceph, Synology, QNAP.
|
||||||
|
- **Netzwerk/Security:** VLAN, Routing & Switching, Cisco, Fortinet/FortiGate,
|
||||||
|
Sophos, Ubiquiti/UniFi, DNS/DHCP, Reverse Proxy (Nginx, Traefik), Let's Encrypt,
|
||||||
|
IT-Security, Patch-Management, ISO 27001, IT-Grundschutz.
|
||||||
|
- **Cloud/DevOps/Automatisierung:** Microsoft Azure, AWS, Kubernetes, Ansible,
|
||||||
|
Terraform, GitLab / CI/CD, Bash-/Shell-Scripting, Portainer, Nextcloud.
|
||||||
|
- **Datenbanken/Web:** MySQL/MariaDB, PostgreSQL, MS SQL Server, Apache, Nginx, IIS.
|
||||||
|
- **Betrieb/Organisation:** Ticketsystem (Jira, OTRS, Zammad), Helpdesk,
|
||||||
|
Rechenzentrum, On-Premise, Managed Services, IT-Dienstleister, Systemhaus,
|
||||||
|
Cloud-Provider, Hosting-Anbieter.
|
||||||
|
|
||||||
|
> Aktuelles Profil (Titel, Skills, Ort) steht in der Lebenslauf-Vorlage der API:
|
||||||
|
> `bewerbungs-tracker` → `GET /templates` (typ `Lebenslauf`). Bei Bedarf dort
|
||||||
|
> gegenlesen, statt zu raten.
|
||||||
|
|
||||||
|
## Quellen-Politik (WICHTIG)
|
||||||
|
|
||||||
|
Ziel ist **immer die Original-Stellenausschreibung direkt beim echten Arbeitgeber**
|
||||||
|
— bevorzugt auf dessen **Firmen-/Karriereseite**.
|
||||||
|
|
||||||
|
- **Ausschließen (nicht listen, nicht importieren):**
|
||||||
|
- **Headhunter / Personalvermittler / Personalberatungen** (recruiten für Dritte).
|
||||||
|
- **Zeitarbeit / Arbeitnehmerüberlassung / Personaldienstleister** (z. B. Randstad,
|
||||||
|
Hays, GULP, Amadeus FiRe, Adecco, Manpower, Piening, Tempton, DIS AG, Robert Half
|
||||||
|
u. ä. — auch unbekannte, wenn die Anzeige „Arbeitnehmerüberlassung",
|
||||||
|
„im Kundenauftrag", „für unseren Kunden", „Personaldienstleister" o. Ä. nennt).
|
||||||
|
- **Stepstone** als Quelle — nicht als Board nutzen, keine `site:stepstone.de`-Query,
|
||||||
|
keine Stepstone-Links importieren.
|
||||||
|
- **Erkennungsmerkmale eines Vermittlers/Zeitarbeit** (bei Unsicherheit die
|
||||||
|
Firmen-/Impressumsseite per `WebFetch` prüfen): Formulierungen wie „für unseren
|
||||||
|
Kunden", „im Auftrag unseres Mandanten", „Arbeitnehmerüberlassung", „Direktvermittlung",
|
||||||
|
„Personaldienstleistung", „Recruiting-Partner"; oder die inserierende Firma ist erkennbar
|
||||||
|
eine Personal-/Recruiting-Agentur. → **verwerfen**.
|
||||||
|
- **Zulässig als Quelle**, wenn es zur Original-Anzeige des Arbeitgebers führt:
|
||||||
|
Firmen-Karriereseite (bevorzugt), Arbeitsagentur/Bundesagentur für Arbeit, Indeed
|
||||||
|
oder LinkedIn **nur** wenn die Anzeige eindeutig vom echten Arbeitgeber (nicht von
|
||||||
|
einem Vermittler) stammt. Führt ein Board-Treffer zu einer Firma, deren eigene
|
||||||
|
Karriereseite dieselbe Stelle direkt listet, **immer den Firmen-Direktlink** nehmen.
|
||||||
|
- **Remote-spezifische Boards** (nur als Ergänzung, immer zur Arbeitgeber-Originalanzeige
|
||||||
|
durchklicken): z. B. `germantechjobs.de`, `remotarr`, `remote.io`, `remoteok.com`,
|
||||||
|
`4dayweek.io`, `join.com`, `arbeitsagentur.de` (Filter „Homeoffice/Telearbeit"),
|
||||||
|
`de.indeed.com` (Filter „Remote"), `de.linkedin.com/jobs` (Filter „Remote") — auch hier
|
||||||
|
Vermittler/Zeitarbeit/Stepstone ausschließen und den Firmen-Direktlink bevorzugen.
|
||||||
|
|
||||||
|
## Workflow
|
||||||
|
|
||||||
|
1. **Bereits erfasste Jobs + Blacklist laden** (Dedup-Basis). Dieser Schritt ist
|
||||||
|
**immer zuerst** auszuführen, damit Stellen aus früheren Suchläufen — **auch aus
|
||||||
|
dem regionalen Skill** — **nicht erneut** gefunden/importiert werden. Zwei Quellen:
|
||||||
|
```bash
|
||||||
|
scripts/applied-set.sh # firma_slug<TAB>firma<TAB>stelle<TAB>ort<TAB>quelle_url<TAB>external_id
|
||||||
|
scripts/blacklist-set.sh # id<TAB>typ<TAB>firma<TAB>stelle<TAB>ort<TAB>domain<TAB>url_norm<TAB>firma_norm<TAB>firma_slug<TAB>stelle_norm<TAB>ort_norm<TAB>grund
|
||||||
|
```
|
||||||
|
- **applied-set** (Bewerbungen **und** importierte Jobangebote): **eine Zeile
|
||||||
|
pro bereits erfasster Firma** (company-level, nicht mehr pro Stelle). Je Zeile
|
||||||
|
drei Dublett-Merkmale merken: **`firma_slug`** (robuster Firmen-Schlüssel:
|
||||||
|
lowercase, Umlaute→ae/oe/ue/ss, `(m/w/d)` raus, End-Rechtsform wie gmbh/ag/kg
|
||||||
|
entfernt, mit `-` verbunden), **`quelle_url`** und **`external_id`**.
|
||||||
|
- **blacklist-set** (gesperrte Muster): je Zeile den **`typ`** und die dazu
|
||||||
|
passenden Felder merken (`url_norm`, `domain`, `firma_norm`, **`firma_slug`**,
|
||||||
|
`stelle_norm`). Die `*_norm`/`firma_slug`-Felder liefert der Server bereits
|
||||||
|
normalisiert (lowercase, Tracking-Parameter entfernt, `(m/w/d)`/Rechtsform
|
||||||
|
bereinigt).
|
||||||
|
|
||||||
|
2. **Kandidaten sammeln — strukturierte Quelle zuerst, dann WebSearch.**
|
||||||
|
|
||||||
|
**(a) Arbeitsagentur-API mit Homeoffice-Filter (primär, strukturiert, frisch).**
|
||||||
|
Vor der Freitext-Suche die Jobsuche-API der Bundesagentur für Arbeit abfragen —
|
||||||
|
**deutschlandweit** (`wo` leer) mit `arbeitszeit=ho` (Homeoffice-Flag):
|
||||||
|
```bash
|
||||||
|
scripts/arbeitsagentur.sh search "<Rolle>" "" 0 30 100 ho
|
||||||
|
# -> firma_slug<TAB>firma<TAB>titel<TAB>ort<TAB>datum<TAB>refnr<TAB>angebotsart
|
||||||
|
```
|
||||||
|
Rollen aus dem Suchprofil durchrotieren (Systemadministrator, IT-Administrator,
|
||||||
|
Linux/Windows-Administrator, Cloud Administrator, System Engineer, DevOps, SRE, …).
|
||||||
|
Jede Zeile sofort per `firma_slug` gegen applied-set/blacklist prüfen (Firmen-Dedup,
|
||||||
|
Schritt 1). Für überlebende Treffer die Details holen:
|
||||||
|
```bash
|
||||||
|
scripts/arbeitsagentur.sh detail <refnr>
|
||||||
|
```
|
||||||
|
Detail liefert **Volltext** (→ `beschreibung`), **`EXTERNE_URL`** (→ bevorzugt
|
||||||
|
`quelle_url`), **`HOMEOFFICE_MOEGLICH`**, **Vergütung**, Firmensitz und die Flags
|
||||||
|
**`ZEITARBEIT_AUE` / `PRIVATE_ARBEITSVERMITTLUNG`** — `True` → Zeitarbeit/Vermittler
|
||||||
|
→ **verwerfen**. ⚠️ `arbeitszeit=ho`/`HOMEOFFICE_MOEGLICH` heißt nur „Homeoffice
|
||||||
|
möglich", **nicht** automatisch „100 % remote" — die harte 100-%-Remote-Prüfung
|
||||||
|
(Schritt 4) gilt unverändert am Volltext. „Keine aktive Anzeige" → verwerfen; `refnr`
|
||||||
|
stabil → Existenz-Check & `external_id`. (Die API ist ortsorientiert und deshalb hier
|
||||||
|
nur **eine** Quelle — WebSearch bleibt für Remote-Boards & remote-first-Firmen wichtig.)
|
||||||
|
|
||||||
|
**(b) WebSearch (ergänzend).** Mit dem `WebSearch`-Tool **viele** Queries fahren —
|
||||||
|
Rolle × **Remote/Homeoffice**, **deutschlandweit** (kein Ort anhängen), mit Fokus
|
||||||
|
auf **Original-Anzeigen der Arbeitgeber**.
|
||||||
|
|
||||||
|
> **Query-Strategie (Recall maximieren — WICHTIG, findet die Suche „kaum noch
|
||||||
|
> Stellen"):** Lieber **viele einfache, natürlichsprachliche Einzel-Queries** als
|
||||||
|
> wenige überladene. **Boolesche Operatoren sparsam einsetzen** — `OR`, `"…"`,
|
||||||
|
> `site:`, `inurl:` und `-minus`-Ausschlüsse **senken bei WebSearch oft drastisch
|
||||||
|
> die Trefferzahl**. Statt `A OR B "…" inurl:jobs`-Monsterqueries lieber **getrennte,
|
||||||
|
> kurze Queries**; Vermittler/Stepstone/Hybrid erst **nachträglich beim Filtern**
|
||||||
|
> (Schritt 3) rauswerfen. Jede Rolle mit **mehreren Remote-Synonymen** und Suffixen
|
||||||
|
> durchvariieren: Remote-Wörter `remote`, `100% Remote`, `Homeoffice`,
|
||||||
|
> `home office`, `ortsunabhängig`, `deutschlandweit`, `remote-first`, `full remote`;
|
||||||
|
> Suffixe `Stellenangebot`, `Job`, `Jobs`, `Festanstellung`, `unbefristet`,
|
||||||
|
> `Vollzeit`, `2026`.
|
||||||
|
|
||||||
|
**Rolle × Remote (mehrere Synonyme/Suffixe durchspielen), Beispiele:**
|
||||||
|
- `Systemadministrator 100% Remote Stellenangebot Deutschland`,
|
||||||
|
`Systemadministrator remote Homeoffice Job`, `Systemadministrator ortsunabhängig Festanstellung`
|
||||||
|
- `IT-Administrator remote deutschlandweit Job`, `IT-Administrator Homeoffice Vollzeit`,
|
||||||
|
`IT-Systemadministrator full remote 2026`
|
||||||
|
- `IT-Systemadministrator vollständig remote Stellenangebot`,
|
||||||
|
`2nd Level Support remote deutschlandweit`, `Remote-Systemadministrator Festanstellung`
|
||||||
|
- `Linux Administrator remote Deutschland`, `Linux System Engineer Homeoffice`,
|
||||||
|
`Windows Administrator remote deutschlandweit`
|
||||||
|
- `Microsoft 365 Administrator remote Homeoffice`, `Cloud Administrator 100% Homeoffice`,
|
||||||
|
`Cloud Engineer remote Deutschland`, `Azure Administrator remote Festanstellung`
|
||||||
|
- `System Engineer remote Deutschland`, `Infrastructure Engineer Homeoffice`,
|
||||||
|
`Platform Engineer remote deutschlandweit`, `SRE remote Deutschland`
|
||||||
|
- `Hosting Administrator remote`, `Managed Services Engineer remote Homeoffice`,
|
||||||
|
`NOC Engineer remote Deutschland`, `IT-Consultant remote Homeoffice`
|
||||||
|
- **Buzzword-getrieben** (holt Anzeigen mit abweichendem Titel, passendem Inhalt):
|
||||||
|
`VMware Administrator remote Deutschland`, `Proxmox remote Homeoffice Job`,
|
||||||
|
`Docker Kubernetes remote Administrator`, `Active Directory remote Homeoffice`,
|
||||||
|
`Veeam Backup remote Job`, `Hetzner OVH remote Administrator`,
|
||||||
|
`OPNsense pfSense Firewall remote Job`, `Ansible Automatisierung remote Deutschland`.
|
||||||
|
- **Remote-Boards zusätzlich** (jeweils **einzeln**, nicht stapeln): `germantechjobs
|
||||||
|
systemadministrator remote`, `remoteok IT administrator`, `4dayweek.io sysadmin`,
|
||||||
|
`join.com systemadministrator remote`, `Systemadministrator Homeoffice arbeitsagentur`,
|
||||||
|
`IT Administrator remote indeed`. Falls doch `site:`, dann einzeln:
|
||||||
|
`site:germantechjobs.de systemadministrator remote`,
|
||||||
|
`site:de.indeed.com IT Administrator remote`,
|
||||||
|
`site:arbeitsagentur.de Systemadministrator Telearbeit`. **Kein** `site:stepstone.de`.
|
||||||
|
Für Details/Arbeitsmodell/Firma einer Trefferseite `WebFetch` nutzen. Sieht ein
|
||||||
|
Treffer nach Firmen-Karriereseite aus: dort direkt nach der Einzelanzeige suchen.
|
||||||
|
|
||||||
|
2b. **Remote-freundliche Arbeitgeber gezielt finden** (Hauptquelle für „unentdeckte"
|
||||||
|
Stellen). Board-Suchen finden nur, was breit ausgeschrieben ist — viele
|
||||||
|
remote-first-Arbeitgeber posten IT-Stellen **ausschließlich auf der eigenen
|
||||||
|
Website**. Deshalb parallel zur Rolle-×-Remote-Suche **remote-affine Arbeitgeber
|
||||||
|
deutschlandweit identifizieren** und deren Karriereseite direkt öffnen:
|
||||||
|
- **Arbeitgeber-Typen recherchieren:** Cloud-/Hosting-Provider, Managed-Service-
|
||||||
|
Provider (MSP), SaaS-/Tech-Unternehmen, IT-Dienstleister/Systemhäuser mit
|
||||||
|
Remote-Kultur, verteilte/remote-first-Firmen. Beispiel-Queries:
|
||||||
|
`remote-first Unternehmen Deutschland IT`, `MSP remote Systemadministrator Karriere`,
|
||||||
|
`SaaS Unternehmen Deutschland remote Stellenangebote`,
|
||||||
|
`Cloud Provider Deutschland Karriere remote`.
|
||||||
|
- **Karriereseite direkt anfahren:** zu jeder gefundenen Firma
|
||||||
|
`WebSearch "<Firma> Karriere remote"` bzw. `"<Firma> Jobs remote"` und die
|
||||||
|
Jobliste per `WebFetch` öffnen; auf offene **100-%-Remote IT-/Administrator-/
|
||||||
|
Support-Stellen** prüfen. Auch `inurl:karriere`, `inurl:jobs`, `inurl:stellen`
|
||||||
|
gezielt einsetzen und gängige Bewerber-Portale erkennen (softgarden/onlyfy/
|
||||||
|
Personio/Workday/join.com).
|
||||||
|
- **Buzzwords als Türöffner:** findet die reine Titel-Suche wenig, mit den
|
||||||
|
Technologie-Buzzwords (oben) + „remote" suchen, z. B.
|
||||||
|
`Active Directory remote Administrator Deutschland`, `Veeam remote Job Homeoffice`.
|
||||||
|
- Jede so gefundene Stelle läuft durch dieselben Prüf-/Filter-/Dedup-Schritte
|
||||||
|
(3–7). Der Firmen-Direktlink ist hier ohnehin schon die bevorzugte `quelle_url`.
|
||||||
|
|
||||||
|
3. **Filtern & bewerten:**
|
||||||
|
- **Arbeitsmodell (harter Ausschluss):** **nur** eindeutig **100 % Remote /
|
||||||
|
vollständig ortsunabhängige** Stellen, deutschlandweit aus dem Homeoffice
|
||||||
|
ausübbar (Anstellung in Deutschland). Alles andere — reine Präsenz, Hybrid mit
|
||||||
|
festen Bürotagen, ortsgebundene/„Homeoffice möglich"-ohne-100%-Zusage,
|
||||||
|
Anstellung außerhalb DE — **verwerfen**. Kein geografisches Ranking; nach
|
||||||
|
Relevanz priorisieren.
|
||||||
|
- **Rolle:** Systemadministration/IT-Infrastruktur; keine reinen Entwickler-,
|
||||||
|
Vertriebs- oder SAP-only-Stellen (außer sie passen klar zum Profil).
|
||||||
|
- **Quelle (harter Ausschluss):** Headhunter/Personalvermittler, Zeitarbeit/
|
||||||
|
Arbeitnehmerüberlassung und Stepstone werden **verworfen** — siehe
|
||||||
|
„Quellen-Politik". Nur Original-Anzeigen echter Arbeitgeber behalten.
|
||||||
|
- **Dedup (persistent, FIRMEN-basiert):** **Eine Firma darf nur EINMAL gefunden
|
||||||
|
werden** (regional **und** remote gemeinsam). Treffer **verwerfen**, sobald
|
||||||
|
**eines** zutrifft: (a) gleiche/sehr ähnliche `quelle_url`, (b) gleiche
|
||||||
|
`external_id`, oder (c) **die Firma steht schon im Applied-Set** — unabhängig vom
|
||||||
|
Stellentitel. Firmen-Gleichheit über `firma_slug` prüfen: Kandidaten-Firma ebenso
|
||||||
|
sluggen (lowercase, Umlaute→ae/oe/ue/ss, `(m/w/d)` & End-Rechtsform raus, mit `-`
|
||||||
|
verbunden) und als **dieselbe Firma** werten, wenn der Slug **gleich** ist ODER
|
||||||
|
**ein Slug ein führendes Bindestrich-Präfix des anderen** ist (mind. 2 Tokens) —
|
||||||
|
so zählen **verschiedene Schreibweisen/Rechtsform-/Namensvarianten derselben
|
||||||
|
Firma als eine** (z. B. „IT-Problemlöser GmbH" = „IT Problemlöser Verwaltungs-
|
||||||
|
und Handels GmbH"). Offensichtlich derselbe Arbeitgeber → als Dublette verwerfen.
|
||||||
|
Distinkte Firmen mit nur gleichem ersten Wort („Meyer IT" vs. „Meyer Logistik")
|
||||||
|
sind **nicht** dieselbe Firma. Im Zweifel behalten und als „evtl. Dublette"
|
||||||
|
markieren.
|
||||||
|
- **Blacklist (harter Ausschluss):** Kandidat **verwerfen** (nicht listen, nicht
|
||||||
|
importieren), sobald er auf einen Blacklist-Eintrag passt — je nach `typ`:
|
||||||
|
- `url` → normalisierte Kandidaten-URL == `url_norm`.
|
||||||
|
- `domain` → Host der Kandidaten-URL == `domain` (auch Subdomains).
|
||||||
|
- `firma` → **dieselbe Firma** wie der Eintrag (Kandidaten-`firma_slug` gleich
|
||||||
|
`firma_slug` **oder** Präfix-Match wie bei der Dedup; `firma_norm` nur als
|
||||||
|
Fallback für Alt-Einträge ohne Slug).
|
||||||
|
- `firma_stelle` → dieselbe Firma (`firma_slug`, wie oben) **und** `stelle_norm`
|
||||||
|
passen (ist `ort_norm` gesetzt, muss auch der Ort passen).
|
||||||
|
- `auto` → wie die konkreten Felder, die der Eintrag trägt.
|
||||||
|
Diese Muster sind bewusst blockiert (u. a. jeder früher importierte Job, siehe
|
||||||
|
Schritt 6) — geblacklistete Stellen daher **stillschweigend überspringen**,
|
||||||
|
nicht als „evtl. Dublette" präsentieren.
|
||||||
|
- **Relevanz:** höher gewichten, je mehr Buzzwords aus dem Profil passen.
|
||||||
|
|
||||||
|
4. **Existenz prüfen, Link prüfen, Remote-Zusage bestätigen, Kontakt-E-Mail &
|
||||||
|
Firmensitz recherchieren** — für jeden Treffer, der in die engere Wahl kommt,
|
||||||
|
**zwingend einzeln**:
|
||||||
|
- **Existenz-Prüfung (Pflicht):** Jede Stelle mit `WebFetch` auf dem Einzel-Link
|
||||||
|
öffnen und bestätigen, dass die Anzeige **noch aktiv** ist (Firma + Stelle
|
||||||
|
stehen dort, kein 404/„Stelle nicht mehr verfügbar"/Redirect auf Jobliste).
|
||||||
|
Snippets aus der WebSearch reichen **nicht** — Suchindizes zeigen oft schon
|
||||||
|
abgelaufene Anzeigen. Kein Live-Nachweis → Treffer **verwerfen**. Bei bereits
|
||||||
|
importierten Angeboten, die nicht mehr existieren, das JobOffer löschen
|
||||||
|
(`DELETE /joboffers/{id}`).
|
||||||
|
- **100-%-Remote live bestätigen (Pflicht):** Im per `WebFetch` geöffneten
|
||||||
|
Anzeigentext prüfen, dass die Stelle **tatsächlich vollständig remote /
|
||||||
|
deutschlandweit ortsunabhängig** ist (Abschnitte „Arbeitsmodell", „Standort",
|
||||||
|
„Das bieten wir"). Nur „Homeoffice möglich"/„hybrid"/feste Präsenztage → **nicht**
|
||||||
|
100 % remote → **verwerfen**. Nur eindeutig vollständig remote Stellen behalten.
|
||||||
|
- **Vollständige Stellenausschreibung erfassen (Pflicht):** Beim `WebFetch` der
|
||||||
|
Einzelanzeige den **kompletten Ausschreibungstext** übernehmen — nicht nur eine
|
||||||
|
Kurzzusammenfassung. Dazu gehören: Einleitung/Unternehmensvorstellung, **Aufgaben/
|
||||||
|
Tätigkeiten**, **Anforderungen/Profil**, **Wir bieten/Benefits**, Angaben zu
|
||||||
|
Arbeitszeit/Vertragsart/**Remote-Regelung**, Gehalt (falls genannt), Bewerbungsweg/
|
||||||
|
Kontakt und Referenz-/Kennziffer. Text weitgehend **wortgetreu und vollständig**
|
||||||
|
sichern (nur Navigations-/Cookie-/Footer-Boilerplate der Seite weglassen), inkl.
|
||||||
|
der Gliederung/Überschriften. Dieser Volltext wandert in `beschreibung`
|
||||||
|
(Schritt 6). Ist die Seite lang/abgeschnitten, `WebFetch` gezielt erneut aufrufen.
|
||||||
|
- **`quelle_url` verifizieren:** Muss auf die **konkrete, noch aktive
|
||||||
|
Einzelanzeige** zeigen (nicht Trefferliste/Suchseite). Deeplink defekt/Liste →
|
||||||
|
per Suche den echten Einzel-Link finden (bevorzugt Firmenwebsite/Karriereseite),
|
||||||
|
sonst verwerfen.
|
||||||
|
- **Vermittler/Zeitarbeit aussortieren (Pflicht):** Zeigt der Treffer auf eine
|
||||||
|
Personalberatung/Headhunter/Zeitarbeit statt den echten Arbeitgeber, **verwerfen**.
|
||||||
|
Bei Unsicherheit die inserierende Firma per `WebFetch` (Impressum/„Über uns")
|
||||||
|
prüfen — Recruiting-/Personaldienstleister raus. Existiert dieselbe Stelle direkt
|
||||||
|
auf der Firmen-Karriereseite, diese Original-Anzeige verwenden.
|
||||||
|
- **Bewerber-Kontakt-E-Mail recherchieren:** Die E-Mail-Adresse ermitteln, an die
|
||||||
|
sich Bewerber wenden. Reihenfolge: (a) direkt in der Stellenanzeige genannte
|
||||||
|
Bewerbungs-/Kontaktadresse; (b) Karriere-/Kontaktseite der Firma
|
||||||
|
(`WebSearch` „<Firma> Karriere Kontakt Bewerbung E-Mail", `WebFetch` der Seite);
|
||||||
|
(c) allgemeine Bewerbungsadresse (`bewerbung@`/`jobs@`/`karriere@<domain>`) nur,
|
||||||
|
wenn auf der Firmenseite belegt. **Nicht raten** — nur belegte Adressen; sonst
|
||||||
|
`kontakt_email` leer lassen und als „nicht gefunden" markieren.
|
||||||
|
- **Firmensitz recherchieren (Pflicht, soweit belegbar):** Da die Stelle
|
||||||
|
ortsunabhängig ist, gibt es keinen festen Arbeitsort — als `adresse` den
|
||||||
|
**eingetragenen Firmensitz** des Arbeitgebers erfassen (Straße, Hausnummer, PLZ,
|
||||||
|
Ort; Quelle: Impressum/Kontaktseite, `WebSearch` „<Firma> Impressum Adresse",
|
||||||
|
`WebFetch` der Seite). **Nicht raten** — nur eine belegte Anschrift übernehmen;
|
||||||
|
sonst `adresse` leer lassen und als „nicht gefunden" markieren. Der Firmensitz
|
||||||
|
ist reine Zusatzinfo und **kein** Filterkriterium (die Stelle bleibt zulässig,
|
||||||
|
egal wo die Firma sitzt).
|
||||||
|
|
||||||
|
5. **Ergebnis präsentieren** als Tabelle: Firma · Stelle · **Arbeitsmodell (100 %
|
||||||
|
Remote)** · Firmensitz · Quelle (Board) · **verifizierter Link** ·
|
||||||
|
**Kontakt-E-Mail** · kurze Passt-Begründung. Sortierung: neue, klar passende
|
||||||
|
Treffer **nach Relevanz** (Buzzword-Passung; bei Gleichstand klar/eindeutig
|
||||||
|
100-%-Remote vor „remote, aber schwammig formuliert"). Fehlt Link, E-Mail oder
|
||||||
|
Firmensitz, kennzeichnen.
|
||||||
|
|
||||||
|
6. **Importieren** — im **interaktiven** Modus nur nach Rückfrage/Bestätigung des
|
||||||
|
Nutzers; im **autonomen Modus** (siehe unten) automatisch ohne Rückfrage. Jeden
|
||||||
|
neuen Treffer als JobOffer einspielen (Upsert, idempotent) über den
|
||||||
|
`bewerbungs-tracker`-Skill:
|
||||||
|
```bash
|
||||||
|
~/.claude/skills/bewerbungs-tracker/scripts/bt.sh POST /joboffers '{
|
||||||
|
"external_id": "remote-<slug(firma)>-<slug(stelle)>",
|
||||||
|
// IMMER setzen und DETERMINISTISCH aus
|
||||||
|
// firma+stelle bilden — NICHT aus der
|
||||||
|
// Board-/Anzeigen-ID. Präfix "remote-" hält
|
||||||
|
// Remote-Funde auseinander; Slug = normalisiert
|
||||||
|
// wie der firma_slug (lowercase, ohne (m/w/d)
|
||||||
|
// & Rechtsform, Interpunktion→"-"), z. B.
|
||||||
|
// "remote-musterfirma-cloud-administrator".
|
||||||
|
// So bekommt DIESELBE Stelle über jeden Lauf
|
||||||
|
// UND jedes Board dasselbe external_id →
|
||||||
|
// Server-Upsert greift zuverlässig statt eine
|
||||||
|
// Dublette anzulegen. (Eine flüchtige Board-ID
|
||||||
|
// würde je Quelle abweichen und Duplikate
|
||||||
|
// erzeugen — daher NICHT nutzen.)
|
||||||
|
"quelle": "stellensuche-remote",
|
||||||
|
"firma": "…", "stelle": "…",
|
||||||
|
"ort": "Remote (Deutschland)", // ortsunabhängig; ggf. "100% Remote / Homeoffice"
|
||||||
|
"adresse": "<eingetragener Firmensitz: Straße Hausnr, PLZ Ort; recherchiert &
|
||||||
|
verifiziert; sonst weglassen>",
|
||||||
|
"gehalt": "…",
|
||||||
|
"beschreibung": "<VOLLSTÄNDIGE Stellenausschreibung>",
|
||||||
|
// Der komplette, in Schritt 4 per WebFetch
|
||||||
|
// erfasste Anzeigentext inkl. Remote-Regelung —
|
||||||
|
// wortgetreu und vollständig, nicht kürzen.
|
||||||
|
// Gliederung/Überschriften erhalten (Markdown ok).
|
||||||
|
"quelle_url": "<verifizierter Einzel-Link, bevorzugt Firmen-Karriereseite>",
|
||||||
|
"art": "Firmenwebsite",
|
||||||
|
"anzeige_datum": "YYYY-MM-DD",
|
||||||
|
"kontakt_email": "<recherchierte Bewerber-E-Mail, sonst weglassen>",
|
||||||
|
"labels": ["Remote-Deutschlandweit", "Homeoffice-Deutschlandweit"]
|
||||||
|
// IMMER beide setzen: dieser Skill sucht ausschließlich
|
||||||
|
// 100% Remote / Homeoffice deutschlandweit → Arbeitsort-
|
||||||
|
// Labels "Remote-Deutschlandweit" + "Homeoffice-Deutschlandweit".
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
`art`-Enum: `E-Mail`, `Online-Portal`, `Indeed`, `StepStone`, `Firmenwebsite`,
|
||||||
|
`Post`, `Initiativbewerbung`, `Arbeitsagentur`, `Sonstiges`. **Standard ist
|
||||||
|
`Firmenwebsite`** (Original-Anzeige des Arbeitgebers). `StepStone` nie verwenden
|
||||||
|
(ausgeschlossene Quelle); `Arbeitsagentur`/`Indeed` nur, wenn der Link direkt zur
|
||||||
|
Arbeitgeber-Anzeige führt.
|
||||||
|
|
||||||
|
**`409`-Antwort beim Import:** Steht der Job (trotz Vorfilter) auf der Blacklist,
|
||||||
|
antwortet `POST /joboffers` mit **409** (`BlacklistConflict`) und nimmt ihn
|
||||||
|
**nicht** auf. Das ist **kein Fehler** — als „übersprungen (Blacklist)" zählen
|
||||||
|
und mit dem nächsten Treffer weitermachen, **nicht** mit `force` erzwingen.
|
||||||
|
|
||||||
|
7. **Direkt blacklisten (nach jedem erfolgreichen Import).** Sobald ein JobOffer
|
||||||
|
angelegt wurde (Antwort **201** / `action:created`; bei `updated` ist der Job
|
||||||
|
bereits erfasst), die **Firma sofort ganz** blacklisten, damit dieser Arbeitgeber
|
||||||
|
in künftigen Läufen — remote **wie regional** — **mit keinem Stellentitel** wieder
|
||||||
|
auftaucht (Regel „eine Firma nur einmal"). Per `typ: firma` sperren — der Server
|
||||||
|
matcht firmenweit über `firma_slug` und deckt so abweichende Schreibweisen/
|
||||||
|
Rechtsformen mit ab:
|
||||||
|
```bash
|
||||||
|
~/.claude/skills/bewerbungs-tracker/scripts/bt.sh POST /joboffers/blacklist '{
|
||||||
|
"typ": "firma",
|
||||||
|
"firma": "<firma wie importiert>",
|
||||||
|
"grund": "auto: stellensuche-remote-import <YYYY-MM-DD>"
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
Server-Antwort **201** = geblacklistet. Normalisierung (Groß/Klein, `(m/w/d)`,
|
||||||
|
Rechtsform, Umlaute) übernimmt der Server. Nur nach **echtem** Neuimport
|
||||||
|
blacklisten — nicht bei einem 409-Skip und nicht bei bloßem `updated`.
|
||||||
|
|
||||||
|
## Autonomer Modus (headless / geplant)
|
||||||
|
|
||||||
|
Wird der Skill **nicht-interaktiv** ausgeführt — d. h. ohne Nutzer, der bestätigen
|
||||||
|
kann (z. B. `claude -p "…" --dangerously-skip-permissions`, Cron/geplanter Lauf,
|
||||||
|
`ollama launch claude … -p …`) — im **autonomen Modus** arbeiten:
|
||||||
|
|
||||||
|
- **Ohne Rückfrage importieren:** Neue, geprüfte Treffer direkt als JobOffer
|
||||||
|
einspielen (Schritt 6) **und anschließend blacklisten** (Schritt 7). Es gibt
|
||||||
|
niemanden zum Bestätigen — nicht auf Eingabe warten.
|
||||||
|
- **Alle Prüfregeln gelten unverändert und strikt:** Existenz-Prüfung, **100-%-Remote
|
||||||
|
live bestätigt**, korrekter verifizierter Einzel-Link, Kontakt-E-Mail nur wenn
|
||||||
|
belegt, **Firmensitz nur wenn belegt**, die persistente Dedup **und der
|
||||||
|
Blacklist-Filter** — importiere **nur** echte, live verifizierte, eindeutig
|
||||||
|
vollständig remote, noch nicht erfasste und **nicht geblacklistete** Stellen.
|
||||||
|
Im Zweifel **nicht** importieren (lieber auslassen).
|
||||||
|
- **Keine Bewerbungen anlegen/versenden**, keine Löschungen bestehender Bewerbungen,
|
||||||
|
keine Generierung anstoßen — nur neue Jobangebote (`POST /joboffers`) einpflegen
|
||||||
|
und den jeweils importierten Job blacklisten (`POST /joboffers/blacklist`).
|
||||||
|
- **Kurzbericht ausgeben** (für das Log): je Treffer `action`
|
||||||
|
(created+blacklisted / updated / skip-dedup / skip-blacklist) mit Firma, Stelle,
|
||||||
|
Arbeitsmodell, Link; am Ende Zähler „X neu importiert & geblacklistet, Y als
|
||||||
|
Dublette/Blacklist übersprungen, Z verworfen (nicht verifizierbar / nicht 100 %
|
||||||
|
remote)".
|
||||||
|
|
||||||
|
## Regeln
|
||||||
|
|
||||||
|
- **Nur 100 % Remote, deutschlandweit.** Es werden **ausschließlich** eindeutig
|
||||||
|
vollständig remote / ortsunabhängige Stellen gelistet/importiert, die aus dem
|
||||||
|
Homeoffice irgendwo in Deutschland ausübbar sind (Anstellung in DE). Reine
|
||||||
|
Präsenz-, Hybrid- (feste Bürotage) und ortsgebundene Stellen werden **verworfen** —
|
||||||
|
Letztere sind Sache des regionalen Skills `it-stellensuche`. Kein Orts-Ranking,
|
||||||
|
Priorität nach Relevanz.
|
||||||
|
- **Labels `Remote-Deutschlandweit` + `Homeoffice-Deutschlandweit` setzen (Pflicht).**
|
||||||
|
Jedes von diesem Skill importierte JobOffer bekommt
|
||||||
|
`labels: ["Remote-Deutschlandweit", "Homeoffice-Deutschlandweit"]` (100 % remote,
|
||||||
|
deutschlandweit). Kein `Regional`-Label — ortsgebundene Stellen sind Sache des
|
||||||
|
Skills `it-stellensuche`.
|
||||||
|
- **Keine erfundenen Stellen.** Nur Jobs ausgeben, die per WebSearch/WebFetch real
|
||||||
|
belegt sind. **`quelle_url` muss der korrekte, geprüfte Direktlink zur
|
||||||
|
Einzelanzeige sein** (per `WebFetch` bestätigt) — keine Such-/Listenseiten, keine
|
||||||
|
geratenen URLs. Kein verifizierbarer Einzel-Link → Treffer nicht listen/importieren.
|
||||||
|
- **100 % Remote live belegt.** Vor dem Listen/Importieren im Anzeigentext bestätigen,
|
||||||
|
dass die Stelle wirklich vollständig remote ist. „Homeoffice möglich"/hybrid/feste
|
||||||
|
Präsenztage genügen **nicht** → verwerfen.
|
||||||
|
- **Nur Original-Anzeigen echter Arbeitgeber.** Headhunter/Personalvermittler,
|
||||||
|
Zeitarbeit/Arbeitnehmerüberlassung und **Stepstone** sind ausgeschlossen — weder
|
||||||
|
listen noch importieren (siehe „Quellen-Politik"). Bevorzugt der Firmen-Direktlink.
|
||||||
|
- **`beschreibung` = vollständige Ausschreibung.** Immer den kompletten, per WebFetch
|
||||||
|
erfassten Anzeigentext (Aufgaben, Anforderungen, Benefits, Remote-Regelung,
|
||||||
|
Bewerbungsweg, Kennziffer) übernehmen — nicht kürzen oder zusammenfassen.
|
||||||
|
- **Kontakt-E-Mail nicht erfinden.** `kontakt_email` nur setzen, wenn die Adresse in
|
||||||
|
der Anzeige oder auf der Firmen-Karriereseite belegt ist; sonst weglassen.
|
||||||
|
- **Firmensitz recherchieren, nicht erfinden.** `adresse` = belegter Firmensitz aus
|
||||||
|
dem Impressum; sonst leer lassen. Kein Filterkriterium — nur Zusatzinfo.
|
||||||
|
- **Keine Doppelfunde — eine Firma nur EINMAL, auch über Läufe und Skills hinweg.**
|
||||||
|
Vor dem Listen/Importieren immer `applied-set.sh` **und** `blacklist-set.sh` laden
|
||||||
|
und jeden Kandidaten per `quelle_url`, `external_id` **und `firma_slug`
|
||||||
|
(firmenweit)** sowie gegen die Blacklist abgleichen. Dedup/Blacklist sind mit dem
|
||||||
|
regionalen Skill **gemeinsam**. **Steht die Firma schon im Applied-Set oder auf der
|
||||||
|
Blacklist — egal unter welchem Stellentitel und egal in welcher Schreibweise/
|
||||||
|
Rechtsform (`firma_slug` gleich ODER Präfix-Match, mind. 2 Tokens) — wird kein
|
||||||
|
weiterer Treffer dieses Arbeitgebers ausgegeben oder importiert.** Auch **innerhalb
|
||||||
|
eines Laufs** jede Firma nur **einmal** präsentieren. Beim Import die `external_id`
|
||||||
|
**deterministisch aus `firma+stelle`** bilden (Präfix `remote-`), **nicht** aus
|
||||||
|
einer Board-/Anzeigen-ID — so ergibt dieselbe Stelle über jeden Lauf/jedes Board
|
||||||
|
dasselbe `external_id` (Server-Upsert statt Dublette). Der Server dedupliziert
|
||||||
|
`POST /joboffers` über `external_id` (Upsert) und die Blacklist (409, firmenweit
|
||||||
|
über `firma_slug`).
|
||||||
|
- **Blacklisten nach jedem Import (Pflicht, firmenweit).** Jeder frisch angelegte Job
|
||||||
|
(`action:created`) wird direkt anschließend per `POST /joboffers/blacklist`
|
||||||
|
(**`typ:firma`** — die ganze Firma) gesperrt (Schritt 7), damit dieser Arbeitgeber
|
||||||
|
in Folgeläufen mit keinem Titel wieder auftaucht. Nicht blacklisten bei `updated`
|
||||||
|
oder 409-Skip. Ein 409 beim `POST /joboffers` bedeutet „steht schon auf der
|
||||||
|
Blacklist" → überspringen, nie mit `force` erzwingen.
|
||||||
|
- **Interaktiv: nicht ungefragt importieren** — erst zeigen, dann auf Bestätigung
|
||||||
|
importieren. **Autonom/headless: automatisch importieren** (siehe „Autonomer
|
||||||
|
Modus"). In **keinem** Modus Bewerbungen anlegen/versenden.
|
||||||
|
- Board-Aggregatoren-Duplikate (dieselbe Stelle auf mehreren Portalen) zu einem
|
||||||
|
Eintrag zusammenfassen, bevorzugt mit Direkt-/Firmenwebsite-Link.
|
||||||
|
- Ist die Bewerbungs-Tracker-API nicht erreichbar (`GET /health`), Dedup nicht
|
||||||
|
möglich → Nutzer warnen und Treffer ohne Dedup mit Hinweis liefern.
|
||||||
|
|
||||||
|
## Abhängigkeiten
|
||||||
|
|
||||||
|
- Tools: `WebSearch`, `WebFetch`.
|
||||||
|
- Skill `bewerbungs-tracker` (für Dedup-Daten, JobOffer-Import und Blacklist:
|
||||||
|
`GET/POST /joboffers/blacklist`).
|
||||||
|
- Scripts: `scripts/arbeitsagentur.sh` (strukturierte Jobsuche-API der Bundesagentur
|
||||||
|
für Arbeit, `search`/`detail` — hier mit `arbeitszeit=ho`), `scripts/applied-set.sh`
|
||||||
|
(erfasste Firmen), `scripts/blacklist-set.sh` (gesperrte Muster) — allesamt
|
||||||
|
**Symlinks** auf die geteilten Scripts des Skills `it-stellensuche` (eine Quelle der
|
||||||
|
Wahrheit). `applied-set`/`blacklist-set` nutzen den `bt.sh`-Helfer des
|
||||||
|
`bewerbungs-tracker`-Skills; `arbeitsagentur.sh` nur `curl` + `python3`.
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
../../it-stellensuche/scripts/applied-set.sh
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
../../it-stellensuche/scripts/arbeitsagentur.sh
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
../../it-stellensuche/scripts/blacklist-set.sh
|
||||||
@@ -0,0 +1,565 @@
|
|||||||
|
---
|
||||||
|
name: it-stellensuche
|
||||||
|
description: Search the web for NEW jobs matching the user's own search profile (cities / remote mode, taken from the Bewerbungs-Tracker) that they have NOT applied to yet, then present them and optionally import them as JobOffers into the Bewerbungs-Tracker. Use whenever the user wants to find new job postings, "Stellensuche", "neue Stellen suchen", "Jobs finden", "Systemadministrator-Stellen", or refresh open job leads. Deduplicates against existing Bewerbungen and Jobangebote via the bewerbungs-tracker skill.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Stellensuche (Suchprofil des jeweiligen Benutzers)
|
||||||
|
|
||||||
|
Findet **neue** Stellen im Web — **in den Städten bzw. im Modus, die das Suchprofil des
|
||||||
|
Benutzers vorgibt** —, filtert alle raus, für die sich der Nutzer schon beworben hat
|
||||||
|
**oder die auf der Blacklist stehen**, und spielt sie auf Wunsch als JobOffers in den
|
||||||
|
Bewerbungs-Tracker ein.
|
||||||
|
|
||||||
|
> **Multi-User (WICHTIG):** Der Tracker ist eine Plattform mit mehreren Benutzern; jeder
|
||||||
|
> hat **sein eigenes Suchprofil und seinen eigenen API-Key**. Region, Modus und
|
||||||
|
> Ausschlüsse stehen **nicht mehr in diesem Skill**, sondern kommen von außen:
|
||||||
|
>
|
||||||
|
> 1. **Aus dem Prompt**, wenn er sie nennt (so ruft der Jobsuche-Runner den Skill auf —
|
||||||
|
> er baut den Prompt aus dem Suchprofil des Benutzers). Der Prompt hat immer Vorrang.
|
||||||
|
> 2. **Sonst aus dem Tracker:** `GET /templates` (typ `Lebenslauf`) liefert Rolle und
|
||||||
|
> Technologien des Benutzers; nach Orten/Modus fragen, wenn der Prompt nichts sagt.
|
||||||
|
>
|
||||||
|
> Die Beispiele weiter unten (Rollen, Buzzwords, Städte) sind **nur Muster** — niemals
|
||||||
|
> ungefragt die Städte oder das Profil eines anderen Benutzers verwenden. Der API-Key in
|
||||||
|
> `BEWERBUNG_API_KEY` bestimmt, in wessen Konto importiert wird.
|
||||||
|
|
||||||
|
> **Blacklist-Prinzip (zentral):** Jeder erfolgreich importierte Job wird
|
||||||
|
> **unmittelbar danach geblacklistet**. So taucht dieselbe Stelle in späteren
|
||||||
|
> Suchläufen nie wieder auf — die Dublett-Vermeidung bleibt bestehen, selbst wenn
|
||||||
|
> das JobOffer später gelöscht oder in eine Bewerbung überführt wird. Umgekehrt
|
||||||
|
> werden Kandidaten, die schon auf der Blacklist stehen, gar nicht erst gelistet
|
||||||
|
> oder importiert.
|
||||||
|
|
||||||
|
## Suchprofil
|
||||||
|
|
||||||
|
**Quelle des Profils (in dieser Reihenfolge):**
|
||||||
|
|
||||||
|
1. **Der Prompt** — der Jobsuche-Runner hängt den Lebenslauf/das Kurzprofil des Benutzers
|
||||||
|
direkt an („--- PROFIL --- … --- ENDE PROFIL ---"). Rollen und Buzzwords **daraus**
|
||||||
|
ableiten.
|
||||||
|
2. **Der Tracker** — sonst `GET /templates` (typ `Lebenslauf`, sonst `Profil/Kurzprofil`)
|
||||||
|
des Benutzers, dem der `BEWERBUNG_API_KEY` gehört.
|
||||||
|
3. Erst wenn beides fehlt: aus den bereits erfassten Bewerbungen (`GET /applications`)
|
||||||
|
ableiten oder nachfragen.
|
||||||
|
|
||||||
|
**Rolle:** breit fassen — dieselbe Tätigkeit läuft je nach Firma unter vielen Titeln.
|
||||||
|
Passende Bezeichnungen als Query-Varianten und für die Relevanzbewertung nutzen.
|
||||||
|
|
||||||
|
> Die folgende Liste ist ein **Beispiel für ein IT-Infrastruktur-Profil** — sie zeigt, wie
|
||||||
|
> breit zu fassen ist, ist aber **kein Default**. Für einen Benutzer mit anderem Beruf die
|
||||||
|
> Titel entsprechend aus **seinem** Lebenslauf bilden.
|
||||||
|
|
||||||
|
- **Kern (Beispiel: 2nd-Level-Sysadmin + IT-Consultant):**
|
||||||
|
Systemadministrator, IT-Administrator, IT-Systemadministrator, IT-Systemadministration,
|
||||||
|
IT-Systemadministrator 2nd Level, 2nd-Level-Administrator, System-Administrator (m/w/d),
|
||||||
|
Fachinformatiker Systemintegration, Administrator (m/w/d).
|
||||||
|
- **Support/Betrieb:** 1st/2nd/3rd-Level-Support, 2nd-Level-Support, IT-Support,
|
||||||
|
IT-Supporter, Support-Techniker, Helpdesk-Mitarbeiter, IT-Techniker,
|
||||||
|
IT-Systemtechniker, Systemtechniker, IT-Systemelektroniker, Servicetechniker IT,
|
||||||
|
Onsite-Techniker, Field-Service-Techniker, IT-Systembetreuer, IT-Betreuer,
|
||||||
|
IT-Allrounder, IT-Koordinator, IT-Mitarbeiter, IT-Operations / IT-Betrieb,
|
||||||
|
IT-Administrator EDV, EDV-Administrator, EDV-Betreuer.
|
||||||
|
- **Infrastruktur/Netz:** System Engineer, Systems Engineer, Infrastructure Engineer,
|
||||||
|
IT-Infrastruktur, Infrastruktur-Administrator, Netzwerkadministrator, Network Engineer,
|
||||||
|
Virtualisierungsadministrator, Storage-/Backup-Administrator, Rechenzentrums-Techniker,
|
||||||
|
Datacenter-Techniker.
|
||||||
|
- **Cloud/Hosting/Modern Workplace (passt zu Hetzner/OVH im CV):** Cloud-Administrator,
|
||||||
|
Cloud Engineer, Cloud-Operations-Engineer, Hosting-Administrator, Hosting Engineer,
|
||||||
|
Microsoft-365-Administrator, M365-/Modern-Workplace-Administrator,
|
||||||
|
Azure-/Entra-Administrator, DevOps-Engineer (Infrastruktur-lastig),
|
||||||
|
Linux-Administrator, Linux System Engineer, Windows-Administrator.
|
||||||
|
- **Beratung/Leitung (klein) — deckt die CV-Rolle „IT-Consultant" ab:** IT-Consultant,
|
||||||
|
IT-Berater (technisch, hands-on), IT-Systemberater, IT-Kundenberater (technisch),
|
||||||
|
IT-Teamleiter/IT-Leiter in kleinen Firmen (nur wenn hands-on).
|
||||||
|
|
||||||
|
Ausschließen bleiben reine Softwareentwickler-, Data-Science-, Vertriebs- und
|
||||||
|
SAP-only-Stellen, außer sie passen klar zum Infrastruktur-Profil.
|
||||||
|
|
||||||
|
> **Ex-Arbeitgeber IMMER ausschließen (harte Regel, unabhängig von der Schreibweise):**
|
||||||
|
> Stellen der **„IT-Problemlöser"** (Essen) — voller Firmenname u. a.
|
||||||
|
> **„IT Problemlöser Verwaltungs- und Handels GmbH"**, auch **„IT-Problemlöser GmbH"**,
|
||||||
|
> **„IT Problemlöser"** o. Ä. — werden **nie** gelistet oder importiert. Es ist der
|
||||||
|
> frühere Arbeitgeber des Nutzers (siehe Lebenslauf). Diese Firma taucht immer wieder
|
||||||
|
> mit **wechselnden Stellentiteln** auf; die `firma_stelle`-Blacklist greift dann nicht,
|
||||||
|
> weil der Titel abweicht. Deshalb **jeden Treffer verwerfen, dessen Firmenname `IT
|
||||||
|
> Problemlöser` / `IT-Problemlöser` (in jeder Rechtsform-/Schreibvariante) enthält** —
|
||||||
|
> ohne auf die Blacklist zu warten. (Firmenweite `firma`-Blacklist-Einträge greifen
|
||||||
|
> inzwischen schreibweisen-robust über `firma_slug` inkl. Präfix-Match — diese
|
||||||
|
> Modell-Regel bleibt trotzdem als zusätzliche Sicherung bestehen.)
|
||||||
|
|
||||||
|
**Region: kommt aus dem Suchprofil des Benutzers, nicht aus diesem Skill.**
|
||||||
|
|
||||||
|
Der Prompt nennt entweder eine **Städteliste** (regionaler Modus) oder **100 % Remote /
|
||||||
|
deutschlandweit** — oder beides. Es gilt strikt:
|
||||||
|
|
||||||
|
- **Regionaler Modus:** **Nur** Stellen mit Arbeitsort in einer der **genannten** Städte.
|
||||||
|
Die **erste genannte Stadt ist der Wohnort** und wird am höchsten priorisiert, danach
|
||||||
|
in der angegebenen Reihenfolge. Orte **außerhalb** dieser Liste werden **verworfen** —
|
||||||
|
auch das direkte Umland, und **auch reine Homeoffice-/Remote-Stellen ohne Sitz in einer
|
||||||
|
der genannten Städte**. Sitzt der Arbeitgeber in einer der Städte und bietet zusätzlich
|
||||||
|
Homeoffice, ist die Stelle zulässig.
|
||||||
|
- **Remote-Modus:** Nur Stellen, die **zu 100 % im Homeoffice** ausübbar sind
|
||||||
|
(deutschlandweit, Firmensitz egal). Präsenz, Hybrid mit festen Bürotagen und vages
|
||||||
|
„Homeoffice möglich" werden verworfen; die 100-%-Zusage live im Anzeigentext bestätigen.
|
||||||
|
|
||||||
|
Nennt der Prompt keine Region, **nachfragen** statt zu raten — niemals auf eine im Skill
|
||||||
|
hinterlegte Städteliste zurückfallen (die gibt es bewusst nicht mehr).
|
||||||
|
|
||||||
|
**Skills/Buzzwords** (für Query-Varianten & Relevanzbewertung): Die **Kernbegriffe kommen
|
||||||
|
aus dem Lebenslauf des jeweiligen Benutzers** (höchste Gewichtung); ergänze naheliegende,
|
||||||
|
dazu passende Technologien — als Suchbegriffe und zum Erkennen passender Anzeigen, auch
|
||||||
|
wenn sie nicht wörtlich im CV stehen.
|
||||||
|
|
||||||
|
> Auch die folgende Liste ist ein **Beispiel** (IT-Infrastruktur). Für andere Profile die
|
||||||
|
> Kernbegriffe aus **deren** Lebenslauf ziehen, nicht von hier.
|
||||||
|
|
||||||
|
- **Kern (Beispiel, aus einem Infrastruktur-CV):** Windows Server, Active Directory,
|
||||||
|
Exchange, Microsoft 365 / MS365, Linux-Administration (Debian/Ubuntu),
|
||||||
|
Docker/Container, Proxmox/Virtualisierung, Netzwerk (OPNsense, pfSense,
|
||||||
|
VPN: WireGuard/OpenVPN/IPsec, LAN/WAN, TCP/IP), Firewall, Monitoring
|
||||||
|
(Prometheus, Grafana, Beszel, Uptime Kuma), Hetzner/OVH Cloud, Self-Hosting,
|
||||||
|
Automatisierung, AI/Claude/Agent-Workflows.
|
||||||
|
- **Microsoft-/Windows-Umfeld:** Entra ID / Azure AD, Intune, Endpoint Manager,
|
||||||
|
Group Policy / GPO, WSUS, PowerShell, Hyper-V, SharePoint, Teams, Windows 10/11,
|
||||||
|
Client-Management, MDM.
|
||||||
|
- **Virtualisierung/Server:** VMware vSphere / ESXi, Hyper-V, Citrix, KVM,
|
||||||
|
Terminalserver / RDS, Server-Hardware (Dell/HPE/Lenovo).
|
||||||
|
- **Backup/Storage:** Veeam, Backup & Recovery, Datensicherung, NAS/SAN, TrueNAS,
|
||||||
|
ZFS, Ceph, Synology, QNAP.
|
||||||
|
- **Netzwerk/Security:** VLAN, Routing & Switching, Cisco, Fortinet/FortiGate,
|
||||||
|
Sophos, Ubiquiti/UniFi, DNS/DHCP, Reverse Proxy (Nginx, Traefik), Let's Encrypt,
|
||||||
|
IT-Security, Patch-Management, ISO 27001, IT-Grundschutz.
|
||||||
|
- **Cloud/DevOps/Automatisierung:** Microsoft Azure, AWS, Kubernetes, Ansible,
|
||||||
|
Terraform, GitLab / CI/CD, Bash-/Shell-Scripting, Portainer, Nextcloud.
|
||||||
|
- **Datenbanken/Web:** MySQL/MariaDB, PostgreSQL, MS SQL Server, Apache, Nginx, IIS.
|
||||||
|
- **Betrieb/Organisation:** Ticketsystem (Jira, OTRS, Zammad), Helpdesk,
|
||||||
|
Rechenzentrum, On-Premise, Managed Services, IT-Dienstleister, Systemhaus.
|
||||||
|
|
||||||
|
> Aktuelles Profil (Titel, Skills, Ort) steht in der Lebenslauf-Vorlage der API:
|
||||||
|
> `bewerbungs-tracker` → `GET /templates` (typ `Lebenslauf`). Bei Bedarf dort
|
||||||
|
> gegenlesen, statt zu raten.
|
||||||
|
|
||||||
|
## Quellen-Politik (WICHTIG)
|
||||||
|
|
||||||
|
Ziel ist **immer die Original-Stellenausschreibung direkt beim echten Arbeitgeber**
|
||||||
|
— bevorzugt auf dessen **Firmen-/Karriereseite**.
|
||||||
|
|
||||||
|
- **Ausschließen (nicht listen, nicht importieren):**
|
||||||
|
- **Headhunter / Personalvermittler / Personalberatungen** (recruiten für Dritte).
|
||||||
|
- **Zeitarbeit / Arbeitnehmerüberlassung / Personaldienstleister** (z. B. Randstad,
|
||||||
|
Hays, GULP, Amadeus FiRe, Adecco, Manpower, Piening, Tempton, DIS AG, Robert Half
|
||||||
|
u. ä. — auch unbekannte, wenn die Anzeige „Arbeitnehmerüberlassung",
|
||||||
|
„im Kundenauftrag", „für unseren Kunden", „Personaldienstleister" o. Ä. nennt).
|
||||||
|
- **Stepstone** als Quelle — nicht als Board nutzen, keine `site:stepstone.de`-Query,
|
||||||
|
keine Stepstone-Links importieren.
|
||||||
|
- **Erkennungsmerkmale eines Vermittlers/Zeitarbeit** (bei Unsicherheit die
|
||||||
|
Firmen-/Impressumsseite per `WebFetch` prüfen): Formulierungen wie „für unseren
|
||||||
|
Kunden", „im Auftrag unseres Mandanten", „Arbeitnehmerüberlassung", „Direktvermittlung",
|
||||||
|
„Personaldienstleistung", „Recruiting-Partner"; oder die inserierende Firma ist erkennbar
|
||||||
|
eine Personal-/Recruiting-Agentur. → **verwerfen**.
|
||||||
|
- **Zulässig als Quelle**, wenn es zur Original-Anzeige des Arbeitgebers führt:
|
||||||
|
Firmen-Karriereseite (bevorzugt), Arbeitsagentur/Bundesagentur für Arbeit, Indeed
|
||||||
|
oder LinkedIn **nur** wenn die Anzeige eindeutig vom echten Arbeitgeber (nicht von
|
||||||
|
einem Vermittler) stammt. Führt ein Board-Treffer zu einer Firma, deren eigene
|
||||||
|
Karriereseite dieselbe Stelle direkt listet, **immer den Firmen-Direktlink** nehmen.
|
||||||
|
|
||||||
|
## Workflow
|
||||||
|
|
||||||
|
1. **Bereits erfasste Jobs + Blacklist laden** (Dedup-Basis). Dieser Schritt ist
|
||||||
|
**immer zuerst** auszuführen, damit Stellen aus früheren Suchläufen **nicht
|
||||||
|
erneut** gefunden/importiert werden. Zwei Quellen:
|
||||||
|
```bash
|
||||||
|
scripts/applied-set.sh # firma_slug<TAB>firma<TAB>stelle<TAB>ort<TAB>quelle_url<TAB>external_id
|
||||||
|
scripts/blacklist-set.sh # id<TAB>typ<TAB>firma<TAB>stelle<TAB>ort<TAB>domain<TAB>url_norm<TAB>firma_norm<TAB>firma_slug<TAB>stelle_norm<TAB>ort_norm<TAB>grund
|
||||||
|
```
|
||||||
|
- **applied-set** (Bewerbungen **und** importierte Jobangebote): **eine Zeile
|
||||||
|
pro bereits erfasster Firma** (company-level, nicht mehr pro Stelle). Je Zeile
|
||||||
|
drei Dublett-Merkmale merken: **`firma_slug`** (robuster Firmen-Schlüssel:
|
||||||
|
lowercase, Umlaute→ae/oe/ue/ss, `(m/w/d)` raus, End-Rechtsform wie gmbh/ag/kg
|
||||||
|
entfernt, mit `-` verbunden), **`quelle_url`** und **`external_id`**.
|
||||||
|
- **blacklist-set** (gesperrte Muster): je Zeile den **`typ`** und die dazu
|
||||||
|
passenden Felder merken (`url_norm`, `domain`, `firma_norm`, **`firma_slug`**,
|
||||||
|
`stelle_norm`). Die `*_norm`/`firma_slug`-Felder liefert der Server bereits
|
||||||
|
normalisiert (lowercase, Tracking-Parameter entfernt, `(m/w/d)`/Rechtsform
|
||||||
|
bereinigt).
|
||||||
|
|
||||||
|
2. **Kandidaten sammeln — strukturierte Quelle zuerst, dann WebSearch.**
|
||||||
|
|
||||||
|
**(a) Arbeitsagentur-API (primär — frisch, hohe Trefferzahl, strukturiert).** Vor
|
||||||
|
der Freitext-Suche die Jobsuche-API der Bundesagentur für Arbeit abfragen. Sie
|
||||||
|
liefert frische, deduplizierbare Treffer mit Firma, Ort, Datum und stabiler
|
||||||
|
`refnr` — genau das, was reine WebSearch nicht zuverlässig hergibt. Pro Rolle einen
|
||||||
|
Umkreis-Query:
|
||||||
|
```bash
|
||||||
|
scripts/arbeitsagentur.sh search "<Rolle>" "<Stadt>" 15 30 50
|
||||||
|
# -> firma_slug<TAB>firma<TAB>titel<TAB>ort<TAB>datum<TAB>refnr<TAB>angebotsart
|
||||||
|
```
|
||||||
|
Rollen aus dem Suchprofil durchrotieren (Systemadministrator, IT-Administrator,
|
||||||
|
Fachinformatiker Systemintegration, IT-Support, IT-Techniker, System Engineer,
|
||||||
|
Netzwerkadministrator, …). Städte: **Gladbeck** deckt per Umkreis 15–20 km
|
||||||
|
Bottrop/Gelsenkirchen/Essen/Oberhausen weitgehend mit ab; **Dorsten** und **Marl**
|
||||||
|
je separat (`wo=Dorsten`, `wo=Marl`, Umkreis ~10). `tage` (Default 30) steuert die
|
||||||
|
Frische. Achtung: der Umkreis zieht auch **Nachbarorte außerhalb der sieben
|
||||||
|
Städte** rein (z. B. Haltern, Recklinghausen, Herten) — die **harte Regionsregel**
|
||||||
|
(nur die sieben Städte, Schritt 3) über die `ort`-Spalte anwenden und Fremdorte
|
||||||
|
**verwerfen**. Jede verbleibende Zeile **sofort per `firma_slug`** gegen
|
||||||
|
applied-set/blacklist prüfen (Firmen-Dedup, Schritt 1) — bereits erfasste/gesperrte
|
||||||
|
Firmen raus. Für
|
||||||
|
**überlebende** Treffer die Details holen:
|
||||||
|
```bash
|
||||||
|
scripts/arbeitsagentur.sh detail <refnr>
|
||||||
|
```
|
||||||
|
Detail liefert **Volltext** (→ `beschreibung`), **`EXTERNE_URL`** (Original-Link des
|
||||||
|
Arbeitgebers → bevorzugt `quelle_url`), **Adresse**, **Vergütung** und die Flags
|
||||||
|
**`ZEITARBEIT_AUE` / `PRIVATE_ARBEITSVERMITTLUNG`** — steht dort `True`, ist es
|
||||||
|
Zeitarbeit/Vermittler → **verwerfen** (ohne WebFetch, spart einen Schritt). Meldet
|
||||||
|
das Detail „keine aktive Anzeige", ist sie abgelaufen → **verwerfen**. Die `refnr`
|
||||||
|
ist stabil → als Existenz-Nachweis und für die `external_id` nutzbar. Führt
|
||||||
|
`EXTERNE_URL` nur auf ein Board (nicht den Arbeitgeber), die Firmen-Karriereseite
|
||||||
|
suchen und deren Direktlink bevorzugen (Quellen-Politik gilt unverändert).
|
||||||
|
|
||||||
|
**(b) WebSearch (ergänzend — für alles, was nicht in der API steht).** Mit dem
|
||||||
|
`WebSearch`-Tool **viele** Queries fahren — Rolle × Ort für **jede der sieben
|
||||||
|
Städte**, mit Fokus auf **Original-Anzeigen der Arbeitgeber**. Nur diese sieben
|
||||||
|
Orte abfragen, keine weiteren Revierstädte.
|
||||||
|
|
||||||
|
> **Query-Strategie (Recall maximieren — WICHTIG, findet die Suche „kaum noch
|
||||||
|
> Stellen"):** Lieber **viele einfache, natürlichsprachliche Einzel-Queries** als
|
||||||
|
> wenige überladene. **Boolesche Operatoren sparsam einsetzen** — `OR`, `site:`,
|
||||||
|
> `inurl:` und `-minus`-Ausschlüsse **senken bei WebSearch oft drastisch die
|
||||||
|
> Trefferzahl**. Statt eine `A OR B OR C … -stepstone -zeitarbeit`-Monsterquery
|
||||||
|
> lieber **getrennte, kurze Queries** absetzen und Vermittler/Stepstone erst
|
||||||
|
> **nachträglich beim Filtern** (Schritt 3) rauswerfen. Pro Stadt **mehrere
|
||||||
|
> Formulierungen** durchvariieren:
|
||||||
|
> **Rolle × Ort × Suffix**, mit den Suffixen `Stellenangebot`, `Job`, `Jobs`,
|
||||||
|
> `Stelle`, `Vollzeit`, `Festanstellung`, `unbefristet`, `gesucht`, `m/w/d`, `2026`
|
||||||
|
> und **ganz ohne Suffix** (nur `Rolle Ort`). Zusätzlich **Buzzword × Ort**
|
||||||
|
> (s. u.). So deckt jede Stadt 10–15 verschiedene Queries ab statt 1–2.
|
||||||
|
|
||||||
|
**Rolle × Ort (je Stadt mehrere Suffixe durchspielen), Beispiele:**
|
||||||
|
- `IT-Systemadministrator Gladbeck Stellenangebot`, `Systemadministrator Gladbeck`,
|
||||||
|
`IT-Administrator Gladbeck Vollzeit`, `IT Systemadministrator Gladbeck m/w/d`
|
||||||
|
- `Systemadministrator Essen Jobs`, `2nd Level Support Essen`,
|
||||||
|
`IT-Administrator Essen Festanstellung`, `IT Systemadministrator Essen gesucht`
|
||||||
|
- `Fachinformatiker Systemintegration Oberhausen`, `IT-Administrator Oberhausen Stelle`,
|
||||||
|
`Systemadministrator Oberhausen unbefristet`
|
||||||
|
- `IT Administrator Gelsenkirchen Stellenangebot`, `Systemadministrator Gelsenkirchen`,
|
||||||
|
`IT-Systemadministrator Gelsenkirchen 2026`, `IT-Support Gelsenkirchen`
|
||||||
|
- `Systemadministrator Bottrop Jobs`, `IT-Administrator Bottrop`,
|
||||||
|
`IT Systemadministrator Bottrop Vollzeit`, `EDV-Administrator Bottrop`
|
||||||
|
- `IT-Administrator Dorsten Stellenangebot`, `Systemadministrator Dorsten`,
|
||||||
|
`IT-Techniker Dorsten Job`
|
||||||
|
- `Systemadministrator Marl Stellenangebot`, `IT-Administrator Marl`,
|
||||||
|
`IT Systemadministrator Marl Vollzeit`, `IT-Techniker Marl Job`
|
||||||
|
- Weitere Titel je Stadt durchrotieren: `System Engineer <Ort>`,
|
||||||
|
`IT-Systembetreuer <Ort>`, `Cloud Administrator <Ort>`, `Linux Administrator <Ort>`,
|
||||||
|
`Netzwerkadministrator <Ort>`, `IT-Consultant <Ort>`, `IT-Support <Ort>`.
|
||||||
|
- **Buzzword × Ort** (holt Anzeigen, die im Titel anders heißen, inhaltlich aber
|
||||||
|
passen — s. Skills/Buzzwords oben): `Active Directory <Ort> Stellenangebot`,
|
||||||
|
`Microsoft 365 Administrator <Ort>`, `VMware <Ort> IT Job`, `Proxmox <Ort>`,
|
||||||
|
`Hyper-V Administrator <Ort>`, `Veeam <Ort> IT`, `PowerShell Administrator <Ort>`.
|
||||||
|
- **Deutsche Jobbörsen zusätzlich anzapfen** (breitere Abdeckung, jeweils als
|
||||||
|
eigene, einfache Query — nicht stapeln): `<Rolle> <Ort> meinestadt`,
|
||||||
|
`<Rolle> <Ort> kimeta`, `<Rolle> <Ort> yourfirm`, `<Rolle> <Ort> arbeitsagentur`,
|
||||||
|
`<Rolle> <Ort> indeed`. Falls doch ein `site:`-Filter, dann **einzeln**:
|
||||||
|
`site:arbeitsagentur.de Systemadministrator <Ort>`, `site:de.indeed.com
|
||||||
|
IT-Administrator <Ort>`, `site:de.linkedin.com/jobs Systemadministrator <Ort>`.
|
||||||
|
**Kein** `site:stepstone.de`. Von Board-Treffern stets zur Original-Anzeige des
|
||||||
|
Arbeitgebers durchklicken.
|
||||||
|
- **Firmen-Karriereseiten gezielt** (bevorzugt): `IT-Administrator Essen Karriere`,
|
||||||
|
`Systemadministrator Gelsenkirchen Karriere Stellenangebot`,
|
||||||
|
`IT Jobs <Ort> Karriere` — Vermittler/Stepstone später beim Filtern aussortieren,
|
||||||
|
nicht per `-minus` in der Query.
|
||||||
|
|
||||||
|
Für Details/Ort/Firma einer Trefferseite `WebFetch` nutzen. Sieht ein Treffer nach
|
||||||
|
Firmen-Karriereseite aus: dort direkt nach der Einzelanzeige suchen.
|
||||||
|
|
||||||
|
2b. **Firmen zuerst finden, dann deren Karriereseite prüfen** (Hauptquelle für
|
||||||
|
„unentdeckte" Stellen). Board-Suchen finden nur, was breit ausgeschrieben ist —
|
||||||
|
viele Arbeitgeber posten IT-Stellen **ausschließlich auf der eigenen Website**.
|
||||||
|
Deshalb parallel zur Rolle-×-Ort-Suche **lokale Arbeitgeber in den sieben Städten
|
||||||
|
identifizieren** und deren Karriereseite direkt öffnen:
|
||||||
|
- **Arbeitgeber recherchieren** je Stadt — Firmen, die typischerweise interne IT
|
||||||
|
oder IT-Dienstleistung haben: Systemhäuser/IT-Dienstleister, Industrie &
|
||||||
|
Mittelstand, Chemie/Logistik (Essen, Oberhausen, Gelsenkirchen), Stadtwerke &
|
||||||
|
kommunale Betriebe, Krankenhäuser/Kliniken, Hochschulen, Versicherungen/Banken,
|
||||||
|
größere Handels-/Handwerksbetriebe. Beispiel-Queries:
|
||||||
|
`größte Arbeitgeber Essen IT`, `Systemhaus Gelsenkirchen`,
|
||||||
|
`IT-Dienstleister Oberhausen`, `Unternehmen Gladbeck IT-Abteilung`,
|
||||||
|
`Stadtwerke Bottrop Karriere`, `Klinikum Dorsten Stellenangebote IT`,
|
||||||
|
`Chemiepark Marl IT`, `Stadtwerke Marl Karriere`.
|
||||||
|
- **Karriereseite direkt anfahren:** zu jeder gefundenen Firma
|
||||||
|
`WebSearch "<Firma> Karriere"` bzw. `"<Firma> Stellenangebote"` und die
|
||||||
|
Jobliste per `WebFetch` öffnen; auf offene **IT-/Administrator-/Support-Stellen**
|
||||||
|
am jeweiligen Standort prüfen. Auch `inurl:karriere`, `inurl:jobs`,
|
||||||
|
`inurl:stellen` gezielt einsetzen und gängige Bewerber-Portale erkennen
|
||||||
|
(z. B. `/karriere`, `jobs.<firma>.de`, softgarden/onlyfy/Personio/Workday-Seiten).
|
||||||
|
- **Buzzwords als Türöffner:** findet die reine Titel-Suche wenig, mit den
|
||||||
|
Technologie-Buzzwords (oben) über die Stadt suchen, z. B.
|
||||||
|
`VMware Essen Stellenangebot`, `Active Directory Gelsenkirchen Job`,
|
||||||
|
`Microsoft 365 Administrator Oberhausen`, `Linux Bottrop IT` — so tauchen
|
||||||
|
Anzeigen auf, die im Titel anders heißen, inhaltlich aber passen.
|
||||||
|
- Jede so gefundene Stelle läuft durch dieselben Prüf-/Filter-/Dedup-Schritte
|
||||||
|
(3–7). Der Firmen-Direktlink ist hier ohnehin schon die bevorzugte `quelle_url`.
|
||||||
|
|
||||||
|
3. **Filtern & bewerten:**
|
||||||
|
- **Region (harter Ausschluss):** **nur** die sieben Städte Gladbeck, Essen,
|
||||||
|
Gelsenkirchen, Oberhausen, Bottrop, Dorsten, Marl. Jede Stelle mit Arbeitsort außerhalb
|
||||||
|
dieser sieben Städte **verwerfen** — auch übrige Revierstädte und reines
|
||||||
|
Homeoffice/Remote ohne Sitz in einer der sieben Städte. **Nach Nähe zu Gladbeck
|
||||||
|
priorisieren** (Gladbeck > Bottrop/Gelsenkirchen > Essen/Oberhausen > Dorsten/Marl).
|
||||||
|
- **Rolle:** Systemadministration/IT-Infrastruktur; keine reinen Entwickler-,
|
||||||
|
Vertriebs- oder SAP-only-Stellen (außer sie passen klar zum Profil).
|
||||||
|
- **Quelle (harter Ausschluss):** Headhunter/Personalvermittler, Zeitarbeit/
|
||||||
|
Arbeitnehmerüberlassung und Stepstone werden **verworfen** — siehe
|
||||||
|
„Quellen-Politik". Nur Original-Anzeigen echter Arbeitgeber behalten.
|
||||||
|
- **Dedup (persistent, FIRMEN-basiert):** **Eine Firma darf nur EINMAL gefunden
|
||||||
|
werden.** Treffer **verwerfen**, sobald **eines** zutrifft: (a) gleiche/sehr
|
||||||
|
ähnliche `quelle_url`, (b) gleiche `external_id`, oder (c) **die Firma steht
|
||||||
|
schon im Applied-Set** — unabhängig vom Stellentitel. Firmen-Gleichheit über
|
||||||
|
`firma_slug` prüfen: Kandidaten-Firma ebenso sluggen (lowercase, Umlaute→
|
||||||
|
ae/oe/ue/ss, `(m/w/d)` & End-Rechtsform raus, mit `-` verbunden) und als
|
||||||
|
**dieselbe Firma** werten, wenn der Slug **gleich** ist ODER **ein Slug ein
|
||||||
|
führendes Bindestrich-Präfix des anderen** ist (mind. 2 Tokens) — so zählen
|
||||||
|
**verschiedene Schreibweisen/Rechtsform-/Namensvarianten derselben Firma als
|
||||||
|
eine** (z. B. „IT-Problemlöser GmbH" = „IT Problemlöser Verwaltungs- und Handels
|
||||||
|
GmbH"; „Stadtwerke Essen" = „Stadtwerke Essen Netz GmbH"). Zusätzlich gesunder
|
||||||
|
Menschenverstand: ist offensichtlich derselbe Arbeitgeber gemeint, als Dublette
|
||||||
|
verwerfen. Distinkte Firmen mit nur gleichem ersten Wort (z. B. „Meyer IT" vs.
|
||||||
|
„Meyer Logistik") sind **nicht** dieselbe Firma. So taucht kein Arbeitgeber aus
|
||||||
|
früheren Läufen erneut auf. Im Zweifel behalten und als „evtl. Dublette" markieren.
|
||||||
|
- **Blacklist (harter Ausschluss):** Kandidat **verwerfen** (nicht listen, nicht
|
||||||
|
importieren), sobald er auf einen Blacklist-Eintrag passt — je nach `typ`:
|
||||||
|
- `url` → normalisierte Kandidaten-URL == `url_norm`.
|
||||||
|
- `domain` → Host der Kandidaten-URL == `domain` (auch Subdomains).
|
||||||
|
- `firma` → **dieselbe Firma** wie der Eintrag (Kandidaten-`firma_slug` gleich
|
||||||
|
`firma_slug` **oder** Präfix-Match wie bei der Dedup; `firma_norm` nur als
|
||||||
|
Fallback für Alt-Einträge ohne Slug).
|
||||||
|
- `firma_stelle` → dieselbe Firma (`firma_slug`, wie oben) **und** `stelle_norm`
|
||||||
|
passen (ist `ort_norm` gesetzt, muss auch der Ort passen).
|
||||||
|
- `auto` → wie die konkreten Felder, die der Eintrag trägt.
|
||||||
|
Diese Muster sind bewusst blockiert (u. a. jeder früher importierte Job, siehe
|
||||||
|
Schritt 6) — geblacklistete Stellen daher **stillschweigend überspringen**,
|
||||||
|
nicht als „evtl. Dublette“ präsentieren.
|
||||||
|
- **Relevanz:** höher gewichten, je mehr Buzzwords aus dem Profil passen.
|
||||||
|
|
||||||
|
4. **Existenz prüfen, Link prüfen, Kontakt-E-Mail & Firmenadresse recherchieren** —
|
||||||
|
für jeden Treffer, der in die engere Wahl kommt, **zwingend einzeln**:
|
||||||
|
- **Existenz-Prüfung (Pflicht):** Jede Stelle mit `WebFetch` auf dem Einzel-Link
|
||||||
|
öffnen und bestätigen, dass die Anzeige **noch aktiv** ist (Firma + Stelle
|
||||||
|
stehen dort, kein 404/„Stelle nicht mehr verfügbar"/Redirect auf Jobliste).
|
||||||
|
Snippets aus der WebSearch reichen **nicht** — Suchindizes zeigen oft schon
|
||||||
|
abgelaufene Anzeigen. Kein Live-Nachweis → Treffer **verwerfen** (nicht listen,
|
||||||
|
nicht importieren). Bei bereits importierten Angeboten, die nicht mehr existieren,
|
||||||
|
das JobOffer löschen (`DELETE /joboffers/{id}`).
|
||||||
|
- **Vollständige Stellenausschreibung erfassen (Pflicht):** Beim `WebFetch` der
|
||||||
|
Einzelanzeige den **kompletten Ausschreibungstext** übernehmen — nicht nur eine
|
||||||
|
Kurzzusammenfassung. Dazu gehören: Einleitung/Unternehmensvorstellung, **Aufgaben/
|
||||||
|
Tätigkeiten**, **Anforderungen/Profil**, **Wir bieten/Benefits**, Angaben zu
|
||||||
|
Arbeitszeit/Vertragsart/Standort, Gehalt (falls genannt), Bewerbungsweg/Kontakt
|
||||||
|
und Referenz-/Kennziffer. Text weitgehend **wortgetreu und vollständig** sichern
|
||||||
|
(nur Navigations-/Cookie-/Footer-Boilerplate der Seite weglassen), inkl. der
|
||||||
|
Gliederung/Überschriften. Dieser Volltext wandert in `beschreibung` (Schritt 6).
|
||||||
|
Ist die Seite lang/abgeschnitten, `WebFetch` gezielt erneut aufrufen, um alle
|
||||||
|
Abschnitte zu bekommen.
|
||||||
|
- **`quelle_url` verifizieren:** Muss auf die **konkrete, noch aktive
|
||||||
|
Einzelanzeige** zeigen (nicht Trefferliste/Suchseite). Deeplink defekt/Liste →
|
||||||
|
per Suche den echten Einzel-Link finden (bevorzugt Firmenwebsite/Karriereseite),
|
||||||
|
sonst verwerfen.
|
||||||
|
- **Vermittler/Zeitarbeit aussortieren (Pflicht):** Zeigt der Treffer auf eine
|
||||||
|
Personalberatung/Headhunter/Zeitarbeit statt den echten Arbeitgeber, **verwerfen**
|
||||||
|
(nicht listen, nicht importieren). Bei Unsicherheit die inserierende Firma per
|
||||||
|
`WebFetch` (Impressum/„Über uns") prüfen — Recruiting-/Personaldienstleister raus.
|
||||||
|
Existiert dieselbe Stelle direkt auf der Firmen-Karriereseite, diese
|
||||||
|
Original-Anzeige verwenden statt des Vermittler-Treffers.
|
||||||
|
- **Bewerber-Kontakt-E-Mail recherchieren:** Die E-Mail-Adresse ermitteln, an die
|
||||||
|
sich Bewerber wenden. Reihenfolge: (a) direkt in der Stellenanzeige genannte
|
||||||
|
Bewerbungs-/Kontaktadresse; (b) Karriere-/Kontaktseite der Firma
|
||||||
|
(`WebSearch` „<Firma> Karriere Kontakt Bewerbung E-Mail“, `WebFetch` der Seite);
|
||||||
|
(c) allgemeine Bewerbungsadresse (`bewerbung@`/`jobs@`/`karriere@<domain>`) nur,
|
||||||
|
wenn auf der Firmenseite belegt. **Nicht raten** — nur Adressen übernehmen, die
|
||||||
|
belegt sind; sonst `kontakt_email` leer lassen und als „nicht gefunden“ markieren.
|
||||||
|
- **Firmenadresse recherchieren (Pflicht):** Die **genaue Anschrift** des
|
||||||
|
Arbeitgebers ermitteln (Straße, Hausnummer, PLZ, Ort) — und zwar die, die
|
||||||
|
**exakt zu dieser Stelle passt**. Reihenfolge: (a) in der Stellenanzeige selbst
|
||||||
|
genannter Einsatz-/Arbeitsort (oft unter „Standort"/„Einsatzort"/am Anzeigenende);
|
||||||
|
(b) Impressum bzw. Kontakt-/Standortseite der Firma
|
||||||
|
(`WebSearch` „<Firma> Impressum Adresse <Ort>“, `WebFetch` der Seite).
|
||||||
|
⚠️ **Adresse muss zur Stelle passen:** Hat die Firma **mehrere Standorte/
|
||||||
|
Niederlassungen**, die Anschrift des **konkreten Arbeitsorts der Stelle** nehmen —
|
||||||
|
**nicht** blind die Zentrale/Hauptsitz-Adresse, wenn die Stelle an einem anderen
|
||||||
|
Standort ausgeschrieben ist. Nennt die Anzeige nur eine Stadt (`ort`), aber keine
|
||||||
|
Straße, gezielt die zu **dieser Stadt** gehörende Niederlassungsadresse suchen und
|
||||||
|
verifizieren. **Nicht raten** — nur eine belegte, zum Stellen-Ort passende Adresse
|
||||||
|
übernehmen; sonst `adresse` leer lassen und als „nicht gefunden“ markieren.
|
||||||
|
Passt die einzige auffindbare Firmenadresse **nicht** zum `ort` der Stelle
|
||||||
|
(anderer Standort), lieber leer lassen als eine falsche Zentrale eintragen.
|
||||||
|
|
||||||
|
5. **Ergebnis präsentieren** als Tabelle: Firma · Stelle · Ort · **Adresse** ·
|
||||||
|
Quelle (Board) · **verifizierter Link** · **Kontakt-E-Mail** · kurze
|
||||||
|
Passt-Begründung. Sortierung: neue, klar passende Treffer **nach Nähe zu Gladbeck**
|
||||||
|
(Gladbeck zuerst, dann Bottrop/Gelsenkirchen, dann Essen/Oberhausen, dann Dorsten/Marl).
|
||||||
|
Fehlt Link, E-Mail oder Adresse, kennzeichnen.
|
||||||
|
|
||||||
|
6. **Importieren** — im **interaktiven** Modus nur nach Rückfrage/Bestätigung des
|
||||||
|
Nutzers; im **autonomen Modus** (siehe unten) automatisch ohne Rückfrage. Jeden
|
||||||
|
neuen Treffer als JobOffer einspielen (Upsert, idempotent) über den
|
||||||
|
`bewerbungs-tracker`-Skill:
|
||||||
|
```bash
|
||||||
|
~/.claude/skills/bewerbungs-tracker/scripts/bt.sh POST /joboffers '{
|
||||||
|
"external_id": "stellensuche-<slug(firma)>-<slug(stelle)>",
|
||||||
|
// IMMER setzen und DETERMINISTISCH aus
|
||||||
|
// firma+stelle bilden — NICHT aus der
|
||||||
|
// Board-/Anzeigen-ID. Slug = normalisiert
|
||||||
|
// wie der firma_slug (lowercase, ohne (m/w/d)
|
||||||
|
// & Rechtsform, Interpunktion→"-"), z. B.
|
||||||
|
// "stellensuche-musterfirma-it-systemadministrator".
|
||||||
|
// So bekommt DIESELBE Stelle über jeden
|
||||||
|
// Lauf UND jedes Board dasselbe external_id
|
||||||
|
// → Server-Upsert greift zuverlässig statt
|
||||||
|
// eine Dublette anzulegen. (Eine flüchtige
|
||||||
|
// Board-ID würde je Quelle abweichen und so
|
||||||
|
// Duplikate erzeugen — daher NICHT nutzen.)
|
||||||
|
"quelle": "stellensuche",
|
||||||
|
"firma": "…", "stelle": "…", "ort": "…",
|
||||||
|
"adresse": "<genaue Anschrift des Arbeitsorts der Stelle: Straße Hausnr, PLZ Ort;
|
||||||
|
recherchiert & verifiziert, passend zum Stellen-Standort; sonst weglassen>",
|
||||||
|
"gehalt": "…",
|
||||||
|
"beschreibung": "<VOLLSTÄNDIGE Stellenausschreibung>",
|
||||||
|
// Der komplette, in Schritt 4 per WebFetch
|
||||||
|
// erfasste Anzeigentext: Aufgaben, Anforderungen/
|
||||||
|
// Profil, Wir-bieten/Benefits, Arbeitszeit/
|
||||||
|
// Vertragsart, Standort, Gehalt, Bewerbungsweg,
|
||||||
|
// Referenz-/Kennziffer — wortgetreu und
|
||||||
|
// vollständig, nicht kürzen/zusammenfassen.
|
||||||
|
// Gliederung/Überschriften erhalten (Markdown
|
||||||
|
// ok). Nur Seiten-Boilerplate weglassen.
|
||||||
|
"quelle_url": "<verifizierter Einzel-Link, bevorzugt Firmen-Karriereseite>",
|
||||||
|
"art": "Firmenwebsite",
|
||||||
|
"anzeige_datum": "YYYY-MM-DD",
|
||||||
|
"kontakt_email": "<recherchierte Bewerber-E-Mail, sonst weglassen>",
|
||||||
|
"labels": ["Regional"] // IMMER setzen: dieser Skill sucht ortsgebunden in
|
||||||
|
// den sieben Städten → Arbeitsort-Label "Regional".
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
`art`-Enum: `E-Mail`, `Online-Portal`, `Indeed`, `StepStone`, `Firmenwebsite`,
|
||||||
|
`Post`, `Initiativbewerbung`, `Arbeitsagentur`, `Sonstiges`. **Standard ist
|
||||||
|
`Firmenwebsite`** (Original-Anzeige des Arbeitgebers). `StepStone` nie verwenden
|
||||||
|
(ausgeschlossene Quelle); `Arbeitsagentur`/`Indeed` nur, wenn der Link direkt zur
|
||||||
|
Arbeitgeber-Anzeige führt.
|
||||||
|
|
||||||
|
**`409`-Antwort beim Import:** Steht der Job (trotz Vorfilter) auf der Blacklist,
|
||||||
|
antwortet `POST /joboffers` mit **409** (`BlacklistConflict`) und nimmt ihn
|
||||||
|
**nicht** auf. Das ist **kein Fehler** — als „übersprungen (Blacklist)" zählen
|
||||||
|
und mit dem nächsten Treffer weitermachen, **nicht** mit `force` erzwingen.
|
||||||
|
|
||||||
|
7. **Direkt blacklisten (nach jedem erfolgreichen Import).** Sobald ein JobOffer
|
||||||
|
angelegt wurde (Antwort **201** / `action:created`; bei `updated` ist der Job
|
||||||
|
bereits erfasst), die **Firma sofort ganz** blacklisten, damit dieser Arbeitgeber
|
||||||
|
in künftigen Läufen **mit keinem Stellentitel** wieder auftaucht (Regel „eine Firma
|
||||||
|
nur einmal"). Per `typ: firma` sperren — der Server matcht firmenweit über
|
||||||
|
`firma_slug` und deckt so abweichende Schreibweisen/Rechtsformen mit ab:
|
||||||
|
```bash
|
||||||
|
~/.claude/skills/bewerbungs-tracker/scripts/bt.sh POST /joboffers/blacklist '{
|
||||||
|
"typ": "firma",
|
||||||
|
"firma": "<firma wie importiert>",
|
||||||
|
"grund": "auto: stellensuche-import <YYYY-MM-DD>"
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
Server-Antwort **201** = geblacklistet. Normalisierung (Groß/Klein, `(m/w/d)`,
|
||||||
|
Rechtsform, Umlaute) übernimmt der Server. Nur nach **echtem** Neuimport
|
||||||
|
blacklisten — nicht bei einem 409-Skip (steht ja schon drauf) und nicht bei
|
||||||
|
bloßem `updated`.
|
||||||
|
|
||||||
|
## Autonomer Modus (headless / geplant)
|
||||||
|
|
||||||
|
Wird der Skill **nicht-interaktiv** ausgeführt — d. h. ohne Nutzer, der bestätigen
|
||||||
|
kann (z. B. `claude -p "…" --dangerously-skip-permissions`, Cron/geplanter Lauf,
|
||||||
|
`ollama launch claude … -p …`) — im **autonomen Modus** arbeiten:
|
||||||
|
|
||||||
|
- **Ohne Rückfrage importieren:** Neue, geprüfte Treffer direkt als JobOffer
|
||||||
|
einspielen (Schritt 6) **und anschließend blacklisten** (Schritt 7). Es gibt
|
||||||
|
niemanden zum Bestätigen — nicht auf Eingabe warten.
|
||||||
|
- **Alle Prüfregeln gelten unverändert und strikt:** Existenz-Prüfung (Schritt 4),
|
||||||
|
korrekter verifizierter Einzel-Link, Kontakt-E-Mail nur wenn belegt, **Firmenadresse
|
||||||
|
nur wenn belegt und zum Stellen-Ort passend** (Schritt 4), die
|
||||||
|
persistente Dedup **und der Blacklist-Filter** (Schritt 1/3) — importiere **nur**
|
||||||
|
echte, live verifizierte, noch nicht erfasste und **nicht geblacklistete** Stellen.
|
||||||
|
Im Zweifel **nicht** importieren (lieber auslassen).
|
||||||
|
- **Keine Bewerbungen anlegen/versenden**, keine Löschungen bestehender Bewerbungen,
|
||||||
|
keine Generierung anstoßen — nur neue Jobangebote (`POST /joboffers`) einpflegen
|
||||||
|
und den jeweils importierten Job blacklisten (`POST /joboffers/blacklist`).
|
||||||
|
- **Kurzbericht ausgeben** (für das Log): je Treffer `action`
|
||||||
|
(created+blacklisted / updated / skip-dedup / skip-blacklist) mit Firma, Stelle,
|
||||||
|
Ort, Link; am Ende Zähler „X neu importiert & geblacklistet, Y als Dublette/
|
||||||
|
Blacklist übersprungen, Z verworfen (nicht verifizierbar)".
|
||||||
|
|
||||||
|
## Regeln
|
||||||
|
|
||||||
|
- **Nur sieben Städte.** Es werden **ausschließlich** Stellen mit Arbeitsort in
|
||||||
|
Gladbeck, Essen, Gelsenkirchen, Oberhausen, Bottrop, Dorsten oder Marl gelistet/importiert.
|
||||||
|
Alles außerhalb dieser sieben Städte (übriges Ruhrgebiet, reines Remote/Homeoffice ohne
|
||||||
|
Sitz in einer der sieben Städte) wird **verworfen**. Priorität nach Nähe zu Gladbeck.
|
||||||
|
- **Label `Regional` setzen (Pflicht).** Jedes von diesem Skill importierte JobOffer
|
||||||
|
bekommt `labels: ["Regional"]` (ortsgebundene Stelle in einer der sieben Städte).
|
||||||
|
Kein Remote-/Homeoffice-Label — solche Stellen sind Sache des Skills
|
||||||
|
`it-stellensuche-remote`.
|
||||||
|
- **Keine erfundenen Stellen.** Nur Jobs ausgeben, die per WebSearch/WebFetch real
|
||||||
|
belegt sind. **`quelle_url` muss der korrekte, geprüfte Direktlink zur
|
||||||
|
Einzelanzeige sein** (per `WebFetch` bestätigt) — keine Such-/Listenseiten, keine
|
||||||
|
geratenen URLs. Kein verifizierbarer Einzel-Link → Treffer nicht listen/importieren.
|
||||||
|
- **Nur Original-Anzeigen echter Arbeitgeber.** Headhunter/Personalvermittler,
|
||||||
|
Zeitarbeit/Arbeitnehmerüberlassung und **Stepstone** sind ausgeschlossen — weder
|
||||||
|
listen noch importieren (siehe „Quellen-Politik"). Bevorzugt der Firmen-Direktlink;
|
||||||
|
im Zweifel (Vermittler? Zeitarbeit?) die inserierende Firma prüfen und lieber
|
||||||
|
verwerfen.
|
||||||
|
- **`beschreibung` = vollständige Ausschreibung.** Immer den kompletten, per WebFetch
|
||||||
|
erfassten Anzeigentext (Aufgaben, Anforderungen, Benefits, Konditionen, Bewerbungsweg,
|
||||||
|
Kennziffer) übernehmen — nicht kürzen oder zusammenfassen. Inhalt aus der realen
|
||||||
|
Anzeige, nicht frei ergänzen.
|
||||||
|
- **Kontakt-E-Mail nicht erfinden.** `kontakt_email` nur setzen, wenn die Adresse in
|
||||||
|
der Anzeige oder auf der Firmen-Karriereseite belegt ist; sonst weglassen.
|
||||||
|
- **Firmenadresse recherchieren, nicht erfinden — und passend zur Stelle.** `adresse`
|
||||||
|
(Straße, Hausnummer, PLZ, Ort) im Web ermitteln und nur eine **belegte** Anschrift
|
||||||
|
eintragen, die **genau zum Arbeitsort der Stelle** gehört. Bei mehreren Standorten
|
||||||
|
die Niederlassung der ausgeschriebenen Stelle nehmen, **nicht** pauschal den
|
||||||
|
Hauptsitz. Passt keine verifizierte Adresse zum Stellen-Ort, `adresse` leer lassen
|
||||||
|
statt eine falsche Zentrale einzutragen.
|
||||||
|
- **Keine Doppelfunde — eine Firma nur EINMAL, auch über Läufe hinweg.** Vor dem
|
||||||
|
Listen/Importieren immer `applied-set.sh` **und** `blacklist-set.sh` laden und
|
||||||
|
jeden Kandidaten per `quelle_url`, `external_id` **und `firma_slug` (firmenweit)**
|
||||||
|
sowie gegen die Blacklist abgleichen. **Steht die Firma schon im Applied-Set oder
|
||||||
|
auf der Blacklist — egal unter welchem Stellentitel und egal in welcher
|
||||||
|
Schreibweise/Rechtsform (`firma_slug` gleich ODER Präfix-Match, mind. 2 Tokens) —
|
||||||
|
wird kein weiterer Treffer dieses Arbeitgebers ausgegeben oder importiert.** Auch
|
||||||
|
**innerhalb eines Laufs** jede Firma nur **einmal** präsentieren (bei mehreren
|
||||||
|
Stellen derselben Firma die am besten passende wählen). Beim Import die
|
||||||
|
`external_id` **deterministisch aus `firma+stelle`** bilden, **nicht** aus einer
|
||||||
|
Board-/Anzeigen-ID — so ergibt dieselbe Stelle über jeden Lauf/jedes Board dasselbe
|
||||||
|
`external_id` (Server-Upsert statt Dublette). Der Server dedupliziert
|
||||||
|
`POST /joboffers` über `external_id` (Upsert) und die Blacklist (409, firmenweit
|
||||||
|
über `firma_slug`).
|
||||||
|
- **Blacklisten nach jedem Import (Pflicht, firmenweit).** Jeder frisch angelegte Job
|
||||||
|
(`action:created`) wird direkt anschließend per `POST /joboffers/blacklist`
|
||||||
|
(**`typ:firma`** — die ganze Firma) gesperrt (Schritt 7), damit dieser Arbeitgeber
|
||||||
|
in Folgeläufen mit keinem Titel wieder auftaucht. Nicht blacklisten bei `updated`
|
||||||
|
oder bei einem 409-Skip. Ein 409 beim `POST /joboffers` bedeutet „steht schon auf
|
||||||
|
der Blacklist" → überspringen, nie mit `force` erzwingen.
|
||||||
|
- **Interaktiv: nicht ungefragt importieren** — erst zeigen, dann auf Bestätigung
|
||||||
|
importieren. **Autonom/headless: automatisch importieren** (siehe „Autonomer
|
||||||
|
Modus"). In **keinem** Modus Bewerbungen anlegen/versenden.
|
||||||
|
- Board-Aggregatoren-Duplikate (dieselbe Stelle auf mehreren Portalen) zu einem
|
||||||
|
Eintrag zusammenfassen, bevorzugt mit Direkt-/Firmenwebsite-Link.
|
||||||
|
- Ist die Bewerbungs-Tracker-API nicht erreichbar (`GET /health`), Dedup nicht
|
||||||
|
möglich → Nutzer warnen und Treffer ohne Dedup mit Hinweis liefern.
|
||||||
|
|
||||||
|
## Abhängigkeiten
|
||||||
|
|
||||||
|
- Tools: `WebSearch`, `WebFetch`.
|
||||||
|
- Skill `bewerbungs-tracker` (für Dedup-Daten, JobOffer-Import und Blacklist:
|
||||||
|
`GET/POST /joboffers/blacklist`).
|
||||||
|
- Scripts: `scripts/arbeitsagentur.sh` (strukturierte Jobsuche-API der Bundesagentur
|
||||||
|
für Arbeit — primäre Quelle, `search`/`detail`), `scripts/applied-set.sh` (erfasste
|
||||||
|
Firmen), `scripts/blacklist-set.sh` (gesperrte Muster). `applied-set`/`blacklist-set`
|
||||||
|
nutzen den `bt.sh`-Helfer des `bewerbungs-tracker`-Skills; `arbeitsagentur.sh` nutzt
|
||||||
|
nur `curl` + `python3` (öffentliche API, kein Key nötig).
|
||||||
+104
@@ -0,0 +1,104 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Prints the set of jobs the user has ALREADY engaged with, for deduplication
|
||||||
|
# during a job search — so the same job is never found/imported twice, also on
|
||||||
|
# LATER runs. Combines existing Bewerbungen (/applications) and already-imported
|
||||||
|
# Jobangebote (/joboffers) from the Bewerbungs-Tracker API.
|
||||||
|
#
|
||||||
|
# Output: EINE Zeile pro bereits erfasster FIRMA (company-level), tab-separated:
|
||||||
|
# firma_slug <TAB> firma <TAB> stelle <TAB> ort <TAB> quelle_url <TAB> external_id
|
||||||
|
#
|
||||||
|
# firma_slug = robuster Firmen-Schlüssel (lowercase, Umlaute ae/oe/ue/ss,
|
||||||
|
# Diakritika entfernt, (m/w/d) raus, End-Rechtsform-Tokens wie
|
||||||
|
# gmbh/ag/kg entfernt, mit "-" verbunden). Identisch zu firmaSlug
|
||||||
|
# in lib/blacklist.js des Servers — deckt Schreibweisen-/
|
||||||
|
# Rechtsform-/Umlaut-Varianten EINER Firma ab.
|
||||||
|
#
|
||||||
|
# Regel: eine FIRMA darf nur EINMAL gefunden werden. Ein neuer Treffer gilt als
|
||||||
|
# Dublette (→ verwerfen), wenn seine Firma zu EINER Zeile hier passt:
|
||||||
|
# * gleicher firma_slug, ODER
|
||||||
|
# * ein firma_slug ist ein führendes Bindestrich-Präfix des anderen (≥2 Tokens)
|
||||||
|
# — d. h. Kurzname vs. voller Firmenname ("it-problemloeser" ⊂
|
||||||
|
# "it-problemloeser-verwaltungs-und-handels"), ODER
|
||||||
|
# * gleiche quelle_url ODER gleiche external_id.
|
||||||
|
# Es wird bewusst NICHT mehr nach Stellentitel unterschieden — eine bereits
|
||||||
|
# erfasste Firma taucht mit KEINEM (auch nicht neuem) Titel wieder auf.
|
||||||
|
# HTML-Entities werden dekodiert.
|
||||||
|
#
|
||||||
|
# Reuses the bewerbungs-tracker skill's helper for auth/base-URL.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
BT="${BT_SCRIPT:-$HOME/.claude/skills/bewerbungs-tracker/scripts/bt.sh}"
|
||||||
|
if [[ ! -x "$BT" ]]; then
|
||||||
|
echo "applied-set.sh: bewerbungs-tracker helper not found at $BT" >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
|
||||||
|
# In Tempdateien schreiben (NICHT in Env-Vars): die kombinierte JSON aus
|
||||||
|
# /applications + /joboffers wird groß; als Env-Var würde sie ARG_MAX (argv+envp)
|
||||||
|
# sprengen ("Argument list too long" beim python3-Start). Dateien lesen umgeht das.
|
||||||
|
TMPDIR_AS="$(mktemp -d)"
|
||||||
|
trap 'rm -rf "$TMPDIR_AS"' EXIT
|
||||||
|
APPS_FILE="$TMPDIR_AS/apps.json"
|
||||||
|
OFFERS_FILE="$TMPDIR_AS/offers.json"
|
||||||
|
"$BT" GET '/applications?limit=500' > "$APPS_FILE"
|
||||||
|
"$BT" GET '/joboffers' > "$OFFERS_FILE"
|
||||||
|
|
||||||
|
APPS_FILE="$APPS_FILE" OFFERS_FILE="$OFFERS_FILE" python3 <<'PY'
|
||||||
|
import os, json, html, re, sys, unicodedata
|
||||||
|
|
||||||
|
def load(name):
|
||||||
|
with open(os.environ[name], encoding='utf-8') as fh:
|
||||||
|
raw = fh.read().split('<http')[0]
|
||||||
|
try:
|
||||||
|
return json.loads(raw)
|
||||||
|
except Exception:
|
||||||
|
return []
|
||||||
|
|
||||||
|
def clean(v):
|
||||||
|
return html.unescape(str(v or '')).replace('\t', ' ').strip()
|
||||||
|
|
||||||
|
# Robuster Firmen-Schlüssel — MUSS mit firmaSlug() in lib/blacklist.js des
|
||||||
|
# Servers übereinstimmen (Umlaut-Translit, (m/w/d) raus, End-Rechtsform-Tokens
|
||||||
|
# entfernt, mit "-" verbunden), damit Skill- und Server-Dedup dieselbe Firma
|
||||||
|
# gleich erkennen.
|
||||||
|
_GENDER = re.compile(r'\((?:[mwdfax](?:\s*/\s*[mwdfax])*)\)', re.I) # (m/w/d), (w/m/x)…
|
||||||
|
_LEGAL_FORMS = {
|
||||||
|
'gmbh', 'ggmbh', 'mbh', 'ug', 'haftungsbeschraenkt', 'ag', 'kg', 'kgaa',
|
||||||
|
'ohg', 'gbr', 'se', 'ek', 'eg', 'ev', 'partg', 'partmbb', 'co', 'cie',
|
||||||
|
'inc', 'incorporated', 'llc', 'ltd', 'limited', 'plc', 'corp', 'corporation',
|
||||||
|
'company', 'sa', 'sarl', 'sas', 'bv', 'nv', 'oy', 'ab', 'as', 'aps', 'srl', 'spa',
|
||||||
|
}
|
||||||
|
def firma_slug(s):
|
||||||
|
t = html.unescape(str(s or '')).lower()
|
||||||
|
t = (t.replace('ä', 'ae').replace('ö', 'oe').replace('ü', 'ue').replace('ß', 'ss'))
|
||||||
|
t = unicodedata.normalize('NFKD', t)
|
||||||
|
t = ''.join(c for c in t if not unicodedata.combining(c))
|
||||||
|
t = _GENDER.sub(' ', t)
|
||||||
|
t = re.sub(r'[^a-z0-9]+', ' ', t)
|
||||||
|
t = re.sub(r'\s+', ' ', t).strip()
|
||||||
|
if not t:
|
||||||
|
return ''
|
||||||
|
words = t.split(' ')
|
||||||
|
kept = words[:]
|
||||||
|
while len(kept) > 1 and kept[-1] in _LEGAL_FORMS:
|
||||||
|
kept.pop()
|
||||||
|
return '-'.join(kept if kept else words)
|
||||||
|
|
||||||
|
# Eine Firma nur EINMAL: pro firma_slug genau eine Zeile (erste gewinnt).
|
||||||
|
seen = set()
|
||||||
|
def emit(firma, stelle, ort, url, ext):
|
||||||
|
slug = firma_slug(firma)
|
||||||
|
if not slug or slug in seen:
|
||||||
|
return
|
||||||
|
seen.add(slug)
|
||||||
|
print('\t'.join([slug, clean(firma), clean(stelle), clean(ort),
|
||||||
|
clean(url), clean(ext)]))
|
||||||
|
|
||||||
|
for a in load('APPS_FILE'):
|
||||||
|
emit(a.get('firma'), a.get('stelle'), a.get('ort'), a.get('quelle_url'), '')
|
||||||
|
for o in load('OFFERS_FILE'):
|
||||||
|
emit(o.get('firma'), o.get('stelle'), o.get('ort'), o.get('quelle_url'),
|
||||||
|
o.get('external_id'))
|
||||||
|
|
||||||
|
print(f'# {len(seen)} bereits erfasste Firmen (Bewerbungen + Jobangebote)', file=sys.stderr)
|
||||||
|
PY
|
||||||
+143
@@ -0,0 +1,143 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Query the Bundesagentur für Arbeit Jobsuche-API — a structured, FRESH, high-recall
|
||||||
|
# primary source for the it-stellensuche skills that complements free-text WebSearch.
|
||||||
|
# Results come back already shaped for the skill's firma_slug-based Dedup, and a
|
||||||
|
# posting's full detail (Volltext, Bewerbungs-URL, Zeitarbeit/Vermittler-Flags,
|
||||||
|
# Adresse) can be pulled with one call — no WebFetch needed for the basics.
|
||||||
|
#
|
||||||
|
# Public, read-only API. The API key is the well-known public value
|
||||||
|
# "jobboerse-jobsuche" (same key the arbeitsagentur.de site itself uses).
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# arbeitsagentur.sh search <was> <wo> [umkreis_km] [tage] [size] [arbeitszeit]
|
||||||
|
# Lists postings for role <was> around location <wo>. One API call.
|
||||||
|
# umkreis_km default 15, tage (veröffentlicht seit) default 30, size default 50.
|
||||||
|
# <wo> leer lassen ("") = deutschlandweit. arbeitszeit optional, u. a. "ho"
|
||||||
|
# (Homeoffice) für die Remote-Suche, sonst weglassen.
|
||||||
|
# Output: one posting per line, tab-separated:
|
||||||
|
# firma_slug <TAB> firma <TAB> titel <TAB> ort <TAB> datum <TAB> refnr <TAB> angebotsart
|
||||||
|
# firma_slug is identical to applied-set.sh / firmaSlug() in lib/blacklist.js,
|
||||||
|
# so a hit can be dedup'd against the applied-set/blacklist immediately.
|
||||||
|
#
|
||||||
|
# arbeitsagentur.sh detail <refnr>
|
||||||
|
# Full posting for a refnr (from a search row). Prints a readable field block
|
||||||
|
# plus the complete Beschreibung — treat it like a WebFetch of the ad. Carries
|
||||||
|
# istArbeitnehmerUeberlassung / istPrivateArbeitsvermittlung → drop Zeitarbeit/
|
||||||
|
# Vermittler here WITHOUT a fetch; externeURL → the employer's original link
|
||||||
|
# (bevorzugt als quelle_url, sofern es zum echten Arbeitgeber führt).
|
||||||
|
#
|
||||||
|
# Existence check: refnr is stable; a detail call that returns no Titel/Beschreibung
|
||||||
|
# (HTTP 404 / error body) means the ad is gone → verwerfen (bzw. JobOffer löschen).
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
API='https://rest.arbeitsagentur.de/jobboerse/jobsuche-service/pc/v4'
|
||||||
|
KEY='jobboerse-jobsuche'
|
||||||
|
|
||||||
|
usage() { sed -n '2,25p' "$0" >&2; exit 2; }
|
||||||
|
|
||||||
|
TMP="$(mktemp)"
|
||||||
|
trap 'rm -f "$TMP"' EXIT
|
||||||
|
|
||||||
|
cmd="${1:-}"; [[ -n "$cmd" ]] && shift || usage
|
||||||
|
|
||||||
|
if [[ "$cmd" == "search" ]]; then
|
||||||
|
was="${1:-}"; wo="${2:-}"; umkreis="${3:-15}"; tage="${4:-30}"; size="${5:-50}"; arbeitszeit="${6:-}"
|
||||||
|
[[ -n "$was" ]] || { echo "search: <was> fehlt" >&2; usage; }
|
||||||
|
# curl-Argumente als Array — sauberes, optionales wo=/arbeitszeit=.
|
||||||
|
args=(-sS -m 30 -G "$API/jobs" -H "X-API-Key: $KEY"
|
||||||
|
--data-urlencode "was=$was"
|
||||||
|
--data-urlencode "umkreis=$umkreis"
|
||||||
|
--data-urlencode "veroeffentlichtseit=$tage"
|
||||||
|
--data-urlencode "size=$size")
|
||||||
|
[[ -n "$wo" ]] && args+=(--data-urlencode "wo=$wo")
|
||||||
|
[[ -n "$arbeitszeit" ]] && args+=(--data-urlencode "arbeitszeit=$arbeitszeit")
|
||||||
|
curl "${args[@]}" -o "$TMP" || { echo "# Arbeitsagentur-API nicht erreichbar" >&2; exit 0; }
|
||||||
|
|
||||||
|
RESP_FILE="$TMP" python3 <<'PY'
|
||||||
|
import os, sys, json, html, re, unicodedata
|
||||||
|
|
||||||
|
# firma_slug — MUSS mit firmaSlug() (lib/blacklist.js) & applied-set.sh übereinstimmen.
|
||||||
|
_GENDER = re.compile(r'\((?:[mwdfax](?:\s*/\s*[mwdfax])*)\)', re.I)
|
||||||
|
_LEGAL = {'gmbh','ggmbh','mbh','ug','haftungsbeschraenkt','ag','kg','kgaa','ohg','gbr',
|
||||||
|
'se','ek','eg','ev','partg','partmbb','co','cie','inc','incorporated','llc',
|
||||||
|
'ltd','limited','plc','corp','corporation','company','sa','sarl','sas','bv',
|
||||||
|
'nv','oy','ab','as','aps','srl','spa'}
|
||||||
|
def firma_slug(s):
|
||||||
|
t = html.unescape(str(s or '')).lower()
|
||||||
|
t = t.replace('ä','ae').replace('ö','oe').replace('ü','ue').replace('ß','ss')
|
||||||
|
t = unicodedata.normalize('NFKD', t)
|
||||||
|
t = ''.join(c for c in t if not unicodedata.combining(c))
|
||||||
|
t = _GENDER.sub(' ', t)
|
||||||
|
t = re.sub(r'[^a-z0-9]+',' ',t)
|
||||||
|
t = re.sub(r'\s+',' ',t).strip()
|
||||||
|
if not t: return ''
|
||||||
|
w = t.split(' '); kept = w[:]
|
||||||
|
while len(kept) > 1 and kept[-1] in _LEGAL: kept.pop()
|
||||||
|
return '-'.join(kept if kept else w)
|
||||||
|
|
||||||
|
def clean(v):
|
||||||
|
return html.unescape(str(v if v is not None else '')).replace('\t',' ').replace('\n',' ').strip()
|
||||||
|
|
||||||
|
try:
|
||||||
|
with open(os.environ['RESP_FILE'], encoding='utf-8') as fh:
|
||||||
|
d = json.load(fh)
|
||||||
|
except Exception as e:
|
||||||
|
print(f'# Arbeitsagentur-API: keine/ungueltige Antwort ({e})', file=sys.stderr); sys.exit(0)
|
||||||
|
|
||||||
|
jobs = d.get('stellenangebote') or []
|
||||||
|
n = 0
|
||||||
|
for j in jobs:
|
||||||
|
slug = firma_slug(j.get('arbeitgeber'))
|
||||||
|
if not slug:
|
||||||
|
continue
|
||||||
|
ort = (j.get('arbeitsort') or {}).get('ort')
|
||||||
|
print('\t'.join([slug, clean(j.get('arbeitgeber')), clean(j.get('titel')), clean(ort),
|
||||||
|
clean(j.get('aktuelleVeroeffentlichungsdatum')),
|
||||||
|
clean(j.get('refnr')), clean(j.get('angebotsart') or 'ARBEIT')]))
|
||||||
|
n += 1
|
||||||
|
print(f'# {n} Treffer (Arbeitsagentur, {d.get("maxErgebnisse","?")} gesamt)', file=sys.stderr)
|
||||||
|
PY
|
||||||
|
|
||||||
|
elif [[ "$cmd" == "detail" ]]; then
|
||||||
|
ref="${1:-}"; [[ -n "$ref" ]] || { echo "detail: <refnr> fehlt" >&2; usage; }
|
||||||
|
seg="$(REF="$ref" python3 -c 'import base64,urllib.parse,os; print(urllib.parse.quote(base64.b64encode(os.environ["REF"].encode()).decode(), safe=""))')"
|
||||||
|
curl -sS -m 30 "$API/jobdetails/$seg" -H "X-API-Key: $KEY" -o "$TMP" \
|
||||||
|
|| { echo "# Arbeitsagentur-API nicht erreichbar" >&2; exit 0; }
|
||||||
|
|
||||||
|
REF="$ref" RESP_FILE="$TMP" python3 <<'PY'
|
||||||
|
import os, sys, json, html
|
||||||
|
try:
|
||||||
|
with open(os.environ['RESP_FILE'], encoding='utf-8') as fh:
|
||||||
|
d = json.load(fh)
|
||||||
|
except Exception as e:
|
||||||
|
print(f'# detail: ungueltige Antwort ({e})', file=sys.stderr); sys.exit(0)
|
||||||
|
|
||||||
|
titel = d.get('stellenangebotsTitel') or d.get('titel')
|
||||||
|
if not titel and not d.get('stellenangebotsBeschreibung'):
|
||||||
|
print(f'# refnr {os.environ.get("REF","")}: keine aktive Anzeige (abgelaufen/entfernt)')
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
|
def u(v): return html.unescape(v) if isinstance(v, str) else v
|
||||||
|
lok = (d.get('stellenlokationen') or [{}])[0]
|
||||||
|
adr = ', '.join(x for x in [lok.get('strasse'),
|
||||||
|
' '.join(y for y in [lok.get('plz'), lok.get('ort')] if y)] if x)
|
||||||
|
|
||||||
|
print('FIRMA:', u(d.get('firma')))
|
||||||
|
print('TITEL:', u(titel))
|
||||||
|
print('ART:', d.get('stellenangebotsart'))
|
||||||
|
print('ZEITARBEIT_AUE:', d.get('istArbeitnehmerUeberlassung'))
|
||||||
|
print('PRIVATE_ARBEITSVERMITTLUNG:', d.get('istPrivateArbeitsvermittlung'))
|
||||||
|
print('HOMEOFFICE_MOEGLICH:', d.get('homeofficemoeglich'))
|
||||||
|
print('VERGUETUNG:', d.get('verguetungsangabe'))
|
||||||
|
print('ADRESSE:', adr or '(keine)')
|
||||||
|
print('EXTERNE_URL:', u(d.get('externeURL')) or '(keine — dann Firmen-Karriereseite suchen)')
|
||||||
|
print('REFERENZNUMMER:', d.get('referenznummer') or os.environ.get('REF',''))
|
||||||
|
print('VEROEFFENTLICHT:', d.get('datumErsteVeroeffentlichung') or d.get('aktuelleVeroeffentlichungsdatum'))
|
||||||
|
print('AENDERUNG:', d.get('aenderungsdatum'))
|
||||||
|
print('--- BESCHREIBUNG ---')
|
||||||
|
print(u(d.get('stellenangebotsBeschreibung')) or '(keine)')
|
||||||
|
PY
|
||||||
|
|
||||||
|
else
|
||||||
|
usage
|
||||||
|
fi
|
||||||
+61
@@ -0,0 +1,61 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Prints the Bewerbungs-Tracker BLACKLIST in a matchable, tab-separated form, so
|
||||||
|
# a job-search run can discard candidates that are blocked BEFORE listing or
|
||||||
|
# importing them (server also rejects blocked offers with 409 on POST /joboffers,
|
||||||
|
# but we don't want to even present them).
|
||||||
|
#
|
||||||
|
# Output: one blacklist entry per line, tab-separated:
|
||||||
|
# id <TAB> typ <TAB> firma <TAB> stelle <TAB> ort <TAB> domain <TAB> url_norm <TAB> firma_norm <TAB> firma_slug <TAB> stelle_norm <TAB> ort_norm <TAB> grund
|
||||||
|
#
|
||||||
|
# typ = url | domain | firma | firma_stelle | auto
|
||||||
|
#
|
||||||
|
# firma_slug = robuster Firmen-Schlüssel (Umlaut-Translit, End-Rechtsform raus,
|
||||||
|
# mit "-" verbunden) — identisch zu applied-set.sh und firmaSlug()
|
||||||
|
# in lib/blacklist.js. Damit greift die Firmen-Sperre auch bei
|
||||||
|
# abweichender Schreibweise/Rechtsform.
|
||||||
|
#
|
||||||
|
# A candidate is BLOCKED when one entry matches:
|
||||||
|
# typ=url -> candidate URL (normalized) == url_norm
|
||||||
|
# typ=domain -> candidate URL host == domain (also matches subdomains)
|
||||||
|
# typ=firma -> SAME company: candidate firma_slug == firma_slug, OR one
|
||||||
|
# firma_slug is a leading hyphen-prefix of the other (>=2
|
||||||
|
# tokens); firma_norm only as fallback for legacy rows.
|
||||||
|
# typ=firma_stelle -> same company (firma_slug, wie oben) AND stelle_norm equal
|
||||||
|
# (ort_norm only narrows further when set)
|
||||||
|
# typ=auto -> treat like the concrete fields it carries (url/firma_stelle)
|
||||||
|
#
|
||||||
|
# The server already provides the *_norm fields (lowercased, tracking params
|
||||||
|
# stripped, gender/legal-form removed), so compare against those.
|
||||||
|
#
|
||||||
|
# Reuses the bewerbungs-tracker skill's helper for auth/base-URL.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
BT="${BT_SCRIPT:-$HOME/.claude/skills/bewerbungs-tracker/scripts/bt.sh}"
|
||||||
|
if [[ ! -x "$BT" ]]; then
|
||||||
|
echo "blacklist-set.sh: bewerbungs-tracker helper not found at $BT" >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
|
||||||
|
bl="$("$BT" GET '/joboffers/blacklist')"
|
||||||
|
|
||||||
|
BL_JSON="$bl" python3 <<'PY'
|
||||||
|
import os, json, html, sys
|
||||||
|
|
||||||
|
def load(name):
|
||||||
|
raw = os.environ[name].split('<http')[0]
|
||||||
|
try:
|
||||||
|
return json.loads(raw)
|
||||||
|
except Exception:
|
||||||
|
return []
|
||||||
|
|
||||||
|
def clean(v):
|
||||||
|
return html.unescape(str(v if v is not None else '')).replace('\t', ' ').strip()
|
||||||
|
|
||||||
|
rows = load('BL_JSON')
|
||||||
|
for e in rows:
|
||||||
|
print('\t'.join(clean(e.get(k)) for k in (
|
||||||
|
'id', 'typ', 'firma', 'stelle', 'ort', 'domain',
|
||||||
|
'url_norm', 'firma_norm', 'firma_slug', 'stelle_norm', 'ort_norm', 'grund')))
|
||||||
|
|
||||||
|
print(f'# {len(rows)} Blacklist-Eintraege', file=sys.stderr)
|
||||||
|
PY
|
||||||
+48
-37
@@ -1,50 +1,61 @@
|
|||||||
# Bewerbungs-Tracker – Indeed Import (Browser-Erweiterung)
|
# NextJobs – Stellen-Import (Browser-Erweiterung)
|
||||||
|
|
||||||
Chromium-Erweiterung (Manifest V3), die auf Indeed einen Button **direkt neben der
|
Chromium-Erweiterung (Manifest V3), die **auf jeder Webseite** Stellen
|
||||||
Stellenbeschreibung** einfügt. Ein Klick sendet alle Stelleninformationen an den
|
ausliest und an NextJobs (`https://nextjobs.cc`) sendet. Auf Indeed wird
|
||||||
Bewerbungs-Tracker. Dort wird die Stelle als **Entwurf** angelegt und die KI
|
zusätzlich ein Button *direkt neben der Stellenbeschreibung* eingeblendet.
|
||||||
erstellt automatisch zugeschnittene Bewerbungsunterlagen (auf Basis der hinterlegten
|
Die Stelle wird im Tracker als **Entwurf** angelegt; die KI erstellt daraus
|
||||||
Vorlagen).
|
zugeschnittene Bewerbungsunterlagen (auf Basis der hinterlegten Vorlagen).
|
||||||
|
|
||||||
|
## Multi-User & Authentifizierung
|
||||||
|
|
||||||
|
Die Tracker-Adresse ist fest `https://nextjobs.cc` (hardcodiert). Jeder
|
||||||
|
Benutzer authentifiziert sich mit seinem **persönlichen REST-API-Key**:
|
||||||
|
|
||||||
|
- Im Tracker unter **Einstellungen → API-Token (X-API-Key)** einen Token
|
||||||
|
setzen (falls noch keiner da ist: neu vergeben).
|
||||||
|
- In der Erweiterung auf das Symbol klicken → **Einstellungen** → Token
|
||||||
|
eintragen → **Speichern** + **Verbindung testen**.
|
||||||
|
|
||||||
|
Der Key wird im `X-API-Key`-Header gesendet; der Server ordnet den Import dem
|
||||||
|
Benutzerkonto zu, das diesen Token hinterlegt hat. Ohne (oder mit ungültigem)
|
||||||
|
Key antwortet der Server mit **401**. Der Key wird pro Browser-Profil in der
|
||||||
|
`chrome.storage.sync` gespeichert — wer mehrere Konten verwaltet, kann den Key
|
||||||
|
jederzeit in den Einstellungen wechseln.
|
||||||
|
|
||||||
## Installation (Entwicklermodus)
|
## Installation (Entwicklermodus)
|
||||||
|
|
||||||
1. Bewerbungs-Tracker starten (Standard: `http://localhost:3000`).
|
1. In Chrome/Edge/Brave `chrome://extensions` öffnen.
|
||||||
2. In Chrome/Edge/Brave `chrome://extensions` öffnen.
|
2. **Entwicklermodus** oben rechts aktivieren.
|
||||||
3. **Entwicklermodus** oben rechts aktivieren.
|
3. **Entpackte Erweiterung laden** klicken und diesen `extension/`-Ordner
|
||||||
4. **Entpackte Erweiterung laden** klicken und diesen `extension/`-Ordner auswählen.
|
auswählen.
|
||||||
|
4. API-Key eintragen (siehe oben).
|
||||||
## Konfiguration
|
|
||||||
|
|
||||||
Auf das Erweiterungs-Symbol klicken und die **Tracker-Adresse** setzen
|
|
||||||
(z. B. `http://localhost:3000` oder die Adresse deiner Deployment-Instanz).
|
|
||||||
Mit **Verbindung testen** prüfen, ob der Tracker erreichbar ist.
|
|
||||||
|
|
||||||
## Verwendung
|
## Verwendung
|
||||||
|
|
||||||
1. Auf `indeed.com` eine Stelle öffnen.
|
1. Auf einer Stellenanzeige das Erweiterungs-Symbol klicken. Die Felder werden
|
||||||
2. Neben der Überschrift **„Vollständige Stellenbeschreibung"** erscheint der grüne
|
automatisch ausgelesen (Schema.org/JSON-LD → OpenGraph → Auswahl/Text).
|
||||||
Button **„An Bewerbungs-Tracker senden"**.
|
Text auf der Seite markieren → wird als Beschreibung übernommen.
|
||||||
3. Klicken → die Stelle wird als Entwurf angelegt, die Unterlagen werden im
|
2. Felder prüfen, ggf. korrigieren und **An Tracker senden** klicken.
|
||||||
Hintergrund generiert. Über den Link „Entwurf öffnen" gelangst du direkt zur
|
Auf Indeed alternativ den grünen Button **„An NextJobs senden"** neben
|
||||||
Bewerbung im Tracker.
|
der Stellenbeschreibung.
|
||||||
|
3. Über den Link **Entwurf öffnen** gelangst du direkt zur Bewerbung im Tracker.
|
||||||
|
|
||||||
## Voraussetzungen im Tracker
|
## Voraussetzungen im Tracker
|
||||||
|
|
||||||
- Unter **Vorlagen** mindestens ein Basis-Dokument (z. B. Anschreiben, Lebenslauf)
|
- Unter **Vorlagen** mindestens ein Basis-Dokument (z. B. Anschreiben,
|
||||||
hinterlegen — diese dienen der KI als Faktengrundlage.
|
Lebenslauf) hinterlegen — diese dienen der KI als Faktengrundlage.
|
||||||
- `OLLAMA_API_KEY` (Ollama Cloud) gesetzt – z. B. in der `.env`-Datei des Trackers –,
|
- `OLLAMA_API_KEY` (Ollama Cloud) gesetzt – z. B. in den Einstellungen des
|
||||||
damit die KI-Generierung läuft.
|
Benutzers – damit die KI-Generierung läuft.
|
||||||
|
- Ein **API-Token (X-API-Key)** für den benutzten Account.
|
||||||
|
|
||||||
## Ausgelesene Felder
|
## Berechtigungen
|
||||||
|
|
||||||
| Feld | Quelle (Indeed DOM) |
|
| Berechtigung | Zweck |
|
||||||
|------|---------------------|
|
|---|---|
|
||||||
| Stelle | `[data-testid="jobsearch-JobInfoHeader-title"]` |
|
| `storage` | API-Key speichern (`chrome.storage.sync`) |
|
||||||
| Firma | `[data-testid="inlineHeader-companyName"]` (im `jobsearch-CompanyInfoContainer`) |
|
| `scripting` + `activeTab` | Stelle auf der aktiven Seite auslesen (nur auf Klick) |
|
||||||
| Ort | `[data-testid="jobsearch-JobInfoHeader-companyLocation"]` |
|
| `https://nextjobs.cc/*` | Import an den Tracker senden |
|
||||||
| Gehalt/Art | `#salaryInfoAndJobType` |
|
| `*://*.indeed.com/*` | Inline-Button auf Indeed einblenden |
|
||||||
| Stellenbeschreibung | `#jobDescriptionText` |
|
|
||||||
| Quelle-URL | aus `vjk`/`jk`-Parameter bzw. aktueller URL |
|
|
||||||
|
|
||||||
Indeed ist eine Single-Page-App; die Erweiterung erkennt Wechsel der geöffneten
|
Die Erweiterung liest nur die aktive Seite aus, wenn du sie aufrufst, und
|
||||||
Stelle per `MutationObserver` und hält den Button aktuell.
|
sendet die Daten ausschließlich an `https://nextjobs.cc`.
|
||||||
+26
-15
@@ -1,15 +1,18 @@
|
|||||||
// Service worker: performs the cross-origin POST to the Bewerbungs-Tracker.
|
// Service worker: performs the cross-origin POST to NextJobs.
|
||||||
// Routing the request through the background worker (instead of the content
|
// Routing the request through the background worker (instead of a content
|
||||||
// script) keeps it independent of the Indeed page's Content-Security-Policy.
|
// script) keeps it independent of the page's Content-Security-Policy.
|
||||||
|
//
|
||||||
|
// Die Tracker-Adresse ist fest (https://nextjobs.cc). Die Authentifizierung
|
||||||
|
// läuft über den persönlichen REST-API-Key (Header X-API-Key), den jeder
|
||||||
|
// Benutzer im Popup hinterlegt — der Server ordnet den Import damit dem
|
||||||
|
// richtigen Benutzerkonto zu. Ohne Key lehnt der Server mit 401 ab.
|
||||||
|
|
||||||
const DEFAULT_TRACKER_URL = 'http://localhost:3000';
|
const TRACKER_URL = 'https://nextjobs.cc';
|
||||||
|
|
||||||
function getTrackerUrl() {
|
function getApiKey() {
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
chrome.storage.sync.get({ trackerUrl: DEFAULT_TRACKER_URL }, (items) => {
|
chrome.storage.sync.get({ apiKey: '' }, (items) => {
|
||||||
let url = (items.trackerUrl || DEFAULT_TRACKER_URL).trim();
|
resolve((items.apiKey || '').trim());
|
||||||
url = url.replace(/\/+$/, ''); // strip trailing slashes
|
|
||||||
resolve(url);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -18,10 +21,14 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
|
|||||||
if (message && message.type === 'IMPORT_JOB') {
|
if (message && message.type === 'IMPORT_JOB') {
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
const base = await getTrackerUrl();
|
const apiKey = await getApiKey();
|
||||||
const res = await fetch(base + '/api/indeed-import', {
|
if (!apiKey) {
|
||||||
|
sendResponse({ ok: false, needKey: true, error: 'Kein API-Key hinterlegt.' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const res = await fetch(TRACKER_URL + '/api/indeed-import', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json', 'X-API-Key': apiKey },
|
||||||
body: JSON.stringify(message.payload),
|
body: JSON.stringify(message.payload),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -39,6 +46,10 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
|
|||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (res.status === 401) {
|
||||||
|
sendResponse({ ok: false, needKey: true, error: 'API-Key abgelehnt (401). Bitte in den Einstellungen prüfen.' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
sendResponse({
|
sendResponse({
|
||||||
ok: false,
|
ok: false,
|
||||||
error: (data && data.error) || `Server antwortete mit ${res.status}`,
|
error: (data && data.error) || `Server antwortete mit ${res.status}`,
|
||||||
@@ -50,14 +61,14 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
|
|||||||
ok: true,
|
ok: true,
|
||||||
id: data.id,
|
id: data.id,
|
||||||
message: data.message,
|
message: data.message,
|
||||||
openUrl: data.url ? base + data.url : null,
|
openUrl: data.url ? TRACKER_URL + data.url : null,
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
sendResponse({
|
sendResponse({
|
||||||
ok: false,
|
ok: false,
|
||||||
error:
|
error:
|
||||||
'Konnte den Bewerbungs-Tracker nicht erreichen. Läuft er, und ist die ' +
|
'Konnte NextJobs nicht erreichen (' + String(err.message) + '). ' +
|
||||||
'Adresse in den Erweiterungs-Einstellungen korrekt? (' + String(err.message) + ')',
|
'Prüfe deine Internetverbindung und ob der API-Key in den Einstellungen korrekt ist.',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
// Content script: injects an "An Bewerbungs-Tracker senden" button next to the
|
// Content script: injects an "An NextJobs senden" button next to the
|
||||||
// Indeed job description, scrapes the job details and forwards them to the
|
// Indeed job description, scrapes the job details and forwards them to the
|
||||||
// tracker's import API (via the background service worker).
|
// tracker's import API (via the background service worker).
|
||||||
|
|
||||||
@@ -103,7 +103,7 @@
|
|||||||
const btn = document.createElement('button');
|
const btn = document.createElement('button');
|
||||||
btn.type = 'button';
|
btn.type = 'button';
|
||||||
btn.className = 'bt-import-btn';
|
btn.className = 'bt-import-btn';
|
||||||
btn.innerHTML = ICON_SEND + '<span>An Bewerbungs-Tracker senden</span>';
|
btn.innerHTML = ICON_SEND + '<span>An NextJobs senden</span>';
|
||||||
|
|
||||||
const status = document.createElement('div');
|
const status = document.createElement('div');
|
||||||
status.className = 'bt-import-status';
|
status.className = 'bt-import-status';
|
||||||
@@ -147,7 +147,7 @@
|
|||||||
setStatus(status, '', '');
|
setStatus(status, '', '');
|
||||||
|
|
||||||
chrome.runtime.sendMessage({ type: 'IMPORT_JOB', payload: payload }, (resp) => {
|
chrome.runtime.sendMessage({ type: 'IMPORT_JOB', payload: payload }, (resp) => {
|
||||||
btn.innerHTML = ICON_SEND + '<span>' + (originalLabel || 'An Bewerbungs-Tracker senden') + '</span>';
|
btn.innerHTML = ICON_SEND + '<span>' + (originalLabel || 'An NextJobs senden') + '</span>';
|
||||||
|
|
||||||
if (chrome.runtime.lastError) {
|
if (chrome.runtime.lastError) {
|
||||||
btn.disabled = false;
|
btn.disabled = false;
|
||||||
|
|||||||
@@ -1,15 +1,12 @@
|
|||||||
{
|
{
|
||||||
"manifest_version": 3,
|
"manifest_version": 3,
|
||||||
"name": "Bewerbungs-Tracker – Stellen-Import",
|
"name": "NextJobs – Stellen-Import",
|
||||||
"version": "1.1.0",
|
"version": "2.0.0",
|
||||||
"description": "Erfasst Stellen von jeder Webseite (nicht nur Indeed) über das Erweiterungs-Popup und sendet sie an den Bewerbungs-Tracker, der automatisch zugeschnittene Bewerbungsunterlagen erstellt.",
|
"description": "Erfasst Stellen von jeder Webseite (nicht nur Indeed) über das Erweiterungs-Popup und sendet sie an NextJobs (https://nextjobs.cc), der automatisch zugeschnittene Bewerbungsunterlagen erstellt. Authentifizierung über deinen persönlichen REST-API-Key.",
|
||||||
"permissions": ["storage", "scripting", "activeTab"],
|
"permissions": ["storage", "scripting", "activeTab"],
|
||||||
"host_permissions": [
|
"host_permissions": [
|
||||||
"*://*.indeed.com/*",
|
"https://nextjobs.cc/*",
|
||||||
"http://localhost/*",
|
"*://*.indeed.com/*"
|
||||||
"http://127.0.0.1/*",
|
|
||||||
"http://*/*",
|
|
||||||
"https://*/*"
|
|
||||||
],
|
],
|
||||||
"content_scripts": [
|
"content_scripts": [
|
||||||
{
|
{
|
||||||
@@ -24,6 +21,6 @@
|
|||||||
},
|
},
|
||||||
"action": {
|
"action": {
|
||||||
"default_popup": "popup.html",
|
"default_popup": "popup.html",
|
||||||
"default_title": "Bewerbungs-Tracker"
|
"default_title": "NextJobs"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -94,10 +94,11 @@
|
|||||||
|
|
||||||
<div id="status"></div>
|
<div id="status"></div>
|
||||||
|
|
||||||
<details id="settings">
|
<details id="settings" open>
|
||||||
<summary>Einstellungen</summary>
|
<summary>Einstellungen</summary>
|
||||||
<label for="trackerUrl">Tracker-Adresse</label>
|
<label for="apiKey">REST-API-Key</label>
|
||||||
<input type="url" id="trackerUrl" placeholder="http://localhost:3000" />
|
<input type="password" id="apiKey" placeholder="API-Key aus den Tracker-Einstellungen" autocomplete="off" />
|
||||||
|
<p class="hint" style="margin:6px 0 0">Deinen persönlichen Key findest du im Tracker unter <b>Einstellungen → API-Token (X-API-Key)</b>. Pro Benutzer eigener Key.</p>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<button class="primary" id="saveBtn">Speichern</button>
|
<button class="primary" id="saveBtn">Speichern</button>
|
||||||
<button class="secondary" id="testBtn">Verbindung testen</button>
|
<button class="secondary" id="testBtn">Verbindung testen</button>
|
||||||
|
|||||||
+25
-14
@@ -1,36 +1,40 @@
|
|||||||
const DEFAULT_TRACKER_URL = 'http://localhost:3000';
|
// Tracker-Adresse ist fest (gehostete Instanz). Jeder Benutzer gibt seinen
|
||||||
|
// eigenen REST-API-Key ein (X-API-Key), über den der Server den Import dem
|
||||||
|
// richtigen Benutzerkonto zuordnet — siehe /api/indeed-import serverseitig.
|
||||||
|
const TRACKER_URL = 'https://nextjobs.cc';
|
||||||
|
|
||||||
const $ = (id) => document.getElementById(id);
|
const $ = (id) => document.getElementById(id);
|
||||||
const statusEl = $('status');
|
const statusEl = $('status');
|
||||||
|
|
||||||
function normalize(url) {
|
|
||||||
return (url || '').trim().replace(/\/+$/, '');
|
|
||||||
}
|
|
||||||
function setStatus(html, cls) {
|
function setStatus(html, cls) {
|
||||||
statusEl.innerHTML = html;
|
statusEl.innerHTML = html;
|
||||||
statusEl.className = cls || '';
|
statusEl.className = cls || '';
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- Settings (tracker URL) ------------------------------------------------
|
// ---- Settings (API-Key) ----------------------------------------------------
|
||||||
|
|
||||||
chrome.storage.sync.get({ trackerUrl: DEFAULT_TRACKER_URL }, (items) => {
|
chrome.storage.sync.get({ apiKey: '' }, (items) => {
|
||||||
$('trackerUrl').value = items.trackerUrl || DEFAULT_TRACKER_URL;
|
$('apiKey').value = items.apiKey || '';
|
||||||
});
|
});
|
||||||
|
|
||||||
$('saveBtn').addEventListener('click', () => {
|
$('saveBtn').addEventListener('click', () => {
|
||||||
const url = normalize($('trackerUrl').value) || DEFAULT_TRACKER_URL;
|
const key = $('apiKey').value.trim();
|
||||||
chrome.storage.sync.set({ trackerUrl: url }, () => {
|
chrome.storage.sync.set({ apiKey: key }, () => {
|
||||||
$('trackerUrl').value = url;
|
setStatus(key ? 'API-Key gespeichert.' : 'API-Key entfernt.', key ? 'ok' : '');
|
||||||
setStatus('Einstellungen gespeichert.', 'ok');
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
$('testBtn').addEventListener('click', async () => {
|
$('testBtn').addEventListener('click', async () => {
|
||||||
const url = normalize($('trackerUrl').value) || DEFAULT_TRACKER_URL;
|
const key = $('apiKey').value.trim();
|
||||||
|
if (!key) { setStatus('Bitte erst API-Key eingeben.', 'err'); return; }
|
||||||
setStatus('Teste Verbindung …', '');
|
setStatus('Teste Verbindung …', '');
|
||||||
try {
|
try {
|
||||||
const res = await fetch(url + '/api/settings', { method: 'GET' });
|
const res = await fetch(TRACKER_URL + '/api/v1/health', {
|
||||||
setStatus(res.ok ? 'Verbindung erfolgreich ✓' : 'Erreichbar, aber unerwartete Antwort (' + res.status + ').', res.ok ? 'ok' : 'err');
|
headers: { 'X-API-Key': key },
|
||||||
|
});
|
||||||
|
if (res.ok) setStatus('Verbindung erfolgreich ✓ — Key gültig.', 'ok');
|
||||||
|
else if (res.status === 401) setStatus('Key abgelehnt (401). Bitte API-Key prüfen.', 'err');
|
||||||
|
else setStatus('Erreichbar, aber unerwartete Antwort (' + res.status + ').', 'err');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setStatus('Nicht erreichbar: ' + err.message, 'err');
|
setStatus('Nicht erreichbar: ' + err.message, 'err');
|
||||||
}
|
}
|
||||||
@@ -158,6 +162,13 @@ function sendJob(payload) {
|
|||||||
setStatus('Fehler: ' + chrome.runtime.lastError.message, 'err');
|
setStatus('Fehler: ' + chrome.runtime.lastError.message, 'err');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (resp && resp.needKey) {
|
||||||
|
$('sendBtn').disabled = false;
|
||||||
|
setStatus('Bitte erst in den Einstellungen deinen API-Key hinterlegen.', 'err');
|
||||||
|
const s = document.getElementById('settings'); if (s) s.open = true;
|
||||||
|
$('apiKey').focus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (resp && resp.duplicate) {
|
if (resp && resp.duplicate) {
|
||||||
$('sendBtn').disabled = false;
|
$('sendBtn').disabled = false;
|
||||||
if (confirm(duplicateWarning(resp.matches))) {
|
if (confirm(duplicateWarning(resp.matches))) {
|
||||||
|
|||||||
+230
-125
@@ -1,18 +1,34 @@
|
|||||||
// Third-party REST API (v1) for the Bewerbungs-Tracker.
|
// Third-party REST API (v1) for the NextJobs.
|
||||||
//
|
//
|
||||||
// Mounted under /api/v1 in server.js. All endpoints except /health require an
|
// Mounted under /api/v1 in server.js. All endpoints except /health require an
|
||||||
// API key (env API_TOKEN) sent in the X-API-Key header. Reuses the server's
|
// API key sent in the X-API-Key header. The key is per-user: it is the
|
||||||
// existing DB helpers, sanitizer, duplicate guard, generation runner and
|
// API_TOKEN the user saved on /einstellungen. The auth step resolves the key to
|
||||||
// attachment directories so behaviour stays consistent with the web UI.
|
// the owning user, then runs every request inside that user's context
|
||||||
|
// (lib/context.js) so all queries + file paths are scoped to that user — the
|
||||||
|
// third-party software only ever sees and creates the user's own data.
|
||||||
|
|
||||||
const express = require('express');
|
const express = require('express');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const blacklist = require('./blacklist');
|
const blacklist = require('./blacklist');
|
||||||
|
const { LABEL_OPTIONS, parseLabels, serializeLabels } = require('./labels');
|
||||||
|
const { normalizeDokumente, standardDokumente } = require('./documents');
|
||||||
|
const { userContext, currentUserId } = require('./context');
|
||||||
|
const config = require('./config');
|
||||||
|
|
||||||
|
const CONFIG_PREFIX = 'cfg:';
|
||||||
|
|
||||||
|
// Replace the stored labels JSON string with a real array on outgoing rows.
|
||||||
|
function withLabels(row) {
|
||||||
|
if (row) row.labels = parseLabels(row.labels);
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
const STATUS_OPTIONS = [
|
const STATUS_OPTIONS = [
|
||||||
'Entwurf', 'Gesendet', 'Eingangsbestätigung', 'Vorstellungsgespräch',
|
'Entwurf', 'Gesendet', 'Eingangsbestätigung',
|
||||||
'Absage', 'Einstellung', 'Keine Rückmeldung',
|
'In Bearbeitung', 'Interessiert', 'Telefonat', 'Warten auf Rückmeldung', 'Warten auf meine Antwort',
|
||||||
|
'Vorstellungsgespräch', 'Vertragsverhandlung',
|
||||||
|
'Absage', 'Absage von meiner Seite', 'Einstellung', 'Keine Rückmeldung',
|
||||||
];
|
];
|
||||||
|
|
||||||
function createExternalApi(deps) {
|
function createExternalApi(deps) {
|
||||||
@@ -27,29 +43,65 @@ function createExternalApi(deps) {
|
|||||||
runGeneration,
|
runGeneration,
|
||||||
anhaengeDir,
|
anhaengeDir,
|
||||||
emailAnhaengeDir,
|
emailAnhaengeDir,
|
||||||
apiToken,
|
userStorageDir,
|
||||||
} = deps;
|
} = deps;
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
|
// The current user's id, resolved from the per-request context set below.
|
||||||
|
const uid = () => currentUserId();
|
||||||
|
|
||||||
// --- API key auth --------------------------------------------------
|
// --- API key auth --------------------------------------------------
|
||||||
// /health is public so monitoring tools can probe availability; everything
|
// /health is public so monitoring tools can probe availability. Every other
|
||||||
// else returns 401 when the header is missing/wrong or the token isn't set.
|
// endpoint resolves the X-API-Key to the user who owns that token (stored in
|
||||||
router.use((req, res, next) => {
|
// app_state as cfg:API_TOKEN), then wraps the rest of the request in that
|
||||||
|
// user's context so all queries/files are scoped to them. A missing or
|
||||||
|
// unknown key yields 401.
|
||||||
|
router.use(async (req, res, next) => {
|
||||||
if (req.path === '/health') return next();
|
if (req.path === '/health') return next();
|
||||||
if (!apiToken) {
|
|
||||||
return res.status(503).json({ error: 'API-Token nicht konfiguriert (API_TOKEN-Umgebungsvariable fehlt).' });
|
|
||||||
}
|
|
||||||
const provided = req.get('X-API-Key');
|
const provided = req.get('X-API-Key');
|
||||||
if (!provided || provided !== apiToken) {
|
if (!provided) {
|
||||||
return res.status(401).json({ error: 'Ungültiger oder fehlender API-Key (Header: X-API-Key).' });
|
return res.status(401).json({ error: 'Ungültiger oder fehlender API-Key (Header: X-API-Key).' });
|
||||||
}
|
}
|
||||||
next();
|
try {
|
||||||
|
// Resolve the token to its owning user (the user whose cfg:API_TOKEN
|
||||||
|
// matches). An empty token is never a valid key — otherwise every user who
|
||||||
|
// has saved their settings once (which stores API_TOKEN as '') would be
|
||||||
|
// matched by an empty header.
|
||||||
|
const matches = await dbAll(
|
||||||
|
`SELECT u.id, u.username, u.is_admin
|
||||||
|
FROM app_state a JOIN users u ON u.id = a.user_id
|
||||||
|
WHERE a.key = ? AND a.value = ? AND a.value != ''`,
|
||||||
|
[CONFIG_PREFIX + 'API_TOKEN', provided]
|
||||||
|
);
|
||||||
|
// Fail closed on an ambiguous token: if two users somehow share a value we
|
||||||
|
// must not silently pick one of them and hand the caller that user's data.
|
||||||
|
// (Saving a token that another user already uses is rejected in the UI.)
|
||||||
|
if (matches.length !== 1) {
|
||||||
|
if (matches.length > 1) {
|
||||||
|
console.error('API auth: Token ist mehreren Benutzern zugeordnet — Zugriff verweigert.');
|
||||||
|
}
|
||||||
|
return res.status(401).json({ error: 'Ungültiger oder fehlender API-Key (Header: X-API-Key).' });
|
||||||
|
}
|
||||||
|
const user = matches[0];
|
||||||
|
req.user = user;
|
||||||
|
// Warm this user's cfg rows: config.get() is synchronous and reads from
|
||||||
|
// the per-user cache, so without this an API request could see the user
|
||||||
|
// as unconfigured (e.g. no Ollama key) purely because nothing had loaded
|
||||||
|
// their rows yet in this process.
|
||||||
|
await config.ensureLoaded(user.id);
|
||||||
|
// Run the remainder of the request inside this user's context so
|
||||||
|
// currentUserId() / config.get() / the scoped helpers all resolve here.
|
||||||
|
userContext.run(user, next);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('API auth error:', err);
|
||||||
|
res.status(401).json({ error: 'Ungültiger oder fehlender API-Key (Header: X-API-Key).' });
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// --- helpers -------------------------------------------------------
|
// --- helpers -------------------------------------------------------
|
||||||
async function getApplication(id) {
|
async function getApplication(id) {
|
||||||
return dbGet('SELECT * FROM bewerbungen WHERE id = ?', [id]);
|
return dbGet('SELECT * FROM bewerbungen WHERE id = ? AND user_id = ?', [id, uid()]);
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- System --------------------------------------------------------
|
// --- System --------------------------------------------------------
|
||||||
@@ -60,12 +112,17 @@ function createExternalApi(deps) {
|
|||||||
// --- Applications --------------------------------------------------
|
// --- Applications --------------------------------------------------
|
||||||
router.get('/applications', async (req, res) => {
|
router.get('/applications', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { month, year, status, art, search } = req.query;
|
const { month, year, status, art, search, label } = req.query;
|
||||||
const limit = Math.min(Math.max(parseInt(req.query.limit, 10) || 100, 1), 500);
|
const limit = Math.min(Math.max(parseInt(req.query.limit, 10) || 100, 1), 500);
|
||||||
const offset = Math.max(parseInt(req.query.offset, 10) || 0, 0);
|
const offset = Math.max(parseInt(req.query.offset, 10) || 0, 0);
|
||||||
|
|
||||||
const where = [];
|
const where = ['user_id = ?'];
|
||||||
const params = [];
|
const params = [uid()];
|
||||||
|
if (label && LABEL_OPTIONS.includes(label)) {
|
||||||
|
// labels is a JSON array string; match the quoted label token.
|
||||||
|
where.push('labels LIKE ?');
|
||||||
|
params.push(`%"${label}"%`);
|
||||||
|
}
|
||||||
if (month) {
|
if (month) {
|
||||||
where.push('strftime("%m", datum) = ?');
|
where.push('strftime("%m", datum) = ?');
|
||||||
params.push(String(month).padStart(2, '0'));
|
params.push(String(month).padStart(2, '0'));
|
||||||
@@ -88,12 +145,13 @@ function createExternalApi(deps) {
|
|||||||
params.push(term, term);
|
params.push(term, term);
|
||||||
}
|
}
|
||||||
|
|
||||||
const clause = where.length ? `WHERE ${where.join(' AND ')}` : '';
|
const clause = `WHERE ${where.join(' AND ')}`;
|
||||||
const applications = await dbAll(
|
const applications = await dbAll(
|
||||||
`SELECT * FROM bewerbungen ${clause} ORDER BY datum DESC, created_at DESC LIMIT ? OFFSET ?`,
|
`SELECT * FROM bewerbungen ${clause} ORDER BY datum DESC, created_at DESC LIMIT ? OFFSET ?`,
|
||||||
[...params, limit, offset]
|
[...params, limit, offset]
|
||||||
);
|
);
|
||||||
await attachVerlauf(applications);
|
await attachVerlauf(applications);
|
||||||
|
applications.forEach(withLabels);
|
||||||
res.json(applications);
|
res.json(applications);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('API list applications error:', error);
|
console.error('API list applications error:', error);
|
||||||
@@ -124,10 +182,11 @@ function createExternalApi(deps) {
|
|||||||
|
|
||||||
const result = await dbRun(
|
const result = await dbRun(
|
||||||
`INSERT INTO bewerbungen
|
`INSERT INTO bewerbungen
|
||||||
(datum, firma, stelle, art, status, notizen, interne_notizen, ort,
|
(user_id, datum, firma, stelle, art, status, notizen, interne_notizen, ort,
|
||||||
stellenbeschreibung, quelle_url, llm_notizen, generierung_status)
|
stellenbeschreibung, quelle_url, llm_notizen, labels, generierung_status)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'nicht_gestartet')`,
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'nicht_gestartet')`,
|
||||||
[
|
[
|
||||||
|
uid(),
|
||||||
datum,
|
datum,
|
||||||
sanitizeInput(firma),
|
sanitizeInput(firma),
|
||||||
sanitizeInput(stelle),
|
sanitizeInput(stelle),
|
||||||
@@ -139,18 +198,19 @@ function createExternalApi(deps) {
|
|||||||
sanitizeInput(b.stellenbeschreibung || ''),
|
sanitizeInput(b.stellenbeschreibung || ''),
|
||||||
sanitizeInput(b.quelle_url || ''),
|
sanitizeInput(b.quelle_url || ''),
|
||||||
sanitizeInput(b.llm_notizen || ''),
|
sanitizeInput(b.llm_notizen || ''),
|
||||||
|
serializeLabels(b.labels),
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
if (b.status && String(b.status).trim()) {
|
if (b.status && String(b.status).trim()) {
|
||||||
await dbRun(
|
await dbRun(
|
||||||
'INSERT INTO status_verlauf (bewerbung_id, datum, status, kommentar) VALUES (?, ?, ?, ?)',
|
'INSERT INTO status_verlauf (user_id, bewerbung_id, datum, status, kommentar) VALUES (?, ?, ?, ?, ?)',
|
||||||
[result.lastID, datum, sanitizeInput(b.status), sanitizeInput(b.kommentar || '')]
|
[uid(), result.lastID, datum, sanitizeInput(b.status), sanitizeInput(b.kommentar || '')]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const application = await dbGet('SELECT * FROM bewerbungen WHERE id = ?', [result.lastID]);
|
const application = await dbGet('SELECT * FROM bewerbungen WHERE id = ? AND user_id = ?', [result.lastID, uid()]);
|
||||||
res.json({ success: true, application });
|
res.json({ success: true, application: withLabels(application) });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('API create application error:', error);
|
console.error('API create application error:', error);
|
||||||
res.status(500).json({ error: 'Serverfehler' });
|
res.status(500).json({ error: 'Serverfehler' });
|
||||||
@@ -162,7 +222,7 @@ function createExternalApi(deps) {
|
|||||||
const application = await getApplication(req.params.id);
|
const application = await getApplication(req.params.id);
|
||||||
if (!application) return res.status(404).json({ error: 'Bewerbung nicht gefunden' });
|
if (!application) return res.status(404).json({ error: 'Bewerbung nicht gefunden' });
|
||||||
await attachVerlauf([application]);
|
await attachVerlauf([application]);
|
||||||
res.json(application);
|
res.json(withLabels(application));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('API get application error:', error);
|
console.error('API get application error:', error);
|
||||||
res.status(500).json({ error: 'Serverfehler' });
|
res.status(500).json({ error: 'Serverfehler' });
|
||||||
@@ -184,8 +244,8 @@ function createExternalApi(deps) {
|
|||||||
`UPDATE bewerbungen SET
|
`UPDATE bewerbungen SET
|
||||||
datum = ?, firma = ?, stelle = ?, art = ?, status = ?, notizen = ?,
|
datum = ?, firma = ?, stelle = ?, art = ?, status = ?, notizen = ?,
|
||||||
interne_notizen = ?, ort = ?, stellenbeschreibung = ?, quelle_url = ?,
|
interne_notizen = ?, ort = ?, stellenbeschreibung = ?, quelle_url = ?,
|
||||||
llm_notizen = ?, updated_at = CURRENT_TIMESTAMP
|
llm_notizen = ?, labels = ?, updated_at = CURRENT_TIMESTAMP
|
||||||
WHERE id = ?`,
|
WHERE id = ? AND user_id = ?`,
|
||||||
[
|
[
|
||||||
b.datum,
|
b.datum,
|
||||||
sanitizeInput(b.firma),
|
sanitizeInput(b.firma),
|
||||||
@@ -198,12 +258,16 @@ function createExternalApi(deps) {
|
|||||||
sanitizeInput(b.stellenbeschreibung ?? existing.stellenbeschreibung),
|
sanitizeInput(b.stellenbeschreibung ?? existing.stellenbeschreibung),
|
||||||
sanitizeInput(b.quelle_url ?? existing.quelle_url),
|
sanitizeInput(b.quelle_url ?? existing.quelle_url),
|
||||||
sanitizeInput(b.llm_notizen ?? existing.llm_notizen),
|
sanitizeInput(b.llm_notizen ?? existing.llm_notizen),
|
||||||
|
Object.prototype.hasOwnProperty.call(b, 'labels')
|
||||||
|
? serializeLabels(b.labels)
|
||||||
|
: (existing.labels || '[]'),
|
||||||
id,
|
id,
|
||||||
|
uid(),
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
const application = await dbGet('SELECT * FROM bewerbungen WHERE id = ?', [id]);
|
const application = await dbGet('SELECT * FROM bewerbungen WHERE id = ? AND user_id = ?', [id, uid()]);
|
||||||
res.json({ success: true, application });
|
res.json({ success: true, application: withLabels(application) });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('API update application error:', error);
|
console.error('API update application error:', error);
|
||||||
res.status(500).json({ error: 'Serverfehler' });
|
res.status(500).json({ error: 'Serverfehler' });
|
||||||
@@ -216,8 +280,8 @@ function createExternalApi(deps) {
|
|||||||
const existing = await getApplication(id);
|
const existing = await getApplication(id);
|
||||||
if (!existing) return res.status(404).json({ error: 'Bewerbung nicht gefunden' });
|
if (!existing) return res.status(404).json({ error: 'Bewerbung nicht gefunden' });
|
||||||
|
|
||||||
await dbRun('DELETE FROM status_verlauf WHERE bewerbung_id = ?', [id]);
|
await dbRun('DELETE FROM status_verlauf WHERE bewerbung_id = ? AND user_id = ?', [id, uid()]);
|
||||||
await dbRun('DELETE FROM bewerbungen WHERE id = ?', [id]);
|
await dbRun('DELETE FROM bewerbungen WHERE id = ? AND user_id = ?', [id, uid()]);
|
||||||
res.json({ success: true });
|
res.json({ success: true });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('API delete application error:', error);
|
console.error('API delete application error:', error);
|
||||||
@@ -231,8 +295,8 @@ function createExternalApi(deps) {
|
|||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
if (!(await getApplication(id))) return res.status(404).json({ error: 'Bewerbung nicht gefunden' });
|
if (!(await getApplication(id))) return res.status(404).json({ error: 'Bewerbung nicht gefunden' });
|
||||||
const verlauf = await dbAll(
|
const verlauf = await dbAll(
|
||||||
'SELECT * FROM status_verlauf WHERE bewerbung_id = ? ORDER BY date(datum) ASC, id ASC',
|
'SELECT * FROM status_verlauf WHERE bewerbung_id = ? AND user_id = ? ORDER BY date(datum) ASC, id ASC',
|
||||||
[id]
|
[id, uid()]
|
||||||
);
|
);
|
||||||
res.json(verlauf);
|
res.json(verlauf);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -253,12 +317,12 @@ function createExternalApi(deps) {
|
|||||||
const day = datum || new Date().toISOString().split('T')[0];
|
const day = datum || new Date().toISOString().split('T')[0];
|
||||||
|
|
||||||
const result = await dbRun(
|
const result = await dbRun(
|
||||||
'INSERT INTO status_verlauf (bewerbung_id, datum, status, kommentar) VALUES (?, ?, ?, ?)',
|
'INSERT INTO status_verlauf (user_id, bewerbung_id, datum, status, kommentar) VALUES (?, ?, ?, ?, ?)',
|
||||||
[id, day, status, sanitizeInput(kommentar || '')]
|
[uid(), id, day, status, sanitizeInput(kommentar || '')]
|
||||||
);
|
);
|
||||||
await syncCurrentStatus(id);
|
await syncCurrentStatus(id);
|
||||||
|
|
||||||
const entry = await dbGet('SELECT * FROM status_verlauf WHERE id = ?', [result.lastID]);
|
const entry = await dbGet('SELECT * FROM status_verlauf WHERE id = ? AND user_id = ?', [result.lastID, uid()]);
|
||||||
res.json(entry);
|
res.json(entry);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('API add timeline error:', error);
|
console.error('API add timeline error:', error);
|
||||||
@@ -270,12 +334,12 @@ function createExternalApi(deps) {
|
|||||||
try {
|
try {
|
||||||
const { id, eintragId } = req.params;
|
const { id, eintragId } = req.params;
|
||||||
const entry = await dbGet(
|
const entry = await dbGet(
|
||||||
'SELECT id FROM status_verlauf WHERE id = ? AND bewerbung_id = ?',
|
'SELECT id FROM status_verlauf WHERE id = ? AND bewerbung_id = ? AND user_id = ?',
|
||||||
[eintragId, id]
|
[eintragId, id, uid()]
|
||||||
);
|
);
|
||||||
if (!entry) return res.status(404).json({ error: 'Verlaufseintrag nicht gefunden' });
|
if (!entry) return res.status(404).json({ error: 'Verlaufseintrag nicht gefunden' });
|
||||||
|
|
||||||
await dbRun('DELETE FROM status_verlauf WHERE id = ?', [eintragId]);
|
await dbRun('DELETE FROM status_verlauf WHERE id = ? AND user_id = ?', [eintragId, uid()]);
|
||||||
await syncCurrentStatus(id);
|
await syncCurrentStatus(id);
|
||||||
res.json({ success: true });
|
res.json({ success: true });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -290,8 +354,8 @@ function createExternalApi(deps) {
|
|||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
if (!(await getApplication(id))) return res.status(404).json({ error: 'Bewerbung nicht gefunden' });
|
if (!(await getApplication(id))) return res.status(404).json({ error: 'Bewerbung nicht gefunden' });
|
||||||
const anhaenge = await dbAll(
|
const anhaenge = await dbAll(
|
||||||
'SELECT id, bewerbung_id, name, dateiname, mime, created_at FROM anhaenge WHERE bewerbung_id = ? ORDER BY id ASC',
|
'SELECT id, bewerbung_id, name, dateiname, mime, created_at FROM anhaenge WHERE bewerbung_id = ? AND user_id = ? ORDER BY id ASC',
|
||||||
[id]
|
[id, uid()]
|
||||||
);
|
);
|
||||||
res.json(anhaenge);
|
res.json(anhaenge);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -304,12 +368,12 @@ function createExternalApi(deps) {
|
|||||||
try {
|
try {
|
||||||
const { id, attachmentId } = req.params;
|
const { id, attachmentId } = req.params;
|
||||||
const anhang = await dbGet(
|
const anhang = await dbGet(
|
||||||
'SELECT * FROM anhaenge WHERE id = ? AND bewerbung_id = ?',
|
'SELECT * FROM anhaenge WHERE id = ? AND bewerbung_id = ? AND user_id = ?',
|
||||||
[attachmentId, id]
|
[attachmentId, id, uid()]
|
||||||
);
|
);
|
||||||
if (!anhang) return res.status(404).json({ error: 'Anhang nicht gefunden' });
|
if (!anhang) return res.status(404).json({ error: 'Anhang nicht gefunden' });
|
||||||
|
|
||||||
const file = path.join(anhaengeDir, anhang.pfad);
|
const file = path.join(userStorageDir(anhaengeDir), anhang.pfad);
|
||||||
if (!fs.existsSync(file)) return res.status(404).json({ error: 'Datei nicht auf Festplatte vorhanden' });
|
if (!fs.existsSync(file)) return res.status(404).json({ error: 'Datei nicht auf Festplatte vorhanden' });
|
||||||
|
|
||||||
res.download(file, anhang.dateiname || anhang.name || path.basename(anhang.pfad));
|
res.download(file, anhang.dateiname || anhang.name || path.basename(anhang.pfad));
|
||||||
@@ -326,14 +390,14 @@ function createExternalApi(deps) {
|
|||||||
if (!(await getApplication(id))) return res.status(404).json({ error: 'Bewerbung nicht gefunden' });
|
if (!(await getApplication(id))) return res.status(404).json({ error: 'Bewerbung nicht gefunden' });
|
||||||
|
|
||||||
const emails = await dbAll(
|
const emails = await dbAll(
|
||||||
'SELECT * FROM emails WHERE bewerbung_id = ? ORDER BY datetime(email_date) ASC, id ASC',
|
'SELECT * FROM emails WHERE bewerbung_id = ? AND user_id = ? ORDER BY datetime(email_date) ASC, id ASC',
|
||||||
[id]
|
[id, uid()]
|
||||||
);
|
);
|
||||||
if (emails.length) {
|
if (emails.length) {
|
||||||
const eIds = emails.map((e) => e.id);
|
const eIds = emails.map((e) => e.id);
|
||||||
const atts = await dbAll(
|
const atts = await dbAll(
|
||||||
`SELECT id, email_id, name, mime FROM email_anhaenge WHERE email_id IN (${eIds.map(() => '?').join(',')})`,
|
`SELECT id, email_id, name, mime FROM email_anhaenge WHERE user_id = ? AND email_id IN (${eIds.map(() => '?').join(',')})`,
|
||||||
eIds
|
[uid(), ...eIds]
|
||||||
);
|
);
|
||||||
const byEmail = {};
|
const byEmail = {};
|
||||||
atts.forEach((a) => { (byEmail[a.email_id] = byEmail[a.email_id] || []).push(a); });
|
atts.forEach((a) => { (byEmail[a.email_id] = byEmail[a.email_id] || []).push(a); });
|
||||||
@@ -350,12 +414,12 @@ function createExternalApi(deps) {
|
|||||||
try {
|
try {
|
||||||
const { emailId, attachmentId } = req.params;
|
const { emailId, attachmentId } = req.params;
|
||||||
const anhang = await dbGet(
|
const anhang = await dbGet(
|
||||||
'SELECT * FROM email_anhaenge WHERE id = ? AND email_id = ?',
|
'SELECT * FROM email_anhaenge WHERE id = ? AND email_id = ? AND user_id = ?',
|
||||||
[attachmentId, emailId]
|
[attachmentId, emailId, uid()]
|
||||||
);
|
);
|
||||||
if (!anhang) return res.status(404).json({ error: 'Anhang nicht gefunden' });
|
if (!anhang) return res.status(404).json({ error: 'Anhang nicht gefunden' });
|
||||||
|
|
||||||
const file = path.join(emailAnhaengeDir, anhang.pfad);
|
const file = path.join(userStorageDir(emailAnhaengeDir), anhang.pfad);
|
||||||
if (!fs.existsSync(file)) return res.status(404).json({ error: 'Datei nicht auf Festplatte vorhanden' });
|
if (!fs.existsSync(file)) return res.status(404).json({ error: 'Datei nicht auf Festplatte vorhanden' });
|
||||||
|
|
||||||
res.download(file, anhang.name || path.basename(anhang.pfad));
|
res.download(file, anhang.name || path.basename(anhang.pfad));
|
||||||
@@ -373,27 +437,36 @@ function createExternalApi(deps) {
|
|||||||
if (!bewerbung) return res.status(404).json({ error: 'Bewerbung nicht gefunden' });
|
if (!bewerbung) return res.status(404).json({ error: 'Bewerbung nicht gefunden' });
|
||||||
|
|
||||||
if (typeof (req.body || {}).llm_notizen !== 'undefined') {
|
if (typeof (req.body || {}).llm_notizen !== 'undefined') {
|
||||||
await dbRun('UPDATE bewerbungen SET llm_notizen = ? WHERE id = ?', [req.body.llm_notizen || '', id]);
|
await dbRun('UPDATE bewerbungen SET llm_notizen = ? WHERE id = ? AND user_id = ?', [req.body.llm_notizen || '', id, uid()]);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Drop existing generated attachments + their files before re-generating.
|
// Drop existing generated attachments + their files before re-generating.
|
||||||
const alte = await dbAll('SELECT * FROM anhaenge WHERE bewerbung_id = ?', [id]);
|
const alte = await dbAll('SELECT * FROM anhaenge WHERE bewerbung_id = ? AND user_id = ?', [id, uid()]);
|
||||||
|
const dir = userStorageDir(anhaengeDir);
|
||||||
for (const a of alte) {
|
for (const a of alte) {
|
||||||
fs.promises.unlink(path.join(anhaengeDir, a.pfad)).catch(() => {});
|
fs.promises.unlink(path.join(dir, a.pfad)).catch(() => {});
|
||||||
}
|
}
|
||||||
await dbRun('DELETE FROM anhaenge WHERE bewerbung_id = ?', [id]);
|
await dbRun('DELETE FROM anhaenge WHERE bewerbung_id = ? AND user_id = ?', [id, uid()]);
|
||||||
await dbRun(
|
await dbRun(
|
||||||
"UPDATE bewerbungen SET generierung_status = 'ausstehend', generierung_fehler = NULL WHERE id = ?",
|
"UPDATE bewerbungen SET generierung_status = 'ausstehend', generierung_fehler = NULL WHERE id = ? AND user_id = ?",
|
||||||
[id]
|
[id, uid()]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Optional: IDs of static attachments (basis_anhaenge) to enclose; none
|
// Optional: IDs of static attachments (basis_anhaenge) to enclose. Omitted
|
||||||
// by default. Also reflected in the cover letter's "Anlagen" list.
|
// means the user's preselection from the settings (GEN_ANLAGEN_DEFAULT,
|
||||||
|
// default: none); an empty array means explicitly none. Also reflected in
|
||||||
|
// the cover letter's "Anlagen" list.
|
||||||
const anlagenIds = Array.isArray((req.body || {}).anlagen)
|
const anlagenIds = Array.isArray((req.body || {}).anlagen)
|
||||||
? req.body.anlagen.map((v) => parseInt(v, 10)).filter((n) => !Number.isNaN(n))
|
? req.body.anlagen.map((v) => parseInt(v, 10)).filter((n) => !Number.isNaN(n))
|
||||||
: [];
|
: config.anlagenDefaultIds();
|
||||||
runGeneration(id, { anlagenIds });
|
// Optional: which documents to produce (["anschreiben"], ["lebenslauf"] or
|
||||||
res.status(202).json({ success: true });
|
// both). Omitted / empty falls back to the user's preselection from the
|
||||||
|
// settings (GEN_DOKUMENTE_DEFAULT, default: both).
|
||||||
|
const gewuenscht = (req.body || {}).dokumente;
|
||||||
|
const dokumente = gewuenscht == null ? standardDokumente() : normalizeDokumente(gewuenscht);
|
||||||
|
await dbRun('UPDATE bewerbungen SET generierung_dokumente = ? WHERE id = ? AND user_id = ?', [dokumente.join(','), id, uid()]);
|
||||||
|
runGeneration(id, { anlagenIds, dokumente });
|
||||||
|
res.status(202).json({ success: true, dokumente });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('API generate error:', error);
|
console.error('API generate error:', error);
|
||||||
res.status(500).json({ error: 'Serverfehler' });
|
res.status(500).json({ error: 'Serverfehler' });
|
||||||
@@ -404,14 +477,14 @@ function createExternalApi(deps) {
|
|||||||
try {
|
try {
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
const bewerbung = await dbGet(
|
const bewerbung = await dbGet(
|
||||||
'SELECT id, generierung_status, generierung_fehler FROM bewerbungen WHERE id = ?',
|
'SELECT id, generierung_status, generierung_fehler FROM bewerbungen WHERE id = ? AND user_id = ?',
|
||||||
[id]
|
[id, uid()]
|
||||||
);
|
);
|
||||||
if (!bewerbung) return res.status(404).json({ error: 'Bewerbung nicht gefunden' });
|
if (!bewerbung) return res.status(404).json({ error: 'Bewerbung nicht gefunden' });
|
||||||
|
|
||||||
const anhaenge = await dbAll(
|
const anhaenge = await dbAll(
|
||||||
'SELECT id, bewerbung_id, name, dateiname, mime, created_at FROM anhaenge WHERE bewerbung_id = ? ORDER BY id ASC',
|
'SELECT id, bewerbung_id, name, dateiname, mime, created_at FROM anhaenge WHERE bewerbung_id = ? AND user_id = ? ORDER BY id ASC',
|
||||||
[id]
|
[id, uid()]
|
||||||
);
|
);
|
||||||
res.json({ status: bewerbung.generierung_status, fehler: bewerbung.generierung_fehler, anhaenge });
|
res.json({ status: bewerbung.generierung_status, fehler: bewerbung.generierung_fehler, anhaenge });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -423,22 +496,40 @@ function createExternalApi(deps) {
|
|||||||
// --- Settings ------------------------------------------------------
|
// --- Settings ------------------------------------------------------
|
||||||
router.get('/settings', async (req, res) => {
|
router.get('/settings', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const settings = await dbGet('SELECT * FROM settings WHERE id = 1');
|
// A user who never saved their personal details has no settings row; that
|
||||||
res.json(settings);
|
// is a valid state, so answer with an empty object instead of no body.
|
||||||
|
const settings = await dbGet('SELECT * FROM settings WHERE user_id = ?', [uid()]);
|
||||||
|
res.json(settings || {});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('API get settings error:', error);
|
console.error('API get settings error:', error);
|
||||||
res.status(500).json({ error: 'Serverfehler' });
|
res.status(500).json({ error: 'Serverfehler' });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Personal details, same set of fields the web UI writes (Persönliche Angaben).
|
||||||
|
// Only the keys present in the body are changed; omitted keys keep their value,
|
||||||
|
// so a client can patch a single field without wiping the rest.
|
||||||
|
const SETTINGS_FIELDS = ['name', 'adresse', 'kundennummer', 'email', 'telefon', 'ort', 'webseite', 'geburtsdatum'];
|
||||||
|
|
||||||
router.put('/settings', async (req, res) => {
|
router.put('/settings', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { name, adresse, kundennummer } = req.body || {};
|
const b = req.body || {};
|
||||||
|
const keys = SETTINGS_FIELDS.filter((k) => typeof b[k] !== 'undefined');
|
||||||
|
if (!keys.length) {
|
||||||
|
return res.status(400).json({ error: `Mindestens eines dieser Felder erforderlich: ${SETTINGS_FIELDS.join(', ')}` });
|
||||||
|
}
|
||||||
|
// Upsert, not UPDATE: a user without a settings row would otherwise match
|
||||||
|
// zero rows and the write would be silently dropped.
|
||||||
|
const werte = keys.map((k) => sanitizeInput(String(b[k] ?? '')));
|
||||||
await dbRun(
|
await dbRun(
|
||||||
'UPDATE settings SET name = ?, adresse = ?, kundennummer = ? WHERE id = 1',
|
`INSERT INTO settings (user_id, ${keys.join(', ')})
|
||||||
[sanitizeInput(name), sanitizeInput(adresse), sanitizeInput(kundennummer)]
|
VALUES (?, ${keys.map(() => '?').join(', ')})
|
||||||
|
ON CONFLICT(user_id) DO UPDATE SET
|
||||||
|
${keys.map((k) => `${k} = excluded.${k}`).join(', ')}`,
|
||||||
|
[uid(), ...werte]
|
||||||
);
|
);
|
||||||
res.json({ success: true });
|
const row = await dbGet('SELECT * FROM settings WHERE user_id = ?', [uid()]);
|
||||||
|
res.json({ success: true, settings: row || {} });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('API save settings error:', error);
|
console.error('API save settings error:', error);
|
||||||
res.status(500).json({ error: 'Serverfehler' });
|
res.status(500).json({ error: 'Serverfehler' });
|
||||||
@@ -448,17 +539,19 @@ function createExternalApi(deps) {
|
|||||||
// --- Statistics & export -------------------------------------------
|
// --- Statistics & export -------------------------------------------
|
||||||
router.get('/statistics', async (req, res) => {
|
router.get('/statistics', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const totalCount = await dbGet('SELECT COUNT(*) as count FROM bewerbungen');
|
const totalCount = await dbGet('SELECT COUNT(*) as count FROM bewerbungen WHERE user_id = ?', [uid()]);
|
||||||
const byArt = await dbAll(`
|
const byArt = await dbAll(
|
||||||
SELECT art, COUNT(*) as count FROM bewerbungen
|
`SELECT art, COUNT(*) as count FROM bewerbungen
|
||||||
WHERE art IS NOT NULL AND art != ''
|
WHERE user_id = ? AND art IS NOT NULL AND art != ''
|
||||||
GROUP BY art ORDER BY count DESC
|
GROUP BY art ORDER BY count DESC`,
|
||||||
`);
|
[uid()]
|
||||||
const byStatus = await dbAll(`
|
);
|
||||||
SELECT status, COUNT(*) as count FROM bewerbungen
|
const byStatus = await dbAll(
|
||||||
WHERE status IS NOT NULL AND status != ''
|
`SELECT status, COUNT(*) as count FROM bewerbungen
|
||||||
GROUP BY status ORDER BY count DESC
|
WHERE user_id = ? AND status IS NOT NULL AND status != ''
|
||||||
`);
|
GROUP BY status ORDER BY count DESC`,
|
||||||
|
[uid()]
|
||||||
|
);
|
||||||
res.json({
|
res.json({
|
||||||
total: totalCount ? totalCount.count : 0,
|
total: totalCount ? totalCount.count : 0,
|
||||||
byArt,
|
byArt,
|
||||||
@@ -473,16 +566,16 @@ function createExternalApi(deps) {
|
|||||||
router.get('/export', async (req, res) => {
|
router.get('/export', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { month, year } = req.query;
|
const { month, year } = req.query;
|
||||||
let query = 'SELECT * FROM bewerbungen ORDER BY datum DESC';
|
let query = 'SELECT * FROM bewerbungen WHERE user_id = ? ORDER BY datum DESC';
|
||||||
const params = [];
|
const params = [uid()];
|
||||||
if (month && year) {
|
if (month && year) {
|
||||||
query = 'SELECT * FROM bewerbungen WHERE strftime("%m", datum) = ? AND strftime("%Y", datum) = ? ORDER BY datum DESC';
|
query = 'SELECT * FROM bewerbungen WHERE user_id = ? AND strftime("%m", datum) = ? AND strftime("%Y", datum) = ? ORDER BY datum DESC';
|
||||||
params.push(String(month).padStart(2, '0'), String(year));
|
params.push(String(month).padStart(2, '0'), String(year));
|
||||||
} else if (month) {
|
} else if (month) {
|
||||||
query = 'SELECT * FROM bewerbungen WHERE strftime("%m", datum) = ? ORDER BY datum DESC';
|
query = 'SELECT * FROM bewerbungen WHERE user_id = ? AND strftime("%m", datum) = ? ORDER BY datum DESC';
|
||||||
params.push(String(month).padStart(2, '0'));
|
params.push(String(month).padStart(2, '0'));
|
||||||
} else if (year) {
|
} else if (year) {
|
||||||
query = 'SELECT * FROM bewerbungen WHERE strftime("%Y", datum) = ? ORDER BY datum DESC';
|
query = 'SELECT * FROM bewerbungen WHERE user_id = ? AND strftime("%Y", datum) = ? ORDER BY datum DESC';
|
||||||
params.push(String(year));
|
params.push(String(year));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -500,7 +593,7 @@ function createExternalApi(deps) {
|
|||||||
// --- Templates -----------------------------------------------------
|
// --- Templates -----------------------------------------------------
|
||||||
router.get('/templates', async (req, res) => {
|
router.get('/templates', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const docs = await dbAll('SELECT * FROM basis_dokumente ORDER BY id ASC');
|
const docs = await dbAll('SELECT * FROM basis_dokumente WHERE user_id = ? ORDER BY id ASC', [uid()]);
|
||||||
res.json(docs);
|
res.json(docs);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('API list templates error:', error);
|
console.error('API list templates error:', error);
|
||||||
@@ -509,13 +602,16 @@ function createExternalApi(deps) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// --- Job offers (ingested by third-party software) -----------------
|
// --- Job offers (ingested by third-party software) -----------------
|
||||||
// POST upserts by (quelle, external_id): re-sending the same offer updates
|
// POST upserts by (user_id, quelle, external_id): re-sending the same offer
|
||||||
// it instead of creating a duplicate. `quelle` defaults to "drittanbieter".
|
// updates it instead of creating a duplicate. `quelle` defaults to
|
||||||
|
// "drittanbieter".
|
||||||
router.get('/joboffers', async (req, res) => {
|
router.get('/joboffers', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const rows = await dbAll(
|
const rows = await dbAll(
|
||||||
`SELECT * FROM jobangebote ORDER BY created_at DESC, id DESC`
|
'SELECT * FROM jobangebote WHERE user_id = ? ORDER BY created_at DESC, id DESC',
|
||||||
|
[uid()]
|
||||||
);
|
);
|
||||||
|
rows.forEach(withLabels);
|
||||||
res.json(rows);
|
res.json(rows);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('API list job offers error:', error);
|
console.error('API list job offers error:', error);
|
||||||
@@ -529,7 +625,8 @@ function createExternalApi(deps) {
|
|||||||
router.get('/joboffers/blacklist', async (req, res) => {
|
router.get('/joboffers/blacklist', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const rows = await dbAll(
|
const rows = await dbAll(
|
||||||
'SELECT * FROM jobangebote_blacklist ORDER BY created_at DESC, id DESC'
|
'SELECT * FROM jobangebote_blacklist WHERE user_id = ? ORDER BY created_at DESC, id DESC',
|
||||||
|
[uid()]
|
||||||
);
|
);
|
||||||
res.json(rows);
|
res.json(rows);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -556,10 +653,10 @@ function createExternalApi(deps) {
|
|||||||
}
|
}
|
||||||
const cols = blacklist.COLUMNS;
|
const cols = blacklist.COLUMNS;
|
||||||
const result = await dbRun(
|
const result = await dbRun(
|
||||||
`INSERT INTO jobangebote_blacklist (${cols.join(', ')}) VALUES (${cols.map(() => '?').join(', ')})`,
|
`INSERT INTO jobangebote_blacklist (user_id, ${cols.join(', ')}) VALUES (?, ${cols.map(() => '?').join(', ')})`,
|
||||||
cols.map((c) => (entry[c] === undefined ? null : entry[c]))
|
[uid(), ...cols.map((c) => (entry[c] === undefined ? null : entry[c]))]
|
||||||
);
|
);
|
||||||
const row = await dbGet('SELECT * FROM jobangebote_blacklist WHERE id = ?', [result.lastID]);
|
const row = await dbGet('SELECT * FROM jobangebote_blacklist WHERE id = ? AND user_id = ?', [result.lastID, uid()]);
|
||||||
res.status(201).json({ success: true, entry: row });
|
res.status(201).json({ success: true, entry: row });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('API create blacklist entry error:', error);
|
console.error('API create blacklist entry error:', error);
|
||||||
@@ -569,9 +666,9 @@ function createExternalApi(deps) {
|
|||||||
|
|
||||||
router.delete('/joboffers/blacklist/:id', async (req, res) => {
|
router.delete('/joboffers/blacklist/:id', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const row = await dbGet('SELECT id FROM jobangebote_blacklist WHERE id = ?', [req.params.id]);
|
const row = await dbGet('SELECT id FROM jobangebote_blacklist WHERE id = ? AND user_id = ?', [req.params.id, uid()]);
|
||||||
if (!row) return res.status(404).json({ error: 'Blacklist-Eintrag nicht gefunden' });
|
if (!row) return res.status(404).json({ error: 'Blacklist-Eintrag nicht gefunden' });
|
||||||
await dbRun('DELETE FROM jobangebote_blacklist WHERE id = ?', [req.params.id]);
|
await dbRun('DELETE FROM jobangebote_blacklist WHERE id = ? AND user_id = ?', [req.params.id, uid()]);
|
||||||
res.json({ success: true });
|
res.json({ success: true });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('API delete blacklist entry error:', error);
|
console.error('API delete blacklist entry error:', error);
|
||||||
@@ -581,9 +678,9 @@ function createExternalApi(deps) {
|
|||||||
|
|
||||||
router.get('/joboffers/:id', async (req, res) => {
|
router.get('/joboffers/:id', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const row = await dbGet('SELECT * FROM jobangebote WHERE id = ?', [req.params.id]);
|
const row = await dbGet('SELECT * FROM jobangebote WHERE id = ? AND user_id = ?', [req.params.id, uid()]);
|
||||||
if (!row) return res.status(404).json({ error: 'Jobangebot nicht gefunden' });
|
if (!row) return res.status(404).json({ error: 'Jobangebot nicht gefunden' });
|
||||||
res.json(row);
|
res.json(withLabels(row));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('API get job offer error:', error);
|
console.error('API get job offer error:', error);
|
||||||
res.status(500).json({ error: 'Serverfehler' });
|
res.status(500).json({ error: 'Serverfehler' });
|
||||||
@@ -598,7 +695,7 @@ function createExternalApi(deps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Reject blacklisted offers outright — they must never reappear.
|
// Reject blacklisted offers outright — they must never reappear.
|
||||||
const blacklistRows = await dbAll('SELECT * FROM jobangebote_blacklist');
|
const blacklistRows = await dbAll('SELECT * FROM jobangebote_blacklist WHERE user_id = ?', [uid()]);
|
||||||
const blocked = blacklist.matchBlacklist(blacklistRows, b);
|
const blocked = blacklist.matchBlacklist(blacklistRows, b);
|
||||||
if (blocked) {
|
if (blocked) {
|
||||||
return res.status(409).json({
|
return res.status(409).json({
|
||||||
@@ -614,7 +711,12 @@ function createExternalApi(deps) {
|
|||||||
const urlNorm = blacklist.normalizeUrl(b.quelle_url || '') || null;
|
const urlNorm = blacklist.normalizeUrl(b.quelle_url || '') || null;
|
||||||
const anzeigeDatum = sanitizeInput(b.anzeige_datum || '');
|
const anzeigeDatum = sanitizeInput(b.anzeige_datum || '');
|
||||||
const kontaktEmail = sanitizeInput(b.kontakt_email || '');
|
const kontaktEmail = sanitizeInput(b.kontakt_email || '');
|
||||||
// Column order matches the UPDATE/INSERT statements below (url_norm last).
|
// Company slug: stored as supplied by the client; derived from firma only
|
||||||
|
// when omitted. This is the key the blacklist matches on (see below).
|
||||||
|
const firmaSlug = b.firma_slug != null && String(b.firma_slug).trim() !== ''
|
||||||
|
? sanitizeInput(String(b.firma_slug))
|
||||||
|
: (blacklist.firmaSlug(b.firma) || null);
|
||||||
|
// Column order matches the UPDATE/INSERT statements below (firma_slug last).
|
||||||
const fields = [
|
const fields = [
|
||||||
sanitizeInput(b.firma),
|
sanitizeInput(b.firma),
|
||||||
sanitizeInput(b.stelle),
|
sanitizeInput(b.stelle),
|
||||||
@@ -628,22 +730,25 @@ function createExternalApi(deps) {
|
|||||||
anzeigeDatum,
|
anzeigeDatum,
|
||||||
kontaktEmail,
|
kontaktEmail,
|
||||||
sanitizeInput(b.status || 'offen'),
|
sanitizeInput(b.status || 'offen'),
|
||||||
|
serializeLabels(b.labels),
|
||||||
urlNorm,
|
urlNorm,
|
||||||
|
firmaSlug,
|
||||||
];
|
];
|
||||||
|
|
||||||
// De-dup: prefer a (quelle, external_id) match, else the same normalized
|
// De-dup: prefer a (user_id, quelle, external_id) match, else the same
|
||||||
// URL — so the same posting never lands twice, even with a new id.
|
// normalized URL — so the same posting never lands twice, even with a
|
||||||
|
// new id.
|
||||||
let existing = null;
|
let existing = null;
|
||||||
if (externalId) {
|
if (externalId) {
|
||||||
existing = await dbGet(
|
existing = await dbGet(
|
||||||
'SELECT id FROM jobangebote WHERE quelle = ? AND external_id = ?',
|
'SELECT id FROM jobangebote WHERE user_id = ? AND quelle = ? AND external_id = ?',
|
||||||
[quelle, externalId]
|
[uid(), quelle, externalId]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (!existing && urlNorm) {
|
if (!existing && urlNorm) {
|
||||||
existing = await dbGet(
|
existing = await dbGet(
|
||||||
'SELECT id FROM jobangebote WHERE url_norm = ? ORDER BY id ASC LIMIT 1',
|
'SELECT id FROM jobangebote WHERE user_id = ? AND url_norm = ? ORDER BY id ASC LIMIT 1',
|
||||||
[urlNorm]
|
[uid(), urlNorm]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -651,21 +756,21 @@ function createExternalApi(deps) {
|
|||||||
await dbRun(
|
await dbRun(
|
||||||
`UPDATE jobangebote SET firma = ?, stelle = ?, ort = ?, adresse = ?, ansprechpartner = ?, gehalt = ?,
|
`UPDATE jobangebote SET firma = ?, stelle = ?, ort = ?, adresse = ?, ansprechpartner = ?, gehalt = ?,
|
||||||
beschreibung = ?, quelle_url = ?, art = ?, anzeige_datum = ?, kontakt_email = ?, status = ?,
|
beschreibung = ?, quelle_url = ?, art = ?, anzeige_datum = ?, kontakt_email = ?, status = ?,
|
||||||
url_norm = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?`,
|
labels = ?, url_norm = ?, firma_slug = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ? AND user_id = ?`,
|
||||||
[...fields, existing.id]
|
[...fields, existing.id, uid()]
|
||||||
);
|
);
|
||||||
const row = await dbGet('SELECT * FROM jobangebote WHERE id = ?', [existing.id]);
|
const row = await dbGet('SELECT * FROM jobangebote WHERE id = ? AND user_id = ?', [existing.id, uid()]);
|
||||||
return res.json({ success: true, action: 'updated', joboffer: row });
|
return res.json({ success: true, action: 'updated', joboffer: withLabels(row) });
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await dbRun(
|
const result = await dbRun(
|
||||||
`INSERT INTO jobangebote
|
`INSERT INTO jobangebote
|
||||||
(external_id, quelle, firma, stelle, ort, adresse, ansprechpartner, gehalt, beschreibung, quelle_url, art, anzeige_datum, kontakt_email, status, url_norm)
|
(user_id, external_id, quelle, firma, stelle, ort, adresse, ansprechpartner, gehalt, beschreibung, quelle_url, art, anzeige_datum, kontakt_email, status, labels, url_norm, firma_slug)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
[externalId, quelle, ...fields]
|
[uid(), externalId, quelle, ...fields]
|
||||||
);
|
);
|
||||||
const row = await dbGet('SELECT * FROM jobangebote WHERE id = ?', [result.lastID]);
|
const row = await dbGet('SELECT * FROM jobangebote WHERE id = ? AND user_id = ?', [result.lastID, uid()]);
|
||||||
res.status(201).json({ success: true, action: 'created', joboffer: row });
|
res.status(201).json({ success: true, action: 'created', joboffer: withLabels(row) });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('API create job offer error:', error);
|
console.error('API create job offer error:', error);
|
||||||
res.status(500).json({ error: 'Serverfehler' });
|
res.status(500).json({ error: 'Serverfehler' });
|
||||||
@@ -676,24 +781,24 @@ function createExternalApi(deps) {
|
|||||||
// be ingested again; pass ?blacklist=false to hard-delete without blocking.
|
// be ingested again; pass ?blacklist=false to hard-delete without blocking.
|
||||||
router.delete('/joboffers/:id', async (req, res) => {
|
router.delete('/joboffers/:id', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const row = await dbGet('SELECT * FROM jobangebote WHERE id = ?', [req.params.id]);
|
const row = await dbGet('SELECT * FROM jobangebote WHERE id = ? AND user_id = ?', [req.params.id, uid()]);
|
||||||
if (!row) return res.status(404).json({ error: 'Jobangebot nicht gefunden' });
|
if (!row) return res.status(404).json({ error: 'Jobangebot nicht gefunden' });
|
||||||
|
|
||||||
const skipBlacklist = req.query.blacklist === 'false' || req.query.blacklist === '0';
|
const skipBlacklist = req.query.blacklist === 'false' || req.query.blacklist === '0';
|
||||||
let blacklisted = false;
|
let blacklisted = false;
|
||||||
if (!skipBlacklist) {
|
if (!skipBlacklist) {
|
||||||
const rows = await dbAll('SELECT * FROM jobangebote_blacklist');
|
const rows = await dbAll('SELECT * FROM jobangebote_blacklist WHERE user_id = ?', [uid()]);
|
||||||
if (!blacklist.matchBlacklist(rows, row)) {
|
if (!blacklist.matchBlacklist(rows, row)) {
|
||||||
const cols = blacklist.COLUMNS;
|
const cols = blacklist.COLUMNS;
|
||||||
const entry = blacklist.buildAutoEntry(row, 'Jobangebot gelöscht (REST-API)');
|
const entry = blacklist.buildAutoEntry(row, 'Jobangebot gelöscht (REST-API)');
|
||||||
await dbRun(
|
await dbRun(
|
||||||
`INSERT INTO jobangebote_blacklist (${cols.join(', ')}) VALUES (${cols.map(() => '?').join(', ')})`,
|
`INSERT INTO jobangebote_blacklist (user_id, ${cols.join(', ')}) VALUES (?, ${cols.map(() => '?').join(', ')})`,
|
||||||
cols.map((c) => (entry[c] === undefined ? null : entry[c]))
|
[uid(), ...cols.map((c) => (entry[c] === undefined ? null : entry[c]))]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
blacklisted = true;
|
blacklisted = true;
|
||||||
}
|
}
|
||||||
await dbRun('DELETE FROM jobangebote WHERE id = ?', [req.params.id]);
|
await dbRun('DELETE FROM jobangebote WHERE id = ? AND user_id = ?', [req.params.id, uid()]);
|
||||||
res.json({ success: true, blacklisted });
|
res.json({ success: true, blacklisted });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('API delete job offer error:', error);
|
console.error('API delete job offer error:', error);
|
||||||
|
|||||||
+81
-8
@@ -8,9 +8,10 @@
|
|||||||
// relevant to its typ are set:
|
// relevant to its typ are set:
|
||||||
// 'url' -> url_norm (exact normalized URL)
|
// 'url' -> url_norm (exact normalized URL)
|
||||||
// 'domain' -> domain (whole host, e.g. a job board)
|
// 'domain' -> domain (whole host, e.g. a job board)
|
||||||
// 'firma' -> firma_norm (block a company entirely)
|
// 'firma' -> firma_slug (block a company entirely; slug is a
|
||||||
// 'firma_stelle' -> firma_norm + stelle_norm (+ optional ort_norm)
|
// legal-form/umlaut-robust key, falls back to firma_norm)
|
||||||
// 'auto' -> url_norm / quelle+external_id / firma_norm+stelle_norm
|
// 'firma_stelle' -> firma_slug + stelle_norm (+ optional ort_norm)
|
||||||
|
// 'auto' -> url_norm / quelle+external_id / firma_slug+stelle_norm
|
||||||
// (created on delete; matches on ANY stored signature)
|
// (created on delete; matches on ANY stored signature)
|
||||||
|
|
||||||
const TYPES = ['url', 'domain', 'firma', 'firma_stelle', 'auto'];
|
const TYPES = ['url', 'domain', 'firma', 'firma_stelle', 'auto'];
|
||||||
@@ -72,6 +73,61 @@ function normalizeUrl(raw) {
|
|||||||
return host + pathname + qs;
|
return host + pathname + qs;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Legal-form / entity suffixes stripped from a company name before slugging,
|
||||||
|
// so "Bosch GmbH", "Bosch AG", "Bosch GmbH & Co. KG" and "Bosch" all collapse
|
||||||
|
// to the same slug. Matched as whole space-separated tokens at the end.
|
||||||
|
const LEGAL_FORM_TOKENS = new Set([
|
||||||
|
'gmbh', 'ggmbh', 'mbh', 'ug', 'haftungsbeschraenkt', 'ag', 'kg', 'kgaa',
|
||||||
|
'ohg', 'gbr', 'se', 'ek', 'eg', 'ev', 'partg', 'partmbb', 'co', 'cie',
|
||||||
|
'inc', 'incorporated', 'llc', 'ltd', 'limited', 'plc', 'corp', 'corporation',
|
||||||
|
'company', 'sa', 'sarl', 'sas', 'bv', 'nv', 'oy', 'ab', 'as', 'aps', 'srl', 'spa',
|
||||||
|
]);
|
||||||
|
|
||||||
|
// A robust matching key for a company name. Beyond normText it transliterates
|
||||||
|
// German umlauts (ä→ae …), strips diacritics, drops trailing legal-form tokens
|
||||||
|
// and joins the rest with hyphens. This is what the blacklist compares on so
|
||||||
|
// that entity form, spacing and umlaut spelling no longer cause misses.
|
||||||
|
function firmaSlug(s) {
|
||||||
|
let t = unescapeInput(s)
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/ä/g, 'ae').replace(/ö/g, 'oe').replace(/ü/g, 'ue').replace(/ß/g, 'ss')
|
||||||
|
.normalize("NFKD").replace(/[\u0300-\u036f]/g, "")
|
||||||
|
.replace(/\((?:[mwdfax](?:\s*\/\s*[mwdfax])*)\)/gi, ' ')
|
||||||
|
.replace(/[^a-z0-9]+/g, ' ')
|
||||||
|
.replace(/\s+/g, ' ')
|
||||||
|
.trim();
|
||||||
|
if (!t) return '';
|
||||||
|
const words = t.split(' ');
|
||||||
|
const kept = words.slice();
|
||||||
|
// Drop legal-form tokens from the end (e.g. "gmbh & co kg" → "gmbh co kg"),
|
||||||
|
// but never strip everything away.
|
||||||
|
while (kept.length > 1 && LEGAL_FORM_TOKENS.has(kept[kept.length - 1])) {
|
||||||
|
kept.pop();
|
||||||
|
}
|
||||||
|
return (kept.length ? kept : words).join('-');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Do two company slugs refer to the SAME employer? True when they are equal, or
|
||||||
|
// when the shorter one's hyphen tokens are a leading prefix of the longer one's.
|
||||||
|
// The prefix rule collapses "short name" vs "full legal name" spellings of one
|
||||||
|
// firm — e.g. "it-problemloeser" vs "it-problemloeser-verwaltungs-und-handels",
|
||||||
|
// or "stadtwerke-berlin" vs "stadtwerke-berlin-netz". To avoid merging distinct
|
||||||
|
// firms that merely share a first word ("meyer-it" vs "meyer-logistik"), a pure
|
||||||
|
// prefix match requires the shorter slug to carry at least two tokens; a
|
||||||
|
// single-token slug only matches an identical one.
|
||||||
|
function sameCompany(a, b) {
|
||||||
|
if (!a || !b) return false;
|
||||||
|
if (a === b) return true;
|
||||||
|
const ta = String(a).split('-').filter(Boolean);
|
||||||
|
const tb = String(b).split('-').filter(Boolean);
|
||||||
|
const [short, long] = ta.length <= tb.length ? [ta, tb] : [tb, ta];
|
||||||
|
if (short.length < 2) return false; // single distinctive token → exact only
|
||||||
|
for (let i = 0; i < short.length; i++) {
|
||||||
|
if (short[i] !== long[i]) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
// Normalise a company / job-title string for fuzzy identity matching:
|
// Normalise a company / job-title string for fuzzy identity matching:
|
||||||
// unescape, lowercase, drop gender markers like "(m/w/d)", collapse anything
|
// unescape, lowercase, drop gender markers like "(m/w/d)", collapse anything
|
||||||
// non-alphanumeric to single spaces.
|
// non-alphanumeric to single spaces.
|
||||||
@@ -95,11 +151,23 @@ function offerSignature(offer) {
|
|||||||
external_id: o.external_id != null && String(o.external_id).trim() !== ''
|
external_id: o.external_id != null && String(o.external_id).trim() !== ''
|
||||||
? String(o.external_id).trim() : null,
|
? String(o.external_id).trim() : null,
|
||||||
firma_norm: normText(o.firma),
|
firma_norm: normText(o.firma),
|
||||||
|
// Prefer a client-supplied slug (offers carry firma_slug); else derive it.
|
||||||
|
firma_slug: o.firma_slug != null && String(o.firma_slug).trim() !== ''
|
||||||
|
? String(o.firma_slug).trim() : firmaSlug(o.firma),
|
||||||
stelle_norm: normText(o.stelle),
|
stelle_norm: normText(o.stelle),
|
||||||
ort_norm: normText(o.ort),
|
ort_norm: normText(o.ort),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Company identity match: prefer the robust slug (equal or prefix — see
|
||||||
|
// sameCompany, which absorbs spelling / legal-form / umlaut variants of one
|
||||||
|
// employer), fall back to firma_norm for legacy rows written before firma_slug
|
||||||
|
// existed (or not yet backfilled).
|
||||||
|
function firmaMatch(row, sig) {
|
||||||
|
if (row.firma_slug && sig.firma_slug) return sameCompany(row.firma_slug, sig.firma_slug);
|
||||||
|
return !!row.firma_norm && row.firma_norm === sig.firma_norm;
|
||||||
|
}
|
||||||
|
|
||||||
// Does one blacklist row block the given signature?
|
// Does one blacklist row block the given signature?
|
||||||
function rowMatches(row, sig) {
|
function rowMatches(row, sig) {
|
||||||
switch (row.typ) {
|
switch (row.typ) {
|
||||||
@@ -108,9 +176,9 @@ function rowMatches(row, sig) {
|
|||||||
case 'domain':
|
case 'domain':
|
||||||
return !!row.domain && row.domain === sig.domain;
|
return !!row.domain && row.domain === sig.domain;
|
||||||
case 'firma':
|
case 'firma':
|
||||||
return !!row.firma_norm && row.firma_norm === sig.firma_norm;
|
return firmaMatch(row, sig);
|
||||||
case 'firma_stelle':
|
case 'firma_stelle':
|
||||||
return !!row.firma_norm && row.firma_norm === sig.firma_norm
|
return firmaMatch(row, sig)
|
||||||
&& !!row.stelle_norm && row.stelle_norm === sig.stelle_norm
|
&& !!row.stelle_norm && row.stelle_norm === sig.stelle_norm
|
||||||
&& (!row.ort_norm || row.ort_norm === sig.ort_norm);
|
&& (!row.ort_norm || row.ort_norm === sig.ort_norm);
|
||||||
case 'auto':
|
case 'auto':
|
||||||
@@ -118,8 +186,8 @@ function rowMatches(row, sig) {
|
|||||||
if (row.url_norm && sig.url_norm && row.url_norm === sig.url_norm) return true;
|
if (row.url_norm && sig.url_norm && row.url_norm === sig.url_norm) return true;
|
||||||
if (row.quelle && row.external_id && sig.external_id
|
if (row.quelle && row.external_id && sig.external_id
|
||||||
&& row.quelle === sig.quelle && row.external_id === sig.external_id) return true;
|
&& row.quelle === sig.quelle && row.external_id === sig.external_id) return true;
|
||||||
if (row.firma_norm && row.stelle_norm
|
if ((row.firma_slug || row.firma_norm) && row.stelle_norm
|
||||||
&& row.firma_norm === sig.firma_norm && row.stelle_norm === sig.stelle_norm
|
&& firmaMatch(row, sig) && row.stelle_norm === sig.stelle_norm
|
||||||
&& (!row.ort_norm || row.ort_norm === sig.ort_norm)) return true;
|
&& (!row.ort_norm || row.ort_norm === sig.ort_norm)) return true;
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -137,7 +205,7 @@ function matchBlacklist(rows, offer) {
|
|||||||
// Column set for INSERT — keeps server.js and api.js in lock-step.
|
// Column set for INSERT — keeps server.js and api.js in lock-step.
|
||||||
const COLUMNS = [
|
const COLUMNS = [
|
||||||
'typ', 'url_norm', 'domain', 'quelle', 'external_id',
|
'typ', 'url_norm', 'domain', 'quelle', 'external_id',
|
||||||
'firma_norm', 'stelle_norm', 'ort_norm', 'firma', 'stelle', 'quelle_url', 'grund',
|
'firma_norm', 'firma_slug', 'stelle_norm', 'ort_norm', 'firma', 'stelle', 'quelle_url', 'grund',
|
||||||
];
|
];
|
||||||
|
|
||||||
function emptyEntry() {
|
function emptyEntry() {
|
||||||
@@ -170,6 +238,7 @@ function buildManualEntry(input) {
|
|||||||
const w = wert || inp.firma || '';
|
const w = wert || inp.firma || '';
|
||||||
e.firma = w || null;
|
e.firma = w || null;
|
||||||
e.firma_norm = normText(w);
|
e.firma_norm = normText(w);
|
||||||
|
e.firma_slug = firmaSlug(w) || null;
|
||||||
if (!e.firma_norm) return null;
|
if (!e.firma_norm) return null;
|
||||||
} else if (typ === 'firma_stelle') {
|
} else if (typ === 'firma_stelle') {
|
||||||
const f = inp.firma || '';
|
const f = inp.firma || '';
|
||||||
@@ -177,6 +246,7 @@ function buildManualEntry(input) {
|
|||||||
e.firma = f || null;
|
e.firma = f || null;
|
||||||
e.stelle = s || null;
|
e.stelle = s || null;
|
||||||
e.firma_norm = normText(f);
|
e.firma_norm = normText(f);
|
||||||
|
e.firma_slug = firmaSlug(f) || null;
|
||||||
e.stelle_norm = normText(s);
|
e.stelle_norm = normText(s);
|
||||||
e.ort_norm = inp.ort ? normText(inp.ort) : null;
|
e.ort_norm = inp.ort ? normText(inp.ort) : null;
|
||||||
if (!e.firma_norm || !e.stelle_norm) return null;
|
if (!e.firma_norm || !e.stelle_norm) return null;
|
||||||
@@ -196,6 +266,7 @@ function buildAutoEntry(offer, grund) {
|
|||||||
e.quelle = sig.external_id ? sig.quelle : null;
|
e.quelle = sig.external_id ? sig.quelle : null;
|
||||||
e.external_id = sig.external_id || null;
|
e.external_id = sig.external_id || null;
|
||||||
e.firma_norm = sig.firma_norm || null;
|
e.firma_norm = sig.firma_norm || null;
|
||||||
|
e.firma_slug = sig.firma_slug || null;
|
||||||
e.stelle_norm = sig.stelle_norm || null;
|
e.stelle_norm = sig.stelle_norm || null;
|
||||||
e.ort_norm = sig.ort_norm || null;
|
e.ort_norm = sig.ort_norm || null;
|
||||||
e.firma = offer && offer.firma ? offer.firma : null;
|
e.firma = offer && offer.firma ? offer.firma : null;
|
||||||
@@ -210,6 +281,8 @@ module.exports = {
|
|||||||
normalizeUrl,
|
normalizeUrl,
|
||||||
urlDomain,
|
urlDomain,
|
||||||
normText,
|
normText,
|
||||||
|
firmaSlug,
|
||||||
|
sameCompany,
|
||||||
offerSignature,
|
offerSignature,
|
||||||
rowMatches,
|
rowMatches,
|
||||||
matchBlacklist,
|
matchBlacklist,
|
||||||
|
|||||||
+38
-9
@@ -4,7 +4,7 @@
|
|||||||
// creates / updates / deletes events (PUT / DELETE with iCalendar VEVENTs).
|
// creates / updates / deletes events (PUT / DELETE with iCalendar VEVENTs).
|
||||||
// Auth is HTTP Basic over HTTPS using the same mail account as lib/mailer.
|
// Auth is HTTP Basic over HTTPS using the same mail account as lib/mailer.
|
||||||
//
|
//
|
||||||
// Config (env):
|
// Config (DB, editable via /einstellungen — see lib/config.js):
|
||||||
// CALDAV_URL full URL of the calendar collection (must end with /)
|
// CALDAV_URL full URL of the calendar collection (must end with /)
|
||||||
// MAIL_USER login (shared with mail)
|
// MAIL_USER login (shared with mail)
|
||||||
// MAIL_PASSWORD password (shared with mail)
|
// MAIL_PASSWORD password (shared with mail)
|
||||||
@@ -14,15 +14,18 @@
|
|||||||
// Europe/Berlin. Wall-clock input from the UI is converted with wallToUtc().
|
// Europe/Berlin. Wall-clock input from the UI is converted with wallToUtc().
|
||||||
|
|
||||||
const crypto = require('crypto');
|
const crypto = require('crypto');
|
||||||
|
const config = require('./config');
|
||||||
|
|
||||||
const TZ = 'Europe/Berlin';
|
const TZ = 'Europe/Berlin';
|
||||||
|
|
||||||
|
// Read fresh on every call so an edit on the /einstellungen page takes effect
|
||||||
|
// immediately (values live in the DB now, not in process.env/.env).
|
||||||
function cfg() {
|
function cfg() {
|
||||||
return {
|
return {
|
||||||
url: (process.env.CALDAV_URL || '').trim(),
|
url: (config.get('CALDAV_URL') || '').trim(),
|
||||||
user: process.env.MAIL_USER || '',
|
user: config.get('MAIL_USER') || '',
|
||||||
pass: process.env.MAIL_PASSWORD || '',
|
pass: config.get('MAIL_PASSWORD') || '',
|
||||||
alarmMin: Number(process.env.CALDAV_ALARM_MIN) || 60,
|
alarmMin: Number(config.get('CALDAV_ALARM_MIN')) || 60,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -73,6 +76,16 @@ function localInputToUtc(dateStr, timeStr) {
|
|||||||
return wallToUtc(y, mo, d, h || 0, mi || 0, TZ);
|
return wallToUtc(y, mo, d, h || 0, mi || 0, TZ);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// UTC Date -> Berliner Wanduhr-Bestandteile { datum: 'YYYY-MM-DD', zeit: 'HH:MM' }
|
||||||
|
// (für vorausgefüllte Bearbeiten-Formulare).
|
||||||
|
function utcToWallInputs(date) {
|
||||||
|
const p = new Intl.DateTimeFormat('en-CA', {
|
||||||
|
timeZone: TZ, year: 'numeric', month: '2-digit', day: '2-digit',
|
||||||
|
hour: '2-digit', minute: '2-digit', hour12: false,
|
||||||
|
}).formatToParts(date).reduce((a, x) => (a[x.type] = x.value, a), {});
|
||||||
|
return { datum: `${p.year}-${p.month}-${p.day}`, zeit: `${p.hour}:${p.minute}` };
|
||||||
|
}
|
||||||
|
|
||||||
// UTC Date -> iCalendar UTC stamp "YYYYMMDDTHHMMSSZ".
|
// UTC Date -> iCalendar UTC stamp "YYYYMMDDTHHMMSSZ".
|
||||||
function toIcsUtc(date) {
|
function toIcsUtc(date) {
|
||||||
return date.getUTCFullYear() + pad(date.getUTCMonth() + 1) + pad(date.getUTCDate())
|
return date.getUTCFullYear() + pad(date.getUTCMonth() + 1) + pad(date.getUTCDate())
|
||||||
@@ -122,6 +135,21 @@ function unescapeText(s) {
|
|||||||
.replace(/\\\\/g, '\\');
|
.replace(/\\\\/g, '\\');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Some clients store SUMMARY/LOCATION/DESCRIPTION with HTML entities baked in
|
||||||
|
// (e.g. "Vorstellungsgespräch – dot.haus"). iCal text is plain, so
|
||||||
|
// decode them back to real characters before we hand the value on.
|
||||||
|
function decodeHtmlEntities(s) {
|
||||||
|
return String(s == null ? '' : s)
|
||||||
|
.replace(/&#(\d+);/g, (_, n) => String.fromCodePoint(parseInt(n, 10)))
|
||||||
|
.replace(/&#x([0-9a-fA-F]+);/g, (_, n) => String.fromCodePoint(parseInt(n, 16)))
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, "'")
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/ /g, ' ')
|
||||||
|
.replace(/&/g, '&');
|
||||||
|
}
|
||||||
|
|
||||||
// Fold a content line to <=75 octets per RFC 5545 (CRLF + single space).
|
// Fold a content line to <=75 octets per RFC 5545 (CRLF + single space).
|
||||||
function fold(line) {
|
function fold(line) {
|
||||||
if (Buffer.byteLength(line, 'utf8') <= 75) return line;
|
if (Buffer.byteLength(line, 'utf8') <= 75) return line;
|
||||||
@@ -142,7 +170,7 @@ function buildICS(evt) {
|
|||||||
const lines = [
|
const lines = [
|
||||||
'BEGIN:VCALENDAR',
|
'BEGIN:VCALENDAR',
|
||||||
'VERSION:2.0',
|
'VERSION:2.0',
|
||||||
'PRODID:-//Bewerbungs-Tracker//Kalender//DE',
|
'PRODID:-//NextJobs//Kalender//DE',
|
||||||
'CALSCALE:GREGORIAN',
|
'CALSCALE:GREGORIAN',
|
||||||
'BEGIN:VEVENT',
|
'BEGIN:VEVENT',
|
||||||
'UID:' + uid,
|
'UID:' + uid,
|
||||||
@@ -204,9 +232,9 @@ function parseVEvent(ics) {
|
|||||||
paramParts.forEach((p) => { const [k, v] = p.split('='); if (k) params[k.toUpperCase()] = v; });
|
paramParts.forEach((p) => { const [k, v] = p.split('='); if (k) params[k.toUpperCase()] = v; });
|
||||||
const key = name.toUpperCase();
|
const key = name.toUpperCase();
|
||||||
if (key === 'UID') ev.uid = value.trim();
|
if (key === 'UID') ev.uid = value.trim();
|
||||||
else if (key === 'SUMMARY') ev.summary = unescapeText(value);
|
else if (key === 'SUMMARY') ev.summary = decodeHtmlEntities(unescapeText(value));
|
||||||
else if (key === 'LOCATION') ev.location = unescapeText(value);
|
else if (key === 'LOCATION') ev.location = decodeHtmlEntities(unescapeText(value));
|
||||||
else if (key === 'DESCRIPTION') ev.description = unescapeText(value);
|
else if (key === 'DESCRIPTION') ev.description = decodeHtmlEntities(unescapeText(value));
|
||||||
else if (key === 'DTSTART') { const r = icsToDate(value, params); ev.start = r.date; ev.allDay = r.allDay; }
|
else if (key === 'DTSTART') { const r = icsToDate(value, params); ev.start = r.date; ev.allDay = r.allDay; }
|
||||||
else if (key === 'DTEND') { const r = icsToDate(value, params); ev.end = r.date; }
|
else if (key === 'DTEND') { const r = icsToDate(value, params); ev.end = r.date; }
|
||||||
}
|
}
|
||||||
@@ -317,6 +345,7 @@ module.exports = {
|
|||||||
collectionUrl,
|
collectionUrl,
|
||||||
wallToUtc,
|
wallToUtc,
|
||||||
localInputToUtc,
|
localInputToUtc,
|
||||||
|
utcToWallInputs,
|
||||||
buildICS,
|
buildICS,
|
||||||
parseVEvent,
|
parseVEvent,
|
||||||
getCtag,
|
getCtag,
|
||||||
|
|||||||
+276
@@ -0,0 +1,276 @@
|
|||||||
|
// Conversational chat over Ollama Cloud with tool calling.
|
||||||
|
//
|
||||||
|
// The rest of the app uses the LLM as a one-shot generator (cover letters,
|
||||||
|
// e-mail replies). This module adds an interactive chat: it streams assistant
|
||||||
|
// tokens from Ollama (`stream: true`, NDJSON) and lets the assistant look up
|
||||||
|
// the user's application data on demand via tool calls instead of having every
|
||||||
|
// application baked into the system prompt (which would be slow and grow with
|
||||||
|
// the data).
|
||||||
|
//
|
||||||
|
// Unlike lib/documents.js, no JSON output schema is enforced — the model just
|
||||||
|
// replies as text, token by token, so the UI can render progressively.
|
||||||
|
|
||||||
|
const promptStore = require('./prompts');
|
||||||
|
const config = require('./config');
|
||||||
|
|
||||||
|
// Firmenrecherche braucht mehr Runden als eine reine Datenbankfrage: Bewerbung
|
||||||
|
// holen -> web_suche -> ein, zwei Seiten lesen -> antworten. Mit 4 Runden lief
|
||||||
|
// der Assistent dabei ins Limit, statt zu antworten.
|
||||||
|
const MAX_TOOL_ROUNDS = 8;
|
||||||
|
|
||||||
|
function isConfigured() {
|
||||||
|
return Boolean(config.ollama().apiKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stream a single chat completion from Ollama. `messages` = [{role, content}]
|
||||||
|
// in chronological order (system prompt is prepended separately). `tools` is an
|
||||||
|
// optional Ollama tool-definition array; when the model calls a tool the
|
||||||
|
// returned `toolCalls` is non-empty and `content` is usually empty. `onToken`
|
||||||
|
// fires with each incremental text chunk. Returns { content, toolCalls }.
|
||||||
|
// Aborts cleanly via `signal`.
|
||||||
|
async function streamChat({ system, messages, tools, onToken, temperature = 0.6, signal }) {
|
||||||
|
const { host: ollamaHost, model: ollamaModel, apiKey } = config.ollama();
|
||||||
|
if (!apiKey) throw new Error('OLLAMA_API_KEY ist nicht gesetzt (unter „Einstellungen“ konfigurieren).');
|
||||||
|
|
||||||
|
const allMessages = [];
|
||||||
|
if (system) allMessages.push({ role: 'system', content: system });
|
||||||
|
for (const m of messages) {
|
||||||
|
// Preserve tool_calls (assistant) and tool_name (tool result) so Ollama can
|
||||||
|
// match tool calls to their results across rounds.
|
||||||
|
const msg = { role: m.role, content: m.content };
|
||||||
|
if (m.tool_calls) msg.tool_calls = m.tool_calls;
|
||||||
|
if (m.tool_name) msg.tool_name = m.tool_name;
|
||||||
|
allMessages.push(msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = {
|
||||||
|
model: ollamaModel,
|
||||||
|
stream: true,
|
||||||
|
options: { temperature },
|
||||||
|
messages: allMessages,
|
||||||
|
};
|
||||||
|
if (tools && tools.length) body.tools = tools;
|
||||||
|
|
||||||
|
let res;
|
||||||
|
try {
|
||||||
|
res = await fetch(`${ollamaHost}/api/chat`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${apiKey}` },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
signal,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
if (err.name === 'AbortError') throw new Error('Abgebrochen.');
|
||||||
|
throw new Error(`Verbindung zur Ollama-API fehlgeschlagen: ${err.message}`);
|
||||||
|
}
|
||||||
|
if (!res.ok) {
|
||||||
|
const text = await res.text().catch(() => '');
|
||||||
|
throw new Error(`Ollama-API antwortete mit ${res.status}: ${text.slice(0, 300)}`);
|
||||||
|
}
|
||||||
|
if (!res.body || !res.body.getReader) {
|
||||||
|
// Node without streaming body support: fall back to buffered response.
|
||||||
|
const data = await res.json();
|
||||||
|
const msg = (data && data.message) || {};
|
||||||
|
const text = (msg.content || '').trim();
|
||||||
|
if (onToken && text) onToken(text);
|
||||||
|
return { content: text, toolCalls: normalizeToolCalls(msg.tool_calls) };
|
||||||
|
}
|
||||||
|
|
||||||
|
const reader = res.body.getReader();
|
||||||
|
const decoder = new TextDecoder();
|
||||||
|
let buffer = '';
|
||||||
|
let full = '';
|
||||||
|
const toolCalls = [];
|
||||||
|
|
||||||
|
// Ollama streams one JSON object per line (NDJSON). Accumulate partial
|
||||||
|
// lines across chunks, then parse each complete line.
|
||||||
|
const handleLine = (line) => {
|
||||||
|
line = line.trim();
|
||||||
|
if (!line) return;
|
||||||
|
let obj;
|
||||||
|
try { obj = JSON.parse(line); } catch (e) { return; } // ignore keepalives
|
||||||
|
const msg = obj && obj.message;
|
||||||
|
if (!msg) return;
|
||||||
|
const delta = msg.content;
|
||||||
|
if (delta) {
|
||||||
|
full += delta;
|
||||||
|
if (onToken) onToken(delta);
|
||||||
|
}
|
||||||
|
if (msg.tool_calls && msg.tool_calls.length) {
|
||||||
|
// Ollama emits tool_calls (complete) on the final chunk; capture them.
|
||||||
|
toolCalls.length = 0;
|
||||||
|
toolCalls.push(...normalizeToolCalls(msg.tool_calls));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
let chunk;
|
||||||
|
try {
|
||||||
|
chunk = await reader.read();
|
||||||
|
} catch (err) {
|
||||||
|
if (err.name === 'AbortError') throw new Error('Abgebrochen.');
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
if (chunk.done) break;
|
||||||
|
buffer += decoder.decode(chunk.value, { stream: true });
|
||||||
|
let nl;
|
||||||
|
while ((nl = buffer.indexOf('\n')) >= 0) {
|
||||||
|
const line = buffer.slice(0, nl);
|
||||||
|
buffer = buffer.slice(nl + 1);
|
||||||
|
handleLine(line);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
handleLine(buffer); // flush trailing line
|
||||||
|
return { content: full.trim(), toolCalls };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Coerce Ollama's tool_calls into a stable shape: [{function:{name, arguments}}]
|
||||||
|
// where arguments is always a plain object (Ollama sometimes sends a string).
|
||||||
|
function normalizeToolCalls(raw) {
|
||||||
|
if (!Array.isArray(raw)) return [];
|
||||||
|
const out = [];
|
||||||
|
for (const tc of raw) {
|
||||||
|
const fn = (tc && tc.function) || {};
|
||||||
|
let args = fn.arguments;
|
||||||
|
if (typeof args === 'string') {
|
||||||
|
try { args = JSON.parse(args); } catch (e) { args = {}; }
|
||||||
|
}
|
||||||
|
out.push({ function: { name: fn.name, arguments: args || {} } });
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run a full agentic turn: stream the model's reply; if it calls tools,
|
||||||
|
// execute them (via `executeTool(name, args) -> any JSON-serialisable value`),
|
||||||
|
// feed the results back, and loop until the model answers in plain text (or
|
||||||
|
// MAX_TOOL_ROUNDS is hit). `onToken` streams the final text answer; `onToolCall`
|
||||||
|
// fires for each tool invocation so the UI can show what's being looked up.
|
||||||
|
async function runChat({ system, messages, tools, executeTool, onToken, onToolCall, signal, temperature = 0.6 }) {
|
||||||
|
const convo = messages.map((m) => ({ role: m.role, content: m.content }));
|
||||||
|
|
||||||
|
for (let round = 0; round < MAX_TOOL_ROUNDS; round++) {
|
||||||
|
const { content, toolCalls } = await streamChat({
|
||||||
|
system, messages: convo, tools, onToken, temperature, signal,
|
||||||
|
});
|
||||||
|
if (!toolCalls || !toolCalls.length) return content;
|
||||||
|
|
||||||
|
// Append the assistant's tool-call message verbatim so the model sees its
|
||||||
|
// own call in the next round, then one tool-result message per call.
|
||||||
|
convo.push({
|
||||||
|
role: 'assistant',
|
||||||
|
content: content || '',
|
||||||
|
tool_calls: toolCalls,
|
||||||
|
});
|
||||||
|
for (const tc of toolCalls) {
|
||||||
|
const name = tc.function.name;
|
||||||
|
if (onToolCall) onToolCall(name, tc.function.arguments);
|
||||||
|
let result;
|
||||||
|
try {
|
||||||
|
result = await executeTool(name, tc.function.arguments);
|
||||||
|
} catch (err) {
|
||||||
|
result = { error: err.message || String(err) };
|
||||||
|
}
|
||||||
|
convo.push({
|
||||||
|
role: 'tool',
|
||||||
|
tool_name: name,
|
||||||
|
content: JSON.stringify(result),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new Error('Maximale Anzahl Tool-Aufrufe erreicht.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Kurzen Thread-Titel aus dem ersten Schlagabtausch formulieren lassen — wie in
|
||||||
|
// ChatGPT, statt die ersten 60 Zeichen der Frage abzuschneiden ("Ich habe mich
|
||||||
|
// bei der Hetzner Online GmbH bewor…"). Ein eigener, winziger Aufruf ohne
|
||||||
|
// Werkzeuge: Der Chat-Systemprompt würde hier nur stören (er will beraten, nicht
|
||||||
|
// betiteln). Wirft nicht — schlägt die Titelgenerierung fehl, bleibt der Aufrufer
|
||||||
|
// einfach beim Fallback-Titel.
|
||||||
|
async function generateTitle({ userText, assistantText, signal }) {
|
||||||
|
const system =
|
||||||
|
'Du formulierst kurze Titel für Chat-Verläufe eines Bewerbungs-Assistenten. ' +
|
||||||
|
'Antworte mit NICHTS außer dem Titel: 2 bis 5 Wörter, Deutsch, keine ' +
|
||||||
|
'Anführungszeichen, kein Punkt am Ende, keine Einleitung. Benenne das Thema ' +
|
||||||
|
'konkret (Firma, Stelle oder Anliegen), nicht generisch wie "Frage zur Bewerbung".';
|
||||||
|
const inhalt =
|
||||||
|
'Nachricht des Nutzers:\n' + String(userText || '').slice(0, 600) +
|
||||||
|
'\n\nAntwort des Assistenten:\n' + String(assistantText || '').slice(0, 600) +
|
||||||
|
'\n\nTitel:';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { content } = await streamChat({
|
||||||
|
system,
|
||||||
|
messages: [{ role: 'user', content: inhalt }],
|
||||||
|
temperature: 0.2,
|
||||||
|
signal,
|
||||||
|
});
|
||||||
|
// Modelle packen den Titel gern in Anführungszeichen, Sternchen oder eine
|
||||||
|
// "Titel:"-Zeile — das wieder abräumen.
|
||||||
|
let titel = String(content || '')
|
||||||
|
.split('\n').map((z) => z.trim()).filter(Boolean).pop() || '';
|
||||||
|
titel = titel
|
||||||
|
.replace(/^\s*(titel|title)\s*:\s*/i, '')
|
||||||
|
.replace(/^["“„'*#\s]+|["“”'*.\s]+$/g, '')
|
||||||
|
.trim();
|
||||||
|
if (titel.length > 60) titel = titel.slice(0, 60).trim();
|
||||||
|
return titel;
|
||||||
|
} catch (err) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build the German system prompt that grounds the assistant. `context` is the
|
||||||
|
// lightweight core gathered by the server: { heute, settings, profil }.
|
||||||
|
// Application data is NOT baked in — the assistant fetches it via tools.
|
||||||
|
function buildContextPrompt(ctx) {
|
||||||
|
const name = (ctx && ctx.settings && ctx.settings.name) || 'der Bewerber';
|
||||||
|
const heute = (ctx && ctx.heute) || '';
|
||||||
|
const parts = [];
|
||||||
|
// The assistant's role/tone is editable (Vorlagen page); the sections below
|
||||||
|
// describe the runtime wiring (tools, date, profile) and stay in code.
|
||||||
|
parts.push(promptStore.get(ctx && ctx.prompts, 'chat', { name }));
|
||||||
|
parts.push(
|
||||||
|
'# Werkzeuge\n' +
|
||||||
|
'Du hast Werkzeuge, um Bewerbungsdaten nachzuschlagen. Nutze sie, sobald der ' +
|
||||||
|
'Nutzer nach einer konkreten Bewerbung, Firma, Status, Stellenbeschreibung, ' +
|
||||||
|
'Korrespondenz oder einem Termin fragt — rufe niemals Daten aus dem Gedächtnis ' +
|
||||||
|
'ab, die du per Werkzeug holen kannst. Lege mehrere benötigte Werkzeuge in ' +
|
||||||
|
'einer Runde parallel an. Wenn eine Suche keine Treffer liefert, weise den ' +
|
||||||
|
'Nutzer darauf hin und frage nach Details (z. B. Link/Stellenbeschreibung).\n\n' +
|
||||||
|
'Zusätzlich kannst du mit `web_suche` und `web_seite_lesen` im Internet ' +
|
||||||
|
'recherchieren. Nutze das für alles, was nicht in den Bewerbungsdaten steht: ' +
|
||||||
|
'Hintergrund zu einer Firma (Produkte, Größe, Standorte, Kultur, aktuelle ' +
|
||||||
|
'Nachrichten), Gehaltsspannen, Technologien, Vorbereitung auf ein Gespräch. ' +
|
||||||
|
'Typischer Ablauf bei „Was muss ich über Firma X wissen?": erst die Bewerbung ' +
|
||||||
|
'per Werkzeug holen (Stelle, Ort, Stellenbeschreibung, quelle_url), dann ' +
|
||||||
|
'gezielt im Web nachrecherchieren und beides zusammenführen. Recherchiere ' +
|
||||||
|
'nicht ungefragt bei jeder Frage — nur, wenn aktuelles oder externes Wissen ' +
|
||||||
|
'die Antwort wirklich besser macht.\n\n' +
|
||||||
|
'Bei Web-Rechercheergebnissen: Gib nur wieder, was in den Treffern steht, und ' +
|
||||||
|
'nenne die Quelle als URL. Wenn du etwas nicht belegen kannst, sage es — rate ' +
|
||||||
|
'nicht. Achte darauf, dass Treffer wirklich die gemeinte Firma betreffen ' +
|
||||||
|
'(Namensdopplungen, falscher Standort) und weise auf Zweifel hin.'
|
||||||
|
);
|
||||||
|
if (heute) {
|
||||||
|
parts.push('# Heute\n' + 'Heutiges Datum: ' + heute + '. ' +
|
||||||
|
'Terminangaben aus Werkzeugen sind UTC-Zeitstempel (ISO, endet auf Z). ' +
|
||||||
|
'Rechne in UTC-Zeiten und leite daraus ab, was "heute", "morgen" usw. ist. ' +
|
||||||
|
'Zeige dem Nutzer Terminzeiten in seiner lokalen Zeit (Europe/Berlin = UTC+1, ' +
|
||||||
|
'bzw. UTC+2 während der Sommerzeit).');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ctx && ctx.profil) {
|
||||||
|
parts.push('# Dein Profil (Lebenslauf)\n' + ctx.profil +
|
||||||
|
'\nNutze dies, um Antworten auf den Bewerber zuzuschneiden: Wenn der Nutzer ' +
|
||||||
|
'nach einer Firma fragt, hebe hervor, welche Anforderungen der Stelle zu ' +
|
||||||
|
'seinem Profil passen und wo Lücken bestehen.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return parts.join('\n\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
isConfigured, streamChat, runChat, buildContextPrompt, generateTitle, MAX_TOOL_ROUNDS,
|
||||||
|
// Dynamic so an edit on /einstellungen is reflected without a restart.
|
||||||
|
get OLLAMA_MODEL() { return config.ollama().model; },
|
||||||
|
};
|
||||||
+259
@@ -0,0 +1,259 @@
|
|||||||
|
// Centralized, per-user configuration store.
|
||||||
|
//
|
||||||
|
// Multi-user: every setting (Ollama, E-Mail, CalDAV, API-Token) is owned by a
|
||||||
|
// user. The values live in the SQLite app_state table (key prefixed "cfg:"),
|
||||||
|
// now keyed by (user_id, key). The /einstellungen page edits the *current* user's
|
||||||
|
// values; the libs (mailer, caldav, chat, documents) read through config.get() /
|
||||||
|
// config.ollama() at call time, picking up the current user from the per-request
|
||||||
|
// context (lib/context.js). An edit therefore takes effect immediately, scoped
|
||||||
|
// to the user who made it — no restart, no .env file.
|
||||||
|
//
|
||||||
|
// On first start of an install that previously used .env (or that still passes
|
||||||
|
// the values as container env vars), the still-present env values are imported
|
||||||
|
// into the *admin* user's rows once — see importEnvIntoAdmin(), called from
|
||||||
|
// server.js at boot. After that the database is the single source of truth.
|
||||||
|
//
|
||||||
|
// process.env is deliberately NOT a read fallback: env config belongs to the
|
||||||
|
// admin, so falling back to it would hand every freshly created user the admin's
|
||||||
|
// mailbox, calendar, Ollama key and API token. Unset keys resolve to the
|
||||||
|
// built-in defaults instead, which leaves a new user's settings empty.
|
||||||
|
|
||||||
|
const { currentUser } = require('./context');
|
||||||
|
|
||||||
|
const DEFAULTS = {
|
||||||
|
// Ollama Cloud (KI text generation + chat).
|
||||||
|
OLLAMA_API_KEY: '',
|
||||||
|
OLLAMA_MODEL: 'glm-5.2:cloud',
|
||||||
|
OLLAMA_HOST: 'https://ollama.com',
|
||||||
|
OLLAMA_TIMEOUT_MS: '300000',
|
||||||
|
// Which documents are preselected when a generation is started (comma separated,
|
||||||
|
// see DOKUMENT_TYPEN in lib/documents.js). Empty means both.
|
||||||
|
GEN_DOKUMENTE_DEFAULT: 'anschreiben,lebenslauf',
|
||||||
|
// Which static attachments (basis_anhaenge IDs, comma separated) are preselected.
|
||||||
|
// Empty = none, which is the default.
|
||||||
|
GEN_ANLAGEN_DEFAULT: '',
|
||||||
|
// E-Mail (SMTP submission + IMAP receive).
|
||||||
|
MAIL_HOST: '',
|
||||||
|
MAIL_SMTP_PORT: '587',
|
||||||
|
MAIL_IMAP_PORT: '993',
|
||||||
|
MAIL_USER: '',
|
||||||
|
MAIL_PASSWORD: '',
|
||||||
|
MAIL_FROM_NAME: '',
|
||||||
|
MAIL_FROM: '',
|
||||||
|
MAIL_IMAP_MAILBOX: 'INBOX',
|
||||||
|
MAIL_POLL_MS: '180000',
|
||||||
|
// Bewerbungskalender (CalDAV, z. B. SOGo).
|
||||||
|
CALDAV_URL: '',
|
||||||
|
CALDAV_ALARM_MIN: '60',
|
||||||
|
CALDAV_POLL_MS: '300000',
|
||||||
|
// REST-API für Drittanbietersoftware (/api/v1, Header X-API-Key).
|
||||||
|
API_TOKEN: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
// UI metadata for the /einstellungen page: grouped sections with input types.
|
||||||
|
// `secret` fields render as password inputs with a reveal toggle.
|
||||||
|
const FIELDS = [
|
||||||
|
{
|
||||||
|
titel: 'Ollama Cloud (KI-Generierung + Chat)',
|
||||||
|
beschreibung: 'Steuerung der KI, die Bewerbungsunterlagen erzeugt und den KI-Chat beantwortet. Ohne API-Schlüssel sind diese Funktionen deaktiviert.',
|
||||||
|
items: [
|
||||||
|
{ key: 'OLLAMA_API_KEY', label: 'API-Schlüssel', secret: true, help: 'Schlüssel von https://ollama.com/settings/keys' },
|
||||||
|
{ key: 'OLLAMA_MODEL', label: 'Modell', help: 'Standard: glm-5.2:cloud' },
|
||||||
|
{ key: 'OLLAMA_HOST', label: 'Host', help: 'Standard: https://ollama.com — lokaler Ollama: http://localhost:11434' },
|
||||||
|
{ key: 'OLLAMA_TIMEOUT_MS', label: 'Timeout (ms)', help: 'Standard: 300000 (5 Min.)' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
titel: 'Bewerbungsunterlagen (Vorauswahl)',
|
||||||
|
beschreibung: 'Was ist auf der Bewerbungsseite vorab angehakt, wenn die KI-Generierung gestartet wird? Pro Bewerbung lässt sich die Auswahl weiterhin ändern; die zuletzt gewählten Dokumente bleiben an der jeweiligen Bewerbung erhalten.',
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
key: 'GEN_DOKUMENTE_DEFAULT',
|
||||||
|
label: 'Standardmäßig generieren',
|
||||||
|
type: 'checkboxes',
|
||||||
|
options: [
|
||||||
|
{ value: 'anschreiben', label: 'Anschreiben' },
|
||||||
|
{ value: 'lebenslauf', label: 'Lebenslauf' },
|
||||||
|
],
|
||||||
|
help: 'Standard: beides. Die E-Mail-Begleitnachricht wird immer erzeugt. Ohne Häkchen gilt wieder beides.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'GEN_ANLAGEN_DEFAULT',
|
||||||
|
label: 'Standardmäßig beigelegte Anlagen',
|
||||||
|
type: 'checkboxes',
|
||||||
|
// Auswahlmöglichkeiten sind die eigenen Anlagen aus „Vorlagen“ und stehen
|
||||||
|
// erst zur Renderzeit fest — die Route reicht sie unter diesem Namen hinein.
|
||||||
|
optionsFrom: 'anlagen',
|
||||||
|
leerHinweis: 'Noch keine Anlagen hinterlegt – unter „Vorlagen“ kannst du Zeugnisse & Co. hochladen.',
|
||||||
|
help: 'Standard: keine. Angehakte Anlagen werden bei jeder Generierung vorausgewählt und im Anschreiben unter „Anlagen“ aufgeführt.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
titel: 'E-Mail (SMTP-Versand + IMAP-Empfang)',
|
||||||
|
beschreibung: 'Versand läuft über den eigenen Mailserver (DKIM/SPF/DMARC-Alignment). Ohne Host/Benutzer/Passwort ist der E-Mail-Teil deaktiviert. Pro Benutzer eigenes Postfach.',
|
||||||
|
items: [
|
||||||
|
{ key: 'MAIL_HOST', label: 'SMTP/IMAP Host' },
|
||||||
|
{ key: 'MAIL_SMTP_PORT', label: 'SMTP-Port', help: '587 = STARTTLS, 465 = implicit TLS' },
|
||||||
|
{ key: 'MAIL_IMAP_PORT', label: 'IMAP-Port', help: 'Standard: 993 (implicit TLS)' },
|
||||||
|
{ key: 'MAIL_USER', label: 'Benutzername (Login)', help: 'Auch CalDAV-Login' },
|
||||||
|
{ key: 'MAIL_PASSWORD', label: 'Passwort', secret: true, help: 'Auch CalDAV-Passwort' },
|
||||||
|
{ key: 'MAIL_FROM_NAME', label: 'Absendername' },
|
||||||
|
{ key: 'MAIL_FROM', label: 'Absenderadresse', help: 'Leer = Benutzername' },
|
||||||
|
{ key: 'MAIL_IMAP_MAILBOX', label: 'IMAP-Postfach', help: 'Standard: INBOX' },
|
||||||
|
{ key: 'MAIL_POLL_MS', label: 'Abrufintervall (ms)', help: 'Standard: 180000 (3 Min.) — greift nach Neustart' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
titel: 'Bewerbungskalender (CalDAV)',
|
||||||
|
beschreibung: 'Voll-URL der Kalender-Sammlung (mit abschließendem /). Authentifizierung läuft über MAIL_USER/MAIL_PASSWORD. Ohne URL sind die Kalender-Funktionen deaktiviert.',
|
||||||
|
items: [
|
||||||
|
{ key: 'CALDAV_URL', label: 'Kalender-URL', help: 'z. B. https://mail.example.com/SOGo/dav/name@…/Calendar/XXXX/' },
|
||||||
|
{ key: 'CALDAV_ALARM_MIN', label: 'Erinnerung (Minuten vor Termin)', help: 'Standard: 60' },
|
||||||
|
{ key: 'CALDAV_POLL_MS', label: 'Sync-Intervall (ms)', help: 'Standard: 300000 (5 Min.) — greift nach Neustart' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
titel: 'REST-API für Drittanbietersoftware',
|
||||||
|
beschreibung: 'Ist ein Token gesetzt, ist /api/v1 für diesen Benutzer aktiv und erwartet den Wert im Header „X-API-Key“. Der Token identifiziert den Benutzer: Anfragen sehen und ändern ausschließlich dessen eigene Daten. Ohne Token antwortet die API (bis auf /health) mit 401. Swagger unter /swagger.',
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
key: 'API_TOKEN',
|
||||||
|
label: 'API-Token (X-API-Key)',
|
||||||
|
secret: true,
|
||||||
|
generate: true,
|
||||||
|
help: 'Leer = API deaktiviert. Über das Auge einblenden, „Neu generieren“ erzeugt einen zufälligen Token — danach speichern. Ein neuer Token macht den alten sofort ungültig.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const PREFIX = 'cfg:';
|
||||||
|
|
||||||
|
// Per-user cache: Map<userId, Object<string,string>>. A user is loaded lazily on
|
||||||
|
// first access (ensureLoaded) and stays cached for the process lifetime. Edits
|
||||||
|
// via saveAll() update the cache in place so subsequent reads are consistent.
|
||||||
|
const cache = new Map(); // userId -> { key: value }
|
||||||
|
const loaded = new Set(); // userIds whose cfg rows have been read from the DB
|
||||||
|
let dbAllFn = null;
|
||||||
|
let dbRunFn = null;
|
||||||
|
|
||||||
|
// The built-in default for a key. Deliberately NOT a process.env lookup: env
|
||||||
|
// values belong to the admin (they are imported into the admin's rows once at
|
||||||
|
// boot, see importEnvIntoAdmin in lib/migrate-multiuser.js). If get() fell back
|
||||||
|
// to process.env, every user without their own row would silently inherit the
|
||||||
|
// admin's credentials — their own mailbox, calendar, Ollama key and API token.
|
||||||
|
// The defaults below are non-secret standards only (model, host, ports, poll
|
||||||
|
// intervals); every credential defaults to empty, so a new user starts with
|
||||||
|
// Ollama/E-Mail/CalDAV/API switched off until they configure their own.
|
||||||
|
function builtinDefault(key) {
|
||||||
|
return DEFAULTS[key];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load one user's cfg rows from the DB into the cache. No-op if already loaded.
|
||||||
|
async function ensureLoaded(userId) {
|
||||||
|
if (!userId || loaded.has(userId)) return;
|
||||||
|
const rows = await dbAllFn('SELECT key, value FROM app_state WHERE user_id = ?', [userId]);
|
||||||
|
const obj = Object.create(null);
|
||||||
|
for (const r of rows) obj[r.key.slice(PREFIX.length)] = r.value;
|
||||||
|
cache.set(userId, obj);
|
||||||
|
loaded.add(userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Drop the cached rows for a user so the next read reloads from the DB. Used
|
||||||
|
// after a direct DB write outside saveAll() (e.g. the one-time env migration).
|
||||||
|
function invalidate(userId) {
|
||||||
|
loaded.delete(userId);
|
||||||
|
cache.delete(userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Synchronous read for the *current user only*. A key the user has not stored
|
||||||
|
// falls back to the built-in default — never to another user's value and never
|
||||||
|
// to process.env. A user who has configured nothing therefore reads as "empty"
|
||||||
|
// (no mail host, no keys), which is exactly what isolates them: mailer/caldav
|
||||||
|
// isConfigured() turns false and their background jobs stay idle.
|
||||||
|
//
|
||||||
|
// Outside a request context (boot) there is no user, so only the defaults apply.
|
||||||
|
// Callers that need a specific user's values must run inside that user's context
|
||||||
|
// (see lib/context.js) and must have called ensureLoaded(userId) first.
|
||||||
|
function get(key) {
|
||||||
|
const u = currentUser();
|
||||||
|
const userObj = u ? cache.get(u.id) : null;
|
||||||
|
if (userObj && userObj[key] !== undefined) return userObj[key];
|
||||||
|
return builtinDefault(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
// All keys with their effective values for the current user — used by the
|
||||||
|
// settings UI. Must be called within a request context.
|
||||||
|
function getAll() {
|
||||||
|
const out = {};
|
||||||
|
for (const key of Object.keys(DEFAULTS)) out[key] = get(key);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The current user's preselected static attachments (GEN_ANLAGEN_DEFAULT) as
|
||||||
|
// basis_anhaenge IDs. Deleted attachments may linger in the list; callers match
|
||||||
|
// them against the existing rows, so stale IDs simply never hit.
|
||||||
|
function anlagenDefaultIds() {
|
||||||
|
return String(get('GEN_ANLAGEN_DEFAULT') || '')
|
||||||
|
.split(',')
|
||||||
|
.map((v) => parseInt(v, 10))
|
||||||
|
.filter((n) => !Number.isNaN(n));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ollama bundle (shared by lib/documents.js + lib/chat.js), for the current user.
|
||||||
|
function ollama() {
|
||||||
|
return {
|
||||||
|
host: (get('OLLAMA_HOST') || 'https://ollama.com').replace(/\/+$/, ''),
|
||||||
|
model: get('OLLAMA_MODEL') || 'glm-5.2:cloud',
|
||||||
|
timeoutMs: Number(get('OLLAMA_TIMEOUT_MS')) || 300000,
|
||||||
|
apiKey: get('OLLAMA_API_KEY') || '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wire up DB helpers. The one-time .env migration now runs per-admin at boot
|
||||||
|
// from server.js (it needs the admin user id); this init only stores the fns.
|
||||||
|
async function init({ dbAll, dbRun }) {
|
||||||
|
dbAllFn = dbAll;
|
||||||
|
dbRunFn = dbRun;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Persist every key for the current user (writes all rows, including empty
|
||||||
|
// strings, so a cleared field is stored as empty and no longer falls back to
|
||||||
|
// env/default). Must be called within a request context.
|
||||||
|
async function saveAll(values) {
|
||||||
|
if (!dbRunFn) throw new Error('Konfigurationsspeicher nicht initialisiert.');
|
||||||
|
const u = currentUser();
|
||||||
|
if (!u) throw new Error('Kein Benutzerkontext für Konfigurationsspeicherung.');
|
||||||
|
const obj = cache.get(u.id) || Object.create(null);
|
||||||
|
for (const key of Object.keys(DEFAULTS)) {
|
||||||
|
const v = values && values[key] != null ? String(values[key]) : '';
|
||||||
|
await dbRunFn(
|
||||||
|
'INSERT INTO app_state (user_id, key, value) VALUES (?, ?, ?) ON CONFLICT(user_id, key) DO UPDATE SET value = excluded.value',
|
||||||
|
[u.id, PREFIX + key, v]
|
||||||
|
);
|
||||||
|
obj[key] = v;
|
||||||
|
}
|
||||||
|
cache.set(u.id, obj);
|
||||||
|
loaded.add(u.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write a single key for an explicit user (used by the one-time env migration,
|
||||||
|
// which runs outside a request context). Updates the cache if loaded.
|
||||||
|
async function setForUser(userId, key, value) {
|
||||||
|
if (!dbRunFn) throw new Error('Konfigurationsspeicher nicht initialisiert.');
|
||||||
|
await dbRunFn(
|
||||||
|
'INSERT INTO app_state (user_id, key, value) VALUES (?, ?, ?) ON CONFLICT(user_id, key) DO UPDATE SET value = excluded.value',
|
||||||
|
[userId, PREFIX + key, String(value)]
|
||||||
|
);
|
||||||
|
if (loaded.has(userId)) {
|
||||||
|
const obj = cache.get(userId) || Object.create(null);
|
||||||
|
obj[key] = String(value);
|
||||||
|
cache.set(userId, obj);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
DEFAULTS, FIELDS, PREFIX,
|
||||||
|
get, getAll, ollama, anlagenDefaultIds, init, saveAll, setForUser, ensureLoaded, invalidate,
|
||||||
|
};
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
// Per-request user context.
|
||||||
|
//
|
||||||
|
// The web UI authenticates a user via a signed session cookie (see the
|
||||||
|
// /login flow + auth middleware in server.js). The middleware stores the
|
||||||
|
// resolved user object in an AsyncLocalStorage, so any code running during the
|
||||||
|
// request — including the libs (config, mailer, caldav, chat) called deep in
|
||||||
|
// the stack — can read the *current user* without threading it through every
|
||||||
|
// function signature. Background jobs (IMAP poll, CalDAV sync) set the same
|
||||||
|
// context per user while iterating, so each user's config/data is used in turn.
|
||||||
|
//
|
||||||
|
// This is the keystone of the multi-tenant split: config.get() reads the current
|
||||||
|
// user's cfg rows, and the per-user query helpers below filter every SELECT/INSERT
|
||||||
|
// by the current user, guaranteeing isolation between users.
|
||||||
|
|
||||||
|
const { AsyncLocalStorage } = require('async_hooks');
|
||||||
|
|
||||||
|
const userContext = new AsyncLocalStorage();
|
||||||
|
|
||||||
|
// The current user object ({ id, username, is_admin }) or null outside a request
|
||||||
|
// (e.g. during boot). Anything that needs the user id must call this and decide
|
||||||
|
// how to behave when it is absent.
|
||||||
|
function currentUser() {
|
||||||
|
return userContext.getStore() || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convenience: the current user's id, or null when no user is set.
|
||||||
|
function currentUserId() {
|
||||||
|
const u = currentUser();
|
||||||
|
return u ? u.id : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { userContext, currentUser, currentUserId };
|
||||||
+375
@@ -0,0 +1,375 @@
|
|||||||
|
// Editable design settings for the generated application documents.
|
||||||
|
//
|
||||||
|
// Same contract as lib/prompts.js: the defaults live here, an override is a row
|
||||||
|
// in the `design` table, and deleting the row restores the default.
|
||||||
|
//
|
||||||
|
// Three layouts ship today:
|
||||||
|
//
|
||||||
|
// sidebar — the classic: neutral greys, tinted sidebar, DIN-5008 letter. The
|
||||||
|
// accent is a single restrained colour; hierarchy comes from weight
|
||||||
|
// and tracking. Right for almost every office application.
|
||||||
|
//
|
||||||
|
// social — a playful media-kit look (rounded cards, pills, hearts, a script
|
||||||
|
// headline, a full-page tinted background). Right for creative,
|
||||||
|
// social-media and design-adjacent roles — and deliberately loud.
|
||||||
|
//
|
||||||
|
// sunny — an editorial poster look: white page, oversized display headings
|
||||||
|
// in a deep accent, pastel accent bars that alternate left/right, a
|
||||||
|
// photo breaking out of a tinted hero band, a solid role badge and
|
||||||
|
// level bars for the languages. Bold but tidy — right for marketing,
|
||||||
|
// media and design roles that still want a readable, gridded page.
|
||||||
|
//
|
||||||
|
// tupfen — the quiet one with a wink: a white page, the name centred and set
|
||||||
|
// large in plain ink, airy body text — and scattered coral dots in
|
||||||
|
// the margins, some running off the page edge. The dots are the only
|
||||||
|
// colour on the sheet; the résumé picks the motif back up as section
|
||||||
|
// markers, bullets and five-dot language meters. Restrained enough
|
||||||
|
// for any office, but nobody mistakes it for a template.
|
||||||
|
//
|
||||||
|
// The cover letter always follows the résumé's layout, so both documents arrive
|
||||||
|
// as one coherent set — that is the whole point of picking a design here.
|
||||||
|
//
|
||||||
|
// The palette stays closed (no free colour picker): every tone is one that still
|
||||||
|
// works when an HR department prints it in greyscale.
|
||||||
|
|
||||||
|
// Accent colours. In `sidebar` the accent is the *only* colour; in `social` and
|
||||||
|
// `sunny` the whole page palette (bands, cards, pills) is derived from it, so
|
||||||
|
// those layouts work in berry as well as in deep blue.
|
||||||
|
const AKZENTE = [
|
||||||
|
{ key: 'anthrazit', label: 'Anthrazit', rgb: [23, 23, 23] },
|
||||||
|
{ key: 'tiefblau', label: 'Tiefblau', rgb: [30, 58, 95] },
|
||||||
|
{ key: 'bordeaux', label: 'Bordeaux', rgb: [122, 31, 47] },
|
||||||
|
{ key: 'waldgruen', label: 'Waldgrün', rgb: [27, 67, 50] },
|
||||||
|
{ key: 'stahlblau', label: 'Stahlblau', rgb: [51, 65, 85] },
|
||||||
|
{ key: 'kupfer', label: 'Kupfer', rgb: [124, 62, 24] },
|
||||||
|
{ key: 'beere', label: 'Beere (Sunny)', rgb: [109, 24, 74] },
|
||||||
|
// Deliberately deeper than the pastel dots of Tupfen's reference: in `sidebar`
|
||||||
|
// the accent *is* the text colour, and a pale coral would be unreadable there.
|
||||||
|
// Tupfen's dots are tinted up from this tone, which lands them back at pastel.
|
||||||
|
{ key: 'koralle', label: 'Koralle (Tupfen)', rgb: [222, 106, 106] },
|
||||||
|
{ key: 'pink', label: 'Pink (verspielt)', rgb: [232, 54, 143] },
|
||||||
|
{ key: 'violett', label: 'Violett (verspielt)', rgb: [147, 51, 187] },
|
||||||
|
];
|
||||||
|
|
||||||
|
const LAYOUTS = [
|
||||||
|
{ key: 'sidebar', label: 'Klassisch mit Sidebar (Standard)' },
|
||||||
|
{ key: 'social', label: 'Social Media (verspielt)' },
|
||||||
|
{ key: 'sunny', label: 'Sunny (plakativ, Farbbänder)' },
|
||||||
|
{ key: 'tupfen', label: 'Tupfen (dezent, verspielte Punkte)' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const SIDEBAR_VARIANTEN = [
|
||||||
|
{ key: 'neutral', label: 'Neutral grau (Standard)' },
|
||||||
|
{ key: 'getoent', label: 'Im Akzent getönt' },
|
||||||
|
{ key: 'weiss', label: 'Weiß (ohne Band)' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const FOTO_FORMEN = [
|
||||||
|
{ key: 'eckig', label: 'Eckig' },
|
||||||
|
{ key: 'rund', label: 'Rund' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const SCHRIFT_STUFEN = [
|
||||||
|
{ key: '90', label: '90 % (kompakt)' },
|
||||||
|
{ key: '95', label: '95 %' },
|
||||||
|
{ key: '100', label: '100 % (Standard)' },
|
||||||
|
{ key: '105', label: '105 %' },
|
||||||
|
{ key: '110', label: '110 % (groß)' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// Defaults per layout: the playful layout is pink and round by default, because
|
||||||
|
// anthracite-and-square would defeat the point of choosing it. Any of these can
|
||||||
|
// still be overridden individually.
|
||||||
|
const LAYOUT_DEFAULTS = {
|
||||||
|
sidebar: { akzent: 'anthrazit', foto_form: 'eckig' },
|
||||||
|
social: { akzent: 'pink', foto_form: 'rund' },
|
||||||
|
// Sunny is built around the berry/pastel pairing of its reference; the photo
|
||||||
|
// sits in a square frame that breaks out of the hero band, so square it is.
|
||||||
|
sunny: { akzent: 'beere', foto_form: 'eckig' },
|
||||||
|
// Tupfen scatters coral dots; a round portrait is simply the largest of them,
|
||||||
|
// so it falls in with the motif instead of fighting it.
|
||||||
|
tupfen: { akzent: 'koralle', foto_form: 'rund' },
|
||||||
|
};
|
||||||
|
|
||||||
|
const DEFAULTS = {
|
||||||
|
layout: 'sidebar',
|
||||||
|
akzent: 'anthrazit',
|
||||||
|
sidebar: 'neutral',
|
||||||
|
foto_anzeigen: '1',
|
||||||
|
foto_form: 'eckig',
|
||||||
|
schrift: '100',
|
||||||
|
};
|
||||||
|
|
||||||
|
const FELDER = [
|
||||||
|
{ key: 'layout', label: 'Layout', optionen: LAYOUTS },
|
||||||
|
{ key: 'akzent', label: 'Akzentfarbe', optionen: AKZENTE },
|
||||||
|
{ key: 'sidebar', label: 'Sidebar-Hintergrund', optionen: SIDEBAR_VARIANTEN, nurLayout: 'sidebar' },
|
||||||
|
{ key: 'foto_form', label: 'Form des Bewerberfotos', optionen: FOTO_FORMEN },
|
||||||
|
{ key: 'schrift', label: 'Schriftgröße', optionen: SCHRIFT_STUFEN },
|
||||||
|
];
|
||||||
|
|
||||||
|
const NEUTRAL_SIDEBAR = [242, 243, 245];
|
||||||
|
|
||||||
|
// Mix a colour towards white. amount = 0 -> white, 1 -> the colour itself.
|
||||||
|
function tint(rgb, amount) {
|
||||||
|
return rgb.map((c) => Math.round(c * amount + 255 * (1 - amount)));
|
||||||
|
}
|
||||||
|
// Mix a colour towards black.
|
||||||
|
function shade(rgb, amount) {
|
||||||
|
return rgb.map((c) => Math.round(c * amount));
|
||||||
|
}
|
||||||
|
|
||||||
|
function findOption(list, key, fallbackKey) {
|
||||||
|
return list.find((o) => o.key === key) || list.find((o) => o.key === fallbackKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
// A checkbox paired with a hidden fallback submits *both* values, which Express
|
||||||
|
// hands over as an array — the last entry is the effective one.
|
||||||
|
function one(v) {
|
||||||
|
return Array.isArray(v) ? v[v.length - 1] : v;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fold the stored overrides onto the defaults, dropping unknown keys/values so a
|
||||||
|
// stale row, a hand-crafted query string or a checkbox pair can never produce an
|
||||||
|
// invalid theme. Fields not explicitly set fall back to the *layout's* default,
|
||||||
|
// which is why switching to "social" turns pink without touching anything else.
|
||||||
|
function settings(overrides = {}) {
|
||||||
|
const layoutRaw = one(overrides.layout);
|
||||||
|
const layout = LAYOUTS.some((l) => l.key === layoutRaw) ? String(layoutRaw) : DEFAULTS.layout;
|
||||||
|
|
||||||
|
const out = { ...DEFAULTS, ...(LAYOUT_DEFAULTS[layout] || {}), layout };
|
||||||
|
|
||||||
|
for (const f of FELDER) {
|
||||||
|
if (f.key === 'layout') continue;
|
||||||
|
const v = one(overrides[f.key]);
|
||||||
|
if (v && f.optionen.some((o) => o.key === String(v))) out[f.key] = String(v);
|
||||||
|
}
|
||||||
|
const foto = one(overrides.foto_anzeigen);
|
||||||
|
if (foto === '0' || foto === '1') out.foto_anzeigen = foto;
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build the concrete theme the renderer draws with. Everything the PDF code
|
||||||
|
// needs to know about the user's choices is in here — the renderer itself has
|
||||||
|
// no idea these are configurable.
|
||||||
|
function resolve(overrides = {}) {
|
||||||
|
const s = settings(overrides);
|
||||||
|
const accent = findOption(AKZENTE, s.akzent, 'anthrazit').rgb;
|
||||||
|
|
||||||
|
let sidebarBg;
|
||||||
|
if (s.sidebar === 'weiss') sidebarBg = [255, 255, 255];
|
||||||
|
else if (s.sidebar === 'getoent') sidebarBg = tint(accent, 0.08);
|
||||||
|
else sidebarBg = NEUTRAL_SIDEBAR;
|
||||||
|
|
||||||
|
// Résumé palettes, one per layout. Every key exists in every palette so the
|
||||||
|
// renderer can read `t.rc.<x>` without knowing which layout it is drawing.
|
||||||
|
const RC = {
|
||||||
|
sidebar: {
|
||||||
|
ink: [33, 33, 33],
|
||||||
|
sub: [92, 96, 100],
|
||||||
|
hair: [208, 210, 214],
|
||||||
|
accent,
|
||||||
|
pageBg: null, // no page tint in the classic layout
|
||||||
|
cardBg: [255, 255, 255],
|
||||||
|
cardBorder: [208, 210, 214],
|
||||||
|
deko: [208, 210, 214], // the classic layout draws no sparkles; kept for shape parity
|
||||||
|
dekoHell: [230, 232, 235],
|
||||||
|
band: tint(accent, 0.14),
|
||||||
|
pillBg: tint(accent, 0.12),
|
||||||
|
pillInk: accent,
|
||||||
|
sidebarBg,
|
||||||
|
track: [199, 202, 208],
|
||||||
|
onSide: [38, 40, 44],
|
||||||
|
onSideSub: [96, 100, 106],
|
||||||
|
},
|
||||||
|
// The playful layout derives its whole palette from the accent: a tinted
|
||||||
|
// page, near-white cards with a soft border, pill chips, and a warm dark ink
|
||||||
|
// that is the accent shaded down rather than plain black.
|
||||||
|
social: {
|
||||||
|
ink: shade(accent, 0.35),
|
||||||
|
sub: tint(shade(accent, 0.55), 0.75),
|
||||||
|
hair: tint(accent, 0.30),
|
||||||
|
accent,
|
||||||
|
pageBg: tint(accent, 0.10),
|
||||||
|
cardBg: [255, 255, 255],
|
||||||
|
cardBorder: tint(accent, 0.28),
|
||||||
|
// Sparkles/blob sit on the tinted page, so they need more punch than the
|
||||||
|
// card border or they simply vanish.
|
||||||
|
deko: tint(accent, 0.45),
|
||||||
|
dekoHell: tint(accent, 0.22),
|
||||||
|
band: tint(accent, 0.22),
|
||||||
|
pillBg: tint(accent, 0.18),
|
||||||
|
pillInk: shade(accent, 0.6),
|
||||||
|
sidebarBg, // unused in this layout, kept so the shape stays stable
|
||||||
|
track: tint(accent, 0.45),
|
||||||
|
onSide: shade(accent, 0.35),
|
||||||
|
onSideSub: tint(shade(accent, 0.55), 0.75),
|
||||||
|
},
|
||||||
|
// Sunny keeps the page white and puts the colour into solid blocks: pastel
|
||||||
|
// bands and rails on one side, the full-strength accent in the display
|
||||||
|
// headings and the role badge. Only two tones, used at maximum contrast.
|
||||||
|
sunny: {
|
||||||
|
ink: [40, 36, 42],
|
||||||
|
sub: [124, 118, 126],
|
||||||
|
hair: tint(accent, 0.22),
|
||||||
|
accent,
|
||||||
|
pageBg: null, // white page — the colour lives in the bands
|
||||||
|
cardBg: [255, 255, 255],
|
||||||
|
cardBorder: tint(accent, 0.22),
|
||||||
|
deko: tint(accent, 0.30),
|
||||||
|
dekoHell: tint(accent, 0.16),
|
||||||
|
band: tint(accent, 0.24), // the pastel hero band and section bars
|
||||||
|
pillBg: accent, // solid badge…
|
||||||
|
pillInk: [255, 255, 255], // …with the text knocked out white
|
||||||
|
sidebarBg, // unused
|
||||||
|
track: tint(accent, 0.16), // rail behind a language level bar
|
||||||
|
onSide: [40, 36, 42],
|
||||||
|
onSideSub: [124, 118, 126],
|
||||||
|
},
|
||||||
|
// Tupfen is a monochrome page that happens to have confetti on it. The text
|
||||||
|
// is ink on white throughout — the accent never touches a paragraph, only
|
||||||
|
// the dots: the scattered ones in the margins, the section markers, the
|
||||||
|
// bullets, the language meters. The hairline stays a *neutral* grey rather
|
||||||
|
// than a tinted one, or the restraint collapses and the page turns pink.
|
||||||
|
tupfen: {
|
||||||
|
ink: [26, 26, 28],
|
||||||
|
sub: [112, 114, 120],
|
||||||
|
hair: [226, 228, 232],
|
||||||
|
accent,
|
||||||
|
pageBg: null, // white page — the dots are the whole design
|
||||||
|
cardBg: [255, 255, 255],
|
||||||
|
cardBorder: [226, 228, 232],
|
||||||
|
deko: tint(accent, 0.62), // the dots' base tone
|
||||||
|
dekoHell: tint(accent, 0.34), // …and the paler ones behind them, for depth
|
||||||
|
band: tint(accent, 0.14),
|
||||||
|
pillBg: tint(accent, 0.12),
|
||||||
|
pillInk: shade(accent, 0.72),
|
||||||
|
sidebarBg, // unused
|
||||||
|
track: tint(accent, 0.20), // an unfilled dot of a language meter
|
||||||
|
onSide: [26, 26, 28],
|
||||||
|
onSideSub: [112, 114, 120],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const rc = RC[s.layout] || RC.sidebar;
|
||||||
|
|
||||||
|
// The cover letter always mirrors the résumé, so the two documents read as one
|
||||||
|
// set — a playful CV never arrives with a stiff monochrome letter.
|
||||||
|
const LC = {
|
||||||
|
sidebar: {
|
||||||
|
ink: [26, 26, 26],
|
||||||
|
muted: [92, 96, 100],
|
||||||
|
hair: [206, 208, 212],
|
||||||
|
accent,
|
||||||
|
pageBg: null,
|
||||||
|
cardBg: [255, 255, 255],
|
||||||
|
cardBorder: [206, 208, 212],
|
||||||
|
deko: [206, 208, 212],
|
||||||
|
dekoHell: [230, 232, 235],
|
||||||
|
band: rc.band,
|
||||||
|
pillBg: tint(accent, 0.12),
|
||||||
|
pillInk: accent,
|
||||||
|
},
|
||||||
|
social: {
|
||||||
|
ink: rc.ink,
|
||||||
|
muted: rc.sub,
|
||||||
|
hair: rc.hair,
|
||||||
|
accent,
|
||||||
|
pageBg: rc.pageBg,
|
||||||
|
cardBg: rc.cardBg,
|
||||||
|
cardBorder: rc.cardBorder,
|
||||||
|
deko: rc.deko,
|
||||||
|
dekoHell: rc.dekoHell,
|
||||||
|
band: rc.band,
|
||||||
|
pillBg: rc.pillBg,
|
||||||
|
pillInk: rc.pillInk,
|
||||||
|
},
|
||||||
|
sunny: {
|
||||||
|
ink: rc.ink,
|
||||||
|
muted: rc.sub,
|
||||||
|
hair: rc.hair,
|
||||||
|
accent,
|
||||||
|
pageBg: rc.pageBg,
|
||||||
|
cardBg: rc.cardBg,
|
||||||
|
cardBorder: rc.cardBorder,
|
||||||
|
deko: rc.deko,
|
||||||
|
dekoHell: rc.dekoHell,
|
||||||
|
band: rc.band,
|
||||||
|
// The letter's role badge sits on the pastel head band, so a solid accent
|
||||||
|
// slug reads too loud next to the résumé's poster. A white pill with the
|
||||||
|
// accent knocked out keeps the motif but softens it — the letter is the
|
||||||
|
// quiet twin, not a second poster.
|
||||||
|
pillBg: rc.cardBg,
|
||||||
|
pillInk: accent,
|
||||||
|
},
|
||||||
|
// Tupfen's letter is not the quieter twin of anything — the résumé is already
|
||||||
|
// quiet. Both sheets are the same white page with the same confetti, so the
|
||||||
|
// letter simply takes the résumé's tones unchanged.
|
||||||
|
tupfen: {
|
||||||
|
ink: rc.ink,
|
||||||
|
muted: rc.sub,
|
||||||
|
hair: rc.hair,
|
||||||
|
accent,
|
||||||
|
pageBg: rc.pageBg,
|
||||||
|
cardBg: rc.cardBg,
|
||||||
|
cardBorder: rc.cardBorder,
|
||||||
|
deko: rc.deko,
|
||||||
|
dekoHell: rc.dekoHell,
|
||||||
|
band: rc.band,
|
||||||
|
pillBg: rc.pillBg,
|
||||||
|
pillInk: rc.pillInk,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const lc = LC[s.layout] || LC.sidebar;
|
||||||
|
|
||||||
|
// Which typefaces the renderer registers and draws with. The playful layout
|
||||||
|
// adds a script face for its "Hallo, ich bin" line; Sunny lives off Poppins
|
||||||
|
// Bold alone (its whole hierarchy is size, not style); the classic one stays
|
||||||
|
// on Lato and embeds nothing extra. Tupfen takes Poppins too — the reference's
|
||||||
|
// geometric grotesk, whose round, wide letterforms rhyme with the dots.
|
||||||
|
const FONTS = {
|
||||||
|
sidebar: { sans: 'Lato', script: null },
|
||||||
|
social: { sans: 'Poppins', script: 'Pacifico' },
|
||||||
|
sunny: { sans: 'Poppins', script: null },
|
||||||
|
tupfen: { sans: 'Poppins', script: null },
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
layout: s.layout,
|
||||||
|
fonts: FONTS[s.layout] || FONTS.sidebar,
|
||||||
|
rc,
|
||||||
|
lc,
|
||||||
|
foto: {
|
||||||
|
anzeigen: s.foto_anzeigen !== '0',
|
||||||
|
rund: s.foto_form === 'rund',
|
||||||
|
},
|
||||||
|
// Starting scale for the one-page fit. The auto-scaler may still shrink
|
||||||
|
// below this when there is a lot of content — it never grows past it.
|
||||||
|
scale: (Number(s.schrift) || 100) / 100,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Field list for the Vorlagen UI, with the current selection marked. Fields that
|
||||||
|
// only apply to one layout carry `nurLayout` so the form can hide them.
|
||||||
|
function list(overrides = {}) {
|
||||||
|
const s = settings(overrides);
|
||||||
|
return FELDER.map((f) => ({
|
||||||
|
key: f.key,
|
||||||
|
label: f.label,
|
||||||
|
aktuell: s[f.key],
|
||||||
|
nurLayout: f.nurLayout || null,
|
||||||
|
optionen: f.optionen.map((o) => ({ key: o.key, label: o.label, gewaehlt: o.key === s[f.key] })),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// True when the user deviates from what the chosen layout would ship with.
|
||||||
|
function isAngepasst(overrides = {}) {
|
||||||
|
const s = settings(overrides);
|
||||||
|
const basis = { ...DEFAULTS, ...(LAYOUT_DEFAULTS[s.layout] || {}), layout: s.layout };
|
||||||
|
return s.layout !== DEFAULTS.layout || Object.keys(basis).some((k) => s[k] !== basis[k]);
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
DEFAULTS, LAYOUT_DEFAULTS, AKZENTE, LAYOUTS, FELDER,
|
||||||
|
settings, resolve, list, isAngepasst,
|
||||||
|
defaultTheme: () => resolve({}),
|
||||||
|
};
|
||||||
+2372
-238
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,93 @@
|
|||||||
|
Copyright 2018 The Pacifico Project Authors (https://github.com/googlefonts/Pacifico)
|
||||||
|
|
||||||
|
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||||
|
This license is copied below, and is also available with a FAQ at:
|
||||||
|
http://scripts.sil.org/OFL
|
||||||
|
|
||||||
|
|
||||||
|
-----------------------------------------------------------
|
||||||
|
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||||
|
-----------------------------------------------------------
|
||||||
|
|
||||||
|
PREAMBLE
|
||||||
|
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||||
|
development of collaborative font projects, to support the font creation
|
||||||
|
efforts of academic and linguistic communities, and to provide a free and
|
||||||
|
open framework in which fonts may be shared and improved in partnership
|
||||||
|
with others.
|
||||||
|
|
||||||
|
The OFL allows the licensed fonts to be used, studied, modified and
|
||||||
|
redistributed freely as long as they are not sold by themselves. The
|
||||||
|
fonts, including any derivative works, can be bundled, embedded,
|
||||||
|
redistributed and/or sold with any software provided that any reserved
|
||||||
|
names are not used by derivative works. The fonts and derivatives,
|
||||||
|
however, cannot be released under any other type of license. The
|
||||||
|
requirement for fonts to remain under this license does not apply
|
||||||
|
to any document created using the fonts or their derivatives.
|
||||||
|
|
||||||
|
DEFINITIONS
|
||||||
|
"Font Software" refers to the set of files released by the Copyright
|
||||||
|
Holder(s) under this license and clearly marked as such. This may
|
||||||
|
include source files, build scripts and documentation.
|
||||||
|
|
||||||
|
"Reserved Font Name" refers to any names specified as such after the
|
||||||
|
copyright statement(s).
|
||||||
|
|
||||||
|
"Original Version" refers to the collection of Font Software components as
|
||||||
|
distributed by the Copyright Holder(s).
|
||||||
|
|
||||||
|
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||||
|
or substituting -- in part or in whole -- any of the components of the
|
||||||
|
Original Version, by changing formats or by porting the Font Software to a
|
||||||
|
new environment.
|
||||||
|
|
||||||
|
"Author" refers to any designer, engineer, programmer, technical
|
||||||
|
writer or other person who contributed to the Font Software.
|
||||||
|
|
||||||
|
PERMISSION & CONDITIONS
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining
|
||||||
|
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||||
|
redistribute, and sell modified and unmodified copies of the Font
|
||||||
|
Software, subject to the following conditions:
|
||||||
|
|
||||||
|
1) Neither the Font Software nor any of its individual components,
|
||||||
|
in Original or Modified Versions, may be sold by itself.
|
||||||
|
|
||||||
|
2) Original or Modified Versions of the Font Software may be bundled,
|
||||||
|
redistributed and/or sold with any software, provided that each copy
|
||||||
|
contains the above copyright notice and this license. These can be
|
||||||
|
included either as stand-alone text files, human-readable headers or
|
||||||
|
in the appropriate machine-readable metadata fields within text or
|
||||||
|
binary files as long as those fields can be easily viewed by the user.
|
||||||
|
|
||||||
|
3) No Modified Version of the Font Software may use the Reserved Font
|
||||||
|
Name(s) unless explicit written permission is granted by the corresponding
|
||||||
|
Copyright Holder. This restriction only applies to the primary font name as
|
||||||
|
presented to the users.
|
||||||
|
|
||||||
|
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||||
|
Software shall not be used to promote, endorse or advertise any
|
||||||
|
Modified Version, except to acknowledge the contribution(s) of the
|
||||||
|
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||||
|
permission.
|
||||||
|
|
||||||
|
5) The Font Software, modified or unmodified, in part or in whole,
|
||||||
|
must be distributed entirely under this license, and must not be
|
||||||
|
distributed under any other license. The requirement for fonts to
|
||||||
|
remain under this license does not apply to any document created
|
||||||
|
using the Font Software.
|
||||||
|
|
||||||
|
TERMINATION
|
||||||
|
This license becomes null and void if any of the above conditions are
|
||||||
|
not met.
|
||||||
|
|
||||||
|
DISCLAIMER
|
||||||
|
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||||
|
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||||
|
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||||
|
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||||
|
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||||
|
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||||
|
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||||
|
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||||
|
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
Copyright 2020 The Poppins Project Authors (https://github.com/itfoundry/Poppins)
|
||||||
|
|
||||||
|
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||||
|
This license is copied below, and is also available with a FAQ at:
|
||||||
|
http://scripts.sil.org/OFL
|
||||||
|
|
||||||
|
|
||||||
|
-----------------------------------------------------------
|
||||||
|
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||||
|
-----------------------------------------------------------
|
||||||
|
|
||||||
|
PREAMBLE
|
||||||
|
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||||
|
development of collaborative font projects, to support the font creation
|
||||||
|
efforts of academic and linguistic communities, and to provide a free and
|
||||||
|
open framework in which fonts may be shared and improved in partnership
|
||||||
|
with others.
|
||||||
|
|
||||||
|
The OFL allows the licensed fonts to be used, studied, modified and
|
||||||
|
redistributed freely as long as they are not sold by themselves. The
|
||||||
|
fonts, including any derivative works, can be bundled, embedded,
|
||||||
|
redistributed and/or sold with any software provided that any reserved
|
||||||
|
names are not used by derivative works. The fonts and derivatives,
|
||||||
|
however, cannot be released under any other type of license. The
|
||||||
|
requirement for fonts to remain under this license does not apply
|
||||||
|
to any document created using the fonts or their derivatives.
|
||||||
|
|
||||||
|
DEFINITIONS
|
||||||
|
"Font Software" refers to the set of files released by the Copyright
|
||||||
|
Holder(s) under this license and clearly marked as such. This may
|
||||||
|
include source files, build scripts and documentation.
|
||||||
|
|
||||||
|
"Reserved Font Name" refers to any names specified as such after the
|
||||||
|
copyright statement(s).
|
||||||
|
|
||||||
|
"Original Version" refers to the collection of Font Software components as
|
||||||
|
distributed by the Copyright Holder(s).
|
||||||
|
|
||||||
|
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||||
|
or substituting -- in part or in whole -- any of the components of the
|
||||||
|
Original Version, by changing formats or by porting the Font Software to a
|
||||||
|
new environment.
|
||||||
|
|
||||||
|
"Author" refers to any designer, engineer, programmer, technical
|
||||||
|
writer or other person who contributed to the Font Software.
|
||||||
|
|
||||||
|
PERMISSION & CONDITIONS
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining
|
||||||
|
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||||
|
redistribute, and sell modified and unmodified copies of the Font
|
||||||
|
Software, subject to the following conditions:
|
||||||
|
|
||||||
|
1) Neither the Font Software nor any of its individual components,
|
||||||
|
in Original or Modified Versions, may be sold by itself.
|
||||||
|
|
||||||
|
2) Original or Modified Versions of the Font Software may be bundled,
|
||||||
|
redistributed and/or sold with any software, provided that each copy
|
||||||
|
contains the above copyright notice and this license. These can be
|
||||||
|
included either as stand-alone text files, human-readable headers or
|
||||||
|
in the appropriate machine-readable metadata fields within text or
|
||||||
|
binary files as long as those fields can be easily viewed by the user.
|
||||||
|
|
||||||
|
3) No Modified Version of the Font Software may use the Reserved Font
|
||||||
|
Name(s) unless explicit written permission is granted by the corresponding
|
||||||
|
Copyright Holder. This restriction only applies to the primary font name as
|
||||||
|
presented to the users.
|
||||||
|
|
||||||
|
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||||
|
Software shall not be used to promote, endorse or advertise any
|
||||||
|
Modified Version, except to acknowledge the contribution(s) of the
|
||||||
|
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||||
|
permission.
|
||||||
|
|
||||||
|
5) The Font Software, modified or unmodified, in part or in whole,
|
||||||
|
must be distributed entirely under this license, and must not be
|
||||||
|
distributed under any other license. The requirement for fonts to
|
||||||
|
remain under this license does not apply to any document created
|
||||||
|
using the Font Software.
|
||||||
|
|
||||||
|
TERMINATION
|
||||||
|
This license becomes null and void if any of the above conditions are
|
||||||
|
not met.
|
||||||
|
|
||||||
|
DISCLAIMER
|
||||||
|
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||||
|
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||||
|
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||||
|
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||||
|
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||||
|
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||||
|
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||||
|
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||||
|
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,44 @@
|
|||||||
|
// Shared label vocabulary for Stellen (Bewerbungen + Jobangebote).
|
||||||
|
//
|
||||||
|
// Labels describe the work-location character of a position. Several labels may
|
||||||
|
// be set per Stelle. They are stored as a JSON array string in the `labels`
|
||||||
|
// column and exposed as a string[] through the web UI and the REST API.
|
||||||
|
|
||||||
|
const LABEL_OPTIONS = [
|
||||||
|
'Regional',
|
||||||
|
'Remote-Deutschlandweit',
|
||||||
|
'Homeoffice-Deutschlandweit',
|
||||||
|
];
|
||||||
|
|
||||||
|
// Normalize any incoming labels value (array, JSON string, comma-separated
|
||||||
|
// string, or a single label) into a clean, de-duplicated array limited to
|
||||||
|
// LABEL_OPTIONS and returned in canonical order for stable output.
|
||||||
|
function parseLabels(raw) {
|
||||||
|
if (raw == null || raw === '') return [];
|
||||||
|
let arr;
|
||||||
|
if (Array.isArray(raw)) {
|
||||||
|
arr = raw;
|
||||||
|
} else if (typeof raw === 'string') {
|
||||||
|
const s = raw.trim();
|
||||||
|
if (!s) return [];
|
||||||
|
if (s[0] === '[') {
|
||||||
|
try { arr = JSON.parse(s); } catch (e) { arr = []; }
|
||||||
|
if (!Array.isArray(arr)) arr = [];
|
||||||
|
} else {
|
||||||
|
arr = s.split(',');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const chosen = new Set(
|
||||||
|
arr.map((v) => String(v).trim()).filter((v) => LABEL_OPTIONS.includes(v))
|
||||||
|
);
|
||||||
|
return LABEL_OPTIONS.filter((l) => chosen.has(l));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Canonical JSON string for storage in the DB (always a valid array).
|
||||||
|
function serializeLabels(raw) {
|
||||||
|
return JSON.stringify(parseLabels(raw));
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { LABEL_OPTIONS, parseLabels, serializeLabels };
|
||||||
+25
-12
@@ -11,18 +11,21 @@
|
|||||||
const nodemailer = require('nodemailer');
|
const nodemailer = require('nodemailer');
|
||||||
const { ImapFlow } = require('imapflow');
|
const { ImapFlow } = require('imapflow');
|
||||||
const { simpleParser } = require('mailparser');
|
const { simpleParser } = require('mailparser');
|
||||||
|
const config = require('./config');
|
||||||
|
|
||||||
|
// Read fresh on every call so an edit on the /einstellungen page takes effect
|
||||||
|
// immediately (values live in the DB now, not in process.env/.env).
|
||||||
function cfg() {
|
function cfg() {
|
||||||
const smtpPort = Number(process.env.MAIL_SMTP_PORT || 587);
|
const smtpPort = Number(config.get('MAIL_SMTP_PORT') || 587);
|
||||||
return {
|
return {
|
||||||
host: process.env.MAIL_HOST || '',
|
host: config.get('MAIL_HOST') || '',
|
||||||
smtpPort,
|
smtpPort,
|
||||||
imapPort: Number(process.env.MAIL_IMAP_PORT || 993),
|
imapPort: Number(config.get('MAIL_IMAP_PORT') || 993),
|
||||||
user: process.env.MAIL_USER || '',
|
user: config.get('MAIL_USER') || '',
|
||||||
pass: process.env.MAIL_PASSWORD || '',
|
pass: config.get('MAIL_PASSWORD') || '',
|
||||||
fromName: process.env.MAIL_FROM_NAME || '',
|
fromName: config.get('MAIL_FROM_NAME') || '',
|
||||||
fromAddr: process.env.MAIL_FROM || process.env.MAIL_USER || '',
|
fromAddr: config.get('MAIL_FROM') || config.get('MAIL_USER') || '',
|
||||||
mailbox: process.env.MAIL_IMAP_MAILBOX || 'INBOX',
|
mailbox: config.get('MAIL_IMAP_MAILBOX') || 'INBOX',
|
||||||
// secure=true means implicit TLS (465); 587 uses STARTTLS (requireTLS).
|
// secure=true means implicit TLS (465); 587 uses STARTTLS (requireTLS).
|
||||||
smtpSecure: smtpPort === 465,
|
smtpSecure: smtpPort === 465,
|
||||||
};
|
};
|
||||||
@@ -62,12 +65,22 @@ async function verify() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Minimal HTML rendering of a plain-text body: escape, then turn newlines into
|
// Minimal HTML rendering of a plain-text body: escape, then turn newlines into
|
||||||
// <br>. Sending multipart/alternative (text + html) reads as normal personal
|
// explicit <br> tags. Sending multipart/alternative (text + html) reads as
|
||||||
// mail and avoids the "text-only, no html" heuristic some filters apply.
|
// normal personal mail and avoids the "text-only, no html" heuristic some
|
||||||
|
// filters apply.
|
||||||
|
//
|
||||||
|
// The line breaks MUST be real <br> tags, not merely CSS white-space:pre-wrap
|
||||||
|
// on literal newlines: pre-wrap renders correctly on first receipt, but when
|
||||||
|
// the recipient's client (e.g. Outlook) quotes the message back to us it
|
||||||
|
// re-parses the HTML, drops the pre-wrap style and collapses the bare newlines
|
||||||
|
// into spaces — turning our carefully formatted reply into a run-on wall of
|
||||||
|
// text in the quoted thread. Explicit <br> survives that round-trip.
|
||||||
function textToHtml(text) {
|
function textToHtml(text) {
|
||||||
const esc = String(text || '')
|
const esc = String(text || '')
|
||||||
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||||
return `<div style="font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:1.5;color:#1a1a1a;white-space:pre-wrap">${esc}</div>`;
|
.replace(/\r\n?/g, '\n')
|
||||||
|
.replace(/\n/g, '<br>\n');
|
||||||
|
return `<div style="font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:1.5;color:#1a1a1a">${esc}</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Send one message via authenticated submission. Returns nodemailer info
|
// Send one message via authenticated submission. Returns nodemailer info
|
||||||
|
|||||||
@@ -0,0 +1,339 @@
|
|||||||
|
// Idempotent migration from the single-user schema to the multi-user schema.
|
||||||
|
//
|
||||||
|
// Used both at server boot (server.js calls runMigration after opening the DB)
|
||||||
|
// and by the standalone scripts/migrate-to-multiuser.js. Safe to run repeatedly:
|
||||||
|
// every step guards itself with "already done?" checks.
|
||||||
|
//
|
||||||
|
// What it does, in order:
|
||||||
|
// 1. Create the `users` + `sessions` tables.
|
||||||
|
// 2. Ensure an `admin` user exists (password "admin", scrypt hash). Record its id.
|
||||||
|
// 3. Add a `user_id` column to every per-user table (ALTER ADD COLUMN, nullable
|
||||||
|
// for upgraded installs — fresh installs create it NOT NULL directly) and
|
||||||
|
// backfill every existing row to the admin id.
|
||||||
|
// 4. Recreate the tables whose PRIMARY KEY / UNIQUE must include user_id so the
|
||||||
|
// constraint becomes per-user: app_state, settings, prompts, design,
|
||||||
|
// jobangebote. Old rows are copied to the admin user.
|
||||||
|
// 5. Move on-disk attachment files into a per-user subdirectory for the admin.
|
||||||
|
// 6. One-time migration of any still-present .env values into the admin's cfg.
|
||||||
|
//
|
||||||
|
// After this, the app boots against a fully multi-user schema and all pre-existing
|
||||||
|
// data is owned by the admin user.
|
||||||
|
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const password = require('./password');
|
||||||
|
const config = require('./config');
|
||||||
|
|
||||||
|
// Tables that need a `user_id` column added (legacy upgrades). Fresh installs
|
||||||
|
// create these with user_id NOT NULL directly in initializeDatabase().
|
||||||
|
const USER_TABLES = [
|
||||||
|
'bewerbungen', 'status_verlauf', 'anhaenge', 'interne_anhaenge',
|
||||||
|
'emails', 'email_anhaenge', 'basis_dokumente', 'basis_anhaenge',
|
||||||
|
'termine', 'chat_threads', 'chat_messages',
|
||||||
|
'jobangebote_blacklist',
|
||||||
|
];
|
||||||
|
// jobangebote is NOT in USER_TABLES: its user_id column + per-user UNIQUE
|
||||||
|
// constraint are installed by the recreate in step 4 (which needs user_id to be
|
||||||
|
// absent so the rebuild fires). Adding it here first would make step 4's
|
||||||
|
// !hasColumn guard short-circuit and leave the legacy single-user UNIQUE in
|
||||||
|
// place, breaking per-user isolation on ingest.
|
||||||
|
|
||||||
|
const ADMIN_USERNAME = 'admin';
|
||||||
|
const ADMIN_DEFAULT_PASSWORD = 'admin';
|
||||||
|
|
||||||
|
async function runMigration({ db, dbAll, dbGet, dbRun }) {
|
||||||
|
// Wire the config store to the same DB so config.setForUser works during the
|
||||||
|
// one-time .env migration below.
|
||||||
|
await config.init({ dbAll, dbRun });
|
||||||
|
const exec = (sql) => new Promise((resolve, reject) => db.run(sql, (err) => err ? reject(err) : resolve()));
|
||||||
|
|
||||||
|
const tableExists = async (name) => {
|
||||||
|
const row = await dbGet("SELECT name FROM sqlite_master WHERE type='table' AND name=?", [name]);
|
||||||
|
return !!row;
|
||||||
|
};
|
||||||
|
|
||||||
|
const columnsOf = async (table) => {
|
||||||
|
const rows = await dbAll(`PRAGMA table_info(${table})`);
|
||||||
|
return rows.map((r) => r.name);
|
||||||
|
};
|
||||||
|
|
||||||
|
const hasColumn = async (table, col) => (await columnsOf(table)).includes(col);
|
||||||
|
|
||||||
|
// 1. users + sessions -------------------------------------------------
|
||||||
|
await exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
username TEXT NOT NULL UNIQUE,
|
||||||
|
password_hash TEXT NOT NULL,
|
||||||
|
is_admin INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
await exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS sessions (
|
||||||
|
token TEXT PRIMARY KEY,
|
||||||
|
user_id INTEGER NOT NULL,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
last_seen DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
await exec('CREATE INDEX IF NOT EXISTS idx_sessions_user ON sessions(user_id)');
|
||||||
|
// Impersonation support: the admin's session keeps its token but switches
|
||||||
|
// user_id to the target, storing the original admin in impersonator_id so the
|
||||||
|
// admin can switch back. Added after the fact for existing installs.
|
||||||
|
if (!(await hasColumn('sessions', 'impersonator_id'))) {
|
||||||
|
await exec('ALTER TABLE sessions ADD COLUMN impersonator_id INTEGER');
|
||||||
|
}
|
||||||
|
// "Angemeldet bleiben": vorher galten alle Sessions 30 Tage. Bestehende
|
||||||
|
// Sessions behalten dieses Verhalten (Default 1) — niemand wird durch das
|
||||||
|
// Update ausgeloggt.
|
||||||
|
if (!(await hasColumn('sessions', 'dauerhaft'))) {
|
||||||
|
await exec('ALTER TABLE sessions ADD COLUMN dauerhaft INTEGER NOT NULL DEFAULT 1');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Ensure admin user (idempotent) -----------------------------------
|
||||||
|
let admin = await dbGet('SELECT id, password_hash FROM users WHERE username = ?', [ADMIN_USERNAME]);
|
||||||
|
if (!admin) {
|
||||||
|
const hash = password.hash(ADMIN_DEFAULT_PASSWORD);
|
||||||
|
const res = await dbRun(
|
||||||
|
'INSERT INTO users (username, password_hash, is_admin) VALUES (?, ?, 1)',
|
||||||
|
[ADMIN_USERNAME, hash]
|
||||||
|
);
|
||||||
|
admin = { id: res.lastID };
|
||||||
|
console.log(`Multi-User-Migration: Admin-Benutzer „${ADMIN_USERNAME}“ angelegt (Passwort „${ADMIN_DEFAULT_PASSWORD}“). Bitte nach erstem Login ändern.`);
|
||||||
|
}
|
||||||
|
const adminId = admin.id;
|
||||||
|
|
||||||
|
// 3. Add user_id column + backfill ------------------------------------
|
||||||
|
for (const tbl of USER_TABLES) {
|
||||||
|
if (!(await tableExists(tbl))) continue; // table may not exist yet on a partial install
|
||||||
|
if (!(await hasColumn(tbl, 'user_id'))) {
|
||||||
|
await exec(`ALTER TABLE ${tbl} ADD COLUMN user_id INTEGER`);
|
||||||
|
}
|
||||||
|
// Backfill every row that is not yet assigned (NULL) to the admin.
|
||||||
|
await dbRun(`UPDATE ${tbl} SET user_id = ? WHERE user_id IS NULL`, [adminId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Recreate tables whose PK/UNIQUE must be per-user -----------------
|
||||||
|
// app_state: PK(key) -> PK(user_id, key)
|
||||||
|
if (await tableExists('app_state')) {
|
||||||
|
if (!(await hasColumn('app_state', 'user_id'))) {
|
||||||
|
await recreate(db, dbAll, dbRun, 'app_state',
|
||||||
|
`CREATE TABLE app_state (
|
||||||
|
user_id INTEGER NOT NULL,
|
||||||
|
key TEXT NOT NULL,
|
||||||
|
value TEXT,
|
||||||
|
PRIMARY KEY (user_id, key),
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||||
|
)`,
|
||||||
|
`INSERT INTO app_state (user_id, key, value) SELECT ?, key, value FROM app_state_old`,
|
||||||
|
[adminId]
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
await dbRun('UPDATE app_state SET user_id = ? WHERE user_id IS NULL', [adminId]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// prompts: PK(key) -> PK(user_id, key)
|
||||||
|
if (await tableExists('prompts') && !(await hasColumn('prompts', 'user_id'))) {
|
||||||
|
await recreate(db, dbAll, dbRun, 'prompts',
|
||||||
|
`CREATE TABLE prompts (
|
||||||
|
user_id INTEGER NOT NULL,
|
||||||
|
key TEXT NOT NULL,
|
||||||
|
inhalt TEXT NOT NULL,
|
||||||
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (user_id, key),
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||||
|
)`,
|
||||||
|
`INSERT INTO prompts (user_id, key, inhalt, updated_at) SELECT ?, key, inhalt, updated_at FROM prompts_old`,
|
||||||
|
[adminId]
|
||||||
|
);
|
||||||
|
} else if (await tableExists('prompts')) {
|
||||||
|
await dbRun('UPDATE prompts SET user_id = ? WHERE user_id IS NULL', [adminId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// design: PK(key) -> PK(user_id, key)
|
||||||
|
if (await tableExists('design') && !(await hasColumn('design', 'user_id'))) {
|
||||||
|
await recreate(db, dbAll, dbRun, 'design',
|
||||||
|
`CREATE TABLE design (
|
||||||
|
user_id INTEGER NOT NULL,
|
||||||
|
key TEXT NOT NULL,
|
||||||
|
value TEXT NOT NULL,
|
||||||
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (user_id, key),
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||||
|
)`,
|
||||||
|
`INSERT INTO design (user_id, key, value, updated_at) SELECT ?, key, value, updated_at FROM design_old`,
|
||||||
|
[adminId]
|
||||||
|
);
|
||||||
|
} else if (await tableExists('design')) {
|
||||||
|
await dbRun('UPDATE design SET user_id = ? WHERE user_id IS NULL', [adminId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// settings: single row CHECK(id=1) -> per-user row (user_id UNIQUE)
|
||||||
|
if (await tableExists('settings') && !(await hasColumn('settings', 'user_id'))) {
|
||||||
|
await recreate(db, dbAll, dbRun, 'settings',
|
||||||
|
`CREATE TABLE settings (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
user_id INTEGER NOT NULL UNIQUE,
|
||||||
|
name TEXT,
|
||||||
|
adresse TEXT,
|
||||||
|
kundennummer TEXT,
|
||||||
|
ort TEXT,
|
||||||
|
webseite TEXT,
|
||||||
|
email TEXT,
|
||||||
|
telefon TEXT,
|
||||||
|
geburtsdatum TEXT,
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||||
|
)`,
|
||||||
|
`INSERT INTO settings (user_id, name, adresse, kundennummer, ort, webseite, email, telefon, geburtsdatum)
|
||||||
|
SELECT ?, name, adresse, kundennummer, ort, webseite, email, telefon, geburtsdatum FROM settings_old`,
|
||||||
|
[adminId]
|
||||||
|
);
|
||||||
|
} else if (await tableExists('settings')) {
|
||||||
|
await dbRun('UPDATE settings SET user_id = ? WHERE user_id IS NULL', [adminId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// jobangebote: UNIQUE(quelle, external_id) -> UNIQUE(user_id, quelle, external_id)
|
||||||
|
if (await tableExists('jobangebote') && !(await hasColumn('jobangebote', 'user_id'))) {
|
||||||
|
// user_id was added in step 3 already; this branch only recreates for the
|
||||||
|
// per-user UNIQUE constraint. Rebuild preserving all columns.
|
||||||
|
const cols = (await columnsOf('jobangebote')).filter((c) => c !== 'user_id');
|
||||||
|
const colList = cols.join(', ');
|
||||||
|
await recreate(db, dbAll, dbRun, 'jobangebote',
|
||||||
|
`CREATE TABLE jobangebote (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
user_id INTEGER NOT NULL,
|
||||||
|
external_id TEXT,
|
||||||
|
quelle TEXT NOT NULL DEFAULT 'drittanbieter',
|
||||||
|
firma TEXT NOT NULL,
|
||||||
|
stelle TEXT NOT NULL,
|
||||||
|
ort TEXT,
|
||||||
|
adresse TEXT,
|
||||||
|
ansprechpartner TEXT,
|
||||||
|
gehalt TEXT,
|
||||||
|
beschreibung TEXT,
|
||||||
|
quelle_url TEXT,
|
||||||
|
art TEXT,
|
||||||
|
anzeige_datum DATE,
|
||||||
|
kontakt_email TEXT,
|
||||||
|
status TEXT NOT NULL DEFAULT 'offen',
|
||||||
|
verknuepfte_bewerbung_id INTEGER,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
url_norm TEXT,
|
||||||
|
firma_slug TEXT,
|
||||||
|
labels TEXT,
|
||||||
|
UNIQUE (user_id, quelle, external_id),
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (verknuepfte_bewerbung_id) REFERENCES bewerbungen(id) ON DELETE SET NULL
|
||||||
|
)`,
|
||||||
|
`INSERT INTO jobangebote (user_id, ${colList}) SELECT ?, ${colList} FROM jobangebote_old`,
|
||||||
|
[adminId]
|
||||||
|
);
|
||||||
|
await exec('CREATE INDEX IF NOT EXISTS idx_jobangebote_url_norm ON jobangebote(url_norm)');
|
||||||
|
await exec('CREATE INDEX IF NOT EXISTS idx_jobangebote_firma_slug ON jobangebote(firma_slug)');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure a settings row exists for the admin (idempotent). On a fresh install
|
||||||
|
// the settings table does not exist yet at this point (it is created by the
|
||||||
|
// server's CREATE TABLE IF NOT EXISTS right after the migration), so skip —
|
||||||
|
// the row is upserted on the admin's first /vorlagen save anyway.
|
||||||
|
if (await tableExists('settings')) {
|
||||||
|
const srow = await dbGet('SELECT id FROM settings WHERE user_id = ?', [adminId]);
|
||||||
|
if (!srow) {
|
||||||
|
await dbRun(
|
||||||
|
`INSERT INTO settings (user_id, name, adresse, kundennummer, ort, webseite, email, telefon, geburtsdatum)
|
||||||
|
VALUES (?, 'Max Mustermann', 'Musterstraße 1, 12345 Musterstadt', '', '', '', '', '', '')`,
|
||||||
|
[adminId]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Move on-disk files into a per-user subdirectory for the admin -----
|
||||||
|
moveFilesIntoUserSubdir(adminId);
|
||||||
|
|
||||||
|
// 6. One-time env -> admin cfg import ----------------------------------
|
||||||
|
// On an upgraded install app_state already exists here, so this fills the
|
||||||
|
// admin's rows right away. On a *fresh* install the table is only created
|
||||||
|
// after this migration returns, so this is a no-op — server.js therefore
|
||||||
|
// calls importEnvIntoAdmin() again once the schema is complete. Idempotent
|
||||||
|
// either way (it only fills keys the admin has not stored).
|
||||||
|
await migrateEnvForAdmin(dbAll, adminId);
|
||||||
|
|
||||||
|
return { adminId };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Import any still-present env config into the admin's rows. Called at boot from
|
||||||
|
// server.js, after initializeDatabase() has created app_state — this is what
|
||||||
|
// makes a deployment that passes its config as container env vars (as ours does)
|
||||||
|
// end up with those values owned by the admin, instead of leaking to every user
|
||||||
|
// through a read-time process.env fallback (which config.get() no longer has).
|
||||||
|
async function importEnvIntoAdmin({ dbAll, dbGet }) {
|
||||||
|
const admin = await dbGet('SELECT id FROM users WHERE username = ?', [ADMIN_USERNAME]);
|
||||||
|
if (!admin) return;
|
||||||
|
await migrateEnvForAdmin(dbAll, admin.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rename `table` to `table_old`, create the new table from `newSchemaSql`,
|
||||||
|
// copy rows via `copySql` (with `copyParams`), then drop `table_old`.
|
||||||
|
async function recreate(db, dbAll, dbRun, table, newSchemaSql, copySql, copyParams) {
|
||||||
|
const exec = (sql) => new Promise((resolve, reject) => db.run(sql, (err) => err ? reject(err) : resolve()));
|
||||||
|
await exec(`ALTER TABLE ${table} RENAME TO ${table}_old`);
|
||||||
|
await exec(newSchemaSql);
|
||||||
|
await dbRun(copySql, copyParams || []);
|
||||||
|
await exec(`DROP TABLE ${table}_old`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Move every file in each per-user storage directory into a `<userId>/` subdir.
|
||||||
|
// Idempotent: if the subdir already contains files (already migrated), leave the
|
||||||
|
// top-level files alone (they would be a re-run left-overs) — but on first run the
|
||||||
|
// top level holds the legacy flat files, which we move in.
|
||||||
|
const STORAGE_DIRS = ['anhaenge', 'basis_anhaenge', 'interne_anhaenge', 'email_anhaenge', 'signatur', 'bewerberfoto'];
|
||||||
|
function moveFilesIntoUserSubdir(userId) {
|
||||||
|
const dataDir = path.join(__dirname, '..', 'data');
|
||||||
|
for (const dir of STORAGE_DIRS) {
|
||||||
|
const base = path.join(dataDir, dir);
|
||||||
|
if (!fs.existsSync(base)) continue;
|
||||||
|
const userDir = path.join(base, String(userId));
|
||||||
|
if (!fs.existsSync(userDir)) fs.mkdirSync(userDir, { recursive: true });
|
||||||
|
for (const name of fs.readdirSync(base)) {
|
||||||
|
if (name === String(userId)) continue;
|
||||||
|
const src = path.join(base, name);
|
||||||
|
if (!fs.statSync(src).isFile()) continue; // skip subdirectories
|
||||||
|
const dst = path.join(userDir, name);
|
||||||
|
if (!fs.existsSync(dst)) fs.renameSync(src, dst);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Move any still-present .env value for a DEFAULT key that the admin hasn't got
|
||||||
|
// stored yet into the admin's cfg rows. Idempotent: only fills keys that are not
|
||||||
|
// yet present for the admin.
|
||||||
|
async function migrateEnvForAdmin(dbAll, adminId) {
|
||||||
|
// On a fresh install app_state does not exist yet at this point (created by
|
||||||
|
// the server right after the migration), so there is nothing to read from or
|
||||||
|
// write to — skip. The .env values, if any, then fall back via config.get().
|
||||||
|
try {
|
||||||
|
await dbAll('SELECT 1 FROM app_state LIMIT 1');
|
||||||
|
} catch (e) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const storedRows = await dbAll('SELECT key FROM app_state WHERE user_id = ?', [adminId]);
|
||||||
|
const stored = new Set(storedRows.map((r) => r.key.slice(config.PREFIX.length)));
|
||||||
|
const migrated = [];
|
||||||
|
for (const key of Object.keys(config.DEFAULTS)) {
|
||||||
|
if (stored.has(key)) continue; // already in the DB, never overwrite from .env
|
||||||
|
const envVal = process.env[key];
|
||||||
|
if (envVal && envVal.length) {
|
||||||
|
await config.setForUser(adminId, key, envVal);
|
||||||
|
migrated.push(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (migrated.length) {
|
||||||
|
console.log(`Multi-User-Migration: ${migrated.length} Konfigurationswerte aus .env in den Admin-Benutzer migriert — .env wird nicht mehr benötigt.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { runMigration, importEnvIntoAdmin, moveFilesIntoUserSubdir, STORAGE_DIRS, ADMIN_USERNAME, ADMIN_DEFAULT_PASSWORD };
|
||||||
+102
-15
@@ -8,9 +8,19 @@ const ART_OPTIONS = [
|
|||||||
'Arbeitsagentur', 'Sonstiges',
|
'Arbeitsagentur', 'Sonstiges',
|
||||||
];
|
];
|
||||||
const STATUS_OPTIONS = [
|
const STATUS_OPTIONS = [
|
||||||
'Entwurf', 'Gesendet', 'Eingangsbestätigung', 'Vorstellungsgespräch',
|
'Entwurf', 'Gesendet', 'Eingangsbestätigung',
|
||||||
'Absage', 'Einstellung', 'Keine Rückmeldung',
|
'In Bearbeitung', 'Interessiert', 'Telefonat', 'Warten auf Rückmeldung', 'Warten auf meine Antwort',
|
||||||
|
'Vorstellungsgespräch', 'Vertragsverhandlung',
|
||||||
|
'Absage', 'Absage von meiner Seite', 'Einstellung', 'Keine Rückmeldung',
|
||||||
];
|
];
|
||||||
|
const { LABEL_OPTIONS } = require('./labels');
|
||||||
|
|
||||||
|
// Reusable schema for the labels array (work-location labels of a Stelle).
|
||||||
|
const labelsSchema = {
|
||||||
|
type: 'array',
|
||||||
|
items: { type: 'string', enum: LABEL_OPTIONS },
|
||||||
|
description: 'Labels der Stelle (Arbeitsort). Mehrere möglich.',
|
||||||
|
};
|
||||||
|
|
||||||
// Shared reusable schemas -----------------------------------------------
|
// Shared reusable schemas -----------------------------------------------
|
||||||
|
|
||||||
@@ -27,15 +37,26 @@ function buildOpenApiSpec(baseUrl = '') {
|
|||||||
return {
|
return {
|
||||||
openapi: '3.0.3',
|
openapi: '3.0.3',
|
||||||
info: {
|
info: {
|
||||||
title: 'Bewerbungs-Tracker REST-API',
|
title: 'NextJobs REST-API',
|
||||||
version: '1.0.0',
|
version: '1.0.0',
|
||||||
description:
|
description:
|
||||||
'REST-API für Drittanbietersoftware zum Lesen und Verwalten von ' +
|
'REST-API für Drittanbietersoftware zum Lesen und Verwalten von ' +
|
||||||
'Bewerbungen, Statusverlauf, generierten Unterlagen (PDFs), ' +
|
'Bewerbungen, Statusverlauf, generierten Unterlagen (PDFs), ' +
|
||||||
'E-Mail-Korrespondenz, Einstellungen und Statistiken.\n\n' +
|
'E-Mail-Korrespondenz, Einstellungen und Statistiken.\n\n' +
|
||||||
'Alle Endpunkte sind unter `/api/v1` gemountet und erfordern ' +
|
'Alle Endpunkte sind unter `/api/v1` gemountet und erfordern ' +
|
||||||
'Authentifizierung über einen API-Key im Header `X-API-Key` ' +
|
'Authentifizierung über einen API-Key im Header `X-API-Key`.\n\n' +
|
||||||
'(konfiguriert via Umgebungsvariable `API_TOKEN`).',
|
'### Der API-Key gehört immer genau einem Benutzer\n\n' +
|
||||||
|
'Der Tracker ist eine Multi-User-Plattform. Jeder Benutzer legt seinen ' +
|
||||||
|
'eigenen Token unter **Einstellungen → REST-API** an („Neu generieren“) — ' +
|
||||||
|
'es gibt keinen globalen oder serverweiten Key.\n\n' +
|
||||||
|
'Der übergebene `X-API-Key` bestimmt, **wessen** Daten die Anfrage sieht: ' +
|
||||||
|
'jeder Endpunkt liest und schreibt ausschließlich die Daten des Benutzers, ' +
|
||||||
|
'dem der Token gehört. Fremde Bewerbungen, E-Mails, Anhänge, Jobangebote ' +
|
||||||
|
'oder Einstellungen sind über die API nicht erreichbar — auch nicht durch ' +
|
||||||
|
'Angabe einer fremden `id`. In dem Fall antwortet die API mit `404`, als ' +
|
||||||
|
'gäbe es den Datensatz nicht. Ein unbekannter, leerer oder mehrdeutiger ' +
|
||||||
|
'Token führt zu `401`.\n\n' +
|
||||||
|
'Ein neu generierter Token macht den vorherigen sofort ungültig.',
|
||||||
},
|
},
|
||||||
servers: baseUrl ? [{ url: `${baseUrl}/api/v1` }] : [{ url: '/api/v1' }],
|
servers: baseUrl ? [{ url: `${baseUrl}/api/v1` }] : [{ url: '/api/v1' }],
|
||||||
tags: [
|
tags: [
|
||||||
@@ -79,6 +100,7 @@ function buildOpenApiSpec(baseUrl = '') {
|
|||||||
{ name: 'year', in: 'query', schema: { type: 'string', pattern: '^[0-9]{4}$' }, description: 'Jahr (z. B. 2026)' },
|
{ name: 'year', in: 'query', schema: { type: 'string', pattern: '^[0-9]{4}$' }, description: 'Jahr (z. B. 2026)' },
|
||||||
{ name: 'status', in: 'query', schema: { type: 'string', enum: STATUS_OPTIONS }, description: 'Filter nach Status' },
|
{ name: 'status', in: 'query', schema: { type: 'string', enum: STATUS_OPTIONS }, description: 'Filter nach Status' },
|
||||||
{ name: 'art', in: 'query', schema: { type: 'string', enum: ART_OPTIONS }, description: 'Filter nach Bewerbungsart' },
|
{ name: 'art', in: 'query', schema: { type: 'string', enum: ART_OPTIONS }, description: 'Filter nach Bewerbungsart' },
|
||||||
|
{ name: 'label', in: 'query', schema: { type: 'string', enum: LABEL_OPTIONS }, description: 'Filter nach Label (Stellen mit diesem Label)' },
|
||||||
{ name: 'search', in: 'query', schema: { type: 'string' }, description: 'Freitextsuche in Firma und Stelle' },
|
{ name: 'search', in: 'query', schema: { type: 'string' }, description: 'Freitextsuche in Firma und Stelle' },
|
||||||
{ name: 'limit', in: 'query', schema: { type: 'integer', minimum: 1, maximum: 500, default: 100 }, description: 'Max. Anzahl Ergebnisse' },
|
{ name: 'limit', in: 'query', schema: { type: 'integer', minimum: 1, maximum: 500, default: 100 }, description: 'Max. Anzahl Ergebnisse' },
|
||||||
{ name: 'offset', in: 'query', schema: { type: 'integer', minimum: 0, default: 0 }, description: 'Ergebnis-Offset (Paging)' },
|
{ name: 'offset', in: 'query', schema: { type: 'integer', minimum: 0, default: 0 }, description: 'Ergebnis-Offset (Paging)' },
|
||||||
@@ -351,7 +373,17 @@ function buildOpenApiSpec(baseUrl = '') {
|
|||||||
items: { type: 'integer' },
|
items: { type: 'integer' },
|
||||||
description:
|
description:
|
||||||
'IDs der zusätzlich beizulegenden statischen Anlagen (basis_anhaenge). ' +
|
'IDs der zusätzlich beizulegenden statischen Anlagen (basis_anhaenge). ' +
|
||||||
'Standard: keine. Die Auswahl erscheint auch im Anschreiben unter "Anlagen".',
|
'Weglassen = Vorauswahl aus den Einstellungen (GEN_ANLAGEN_DEFAULT, Standard: keine), ' +
|
||||||
|
'leeres Array = ausdrücklich keine. Die Auswahl erscheint auch im Anschreiben unter "Anlagen".',
|
||||||
|
},
|
||||||
|
dokumente: {
|
||||||
|
type: 'array',
|
||||||
|
items: { type: 'string', enum: ['anschreiben', 'lebenslauf'] },
|
||||||
|
description:
|
||||||
|
'Welche Unterlagen erzeugt werden sollen. Weglassen = Vorauswahl aus den Einstellungen ' +
|
||||||
|
'(GEN_DOKUMENTE_DEFAULT, Standard: beide). Wird der Lebenslauf nicht erzeugt, führt ihn ' +
|
||||||
|
'das Anschreiben auch nicht unter "Anlagen" auf, und der E-Mail-Begleittext kündigt ihn nicht an.',
|
||||||
|
example: ['anschreiben'],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -390,22 +422,48 @@ function buildOpenApiSpec(baseUrl = '') {
|
|||||||
'/settings': {
|
'/settings': {
|
||||||
get: {
|
get: {
|
||||||
tags: ['Settings'],
|
tags: ['Settings'],
|
||||||
summary: 'Einstellungen abrufen',
|
summary: 'Persönliche Angaben abrufen',
|
||||||
|
description:
|
||||||
|
'Liefert die persönlichen Angaben **des Benutzers, dem der API-Key gehört**. ' +
|
||||||
|
'Hat er noch nichts gespeichert, ist die Antwort ein leeres Objekt `{}`.',
|
||||||
responses: {
|
responses: {
|
||||||
'200': { description: 'Einstellungen', content: { 'application/json': { schema: { $ref: '#/components/schemas/Settings' } } } },
|
'200': { description: 'Persönliche Angaben (ggf. `{}`)', content: { 'application/json': { schema: { $ref: '#/components/schemas/Settings' } } } },
|
||||||
'401': errorResponse,
|
'401': errorResponse,
|
||||||
'500': errorResponse,
|
'500': errorResponse,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
put: {
|
put: {
|
||||||
tags: ['Settings'],
|
tags: ['Settings'],
|
||||||
summary: 'Einstellungen speichern',
|
summary: 'Persönliche Angaben speichern',
|
||||||
|
description:
|
||||||
|
'Speichert die persönlichen Angaben des eigenen Benutzers. Es werden nur die ' +
|
||||||
|
'Felder geändert, die im Body vorkommen — weggelassene Felder behalten ihren ' +
|
||||||
|
'Wert. Existiert noch keine Zeile, wird sie angelegt.',
|
||||||
requestBody: {
|
requestBody: {
|
||||||
required: true,
|
required: true,
|
||||||
content: { 'application/json': { schema: { $ref: '#/components/schemas/Settings' } } },
|
content: {
|
||||||
|
'application/json': {
|
||||||
|
schema: { $ref: '#/components/schemas/Settings' },
|
||||||
|
example: { name: 'Max Mustermann', ort: 'Berlin' },
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
responses: {
|
responses: {
|
||||||
'200': { description: 'Gespeichert', content: { 'application/json': { schema: { $ref: '#/components/schemas/Ok' } } } },
|
'200': {
|
||||||
|
description: 'Gespeichert; gibt den neuen Stand zurück',
|
||||||
|
content: {
|
||||||
|
'application/json': {
|
||||||
|
schema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
success: { type: 'boolean', example: true },
|
||||||
|
settings: { $ref: '#/components/schemas/Settings' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'400': errorResponse,
|
||||||
'401': errorResponse,
|
'401': errorResponse,
|
||||||
'500': errorResponse,
|
'500': errorResponse,
|
||||||
},
|
},
|
||||||
@@ -635,7 +693,11 @@ function buildOpenApiSpec(baseUrl = '') {
|
|||||||
type: 'apiKey',
|
type: 'apiKey',
|
||||||
in: 'header',
|
in: 'header',
|
||||||
name: 'X-API-Key',
|
name: 'X-API-Key',
|
||||||
description: 'API-Token aus der Umgebungsvariable API_TOKEN.',
|
description:
|
||||||
|
'Persönlicher API-Token des Benutzers, zu finden unter Einstellungen → ' +
|
||||||
|
'REST-API (dort per „Neu generieren“ erzeugen und einblenden). Der Token ' +
|
||||||
|
'identifiziert den Benutzer — die Anfrage sieht und ändert nur dessen ' +
|
||||||
|
'eigene Daten.',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
schemas: {
|
schemas: {
|
||||||
@@ -663,6 +725,7 @@ function buildOpenApiSpec(baseUrl = '') {
|
|||||||
stelle: { type: 'string' },
|
stelle: { type: 'string' },
|
||||||
art: { type: 'string', enum: ART_OPTIONS, nullable: true },
|
art: { type: 'string', enum: ART_OPTIONS, nullable: true },
|
||||||
status: { type: 'string', enum: STATUS_OPTIONS, nullable: true },
|
status: { type: 'string', enum: STATUS_OPTIONS, nullable: true },
|
||||||
|
labels: { ...labelsSchema, nullable: true },
|
||||||
notizen: { type: 'string', nullable: true },
|
notizen: { type: 'string', nullable: true },
|
||||||
interne_notizen: { type: 'string', nullable: true },
|
interne_notizen: { type: 'string', nullable: true },
|
||||||
ort: { type: 'string', nullable: true },
|
ort: { type: 'string', nullable: true },
|
||||||
@@ -692,6 +755,7 @@ function buildOpenApiSpec(baseUrl = '') {
|
|||||||
stelle: { type: 'string' },
|
stelle: { type: 'string' },
|
||||||
art: { type: 'string', enum: ART_OPTIONS },
|
art: { type: 'string', enum: ART_OPTIONS },
|
||||||
status: { type: 'string', enum: STATUS_OPTIONS },
|
status: { type: 'string', enum: STATUS_OPTIONS },
|
||||||
|
labels: labelsSchema,
|
||||||
notizen: { type: 'string' },
|
notizen: { type: 'string' },
|
||||||
interne_notizen: { type: 'string' },
|
interne_notizen: { type: 'string' },
|
||||||
ort: { type: 'string' },
|
ort: { type: 'string' },
|
||||||
@@ -711,6 +775,7 @@ function buildOpenApiSpec(baseUrl = '') {
|
|||||||
stelle: { type: 'string' },
|
stelle: { type: 'string' },
|
||||||
art: { type: 'string', enum: ART_OPTIONS },
|
art: { type: 'string', enum: ART_OPTIONS },
|
||||||
status: { type: 'string', enum: STATUS_OPTIONS },
|
status: { type: 'string', enum: STATUS_OPTIONS },
|
||||||
|
labels: labelsSchema,
|
||||||
notizen: { type: 'string' },
|
notizen: { type: 'string' },
|
||||||
interne_notizen: { type: 'string' },
|
interne_notizen: { type: 'string' },
|
||||||
ort: { type: 'string' },
|
ort: { type: 'string' },
|
||||||
@@ -817,10 +882,19 @@ function buildOpenApiSpec(baseUrl = '') {
|
|||||||
},
|
},
|
||||||
Settings: {
|
Settings: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
|
description:
|
||||||
|
'Persönliche Angaben des Benutzers, dem der API-Key gehört (fließen in ' +
|
||||||
|
'die generierten Unterlagen ein). Hat der Benutzer noch nichts gespeichert, ' +
|
||||||
|
'liefert GET ein leeres Objekt `{}` — das ist ein gültiger Zustand.',
|
||||||
properties: {
|
properties: {
|
||||||
name: { type: 'string' },
|
name: { type: 'string', example: 'Max Mustermann' },
|
||||||
adresse: { type: 'string' },
|
adresse: { type: 'string', example: 'Musterstraße 1, 12345 Musterstadt' },
|
||||||
kundennummer: { type: 'string' },
|
kundennummer: { type: 'string', description: 'Kundennummer beim Jobcenter' },
|
||||||
|
email: { type: 'string', example: 'max@example.com' },
|
||||||
|
telefon: { type: 'string', example: '030 12345678' },
|
||||||
|
ort: { type: 'string', example: 'Berlin' },
|
||||||
|
webseite: { type: 'string', example: 'example.com' },
|
||||||
|
geburtsdatum: { type: 'string', example: '01.01.1990' },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
Statistics: {
|
Statistics: {
|
||||||
@@ -871,8 +945,18 @@ function buildOpenApiSpec(baseUrl = '') {
|
|||||||
anzeige_datum: { type: 'string', format: 'date', nullable: true, description: 'Datum der eigentlichen Stellenanzeige (vom Drittanbieter übergeben)' },
|
anzeige_datum: { type: 'string', format: 'date', nullable: true, description: 'Datum der eigentlichen Stellenanzeige (vom Drittanbieter übergeben)' },
|
||||||
kontakt_email: { type: 'string', format: 'email', nullable: true, description: 'Kontakt-E-Mail-Adresse der Stelle' },
|
kontakt_email: { type: 'string', format: 'email', nullable: true, description: 'Kontakt-E-Mail-Adresse der Stelle' },
|
||||||
status: { type: 'string', enum: ['offen', 'uebernommen', 'abgelehnt'] },
|
status: { type: 'string', enum: ['offen', 'uebernommen', 'abgelehnt'] },
|
||||||
|
favorit: {
|
||||||
|
type: 'integer',
|
||||||
|
enum: [0, 1],
|
||||||
|
readOnly: true,
|
||||||
|
description: 'Vom Nutzer in der Weboberfläche gemerktes Angebot (Stern). Wird nur dort gesetzt ' +
|
||||||
|
'und beim erneuten Einspielen desselben Angebots nicht überschrieben. Unabhängig vom ' +
|
||||||
|
'Favoriten-Merkmal einer Bewerbung.',
|
||||||
|
},
|
||||||
|
labels: { ...labelsSchema, nullable: true },
|
||||||
verknuepfte_bewerbung_id: { type: 'integer', nullable: true },
|
verknuepfte_bewerbung_id: { type: 'integer', nullable: true },
|
||||||
url_norm: { type: 'string', nullable: true, description: 'Normalisierte URL für die De-Duplizierung (serverseitig gesetzt)' },
|
url_norm: { type: 'string', nullable: true, description: 'Normalisierte URL für die De-Duplizierung (serverseitig gesetzt)' },
|
||||||
|
firma_slug: { type: 'string', nullable: true, description: 'Firmen-Slug für Blacklist-Abgleich; wie übergeben gespeichert, bei Fehlen aus firma abgeleitet.' },
|
||||||
created_at: { type: 'string', format: 'date-time' },
|
created_at: { type: 'string', format: 'date-time' },
|
||||||
updated_at: { type: 'string', format: 'date-time' },
|
updated_at: { type: 'string', format: 'date-time' },
|
||||||
},
|
},
|
||||||
@@ -891,10 +975,12 @@ function buildOpenApiSpec(baseUrl = '') {
|
|||||||
gehalt: { type: 'string' },
|
gehalt: { type: 'string' },
|
||||||
beschreibung: { type: 'string', description: 'Stellenbeschreibungstext' },
|
beschreibung: { type: 'string', description: 'Stellenbeschreibungstext' },
|
||||||
quelle_url: { type: 'string' },
|
quelle_url: { type: 'string' },
|
||||||
|
firma_slug: { type: 'string', description: 'Optionaler Firmen-Slug für den Blacklist-Abgleich (z. B. "bosch"). Wird wie übergeben gespeichert; fehlt er, leitet der Server ihn aus firma ab.' },
|
||||||
art: { type: 'string', enum: ART_OPTIONS },
|
art: { type: 'string', enum: ART_OPTIONS },
|
||||||
anzeige_datum: { type: 'string', format: 'date', description: 'Datum der eigentlichen Stellenanzeige (ISO YYYY-MM-DD)' },
|
anzeige_datum: { type: 'string', format: 'date', description: 'Datum der eigentlichen Stellenanzeige (ISO YYYY-MM-DD)' },
|
||||||
kontakt_email: { type: 'string', format: 'email', description: 'Kontakt-E-Mail-Adresse der Stelle' },
|
kontakt_email: { type: 'string', format: 'email', description: 'Kontakt-E-Mail-Adresse der Stelle' },
|
||||||
status: { type: 'string', enum: ['offen', 'uebernommen', 'abgelehnt'], default: 'offen' },
|
status: { type: 'string', enum: ['offen', 'uebernommen', 'abgelehnt'], default: 'offen' },
|
||||||
|
labels: labelsSchema,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
JobOfferResult: {
|
JobOfferResult: {
|
||||||
@@ -923,6 +1009,7 @@ function buildOpenApiSpec(baseUrl = '') {
|
|||||||
quelle: { type: 'string', nullable: true },
|
quelle: { type: 'string', nullable: true },
|
||||||
external_id: { type: 'string', nullable: true },
|
external_id: { type: 'string', nullable: true },
|
||||||
firma_norm: { type: 'string', nullable: true },
|
firma_norm: { type: 'string', nullable: true },
|
||||||
|
firma_slug: { type: 'string', nullable: true, description: 'Rechtsform-/Umlaut-robuster Firmen-Slug; primärer Abgleich für typ=firma/firma_stelle/auto.' },
|
||||||
stelle_norm: { type: 'string', nullable: true },
|
stelle_norm: { type: 'string', nullable: true },
|
||||||
ort_norm: { type: 'string', nullable: true },
|
ort_norm: { type: 'string', nullable: true },
|
||||||
firma: { type: 'string', nullable: true },
|
firma: { type: 'string', nullable: true },
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
// Password hashing using Node's built-in scrypt + a per-hash random salt.
|
||||||
|
//
|
||||||
|
// No external dependency (bcrypt would need a native build step). scrypt is
|
||||||
|
// memory-hard and well suited for interactive logins. Hash format:
|
||||||
|
// "<saltHex>:<hashHex>" (salt is 16 bytes, hash is 64 bytes)
|
||||||
|
//
|
||||||
|
// verify() is async (crypto.scrypt, non-blocking) so a login attempt cannot pin
|
||||||
|
// the event loop and stall every other request — a synchronous scrypt was a
|
||||||
|
// cheap CPU-DoS vector. DUMMY_HASH is a real hash for the "unknown user" path so
|
||||||
|
// login timing does not leak which usernames exist.
|
||||||
|
|
||||||
|
const crypto = require('crypto');
|
||||||
|
|
||||||
|
const KEYLEN = 64;
|
||||||
|
const DUMMY_SALT = '00000000000000000000000000000000';
|
||||||
|
const DUMMY_HASH = `${DUMMY_SALT}:${crypto.scryptSync('nextjobs-dummy', DUMMY_SALT, KEYLEN).toString('hex')}`;
|
||||||
|
|
||||||
|
function hash(password) {
|
||||||
|
const salt = crypto.randomBytes(16).toString('hex');
|
||||||
|
const out = crypto.scryptSync(password, salt, KEYLEN).toString('hex');
|
||||||
|
return `${salt}:${out}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve to true/false. Always runs a full scrypt (constant work) before
|
||||||
|
// deciding, so the caller cannot tell a wrong password from a bad hash format.
|
||||||
|
function verify(password, stored) {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
if (typeof stored !== 'string' || !stored.includes(':')) return resolve(false);
|
||||||
|
const idx = stored.indexOf(':');
|
||||||
|
const salt = stored.slice(0, idx);
|
||||||
|
const expected = stored.slice(idx + 1);
|
||||||
|
if (!salt || !expected) return resolve(false);
|
||||||
|
crypto.scrypt(String(password == null ? '' : password), salt, KEYLEN, (err, buf) => {
|
||||||
|
if (err) return resolve(false);
|
||||||
|
const computed = buf.toString('hex');
|
||||||
|
if (computed.length !== expected.length) return resolve(false);
|
||||||
|
// Constant-time compare to avoid timing side channels.
|
||||||
|
resolve(crypto.timingSafeEqual(Buffer.from(computed), Buffer.from(expected)));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { hash, verify, DUMMY_HASH };
|
||||||
+159
@@ -0,0 +1,159 @@
|
|||||||
|
// Editable KI system prompts.
|
||||||
|
//
|
||||||
|
// Every prompt that shapes the assistant's role, tone and rules lives here as a
|
||||||
|
// default and can be overridden per installation on the Vorlagen page. Only the
|
||||||
|
// *system* prompts are editable: the user prompts carry the JSON skeleton the
|
||||||
|
// answers are parsed against, so they stay in code where a typo can't break
|
||||||
|
// generation. An override is a row in the `prompts` table; deleting the row
|
||||||
|
// restores the default (that's what "Zurücksetzen" does).
|
||||||
|
//
|
||||||
|
// Placeholders are written as {{name}} and filled by render().
|
||||||
|
|
||||||
|
const DEFAULTS = [
|
||||||
|
{
|
||||||
|
key: 'unterlagen',
|
||||||
|
titel: 'Bewerbungsunterlagen (Anschreiben & Lebenslauf)',
|
||||||
|
beschreibung:
|
||||||
|
'Rolle und Regeln für die Generierung der Bewerbungsunterlagen aus deinen Basis-Unterlagen. ' +
|
||||||
|
'Der Aufbau der Dokumente (JSON-Struktur, Felder) ist fest verdrahtet und wird hiervon nicht berührt.',
|
||||||
|
platzhalter: [],
|
||||||
|
inhalt:
|
||||||
|
'Du bist ein erfahrener Bewerbungscoach und erstellst professionelle, ' +
|
||||||
|
'deutschsprachige Bewerbungstexte. Du passt die BEREITGESTELLTEN Basis-Unterlagen ' +
|
||||||
|
'des Bewerbers auf eine konkrete Stellenausschreibung an. Wichtigste Regel: Du ' +
|
||||||
|
'erfindest KEINE Fakten, Qualifikationen, Abschlüsse, Kontaktdaten oder ' +
|
||||||
|
'Berufserfahrungen. Verwende ausschließlich Informationen aus den Basis-Unterlagen. ' +
|
||||||
|
'Du darfst umformulieren, gewichten, relevante Punkte hervorheben und auf die Stelle ' +
|
||||||
|
'zuschneiden — aber nichts hinzudichten. Schreibe natürlich, konkret und ohne Floskeln, so wie ein ' +
|
||||||
|
'deutscher Muttersprachler schreibt: keine Buzzword-Paare, keine leeren Eigenschaftswörter und keine ' +
|
||||||
|
'Selbstetiketten wie "Als Muttersprachler ..."; jede Aussage aus einer konkreten Tätigkeit heraus. ' +
|
||||||
|
'Verwende AUSSCHLIESSLICH deutsche Sprache und das lateinische Alphabet. Keine englischen, ' +
|
||||||
|
'chinesischen, japanischen, koreanischen oder anderen fremdsprachigen Wörter oder Schriftzeichen - ' +
|
||||||
|
'auch nicht einzelne Zeichen oder Füllwörter.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'chat',
|
||||||
|
titel: 'KI-Chat (Bewerbungs-Assistent)',
|
||||||
|
beschreibung:
|
||||||
|
'Grundhaltung des Chat-Assistenten. Die Hinweise zu Werkzeugen, Datum und deinem Profil ' +
|
||||||
|
'werden automatisch ergänzt.',
|
||||||
|
platzhalter: ['name'],
|
||||||
|
inhalt:
|
||||||
|
'Du bist ein hilfreicher, deutschsprachiger Bewerbungs-Assistent für {{name}}. ' +
|
||||||
|
'Du beantwortest Fragen zu laufenden Bewerbungen, hilfst beim Formulieren von ' +
|
||||||
|
'Antworten, beim Vorbereiten auf Gespräche und beim Überblick über den Status. ' +
|
||||||
|
'Antworte natürlich, knapp und auf Deutsch im lateinischen Alphabet. ' +
|
||||||
|
'Erfinde KEINE Fakten (keine erfundenen Termine, Zahlen, Zusagen, Firmen). ' +
|
||||||
|
'Wenn eine Angabe nötig ist, die du nicht weißt, setze einen klar erkennbaren ' +
|
||||||
|
'Platzhalter in eckigen Klammern. Verwende nur den einfachen Bindestrich "-". ' +
|
||||||
|
'Fasse Stellenbeschreibungen verständlich zusammen, statt sie wörtlich ' +
|
||||||
|
'wiederzugeben, und beziehe sie auf das Profil des Bewerbers.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'email_antwort',
|
||||||
|
titel: 'E-Mail: Antwort auf eine Nachricht',
|
||||||
|
beschreibung: 'Rolle für den KI-Entwurf einer Antwort-E-Mail an ein Unternehmen.',
|
||||||
|
platzhalter: ['name'],
|
||||||
|
inhalt:
|
||||||
|
'Du bist {{name}} und schreibst als Bewerber eine höfliche, professionelle, ' +
|
||||||
|
'deutschsprachige Antwort-E-Mail an ein Unternehmen im laufenden Bewerbungsprozess. ' +
|
||||||
|
'Du antwortest konkret auf die eingegangene Nachricht.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'email_absage',
|
||||||
|
titel: 'E-Mail: Absage (andere Stelle angenommen)',
|
||||||
|
beschreibung: 'Rolle für den KI-Entwurf einer Absage an ein Unternehmen.',
|
||||||
|
platzhalter: ['name'],
|
||||||
|
inhalt:
|
||||||
|
'Du bist {{name}} und schreibst als Bewerber eine höfliche, professionelle, ' +
|
||||||
|
'deutschsprachige Absage-E-Mail an ein Unternehmen im laufenden Bewerbungsprozess. ' +
|
||||||
|
'Du teilst mit, dass du dich entschieden hast, eine andere Stelle anzunehmen, und ' +
|
||||||
|
'bedankst dich für die Zeit, das Gespräch und das Vertrauen. Halte die E-Mail bewusst ' +
|
||||||
|
'kurz: ein bis zwei kurze Absätze genügen - keine langen Erklärungen, keine Rechtfertigung. ' +
|
||||||
|
'Bleibe wertschätzend und bestimmt zugleich; nenne keine Gründe im Detail und erfinde keine ' +
|
||||||
|
'konkreten Namen anderer Unternehmen.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'email_stil',
|
||||||
|
titel: 'E-Mail: gemeinsame Stilregeln',
|
||||||
|
beschreibung:
|
||||||
|
'Wird an beide E-Mail-Prompts oben angehängt. Hier stehen die Regeln, die für jede ' +
|
||||||
|
'generierte E-Mail gelten sollen.',
|
||||||
|
platzhalter: [],
|
||||||
|
inhalt:
|
||||||
|
'Wichtig: Du erfindest KEINE Fakten (keine erfundenen Termine, Zahlen, Zusagen). ' +
|
||||||
|
'Wenn eine konkrete Angabe nötig ist, die du nicht kennst, setze einen klar erkennbaren ' +
|
||||||
|
'Platzhalter in eckigen Klammern, z. B. "[Terminvorschlag einfügen]". Schreibe natürlich, ' +
|
||||||
|
'knapp und verbindlich, wie ein deutscher Muttersprachler - ohne leere Floskeln, ohne ' +
|
||||||
|
'Buzzword-Paare und ohne Selbstetiketten wie "Als Muttersprachler ...". Verwende ausschließlich ' +
|
||||||
|
'deutsche Sprache und das lateinische Alphabet - keine fremdsprachigen Wörter oder Schriftzeichen.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'jobsuche_feinschliff',
|
||||||
|
titel: 'Jobsuche: Feinschliff vorschlagen',
|
||||||
|
beschreibung:
|
||||||
|
'Rolle für den KI-Vorschlag deiner Jobsuche (Berufsbezeichnungen / zusätzlich berücksichtigen / ' +
|
||||||
|
'ausschließen). Grundlage sind deine Basis-Unterlagen oben.',
|
||||||
|
platzhalter: [],
|
||||||
|
inhalt:
|
||||||
|
'Du bist ein erfahrener Recruiter und stellst die Suchkriterien für die automatische ' +
|
||||||
|
'Stellensuche eines Bewerbers auf. Du liest seine Basis-Unterlagen (Lebenslauf, Kurzprofil, ' +
|
||||||
|
'Anschreiben). Das Wichtigste sind die Berufsbezeichnungen: unter welchen Stellentiteln seine ' +
|
||||||
|
'Tätigkeit real ausgeschrieben wird - denn danach wird gesucht, und was hier fehlt, findet die ' +
|
||||||
|
'Suche nie. Dieselbe Arbeit läuft je nach Firma unter verschiedenen Titeln; decke diese ' +
|
||||||
|
'Varianten ab, aber bleib bei dem, was sein Erfahrungsniveau hergibt. Zusatzbegriffe sind ' +
|
||||||
|
'KEINE Stellentitel, sondern belegbarer Kontext (Technologien, Branchen, Umfelder, ' +
|
||||||
|
'Arbeitszeitmodelle). Ausschlüsse leitest du aus dem ab, was erkennbar nicht zum Bewerber ' +
|
||||||
|
'passt - etwa Stellen weit unter oder weit über seinem Erfahrungsniveau, fachfremde Rollen ' +
|
||||||
|
'oder Anstellungsformen, die er offensichtlich nicht sucht. Du erfindest nichts: Jeder ' +
|
||||||
|
'Vorschlag muss sich aus den Unterlagen belegen lassen. Antworte auf Deutsch im lateinischen ' +
|
||||||
|
'Alphabet.',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const BY_KEY = new Map(DEFAULTS.map((p) => [p.key, p]));
|
||||||
|
|
||||||
|
// Default text of a prompt, or '' for an unknown key.
|
||||||
|
function defaultText(key) {
|
||||||
|
const p = BY_KEY.get(key);
|
||||||
|
return p ? p.inhalt : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function isKnownKey(key) {
|
||||||
|
return BY_KEY.has(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Substitute {{placeholders}}. Unknown placeholders are left untouched so a typo
|
||||||
|
// in the UI shows up in the output instead of silently emptying the sentence.
|
||||||
|
function render(text, vars = {}) {
|
||||||
|
return String(text == null ? '' : text).replace(/\{\{(\w+)\}\}/g, (match, name) =>
|
||||||
|
Object.prototype.hasOwnProperty.call(vars, name) && vars[name] ? String(vars[name]) : match
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Look a prompt up in the overrides map (key -> text), falling back to the
|
||||||
|
// default, and fill its placeholders. `prompts` may be undefined — callers that
|
||||||
|
// don't pass any overrides simply get the defaults.
|
||||||
|
function get(prompts, key, vars = {}) {
|
||||||
|
const override = prompts && typeof prompts[key] === 'string' ? prompts[key].trim() : '';
|
||||||
|
return render(override || defaultText(key), vars);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The list rendered on the Vorlagen page: every prompt with its current text and
|
||||||
|
// whether it deviates from the default.
|
||||||
|
function list(overrides = {}) {
|
||||||
|
return DEFAULTS.map((p) => {
|
||||||
|
const override = typeof overrides[p.key] === 'string' ? overrides[p.key].trim() : '';
|
||||||
|
return {
|
||||||
|
key: p.key,
|
||||||
|
titel: p.titel,
|
||||||
|
beschreibung: p.beschreibung,
|
||||||
|
platzhalter: p.platzhalter,
|
||||||
|
inhalt: override || p.inhalt,
|
||||||
|
standard: p.inhalt,
|
||||||
|
angepasst: Boolean(override && override !== p.inhalt.trim()),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { DEFAULTS, defaultText, isKnownKey, render, get, list };
|
||||||
+344
@@ -0,0 +1,344 @@
|
|||||||
|
// Volltextsuche über Bewerbungen und ihre E-Mail-Korrespondenz (SQLite FTS5).
|
||||||
|
//
|
||||||
|
// Bisher filterte die Startseite nur clientseitig über Firma + Stelle der schon
|
||||||
|
// gerenderten Zeilen. Damit war unauffindbar, was nur in einer E-Mail steht — der
|
||||||
|
// Name der Ansprechpartnerin, ein Betreff, eine Zusage im Fließtext. Diese
|
||||||
|
// Suche indiziert daher beides.
|
||||||
|
//
|
||||||
|
// Aufbau: ein FTS5-Index aus *Fragmenten*. Ein Fragment ist entweder eine
|
||||||
|
// Bewerbung (Firma, Stelle, Notizen, Stellenbeschreibung …) oder eine einzelne
|
||||||
|
// E-Mail (Absender/Empfänger, Betreff, Text). Jedes E-Mail-Fragment trägt Firma
|
||||||
|
// und Stelle seiner Bewerbung mit — nur so findet eine Anfrage wie
|
||||||
|
// "müller bosch" die Bewerbung, obwohl der Name allein in der E-Mail und die
|
||||||
|
// Firma allein in der Bewerbung steht (FTS5 verknüpft Terme innerhalb *einer*
|
||||||
|
// Zeile mit UND).
|
||||||
|
//
|
||||||
|
// Gepflegt wird der Index von SQL-Triggern, nicht von der Anwendung: Bewerbungen
|
||||||
|
// und E-Mails werden an vielen Stellen geschrieben (Web-UI, REST-API, IMAP-Abruf,
|
||||||
|
// KI-Import), und ein vergessener Aufruf würde den Index still veralten lassen.
|
||||||
|
//
|
||||||
|
// Die Ergebnisse werden nach Bewerbung gruppiert zurückgegeben: eine Bewerbung
|
||||||
|
// ist ein Treffer, die Fragmente sagen, *wo* sie getroffen wurde ("in E-Mail von
|
||||||
|
// Frau Müller"). E-Mails ohne zugeordnete Bewerbung (Postfach) sind eigene Treffer.
|
||||||
|
|
||||||
|
// Spaltengewichte für bm25: ein Firmentreffer wiegt schwerer als ein Wort
|
||||||
|
// irgendwo im Mailtext. Reihenfolge = Spaltenreihenfolge im Index.
|
||||||
|
const GEWICHTE = [12.0, 8.0, 6.0, 4.0, 1.0]; // firma, stelle, person, betreff, text
|
||||||
|
|
||||||
|
// Beschnitt pro Mailtext: eine lange Signatur/Zitatkette bläht den Index auf,
|
||||||
|
// ohne die Suche besser zu machen.
|
||||||
|
const MAX_TEXT = 20000;
|
||||||
|
|
||||||
|
// Wie viele Bewerbungen eine Suche höchstens zurückgibt.
|
||||||
|
const LIMIT = 25;
|
||||||
|
|
||||||
|
// Markierungen um Fundstellen im Snippet. Bewusst keine HTML-Tags: die Rohtexte
|
||||||
|
// werden erst im Client escaped und die Marker danach durch <mark> ersetzt, sonst
|
||||||
|
// wäre der Snippet ein XSS-Vektor (Mailtexte sind Fremdinhalt).
|
||||||
|
const MARK_START = '\u0002';
|
||||||
|
const MARK_END = '\u0003';
|
||||||
|
|
||||||
|
// Feld-Filter, die der Nutzer tippen kann: "firma:bosch", "von:müller".
|
||||||
|
const FELD_ALIASE = {
|
||||||
|
firma: 'firma', unternehmen: 'firma',
|
||||||
|
stelle: 'stelle', job: 'stelle', position: 'stelle',
|
||||||
|
person: 'person', von: 'person', an: 'person', name: 'person', kontakt: 'person',
|
||||||
|
betreff: 'betreff', subject: 'betreff',
|
||||||
|
text: 'text', notiz: 'text', notizen: 'text', mail: 'text', inhalt: 'text',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Der Bewerbungs-Teil eines Fragments (Notizen, Beschreibung, Metadaten) — als
|
||||||
|
// SQL-Ausdruck, weil ihn Trigger und Backfill gleichermaßen brauchen.
|
||||||
|
const BEWERBUNG_TEXT = `
|
||||||
|
trim(coalesce(%s.notizen, '') || ' ' || coalesce(%s.interne_notizen, '') || ' ' ||
|
||||||
|
coalesce(%s.llm_notizen, '') || ' ' || coalesce(%s.ort, '') || ' ' ||
|
||||||
|
coalesce(%s.art, '') || ' ' || coalesce(%s.status, '') || ' ' ||
|
||||||
|
coalesce(%s.labels, '') || ' ' || coalesce(%s.stellenbeschreibung, '') || ' ' ||
|
||||||
|
coalesce(%s.quelle_url, ''))
|
||||||
|
`;
|
||||||
|
const bewerbungText = (alias) => BEWERBUNG_TEXT.replace(/%s/g, alias);
|
||||||
|
|
||||||
|
// Schema + Trigger + einmaliger Backfill. Idempotent: läuft bei jedem Start.
|
||||||
|
async function ensureSchema({ exec, dbGet }) {
|
||||||
|
await exec(`
|
||||||
|
CREATE VIRTUAL TABLE IF NOT EXISTS suche_index USING fts5(
|
||||||
|
firma, stelle, person, betreff, text,
|
||||||
|
user_id UNINDEXED,
|
||||||
|
bewerbung_id UNINDEXED,
|
||||||
|
typ UNINDEXED,
|
||||||
|
ref_id UNINDEXED,
|
||||||
|
meta UNINDEXED,
|
||||||
|
tokenize = 'unicode61 remove_diacritics 2'
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Trigger. "typ" trennt die beiden Fragmentarten, "ref_id" zeigt auf die
|
||||||
|
// Quellzeile — beides brauchen die Trigger, um gezielt zu löschen.
|
||||||
|
await exec(`
|
||||||
|
CREATE TRIGGER IF NOT EXISTS bewerbungen_suche_ai AFTER INSERT ON bewerbungen BEGIN
|
||||||
|
INSERT INTO suche_index (firma, stelle, person, betreff, text, user_id, bewerbung_id, typ, ref_id, meta)
|
||||||
|
VALUES (coalesce(new.firma, ''), coalesce(new.stelle, ''), coalesce(new.email_empfaenger, ''), '',
|
||||||
|
${bewerbungText('new')}, new.user_id, new.id, 'bewerbung', new.id, '');
|
||||||
|
END
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Beim Ändern: eigenes Fragment neu aufbauen und Firma/Stelle in den
|
||||||
|
// E-Mail-Fragmenten nachziehen (sie tragen sie denormalisiert mit).
|
||||||
|
await exec(`
|
||||||
|
CREATE TRIGGER IF NOT EXISTS bewerbungen_suche_au AFTER UPDATE ON bewerbungen BEGIN
|
||||||
|
DELETE FROM suche_index WHERE typ = 'bewerbung' AND ref_id = old.id;
|
||||||
|
INSERT INTO suche_index (firma, stelle, person, betreff, text, user_id, bewerbung_id, typ, ref_id, meta)
|
||||||
|
VALUES (coalesce(new.firma, ''), coalesce(new.stelle, ''), coalesce(new.email_empfaenger, ''), '',
|
||||||
|
${bewerbungText('new')}, new.user_id, new.id, 'bewerbung', new.id, '');
|
||||||
|
UPDATE suche_index SET firma = coalesce(new.firma, ''), stelle = coalesce(new.stelle, '')
|
||||||
|
WHERE typ = 'email' AND bewerbung_id = new.id;
|
||||||
|
END
|
||||||
|
`);
|
||||||
|
|
||||||
|
await exec(`
|
||||||
|
CREATE TRIGGER IF NOT EXISTS bewerbungen_suche_ad AFTER DELETE ON bewerbungen BEGIN
|
||||||
|
DELETE FROM suche_index WHERE bewerbung_id = old.id;
|
||||||
|
END
|
||||||
|
`);
|
||||||
|
|
||||||
|
// E-Mails: Firma/Stelle der zugeordneten Bewerbung wandern ins Fragment, damit
|
||||||
|
// "müller bosch" trifft. meta trägt, was das Ergebnis anzeigt (Betreff, Absender,
|
||||||
|
// Datum, Richtung) — als JSON, unindiziert.
|
||||||
|
const emailFragment = () => `
|
||||||
|
INSERT INTO suche_index (firma, stelle, person, betreff, text, user_id, bewerbung_id, typ, ref_id, meta)
|
||||||
|
SELECT coalesce(b.firma, ''), coalesce(b.stelle, ''),
|
||||||
|
trim(coalesce(new.from_addr, '') || ' ' || coalesce(new.to_addr, '')),
|
||||||
|
coalesce(new.subject, ''), substr(coalesce(new.body_text, ''), 1, ${MAX_TEXT}),
|
||||||
|
new.user_id, new.bewerbung_id, 'email', new.id,
|
||||||
|
json_object('betreff', coalesce(new.subject, ''), 'von', coalesce(new.from_addr, ''),
|
||||||
|
'an', coalesce(new.to_addr, ''), 'datum', coalesce(new.email_date, new.created_at),
|
||||||
|
'richtung', coalesce(new.direction, ''))
|
||||||
|
FROM (SELECT 1) LEFT JOIN bewerbungen b ON b.id = new.bewerbung_id;
|
||||||
|
`;
|
||||||
|
|
||||||
|
await exec(`
|
||||||
|
CREATE TRIGGER IF NOT EXISTS emails_suche_ai AFTER INSERT ON emails BEGIN
|
||||||
|
${emailFragment()}
|
||||||
|
END
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Eine E-Mail wird u. a. beim Zuordnen im Postfach aktualisiert (bewerbung_id) —
|
||||||
|
// dann muss das Fragment mitsamt Firma/Stelle neu entstehen.
|
||||||
|
await exec(`
|
||||||
|
CREATE TRIGGER IF NOT EXISTS emails_suche_au AFTER UPDATE ON emails BEGIN
|
||||||
|
DELETE FROM suche_index WHERE typ = 'email' AND ref_id = old.id;
|
||||||
|
${emailFragment()}
|
||||||
|
END
|
||||||
|
`);
|
||||||
|
|
||||||
|
await exec(`
|
||||||
|
CREATE TRIGGER IF NOT EXISTS emails_suche_ad AFTER DELETE ON emails BEGIN
|
||||||
|
DELETE FROM suche_index WHERE typ = 'email' AND ref_id = old.id;
|
||||||
|
END
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Backfill: nur beim allerersten Start nach der Einführung (Index noch leer,
|
||||||
|
// Daten aber vorhanden). Danach halten die Trigger alles aktuell.
|
||||||
|
const leer = await dbGet('SELECT count(*) AS n FROM suche_index');
|
||||||
|
if (leer && leer.n === 0) {
|
||||||
|
await exec(`
|
||||||
|
INSERT INTO suche_index (firma, stelle, person, betreff, text, user_id, bewerbung_id, typ, ref_id, meta)
|
||||||
|
SELECT coalesce(b.firma, ''), coalesce(b.stelle, ''), coalesce(b.email_empfaenger, ''), '',
|
||||||
|
${bewerbungText('b')}, b.user_id, b.id, 'bewerbung', b.id, ''
|
||||||
|
FROM bewerbungen b
|
||||||
|
`);
|
||||||
|
await exec(`
|
||||||
|
INSERT INTO suche_index (firma, stelle, person, betreff, text, user_id, bewerbung_id, typ, ref_id, meta)
|
||||||
|
SELECT coalesce(b.firma, ''), coalesce(b.stelle, ''),
|
||||||
|
trim(coalesce(e.from_addr, '') || ' ' || coalesce(e.to_addr, '')),
|
||||||
|
coalesce(e.subject, ''), substr(coalesce(e.body_text, ''), 1, ${MAX_TEXT}),
|
||||||
|
e.user_id, e.bewerbung_id, 'email', e.id,
|
||||||
|
json_object('betreff', coalesce(e.subject, ''), 'von', coalesce(e.from_addr, ''),
|
||||||
|
'an', coalesce(e.to_addr, ''), 'datum', coalesce(e.email_date, e.created_at),
|
||||||
|
'richtung', coalesce(e.direction, ''))
|
||||||
|
FROM emails e LEFT JOIN bewerbungen b ON b.id = e.bewerbung_id
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Eingabe -> FTS5-MATCH-Ausdruck
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// Was der Nutzer tippen darf:
|
||||||
|
// müller -> Präfixsuche über alle Felder
|
||||||
|
// "frau müller" -> Wortfolge
|
||||||
|
// firma:bosch -> nur in diesem Feld
|
||||||
|
// -zeitarbeit -> schließt Treffer aus
|
||||||
|
// Alles andere (FTS-Sonderzeichen wie * : ^ NEAR) wird entschärft: jeder Term
|
||||||
|
// geht in Anführungszeichen in die Query, damit eine Eingabe wie "c++" oder ein
|
||||||
|
// Doppelpunkt keinen Syntaxfehler auslöst statt zu suchen.
|
||||||
|
function buildMatch(roh) {
|
||||||
|
const q = String(roh == null ? '' : roh).trim();
|
||||||
|
if (!q) return null;
|
||||||
|
|
||||||
|
const TOKEN = /(-?)(?:(\w+):)?(?:"([^"]*)"|(\S+))/gu;
|
||||||
|
const positiv = [];
|
||||||
|
const negativ = [];
|
||||||
|
|
||||||
|
let m;
|
||||||
|
while ((m = TOKEN.exec(q)) !== null) {
|
||||||
|
const [, minus, feldRoh, phrase, wort] = m;
|
||||||
|
const inhalt = (phrase != null ? phrase : wort || '').trim();
|
||||||
|
// Ohne Buchstaben/Ziffern bleibt nichts Suchbares übrig (z. B. "--" oder ":").
|
||||||
|
const sauber = inhalt.replace(/"/g, ' ').trim();
|
||||||
|
if (!/[\p{L}\p{N}]/u.test(sauber)) continue;
|
||||||
|
|
||||||
|
const feld = feldRoh ? FELD_ALIASE[feldRoh.toLowerCase()] : null;
|
||||||
|
// Unbekanntes Präfix ("foo:bar") ist kein Feldfilter, sondern Suchtext.
|
||||||
|
const suchtext = feldRoh && !feld ? `${feldRoh} ${sauber}` : sauber;
|
||||||
|
const term = umlautTerm(suchtext);
|
||||||
|
// Ein Feldfilter darf auf einen geklammerten Ausdruck zeigen: {firma} : ("a"* OR "b"*)
|
||||||
|
const mitFeld = feld ? `{${feld}} : ${term}` : term;
|
||||||
|
(minus ? negativ : positiv).push(mitFeld);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Eine reine Ausschlussfrage ("-zeitarbeit") hat keinen Anker — FTS5 braucht
|
||||||
|
// mindestens einen positiven Term, und "alles außer X" ist ohnehin keine Suche.
|
||||||
|
if (!positiv.length) return null;
|
||||||
|
const basis = positiv.join(' AND ');
|
||||||
|
return negativ.length ? `${basis} NOT (${negativ.join(' OR ')})` : basis;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ein Term als FTS5-Ausdruck, mit Präfixsuche ("bosch"* findet auch
|
||||||
|
// "boschgruppe" — beim Tippen der Normalfall).
|
||||||
|
//
|
||||||
|
// Dazu die deutsche Umlaut-Frage: der Index faltet Diakritika (Müller -> muller),
|
||||||
|
// deshalb findet "muller" bereits "Müller". Die ausgeschriebene Form "mueller"
|
||||||
|
// aber nicht — und genau die tippt man bei Namen ständig. Also wird zusätzlich
|
||||||
|
// die Variante mit aufgelöstem ue/oe/ae/ss gesucht, ODER-verknüpft. Falsche
|
||||||
|
// Auflösungen ("Neuenkirchen" -> "nunkirchen") laufen dabei einfach ins Leere,
|
||||||
|
// der Originalterm bleibt ja daneben stehen.
|
||||||
|
function umlautTerm(text) {
|
||||||
|
const roh = `"${text}"*`;
|
||||||
|
const variante = text
|
||||||
|
.replace(/ue/gi, 'u').replace(/oe/gi, 'o').replace(/ae/gi, 'a')
|
||||||
|
.replace(/ß/g, 'ss');
|
||||||
|
if (variante.toLowerCase() === text.toLowerCase()) return roh;
|
||||||
|
return `(${roh} OR "${variante}"*)`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Suche
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const SPALTEN = ['firma', 'stelle', 'person', 'betreff', 'text'];
|
||||||
|
const FELD_LABEL = { firma: 'Firma', stelle: 'Stelle', person: 'Kontakt', betreff: 'Betreff', text: 'Text' };
|
||||||
|
|
||||||
|
// Findet Bewerbungen (und nicht zugeordnete E-Mails) zu einer Suchanfrage.
|
||||||
|
// `dbAll` ist der Query-Helfer der App, `userId` grenzt hart auf den Besitzer ein
|
||||||
|
// (der Index ist gemeinsam, die unindizierte user_id-Spalte trennt die Mandanten).
|
||||||
|
// Rückgabe: nach Score sortierte Treffer, jeder mit Fundstellen + Snippets.
|
||||||
|
async function suche(dbAll, userId, roh, limit = LIMIT) {
|
||||||
|
const match = buildMatch(roh);
|
||||||
|
if (!match || !userId) return [];
|
||||||
|
|
||||||
|
const gewichte = GEWICHTE.join(', ');
|
||||||
|
const snippets = SPALTEN
|
||||||
|
.map((_, i) => `snippet(suche_index, ${i}, '${MARK_START}', '${MARK_END}', '…', 12) AS s_${i}`)
|
||||||
|
.join(', ');
|
||||||
|
|
||||||
|
// Fragmente laden (mehrere je Bewerbung möglich), das Gruppieren macht JS —
|
||||||
|
// in SQL ginge es nur mit einem zweiten Durchlauf über die FTS-Tabelle.
|
||||||
|
let rows;
|
||||||
|
try {
|
||||||
|
rows = await dbAll(
|
||||||
|
`SELECT bewerbung_id, typ, ref_id, meta, ${snippets},
|
||||||
|
bm25(suche_index, ${gewichte}) AS score
|
||||||
|
FROM suche_index
|
||||||
|
WHERE suche_index MATCH ? AND user_id = ?
|
||||||
|
ORDER BY score
|
||||||
|
LIMIT ?`,
|
||||||
|
[match, userId, limit * 4] // Puffer: mehrere Fragmente fallen auf eine Bewerbung zusammen
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
// Eine Eingabe, die FTS5 trotz Entschärfung nicht parst, ist kein Serverfehler,
|
||||||
|
// sondern schlicht kein Treffer.
|
||||||
|
if (/fts5|syntax/i.test(e.message)) return [];
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
if (!rows.length) return [];
|
||||||
|
|
||||||
|
// Fragmente -> Treffer je Bewerbung. Nicht zugeordnete E-Mails bleiben einzeln.
|
||||||
|
const treffer = new Map();
|
||||||
|
for (const r of rows) {
|
||||||
|
const key = r.bewerbung_id ? `b${r.bewerbung_id}` : `e${r.ref_id}`;
|
||||||
|
if (!treffer.has(key)) {
|
||||||
|
treffer.set(key, {
|
||||||
|
typ: r.bewerbung_id ? 'bewerbung' : 'email',
|
||||||
|
bewerbung_id: r.bewerbung_id || null,
|
||||||
|
email_id: r.bewerbung_id ? null : r.ref_id,
|
||||||
|
score: r.score,
|
||||||
|
fundstellen: [],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const t = treffer.get(key);
|
||||||
|
t.score = Math.min(t.score, r.score); // bm25: kleiner = besser
|
||||||
|
const fund = fundstelle(r);
|
||||||
|
if (fund) t.fundstellen.push(fund);
|
||||||
|
}
|
||||||
|
|
||||||
|
const liste = [...treffer.values()].sort((a, b) => a.score - b.score).slice(0, limit);
|
||||||
|
await ergaenzeBewerbungen(dbAll, userId, liste);
|
||||||
|
for (const t of liste) t.fundstellen = t.fundstellen.slice(0, 3);
|
||||||
|
return liste;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Aus einem Fragment die beste Fundstelle bauen: die Spalte mit Markierung im
|
||||||
|
// Snippet (FTS5 liefert für Spalten ohne Treffer den Anfang des Textes zurück).
|
||||||
|
function fundstelle(row) {
|
||||||
|
for (let i = 0; i < SPALTEN.length; i++) {
|
||||||
|
const s = row[`s_${i}`];
|
||||||
|
if (!s || s.indexOf(MARK_START) === -1) continue;
|
||||||
|
const meta = row.meta ? sicherJson(row.meta) : null;
|
||||||
|
return {
|
||||||
|
quelle: row.typ, // 'bewerbung' | 'email'
|
||||||
|
feld: SPALTEN[i],
|
||||||
|
feld_label: FELD_LABEL[SPALTEN[i]],
|
||||||
|
snippet: s,
|
||||||
|
email: meta && row.typ === 'email' ? meta : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sicherJson(s) {
|
||||||
|
try { return JSON.parse(s); } catch (e) { return null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Snippet ohne die Fundstellen-Marker — für Verbraucher, die nichts hervorheben
|
||||||
|
// (der KI-Chat etwa: Steuerzeichen im Tool-Ergebnis würden das Modell nur irritieren).
|
||||||
|
function ohneMarker(s) {
|
||||||
|
return String(s == null ? '' : s).split(MARK_START).join('').split(MARK_END).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Die Anzeigedaten der getroffenen Bewerbungen nachladen (der Index hält nur
|
||||||
|
// Suchtext). Eine Abfrage für alle Treffer.
|
||||||
|
async function ergaenzeBewerbungen(dbAll, userId, liste) {
|
||||||
|
const ids = liste.filter((t) => t.bewerbung_id).map((t) => t.bewerbung_id);
|
||||||
|
if (!ids.length) return;
|
||||||
|
const rows = await dbAll(
|
||||||
|
`SELECT id, firma, stelle, status, datum, art FROM bewerbungen
|
||||||
|
WHERE user_id = ? AND id IN (${ids.map(() => '?').join(',')})`,
|
||||||
|
[userId, ...ids]
|
||||||
|
);
|
||||||
|
const byId = new Map(rows.map((r) => [r.id, r]));
|
||||||
|
for (const t of liste) {
|
||||||
|
const b = t.bewerbung_id ? byId.get(t.bewerbung_id) : null;
|
||||||
|
if (b) t.bewerbung = b;
|
||||||
|
}
|
||||||
|
// Eine Bewerbung, die es nicht mehr gibt (Index hinkt hinterher), fällt raus.
|
||||||
|
for (let i = liste.length - 1; i >= 0; i--) {
|
||||||
|
if (liste[i].typ === 'bewerbung' && !liste[i].bewerbung) liste.splice(i, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
ensureSchema, suche, buildMatch, ohneMarker,
|
||||||
|
MARK_START, MARK_END, LIMIT, FELD_ALIASE,
|
||||||
|
};
|
||||||
@@ -0,0 +1,290 @@
|
|||||||
|
// Per-user job-search profile ("Suchprofil") + run queue.
|
||||||
|
//
|
||||||
|
// Background: the job search used to be two host-side cron scripts with the
|
||||||
|
// search criteria (seven cities, roles, buzzwords) hard-coded into the prompt —
|
||||||
|
// one user's profile, baked into the shell script. On a multi-user platform each
|
||||||
|
// user needs their own criteria, so the *what* now lives here, per user.
|
||||||
|
//
|
||||||
|
// Division of labour, forced by the deployment: the app runs in a container that
|
||||||
|
// has neither `claude` nor `ollama` (both live on the host), so the app cannot
|
||||||
|
// execute a search itself. Instead:
|
||||||
|
//
|
||||||
|
// app (container) -> writes a row into `suchlaeufe` (queued run)
|
||||||
|
// runner (host) -> picks queued/due runs up, executes the headless search,
|
||||||
|
// writes status + results back into the same table
|
||||||
|
//
|
||||||
|
// The runner talks to the tracker through the REST API using *that user's* API
|
||||||
|
// key, so an imported Jobangebot lands in the right account and goes through the
|
||||||
|
// same dedup/blacklist path as everything else.
|
||||||
|
//
|
||||||
|
// What the user configures (Städte, Modus, Zusatz-/Ausschlussbegriffe) and what
|
||||||
|
// is derived: the role titles and technology buzzwords still come from the user's
|
||||||
|
// own Lebenslauf (basis_dokumente, typ "Lebenslauf") — the profile steers *where*
|
||||||
|
// and sets guard rails, the CV supplies *what*.
|
||||||
|
|
||||||
|
const MODI = ['regional', 'remote', 'beides'];
|
||||||
|
|
||||||
|
// Weekdays as ISO numbers (1 = Monday … 7 = Sunday), matching what the UI shows.
|
||||||
|
const WOCHENTAGE = [
|
||||||
|
{ nr: 1, kurz: 'Mo' }, { nr: 2, kurz: 'Di' }, { nr: 3, kurz: 'Mi' },
|
||||||
|
{ nr: 4, kurz: 'Do' }, { nr: 5, kurz: 'Fr' }, { nr: 6, kurz: 'Sa' }, { nr: 7, kurz: 'So' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const DEFAULTS = {
|
||||||
|
aktiv: 0,
|
||||||
|
modus: 'regional',
|
||||||
|
staedte: [],
|
||||||
|
berufsbezeichnungen: '',
|
||||||
|
zusatz_begriffe: '',
|
||||||
|
ausschluesse: 'Zeitarbeit / Arbeitnehmerüberlassung, Personalvermittler / Headhunter',
|
||||||
|
zeitplan_tage: '1,2,3,4,5',
|
||||||
|
zeitplan_zeit: '17:00',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Cap the city list: each city multiplies the number of web queries a run makes,
|
||||||
|
// and an unbounded list would turn one run into an all-night job.
|
||||||
|
const MAX_STAEDTE = 12;
|
||||||
|
|
||||||
|
function parseStaedte(raw) {
|
||||||
|
if (Array.isArray(raw)) return normalizeStaedte(raw);
|
||||||
|
if (typeof raw !== 'string' || !raw.trim()) return [];
|
||||||
|
// Accept both the stored JSON array and free text from the form ("A, B\nC").
|
||||||
|
if (raw.trim().startsWith('[')) {
|
||||||
|
try { return normalizeStaedte(JSON.parse(raw)); } catch (e) { /* fall through */ }
|
||||||
|
}
|
||||||
|
return normalizeStaedte(raw.split(/[,;\n]/));
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeStaedte(list) {
|
||||||
|
const out = [];
|
||||||
|
for (const s of list) {
|
||||||
|
const v = String(s == null ? '' : s).trim().replace(/\s+/g, ' ');
|
||||||
|
if (!v) continue;
|
||||||
|
if (out.some((x) => x.toLowerCase() === v.toLowerCase())) continue; // case-insensitive dedup
|
||||||
|
out.push(v);
|
||||||
|
if (out.length >= MAX_STAEDTE) break;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseTage(raw) {
|
||||||
|
const list = String(raw || '').split(',')
|
||||||
|
.map((n) => parseInt(n, 10))
|
||||||
|
.filter((n) => Number.isInteger(n) && n >= 1 && n <= 7);
|
||||||
|
return [...new Set(list)].sort((a, b) => a - b);
|
||||||
|
}
|
||||||
|
|
||||||
|
// "HH:MM", clamped to a valid time; anything unparseable falls back to the default.
|
||||||
|
function parseZeit(raw) {
|
||||||
|
const m = /^(\d{1,2}):(\d{2})$/.exec(String(raw || '').trim());
|
||||||
|
if (!m) return DEFAULTS.zeitplan_zeit;
|
||||||
|
const h = Math.min(23, Math.max(0, parseInt(m[1], 10)));
|
||||||
|
const min = Math.min(59, Math.max(0, parseInt(m[2], 10)));
|
||||||
|
return `${String(h).padStart(2, '0')}:${String(min).padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// A DB row (or nothing) -> the profile object the app and the runner work with.
|
||||||
|
// A user without a row is a valid state: they simply have no search configured.
|
||||||
|
function fromRow(row) {
|
||||||
|
const r = row || {};
|
||||||
|
const modus = MODI.includes(r.modus) ? r.modus : DEFAULTS.modus;
|
||||||
|
return {
|
||||||
|
aktiv: r.aktiv ? 1 : 0,
|
||||||
|
modus,
|
||||||
|
staedte: parseStaedte(r.staedte),
|
||||||
|
berufsbezeichnungen: r.berufsbezeichnungen || '',
|
||||||
|
zusatz_begriffe: r.zusatz_begriffe || '',
|
||||||
|
ausschluesse: r.ausschluesse != null ? r.ausschluesse : DEFAULTS.ausschluesse,
|
||||||
|
zeitplan_tage: parseTage(r.zeitplan_tage != null ? r.zeitplan_tage : DEFAULTS.zeitplan_tage),
|
||||||
|
zeitplan_zeit: parseZeit(r.zeitplan_zeit != null ? r.zeitplan_zeit : DEFAULTS.zeitplan_zeit),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Form body -> storable values. `sanitize` is the app's XSS sanitizer, injected so
|
||||||
|
// this module stays free of app dependencies.
|
||||||
|
function fromForm(body, sanitize = (v) => v) {
|
||||||
|
const b = body || {};
|
||||||
|
const modus = MODI.includes(b.modus) ? b.modus : DEFAULTS.modus;
|
||||||
|
const tage = Array.isArray(b.tage) ? b.tage : (b.tage ? [b.tage] : []);
|
||||||
|
return {
|
||||||
|
aktiv: (b.aktiv === '1' || b.aktiv === 'on' || b.aktiv === true) ? 1 : 0,
|
||||||
|
modus,
|
||||||
|
staedte: parseStaedte(b.staedte).map((s) => sanitize(s)),
|
||||||
|
berufsbezeichnungen: sanitize(String(b.berufsbezeichnungen || '').trim()),
|
||||||
|
zusatz_begriffe: sanitize(String(b.zusatz_begriffe || '').trim()),
|
||||||
|
ausschluesse: sanitize(String(b.ausschluesse || '').trim()),
|
||||||
|
zeitplan_tage: parseTage(tage.join(',')),
|
||||||
|
zeitplan_zeit: parseZeit(b.zeitplan_zeit),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Values as they go into the DB (arrays serialized).
|
||||||
|
function toRow(profil) {
|
||||||
|
return {
|
||||||
|
aktiv: profil.aktiv ? 1 : 0,
|
||||||
|
modus: profil.modus,
|
||||||
|
staedte: JSON.stringify(profil.staedte || []),
|
||||||
|
berufsbezeichnungen: profil.berufsbezeichnungen || '',
|
||||||
|
zusatz_begriffe: profil.zusatz_begriffe || '',
|
||||||
|
ausschluesse: profil.ausschluesse || '',
|
||||||
|
zeitplan_tage: (profil.zeitplan_tage || []).join(','),
|
||||||
|
zeitplan_zeit: profil.zeitplan_zeit || DEFAULTS.zeitplan_zeit,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Is the profile usable for a run? A regional search without a single city would
|
||||||
|
// otherwise send the model off to search "everywhere", which is exactly the
|
||||||
|
// unbounded run we want to avoid.
|
||||||
|
function validate(profil) {
|
||||||
|
const fehler = [];
|
||||||
|
if (!MODI.includes(profil.modus)) fehler.push('Ungültiger Modus.');
|
||||||
|
if (profil.modus !== 'remote' && !(profil.staedte || []).length) {
|
||||||
|
fehler.push('Für die regionale Suche muss mindestens eine Stadt angegeben sein.');
|
||||||
|
}
|
||||||
|
if (profil.aktiv && !(profil.zeitplan_tage || []).length) {
|
||||||
|
fehler.push('Für den Zeitplan muss mindestens ein Wochentag gewählt sein.');
|
||||||
|
}
|
||||||
|
return fehler;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Is a scheduled run due? `letzterLauf` is the ISO timestamp of the last run that
|
||||||
|
// the schedule triggered (manual runs do not count, so a manual test does not
|
||||||
|
// swallow the day's scheduled run). Due means: today is one of the chosen days,
|
||||||
|
// the configured time has passed, and no scheduled run has happened since that
|
||||||
|
// time today.
|
||||||
|
function istFaellig(profil, jetzt, letzterLauf) {
|
||||||
|
if (!profil.aktiv) return false;
|
||||||
|
const tage = profil.zeitplan_tage || [];
|
||||||
|
if (!tage.length) return false;
|
||||||
|
|
||||||
|
const iso = jetzt.getDay() === 0 ? 7 : jetzt.getDay(); // JS: 0=So -> ISO 7
|
||||||
|
if (!tage.includes(iso)) return false;
|
||||||
|
|
||||||
|
const [h, m] = profil.zeitplan_zeit.split(':').map(Number);
|
||||||
|
const faelligAb = new Date(jetzt);
|
||||||
|
faelligAb.setHours(h, m, 0, 0);
|
||||||
|
if (jetzt < faelligAb) return false; // today's slot not reached yet
|
||||||
|
|
||||||
|
if (!letzterLauf) return true;
|
||||||
|
const letzte = new Date(letzterLauf);
|
||||||
|
if (Number.isNaN(letzte.getTime())) return true;
|
||||||
|
return letzte < faelligAb; // already ran for this slot?
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build the instruction the headless search agent gets. Everything user-specific
|
||||||
|
// is interpolated here, so the skill itself no longer carries anyone's cities.
|
||||||
|
// `profilText` is the user's CV/profile text (from their Lebenslauf template) —
|
||||||
|
// the roles and technologies to search for are derived from it, not configured.
|
||||||
|
function buildPrompt(profil, profilText) {
|
||||||
|
const teile = [];
|
||||||
|
|
||||||
|
teile.push(
|
||||||
|
'Führe eine IT-/Fach-Stellensuche vollständig autonom im Hintergrund aus, ohne jede Rückfrage. ' +
|
||||||
|
'Nutze dafür den Stellensuche-Skill (Quellen-Politik, Dedup, Blacklist, Volltext-Beschreibung).'
|
||||||
|
);
|
||||||
|
|
||||||
|
if (profil.modus === 'regional') {
|
||||||
|
teile.push(
|
||||||
|
`Suche AUSSCHLIESSLICH Stellen mit Arbeitsort in diesen Städten: ${profil.staedte.join(', ')} ` +
|
||||||
|
`(Priorität in dieser Reihenfolge, die erste ist der Wohnort). Stellen außerhalb dieser Städte ` +
|
||||||
|
`verwerfen — auch das übrige Umland und reines Homeoffice/Remote ohne Sitz in einer dieser Städte. ` +
|
||||||
|
`Sitzt der Arbeitgeber in einer dieser Städte und bietet zusätzlich Homeoffice, ist die Stelle zulässig. ` +
|
||||||
|
`Setze IMMER labels=["Regional"].`
|
||||||
|
);
|
||||||
|
} else if (profil.modus === 'remote') {
|
||||||
|
teile.push(
|
||||||
|
'Suche AUSSCHLIESSLICH Stellen, die 100 % REMOTE / vollständig im Homeoffice und deutschlandweit ' +
|
||||||
|
'ortsunabhängig ausübbar sind (Anstellung in Deutschland). Firmensitz egal. Verwirf Präsenz-, Hybrid- ' +
|
||||||
|
'und ortsgebundene Stellen sowie vages „Homeoffice möglich" ohne klare 100-%-Remote-Zusage; bestätige ' +
|
||||||
|
'die Remote-Regelung live im Anzeigentext per WebFetch. Setze IMMER labels=["Remote"].'
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
teile.push(
|
||||||
|
`Suche in ZWEI Durchgängen: (1) regional mit Arbeitsort ausschließlich in ${profil.staedte.join(', ')} ` +
|
||||||
|
`(Priorität in dieser Reihenfolge) — diese Treffer mit labels=["Regional"]; (2) deutschlandweit Stellen, ` +
|
||||||
|
`die 100 % REMOTE / vollständig im Homeoffice ausübbar sind (live per WebFetch bestätigen) — diese ` +
|
||||||
|
`Treffer mit labels=["Remote"]. Alles, was in keinen der beiden Durchgänge fällt, verwerfen.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Die Berufsbezeichnungen sind die eigentlichen Suchanfragen — sie entscheiden,
|
||||||
|
// welche Anzeigen überhaupt gefunden werden. Sind sie gesetzt, sucht der Agent
|
||||||
|
// danach, statt sich die Titel bei jedem Lauf neu aus dem Lebenslauf zu
|
||||||
|
// improvisieren (was der Nutzer weder sieht noch steuern kann). Das Profil
|
||||||
|
// liefert dann nur noch die Technologien zur Gewichtung.
|
||||||
|
if (profil.berufsbezeichnungen) {
|
||||||
|
teile.push(
|
||||||
|
`Suche gezielt nach Stellen mit diesen Berufsbezeichnungen (das sind deine Suchanfragen, jede einzeln ` +
|
||||||
|
`abfragen): ${profil.berufsbezeichnungen}. Nahe Schreibweisen und gängige Varianten desselben Titels ` +
|
||||||
|
`(m/w/d, "IT-" davor, Singular/Plural, englische Entsprechung) darfst du ergänzen; auf fachfremde Rollen ` +
|
||||||
|
`ausweichen darfst du NICHT.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (profilText && profilText.trim()) {
|
||||||
|
teile.push(
|
||||||
|
(profil.berufsbezeichnungen
|
||||||
|
? 'Technologie-Buzzwords und die Gewichtung der Treffer leitest du AUS DEM FOLGENDEN PROFIL DES ' +
|
||||||
|
'BEWERBERS ab (die Suchtitel stehen oben und gelten) — gewichte Treffer höher, je mehr aus dem ' +
|
||||||
|
'Profil passt:'
|
||||||
|
: 'Leite Rollenbezeichnungen (Query-Varianten) und Technologie-Buzzwords AUS DEM FOLGENDEN PROFIL DES ' +
|
||||||
|
'BEWERBERS ab — fasse die Rolle breit (dieselbe Tätigkeit läuft je nach Firma unter vielen Titeln) und ' +
|
||||||
|
'gewichte Treffer höher, je mehr aus dem Profil passt:') +
|
||||||
|
'\n\n--- PROFIL ---\n' +
|
||||||
|
profilText.trim().slice(0, 6000) +
|
||||||
|
'\n--- ENDE PROFIL ---'
|
||||||
|
);
|
||||||
|
} else if (profil.berufsbezeichnungen) {
|
||||||
|
teile.push(
|
||||||
|
'Es ist kein Lebenslauf/Profil hinterlegt — halte dich strikt an die oben genannten Berufsbezeichnungen.'
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
teile.push(
|
||||||
|
'ACHTUNG: Es ist kein Lebenslauf/Profil hinterlegt. Leite Rollen und Suchbegriffe aus den bereits ' +
|
||||||
|
'erfassten Bewerbungen des Benutzers ab (GET /applications) und halte die Suche eng an deren Stellentiteln.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (profil.zusatz_begriffe) {
|
||||||
|
teile.push(`Zusätzlich ausdrücklich berücksichtigen: ${profil.zusatz_begriffe}.`);
|
||||||
|
}
|
||||||
|
if (profil.ausschluesse) {
|
||||||
|
teile.push(`Ausschlusskriterien — solche Treffer verwerfen: ${profil.ausschluesse}.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
teile.push(
|
||||||
|
'Fokussiere auf Original-Stellenanzeigen echter Arbeitgeber, bevorzugt direkt auf deren Karriereseite ' +
|
||||||
|
'(Firmen recherchieren, Karriereseite per WebFetch prüfen) — nicht nur breit ausgeschriebene Board-Anzeigen.'
|
||||||
|
);
|
||||||
|
|
||||||
|
teile.push(
|
||||||
|
'Pflege JEDEN neuen, live per WebFetch verifizierten Treffer sofort selbst als Jobangebot ein ' +
|
||||||
|
'(POST /joboffers, external_id DETERMINISTISCH aus firma+stelle mit Präfix "stellensuche-", NICHT aus einer ' +
|
||||||
|
'flüchtigen Board-ID, damit der Server-Upsert statt einer Dublette greift; art=Firmenwebsite wo möglich; die ' +
|
||||||
|
'VOLLSTÄNDIGE Stellenausschreibung als beschreibung; kontakt_email nur wenn belegt). Beachte die Dedup gegen ' +
|
||||||
|
'bereits erfasste Bewerbungen/Jobangebote und die Blacklist. Sperre jeden echten Neuimport (action:created) ' +
|
||||||
|
'danach sofort per POST /joboffers/blacklist (typ=firma_stelle, ort falls bekannt); bei 409 oder bloßem ' +
|
||||||
|
'updated NICHT blacklisten und NICHT mit force erzwingen.'
|
||||||
|
);
|
||||||
|
|
||||||
|
teile.push(
|
||||||
|
'Stelle am Ende KEINE Frage. Gib als LETZTE Zeile exakt diese Bilanz aus (nur Zahlen):\n' +
|
||||||
|
'ERGEBNIS neu=<X> dubletten=<Y> verworfen=<Z>'
|
||||||
|
);
|
||||||
|
|
||||||
|
return teile.join('\n\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
// The runner reports back "ERGEBNIS neu=1 dubletten=2 verworfen=3"; pull the
|
||||||
|
// numbers out of whatever the agent printed. Missing counts read as 0.
|
||||||
|
function parseErgebnis(text) {
|
||||||
|
const m = /ERGEBNIS\s+neu=(\d+)\s+dubletten=(\d+)\s+verworfen=(\d+)/i.exec(String(text || ''));
|
||||||
|
if (!m) return null;
|
||||||
|
return { neu: Number(m[1]), dubletten: Number(m[2]), verworfen: Number(m[3]) };
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
MODI, WOCHENTAGE, DEFAULTS, MAX_STAEDTE,
|
||||||
|
fromRow, fromForm, toRow, validate, istFaellig, buildPrompt, parseErgebnis,
|
||||||
|
parseStaedte, parseTage, parseZeit,
|
||||||
|
};
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
// Web-Recherche für den KI-Chat (Ollama Web Search API).
|
||||||
|
//
|
||||||
|
// Der Chat-Assistent kennt bisher nur, was in der Datenbank steht. Für Fragen
|
||||||
|
// wie „Was muss ich über die Firma wissen?" fehlt ihm alles, was nicht in der
|
||||||
|
// Stellenbeschreibung steht — Größe, Produkte, News, Kultur. Diese beiden
|
||||||
|
// Funktionen geben ihm einen Blick nach draußen:
|
||||||
|
//
|
||||||
|
// suche(query) -> Trefferliste (Titel, URL, Auszug)
|
||||||
|
// seiteLesen(url) -> Volltext einer Seite (z. B. die „Über uns"-Seite)
|
||||||
|
//
|
||||||
|
// Beides läuft über ollama.com und authentifiziert sich mit demselben
|
||||||
|
// OLLAMA_API_KEY, den der Benutzer für Chat und Dokumentenerzeugung hinterlegt
|
||||||
|
// hat — es ist also keine zusätzliche Konfiguration nötig. Anders als /api/chat
|
||||||
|
// ist die Websuche ein reiner Cloud-Dienst: Sie hängt NICHT an OLLAMA_HOST.
|
||||||
|
// Wer lokal gegen http://localhost:11434 chattet, aber keinen ollama.com-Key
|
||||||
|
// hinterlegt hat, hat schlicht keine Websuche (isConfigured() -> false).
|
||||||
|
|
||||||
|
const config = require('./config');
|
||||||
|
|
||||||
|
const HOST = 'https://ollama.com';
|
||||||
|
const TIMEOUT_MS = 30000;
|
||||||
|
// Antworten landen im Kontextfenster des Modells und werden bei jeder weiteren
|
||||||
|
// Runde erneut mitgeschickt. Deshalb hart deckeln: lieber knappe, brauchbare
|
||||||
|
// Auszüge als eine komplette Seite, die den Chat langsam und teuer macht.
|
||||||
|
const MAX_SNIPPET = 1200;
|
||||||
|
const MAX_SEITE = 6000;
|
||||||
|
|
||||||
|
function isConfigured() {
|
||||||
|
return Boolean(config.ollama().apiKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function post(pfad, body) {
|
||||||
|
const { apiKey } = config.ollama();
|
||||||
|
if (!apiKey) throw new Error('Websuche nicht verfügbar: kein Ollama-API-Schlüssel konfiguriert.');
|
||||||
|
|
||||||
|
const ctrl = new AbortController();
|
||||||
|
const timer = setTimeout(() => ctrl.abort(), TIMEOUT_MS);
|
||||||
|
let res;
|
||||||
|
try {
|
||||||
|
res = await fetch(`${HOST}${pfad}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${apiKey}` },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
signal: ctrl.signal,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
if (err.name === 'AbortError') throw new Error('Websuche: Zeitüberschreitung.');
|
||||||
|
throw new Error(`Websuche fehlgeschlagen: ${err.message}`);
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timer);
|
||||||
|
}
|
||||||
|
if (!res.ok) {
|
||||||
|
const text = await res.text().catch(() => '');
|
||||||
|
throw new Error(`Websuche antwortete mit ${res.status}: ${text.slice(0, 200)}`);
|
||||||
|
}
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
function kuerzen(text, max) {
|
||||||
|
const t = String(text || '').replace(/\s+\n/g, '\n').trim();
|
||||||
|
return t.length > max ? `${t.slice(0, max)}…` : t;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Volltextsuche im Web. `maxResults` deckelt Ollama selbst bei 10.
|
||||||
|
async function suche(query, maxResults = 5) {
|
||||||
|
const q = String(query || '').trim();
|
||||||
|
if (q.length < 2) return { treffer: [], hinweis: 'Suchbegriff zu kurz' };
|
||||||
|
const limit = Math.min(Math.max(Number(maxResults) || 5, 1), 10);
|
||||||
|
const data = await post('/api/web_search', { query: q, max_results: limit });
|
||||||
|
const results = Array.isArray(data && data.results) ? data.results : [];
|
||||||
|
return {
|
||||||
|
treffer: results.map((r) => ({
|
||||||
|
titel: r.title || '',
|
||||||
|
url: r.url || '',
|
||||||
|
auszug: kuerzen(r.content, MAX_SNIPPET),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Eine konkrete Seite lesen (Stellenanzeige, Karriere-/Über-uns-Seite, Presse).
|
||||||
|
async function seiteLesen(url) {
|
||||||
|
const u = String(url || '').trim();
|
||||||
|
if (!/^https?:\/\//i.test(u)) throw new Error('Ungültige URL (muss mit http:// oder https:// beginnen).');
|
||||||
|
const data = await post('/api/web_fetch', { url: u });
|
||||||
|
return {
|
||||||
|
url: u,
|
||||||
|
titel: (data && data.title) || '',
|
||||||
|
inhalt: kuerzen(data && data.content, MAX_SEITE),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { isConfigured, suche, seiteLesen };
|
||||||
+104
@@ -0,0 +1,104 @@
|
|||||||
|
// Minimal, dependency-free ZIP (STORE, no compression) for serving the
|
||||||
|
// browser extension as a single downloadable file. The files are tiny, so
|
||||||
|
// skipping DEFLATE keeps the code small and needs no native tooling in the
|
||||||
|
// container (the slim runtime image ships no `zip` binary).
|
||||||
|
//
|
||||||
|
// Returns a Buffer containing a valid .zip whose entries sit at the archive
|
||||||
|
// root (flat — matching "load unpacked extension" on an extracted folder).
|
||||||
|
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const CRC_TABLE = (() => {
|
||||||
|
const t = new Uint32Array(256);
|
||||||
|
for (let n = 0; n < 256; n++) {
|
||||||
|
let c = n;
|
||||||
|
for (let k = 0; k < 8; k++) c = (c & 1) ? (0xedb88320 ^ (c >>> 1)) : (c >>> 1);
|
||||||
|
t[n] = c >>> 0;
|
||||||
|
}
|
||||||
|
return t;
|
||||||
|
})();
|
||||||
|
|
||||||
|
function crc32(buf) {
|
||||||
|
let c = 0xffffffff;
|
||||||
|
for (let i = 0; i < buf.length; i++) c = CRC_TABLE[(c ^ buf[i]) & 0xff] ^ (c >>> 8);
|
||||||
|
return (c ^ 0xffffffff) >>> 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// DOS time/date for a fixed deterministic stamp (1980-01-01 00:00:00).
|
||||||
|
// We avoid per-build timestamps so the archive is byte-identical for a given
|
||||||
|
// source state (cache-friendly, reproducible).
|
||||||
|
const DOS_TIME = 0x0000;
|
||||||
|
const DOS_DATE = 0x0021; // 1980-01-01
|
||||||
|
|
||||||
|
function u16(n) { return Buffer.from([n & 0xff, (n >>> 8) & 0xff]); }
|
||||||
|
function u32(n) { return Buffer.from([n & 0xff, (n >>> 8) & 0xff, (n >>> 16) & 0xff, (n >>> 24) & 0xff]); }
|
||||||
|
|
||||||
|
// Files: array of { name, data: Buffer } (root-relative, no leading slash).
|
||||||
|
function zipFiles(files) {
|
||||||
|
const localParts = [];
|
||||||
|
const centralParts = [];
|
||||||
|
let offset = 0;
|
||||||
|
|
||||||
|
for (const f of files) {
|
||||||
|
const nameBuf = Buffer.from(f.name, 'utf8');
|
||||||
|
const crc = crc32(f.data);
|
||||||
|
const size = f.data.length;
|
||||||
|
|
||||||
|
const local = Buffer.concat([
|
||||||
|
u32(0x04034b50), // local file header signature
|
||||||
|
u16(20), // version needed to extract
|
||||||
|
u16(0), // general purpose bit flag
|
||||||
|
u16(0), // compression method (0 = store)
|
||||||
|
u16(DOS_TIME), u16(DOS_DATE),
|
||||||
|
u32(crc), u32(size), u32(size),
|
||||||
|
u16(nameBuf.length), u16(0),
|
||||||
|
nameBuf,
|
||||||
|
f.data,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const central = Buffer.concat([
|
||||||
|
u32(0x02014b50), // central directory header signature
|
||||||
|
u16(20), // version made by
|
||||||
|
u16(20), // version needed to extract
|
||||||
|
u16(0), u16(0),
|
||||||
|
u16(DOS_TIME), u16(DOS_DATE),
|
||||||
|
u32(crc), u32(size), u32(size),
|
||||||
|
u16(nameBuf.length), u16(0), u16(0), // name len, extra len, comment len
|
||||||
|
u16(0), // disk number start
|
||||||
|
u16(0), // internal attributes
|
||||||
|
u32(0), // external attributes
|
||||||
|
u32(offset), // local header offset
|
||||||
|
nameBuf,
|
||||||
|
]);
|
||||||
|
|
||||||
|
localParts.push(local);
|
||||||
|
centralParts.push(central);
|
||||||
|
offset += local.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
const centralBuf = Buffer.concat(centralParts);
|
||||||
|
const end = Buffer.concat([
|
||||||
|
u32(0x06054b50), // end of central directory signature
|
||||||
|
u16(0), u16(0), // disk number, disk with CD
|
||||||
|
u16(files.length), u16(files.length), // entries on this disk, total
|
||||||
|
u32(centralBuf.length), u32(offset),
|
||||||
|
u16(0), // comment length
|
||||||
|
]);
|
||||||
|
|
||||||
|
return Buffer.concat([...localParts, centralBuf, end]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read every file in a directory (non-recursive, flat) and return a zip Buffer.
|
||||||
|
function zipDir(dir) {
|
||||||
|
const names = fs.readdirSync(dir).sort();
|
||||||
|
const files = [];
|
||||||
|
for (const name of names) {
|
||||||
|
const full = path.join(dir, name);
|
||||||
|
if (!fs.statSync(full).isFile()) continue;
|
||||||
|
files.push({ name, data: fs.readFileSync(full) });
|
||||||
|
}
|
||||||
|
return zipFiles(files);
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { zipDir, zipFiles, crc32 };
|
||||||
+2
-1
@@ -8,7 +8,8 @@
|
|||||||
"dev": "nodemon server.js",
|
"dev": "nodemon server.js",
|
||||||
"docker:build": "docker build -t git.hackner.dev/thomas/jobbi-bewerbung:latest .",
|
"docker:build": "docker build -t git.hackner.dev/thomas/jobbi-bewerbung:latest .",
|
||||||
"docker:push": "docker push 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"
|
"docker:deploy": "npm run docker:build && npm run docker:push",
|
||||||
|
"docker:build-agent": "docker build -t jobbi-bewerbung-agent:latest agent"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"ejs": "^3.1.9",
|
"ejs": "^3.1.9",
|
||||||
|
|||||||
+373
-25
@@ -1,5 +1,5 @@
|
|||||||
/* ============================================
|
/* ============================================
|
||||||
Bewerbungs-Tracker - Custom Styles
|
NextJobs - Custom Styles
|
||||||
============================================ */
|
============================================ */
|
||||||
|
|
||||||
/* Base styles */
|
/* Base styles */
|
||||||
@@ -7,6 +7,290 @@
|
|||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
Top navigation (views/partials/header.ejs)
|
||||||
|
============================================
|
||||||
|
The bar carries no surface of its own — it takes the page background and is
|
||||||
|
held apart from the content only by a hairline (and, once scrolled, the
|
||||||
|
faintest shadow). Everything in it is quiet; the single accent is the active
|
||||||
|
item, marked by a thin rail on the bottom edge. That rail is the whole point,
|
||||||
|
since the header used to have no active state at all.
|
||||||
|
|
||||||
|
--nav-bg mirrors the page background per theme. It is what the focus rings and
|
||||||
|
the badge ring cut themselves out against — without it they would need a
|
||||||
|
hard-coded colour and would break in one of the two themes. */
|
||||||
|
:root { --nav-bg: #f9fafb; } /* gray-50, matches body */
|
||||||
|
.dark { --nav-bg: #111827; } /* gray-900, matches body */
|
||||||
|
|
||||||
|
.nav-brand:focus-visible,
|
||||||
|
.nav-item:focus-visible,
|
||||||
|
.icon-btn:focus-visible {
|
||||||
|
outline: none;
|
||||||
|
box-shadow: 0 0 0 2px var(--nav-bg), 0 0 0 4px rgba(37, 99, 235, 0.6);
|
||||||
|
}
|
||||||
|
.dark .nav-brand:focus-visible,
|
||||||
|
.dark .nav-item:focus-visible,
|
||||||
|
.dark .icon-btn:focus-visible {
|
||||||
|
box-shadow: 0 0 0 2px var(--nav-bg), 0 0 0 4px rgba(56, 189, 248, 0.7);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hairline separation only once content actually slides underneath. */
|
||||||
|
#siteHeader.is-scrolled {
|
||||||
|
box-shadow: 0 1px 3px rgba(15, 23, 42, 0.06), 0 1px 2px rgba(15, 23, 42, 0.04);
|
||||||
|
}
|
||||||
|
.dark #siteHeader.is-scrolled {
|
||||||
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-item {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.375rem;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
color: rgb(71 85 105); /* slate-600 */
|
||||||
|
white-space: nowrap;
|
||||||
|
transition: color 150ms ease, background-color 150ms ease;
|
||||||
|
}
|
||||||
|
button.nav-item, .icon-btn { cursor: pointer; }
|
||||||
|
.nav-item:hover {
|
||||||
|
color: rgb(15 23 42); /* slate-900 */
|
||||||
|
background-color: rgba(15, 23, 42, 0.05);
|
||||||
|
}
|
||||||
|
.nav-item.is-active { color: rgb(15 23 42); }
|
||||||
|
|
||||||
|
.dark .nav-item { color: rgb(148 163 184); } /* slate-400 */
|
||||||
|
.dark .nav-item:hover {
|
||||||
|
color: #fff;
|
||||||
|
background-color: rgba(255, 255, 255, 0.06);
|
||||||
|
}
|
||||||
|
.dark .nav-item.is-active { color: #fff; }
|
||||||
|
|
||||||
|
/* The rail: sits on the header's bottom edge, lights up for the active section
|
||||||
|
and previews faintly on hover. */
|
||||||
|
.nav-rail {
|
||||||
|
position: absolute;
|
||||||
|
left: 0.75rem;
|
||||||
|
right: 0.75rem;
|
||||||
|
bottom: -1px;
|
||||||
|
height: 2px;
|
||||||
|
border-radius: 9999px;
|
||||||
|
background-color: transparent;
|
||||||
|
transform: scaleX(0);
|
||||||
|
transition: transform 200ms ease, background-color 200ms ease;
|
||||||
|
}
|
||||||
|
.nav-item:hover .nav-rail {
|
||||||
|
background-color: rgba(100, 116, 139, 0.35);
|
||||||
|
transform: scaleX(1);
|
||||||
|
}
|
||||||
|
.nav-item.is-active .nav-rail {
|
||||||
|
background-color: rgb(37 99 235); /* blue-600, the app's action colour */
|
||||||
|
transform: scaleX(1);
|
||||||
|
}
|
||||||
|
.dark .nav-item:hover .nav-rail { background-color: rgba(148, 163, 184, 0.4); }
|
||||||
|
.dark .nav-item.is-active .nav-rail {
|
||||||
|
background-color: rgb(56 189 248); /* sky-400 reads better on the dark page */
|
||||||
|
box-shadow: 0 0 10px rgba(56, 189, 248, 0.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Counters shown inline next to a nav label.
|
||||||
|
`:not(.hidden)` is deliberate: a bare `display: inline-flex` would fight
|
||||||
|
Tailwind's `.hidden` (same specificity, load order decides) and the badge would
|
||||||
|
show even at count 0. This way "hidden" always wins. */
|
||||||
|
.nav-badge:not(.hidden) {
|
||||||
|
display: inline-flex;
|
||||||
|
}
|
||||||
|
.nav-badge {
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-width: 1.125rem;
|
||||||
|
height: 1.125rem;
|
||||||
|
padding: 0 0.3rem;
|
||||||
|
border-radius: 9999px;
|
||||||
|
font-size: 0.625rem;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
.nav-badge--red { background-color: rgb(244 63 94); } /* rose-500 */
|
||||||
|
.nav-badge--green { background-color: rgb(16 185 129); } /* emerald-500 */
|
||||||
|
|
||||||
|
/* Icon-only controls in the bar (bell, burger). */
|
||||||
|
.icon-btn {
|
||||||
|
position: relative;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
width: 2.25rem;
|
||||||
|
height: 2.25rem;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
color: rgb(71 85 105); /* slate-600 */
|
||||||
|
transition: color 150ms ease, background-color 150ms ease;
|
||||||
|
}
|
||||||
|
.icon-btn:hover {
|
||||||
|
color: rgb(15 23 42);
|
||||||
|
background-color: rgba(15, 23, 42, 0.05);
|
||||||
|
}
|
||||||
|
.dark .icon-btn { color: rgb(148 163 184); }
|
||||||
|
.dark .icon-btn:hover {
|
||||||
|
color: #fff;
|
||||||
|
background-color: rgba(255, 255, 255, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Count badge floating on an icon button. Same `:not(.hidden)` reason as above. */
|
||||||
|
.dot-badge:not(.hidden) {
|
||||||
|
display: inline-flex;
|
||||||
|
}
|
||||||
|
.dot-badge {
|
||||||
|
position: absolute;
|
||||||
|
top: -0.125rem;
|
||||||
|
right: -0.125rem;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-width: 1.125rem;
|
||||||
|
height: 1.125rem;
|
||||||
|
padding: 0 0.25rem;
|
||||||
|
border-radius: 9999px;
|
||||||
|
font-size: 0.625rem;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1;
|
||||||
|
color: #fff;
|
||||||
|
box-shadow: 0 0 0 2px var(--nav-bg); /* cut out against the bar in both themes */
|
||||||
|
}
|
||||||
|
.dot-badge--red { background-color: rgb(244 63 94); }
|
||||||
|
|
||||||
|
/* Dropdown panels. */
|
||||||
|
.dd-panel {
|
||||||
|
position: absolute;
|
||||||
|
right: 0;
|
||||||
|
top: calc(100% + 0.5rem);
|
||||||
|
z-index: 50;
|
||||||
|
padding: 0.375rem;
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
background-color: #fff;
|
||||||
|
box-shadow: 0 20px 40px -12px rgba(0, 0, 0, 0.35);
|
||||||
|
border: 1px solid rgba(15, 23, 42, 0.08);
|
||||||
|
animation: dd-in 140ms ease-out;
|
||||||
|
}
|
||||||
|
.dark .dd-panel {
|
||||||
|
background-color: rgb(30 41 59); /* slate-800 */
|
||||||
|
border-color: rgba(255, 255, 255, 0.08);
|
||||||
|
box-shadow: 0 20px 40px -12px rgba(0, 0, 0, 0.6);
|
||||||
|
}
|
||||||
|
/* Menus that hang off a left-aligned nav item open to the left edge. */
|
||||||
|
nav .dd-panel { right: auto; left: 0; }
|
||||||
|
|
||||||
|
/* Auf schmalen Schirmen taugt das Ausrichten am Knopf nicht: das Panel hängt an
|
||||||
|
der Glocke, rechts von ihr stehen aber noch Konto und Burger — ein 20rem breites
|
||||||
|
Panel wächst dann nach links aus dem Bild heraus (gemessen: -51px bei 375px).
|
||||||
|
Deshalb spannt es sich hier über die Breite des Headers.
|
||||||
|
|
||||||
|
Der Trick ist `position: static` auf dem Anker: damit wird der Header (sticky =
|
||||||
|
positioniert) zum Bezugsrahmen des absolut gesetzten Panels. Das hält auch die
|
||||||
|
vertikale Lage richtig — top: 100% ist dann die Unterkante des Headers, inklusive
|
||||||
|
des Impersonation-Banners, wenn eines da ist. */
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
#siteHeader .dd-anchor { position: static; }
|
||||||
|
#siteHeader .dd-panel {
|
||||||
|
left: 0.75rem;
|
||||||
|
right: 0.75rem;
|
||||||
|
width: auto; /* schlägt Tailwinds w-80 / w-60 (ID erhöht die Spezifität) */
|
||||||
|
max-width: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes dd-in {
|
||||||
|
from { opacity: 0; transform: translateY(-4px) scale(0.98); }
|
||||||
|
to { opacity: 1; transform: none; }
|
||||||
|
}
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.dd-panel { animation: none; }
|
||||||
|
.nav-rail { transition: none; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.dd-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 0.625rem;
|
||||||
|
padding: 0.5rem 0.625rem;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
color: rgb(51 65 85); /* slate-700 */
|
||||||
|
transition: background-color 150ms ease, color 150ms ease;
|
||||||
|
}
|
||||||
|
.dd-item:hover { background-color: rgb(241 245 249); } /* slate-100 */
|
||||||
|
.dark .dd-item { color: rgb(203 213 225); }
|
||||||
|
.dark .dd-item:hover { background-color: rgba(255, 255, 255, 0.06); }
|
||||||
|
.dd-item:focus-visible {
|
||||||
|
outline: none;
|
||||||
|
box-shadow: 0 0 0 2px rgba(56, 189, 248, 0.8);
|
||||||
|
}
|
||||||
|
.dd-item.is-active { background-color: rgb(239 246 255); color: rgb(29 78 216); } /* blue-50/700 */
|
||||||
|
.dark .dd-item.is-active { background-color: rgba(56, 189, 248, 0.12); color: rgb(125 211 252); }
|
||||||
|
.dd-item--danger { color: rgb(190 18 60); } /* rose-700 */
|
||||||
|
.dd-item--danger:hover { background-color: rgb(255 241 242); }
|
||||||
|
.dark .dd-item--danger { color: rgb(253 164 175); }
|
||||||
|
.dark .dd-item--danger:hover { background-color: rgba(244, 63, 94, 0.12); }
|
||||||
|
|
||||||
|
.dd-icon {
|
||||||
|
width: 1.125rem;
|
||||||
|
height: 1.125rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-top: 0.075rem;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
.dd-title {
|
||||||
|
display: block;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
|
.dd-desc {
|
||||||
|
display: block;
|
||||||
|
margin-top: 0.075rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
line-height: 1.35;
|
||||||
|
color: rgb(100 116 139); /* slate-500 */
|
||||||
|
}
|
||||||
|
.dark .dd-desc { color: rgb(148 163 184); }
|
||||||
|
.dd-item.is-active .dd-desc { color: inherit; opacity: 0.75; }
|
||||||
|
|
||||||
|
/* Mobile menu. */
|
||||||
|
.m-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.625rem 0.75rem;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
font-size: 0.9375rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: rgb(71 85 105); /* slate-600 */
|
||||||
|
transition: background-color 150ms ease, color 150ms ease;
|
||||||
|
}
|
||||||
|
.m-item:hover { background-color: rgba(15, 23, 42, 0.05); color: rgb(15 23 42); }
|
||||||
|
.m-item.is-active {
|
||||||
|
color: rgb(29 78 216); /* blue-700 */
|
||||||
|
background-color: rgb(239 246 255); /* blue-50 */
|
||||||
|
box-shadow: inset 2px 0 0 rgb(37 99 235);
|
||||||
|
}
|
||||||
|
.dark .m-item { color: rgb(148 163 184); }
|
||||||
|
.dark .m-item:hover { background-color: rgba(255, 255, 255, 0.06); color: #fff; }
|
||||||
|
.dark .m-item.is-active {
|
||||||
|
color: #fff;
|
||||||
|
background-color: rgba(56, 189, 248, 0.12);
|
||||||
|
box-shadow: inset 2px 0 0 rgb(56 189 248);
|
||||||
|
}
|
||||||
|
.m-label {
|
||||||
|
padding: 0 0.75rem;
|
||||||
|
font-size: 0.6875rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.12em;
|
||||||
|
color: rgb(100 116 139);
|
||||||
|
}
|
||||||
|
|
||||||
/* Smooth scrolling */
|
/* Smooth scrolling */
|
||||||
html {
|
html {
|
||||||
scroll-behavior: smooth;
|
scroll-behavior: smooth;
|
||||||
@@ -149,23 +433,6 @@ button.loading::after {
|
|||||||
|
|
||||||
/* Responsive adjustments */
|
/* Responsive adjustments */
|
||||||
@media (max-width: 768px) {
|
@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 */
|
/* Make table scroll horizontally on mobile */
|
||||||
.overflow-hidden {
|
.overflow-hidden {
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
@@ -174,16 +441,97 @@ button.loading::after {
|
|||||||
table {
|
table {
|
||||||
min-width: 768px;
|
min-width: 768px;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* Adjust modal width on mobile */
|
/* ============================================
|
||||||
.max-w-lg {
|
Smartphone support (< 640px)
|
||||||
max-width: 90vw;
|
The layout already collapses most grids via Tailwind's sm:/md: prefixes and
|
||||||
margin: 0 5vw;
|
the header ships a burger menu. The rules below cover the few surfaces that
|
||||||
|
still assume desktop: tall modals, the 7-column dashboard grid, and a couple
|
||||||
|
of non-wrapping toolbars.
|
||||||
|
============================================ */
|
||||||
|
|
||||||
|
/* Modals: the overlay locks body scroll (overflow:hidden on <body>), so a panel
|
||||||
|
taller than the viewport must scroll. The scroll container is the INNER
|
||||||
|
wrapper, not the backdrop, and it gets a DEFINITE height:100vh + overflow-y:auto.
|
||||||
|
A mere min-height:100vh (Tailwind's min-h-screen) is not enough: in Safari the
|
||||||
|
flex wrapper then does NOT grow to fit the panel, the panel overflows visibly
|
||||||
|
("card geht aus dem Bild") but the container reports no overflow — so nothing
|
||||||
|
scrolls and the Save button is unreachable. A definite height + overflow
|
||||||
|
forces a real scroll container in every browser. The panel keeps margin:auto
|
||||||
|
so a SHORT modal stays centered, while a tall one starts at the top (no
|
||||||
|
top-clip) and scrolls to the bottom. -webkit-overflow-scrolling gives older
|
||||||
|
iOS momentum touch scrolling. */
|
||||||
|
.fixed.inset-0 {
|
||||||
|
overflow: hidden !important;
|
||||||
|
}
|
||||||
|
.fixed.inset-0 > .min-h-screen {
|
||||||
|
height: 100vh !important;
|
||||||
|
min-height: 0 !important;
|
||||||
|
overflow-y: auto !important;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
align-items: flex-start !important;
|
||||||
|
}
|
||||||
|
.fixed.inset-0 > .min-h-screen > .max-w-lg,
|
||||||
|
.fixed.inset-0 > .min-h-screen > .max-w-md,
|
||||||
|
.fixed.inset-0 > .min-h-screen > .max-w-2xl,
|
||||||
|
.fixed.inset-0 > .min-h-screen > .max-w-3xl,
|
||||||
|
.fixed.inset-0 > .min-h-screen > .max-w-4xl {
|
||||||
|
margin: auto !important;
|
||||||
|
}
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.fixed.inset-0 > .min-h-screen > .max-w-lg,
|
||||||
|
.fixed.inset-0 > .min-h-screen > .max-w-md {
|
||||||
|
max-width: calc(100vw - 2rem) !important;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.max-w-md {
|
/* Dashboard application list: the desktop layout is a 7-column grid forced to
|
||||||
max-width: 90vw;
|
760px and scrolled horizontally — usable on a phone only with two-thumb
|
||||||
margin: 0 5vw;
|
scrolling. Below md, restyle each row into a stacked card so the primary
|
||||||
|
content fits the viewport. The column header row is hidden; the cells are
|
||||||
|
re-placed via grid-template-areas (their DOM order is unrelated to their
|
||||||
|
visual position).
|
||||||
|
|
||||||
|
Die Grenze ist bewusst 767px und nicht 640px wie beim Rest: das Raster braucht
|
||||||
|
allein durch seine minmax()-Spalten rund 764px, und die Karte bekommt ihre
|
||||||
|
min-w-[760px] erst ab md (768px). Zwischen 641px und 767px — Handy quer, große
|
||||||
|
Phones — galt vorher also das Desktop-Raster ohne Not, und Stelle, Art und
|
||||||
|
Status lagen außerhalb des Sichtbaren (gemessen: 132px Überhang bei 667px). */
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
.app-head {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
.app-line.app-grid {
|
||||||
|
grid-template-columns: 1.25rem 1fr auto !important;
|
||||||
|
grid-template-areas:
|
||||||
|
"chevron firma actions"
|
||||||
|
"chevron stelle actions"
|
||||||
|
"chevron datum actions"
|
||||||
|
"chevron art actions"
|
||||||
|
"chevron status actions" !important;
|
||||||
|
row-gap: 0.2rem !important;
|
||||||
|
column-gap: 0.625rem !important;
|
||||||
|
padding: 0.75rem 1rem !important;
|
||||||
|
align-items: start !important;
|
||||||
|
}
|
||||||
|
.app-line.app-grid > *:nth-child(1) { grid-area: chevron; align-self: center; }
|
||||||
|
.app-line.app-grid > *:nth-child(2) { grid-area: datum; }
|
||||||
|
.app-line.app-grid > *:nth-child(3) { grid-area: firma; }
|
||||||
|
.app-line.app-grid > *:nth-child(4) { grid-area: stelle; }
|
||||||
|
.app-line.app-grid > *:nth-child(5) { grid-area: art; }
|
||||||
|
.app-line.app-grid > *:nth-child(6) { grid-area: status; }
|
||||||
|
.app-line.app-grid > *:nth-child(7) { grid-area: actions; align-self: center; }
|
||||||
|
/* Let text wrap inside a card instead of truncating to the now-narrow column. */
|
||||||
|
.app-line.app-grid .truncate {
|
||||||
|
white-space: normal !important;
|
||||||
|
overflow: visible !important;
|
||||||
|
text-overflow: clip !important;
|
||||||
|
}
|
||||||
|
/* Enlarge the edit/delete hit targets for touch. */
|
||||||
|
.app-line.app-grid .app-actions a,
|
||||||
|
.app-line.app-grid .app-actions button {
|
||||||
|
padding: 0.5rem !important;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="NextJobs">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="g" x1="0" y1="0" x2="1" y2="1">
|
||||||
|
<stop offset="0" stop-color="#38bdf8"/>
|
||||||
|
<stop offset="1" stop-color="#2563eb"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<rect width="64" height="64" rx="15" fill="url(#g)"/>
|
||||||
|
<g transform="translate(16 16) scale(1.33)" fill="none" stroke="#fff" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M9 12h6m-6 4h6m2 5H7a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5.586a1 1 0 0 1 .707.293l5.414 5.414a1 1 0 0 1 .293.707V19a2 2 0 0 1-2 2z"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 632 B |
@@ -0,0 +1,519 @@
|
|||||||
|
// KI-Chat client: renders messages (markdown for assistant), sends messages,
|
||||||
|
// streams assistant tokens over SSE, and handles "new chat" reset.
|
||||||
|
// Tailwind CDN is loaded via the head partial; this file only handles logic.
|
||||||
|
(function () {
|
||||||
|
const boot = window.__CHAT_BOOT__ || {};
|
||||||
|
let activeId = boot.activeId || null;
|
||||||
|
const log = document.getElementById('chatLog');
|
||||||
|
const form = document.getElementById('chatForm');
|
||||||
|
const input = document.getElementById('chatInput');
|
||||||
|
const sendBtn = document.getElementById('sendBtn');
|
||||||
|
const newThreadBtn = document.getElementById('newThreadBtn');
|
||||||
|
const threadList = document.getElementById('threadList');
|
||||||
|
let sending = false;
|
||||||
|
|
||||||
|
// ----- helpers -----
|
||||||
|
function escapeHtml(s) {
|
||||||
|
const d = document.createElement('div');
|
||||||
|
d.textContent = s == null ? '' : String(s);
|
||||||
|
return d.innerHTML;
|
||||||
|
}
|
||||||
|
|
||||||
|
function scrollBottom() {
|
||||||
|
if (log) log.scrollTop = log.scrollHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Minimal markdown → HTML. HTML is escaped first, so model output can never
|
||||||
|
// inject markup; only the markdown tokens below are turned into tags. Link
|
||||||
|
// targets are restricted to http(s)/mailto/relative so a `javascript:` URL
|
||||||
|
// from the model cannot become a clickable XSS vector, and the URL is stripped
|
||||||
|
// of characters that could break out of the href attribute.
|
||||||
|
function link(url, label) {
|
||||||
|
const safe = String(url).trim().replace(/["'<>`\\]/g, '');
|
||||||
|
return `<a href="${safe}" target="_blank" rel="noopener noreferrer">${label}</a>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function inlineFmt(t) {
|
||||||
|
// Fertige <a>-Tags werden geparkt, damit die Autolink-Regel unten nicht in
|
||||||
|
// eine bereits gebaute href hineinläuft und den Tag zerlegt.
|
||||||
|
const parked = [];
|
||||||
|
const park = (html) => `\u0000${parked.push(html) - 1}\u0000`;
|
||||||
|
|
||||||
|
let s = t
|
||||||
|
.replace(/`([^`]+)`/g, '<code>$1</code>')
|
||||||
|
.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
|
||||||
|
.replace(/(^|[^*])\*([^*]+)\*/g, '$1<em>$2</em>')
|
||||||
|
.replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, (m, label, url) => {
|
||||||
|
const u = String(url).trim();
|
||||||
|
if (u && !/^(https?:|mailto:|\/|#)/i.test(u)) return m;
|
||||||
|
return park(link(u, label));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Nackte Quellen-URLs klickbar machen — bei Web-Recherchen nennt der
|
||||||
|
// Assistent Belege oft als reine URL, nicht als Markdown-Link. Nur http(s),
|
||||||
|
// und Satzzeichen am Ende gehören zum Satz, nicht zur Adresse.
|
||||||
|
s = s.replace(/https?:\/\/[^\s<>"'`\u0000]+/g, (u) => {
|
||||||
|
const m = u.match(/[.,;:!?)\]]+$/);
|
||||||
|
const rein = m ? u.slice(0, -m[0].length) : u;
|
||||||
|
return park(link(rein, rein)) + (m ? m[0] : '');
|
||||||
|
});
|
||||||
|
|
||||||
|
return s.replace(/\u0000(\d+)\u0000/g, (m, i) => parked[Number(i)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Trennzeile einer Markdown-Tabelle: |---|:---:|---:| (mind. zwei Striche je
|
||||||
|
// Spalte, Doppelpunkte für die Ausrichtung).
|
||||||
|
function isTableSeparator(line) {
|
||||||
|
return /^\s*\|?\s*:?-{2,}:?\s*(\|\s*:?-{2,}:?\s*)*\|?\s*$/.test(line);
|
||||||
|
}
|
||||||
|
|
||||||
|
function tableCells(line) {
|
||||||
|
return line.trim().replace(/^\|/, '').replace(/\|$/, '').split('|').map((c) => c.trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
function alignOf(sep) {
|
||||||
|
const links = sep.startsWith(':');
|
||||||
|
const rechts = sep.endsWith(':');
|
||||||
|
if (links && rechts) return 'center';
|
||||||
|
if (rechts) return 'right';
|
||||||
|
return null; // linksbündig ist der Standard, kein style nötig
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderMarkdown(src) {
|
||||||
|
const esc = escapeHtml(src == null ? '' : String(src)).replace(/\r\n/g, '\n');
|
||||||
|
const lines = esc.split('\n');
|
||||||
|
let html = '';
|
||||||
|
let i = 0;
|
||||||
|
let para = [];
|
||||||
|
const flushPara = () => {
|
||||||
|
if (para.length) html += '<p>' + inlineFmt(para.join(' ')) + '</p>';
|
||||||
|
para = [];
|
||||||
|
};
|
||||||
|
while (i < lines.length) {
|
||||||
|
const line = lines[i];
|
||||||
|
if (/^```/.test(line)) { // fenced code block
|
||||||
|
flushPara();
|
||||||
|
const code = [];
|
||||||
|
i++;
|
||||||
|
while (i < lines.length && !/^```/.test(lines[i])) { code.push(lines[i]); i++; }
|
||||||
|
i++; // closing fence
|
||||||
|
html += '<pre><code>' + code.join('\n') + '</code></pre>';
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const hm = line.match(/^(#{1,6})\s+(.*)$/);
|
||||||
|
if (hm) {
|
||||||
|
flushPara();
|
||||||
|
const lvl = Math.min(hm[1].length, 3);
|
||||||
|
html += '<h' + lvl + '>' + inlineFmt(hm[2].trim()) + '</h' + lvl + '>';
|
||||||
|
i++; continue;
|
||||||
|
}
|
||||||
|
if (/^\s*[-*]\s+/.test(line)) { // bullet list
|
||||||
|
flushPara();
|
||||||
|
const items = [];
|
||||||
|
while (i < lines.length && /^\s*[-*]\s+/.test(lines[i])) {
|
||||||
|
items.push('<li>' + inlineFmt(lines[i].replace(/^\s*[-*]\s+/, '')) + '</li>');
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
html += '<ul>' + items.join('') + '</ul>';
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (/^\s*\d+\.\s+/.test(line)) { // numbered list
|
||||||
|
flushPara();
|
||||||
|
const items = [];
|
||||||
|
while (i < lines.length && /^\s*\d+\.\s+/.test(lines[i])) {
|
||||||
|
items.push('<li>' + inlineFmt(lines[i].replace(/^\s*\d+\.\s+/, '')) + '</li>');
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
html += '<ol>' + items.join('') + '</ol>';
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Tabelle (GFM): Kopfzeile + Trennzeile (|---|:--:|), dann Datenzeilen.
|
||||||
|
// Das Modell nutzt Tabellen gern für Anforderung/Erfahrung-Vergleiche;
|
||||||
|
// ohne diesen Zweig landeten sie als Pipe-Wüste in einem Absatz.
|
||||||
|
if (line.indexOf('|') >= 0 && i + 1 < lines.length && isTableSeparator(lines[i + 1])) {
|
||||||
|
flushPara();
|
||||||
|
const kopf = tableCells(line);
|
||||||
|
const ausricht = tableCells(lines[i + 1]).map(alignOf);
|
||||||
|
i += 2;
|
||||||
|
const zeilen = [];
|
||||||
|
while (i < lines.length && lines[i].indexOf('|') >= 0 && !/^\s*$/.test(lines[i])) {
|
||||||
|
zeilen.push(tableCells(lines[i]));
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
const zelle = (tag, inhalt, idx) => {
|
||||||
|
const a = ausricht[idx];
|
||||||
|
return '<' + tag + (a ? ' style="text-align:' + a + '"' : '') + '>' + inlineFmt(inhalt || '') + '</' + tag + '>';
|
||||||
|
};
|
||||||
|
let t = '<div class="chat-md-table"><table><thead><tr>';
|
||||||
|
kopf.forEach((c, idx) => { t += zelle('th', c, idx); });
|
||||||
|
t += '</tr></thead><tbody>';
|
||||||
|
for (const z of zeilen) {
|
||||||
|
t += '<tr>';
|
||||||
|
// An der Kopfbreite ausrichten: fehlende Zellen leer, überzählige raus —
|
||||||
|
// sonst zerreißt eine krumme Zeile das Tabellenraster.
|
||||||
|
for (let c = 0; c < kopf.length; c++) t += zelle('td', z[c], c);
|
||||||
|
t += '</tr>';
|
||||||
|
}
|
||||||
|
html += t + '</tbody></table></div>';
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (/^\s*([-*_])\1{2,}\s*$/.test(line)) { // Trennlinie
|
||||||
|
flushPara();
|
||||||
|
html += '<hr>';
|
||||||
|
i++; continue;
|
||||||
|
}
|
||||||
|
if (/^\s*$/.test(line)) { flushPara(); i++; continue; }
|
||||||
|
para.push(line.trim());
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
flushPara();
|
||||||
|
return html;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearLogToEmpty(msg) {
|
||||||
|
if (!log) return;
|
||||||
|
log.innerHTML =
|
||||||
|
'<div class="h-full flex items-center justify-center text-gray-400 text-sm px-6">' +
|
||||||
|
escapeHtml(msg) + '</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build a message row. Assistant content is markdown-rendered + left aligned;
|
||||||
|
// user content is escaped plain text with preserved line breaks.
|
||||||
|
function buildRow(role, contentHtml) {
|
||||||
|
const wrap = document.createElement('div');
|
||||||
|
wrap.className = 'flex ' + (role === 'user' ? 'justify-end' : 'justify-start');
|
||||||
|
const bubble = document.createElement('div');
|
||||||
|
bubble.className = 'max-w-[85%] rounded-lg px-3 py-2 text-sm text-left ' +
|
||||||
|
(role === 'user'
|
||||||
|
? 'bg-blue-600 text-white whitespace-pre-wrap break-words'
|
||||||
|
: 'bg-gray-100 dark:bg-gray-700 text-gray-800 dark:text-gray-100 chat-md');
|
||||||
|
bubble.innerHTML = contentHtml;
|
||||||
|
wrap.appendChild(bubble);
|
||||||
|
return { wrap, bubble };
|
||||||
|
}
|
||||||
|
|
||||||
|
function addMessage(role, content) {
|
||||||
|
if (log && log.querySelector('.h-full')) log.innerHTML = '';
|
||||||
|
const html = role === 'user' ? escapeHtml(content) : renderMarkdown(content);
|
||||||
|
const { wrap, bubble } = buildRow(role, html);
|
||||||
|
log.appendChild(wrap);
|
||||||
|
scrollBottom();
|
||||||
|
return bubble;
|
||||||
|
}
|
||||||
|
|
||||||
|
function typingIndicator() {
|
||||||
|
if (log && log.querySelector('.h-full')) log.innerHTML = '';
|
||||||
|
const wrap = document.createElement('div');
|
||||||
|
wrap.className = 'flex justify-start';
|
||||||
|
wrap.id = 'typing';
|
||||||
|
wrap.innerHTML = '<div class="rounded-lg px-3 py-2 bg-gray-100 dark:bg-gray-700 text-gray-500 flex gap-1">' +
|
||||||
|
'<span class="typing-dot"></span><span class="typing-dot"></span><span class="typing-dot"></span></div>';
|
||||||
|
log.appendChild(wrap);
|
||||||
|
scrollBottom();
|
||||||
|
return wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Titel des aktiven Threads in der Seitenleiste setzen (kein innerHTML: der
|
||||||
|
// Titel kommt aus einer Modellantwort).
|
||||||
|
function setThreadTitle(titel) {
|
||||||
|
if (!titel || !activeId) return;
|
||||||
|
const li = threadList && threadList.querySelector('li[data-thread-li="' + activeId + '"]');
|
||||||
|
const titleEl = li && li.querySelector('.threadLink .font-medium');
|
||||||
|
if (titleEl && titel !== titleEl.textContent) titleEl.textContent = titel;
|
||||||
|
}
|
||||||
|
|
||||||
|
function prependThread(id, titel) {
|
||||||
|
if (!threadList) return;
|
||||||
|
const empty = threadList.querySelector('li.text-center');
|
||||||
|
if (empty) empty.remove();
|
||||||
|
const li = document.createElement('li');
|
||||||
|
li.setAttribute('data-thread-li', id);
|
||||||
|
li.innerHTML =
|
||||||
|
'<div class="flex items-center group">' +
|
||||||
|
'<a href="/chat?thread=' + encodeURIComponent(id) + '" data-thread="' + id +
|
||||||
|
'" class="threadLink flex-1 min-w-0 px-3 py-2 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-700/40">' +
|
||||||
|
'<div class="truncate font-medium">' + escapeHtml(titel || '(neuer Chat)') + '</div>' +
|
||||||
|
'<div class="text-[11px] text-gray-400">gerade</div>' +
|
||||||
|
'</a>' +
|
||||||
|
'<button type="button" data-rename-thread="' + id +
|
||||||
|
'" class="threadRename shrink-0 p-1.5 rounded text-gray-400 hover:text-blue-600 hover:bg-blue-50 dark:hover:bg-blue-900/30 opacity-0 group-hover:opacity-100 focus:opacity-100" title="Namen ändern" aria-label="Namen ändern">' +
|
||||||
|
'<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">' +
|
||||||
|
'<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"></path>' +
|
||||||
|
'</svg>' +
|
||||||
|
'</button>' +
|
||||||
|
'<button type="button" data-delete-thread="' + id +
|
||||||
|
'" class="threadDelete shrink-0 mr-2 p-1.5 rounded text-gray-400 hover:text-red-600 hover:bg-red-50 dark:hover:bg-red-900/30 opacity-0 group-hover:opacity-100 focus:opacity-100" title="Verlauf löschen" aria-label="Verlauf löschen">' +
|
||||||
|
'<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">' +
|
||||||
|
'<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6M1 7h22M9 7V4a1 1 0 011-1h4a1 1 0 011 1v3"></path>' +
|
||||||
|
'</svg>' +
|
||||||
|
'</button>' +
|
||||||
|
'</div>';
|
||||||
|
threadList.insertBefore(li, threadList.firstChild);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----- "Neuer Chat": reset client-side to a fresh empty conversation -----
|
||||||
|
function resetToNewChat() {
|
||||||
|
activeId = null;
|
||||||
|
document.getElementById('activeThread').value = '';
|
||||||
|
clearLogToEmpty('Stelle eine Frage zu deinen Bewerbungen.');
|
||||||
|
if (input) { input.value = ''; input.disabled = false; input.style.height = 'auto'; input.focus(); }
|
||||||
|
if (sendBtn) sendBtn.disabled = false;
|
||||||
|
// deselect sidebar rows
|
||||||
|
threadList && threadList.querySelectorAll('.bg-blue-50, .dark\\:bg-gray-700').forEach((el) => {
|
||||||
|
el.classList.remove('bg-blue-50', 'dark:bg-gray-700');
|
||||||
|
});
|
||||||
|
history.replaceState(null, '', '/chat');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createThread() {
|
||||||
|
const res = await fetch('/chat/api/threads', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ titel: '' }),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error('Thread konnte nicht angelegt werden.');
|
||||||
|
const data = await res.json();
|
||||||
|
activeId = data.id;
|
||||||
|
document.getElementById('activeThread').value = activeId;
|
||||||
|
prependThread(activeId, data.titel);
|
||||||
|
history.replaceState(null, '', '/chat?thread=' + activeId);
|
||||||
|
return activeId;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse the SSE response body stream; call onEvent with each parsed data object.
|
||||||
|
async function consumeSSE(res, onEvent) {
|
||||||
|
const reader = res.body.getReader();
|
||||||
|
const decoder = new TextDecoder();
|
||||||
|
let buffer = '';
|
||||||
|
while (true) {
|
||||||
|
const { done, value } = await reader.read();
|
||||||
|
if (done) break;
|
||||||
|
buffer += decoder.decode(value, { stream: true });
|
||||||
|
let sep;
|
||||||
|
while ((sep = buffer.indexOf('\n\n')) >= 0) {
|
||||||
|
const block = buffer.slice(0, sep);
|
||||||
|
buffer = buffer.slice(sep + 2);
|
||||||
|
for (const line of block.split('\n')) {
|
||||||
|
if (!line.startsWith('data:')) continue;
|
||||||
|
const payload = line.slice(5).trim();
|
||||||
|
if (!payload) continue;
|
||||||
|
try { onEvent(JSON.parse(payload)); } catch (e) { /* ignore */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendMessage(text) {
|
||||||
|
if (sending || !text.trim()) return;
|
||||||
|
sending = true;
|
||||||
|
sendBtn.disabled = true;
|
||||||
|
|
||||||
|
if (!activeId) {
|
||||||
|
try { await createThread(); }
|
||||||
|
catch (e) { sending = false; sendBtn.disabled = false; alert(e.message); return; }
|
||||||
|
}
|
||||||
|
|
||||||
|
addMessage('user', text);
|
||||||
|
input.value = '';
|
||||||
|
input.style.height = 'auto';
|
||||||
|
const typing = typingIndicator();
|
||||||
|
let assistantBubble = null;
|
||||||
|
let assistantText = '';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch('/chat/api/threads/' + activeId + '/messages', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ content: text }),
|
||||||
|
});
|
||||||
|
if (!res.ok || !res.body) {
|
||||||
|
let msg = 'Senden fehlgeschlagen (' + res.status + ')';
|
||||||
|
try { const j = await res.json(); msg = j.error || msg; } catch (e) {}
|
||||||
|
throw new Error(msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
await consumeSSE(res, (ev) => {
|
||||||
|
if (ev.type === 'tool') {
|
||||||
|
// Tool lookups run before the first text token; surface a muted hint
|
||||||
|
// inside the typing indicator so the user sees activity.
|
||||||
|
const typingEl = document.getElementById('typing');
|
||||||
|
if (typingEl && !assistantBubble) {
|
||||||
|
const label = ev.label || ev.name || 'nachschlagen…';
|
||||||
|
typingEl.innerHTML = '<div class="rounded-lg px-3 py-2 bg-gray-100 dark:bg-gray-700 text-gray-400 text-sm">' +
|
||||||
|
'🔍 ' + escapeHtml(label) + '</div>';
|
||||||
|
}
|
||||||
|
scrollBottom();
|
||||||
|
} else if (ev.type === 'token') {
|
||||||
|
if (!assistantBubble) {
|
||||||
|
if (typing) typing.remove();
|
||||||
|
assistantBubble = addMessage('assistant', '');
|
||||||
|
}
|
||||||
|
assistantText += ev.content;
|
||||||
|
assistantBubble.innerHTML = renderMarkdown(assistantText);
|
||||||
|
scrollBottom();
|
||||||
|
} else if (ev.type === 'done') {
|
||||||
|
if (assistantBubble && ev.content) {
|
||||||
|
assistantBubble.innerHTML = renderMarkdown(ev.content);
|
||||||
|
} else if (!assistantBubble) {
|
||||||
|
if (typing) typing.remove();
|
||||||
|
addMessage('assistant', ev.content || '(keine Antwort)');
|
||||||
|
}
|
||||||
|
// Live-update the auto-generated thread title in the sidebar.
|
||||||
|
setThreadTitle(ev.titel);
|
||||||
|
scrollBottom();
|
||||||
|
} else if (ev.type === 'titel') {
|
||||||
|
// Kommt nach 'done' nach: der vom Modell formulierte Titel löst den
|
||||||
|
// vorläufigen (abgeschnittene erste Nachricht) in der Seitenleiste ab.
|
||||||
|
setThreadTitle(ev.titel);
|
||||||
|
} else if (ev.type === 'error') {
|
||||||
|
if (typing) typing.remove();
|
||||||
|
if (!assistantBubble) addMessage('assistant', 'Fehler: ' + ev.message);
|
||||||
|
else assistantBubble.innerHTML = renderMarkdown(assistantText + '\n\n_Fehler: ' + ev.message + '_');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
if (typing) typing.remove();
|
||||||
|
addMessage('assistant', 'Fehler: ' + (err.message || 'unbekannt'));
|
||||||
|
} finally {
|
||||||
|
if (typing && typing.parentNode) typing.remove();
|
||||||
|
sending = false;
|
||||||
|
sendBtn.disabled = false;
|
||||||
|
input.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----- wiring -----
|
||||||
|
if (input) {
|
||||||
|
input.addEventListener('input', () => {
|
||||||
|
input.style.height = 'auto';
|
||||||
|
input.style.height = Math.min(input.scrollHeight, 160) + 'px';
|
||||||
|
});
|
||||||
|
input.addEventListener('keydown', (e) => {
|
||||||
|
if (e.key === 'Enter' && !e.shiftKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
sendMessage(input.value);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (form) {
|
||||||
|
form.addEventListener('submit', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
sendMessage(input.value);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newThreadBtn) {
|
||||||
|
newThreadBtn.addEventListener('click', resetToNewChat);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rename a thread inline: replace the title with an input, save on
|
||||||
|
// Enter/blur (PATCH), cancel on Escape.
|
||||||
|
async function saveRename(input, link, id, original) {
|
||||||
|
const val = input.value.trim();
|
||||||
|
if (!val) { link.querySelector('.font-medium').textContent = original; return; }
|
||||||
|
try {
|
||||||
|
const res = await fetch('/chat/api/threads/' + id, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ titel: val }),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error('Speichern fehlgeschlagen');
|
||||||
|
link.querySelector('.font-medium').textContent = val;
|
||||||
|
} catch (err) {
|
||||||
|
link.querySelector('.font-medium').textContent = original;
|
||||||
|
alert(err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function startRename(li, id) {
|
||||||
|
const link = li.querySelector('.threadLink');
|
||||||
|
const titleEl = link.querySelector('.font-medium');
|
||||||
|
const original = titleEl.textContent;
|
||||||
|
const input = document.createElement('input');
|
||||||
|
input.type = 'text';
|
||||||
|
input.value = original;
|
||||||
|
input.className = 'font-medium w-full bg-transparent border border-blue-400 rounded px-1 py-0.5 text-sm text-gray-800 dark:text-gray-100 focus:outline-none focus:ring-1 focus:ring-blue-500';
|
||||||
|
titleEl.replaceWith(input);
|
||||||
|
input.focus();
|
||||||
|
input.select();
|
||||||
|
let done = false;
|
||||||
|
const commit = () => {
|
||||||
|
if (done) return; done = true;
|
||||||
|
const title = document.createElement('div');
|
||||||
|
title.className = 'truncate font-medium';
|
||||||
|
title.textContent = input.value.trim() || original;
|
||||||
|
input.replaceWith(title);
|
||||||
|
if (input.value.trim() && input.value.trim() !== original) {
|
||||||
|
saveRename(input, link, id, original);
|
||||||
|
} else {
|
||||||
|
title.textContent = original;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
input.addEventListener('keydown', (e) => {
|
||||||
|
if (e.key === 'Enter') { e.preventDefault(); commit(); }
|
||||||
|
else if (e.key === 'Escape') { e.preventDefault(); done = true; const title = document.createElement('div'); title.className = 'truncate font-medium'; title.textContent = original; input.replaceWith(title); }
|
||||||
|
});
|
||||||
|
input.addEventListener('blur', commit);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (threadList) {
|
||||||
|
threadList.addEventListener('click', (e) => {
|
||||||
|
const renameBtn = e.target.closest('[data-rename-thread]');
|
||||||
|
if (renameBtn) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
const id = Number(renameBtn.getAttribute('data-rename-thread'));
|
||||||
|
const li = threadList.querySelector('li[data-thread-li="' + id + '"]');
|
||||||
|
if (li) startRename(li, id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete a thread from the sidebar. Event delegation: works for server-rendered
|
||||||
|
// rows and ones added client-side (prependThread).
|
||||||
|
if (threadList) {
|
||||||
|
threadList.addEventListener('click', async (e) => {
|
||||||
|
const btn = e.target.closest('[data-delete-thread]');
|
||||||
|
if (!btn) return;
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
const id = Number(btn.getAttribute('data-delete-thread'));
|
||||||
|
if (!id || !confirm('Diesen Chat-Verlauf wirklich löschen?')) return;
|
||||||
|
try {
|
||||||
|
const res = await fetch('/chat/api/threads/' + id, { method: 'DELETE' });
|
||||||
|
if (!res.ok) throw new Error('Löschen fehlgeschlagen (' + res.status + ')');
|
||||||
|
} catch (err) {
|
||||||
|
alert(err.message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const li = threadList.querySelector('li[data-thread-li="' + id + '"]');
|
||||||
|
if (li) li.remove();
|
||||||
|
// If the deleted thread was active, start a fresh chat.
|
||||||
|
if (activeId === id) resetToNewChat();
|
||||||
|
// If no threads remain, show the empty placeholder.
|
||||||
|
if (!threadList.querySelector('li[data-thread-li]')) {
|
||||||
|
const empty = document.createElement('li');
|
||||||
|
empty.className = 'px-3 py-4 text-sm text-gray-400 text-center';
|
||||||
|
empty.textContent = 'Noch keine Verläufe.';
|
||||||
|
threadList.appendChild(empty);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render any pre-loaded messages (existing thread) with the same renderer.
|
||||||
|
function renderInitial() {
|
||||||
|
const blob = document.getElementById('initialMessages');
|
||||||
|
if (!blob) return;
|
||||||
|
let msgs = [];
|
||||||
|
try { msgs = JSON.parse(blob.textContent); } catch (e) { return; }
|
||||||
|
if (!Array.isArray(msgs) || !msgs.length) return;
|
||||||
|
if (log.querySelector('.h-full')) log.innerHTML = '';
|
||||||
|
msgs.forEach((m) => addMessage(m.role, m.content));
|
||||||
|
scrollBottom();
|
||||||
|
}
|
||||||
|
|
||||||
|
renderInitial();
|
||||||
|
scrollBottom();
|
||||||
|
if (input && !input.disabled) input.focus();
|
||||||
|
})();
|
||||||
+139
-241
@@ -1,37 +1,28 @@
|
|||||||
// ============================================
|
// ============================================
|
||||||
// Bewerbungs-Tracker - Client-side JavaScript
|
// NextJobs - Client-side JavaScript
|
||||||
// ============================================
|
// ============================================
|
||||||
|
|
||||||
// DOM Elements
|
// DOM Elements
|
||||||
const body = document.getElementById('body');
|
const body = document.getElementById('body');
|
||||||
const darkModeToggle = document.getElementById('darkModeToggle');
|
|
||||||
const sunIcon = document.getElementById('sunIcon');
|
|
||||||
const moonIcon = document.getElementById('moonIcon');
|
|
||||||
|
|
||||||
// Modal Elements
|
// Modal Elements
|
||||||
const settingsModal = document.getElementById('settingsModal');
|
|
||||||
const applicationModal = document.getElementById('applicationModal');
|
const applicationModal = document.getElementById('applicationModal');
|
||||||
const deleteModal = document.getElementById('deleteModal');
|
const deleteModal = document.getElementById('deleteModal');
|
||||||
const pdfExportModal = document.getElementById('pdfExportModal');
|
const pdfExportModal = document.getElementById('pdfExportModal');
|
||||||
|
|
||||||
// Form Elements
|
// Form Elements
|
||||||
const settingsForm = document.getElementById('settingsForm');
|
|
||||||
const applicationForm = document.getElementById('applicationForm');
|
const applicationForm = document.getElementById('applicationForm');
|
||||||
const pdfExportForm = document.getElementById('pdfExportForm');
|
const pdfExportForm = document.getElementById('pdfExportForm');
|
||||||
const filterForm = document.getElementById('filterForm');
|
|
||||||
|
|
||||||
// Button Elements
|
// Button Elements
|
||||||
const settingsBtn = document.getElementById('settingsBtn');
|
|
||||||
const addApplicationBtn = document.getElementById('addApplicationBtn');
|
const addApplicationBtn = document.getElementById('addApplicationBtn');
|
||||||
const exportPdfBtn = document.getElementById('exportPdfBtn');
|
const exportPdfBtn = document.getElementById('exportPdfBtn');
|
||||||
|
|
||||||
// Close Modal Buttons
|
// Close Modal Buttons
|
||||||
const closeSettingsModal = document.getElementById('closeSettingsModal');
|
|
||||||
const closeApplicationModal = document.getElementById('closeApplicationModal');
|
const closeApplicationModal = document.getElementById('closeApplicationModal');
|
||||||
const closeDeleteModal = document.getElementById('closeDeleteModal');
|
const closeDeleteModal = document.getElementById('closeDeleteModal');
|
||||||
const closePdfModal = document.getElementById('closePdfModal');
|
const closePdfModal = document.getElementById('closePdfModal');
|
||||||
|
|
||||||
const cancelSettings = document.getElementById('cancelSettings');
|
|
||||||
const cancelApplication = document.getElementById('cancelApplication');
|
const cancelApplication = document.getElementById('cancelApplication');
|
||||||
const cancelDelete = document.getElementById('cancelDelete');
|
const cancelDelete = document.getElementById('cancelDelete');
|
||||||
const cancelPdfExport = document.getElementById('cancelPdfExport');
|
const cancelPdfExport = document.getElementById('cancelPdfExport');
|
||||||
@@ -42,40 +33,6 @@ let currentApplicationId = null;
|
|||||||
let currentDeleteId = null;
|
let currentDeleteId = null;
|
||||||
let pdfLibrariesLoaded = false;
|
let pdfLibrariesLoaded = false;
|
||||||
|
|
||||||
// ============================================
|
|
||||||
// Dark Mode
|
|
||||||
// ============================================
|
|
||||||
|
|
||||||
function initializeDarkMode() {
|
|
||||||
// Check localStorage for dark mode preference
|
|
||||||
const darkMode = localStorage.getItem('darkMode');
|
|
||||||
|
|
||||||
// The `dark` class must live on <html> so that Tailwind's `.dark .dark:*`
|
|
||||||
// selectors apply to the <body> itself, not just its descendants.
|
|
||||||
if (darkMode === 'enabled' || (!darkMode && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
|
|
||||||
document.documentElement.classList.add('dark');
|
|
||||||
sunIcon.classList.add('hidden');
|
|
||||||
moonIcon.classList.remove('hidden');
|
|
||||||
} else {
|
|
||||||
document.documentElement.classList.remove('dark');
|
|
||||||
sunIcon.classList.remove('hidden');
|
|
||||||
moonIcon.classList.add('hidden');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function toggleDarkMode() {
|
|
||||||
document.documentElement.classList.toggle('dark');
|
|
||||||
sunIcon.classList.toggle('hidden');
|
|
||||||
moonIcon.classList.toggle('hidden');
|
|
||||||
|
|
||||||
// Save preference to localStorage
|
|
||||||
if (document.documentElement.classList.contains('dark')) {
|
|
||||||
localStorage.setItem('darkMode', 'enabled');
|
|
||||||
} else {
|
|
||||||
localStorage.setItem('darkMode', 'disabled');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================
|
// ============================================
|
||||||
// Modal Functions
|
// Modal Functions
|
||||||
// ============================================
|
// ============================================
|
||||||
@@ -96,51 +53,6 @@ function resetApplicationForm() {
|
|||||||
currentApplicationId = null;
|
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)
|
// Application Management (CRUD)
|
||||||
// ============================================
|
// ============================================
|
||||||
@@ -184,7 +96,8 @@ function saveApplication(event) {
|
|||||||
status: formData.get('status'),
|
status: formData.get('status'),
|
||||||
notizen: formData.get('notizen'),
|
notizen: formData.get('notizen'),
|
||||||
interne_notizen: formData.get('interne_notizen'),
|
interne_notizen: formData.get('interne_notizen'),
|
||||||
kommentar: formData.get('kommentar')
|
kommentar: formData.get('kommentar'),
|
||||||
|
labels: formData.getAll('labels')
|
||||||
};
|
};
|
||||||
|
|
||||||
let url = '/api/bewerbungen';
|
let url = '/api/bewerbungen';
|
||||||
@@ -299,15 +212,32 @@ function generatePDF(event) {
|
|||||||
url += params.join('&') + '&';
|
url += params.join('&') + '&';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// loadPdfLibraries() is awaited here too (not only when the modal opens) so
|
||||||
|
// the autotable plugin is guaranteed to be attached before we build the doc.
|
||||||
|
// Without this, opening the modal and submitting before the plugin script
|
||||||
|
// finished loading would reach generatePdfDocument() with doc.autoTable
|
||||||
|
// still undefined ("doc.autoTable is not a function").
|
||||||
Promise.all([
|
Promise.all([
|
||||||
|
loadPdfLibraries(),
|
||||||
fetch('/api/settings').then(res => res.json()),
|
fetch('/api/settings').then(res => res.json()),
|
||||||
fetch(url).then(res => res.json())
|
fetch(url).then(res => res.json())
|
||||||
])
|
])
|
||||||
.then(([settings, applications]) => {
|
.then(([_loaded, settings, applications]) => {
|
||||||
|
// /api/export returns an array of applications; on a server error it
|
||||||
|
// returns { error: '...' } instead. Guard so we surface a real message
|
||||||
|
// rather than crashing inside generatePdfDocument with "applications.map
|
||||||
|
// is not a function".
|
||||||
|
if (!Array.isArray(applications)) {
|
||||||
|
alert('Export fehlgeschlagen: ' + ((applications && applications.error) || 'Unbekannter Serverfehler'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
generatePdfDocument(settings, applications, month, year);
|
generatePdfDocument(settings, applications, month, year);
|
||||||
hideModal(pdfExportModal);
|
hideModal(pdfExportModal);
|
||||||
})
|
})
|
||||||
.catch(error => console.error('Error generating PDF:', error));
|
.catch(error => {
|
||||||
|
console.error('Error generating PDF:', error);
|
||||||
|
alert('Export fehlgeschlagen. Bitte erneut versuchen.');
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// PDF Generation with jsPDF
|
// PDF Generation with jsPDF
|
||||||
@@ -330,10 +260,46 @@ function generatePdfDocument(settings, applications, month, year) {
|
|||||||
'Juli', 'August', 'September', 'Oktober', 'November', 'Dezember'];
|
'Juli', 'August', 'September', 'Oktober', 'November', 'Dezember'];
|
||||||
|
|
||||||
const monthName = month ? monthNames[parseInt(month) - 1] : 'alle';
|
const monthName = month ? monthNames[parseInt(month) - 1] : 'alle';
|
||||||
|
|
||||||
|
// The status history is sorted ascending, so its last entry is the most recent
|
||||||
|
// status change. The export keys date + status off that entry (falling back to
|
||||||
|
// the application's own date/status when there is no history).
|
||||||
|
const lastVerlauf = (app) => {
|
||||||
|
const v = Array.isArray(app.verlauf) ? app.verlauf : [];
|
||||||
|
return v.length ? v[v.length - 1] : null;
|
||||||
|
};
|
||||||
|
const lastStatusOf = (app) => {
|
||||||
|
const lv = lastVerlauf(app);
|
||||||
|
const s = (lv && lv.status) || app.status;
|
||||||
|
return (s && s.trim()) ? s.trim() : 'Ohne Status';
|
||||||
|
};
|
||||||
|
const effDateOf = (app) => {
|
||||||
|
const lv = lastVerlauf(app);
|
||||||
|
return new Date(lv ? lv.datum : app.datum);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Imported/scraped values can contain HTML entities (e.g. "GmbH & Co. KG")
|
||||||
|
// and sanitizeInput() stores "< > \" '" escaped — decode them so the PDF shows
|
||||||
|
// real characters instead of the raw entity text.
|
||||||
|
const decodeEntities = (str) => {
|
||||||
|
if (str == null) return '';
|
||||||
|
return String(str)
|
||||||
|
.replace(/&#(\d+);/g, (_, n) => String.fromCharCode(parseInt(n, 10)))
|
||||||
|
.replace(/&#x([0-9a-fA-F]+);/g, (_, n) => String.fromCharCode(parseInt(n, 16)))
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, "'")
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/ /g, ' ')
|
||||||
|
.replace(/&/g, '&');
|
||||||
|
};
|
||||||
|
// Cleaned cell text with an em-dash fallback for missing/empty values.
|
||||||
|
const cell = (v) => decodeEntities(v).trim() || '—';
|
||||||
|
|
||||||
// Determine the year shown in the title. When no year was picked in the form,
|
// Determine the year shown in the title. When no year was picked in the form,
|
||||||
// derive it from the data so the title still reads e.g. "Juni 2026".
|
// derive it from the data so the title still reads e.g. "Juni 2026".
|
||||||
const yearsInData = [...new Set(
|
const yearsInData = [...new Set(
|
||||||
applications.map((a) => new Date(a.datum).getFullYear()).filter((y) => !isNaN(y))
|
applications.map((a) => effDateOf(a).getFullYear()).filter((y) => !isNaN(y))
|
||||||
)].sort((a, b) => a - b);
|
)].sort((a, b) => a - b);
|
||||||
const displayYear = year
|
const displayYear = year
|
||||||
|| (yearsInData.length === 1 ? String(yearsInData[0])
|
|| (yearsInData.length === 1 ? String(yearsInData[0])
|
||||||
@@ -388,16 +354,24 @@ function generatePdfDocument(settings, applications, month, year) {
|
|||||||
yPos += 6;
|
yPos += 6;
|
||||||
|
|
||||||
// ---- Group entries by status (order kept in sync with views/index.ejs) ----
|
// ---- Group entries by status (order kept in sync with views/index.ejs) ----
|
||||||
const statusOrder = ['Entwurf', 'Gesendet', 'Eingangsbestätigung', 'Vorstellungsgespräch',
|
const statusOrder = ['Entwurf', 'Gesendet', 'Eingangsbestätigung',
|
||||||
'Einstellung', 'Absage', 'Keine Rückmeldung'];
|
'In Bearbeitung', 'Interessiert', 'Telefonat', 'Warten auf Rückmeldung', 'Warten auf meine Antwort',
|
||||||
|
'Vorstellungsgespräch', 'Vertragsverhandlung', 'Einstellung', 'Absage', 'Absage von meiner Seite', 'Keine Rückmeldung'];
|
||||||
// Accent colour per status (RGB) – reused by the chart and the list headings
|
// Accent colour per status (RGB) – reused by the chart and the list headings
|
||||||
const statusColors = {
|
const statusColors = {
|
||||||
'Entwurf': [168, 85, 247],
|
'Entwurf': [168, 85, 247],
|
||||||
'Gesendet': [59, 130, 246],
|
'Gesendet': [59, 130, 246],
|
||||||
'Eingangsbestätigung': [14, 165, 233],
|
'Eingangsbestätigung': [14, 165, 233],
|
||||||
|
'In Bearbeitung': [20, 184, 166],
|
||||||
|
'Interessiert': [236, 72, 153],
|
||||||
|
'Telefonat': [244, 114, 182],
|
||||||
|
'Warten auf Rückmeldung': [249, 115, 22],
|
||||||
|
'Warten auf meine Antwort': [217, 70, 239],
|
||||||
'Vorstellungsgespräch': [245, 158, 11],
|
'Vorstellungsgespräch': [245, 158, 11],
|
||||||
|
'Vertragsverhandlung': [132, 204, 22],
|
||||||
'Einstellung': [34, 197, 94],
|
'Einstellung': [34, 197, 94],
|
||||||
'Absage': [239, 68, 68],
|
'Absage': [239, 68, 68],
|
||||||
|
'Absage von meiner Seite': [225, 29, 72],
|
||||||
'Keine Rückmeldung': [107, 114, 128],
|
'Keine Rückmeldung': [107, 114, 128],
|
||||||
'Ohne Status': [148, 163, 184]
|
'Ohne Status': [148, 163, 184]
|
||||||
};
|
};
|
||||||
@@ -406,7 +380,7 @@ function generatePdfDocument(settings, applications, month, year) {
|
|||||||
|
|
||||||
const groups = {};
|
const groups = {};
|
||||||
applications.forEach((app) => {
|
applications.forEach((app) => {
|
||||||
const key = (app.status && app.status.trim()) ? app.status.trim() : 'Ohne Status';
|
const key = lastStatusOf(app);
|
||||||
(groups[key] = groups[key] || []).push(app);
|
(groups[key] = groups[key] || []).push(app);
|
||||||
});
|
});
|
||||||
const orderedKeys = [
|
const orderedKeys = [
|
||||||
@@ -558,121 +532,70 @@ function generatePdfDocument(settings, applications, month, year) {
|
|||||||
doc.setTextColor(0, 0, 0);
|
doc.setTextColor(0, 0, 0);
|
||||||
yPos += 12;
|
yPos += 12;
|
||||||
|
|
||||||
// One block per application — no table, so the note (which documents the
|
// ---- Compact application table (one row per application) ----
|
||||||
// full Verlauf) gets the entire page width and is shown completely.
|
// Details such as Notizen and the full Status-Verlauf are intentionally left
|
||||||
orderedKeys.forEach((statusKey) => {
|
// out; every row shows only the last status and the date of that status change.
|
||||||
const group = groups[statusKey];
|
sectionHeading('Bewerbungen');
|
||||||
|
|
||||||
// Status group heading (dark bar), kept together with its first entry
|
const tableRows = applications
|
||||||
ensureSpace(40);
|
.map((app) => ({ app, date: effDateOf(app), status: lastStatusOf(app) }))
|
||||||
doc.setFillColor(55, 65, 81);
|
.sort((a, b) => a.date - b.date);
|
||||||
doc.rect(margin, yPos, contentWidth, 10, 'F');
|
|
||||||
doc.setFont('helvetica', 'bold');
|
if (tableRows.length === 0) {
|
||||||
doc.setFontSize(12);
|
doc.setFont('helvetica', 'italic');
|
||||||
doc.setTextColor(255, 255, 255);
|
doc.setFontSize(10.5);
|
||||||
doc.text(`${statusKey} (${group.length})`, margin + 3, yPos + 6.8);
|
doc.setTextColor(107, 114, 128);
|
||||||
yPos += 14;
|
doc.text('Keine Bewerbungen im gewählten Zeitraum.', margin, yPos + 2);
|
||||||
doc.setTextColor(0, 0, 0);
|
doc.setTextColor(0, 0, 0);
|
||||||
|
yPos += 10;
|
||||||
group.forEach((app) => {
|
} else {
|
||||||
const datum = new Date(app.datum).toLocaleDateString('de-DE');
|
doc.autoTable({
|
||||||
const firma = app.firma || '—';
|
startY: yPos,
|
||||||
const stelle = app.stelle || '—';
|
margin: { left: margin, right: margin },
|
||||||
const art = app.art || '—';
|
head: [['Datum', 'Firma', 'Stelle', 'Art', 'Status']],
|
||||||
const notizen = (app.notizen || '').trim();
|
body: tableRows.map(({ app, date, status }) => ([
|
||||||
|
isNaN(date) ? '—' : date.toLocaleDateString('de-DE'),
|
||||||
// Keep the heading together with the start of its content
|
cell(app.firma),
|
||||||
ensureSpace(28);
|
cell(app.stelle),
|
||||||
|
cell(app.art),
|
||||||
// Heading bar: Firma – Stelle (left), Datum (right)
|
status
|
||||||
doc.setFillColor(225, 232, 240);
|
])),
|
||||||
doc.rect(margin, yPos, contentWidth, 9, 'F');
|
// Never split a row across a page boundary — a split row leaves the
|
||||||
doc.setFont('helvetica', 'bold');
|
// single-line cells (Datum, Art, Status) blank on the continuation page.
|
||||||
doc.setFontSize(11);
|
rowPageBreak: 'avoid',
|
||||||
doc.setTextColor(20, 20, 20);
|
styles: {
|
||||||
const heading = doc.splitTextToSize(`${firma} – ${stelle}`, contentWidth - 40)[0];
|
font: 'helvetica', fontSize: 9, cellPadding: 2.4,
|
||||||
doc.text(heading, margin + 2, yPos + 6);
|
textColor: [31, 41, 55], lineColor: [226, 232, 240],
|
||||||
doc.text(datum, pageWidth - margin - 2, yPos + 6, { align: 'right' });
|
lineWidth: 0.2, valign: 'middle', overflow: 'linebreak'
|
||||||
yPos += 9;
|
},
|
||||||
|
headStyles: {
|
||||||
// Meta line: Art
|
fillColor: [55, 65, 81], textColor: [255, 255, 255],
|
||||||
doc.setFont('helvetica', 'normal');
|
fontStyle: 'bold', fontSize: 9.5, cellPadding: 2.6
|
||||||
doc.setFontSize(9);
|
},
|
||||||
doc.setTextColor(90, 90, 90);
|
alternateRowStyles: { fillColor: [248, 250, 252] },
|
||||||
doc.text(`Art: ${art}`, margin + 2, yPos + 5);
|
columnStyles: {
|
||||||
yPos += 9;
|
0: { cellWidth: 23 },
|
||||||
|
1: { cellWidth: 41, fontStyle: 'bold' },
|
||||||
// Status-Verlauf timeline (chronological status changes with comments)
|
2: { cellWidth: 'auto' },
|
||||||
const verlauf = Array.isArray(app.verlauf) ? app.verlauf : [];
|
3: { cellWidth: 25 },
|
||||||
if (verlauf.length) {
|
4: { cellWidth: 39, halign: 'center', fontStyle: 'bold' }
|
||||||
doc.setFont('helvetica', 'bold');
|
},
|
||||||
doc.setFontSize(10);
|
// Tint the Status cell in its status colour so the column reads like a badge
|
||||||
doc.setTextColor(20, 20, 20);
|
didParseCell: (data) => {
|
||||||
ensureSpace(6);
|
if (data.section === 'body' && data.column.index === 4) {
|
||||||
doc.text('Status-Verlauf:', margin + 2, yPos + 4);
|
const c = colorFor(String(data.cell.raw));
|
||||||
yPos += 6;
|
data.cell.styles.textColor = c;
|
||||||
|
data.cell.styles.fillColor = [
|
||||||
doc.setFontSize(9);
|
Math.round(c[0] * 0.13 + 255 * 0.87),
|
||||||
verlauf.forEach((v) => {
|
Math.round(c[1] * 0.13 + 255 * 0.87),
|
||||||
const vDatum = new Date(v.datum).toLocaleDateString('de-DE');
|
Math.round(c[2] * 0.13 + 255 * 0.87)
|
||||||
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;
|
yPos = doc.lastAutoTable.finalY + 10;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Notizen — only rendered when the entry actually has notes
|
|
||||||
if (notizen) {
|
|
||||||
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;
|
|
||||||
notizen.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
|
// Footer with confirmation
|
||||||
ensureSpace(20);
|
ensureSpace(20);
|
||||||
yPos += 6;
|
yPos += 6;
|
||||||
@@ -699,7 +622,12 @@ function loadPdfLibraries() {
|
|||||||
// Check if already loading
|
// Check if already loading
|
||||||
if (document.getElementById('jspdf-script')) {
|
if (document.getElementById('jspdf-script')) {
|
||||||
const checkLoaded = setInterval(() => {
|
const checkLoaded = setInterval(() => {
|
||||||
if (window.jspdf && window.jspdf.jsPDF) {
|
// Wait for the autotable plugin too, not just jsPDF: jsPDF loads
|
||||||
|
// first (script1), the plugin second (script2). Resolving the
|
||||||
|
// moment jsPDF exists leaves doc.autoTable undefined if script2
|
||||||
|
// has not run yet.
|
||||||
|
if (window.jspdf && window.jspdf.jsPDF
|
||||||
|
&& typeof window.jspdf.jsPDF.API.autoTable === 'function') {
|
||||||
clearInterval(checkLoaded);
|
clearInterval(checkLoaded);
|
||||||
pdfLibrariesLoaded = true;
|
pdfLibrariesLoaded = true;
|
||||||
resolve();
|
resolve();
|
||||||
@@ -708,13 +636,14 @@ function loadPdfLibraries() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load jsPDF from CDN
|
// Load jsPDF from the app's own /vendor (self-hosted) so no external
|
||||||
|
// script source has to be trusted in the Content-Security-Policy.
|
||||||
const script1 = document.createElement('script');
|
const script1 = document.createElement('script');
|
||||||
script1.id = 'jspdf-script';
|
script1.id = 'jspdf-script';
|
||||||
script1.src = 'https://cdnjs.cloudflare.com/ajax/libs/jspdf/2.5.1/jspdf.umd.min.js';
|
script1.src = '/vendor/jspdf.umd.min.js';
|
||||||
script1.onload = () => {
|
script1.onload = () => {
|
||||||
const script2 = document.createElement('script');
|
const script2 = document.createElement('script');
|
||||||
script2.src = 'https://cdnjs.cloudflare.com/ajax/libs/jspdf-autotable/3.8.2/jspdf.plugin.autotable.min.js';
|
script2.src = '/vendor/jspdf.plugin.autotable.min.js';
|
||||||
script2.onload = () => {
|
script2.onload = () => {
|
||||||
pdfLibrariesLoaded = true;
|
pdfLibrariesLoaded = true;
|
||||||
resolve();
|
resolve();
|
||||||
@@ -729,18 +658,6 @@ function loadPdfLibraries() {
|
|||||||
// Event Listeners
|
// 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
|
// Application Modal
|
||||||
addApplicationBtn.addEventListener('click', openAddApplicationModal);
|
addApplicationBtn.addEventListener('click', openAddApplicationModal);
|
||||||
closeApplicationModal.addEventListener('click', () => {
|
closeApplicationModal.addEventListener('click', () => {
|
||||||
@@ -764,20 +681,6 @@ closePdfModal.addEventListener('click', () => hideModal(pdfExportModal));
|
|||||||
cancelPdfExport.addEventListener('click', () => hideModal(pdfExportModal));
|
cancelPdfExport.addEventListener('click', () => hideModal(pdfExportModal));
|
||||||
pdfExportForm.addEventListener('submit', generatePDF);
|
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).
|
// Delete buttons (event delegation). Editing opens its own page (/bewerbung/:id).
|
||||||
document.addEventListener('click', (e) => {
|
document.addEventListener('click', (e) => {
|
||||||
if (e.target.closest('.delete-btn')) {
|
if (e.target.closest('.delete-btn')) {
|
||||||
@@ -788,7 +691,6 @@ document.addEventListener('click', (e) => {
|
|||||||
|
|
||||||
// Close modals when clicking outside
|
// Close modals when clicking outside
|
||||||
document.addEventListener('click', (e) => {
|
document.addEventListener('click', (e) => {
|
||||||
if (e.target === settingsModal) hideModal(settingsModal);
|
|
||||||
if (e.target === applicationModal) {
|
if (e.target === applicationModal) {
|
||||||
hideModal(applicationModal);
|
hideModal(applicationModal);
|
||||||
resetApplicationForm();
|
resetApplicationForm();
|
||||||
@@ -800,7 +702,6 @@ document.addEventListener('click', (e) => {
|
|||||||
// Close modals with Escape key
|
// Close modals with Escape key
|
||||||
document.addEventListener('keydown', (e) => {
|
document.addEventListener('keydown', (e) => {
|
||||||
if (e.key === 'Escape') {
|
if (e.key === 'Escape') {
|
||||||
if (!settingsModal.classList.contains('hidden')) hideModal(settingsModal);
|
|
||||||
if (!applicationModal.classList.contains('hidden')) {
|
if (!applicationModal.classList.contains('hidden')) {
|
||||||
hideModal(applicationModal);
|
hideModal(applicationModal);
|
||||||
resetApplicationForm();
|
resetApplicationForm();
|
||||||
@@ -817,7 +718,4 @@ document.getElementById('currentYear').textContent = new Date().getFullYear();
|
|||||||
// Initialize
|
// Initialize
|
||||||
// ============================================
|
// ============================================
|
||||||
|
|
||||||
// Initialize dark mode
|
console.log('NextJobs initialized');
|
||||||
initializeDarkMode();
|
|
||||||
|
|
||||||
console.log('Bewerbungs-Tracker initialized');
|
|
||||||
|
|||||||
+10
File diff suppressed because one or more lines are too long
Vendored
+398
File diff suppressed because one or more lines are too long
Executable
+381
@@ -0,0 +1,381 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
// Host-side runner for the per-user job search.
|
||||||
|
//
|
||||||
|
// Why it lives on the host and not in the app: the app container has neither
|
||||||
|
// `claude` nor `ollama` nor a docker socket. The app therefore only *queues*
|
||||||
|
// runs (table suchlaeufe); this script — started by cron every few minutes —
|
||||||
|
// picks them up and executes them. It replaces the two old single-user scripts
|
||||||
|
// (jobsuche-cron.sh and jobsuche-remote-cron.sh), whose search criteria were
|
||||||
|
// hard-coded into the prompt.
|
||||||
|
//
|
||||||
|
// Isolation: every run executes in its OWN throwaway Docker container
|
||||||
|
// (AGENT_IMAGE), with the requesting user's dedicated home directory mounted
|
||||||
|
// as /home/agent. That home IS that user's ~/.claude (memories, session
|
||||||
|
// contexts, history, settings) — never shared with another user. The three
|
||||||
|
// search skills are shared code baked into the image and symlinked into
|
||||||
|
// ~/.claude/skills by the image entrypoint.
|
||||||
|
//
|
||||||
|
// Per run it:
|
||||||
|
// 1. enqueues scheduled runs that are due (per user's Zeitplan),
|
||||||
|
// 2. claims queued runs (status angefordert -> laeuft) and runs them in
|
||||||
|
// parallel up to MAX_PARALLEL — different users have different Ollama
|
||||||
|
// keys and thus separate rate limits, so parallelism is safe across users,
|
||||||
|
// 3. builds the prompt from that user's Suchprofil + their Lebenslauf,
|
||||||
|
// 4. starts a container that runs the headless agent with THAT user's
|
||||||
|
// credentials:
|
||||||
|
// - BEWERBUNG_API_KEY = the user's API token (created if missing), so
|
||||||
|
// every imported Jobangebot lands in their account and passes the
|
||||||
|
// same dedup/blacklist path as the rest of the app,
|
||||||
|
// - ANTHROPIC_* = the user's own Ollama endpoint+key, so the search is
|
||||||
|
// billed to whoever requested it,
|
||||||
|
// 5. parses the agent's final "ERGEBNIS neu=… dubletten=… verworfen=…" line
|
||||||
|
// and writes status + counts back, so the UI can show what happened.
|
||||||
|
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const crypto = require('crypto');
|
||||||
|
const { spawn, spawnSync } = require('child_process');
|
||||||
|
const sqlite3 = require('sqlite3');
|
||||||
|
|
||||||
|
const suchprofil = require('../lib/suchprofil');
|
||||||
|
|
||||||
|
const DB_PATH = process.env.JOBSUCHE_DB || '/opt/jobbi-bewerbung/data/bewerbungen.db';
|
||||||
|
const LOG_DIR = process.env.JOBSUCHE_LOG_DIR || '/opt/jobbi-bewerbung/logs';
|
||||||
|
const API_URL = process.env.JOBSUCHE_API_URL || 'http://localhost:4327/api/v1';
|
||||||
|
const RUN_TIMEOUT_MS = (Number(process.env.JOBSUCHE_TIMEOUT) || 2700) * 1000;
|
||||||
|
// A run that has been 'laeuft' for longer than this lost its process (reboot,
|
||||||
|
// OOM); it would otherwise block the user's queue forever.
|
||||||
|
const STALE_MS = RUN_TIMEOUT_MS + 10 * 60 * 1000;
|
||||||
|
const LOG_RETENTION_DAYS = Number(process.env.JOBSUCHE_LOG_RETENTION_DAYS) || 30;
|
||||||
|
|
||||||
|
// Agent-Container. Das Image wird lokal gebaut (npm run docker:build-agent),
|
||||||
|
// nicht über die Registry verteilt — es läuft nur auf diesem Host.
|
||||||
|
const AGENT_IMAGE = process.env.JOBSUCHE_AGENT_IMAGE || 'jobbi-bewerbung-agent:latest';
|
||||||
|
// uid/gid, als die der Container läuft und der das pro-Benutzer-Home gehört.
|
||||||
|
const AGENT_UID = Number(process.env.JOBSUCHE_AGENT_UID) || 1000;
|
||||||
|
const AGENT_GID = Number(process.env.JOBSUCHE_AGENT_GID) || AGENT_UID;
|
||||||
|
// Pro-Benutzer-Verzeichnisse (je HOME = je ~/.claude).
|
||||||
|
const AGENTS_DIR = process.env.JOBSUCHE_AGENTS_DIR || '/opt/jobbi-bewerbung/agents';
|
||||||
|
// Gleichzeitig laufende Such-Container. Verschiedene Benutzer haben getrennte
|
||||||
|
// Ollama-Keys -> getrennte Rate-Limits, deshalb ist pro-Benutzer-Parallelität
|
||||||
|
// sicher. Der Wert begrenzt lediglich die Host-Last.
|
||||||
|
const MAX_PARALLEL = Number(process.env.JOBSUCHE_MAX_PARALLEL) || 4;
|
||||||
|
|
||||||
|
const CFG = 'cfg:';
|
||||||
|
|
||||||
|
const db = new sqlite3.Database(DB_PATH);
|
||||||
|
const all = (sql, p = []) => new Promise((res, rej) => db.all(sql, p, (e, r) => (e ? rej(e) : res(r))));
|
||||||
|
const get = (sql, p = []) => new Promise((res, rej) => db.get(sql, p, (e, r) => (e ? rej(e) : res(r))));
|
||||||
|
const run = (sql, p = []) => new Promise((res, rej) => db.run(sql, p, function (e) { e ? rej(e) : res(this); }));
|
||||||
|
|
||||||
|
function log(msg) {
|
||||||
|
console.log(`${new Date().toISOString()} ${msg}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function cfgWert(userId, key) {
|
||||||
|
const row = await get('SELECT value FROM app_state WHERE user_id = ? AND key = ?', [userId, CFG + key]);
|
||||||
|
return row && row.value ? row.value : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// The user's API token, created on first use. Without it the agent could not
|
||||||
|
// import anything; making the user generate one by hand first would just be a
|
||||||
|
// trap ("profile active, nothing happens").
|
||||||
|
async function ensureApiKey(userId) {
|
||||||
|
const vorhanden = await cfgWert(userId, 'API_TOKEN');
|
||||||
|
if (vorhanden) return vorhanden;
|
||||||
|
const token = crypto.randomBytes(32).toString('hex');
|
||||||
|
await run(
|
||||||
|
'INSERT INTO app_state (user_id, key, value) VALUES (?, ?, ?) ' +
|
||||||
|
'ON CONFLICT(user_id, key) DO UPDATE SET value = excluded.value',
|
||||||
|
[userId, CFG + 'API_TOKEN', token]
|
||||||
|
);
|
||||||
|
log(` API-Key für Benutzer ${userId} automatisch erzeugt.`);
|
||||||
|
return token;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The CV text the search profile is derived from. Prefer the Lebenslauf, fall
|
||||||
|
// back to a Profil/Kurzprofil — the point is to give the model the user's actual
|
||||||
|
// roles and technologies rather than a hard-coded list.
|
||||||
|
async function profilText(userId) {
|
||||||
|
const rows = await all(
|
||||||
|
`SELECT typ, inhalt FROM basis_dokumente
|
||||||
|
WHERE user_id = ? AND typ IN ('Lebenslauf', 'Profil/Kurzprofil')
|
||||||
|
AND inhalt IS NOT NULL AND inhalt != ''
|
||||||
|
ORDER BY CASE typ WHEN 'Lebenslauf' THEN 0 ELSE 1 END, id ASC`,
|
||||||
|
[userId]
|
||||||
|
);
|
||||||
|
return rows.map((r) => (r.inhalt || '').trim()).filter(Boolean).join('\n\n---\n\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Legt das pro-Benutzer-Home an (falls noch nicht vorhanden) und übergibt es
|
||||||
|
// an den Container-Agent. Bleibt über Läufe hinweg bestehen, damit Memories
|
||||||
|
// und Contexte erhalten bleiben.
|
||||||
|
function ensureAgentDir(userId) {
|
||||||
|
const dir = path.join(AGENTS_DIR, String(userId));
|
||||||
|
fs.mkdirSync(dir, { recursive: true });
|
||||||
|
spawnSync('chown', ['-R', `${AGENT_UID}:${AGENT_GID}`, dir]);
|
||||||
|
return dir;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Queue scheduled runs that are due.
|
||||||
|
async function faelligeEinreihen() {
|
||||||
|
const jetzt = new Date();
|
||||||
|
const users = await all('SELECT user_id FROM suchprofil WHERE aktiv = 1');
|
||||||
|
for (const { user_id: userId } of users) {
|
||||||
|
const profil = suchprofil.fromRow(await get('SELECT * FROM suchprofil WHERE user_id = ?', [userId]));
|
||||||
|
if (suchprofil.validate(profil).length) continue; // unbrauchbares Profil -> kein Zeitplan-Lauf
|
||||||
|
|
||||||
|
// Only scheduled runs count here: a manual test run must not swallow the
|
||||||
|
// day's automatic run.
|
||||||
|
const letzter = await get(
|
||||||
|
"SELECT angefordert_at FROM suchlaeufe WHERE user_id = ? AND ausloeser = 'zeitplan' ORDER BY id DESC LIMIT 1",
|
||||||
|
[userId]
|
||||||
|
);
|
||||||
|
const letzterAt = letzter ? `${letzter.angefordert_at}Z`.replace(' ', 'T') : null;
|
||||||
|
if (!suchprofil.istFaellig(profil, jetzt, letzterAt)) continue;
|
||||||
|
|
||||||
|
const offen = await get(
|
||||||
|
"SELECT id FROM suchlaeufe WHERE user_id = ? AND status IN ('angefordert','laeuft') LIMIT 1",
|
||||||
|
[userId]
|
||||||
|
);
|
||||||
|
if (offen) continue; // läuft schon etwas für diesen Benutzer
|
||||||
|
|
||||||
|
await run("INSERT INTO suchlaeufe (user_id, status, ausloeser) VALUES (?, 'angefordert', 'zeitplan')", [userId]);
|
||||||
|
log(`Zeitplan: Lauf für Benutzer ${userId} eingereiht.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Runs whose process died: release them so the user is not stuck.
|
||||||
|
async function verwaisteAufraeumen() {
|
||||||
|
const res = await run(
|
||||||
|
`UPDATE suchlaeufe SET status = 'fehler', beendet_at = CURRENT_TIMESTAMP,
|
||||||
|
fehler = 'Lauf abgebrochen (Container nicht mehr vorhanden).'
|
||||||
|
WHERE status = 'laeuft'
|
||||||
|
AND gestartet_at IS NOT NULL
|
||||||
|
AND (julianday('now') - julianday(gestartet_at)) * 86400000 > ?`,
|
||||||
|
[STALE_MS]
|
||||||
|
);
|
||||||
|
if (res.changes) log(`${res.changes} verwaiste(n) Lauf/Läufe als Fehler markiert.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Claim the oldest queued run. The conditional UPDATE is the claim: a second
|
||||||
|
// runner starting concurrently gets changes = 0 and moves on.
|
||||||
|
async function naechstenLaufClaimen() {
|
||||||
|
const kandidat = await get("SELECT * FROM suchlaeufe WHERE status = 'angefordert' ORDER BY id ASC LIMIT 1");
|
||||||
|
if (!kandidat) return null;
|
||||||
|
const res = await run(
|
||||||
|
"UPDATE suchlaeufe SET status = 'laeuft', gestartet_at = CURRENT_TIMESTAMP WHERE id = ? AND status = 'angefordert'",
|
||||||
|
[kandidat.id]
|
||||||
|
);
|
||||||
|
if (!res.changes) return null;
|
||||||
|
return get('SELECT * FROM suchlaeufe WHERE id = ?', [kandidat.id]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function laufAbschliessen(id, status, felder = {}) {
|
||||||
|
await run(
|
||||||
|
`UPDATE suchlaeufe SET status = ?, beendet_at = CURRENT_TIMESTAMP,
|
||||||
|
neu = ?, dubletten = ?, verworfen = ?, fehler = ?, log_datei = ?
|
||||||
|
WHERE id = ?`,
|
||||||
|
[status, felder.neu ?? null, felder.dubletten ?? null, felder.verworfen ?? null,
|
||||||
|
felder.fehler || null, felder.log || null, id]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start the headless agent inside a throwaway container for this user. Only the
|
||||||
|
// curated env below is passed in (not process.env) so no host secrets leak.
|
||||||
|
function agentStarten(prompt, envObj, logStream, containerName, agentDir) {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const args = [
|
||||||
|
'run', '--rm',
|
||||||
|
'--name', containerName,
|
||||||
|
// --network host: der Agent erreicht den Tracker (localhost:4327), das
|
||||||
|
// Internet (WebSearch/WebFetch/Arbeitsagentur) und den Ollama-Endpoint
|
||||||
|
// des Benutzers — alles über die Host-Netzwerk-Sicht.
|
||||||
|
'--network', 'host',
|
||||||
|
'--user', `${AGENT_UID}:${AGENT_GID}`,
|
||||||
|
'-e', 'HOME=/home/agent',
|
||||||
|
'-e', 'IS_SANDBOX=1',
|
||||||
|
];
|
||||||
|
for (const [k, v] of Object.entries(envObj)) args.push('-e', `${k}=${v}`);
|
||||||
|
args.push(
|
||||||
|
'-v', `${agentDir}:/home/agent`,
|
||||||
|
'-w', '/work',
|
||||||
|
AGENT_IMAGE,
|
||||||
|
'claude', '--dangerously-skip-permissions', '-p', prompt,
|
||||||
|
);
|
||||||
|
|
||||||
|
const kind = spawn('docker', args, { stdio: ['ignore', 'pipe', 'pipe'] });
|
||||||
|
|
||||||
|
let ausgabe = '';
|
||||||
|
const sammeln = (buf) => {
|
||||||
|
const s = buf.toString();
|
||||||
|
ausgabe += s;
|
||||||
|
logStream.write(s);
|
||||||
|
};
|
||||||
|
kind.stdout.on('data', sammeln);
|
||||||
|
kind.stderr.on('data', sammeln);
|
||||||
|
|
||||||
|
// Beim Timeout den `docker run`-Client killen UND den Container per Name
|
||||||
|
// stoppen — ein kill des Clients allein ließe den Container sonst weiterlaufen.
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
spawnSync('docker', ['kill', containerName], { stdio: 'ignore' });
|
||||||
|
kind.kill('SIGTERM');
|
||||||
|
setTimeout(() => kind.kill('SIGKILL'), 10000);
|
||||||
|
}, RUN_TIMEOUT_MS);
|
||||||
|
|
||||||
|
kind.on('error', (err) => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
resolve({ code: -1, ausgabe, fehler: `Agent-Container nicht startbar: ${err.message}` });
|
||||||
|
});
|
||||||
|
kind.on('close', (code, signal) => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
const abgebrochen = signal === 'SIGTERM' || signal === 'SIGKILL';
|
||||||
|
resolve({
|
||||||
|
code,
|
||||||
|
ausgabe,
|
||||||
|
fehler: abgebrochen ? `Zeitlimit von ${RUN_TIMEOUT_MS / 1000}s überschritten.` : null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function laufAusfuehren(lauf) {
|
||||||
|
const userId = lauf.user_id;
|
||||||
|
const user = await get('SELECT username FROM users WHERE id = ?', [userId]);
|
||||||
|
const name = user ? user.username : `#${userId}`;
|
||||||
|
const containerName = `jobbi-agent-${userId}-${lauf.id}`;
|
||||||
|
log(`Lauf ${lauf.id} (Benutzer ${name}, ${lauf.ausloeser}) startet im Container ${containerName}.`);
|
||||||
|
|
||||||
|
const profil = suchprofil.fromRow(await get('SELECT * FROM suchprofil WHERE user_id = ?', [userId]));
|
||||||
|
const fehler = suchprofil.validate(profil);
|
||||||
|
if (fehler.length) {
|
||||||
|
await laufAbschliessen(lauf.id, 'fehler', { fehler: fehler.join(' ') });
|
||||||
|
log(` abgebrochen: ${fehler.join(' ')}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ollamaKey = await cfgWert(userId, 'OLLAMA_API_KEY');
|
||||||
|
if (!ollamaKey) {
|
||||||
|
const msg = 'Kein eigener Ollama-API-Schlüssel hinterlegt — der Suchlauf läuft über das KI-Kontingent des Benutzers. Bitte in den Einstellungen eintragen.';
|
||||||
|
await laufAbschliessen(lauf.id, 'fehler', { fehler: msg });
|
||||||
|
log(` abgebrochen: kein Ollama-Key für ${name}.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const apiKey = await ensureApiKey(userId);
|
||||||
|
const modell = (await cfgWert(userId, 'OLLAMA_MODEL')) || 'glm-5.2:cloud';
|
||||||
|
const prompt = suchprofil.buildPrompt(profil, await profilText(userId));
|
||||||
|
|
||||||
|
fs.mkdirSync(LOG_DIR, { recursive: true });
|
||||||
|
const stamp = new Date().toISOString().replace(/[:.]/g, '-');
|
||||||
|
const logDatei = path.join(LOG_DIR, `jobsuche_${name}_${stamp}.log`);
|
||||||
|
const logStream = fs.createWriteStream(logDatei, { flags: 'a' });
|
||||||
|
logStream.write(`=== Suchlauf ${lauf.id} — Benutzer ${name} (${lauf.ausloeser}) ===\n`);
|
||||||
|
logStream.write(`Modus: ${profil.modus} | Städte: ${profil.staedte.join(', ') || '–'}\n`);
|
||||||
|
logStream.write(`Modell: ${modell} | Container: ${containerName}\n${'-'.repeat(50)}\n`);
|
||||||
|
|
||||||
|
// Nur kuratierte Env-Vars in den Container — keine Host-Secrets.
|
||||||
|
const envObj = {
|
||||||
|
ANTHROPIC_BASE_URL: (await cfgWert(userId, 'OLLAMA_HOST')) || 'https://ollama.com',
|
||||||
|
ANTHROPIC_AUTH_TOKEN: ollamaKey,
|
||||||
|
ANTHROPIC_MODEL: modell,
|
||||||
|
ANTHROPIC_SMALL_FAST_MODEL: modell,
|
||||||
|
BEWERBUNG_API_URL: API_URL,
|
||||||
|
BEWERBUNG_API_KEY: apiKey,
|
||||||
|
JOBSUCHE_MODEL: modell,
|
||||||
|
};
|
||||||
|
|
||||||
|
const agentDir = ensureAgentDir(userId);
|
||||||
|
const { code, ausgabe, fehler: laufFehler } = await agentStarten(prompt, envObj, logStream, containerName, agentDir);
|
||||||
|
const ergebnis = suchprofil.parseErgebnis(ausgabe);
|
||||||
|
|
||||||
|
logStream.write(`\n${'-'.repeat(50)}\nExit-Code: ${code}\n`);
|
||||||
|
logStream.end();
|
||||||
|
|
||||||
|
if (laufFehler) {
|
||||||
|
await laufAbschliessen(lauf.id, 'fehler', { fehler: laufFehler, log: logDatei });
|
||||||
|
log(` Lauf ${lauf.id} fehlgeschlagen: ${laufFehler}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (code !== 0 && !ergebnis) {
|
||||||
|
await laufAbschliessen(lauf.id, 'fehler', {
|
||||||
|
fehler: `Suchlauf mit Exit-Code ${code} beendet (Details im Log).`,
|
||||||
|
log: logDatei,
|
||||||
|
});
|
||||||
|
log(` Lauf ${lauf.id} fehlgeschlagen (Exit ${code}).`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!ergebnis) {
|
||||||
|
// The agent finished but did not report its tally — treat as done with unknown
|
||||||
|
// numbers rather than as a failure; the import itself already happened.
|
||||||
|
await laufAbschliessen(lauf.id, 'fertig', { neu: 0, dubletten: 0, verworfen: 0, log: logDatei });
|
||||||
|
log(` Lauf ${lauf.id} beendet, aber ohne ERGEBNIS-Zeile.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await laufAbschliessen(lauf.id, 'fertig', { ...ergebnis, log: logDatei });
|
||||||
|
log(` Lauf ${lauf.id} fertig: ${ergebnis.neu} neu, ${ergebnis.dubletten} Dubletten, ${ergebnis.verworfen} verworfen.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Führt eine Liste von Läufen mit höchstens `parallel` gleichzeitig aus. Jeder
|
||||||
|
// Lauf läuft in seinem eigenen Container; verschiedene Benutzer haben getrennte
|
||||||
|
// Ollama-Keys, deshalb ist pro-Benutzer-Parallelität sicher.
|
||||||
|
async function runPool(items, parallel, fn) {
|
||||||
|
const queue = items.slice();
|
||||||
|
const workers = Array.from({ length: Math.min(parallel, queue.length || 1) }, async () => {
|
||||||
|
while (queue.length) {
|
||||||
|
const item = queue.shift();
|
||||||
|
try {
|
||||||
|
await fn(item);
|
||||||
|
} catch (e) {
|
||||||
|
log(` Lauf ${item.id} unerwartet abgebrochen: ${e && e.message ? e.message : e}`);
|
||||||
|
try {
|
||||||
|
await laufAbschliessen(item.id, 'fehler', { fehler: `Runner-Ausnahme: ${e && e.message ? e.message : e}` });
|
||||||
|
} catch (_) { /* Status ist dann zwar offen, aber ein Folge-Tick räumt auf. */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
await Promise.all(workers);
|
||||||
|
}
|
||||||
|
|
||||||
|
function alteLogsAufraeumen() {
|
||||||
|
try {
|
||||||
|
const grenze = Date.now() - LOG_RETENTION_DAYS * 86400000;
|
||||||
|
for (const f of fs.readdirSync(LOG_DIR)) {
|
||||||
|
if (!f.startsWith('jobsuche_') || !f.endsWith('.log')) continue;
|
||||||
|
const p = path.join(LOG_DIR, f);
|
||||||
|
if (fs.statSync(p).mtimeMs < grenze) fs.unlinkSync(p);
|
||||||
|
}
|
||||||
|
} catch (e) { /* Aufräumen ist best effort */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
if (!fs.existsSync(DB_PATH)) {
|
||||||
|
console.error(`Datenbank nicht gefunden: ${DB_PATH}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
await run('PRAGMA busy_timeout = 10000'); // die App schreibt parallel
|
||||||
|
|
||||||
|
await verwaisteAufraeumen();
|
||||||
|
await faelligeEinreihen();
|
||||||
|
|
||||||
|
// Alle wartenden Läufe einsammeln (pro Benutzer steht ohnehin nur einer an)
|
||||||
|
// und mit begrenzter Parallelität abarbeiten.
|
||||||
|
const laeufe = [];
|
||||||
|
let lauf;
|
||||||
|
while ((lauf = await naechstenLaufClaimen())) laeufe.push(lauf);
|
||||||
|
if (!laeufe.length) {
|
||||||
|
log('Nichts zu tun.');
|
||||||
|
} else {
|
||||||
|
log(`${laeufe.length} Lauf/Läufe eingereiht, starte bis zu ${MAX_PARALLEL} parallel.`);
|
||||||
|
await runPool(laeufe, MAX_PARALLEL, laufAusfuehren);
|
||||||
|
}
|
||||||
|
|
||||||
|
alteLogsAufraeumen();
|
||||||
|
db.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((e) => {
|
||||||
|
console.error('Runner-Fehler:', e);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
Executable
+71
@@ -0,0 +1,71 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Cron-Einstiegspunkt für die Jobsuche (Multi-User).
|
||||||
|
#
|
||||||
|
# Ersetzt jobsuche-cron.sh + jobsuche-remote-cron.sh: Diese beiden hatten das
|
||||||
|
# Suchprofil (sieben Städte, Rollen, Buzzwords) fest im Prompt stehen und liefen
|
||||||
|
# mit EINEM globalen API-Token aus der .env — beides ist mit der Multi-User-
|
||||||
|
# Plattform hinfällig.
|
||||||
|
#
|
||||||
|
# Jetzt: Jeder Benutzer pflegt sein Suchprofil und seinen Zeitplan in der App
|
||||||
|
# (/jobsuche). Dieses Skript ruft nur noch den Runner auf, der fällige und manuell
|
||||||
|
# angeforderte Läufe abarbeitet — je Benutzer mit dessen eigenem Ollama- und
|
||||||
|
# API-Schlüssel. Jeder Suchlauf läuft isoliert im eigenen Docker-Container pro
|
||||||
|
# Benutzer; der Runner selbst bleibt auf dem Host (Docker-CLI + DB-Zugriff).
|
||||||
|
#
|
||||||
|
# Cron (alle 5 Minuten — die Uhrzeit steuert der Benutzer, nicht der Cron):
|
||||||
|
# */5 * * * * /opt/jobbi-bewerbung/bin/jobsuche-runner.sh
|
||||||
|
#
|
||||||
|
# Manueller Lauf: /opt/jobbi-bewerbung/bin/jobsuche-runner.sh
|
||||||
|
|
||||||
|
set -uo pipefail
|
||||||
|
|
||||||
|
export HOME="${HOME:-/root}"
|
||||||
|
export PATH="/root/.local/bin:/usr/local/bin:/usr/bin:/bin:$PATH"
|
||||||
|
# Node läuft unter nvm — der Cron-Pfad kennt das Verzeichnis nicht, also nvm
|
||||||
|
# laden, damit `node` (und der Default-Node des Benutzers) gefunden wird. Ohne
|
||||||
|
# das schlug jeder Cron-Tick mit `node: command not found` fehl und die
|
||||||
|
# wartenden Suchläufe blieben ewig im Status 'angefordert' hängen.
|
||||||
|
export NVM_DIR="${NVM_DIR:-$HOME/.nvm}"
|
||||||
|
[ -s "$NVM_DIR/nvm.sh" ] && . "$NVM_DIR/nvm.sh"
|
||||||
|
|
||||||
|
PROJECT_DIR="/opt/jobbi-bewerbung"
|
||||||
|
LOG_DIR="$PROJECT_DIR/logs"
|
||||||
|
RUNNER="$PROJECT_DIR/source/scripts/jobsuche-runner.js"
|
||||||
|
# Agent-Image (pro Suchlauf ein frischer Container). Wird lokal gebaut:
|
||||||
|
# npm --prefix "$PROJECT_DIR/source" run docker:build-agent
|
||||||
|
AGENT_IMAGE="${JOBSUCHE_AGENT_IMAGE:-jobbi-bewerbung-agent:latest}"
|
||||||
|
|
||||||
|
mkdir -p "$LOG_DIR"
|
||||||
|
LOG="$LOG_DIR/jobsuche-runner.log"
|
||||||
|
|
||||||
|
# Einzelinstanz: ein noch laufender Suchlauf darf vom nächsten Cron-Tick nicht
|
||||||
|
# ein zweites Mal gestartet werden.
|
||||||
|
exec 9>"$LOG_DIR/jobsuche-runner.lock"
|
||||||
|
if ! flock -n 9; then
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
cd "$PROJECT_DIR" || exit 1
|
||||||
|
|
||||||
|
# API-Erreichbarkeit prüfen — ohne den Tracker kann der Agent nichts importieren.
|
||||||
|
if ! curl -fsS --max-time 10 http://localhost:4327/api/v1/health >/dev/null 2>&1; then
|
||||||
|
echo "$(date -Is) FEHLER: NextJobs (localhost:4327) nicht erreichbar – Lauf übersprungen." >> "$LOG"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Agent-Image muss vorhanden sein, sonst stapeln sich fehlgeschlagene Läufe.
|
||||||
|
if ! docker image inspect "$AGENT_IMAGE" >/dev/null 2>&1; then
|
||||||
|
echo "$(date -Is) FEHLER: Agent-Image '$AGENT_IMAGE' fehlt – bitte bauen: npm --prefix $PROJECT_DIR/source run docker:build-agent" >> "$LOG"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
node "$RUNNER" >> "$LOG" 2>&1
|
||||||
|
STATUS=$?
|
||||||
|
|
||||||
|
# Log der Runner-Steuerung kurz halten (die eigentlichen Suchlauf-Logs liegen
|
||||||
|
# je Lauf separat und werden vom Runner selbst rotiert).
|
||||||
|
if [ -f "$LOG" ] && [ "$(wc -l < "$LOG")" -gt 5000 ]; then
|
||||||
|
tail -n 2000 "$LOG" > "$LOG.tmp" && mv "$LOG.tmp" "$LOG"
|
||||||
|
fi
|
||||||
|
|
||||||
|
exit $STATUS
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
// Standalone one-shot migration from the single-user schema to the multi-user
|
||||||
|
// schema. Idempotent (safe to run repeatedly). Run it once on an existing
|
||||||
|
// single-user install before (or alongside) the first boot of the multi-user
|
||||||
|
// server — the server runs the same migration at boot too, so this script is
|
||||||
|
// mainly a convenience for migrating without starting the server.
|
||||||
|
//
|
||||||
|
// node scripts/migrate-to-multiuser.js [path/to/bewerbungen.db]
|
||||||
|
//
|
||||||
|
// All existing data is assigned to the admin user (admin:admin). Change the
|
||||||
|
// admin password after first login.
|
||||||
|
|
||||||
|
const path = require('path');
|
||||||
|
const sqlite3 = require('sqlite3').verbose();
|
||||||
|
const { runMigration, ADMIN_USERNAME, ADMIN_DEFAULT_PASSWORD } = require('../lib/migrate-multiuser');
|
||||||
|
|
||||||
|
const dbPath = process.argv[2]
|
||||||
|
? path.resolve(process.argv[2])
|
||||||
|
: path.join(__dirname, '..', 'data', 'bewerbungen.db');
|
||||||
|
|
||||||
|
const db = new sqlite3.Database(dbPath);
|
||||||
|
|
||||||
|
// Promise wrappers matching the server's helpers.
|
||||||
|
const dbGet = (sql, params = []) => new Promise((resolve, reject) => db.get(sql, params, (err, row) => err ? reject(err) : resolve(row)));
|
||||||
|
const dbAll = (sql, params = []) => new Promise((resolve, reject) => db.all(sql, params, (err, rows) => err ? reject(err) : resolve(rows)));
|
||||||
|
const dbRun = (sql, params = []) => new Promise((resolve, reject) => db.run(sql, params, function (err) { err ? reject(err) : resolve({ lastID: this.lastID, changes: this.changes }); }));
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
console.log(`Multi-User-Migration startet für Datenbank: ${dbPath}`);
|
||||||
|
const { adminId } = await runMigration({ db, dbAll, dbGet, dbRun });
|
||||||
|
console.log(`Multi-User-Migration abgeschlossen. Alle Daten gehören jetzt dem Benutzer „${ADMIN_USERNAME}“ (id ${adminId}).`);
|
||||||
|
console.log(`Bitte nach dem ersten Login das Passwort „${ADMIN_DEFAULT_PASSWORD}“ ändern.`);
|
||||||
|
db.close();
|
||||||
|
process.exit(0);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Multi-User-Migration fehlgeschlagen:', err);
|
||||||
|
db.close();
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
})();
|
||||||
@@ -0,0 +1,229 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<%- include('partials/head') %>
|
||||||
|
</head>
|
||||||
|
<body class="min-h-screen flex flex-col transition-colors duration-300 bg-gray-50 dark:bg-gray-900 text-gray-900 dark:text-gray-100" id="body">
|
||||||
|
<%- include('partials/header') %>
|
||||||
|
|
||||||
|
<main class="flex-1 container mx-auto px-4 py-8 max-w-5xl">
|
||||||
|
<a href="/admin" class="inline-flex items-center gap-2 text-sm text-blue-600 dark:text-blue-400 hover:underline mb-6">
|
||||||
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 19l-7-7m0 0l7-7m-7 7h18"></path>
|
||||||
|
</svg>
|
||||||
|
Zur Benutzerverwaltung
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<%
|
||||||
|
const istIch = user.id === currentUserId;
|
||||||
|
const letzterAdmin = user.is_admin && adminCount <= 1;
|
||||||
|
%>
|
||||||
|
|
||||||
|
<div class="flex flex-wrap items-center gap-4 mb-8">
|
||||||
|
<span class="grid h-14 w-14 place-items-center rounded-full text-lg font-semibold <%= user.is_admin ? 'bg-blue-600/10 text-blue-700 dark:bg-blue-500/15 dark:text-blue-300' : 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-300' %>">
|
||||||
|
<%= String(user.username || '?').charAt(0).toUpperCase() %>
|
||||||
|
</span>
|
||||||
|
<div class="min-w-0">
|
||||||
|
<div class="flex flex-wrap items-center gap-2.5">
|
||||||
|
<h1 class="text-2xl font-bold text-gray-800 dark:text-white"><%= user.username %></h1>
|
||||||
|
<% if (user.is_admin) { %>
|
||||||
|
<span class="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300">Admin</span>
|
||||||
|
<% } else { %>
|
||||||
|
<span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-300">Benutzer</span>
|
||||||
|
<% } %>
|
||||||
|
<% if (istIch) { %><span class="text-xs text-gray-400">(du)</span><% } %>
|
||||||
|
</div>
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400 mt-0.5">
|
||||||
|
<%= anzahlBewerbungen %> <%= anzahlBewerbungen === 1 ? 'Bewerbung' : 'Bewerbungen' %> ·
|
||||||
|
angelegt <time data-dt="<%= user.created_at %>"><%= user.created_at %></time>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 items-start">
|
||||||
|
<!-- Verwaltung -->
|
||||||
|
<section class="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6">
|
||||||
|
<h2 class="text-lg font-semibold text-gray-800 dark:text-white mb-4">Benutzer verwalten</h2>
|
||||||
|
|
||||||
|
<div class="space-y-5">
|
||||||
|
<!-- Admin-Status -->
|
||||||
|
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<p class="text-sm font-medium text-gray-800 dark:text-gray-100">Admin-Status</p>
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
<% if (letzterAdmin) { %>Letzter Admin – kann nicht abgesetzt werden.<% } else { %>Admins können Benutzer verwalten und andere Konten übernehmen.<% } %>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<form method="POST" action="/admin/users/<%= user.id %>/toggle-admin" class="inline">
|
||||||
|
<button type="submit" <%= letzterAdmin ? 'disabled' : '' %>
|
||||||
|
class="inline-flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium rounded-md transition-colors disabled:opacity-50 disabled:cursor-not-allowed
|
||||||
|
<%= user.is_admin ? 'bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-200 hover:bg-gray-200 dark:hover:bg-gray-600' : 'bg-blue-600 hover:bg-blue-700 text-white' %>">
|
||||||
|
<svg class="h-4 w-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M11.049 2.927c.3-.921 1.603-.921 1.902 0a1.724 1.724 0 002.573 1.066c.78-.448 1.707.479 1.26 1.26a1.724 1.724 0 001.065 2.572c.921.3.921 1.603 0 1.902a1.724 1.724 0 00-1.066 2.573c.448.78-.479 1.707-1.26 1.26a1.724 1.724 0 00-2.572 1.065c-.3.921-1.603.921-1.902 0a1.724 1.724 0 00-2.573-1.066c-.78.448-1.707-.479-1.26-1.26a1.724 1.724 0 00-1.065-2.572c-.921-.3-.921-1.603 0-1.902a1.724 1.724 0 001.066-2.573c-.448-.78.479-1.707 1.26-1.26.996.608 2.296.07 2.572-1.065z"></path><path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path></svg>
|
||||||
|
<%= user.is_admin ? 'Zum Benutzer herabstufen' : 'Zum Admin machen' %>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr class="border-gray-100 dark:border-gray-700">
|
||||||
|
|
||||||
|
<!-- Suchläufe starten -->
|
||||||
|
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<p class="text-sm font-medium text-gray-800 dark:text-gray-100">Suchläufe selber starten</p>
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400">Manueller „Jetzt suchen"-Knopf auf der Jobsuche. Der automatische Zeitplan läuft davon unberührt.</p>
|
||||||
|
</div>
|
||||||
|
<form method="POST" action="/admin/users/<%= user.id %>/toggle-suchlauf" class="inline">
|
||||||
|
<button type="submit"
|
||||||
|
class="inline-flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium rounded-md transition-colors
|
||||||
|
<%= user.darf_suchlauf_starten ? 'bg-green-600 hover:bg-green-700 text-white' : 'bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-200 hover:bg-gray-200 dark:hover:bg-gray-600' %>">
|
||||||
|
<span class="inline-block w-2 h-2 rounded-full <%= user.darf_suchlauf_starten ? 'bg-white' : 'bg-gray-400' %>"></span>
|
||||||
|
<%= user.darf_suchlauf_starten ? 'Erlaubt' : 'Gesperrt' %>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr class="border-gray-100 dark:border-gray-700">
|
||||||
|
|
||||||
|
<!-- Zeitplan ändern -->
|
||||||
|
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<p class="text-sm font-medium text-gray-800 dark:text-gray-100">Zeitplan ändern dürfen</p>
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400">Wochentage, Uhrzeit und „aktiv". Standard: Mo–Fr 17:00, verwaltet vom Administrator.</p>
|
||||||
|
</div>
|
||||||
|
<form method="POST" action="/admin/users/<%= user.id %>/toggle-zeitplan" class="inline">
|
||||||
|
<button type="submit"
|
||||||
|
class="inline-flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium rounded-md transition-colors
|
||||||
|
<%= user.darf_zeitplan_aendern ? 'bg-green-600 hover:bg-green-700 text-white' : 'bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-200 hover:bg-gray-200 dark:hover:bg-gray-600' %>">
|
||||||
|
<span class="inline-block w-2 h-2 rounded-full <%= user.darf_zeitplan_aendern ? 'bg-white' : 'bg-gray-400' %>"></span>
|
||||||
|
<%= user.darf_zeitplan_aendern ? 'Erlaubt' : 'Gesperrt' %>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr class="border-gray-100 dark:border-gray-700">
|
||||||
|
|
||||||
|
<!-- Passwort zurücksetzen -->
|
||||||
|
<div>
|
||||||
|
<div class="flex flex-wrap items-center justify-between gap-3 mb-2">
|
||||||
|
<div>
|
||||||
|
<p class="text-sm font-medium text-gray-800 dark:text-gray-100">Passwort zurücksetzen</p>
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400">Alle Sitzungen dieses Benutzers werden ungültig.</p>
|
||||||
|
</div>
|
||||||
|
<button type="button" onclick="document.getElementById('resetForm').classList.toggle('hidden')"
|
||||||
|
class="act-btn act-blue">
|
||||||
|
<svg class="h-4 w-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z"></path></svg>
|
||||||
|
<span>Neues Passwort</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<form id="resetForm" method="POST" action="/admin/users/<%= user.id %>/reset-password" class="hidden flex items-end gap-3 flex-wrap rounded-lg border border-blue-200 bg-blue-50/50 p-4 dark:border-blue-500/20 dark:bg-blue-500/5">
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
|
Neues Passwort für <%= user.username %>
|
||||||
|
<input name="password" type="password" required
|
||||||
|
class="mt-1.5 block w-full sm:w-72 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 px-3 py-2 text-gray-800 dark:text-white focus:ring-2 focus:ring-blue-500 focus:border-transparent outline-none" />
|
||||||
|
</label>
|
||||||
|
<button type="submit" class="rounded-lg bg-blue-600 hover:bg-blue-700 transition-colors text-white font-medium px-4 py-2 shadow-sm">Speichern</button>
|
||||||
|
<button type="button" onclick="document.getElementById('resetForm').classList.toggle('hidden')"
|
||||||
|
class="rounded-lg px-3 py-2 text-sm text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200">Abbrechen</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr class="border-gray-100 dark:border-gray-700">
|
||||||
|
|
||||||
|
<!-- Anmelden als / Suchprofil -->
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
<% if (!istIch) { %>
|
||||||
|
<form method="POST" action="/admin/users/<%= user.id %>/impersonate" class="inline"
|
||||||
|
onsubmit="return confirm('Als „<%= user.username %>“ anmelden? Du siehst dann dessen Konto. Admin-Rechte sind dabei pausiert; über das Banner oben kannst du zurückwechseln.');">
|
||||||
|
<button type="submit" class="act-btn act-emerald" title="Dieses Konto übernehmen (Impersonation)">
|
||||||
|
<svg class="h-4 w-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14v7m-4-4h8"></path></svg>
|
||||||
|
<span>Anmelden als</span>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
<% } %>
|
||||||
|
<a href="/jobsuche?user=<%= user.id %>" class="act-btn act-neutral" title="Suchprofil & Zeitplan dieses Benutzers verwalten">
|
||||||
|
<svg class="h-4 w-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"></path></svg>
|
||||||
|
<span>Jobsuche</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<% if (!istIch) { %>
|
||||||
|
<hr class="border-gray-100 dark:border-gray-700">
|
||||||
|
|
||||||
|
<!-- Löschen -->
|
||||||
|
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<p class="text-sm font-medium text-rose-700 dark:text-rose-400">Benutzer löschen</p>
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400">Löscht das Konto inkl. aller Bewerbungen und Dateien. Unumkehrbar.</p>
|
||||||
|
</div>
|
||||||
|
<form method="POST" action="/admin/users/<%= user.id %>/delete" class="inline"
|
||||||
|
onsubmit="return confirm('Benutzer „<%= user.username %>“ inkl. aller Daten wirklich löschen? Das lässt sich nicht rückgängig machen.');">
|
||||||
|
<button type="submit" class="act-btn act-danger" title="Benutzer inkl. aller Daten löschen">
|
||||||
|
<svg class="h-4 w-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"></path></svg>
|
||||||
|
<span>Löschen</span>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<% } %>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Letzte Bewerbungen -->
|
||||||
|
<section class="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6">
|
||||||
|
<h2 class="text-lg font-semibold text-gray-800 dark:text-white mb-4">Letzte Bewerbungen</h2>
|
||||||
|
<% if (bewerbungen.length) { %>
|
||||||
|
<ul class="divide-y divide-gray-100 dark:divide-gray-700">
|
||||||
|
<% bewerbungen.forEach(function(b){ %>
|
||||||
|
<li class="py-3 flex items-center gap-3">
|
||||||
|
<span class="inline-block w-2.5 h-2.5 rounded-full flex-shrink-0
|
||||||
|
<%= b.status === 'Einstellung' ? 'bg-green-500' :
|
||||||
|
b.status === 'Absage' ? 'bg-red-500' :
|
||||||
|
b.status === 'Vorstellungsgespräch' ? 'bg-yellow-500' :
|
||||||
|
'bg-gray-300' %>"></span>
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<p class="text-sm font-medium text-gray-800 dark:text-gray-100 truncate"><%= b.firma %></p>
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400 truncate"><%= b.stelle %></p>
|
||||||
|
</div>
|
||||||
|
<span class="text-xs text-gray-400 dark:text-gray-500 whitespace-nowrap shrink-0"><%= new Date(b.datum).toLocaleDateString('de-DE') %></span>
|
||||||
|
</li>
|
||||||
|
<% }); %>
|
||||||
|
</ul>
|
||||||
|
<% if (anzahlBewerbungen > bewerbungen.length) { %>
|
||||||
|
<p class="text-xs text-gray-400 dark:text-gray-500 mt-3">… und <%= anzahlBewerbungen - bewerbungen.length %> weitere.</p>
|
||||||
|
<% } %>
|
||||||
|
<% } else { %>
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400">Noch keine Bewerbungen.</p>
|
||||||
|
<% } %>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<%- include('partials/footer') %>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.act-btn{display:inline-flex;align-items:center;gap:.375rem;border-radius:.5rem;padding:.375rem .625rem;font-size:.75rem;font-weight:500;transition:background-color .15s,color .15s;line-height:1;}
|
||||||
|
.act-neutral{color:#4b5563}.act-neutral:hover{background:#f3f4f6}
|
||||||
|
.dark .act-neutral{color:#d1d5db}.dark .act-neutral:hover{background:rgba(255,255,255,.06)}
|
||||||
|
.act-emerald{color:#047857}.act-emerald:hover{background:#ecfdf5}
|
||||||
|
.dark .act-emerald{color:#6ee7b7}.dark .act-emerald:hover{background:rgba(16,185,129,.12)}
|
||||||
|
.act-blue{color:#2563eb}.act-blue:hover{background:#eff6ff}
|
||||||
|
.dark .act-blue{color:#93c5fd}.dark .act-blue:hover{background:rgba(59,130,246,.12)}
|
||||||
|
.act-danger{color:#e11d48}.act-danger:hover{background:#fff1f2}
|
||||||
|
.dark .act-danger{color:#fda4af}.dark .act-danger:hover{background:rgba(244,63,94,.12)}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Relative Zeitangaben (data-dt) wie in der Admin-Übersicht formatieren.
|
||||||
|
(function () {
|
||||||
|
document.querySelectorAll('time[data-dt]').forEach(function (t) {
|
||||||
|
var s = t.getAttribute('data-dt');
|
||||||
|
if (!s) return;
|
||||||
|
var d = new Date(s.replace(' ', 'T') + 'Z');
|
||||||
|
if (isNaN(d.getTime())) return;
|
||||||
|
t.textContent = d.toLocaleDateString('de-DE') + ', ' + d.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' });
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<%- include('partials/favorit-script') %>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
+262
@@ -0,0 +1,262 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<%- include('partials/head') %>
|
||||||
|
</head>
|
||||||
|
<body class="min-h-screen flex flex-col transition-colors duration-300 bg-gray-50 dark:bg-gray-900 text-gray-900 dark:text-gray-100" id="body">
|
||||||
|
<%- include('partials/header') %>
|
||||||
|
|
||||||
|
<main class="flex-1 container mx-auto px-4 py-8 max-w-5xl">
|
||||||
|
<a href="/" class="inline-flex items-center gap-2 text-sm text-blue-600 dark:text-blue-400 hover:underline mb-6">
|
||||||
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 19l-7-7m0 0l7-7m-7 7h18"></path>
|
||||||
|
</svg>
|
||||||
|
Zurück zur Übersicht
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<% var totalBewerbungen = users.reduce((s,u) => s + (u.anzahl_bewerbungen||0), 0); var adminCount = users.filter(u=>u.is_admin).length; %>
|
||||||
|
|
||||||
|
<div class="flex flex-wrap items-end justify-between gap-3 mb-6">
|
||||||
|
<div>
|
||||||
|
<h2 class="text-2xl font-bold flex items-center gap-2.5">
|
||||||
|
<span class="grid h-9 w-9 place-items-center rounded-xl bg-blue-600/10 text-blue-600 dark:bg-blue-500/15 dark:text-blue-300 ring-1 ring-blue-600/15">
|
||||||
|
<svg class="h-5 w-5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M17 20h5v-2a4 4 0 00-3-3.87M9 20H4v-2a4 4 0 013-3.87m6-2a4 4 0 100-8 4 4 0 000 8z"></path>
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
Benutzerverwaltung
|
||||||
|
</h2>
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">Konten anlegen, Passwörter zurücksetzen, ein Konto übernehmen oder Benutzer inkl. aller Daten löschen.</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-wrap gap-3">
|
||||||
|
<div class="rounded-xl border border-gray-200 bg-white px-4 py-2.5 text-center shadow-sm dark:border-white/5 dark:bg-gray-800">
|
||||||
|
<div class="text-xl font-bold leading-none"><%= users.length %></div>
|
||||||
|
<div class="text-[11px] uppercase tracking-wide text-gray-500 dark:text-gray-400 mt-1">Benutzer</div>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-xl border border-gray-200 bg-white px-4 py-2.5 text-center shadow-sm dark:border-white/5 dark:bg-gray-800">
|
||||||
|
<div class="text-xl font-bold leading-none text-blue-600 dark:text-blue-300"><%= adminCount %></div>
|
||||||
|
<div class="text-[11px] uppercase tracking-wide text-gray-500 dark:text-gray-400 mt-1">Admins</div>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-xl border border-gray-200 bg-white px-4 py-2.5 text-center shadow-sm dark:border-white/5 dark:bg-gray-800">
|
||||||
|
<div class="text-xl font-bold leading-none"><%= totalBewerbungen %></div>
|
||||||
|
<div class="text-[11px] uppercase tracking-wide text-gray-500 dark:text-gray-400 mt-1">Bewerbungen</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid gap-6">
|
||||||
|
<!-- Neuen Benutzer anlegen -->
|
||||||
|
<section class="bg-white dark:bg-gray-800 rounded-xl shadow-md p-6">
|
||||||
|
<h3 class="text-lg font-semibold mb-1 flex items-center gap-2">
|
||||||
|
<svg class="h-5 w-5 text-blue-600 dark:text-blue-300" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M18 9v3m0 0v3m0-3h3m-3 0h-3m-2-5a4 4 0 11-8 0 4 4 0 018 0zM3 20a6 6 0 0112 0v1H3v-1z"></path>
|
||||||
|
</svg>
|
||||||
|
Neuen Benutzer anlegen
|
||||||
|
</h3>
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400 mb-5">Legt ein neues Konto an. Ein Admin kann ebenfalls Benutzer verwalten.</p>
|
||||||
|
<form method="POST" action="/admin/users" class="grid sm:grid-cols-2 lg:grid-cols-4 gap-4 items-end">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1.5" for="newUsername">Benutzername</label>
|
||||||
|
<input id="newUsername" name="username" type="text" required maxlength="64" placeholder="z. B. maxm"
|
||||||
|
class="w-full rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 px-3 py-2 text-gray-800 dark:text-white focus:ring-2 focus:ring-blue-500 focus:border-transparent outline-none" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1.5" for="newPassword">Passwort</label>
|
||||||
|
<input id="newPassword" name="password" type="password" required placeholder="Startpasswort"
|
||||||
|
class="w-full rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 px-3 py-2 text-gray-800 dark:text-white focus:ring-2 focus:ring-blue-500 focus:border-transparent outline-none" />
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center h-[42px]">
|
||||||
|
<label class="inline-flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300 cursor-pointer">
|
||||||
|
<input type="checkbox" name="is_admin" value="1" class="rounded border-gray-300 dark:border-gray-600 text-blue-600 focus:ring-blue-500" />
|
||||||
|
Admin-Rechte
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="inline-flex justify-center items-center gap-2 rounded-lg bg-blue-600 hover:bg-blue-700 transition-colors text-white font-medium px-4 py-2 shadow-sm">
|
||||||
|
<svg class="h-4 w-4" fill="none" stroke="currentColor" stroke-width="2.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M12 4v16m8-8H4"></path></svg>
|
||||||
|
Anlegen
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Bestehende Benutzer -->
|
||||||
|
<section class="bg-white dark:bg-gray-800 rounded-xl shadow-md overflow-hidden">
|
||||||
|
<div class="px-6 pt-6">
|
||||||
|
<h3 class="text-lg font-semibold flex items-center gap-2">
|
||||||
|
<svg class="h-5 w-5 text-gray-500 dark:text-gray-400" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z"></path>
|
||||||
|
</svg>
|
||||||
|
Bestehende Benutzer
|
||||||
|
</h3>
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1 mb-4">Über „Anmelden als" übernimmst du ein Konto komplett — Admin-Rechte sind dabei pausiert, zurück über das Banner.</p>
|
||||||
|
</div>
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr class="text-left text-gray-500 dark:text-gray-400 border-b border-gray-200 dark:border-gray-700 bg-gray-50/60 dark:bg-white/5">
|
||||||
|
<th class="py-2.5 px-6 font-medium">Benutzername</th>
|
||||||
|
<th class="py-2.5 pr-4 font-medium">Rolle</th>
|
||||||
|
<th class="py-2.5 pr-4 font-medium text-center">Bewerbungen</th>
|
||||||
|
<th class="py-2.5 pr-4 font-medium">Erstellt</th>
|
||||||
|
<th class="py-2.5 pr-6 font-medium text-right">Aktionen</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-gray-100 dark:divide-gray-700">
|
||||||
|
<% users.forEach(function(u) { %>
|
||||||
|
<tr>
|
||||||
|
<td class="py-3 px-6 font-medium text-gray-800 dark:text-gray-100">
|
||||||
|
<a href="/admin/users/<%= u.id %>" class="flex items-center gap-2.5 group -mx-1.5 -my-1 px-1.5 py-1 rounded-md hover:bg-gray-50 dark:hover:bg-gray-700/60 transition-colors">
|
||||||
|
<span class="grid h-8 w-8 place-items-center rounded-full text-xs font-semibold <%= u.is_admin ? 'bg-blue-600/10 text-blue-700 dark:bg-blue-500/15 dark:text-blue-300' : 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-300' %>">
|
||||||
|
<%= String(u.username || '?').charAt(0).toUpperCase() %>
|
||||||
|
</span>
|
||||||
|
<span class="group-hover:text-blue-600 dark:group-hover:text-blue-400"><%= u.username %></span>
|
||||||
|
<% if (u.id === currentUserId) { %><span class="text-xs text-gray-400">(du)</span><% } %>
|
||||||
|
<svg class="w-4 h-4 text-gray-300 dark:text-gray-600 group-hover:text-blue-500 transition-colors" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"></path></svg>
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
<td class="py-3 pr-4">
|
||||||
|
<% if (u.is_admin) { %>
|
||||||
|
<span class="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300">
|
||||||
|
<svg class="h-3 w-3" fill="none" stroke="currentColor" stroke-width="2.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M11.049 2.927c.3-.921 1.603-.921 1.902 0a1.724 1.724 0 002.573 1.066c.78-.448 1.707.479 1.26 1.26a1.724 1.724 0 001.065 2.572c.921.3.921 1.603 0 1.902a1.724 1.724 0 00-1.066 2.573c.448.78-.479 1.707-1.26 1.26a1.724 1.724 0 00-2.572 1.065c-.3.921-1.603.921-1.902 0a1.724 1.724 0 00-2.573-1.066c-.78.448-1.707-.479-1.26-1.26a1.724 1.724 0 00-1.065-2.572c-.921-.3-.921-1.603 0-1.902a1.724 1.724 0 001.066-2.573c-.448-.78.479-1.707 1.26-1.26.996.608 2.296.07 2.572-1.065z"></path><path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path></svg>
|
||||||
|
Admin
|
||||||
|
</span>
|
||||||
|
<% } else { %>
|
||||||
|
<span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-300">Benutzer</span>
|
||||||
|
<% } %>
|
||||||
|
</td>
|
||||||
|
<td class="py-3 pr-4 text-center text-gray-600 dark:text-gray-300"><%= u.anzahl_bewerbungen %></td>
|
||||||
|
<td class="py-3 pr-4 text-gray-500 dark:text-gray-400">
|
||||||
|
<time data-dt="<%= u.created_at %>"><%= u.created_at %></time>
|
||||||
|
</td>
|
||||||
|
<td class="py-3 pr-6">
|
||||||
|
<div class="flex flex-wrap items-center justify-end gap-1.5">
|
||||||
|
<% if (u.id !== currentUserId) { %>
|
||||||
|
<form method="POST" action="/admin/users/<%= u.id %>/impersonate" class="inline"
|
||||||
|
onsubmit="return confirm('Als „<%= u.username %>“ anmelden? Du siehst dann dessen Konto. Admin-Rechte sind dabei pausiert; über das Banner oben kannst du zurückwechseln.');">
|
||||||
|
<button type="submit" class="act-btn act-emerald" title="Dieses Konto übernehmen (Impersonation)">
|
||||||
|
<svg class="h-4 w-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14v7m-4-4h8"></path></svg>
|
||||||
|
<span>Anmelden als</span>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
<% } %>
|
||||||
|
<button type="button"
|
||||||
|
onclick="document.getElementById('resetForm<%= u.id %>').classList.toggle('hidden')"
|
||||||
|
class="act-btn act-blue" title="Passwort zurücksetzen">
|
||||||
|
<svg class="h-4 w-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z"></path></svg>
|
||||||
|
<span>Passwort</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr id="resetForm<%= u.id %>" class="hidden">
|
||||||
|
<td colspan="5" class="px-6 pb-4">
|
||||||
|
<form method="POST" action="/admin/users/<%= u.id %>/reset-password" class="flex items-end gap-3 flex-wrap rounded-lg border border-blue-200 bg-blue-50/50 p-4 dark:border-blue-500/20 dark:bg-blue-500/5">
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
|
Neues Passwort für <%= u.username %>
|
||||||
|
<input name="password" type="password" required
|
||||||
|
class="mt-1.5 block w-full sm:w-72 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 px-3 py-2 text-gray-800 dark:text-white focus:ring-2 focus:ring-blue-500 focus:border-transparent outline-none" />
|
||||||
|
</label>
|
||||||
|
<button type="submit" class="rounded-lg bg-blue-600 hover:bg-blue-700 transition-colors text-white font-medium px-4 py-2 shadow-sm">
|
||||||
|
Speichern
|
||||||
|
</button>
|
||||||
|
<button type="button" onclick="document.getElementById('resetForm<%= u.id %>').classList.toggle('hidden')"
|
||||||
|
class="rounded-lg px-3 py-2 text-sm text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200">
|
||||||
|
Abbrechen
|
||||||
|
</button>
|
||||||
|
<span class="text-xs text-gray-500 dark:text-gray-400 self-center">Alle Sitzungen dieses Benutzers werden ungültig.</span>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<% }); %>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Impersonations-Audit -->
|
||||||
|
<section class="bg-white dark:bg-gray-800 rounded-xl shadow-md p-6">
|
||||||
|
<h3 class="text-lg font-semibold mb-1 flex items-center gap-2">
|
||||||
|
<svg class="h-5 w-5 text-gray-500 dark:text-gray-400" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"></path>
|
||||||
|
</svg>
|
||||||
|
Impersonationen (Audit)
|
||||||
|
</h3>
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400 mb-4">Nachvollziehbar protokolliert: jeder Wechsel auf ein anderes Konto und jeder Rückwechsel.</p>
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr class="text-left text-gray-500 dark:text-gray-400 border-b border-gray-200 dark:border-gray-700">
|
||||||
|
<th class="py-2 pr-4 font-medium">Admin</th>
|
||||||
|
<th class="py-2 pr-4 font-medium">Aktion</th>
|
||||||
|
<th class="py-2 pr-4 font-medium">Zeit</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="auditBody" class="divide-y divide-gray-100 dark:divide-gray-700">
|
||||||
|
<tr><td colspan="3" class="py-4 text-gray-400 dark:text-gray-500">… wird geladen</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.act-btn{display:inline-flex;align-items:center;gap:.375rem;border-radius:.5rem;padding:.375rem .625rem;font-size:.75rem;font-weight:500;transition:background-color .15s,color .15s;line-height:1;}
|
||||||
|
.act-neutral{color:#4b5563}.act-neutral:hover{background:#f3f4f6}
|
||||||
|
.dark .act-neutral{color:#d1d5db}.dark .act-neutral:hover{background:rgba(255,255,255,.06)}
|
||||||
|
.act-emerald{color:#047857}.act-emerald:hover{background:#ecfdf5}
|
||||||
|
.dark .act-emerald{color:#6ee7b7}.dark .act-emerald:hover{background:rgba(16,185,129,.12)}
|
||||||
|
.act-blue{color:#2563eb}.act-blue:hover{background:#eff6ff}
|
||||||
|
.dark .act-blue{color:#93c5fd}.dark .act-blue:hover{background:rgba(59,130,246,.12)}
|
||||||
|
.act-danger{color:#e11d48}.act-danger:hover{background:#fff1f2}
|
||||||
|
.dark .act-danger{color:#fda4af}.dark .act-danger:hover{background:rgba(244,63,94,.12)}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
function esc(s) { var d = document.createElement('div'); d.textContent = s == null ? '' : String(s); return d.innerHTML; }
|
||||||
|
// Relative Zeitangaben für die „Erstellt"-Spalte.
|
||||||
|
function fmtRel(v) {
|
||||||
|
if (!v) return '';
|
||||||
|
var dt = new Date(String(v).replace(' ', 'T') + 'Z');
|
||||||
|
if (isNaN(dt)) return v;
|
||||||
|
var diff = (Date.now() - dt.getTime()) / 1000, out;
|
||||||
|
if (diff < 60) out = 'gerade eben';
|
||||||
|
else if (diff < 3600) out = Math.floor(diff / 60) + ' Min.';
|
||||||
|
else if (diff < 86400) out = Math.floor(diff / 3600) + ' Std.';
|
||||||
|
else if (diff < 2592000) out = Math.floor(diff / 86400) + ' T';
|
||||||
|
else out = dt.toLocaleDateString('de-DE');
|
||||||
|
var full = dt.toLocaleString('de-DE');
|
||||||
|
return '<time title="' + esc(full) + '">' + out + '</time>';
|
||||||
|
}
|
||||||
|
document.querySelectorAll('time[data-dt]').forEach(function (el) { el.outerHTML = fmtRel(el.getAttribute('data-dt')); });
|
||||||
|
|
||||||
|
// Audit-Liste laden.
|
||||||
|
fetch('/admin/audit/impersonations', { cache: 'no-store' })
|
||||||
|
.then(function (r) { return r.json(); })
|
||||||
|
.then(function (d) {
|
||||||
|
var body = document.getElementById('auditBody');
|
||||||
|
var items = (d && d.items) || [];
|
||||||
|
if (!items.length) {
|
||||||
|
body.innerHTML = '<tr><td colspan="3" class="py-4 text-gray-400 dark:text-gray-500">Keine Impersonationen protokolliert.</td></tr>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
body.innerHTML = items.map(function (it) {
|
||||||
|
var isStop = /stop/.test(it.action);
|
||||||
|
var badge = isStop
|
||||||
|
? '<span class="inline-flex px-2 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-300">Rückwechsel</span>'
|
||||||
|
: '<span class="inline-flex px-2 py-0.5 rounded-full text-xs font-medium bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-300">Anmeldung als</span>';
|
||||||
|
var ziel = isStop ? '' : ' <span class="text-gray-700 dark:text-gray-300">' + esc(it.action.replace(/^impersonate_start\s*→\s*/, '')) + '</span>';
|
||||||
|
return '<tr>'
|
||||||
|
+ '<td class="py-2 pr-4 font-medium text-gray-800 dark:text-gray-100">' + esc(it.actor || '?') + '</td>'
|
||||||
|
+ '<td class="py-2 pr-4">' + badge + ziel + '</td>'
|
||||||
|
+ '<td class="py-2 pr-4 text-gray-500 dark:text-gray-400">' + fmtRel(it.created_at) + '</td>'
|
||||||
|
+ '</tr>';
|
||||||
|
}).join('');
|
||||||
|
})
|
||||||
|
.catch(function () {
|
||||||
|
document.getElementById('auditBody').innerHTML = '<tr><td colspan="3" class="py-4 text-gray-400 dark:text-gray-500">Audit konnte nicht geladen werden.</td></tr>';
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,169 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<%- include('partials/head') %>
|
||||||
|
</head>
|
||||||
|
<body class="min-h-screen flex flex-col transition-colors duration-300 bg-gray-50 dark:bg-gray-900 text-gray-900 dark:text-gray-100" id="body">
|
||||||
|
<%- include('partials/header') %>
|
||||||
|
|
||||||
|
<main class="flex-1 container mx-auto px-4 py-8 max-w-4xl">
|
||||||
|
<%
|
||||||
|
// Farben synchron zur Startseite (views/index.ejs) halten.
|
||||||
|
const statusDot = {
|
||||||
|
'Entwurf': 'bg-purple-500',
|
||||||
|
'Gesendet': 'bg-indigo-500',
|
||||||
|
'Eingangsbestätigung': 'bg-blue-500',
|
||||||
|
'In Bearbeitung': 'bg-teal-500',
|
||||||
|
'Interessiert': 'bg-pink-500',
|
||||||
|
'Telefonat': 'bg-sky-500',
|
||||||
|
'Warten auf Rückmeldung': 'bg-orange-500',
|
||||||
|
'Warten auf meine Antwort': 'bg-fuchsia-500',
|
||||||
|
'Vorstellungsgespräch': 'bg-yellow-500',
|
||||||
|
'Vertragsverhandlung': 'bg-lime-500',
|
||||||
|
'Einstellung': 'bg-green-500',
|
||||||
|
'Absage': 'bg-red-500',
|
||||||
|
'Absage von meiner Seite': 'bg-rose-500',
|
||||||
|
'Keine Rückmeldung': 'bg-gray-400'
|
||||||
|
};
|
||||||
|
|
||||||
|
// Welche Seitenzahlen die Blätterleiste zeigt: erste, letzte, die aktuelle
|
||||||
|
// und je eine daneben — dazwischen Auslassungen (null). Bei wenigen Seiten
|
||||||
|
// stehen ohnehin alle da.
|
||||||
|
const zahlen = [];
|
||||||
|
for (let i = 1; i <= seiten; i++) {
|
||||||
|
const nah = Math.abs(i - seite) <= 1;
|
||||||
|
const rand = (i === 1 || i === seiten);
|
||||||
|
if (nah || rand) {
|
||||||
|
if (zahlen.length && zahlen[zahlen.length - 1] !== null && i - zahlen[zahlen.length - 1] > 1) zahlen.push(null);
|
||||||
|
zahlen.push(i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const von = gesamt === 0 ? 0 : (seite - 1) * proSeite + 1;
|
||||||
|
const bis = Math.min(seite * proSeite, gesamt);
|
||||||
|
%>
|
||||||
|
|
||||||
|
<div class="mb-6 flex flex-wrap items-baseline justify-between gap-x-4 gap-y-1">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-2xl font-bold text-gray-800 dark:text-white">Aktivitäten</h1>
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||||
|
Jede Statusänderung, über alle Bewerbungen hinweg – die jüngste zuerst.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<a href="/" class="text-sm text-blue-600 dark:text-blue-400 hover:underline">Zurück zu den Bewerbungen</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section class="bg-white dark:bg-gray-800 rounded-lg shadow-md">
|
||||||
|
<div class="flex flex-wrap items-baseline justify-between gap-x-4 gap-y-1 px-6 py-4 border-b border-gray-200 dark:border-gray-700">
|
||||||
|
<p class="text-sm text-gray-600 dark:text-gray-300">
|
||||||
|
<% if (gesamt) { %>
|
||||||
|
<span class="font-semibold text-gray-800 dark:text-gray-100"><%= von %>–<%= bis %></span>
|
||||||
|
von <span class="font-semibold text-gray-800 dark:text-gray-100"><%= gesamt %></span>
|
||||||
|
<%= gesamt === 1 ? 'Eintrag' : 'Einträgen' %>
|
||||||
|
<% } else { %>
|
||||||
|
Keine Einträge
|
||||||
|
<% } %>
|
||||||
|
</p>
|
||||||
|
<% if (seiten > 1) { %>
|
||||||
|
<p class="text-sm text-gray-400 dark:text-gray-500">Seite <%= seite %> von <%= seiten %></p>
|
||||||
|
<% } %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<% if (aktivitaet.length) { %>
|
||||||
|
<ul class="divide-y divide-gray-100 dark:divide-gray-700/60">
|
||||||
|
<% aktivitaet.forEach(function (a) {
|
||||||
|
var dotCls = a.typ === 'email'
|
||||||
|
? (a.direction === 'out' ? 'bg-sky-500' : 'bg-blue-500')
|
||||||
|
: (statusDot[a.status] || 'bg-gray-300');
|
||||||
|
var titel = '', titelSub = '';
|
||||||
|
if (a.bewerbung_id) {
|
||||||
|
titel = a.firma || '(ohne Firma)';
|
||||||
|
titelSub = a.stelle || '';
|
||||||
|
} else if (a.typ === 'email') {
|
||||||
|
titel = a.direction === 'out' ? ('An: ' + (a.to_addr || '?')) : (a.from_addr || a.subject || 'E-Mail');
|
||||||
|
} else {
|
||||||
|
titel = 'Gelöschte Bewerbung';
|
||||||
|
}
|
||||||
|
%>
|
||||||
|
<li>
|
||||||
|
<% if (a.bewerbung_id) { %>
|
||||||
|
<a href="/bewerbung/<%= a.bewerbung_id %>" class="flex items-start gap-3 px-6 py-3 hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors">
|
||||||
|
<% } else { %>
|
||||||
|
<div class="flex items-start gap-3 px-6 py-3">
|
||||||
|
<% } %>
|
||||||
|
<span class="mt-1.5 inline-block w-2.5 h-2.5 rounded-full shrink-0 <%= dotCls %>"></span>
|
||||||
|
<span class="min-w-0 flex-1">
|
||||||
|
<span class="flex flex-wrap items-baseline gap-x-2 gap-y-0.5">
|
||||||
|
<span class="text-sm font-medium <%= a.bewerbung_id ? 'text-gray-900 dark:text-white' : 'text-gray-500 dark:text-gray-400' %> break-words"><%= titel %></span>
|
||||||
|
<% if (titelSub) { %>
|
||||||
|
<span class="text-sm text-gray-500 dark:text-gray-400 break-words">· <%= titelSub %></span>
|
||||||
|
<% } %>
|
||||||
|
</span>
|
||||||
|
<span class="mt-0.5 flex flex-wrap items-baseline gap-x-2 text-sm">
|
||||||
|
<span class="font-medium text-gray-700 dark:text-gray-200"><%= a.status %></span>
|
||||||
|
<% if (a.datum) { %>
|
||||||
|
<span class="text-gray-400 dark:text-gray-500 whitespace-nowrap">· <%= new Date(a.datum).toLocaleDateString('de-DE') %></span>
|
||||||
|
<% } %>
|
||||||
|
<% if (a.kommentar && a.kommentar.trim()) { %>
|
||||||
|
<span class="text-gray-500 dark:text-gray-400 break-words">· <%= a.kommentar %></span>
|
||||||
|
<% } %>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<% if (a.bewerbung_id) { %></a><% } else { %></div><% } %>
|
||||||
|
</li>
|
||||||
|
<% }); %>
|
||||||
|
</ul>
|
||||||
|
<% } else { %>
|
||||||
|
<div class="px-6 py-16 text-center text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
Noch keine Aktivitäten — Statusänderungen und E-Mails erscheinen hier.
|
||||||
|
</div>
|
||||||
|
<% } %>
|
||||||
|
|
||||||
|
<% if (seiten > 1) { %>
|
||||||
|
<!-- Blätterleiste: echte Links (kein JS), damit Zurück-Taste, Lesezeichen
|
||||||
|
und das Öffnen in einem neuen Tab funktionieren. -->
|
||||||
|
<nav class="flex flex-wrap items-center justify-center gap-1 px-6 py-4 border-t border-gray-200 dark:border-gray-700" aria-label="Seiten">
|
||||||
|
<% if (seite > 1) { %>
|
||||||
|
<a href="/aktivitaeten?seite=<%= seite - 1 %>" rel="prev"
|
||||||
|
class="inline-flex items-center gap-1 px-3 py-1.5 text-sm rounded-md border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors">
|
||||||
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"></path></svg>
|
||||||
|
Zurück
|
||||||
|
</a>
|
||||||
|
<% } else { %>
|
||||||
|
<span class="inline-flex items-center gap-1 px-3 py-1.5 text-sm rounded-md border border-gray-200 dark:border-gray-700 text-gray-300 dark:text-gray-600 cursor-not-allowed">
|
||||||
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"></path></svg>
|
||||||
|
Zurück
|
||||||
|
</span>
|
||||||
|
<% } %>
|
||||||
|
|
||||||
|
<% zahlen.forEach(function (n) { %>
|
||||||
|
<% if (n === null) { %>
|
||||||
|
<span class="px-2 text-sm text-gray-400 dark:text-gray-600">…</span>
|
||||||
|
<% } else if (n === seite) { %>
|
||||||
|
<span aria-current="page"
|
||||||
|
class="min-w-[2.25rem] text-center px-2 py-1.5 text-sm font-medium rounded-md bg-blue-600 text-white"><%= n %></span>
|
||||||
|
<% } else { %>
|
||||||
|
<a href="/aktivitaeten?seite=<%= n %>"
|
||||||
|
class="min-w-[2.25rem] text-center px-2 py-1.5 text-sm rounded-md border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"><%= n %></a>
|
||||||
|
<% } %>
|
||||||
|
<% }); %>
|
||||||
|
|
||||||
|
<% if (seite < seiten) { %>
|
||||||
|
<a href="/aktivitaeten?seite=<%= seite + 1 %>" rel="next"
|
||||||
|
class="inline-flex items-center gap-1 px-3 py-1.5 text-sm rounded-md border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors">
|
||||||
|
Weiter
|
||||||
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"></path></svg>
|
||||||
|
</a>
|
||||||
|
<% } else { %>
|
||||||
|
<span class="inline-flex items-center gap-1 px-3 py-1.5 text-sm rounded-md border border-gray-200 dark:border-gray-700 text-gray-300 dark:text-gray-600 cursor-not-allowed">
|
||||||
|
Weiter
|
||||||
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"></path></svg>
|
||||||
|
</span>
|
||||||
|
<% } %>
|
||||||
|
</nav>
|
||||||
|
<% } %>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<%- include('partials/footer') %>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
+727
-350
File diff suppressed because it is too large
Load Diff
+2
-21
@@ -3,10 +3,10 @@
|
|||||||
<head>
|
<head>
|
||||||
<%- include('partials/head') %>
|
<%- include('partials/head') %>
|
||||||
</head>
|
</head>
|
||||||
<body class="min-h-screen transition-colors duration-300 bg-gray-50 dark:bg-gray-900 text-gray-900 dark:text-gray-100" id="body">
|
<body class="min-h-screen flex flex-col transition-colors duration-300 bg-gray-50 dark:bg-gray-900 text-gray-900 dark:text-gray-100" id="body">
|
||||||
<%- include('partials/header') %>
|
<%- include('partials/header') %>
|
||||||
|
|
||||||
<main class="container mx-auto px-4 py-8 max-w-5xl">
|
<main class="flex-1 container mx-auto px-4 py-8 max-w-5xl">
|
||||||
<!-- Back link -->
|
<!-- Back link -->
|
||||||
<a href="/jobangebote" class="inline-flex items-center gap-2 text-sm text-blue-600 dark:text-blue-400 hover:underline mb-6">
|
<a href="/jobangebote" class="inline-flex items-center gap-2 text-sm text-blue-600 dark:text-blue-400 hover:underline mb-6">
|
||||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
@@ -156,25 +156,6 @@
|
|||||||
|
|
||||||
<script>
|
<script>
|
||||||
(function () {
|
(function () {
|
||||||
const root = document.documentElement;
|
|
||||||
const dm = localStorage.getItem('darkMode');
|
|
||||||
if (dm === 'enabled' || (!dm && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
|
|
||||||
root.classList.add('dark');
|
|
||||||
}
|
|
||||||
const toggle = document.getElementById('darkModeToggle');
|
|
||||||
const sun = document.getElementById('sunIcon');
|
|
||||||
const moon = document.getElementById('moonIcon');
|
|
||||||
function sync() {
|
|
||||||
const d = root.classList.contains('dark');
|
|
||||||
if (sun) sun.classList.toggle('hidden', d);
|
|
||||||
if (moon) moon.classList.toggle('hidden', !d);
|
|
||||||
}
|
|
||||||
sync();
|
|
||||||
if (toggle) toggle.addEventListener('click', () => {
|
|
||||||
root.classList.toggle('dark');
|
|
||||||
localStorage.setItem('darkMode', root.classList.contains('dark') ? 'enabled' : 'disabled');
|
|
||||||
sync();
|
|
||||||
});
|
|
||||||
const cy = document.getElementById('currentYear');
|
const cy = document.getElementById('currentYear');
|
||||||
if (cy) cy.textContent = new Date().getFullYear();
|
if (cy) cy.textContent = new Date().getFullYear();
|
||||||
|
|
||||||
|
|||||||
+141
@@ -0,0 +1,141 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<%- include('partials/head') %>
|
||||||
|
<style>
|
||||||
|
#chatLog::-webkit-scrollbar { width: 8px; }
|
||||||
|
#chatLog::-webkit-scrollbar-thumb { background: rgba(120,120,120,.4); border-radius: 4px; }
|
||||||
|
/* Typing indicator: three dots with a staggered bounce. */
|
||||||
|
.typing-dot {
|
||||||
|
width: 7px; height: 7px; border-radius: 9999px;
|
||||||
|
background: currentColor; display: inline-block;
|
||||||
|
opacity: 0.35;
|
||||||
|
animation: chat-typing 1.2s infinite ease-in-out both;
|
||||||
|
}
|
||||||
|
.typing-dot:nth-child(2) { animation-delay: 0.18s; }
|
||||||
|
.typing-dot:nth-child(3) { animation-delay: 0.36s; }
|
||||||
|
@keyframes chat-typing {
|
||||||
|
0%, 80%, 100% { transform: translateY(0); opacity: 0.35; }
|
||||||
|
40% { transform: translateY(-5px); opacity: 1; }
|
||||||
|
}
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.typing-dot { animation: none; opacity: 0.6; }
|
||||||
|
}
|
||||||
|
/* Thread rename/delete rely on hover/focus to appear — neither exists on a
|
||||||
|
touchscreen, so the controls would be unreachable. Show them on small
|
||||||
|
(likely touch) screens. */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.threadRename, .threadDelete { opacity: 1 !important; }
|
||||||
|
}
|
||||||
|
/* Markdown rendering inside assistant bubbles */
|
||||||
|
.chat-md { line-height: 1.5; }
|
||||||
|
.chat-md > *:first-child { margin-top: 0; }
|
||||||
|
.chat-md > *:last-child { margin-bottom: 0; }
|
||||||
|
.chat-md p { margin: 0 0 0.5rem 0; }
|
||||||
|
.chat-md h1, .chat-md h2, .chat-md h3 { font-weight: 600; margin: 0.6rem 0 0.3rem 0; line-height: 1.25; }
|
||||||
|
.chat-md h1 { font-size: 1.05rem; }
|
||||||
|
.chat-md h2 { font-size: 1rem; }
|
||||||
|
.chat-md h3 { font-size: 0.95rem; }
|
||||||
|
.chat-md ul, .chat-md ol { margin: 0 0 0.5rem 0; padding-left: 1.25rem; }
|
||||||
|
.chat-md li { margin: 0.15rem 0; }
|
||||||
|
.chat-md code { background: rgba(0,0,0,0.08); padding: 0.1rem 0.3rem; border-radius: 4px; font-size: 0.85em; }
|
||||||
|
.dark .chat-md code { background: rgba(255,255,255,0.12); }
|
||||||
|
.chat-md pre { background: rgba(0,0,0,0.08); padding: 0.6rem; border-radius: 6px; overflow-x: auto; margin: 0 0 0.5rem 0; }
|
||||||
|
.dark .chat-md pre { background: rgba(0,0,0,0.35); }
|
||||||
|
.chat-md pre code { background: none; padding: 0; }
|
||||||
|
.chat-md a { text-decoration: underline; }
|
||||||
|
.chat-md hr { border: 0; border-top: 1px solid rgba(0,0,0,0.12); margin: 0.7rem 0; }
|
||||||
|
.dark .chat-md hr { border-top-color: rgba(255,255,255,0.15); }
|
||||||
|
/* Tabellen (z. B. Anforderung/Erfahrung-Vergleiche). Der Wrapper scrollt,
|
||||||
|
damit eine breite Tabelle nicht die Chat-Blase auseinanderzieht. */
|
||||||
|
.chat-md-table { overflow-x: auto; margin: 0 0 0.5rem 0; }
|
||||||
|
.chat-md table { border-collapse: collapse; width: 100%; font-size: 0.875rem; }
|
||||||
|
.chat-md th, .chat-md td { border: 1px solid rgba(0,0,0,0.12); padding: 0.35rem 0.55rem; text-align: left; vertical-align: top; }
|
||||||
|
.chat-md th { background: rgba(0,0,0,0.04); font-weight: 600; }
|
||||||
|
.dark .chat-md th, .dark .chat-md td { border-color: rgba(255,255,255,0.15); }
|
||||||
|
.dark .chat-md th { background: rgba(255,255,255,0.06); }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body class="min-h-screen flex flex-col transition-colors duration-300 bg-gray-50 dark:bg-gray-900 text-gray-900 dark:text-gray-100" id="body">
|
||||||
|
<%- include('partials/header', { hideSettings: true }) %>
|
||||||
|
|
||||||
|
<main class="container mx-auto px-4 py-4">
|
||||||
|
<div class="flex items-center justify-between mb-3">
|
||||||
|
<h2 class="text-xl font-bold text-gray-800 dark:text-white">KI-Bewerbungs-Assistent</h2>
|
||||||
|
<button id="newThreadBtn"
|
||||||
|
class="px-3 py-1.5 rounded-md bg-blue-600 hover:bg-blue-700 text-white text-sm font-medium">
|
||||||
|
+ Neuer Chat
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-[240px_1fr] gap-3 min-h-[360px] md:h-[calc(100vh-180px)]">
|
||||||
|
<!-- Thread sidebar -->
|
||||||
|
<aside class="rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 overflow-hidden flex flex-col max-h-48 md:max-h-none">
|
||||||
|
<div class="px-3 py-2 text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400 border-b border-gray-200 dark:border-gray-700">Verläufe</div>
|
||||||
|
<ul id="threadList" class="overflow-y-auto flex-1 divide-y divide-gray-100 dark:divide-gray-700">
|
||||||
|
<% threads.forEach(function(t) { %>
|
||||||
|
<li data-thread-li="<%= t.id %>">
|
||||||
|
<div class="flex items-center group <%= (t.id === activeId) ? 'bg-blue-50 dark:bg-gray-700' : '' %>">
|
||||||
|
<a href="/chat?thread=<%= t.id %>"
|
||||||
|
data-thread="<%= t.id %>"
|
||||||
|
class="threadLink flex-1 min-w-0 px-3 py-2 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-700/40">
|
||||||
|
<div class="truncate font-medium"><%= t.titel || '(ohne Titel)' %></div>
|
||||||
|
<div class="text-[11px] text-gray-400"><%= new Date(t.updated_at).toLocaleDateString('de-DE') %></div>
|
||||||
|
</a>
|
||||||
|
<button type="button" data-rename-thread="<%= t.id %>"
|
||||||
|
class="threadRename shrink-0 p-1.5 rounded text-gray-400 hover:text-blue-600 hover:bg-blue-50 dark:hover:bg-blue-900/30 opacity-0 group-hover:opacity-100 focus:opacity-100"
|
||||||
|
title="Namen ändern" aria-label="Namen ändern">
|
||||||
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"></path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button type="button" data-delete-thread="<%= t.id %>"
|
||||||
|
class="threadDelete shrink-0 mr-2 p-1.5 rounded text-gray-400 hover:text-red-600 hover:bg-red-50 dark:hover:bg-red-900/30 opacity-0 group-hover:opacity-100 focus:opacity-100"
|
||||||
|
title="Verlauf löschen" aria-label="Verlauf löschen">
|
||||||
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6M1 7h22M9 7V4a1 1 0 011-1h4a1 1 0 011 1v3"></path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
<% }); %>
|
||||||
|
<% if (!threads.length) { %>
|
||||||
|
<li class="px-3 py-4 text-sm text-gray-400 text-center">Noch keine Verläufe.</li>
|
||||||
|
<% } %>
|
||||||
|
</ul>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<!-- Message area -->
|
||||||
|
<section class="rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 flex flex-col overflow-hidden h-[60vh] md:h-auto">
|
||||||
|
<div id="chatLog" class="flex-1 overflow-y-auto p-4 space-y-3 text-gray-800 dark:text-gray-100">
|
||||||
|
<% if (!messages.length) { %>
|
||||||
|
<div class="h-full flex items-center justify-center text-gray-400 text-sm px-6">
|
||||||
|
Stelle eine Frage zu deinen Bewerbungen.
|
||||||
|
</div>
|
||||||
|
<% } %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form id="chatForm" class="border-t border-gray-200 dark:border-gray-700 p-3 flex gap-2">
|
||||||
|
<textarea id="chatInput" rows="1" autocomplete="off"
|
||||||
|
class="flex-1 resize-none rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-900 px-3 py-2 text-sm text-gray-800 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
placeholder="Frage stellen … (Enter zum Senden, Shift+Enter = Zeilenumbruch)"></textarea>
|
||||||
|
<button type="submit"
|
||||||
|
class="px-4 py-2 rounded-md bg-blue-600 hover:bg-blue-700 text-white text-sm font-medium disabled:opacity-50"
|
||||||
|
id="sendBtn">Senden</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<script id="initialMessages" type="application/json"><%- JSON.stringify(messages.map(function(m){ return { role: m.role, content: m.content }; })).replace(/</g, '\\u003c') %></script>
|
||||||
|
<input type="hidden" id="activeThread" value="<%= activeId || '' %>">
|
||||||
|
|
||||||
|
<script>
|
||||||
|
window.__CHAT_BOOT__ = {
|
||||||
|
activeId: <%= activeId ? JSON.stringify(activeId) : 'null' %>,
|
||||||
|
needsThread: <%= activeId ? 'false' : 'true' %>
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
<script src="/js/chat.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
+496
-337
@@ -3,102 +3,81 @@
|
|||||||
<head>
|
<head>
|
||||||
<%- include('partials/head') %>
|
<%- include('partials/head') %>
|
||||||
</head>
|
</head>
|
||||||
<body class="min-h-screen transition-colors duration-300 bg-gray-50 dark:bg-gray-900 text-gray-900 dark:text-gray-100" id="body">
|
<body class="min-h-screen flex flex-col transition-colors duration-300 bg-gray-50 dark:bg-gray-900 text-gray-900 dark:text-gray-100" id="body">
|
||||||
<%- include('partials/header') %>
|
<%- include('partials/header') %>
|
||||||
|
|
||||||
<main class="container mx-auto px-4 py-8">
|
<main class="flex-1 container mx-auto px-4 py-8">
|
||||||
<!-- Search -->
|
<% var zeigeDashboard = !(typeof setupWizard !== 'undefined' && setupWizard && setupWizard.aktiv); %>
|
||||||
<div class="flex justify-center mb-8">
|
<% if (!zeigeDashboard) { %>
|
||||||
<div class="relative w-full max-w-xl">
|
<%- include('partials/einrichtung') %>
|
||||||
|
<% } %>
|
||||||
|
<!-- Dashboard: Markup bleibt immer im DOM, damit main.js seine Listener
|
||||||
|
binden kann (auch der "Bewerbung manuell anlegen"-Pfad des Wizards).
|
||||||
|
Bei aktivem Wizard wird der Block nur ausgeblendet. -->
|
||||||
|
<div id="dashboardWrap" class="<%= zeigeDashboard ? '' : 'hidden' %>">
|
||||||
|
<!-- Toolbar: search + actions -->
|
||||||
|
<div class="flex flex-col gap-3 mb-8 sm:flex-row sm:items-center">
|
||||||
|
<div class="relative flex-1">
|
||||||
<svg class="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400 dark:text-gray-500 pointer-events-none" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400 dark:text-gray-500 pointer-events-none" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"></path>
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"></path>
|
||||||
</svg>
|
</svg>
|
||||||
<input id="firmaSearch" type="text" placeholder="Firma suchen …" autocomplete="off"
|
<input id="firmaSearch" type="search" role="searchbox" autocomplete="off" spellcheck="false"
|
||||||
class="w-full pl-12 pr-10 py-3 rounded-full border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-800 dark:text-white shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition">
|
placeholder="Suchen: Firma, Stelle, Notizen, E-Mails …"
|
||||||
|
aria-label="Bewerbungen und E-Mails durchsuchen"
|
||||||
|
aria-controls="searchResults" aria-expanded="false"
|
||||||
|
class="w-full pl-12 pr-20 py-3 rounded-full border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-800 dark:text-white shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition">
|
||||||
|
<kbd id="firmaSearchHint"
|
||||||
|
class="absolute right-3 top-1/2 -translate-y-1/2 hidden sm:inline-block px-1.5 py-0.5 rounded border border-gray-300 dark:border-gray-600 text-[11px] font-sans text-gray-400 dark:text-gray-500 pointer-events-none">/</kbd>
|
||||||
|
<span id="firmaSearchSpinner"
|
||||||
|
class="hidden absolute right-10 top-1/2 -translate-y-1/2 w-4 h-4 rounded-full border-2 border-gray-300 dark:border-gray-600 border-t-blue-500 animate-spin"></span>
|
||||||
<button id="firmaSearchClear" type="button" aria-label="Suche zurücksetzen"
|
<button id="firmaSearchClear" type="button" aria-label="Suche zurücksetzen"
|
||||||
class="hidden absolute right-3 top-1/2 -translate-y-1/2 p-1 rounded-full text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors">
|
class="hidden absolute right-3 top-1/2 -translate-y-1/2 p-1 rounded-full text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors">
|
||||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path></svg>
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path></svg>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Filter Section -->
|
<div class="flex gap-2">
|
||||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6 mb-8">
|
|
||||||
<h2 class="text-lg font-semibold text-gray-800 dark:text-white mb-4">Filter</h2>
|
|
||||||
<form id="filterForm" class="flex flex-wrap gap-4 items-end">
|
|
||||||
<div class="flex-1 min-w-32">
|
|
||||||
<label for="filterMonth" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
|
||||||
Monat
|
|
||||||
</label>
|
|
||||||
<select
|
|
||||||
id="filterMonth"
|
|
||||||
name="month"
|
|
||||||
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-800 dark:text-white">
|
|
||||||
<option value="">-- Alle Monate --</option>
|
|
||||||
<% availableMonths.forEach(m => { %>
|
|
||||||
<option value="<%= m.month %>"><%= m.month %> - <%= m.year %></option>
|
|
||||||
<% }); %>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex-1 min-w-32">
|
|
||||||
<label for="filterYear" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
|
||||||
Jahr
|
|
||||||
</label>
|
|
||||||
<select
|
|
||||||
id="filterYear"
|
|
||||||
name="year"
|
|
||||||
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-800 dark:text-white">
|
|
||||||
<option value="">-- Alle Jahre --</option>
|
|
||||||
<% const years = [...new Set(availableMonths.map(m => m.year))].sort().reverse(); %>
|
|
||||||
<% years.forEach(y => { %>
|
|
||||||
<option value="<%= y %>"><%= y %></option>
|
|
||||||
<% }); %>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex space-x-2">
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
class="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-md transition-colors">
|
|
||||||
Filtern
|
|
||||||
</button>
|
|
||||||
<a
|
|
||||||
href="/"
|
|
||||||
class="px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-md text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors">
|
|
||||||
Zurücksetzen
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="ml-auto">
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
id="exportPdfBtn"
|
id="exportPdfBtn"
|
||||||
class="px-4 py-2 bg-green-600 hover:bg-green-700 text-white rounded-md transition-colors flex items-center space-x-2">
|
class="inline-flex items-center gap-2 rounded-full px-4 py-3 text-sm font-medium text-white shadow-sm transition-colors bg-emerald-600 hover:bg-emerald-700">
|
||||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path>
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 16a4 4 0 01-.88-7.9A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13v6m-3-3h6"></path>
|
||||||
</svg>
|
</svg>
|
||||||
<span>PDF Export</span>
|
<span>PDF Export</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
id="addApplicationBtn"
|
id="addApplicationBtn"
|
||||||
class="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-md transition-colors flex items-center space-x-2">
|
class="inline-flex items-center gap-2 rounded-full px-4 py-3 text-sm font-medium text-white shadow-sm transition-colors bg-blue-600 hover:bg-blue-700">
|
||||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6"></path>
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6"></path>
|
||||||
</svg>
|
</svg>
|
||||||
<span>Bewerbung hinzufügen</span>
|
<span>Bewerbung hinzufügen</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</div>
|
||||||
|
|
||||||
|
<!-- Suchergebnisse (Volltext über Bewerbungen + E-Mails, /api/suche) -->
|
||||||
|
<div id="searchPanel" class="hidden bg-white dark:bg-gray-800 rounded-lg shadow-md mb-8">
|
||||||
|
<div class="flex flex-wrap items-baseline justify-between gap-x-4 gap-y-1 px-6 py-3 border-b border-gray-200 dark:border-gray-700">
|
||||||
|
<p id="searchSummary" class="text-sm text-gray-600 dark:text-gray-300"></p>
|
||||||
|
<p class="text-xs text-gray-400 dark:text-gray-500">
|
||||||
|
Durchsucht Firma, Stelle, Notizen, Stellenbeschreibung und die E-Mail-Korrespondenz.
|
||||||
|
<span class="hidden sm:inline">Verfeinern: <code>firma:bosch</code>, <code>von:müller</code>, <code>"genauer wortlaut"</code>, <code>-zeitarbeit</code></span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<ul id="searchResults" role="listbox" aria-label="Suchergebnisse" class="divide-y divide-gray-100 dark:divide-gray-700/60"></ul>
|
||||||
|
<div id="searchEmpty" class="hidden px-6 py-12 text-center text-gray-500 dark:text-gray-400">
|
||||||
|
<p>Nichts gefunden für „<span id="searchEmptyTerm"></span>".</p>
|
||||||
|
<p class="mt-2 text-sm">Gesucht wird auch in E-Mails – prüfe die Schreibweise oder suche nach einem Teilwort.</p>
|
||||||
|
</div>
|
||||||
|
<div id="searchError" class="hidden px-6 py-8 text-center text-sm text-red-600 dark:text-red-400"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Upcoming appointments (from the application calendar) -->
|
<!-- Upcoming appointments (from the application calendar) -->
|
||||||
<% if (typeof kommendeTermine !== 'undefined' && kommendeTermine && kommendeTermine.length) { %>
|
<% if (typeof kommendeTermine !== 'undefined' && kommendeTermine && kommendeTermine.length) { %>
|
||||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6 mb-8">
|
<div id="termineCard" class="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6 mb-8">
|
||||||
<div class="flex items-center gap-2 mb-4">
|
<div class="flex items-center gap-2 mb-4">
|
||||||
<svg class="w-5 h-5 text-blue-600 dark:text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"></path></svg>
|
<svg class="w-5 h-5 text-blue-600 dark:text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"></path></svg>
|
||||||
<h2 class="text-lg font-semibold text-gray-800 dark:text-white">Anstehende Termine</h2>
|
<h2 class="text-lg font-semibold text-gray-800 dark:text-white">Anstehende Termine</h2>
|
||||||
@@ -130,89 +109,85 @@
|
|||||||
</div>
|
</div>
|
||||||
<% } %>
|
<% } %>
|
||||||
|
|
||||||
<!-- Statistics Section -->
|
<!-- Gemerkte Bewerbungen — direkt unter den anstehenden Terminen -->
|
||||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6 mb-8">
|
<%- include('partials/bewerbung-favoriten') %>
|
||||||
<h2 class="text-lg font-semibold text-gray-800 dark:text-white mb-6">Statistik</h2>
|
|
||||||
|
|
||||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
|
<!-- Applications -->
|
||||||
<!-- Total Applications -->
|
<style>
|
||||||
<div class="bg-blue-50 dark:bg-gray-700 rounded-lg p-4 text-center">
|
/* Shared column grid – used by the header labels and every application line */
|
||||||
<div class="text-3xl font-bold text-blue-600 dark:text-blue-400"><%= statistics.total %></div>
|
.app-grid {
|
||||||
<div class="text-gray-600 dark:text-gray-400 mt-2">Gesamtbewerbungen</div>
|
display: grid;
|
||||||
</div>
|
grid-template-columns: 1.25rem 5.5rem minmax(7rem, 1.6fr) minmax(8rem, 2fr) 5.5rem 8.5rem 5.5rem;
|
||||||
|
gap: 0 0.75rem;
|
||||||
<!-- By Application Type -->
|
align-items: center;
|
||||||
<div class="bg-green-50 dark:bg-gray-700 rounded-lg p-4">
|
}
|
||||||
<h3 class="text-sm font-medium text-gray-700 dark:text-gray-300 mb-3">Nach Bewerbungsart</h3>
|
/* Smooth height collapse via the grid-template-rows 0fr → 1fr trick */
|
||||||
<div class="space-y-2">
|
.collapsible {
|
||||||
<% if (statistics.byArt.length > 0) { %>
|
display: grid;
|
||||||
<% statistics.byArt.forEach(item => { %>
|
grid-template-rows: 0fr;
|
||||||
<div class="flex justify-between text-sm">
|
transition: grid-template-rows 0.32s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
<span class="text-gray-600 dark:text-gray-400"><%= item.art %></span>
|
}
|
||||||
<span class="font-medium text-gray-800 dark:text-white"><%= item.count %></span>
|
.collapsible > .collapsible-inner {
|
||||||
</div>
|
overflow: hidden;
|
||||||
<% }); %>
|
min-height: 0;
|
||||||
<% } else { %>
|
}
|
||||||
<p class="text-sm text-gray-500 dark:text-gray-400">Keine Daten verfügbar</p>
|
.collapsible.open {
|
||||||
<% } %>
|
grid-template-rows: 1fr;
|
||||||
</div>
|
}
|
||||||
</div>
|
.chevron {
|
||||||
|
transition: transform 0.25s ease;
|
||||||
<!-- By Status -->
|
}
|
||||||
<div class="bg-purple-50 dark:bg-gray-700 rounded-lg p-4">
|
.chevron.rotate {
|
||||||
<h3 class="text-sm font-medium text-gray-700 dark:text-gray-300 mb-3">Nach Status</h3>
|
transform: rotate(90deg);
|
||||||
<div class="space-y-2">
|
}
|
||||||
<% if (statistics.byStatus.length > 0) { %>
|
</style>
|
||||||
<% statistics.byStatus.forEach(item => { %>
|
|
||||||
<div class="flex justify-between text-sm">
|
|
||||||
<span class="text-gray-600 dark:text-gray-400"><%= item.status %></span>
|
|
||||||
<span class="font-medium text-gray-800 dark:text-white"><%= item.count %></span>
|
|
||||||
</div>
|
|
||||||
<% }); %>
|
|
||||||
<% } else { %>
|
|
||||||
<p class="text-sm text-gray-500 dark:text-gray-400">Keine Daten verfügbar</p>
|
|
||||||
<% } %>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Applications Table -->
|
|
||||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-md overflow-x-auto">
|
|
||||||
<table class="w-full min-w-[720px] table-fixed">
|
|
||||||
<colgroup>
|
|
||||||
<col class="w-24">
|
|
||||||
<col class="w-[22%]">
|
|
||||||
<col class="w-[28%]">
|
|
||||||
<col class="w-28">
|
|
||||||
<col class="w-36">
|
|
||||||
<col class="w-24">
|
|
||||||
</colgroup>
|
|
||||||
<thead class="bg-gray-50 dark:bg-gray-700">
|
|
||||||
<tr>
|
|
||||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">Datum</th>
|
|
||||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">Firma</th>
|
|
||||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">Stelle</th>
|
|
||||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">Art</th>
|
|
||||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">Status</th>
|
|
||||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">Aktionen</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<% if (applications.length > 0) { %>
|
|
||||||
<%
|
<%
|
||||||
// Status-Reihenfolge synchron zu public/js/main.js halten
|
// Status-Reihenfolge synchron zu public/js/main.js halten. Steht hier oben,
|
||||||
const statusOrder = ['Entwurf', 'Gesendet', 'Eingangsbestätigung', 'Vorstellungsgespräch',
|
// weil sowohl die Liste als auch die Aktivitäten weiter unten sie brauchen.
|
||||||
'Einstellung', 'Absage', 'Keine Rückmeldung'];
|
const statusOrder = ['Entwurf', 'Gesendet', 'Eingangsbestätigung',
|
||||||
|
'In Bearbeitung', 'Interessiert', 'Telefonat', 'Warten auf Rückmeldung', 'Warten auf meine Antwort',
|
||||||
|
'Vorstellungsgespräch', 'Vertragsverhandlung', 'Einstellung', 'Absage', 'Absage von meiner Seite', 'Keine Rückmeldung'];
|
||||||
const statusDot = {
|
const statusDot = {
|
||||||
'Entwurf': 'bg-purple-500',
|
'Entwurf': 'bg-purple-500',
|
||||||
'Gesendet': 'bg-indigo-500',
|
'Gesendet': 'bg-indigo-500',
|
||||||
'Eingangsbestätigung': 'bg-blue-500',
|
'Eingangsbestätigung': 'bg-blue-500',
|
||||||
|
'In Bearbeitung': 'bg-teal-500',
|
||||||
|
'Interessiert': 'bg-pink-500',
|
||||||
|
'Telefonat': 'bg-sky-500',
|
||||||
|
'Warten auf Rückmeldung': 'bg-orange-500',
|
||||||
|
'Warten auf meine Antwort': 'bg-fuchsia-500',
|
||||||
'Vorstellungsgespräch': 'bg-yellow-500',
|
'Vorstellungsgespräch': 'bg-yellow-500',
|
||||||
|
'Vertragsverhandlung': 'bg-lime-500',
|
||||||
'Einstellung': 'bg-green-500',
|
'Einstellung': 'bg-green-500',
|
||||||
'Absage': 'bg-red-500',
|
'Absage': 'bg-red-500',
|
||||||
|
'Absage von meiner Seite': 'bg-rose-500',
|
||||||
'Keine Rückmeldung': 'bg-gray-400',
|
'Keine Rückmeldung': 'bg-gray-400',
|
||||||
'Ohne Status': 'bg-gray-300'
|
'Ohne Status': 'bg-gray-300'
|
||||||
};
|
};
|
||||||
|
%>
|
||||||
|
<div id="listCard" class="bg-white dark:bg-gray-800 rounded-lg shadow-md overflow-x-auto">
|
||||||
|
<!-- Kopf der Karte: Gesamtzahl der Bewerbungen. Bewusst außerhalb des
|
||||||
|
md:min-w-Rahmens unten, damit er auf dem Handy nicht mitscrollt. -->
|
||||||
|
<div class="flex flex-wrap items-baseline justify-between gap-x-4 gap-y-1 px-6 py-4">
|
||||||
|
<h2 class="text-lg font-semibold text-gray-800 dark:text-white">Bewerbungen</h2>
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
<span class="font-semibold text-gray-800 dark:text-gray-100"><%= statistics.total %></span>
|
||||||
|
<%= statistics.total === 1 ? 'Bewerbung' : 'Bewerbungen' %> gesamt
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="md:min-w-[760px]">
|
||||||
|
<!-- Column labels (desktop only — on phones the rows become stacked cards) -->
|
||||||
|
<div class="app-head app-grid px-6 py-3 bg-gray-50 dark:bg-gray-700 text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
|
||||||
|
<span></span>
|
||||||
|
<span>Datum</span>
|
||||||
|
<span>Firma</span>
|
||||||
|
<span>Stelle</span>
|
||||||
|
<span>Art</span>
|
||||||
|
<span>Status</span>
|
||||||
|
<span>Aktionen</span>
|
||||||
|
</div>
|
||||||
|
<% if (applications.length > 0) { %>
|
||||||
|
<%
|
||||||
const groups = {};
|
const groups = {};
|
||||||
applications.forEach(a => {
|
applications.forEach(a => {
|
||||||
const key = (a.status && a.status.trim()) ? a.status.trim() : 'Ohne Status';
|
const key = (a.status && a.status.trim()) ? a.status.trim() : 'Ohne Status';
|
||||||
@@ -223,261 +198,436 @@
|
|||||||
...Object.keys(groups).filter(k => !statusOrder.includes(k))
|
...Object.keys(groups).filter(k => !statusOrder.includes(k))
|
||||||
];
|
];
|
||||||
%>
|
%>
|
||||||
|
<div id="appList">
|
||||||
<% orderedKeys.forEach(statusKey => { %>
|
<% orderedKeys.forEach(statusKey => { %>
|
||||||
<tbody class="status-group bg-gray-100 dark:bg-gray-900/40 border-b border-gray-200 dark:border-gray-700">
|
<section class="status-group border-t border-gray-200 dark:border-gray-700">
|
||||||
<tr>
|
<!-- Klappbarer Status-Kopf (Gesendet, Eingangsbestätigung, …) -->
|
||||||
<td colspan="6" class="px-6 py-3">
|
<button type="button" class="group-toggle w-full flex items-center gap-3 px-6 py-3 bg-gray-100 dark:bg-gray-900/40 hover:bg-gray-200/70 dark:hover:bg-gray-900/60 transition-colors text-left">
|
||||||
<div class="flex items-center gap-3">
|
<svg class="chevron w-4 h-4 flex-shrink-0 text-gray-400 dark:text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<span class="inline-block w-2.5 h-2.5 rounded-full <%= statusDot[statusKey] || 'bg-gray-300' %>"></span>
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"></path>
|
||||||
|
</svg>
|
||||||
|
<span class="inline-block w-2.5 h-2.5 rounded-full flex-shrink-0 <%= statusDot[statusKey] || 'bg-gray-300' %>"></span>
|
||||||
<span class="text-sm font-semibold uppercase tracking-wider text-gray-700 dark:text-gray-200"><%= statusKey %></span>
|
<span class="text-sm font-semibold uppercase tracking-wider text-gray-700 dark:text-gray-200"><%= statusKey %></span>
|
||||||
<span data-group-count class="text-xs font-medium px-2 py-0.5 rounded-full bg-white dark:bg-gray-700 text-gray-600 dark:text-gray-300"><%= groups[statusKey].length %></span>
|
<span data-group-count class="text-xs font-medium px-2 py-0.5 rounded-full bg-white dark:bg-gray-700 text-gray-600 dark:text-gray-300"><%= groups[statusKey].length %></span>
|
||||||
</div>
|
</button>
|
||||||
</td>
|
<div class="group-body collapsible">
|
||||||
</tr>
|
<div class="collapsible-inner">
|
||||||
</tbody>
|
|
||||||
<% groups[statusKey].forEach(app => { %>
|
<% groups[statusKey].forEach(app => { %>
|
||||||
<%
|
<div class="app-row border-t border-gray-100 dark:border-gray-700/60">
|
||||||
const hatNotizen = app.notizen && app.notizen.trim().length > 0;
|
<!-- Eine Zeile pro Bewerbung – Klick öffnet die ganze Bewerbung -->
|
||||||
const hatInterne = app.interne_notizen && app.interne_notizen.trim().length > 0;
|
<div class="app-line app-grid px-6 py-3 text-sm transition-colors cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-700/50" data-bewerbung="<%= app.id %>">
|
||||||
const hatVerlauf = app.verlauf && app.verlauf.length > 0;
|
<span class="flex items-center justify-center"></span>
|
||||||
const hatDetails = hatNotizen || hatInterne || hatVerlauf;
|
<span class="whitespace-nowrap text-gray-900 dark:text-white"><%= new Date(app.datum).toLocaleDateString('de-DE') %></span>
|
||||||
const padB = hatDetails ? 'pb-2' : 'pb-4';
|
<span class="min-w-0 truncate font-medium text-gray-900 dark:text-white" title="<%= app.firma %>"><%= app.firma %></span>
|
||||||
%>
|
<span class="min-w-0">
|
||||||
<tbody class="app-row bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700" data-search="<%= ((app.firma || '') + ' ' + (app.stelle || '')).toLowerCase().replace(/"/g, '') %>">
|
<span class="block truncate text-gray-900 dark:text-white" title="<%= app.stelle %>"><%= app.stelle %></span>
|
||||||
<tr class="hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors">
|
<% if (app.labelsArr && app.labelsArr.length) { %>
|
||||||
<td class="px-6 pt-4 <%= padB %> whitespace-nowrap text-sm text-gray-900 dark:text-white">
|
<span class="flex flex-wrap gap-1 mt-1"><%- include('partials/labelChips', { labels: app.labelsArr }) %></span>
|
||||||
<%= new Date(app.datum).toLocaleDateString('de-DE') %>
|
<% } %>
|
||||||
</td>
|
</span>
|
||||||
<td class="px-6 pt-4 <%= padB %> align-top text-sm font-medium text-gray-900 dark:text-white break-words">
|
<span class="min-w-0 truncate text-gray-500 dark:text-gray-400"><%= app.art || '-' %></span>
|
||||||
<%= app.firma %>
|
<span class="min-w-0">
|
||||||
</td>
|
<span class="inline-block max-w-full truncate align-middle px-2 py-1 rounded-full text-xs font-medium
|
||||||
<td class="px-6 pt-4 <%= padB %> align-top text-sm text-gray-900 dark:text-white break-words">
|
|
||||||
<%= app.stelle %>
|
|
||||||
</td>
|
|
||||||
<td class="px-6 pt-4 <%= padB %> whitespace-nowrap text-sm text-gray-500 dark:text-gray-400">
|
|
||||||
<%= app.art || '-' %>
|
|
||||||
</td>
|
|
||||||
<td class="px-6 pt-4 <%= padB %> whitespace-nowrap text-sm">
|
|
||||||
<span class="px-2 py-1 rounded-full text-xs font-medium
|
|
||||||
<%= app.status === 'Einstellung' ? 'bg-green-100 text-green-800 dark:bg-green-800 dark:text-green-100' :
|
<%= app.status === 'Einstellung' ? 'bg-green-100 text-green-800 dark:bg-green-800 dark:text-green-100' :
|
||||||
app.status === 'Absage' ? 'bg-red-100 text-red-800 dark:bg-red-800 dark:text-red-100' :
|
app.status === 'Absage' ? 'bg-red-100 text-red-800 dark:bg-red-800 dark:text-red-100' :
|
||||||
|
app.status === 'Absage von meiner Seite' ? 'bg-rose-100 text-rose-800 dark:bg-rose-800 dark:text-rose-100' :
|
||||||
app.status === 'Vorstellungsgespräch' ? 'bg-yellow-100 text-yellow-800 dark:bg-yellow-800 dark:text-yellow-100' :
|
app.status === 'Vorstellungsgespräch' ? 'bg-yellow-100 text-yellow-800 dark:bg-yellow-800 dark:text-yellow-100' :
|
||||||
|
app.status === 'Vertragsverhandlung' ? 'bg-lime-100 text-lime-800 dark:bg-lime-800 dark:text-lime-100' :
|
||||||
|
app.status === 'In Bearbeitung' ? 'bg-teal-100 text-teal-800 dark:bg-teal-800 dark:text-teal-100' :
|
||||||
|
app.status === 'Interessiert' ? 'bg-pink-100 text-pink-800 dark:bg-pink-800 dark:text-pink-100' :
|
||||||
|
app.status === 'Telefonat' ? 'bg-sky-100 text-sky-800 dark:bg-sky-800 dark:text-sky-100' :
|
||||||
|
app.status === 'Warten auf Rückmeldung' ? 'bg-orange-100 text-orange-800 dark:bg-orange-800 dark:text-orange-100' :
|
||||||
|
app.status === 'Warten auf meine Antwort' ? 'bg-fuchsia-100 text-fuchsia-800 dark:bg-fuchsia-800 dark:text-fuchsia-100' :
|
||||||
app.status === 'Entwurf' ? 'bg-purple-100 text-purple-800 dark:bg-purple-800 dark:text-purple-100' :
|
app.status === 'Entwurf' ? 'bg-purple-100 text-purple-800 dark:bg-purple-800 dark:text-purple-100' :
|
||||||
'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300' %>">
|
'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300' %>">
|
||||||
<%= app.status || '-' %>
|
<%= app.status || '-' %>
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</span>
|
||||||
<td class="px-6 pt-4 <%= padB %> whitespace-nowrap text-sm align-top">
|
<span class="app-actions flex items-center gap-3">
|
||||||
|
<%- include('partials/favorit-button', { j: app, endpoint: '/api/bewerbungen/' + app.id + '/favorit', compact: true }) %>
|
||||||
<a href="/bewerbung/<%= app.id %>"
|
<a href="/bewerbung/<%= app.id %>"
|
||||||
class="inline-block align-middle text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300 mr-3"
|
class="text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300"
|
||||||
aria-label="Bearbeiten">
|
aria-label="Bearbeiten">
|
||||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"></path>
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"></path>
|
||||||
</svg>
|
</svg>
|
||||||
</a>
|
</a>
|
||||||
<button
|
<button type="button"
|
||||||
class="delete-btn align-middle text-red-600 hover:text-red-800 dark:text-red-400 dark:hover:text-red-300"
|
class="delete-btn text-red-600 hover:text-red-800 dark:text-red-400 dark:hover:text-red-300"
|
||||||
data-id="<%= app.id %>">
|
data-id="<%= app.id %>" aria-label="Löschen">
|
||||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"></path>
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"></path>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
</td>
|
</span>
|
||||||
</tr>
|
</div>
|
||||||
<% if (hatDetails) { %>
|
|
||||||
<tr class="hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors">
|
|
||||||
<td colspan="6" class="px-6 pb-5 pt-0 space-y-3">
|
|
||||||
<% if (hatVerlauf) { %>
|
|
||||||
<div class="rounded-lg border-l-4 border-indigo-400 dark:border-indigo-500 bg-indigo-50/60 dark:bg-gray-700/40 px-4 py-3">
|
|
||||||
<div class="flex items-center gap-2 mb-2">
|
|
||||||
<svg class="w-4 h-4 text-indigo-500 dark:text-indigo-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"></path>
|
|
||||||
</svg>
|
|
||||||
<span class="text-xs font-semibold uppercase tracking-wider text-indigo-600 dark:text-indigo-400">Status-Verlauf</span>
|
|
||||||
</div>
|
</div>
|
||||||
<ol class="space-y-1.5">
|
|
||||||
<% app.verlauf.forEach(v => { %>
|
|
||||||
<li class="flex flex-wrap gap-x-2 text-sm">
|
|
||||||
<span class="text-gray-500 dark:text-gray-400 whitespace-nowrap"><%= new Date(v.datum).toLocaleDateString('de-DE') %></span>
|
|
||||||
<span class="font-semibold text-gray-800 dark:text-gray-100"><%= v.status %></span>
|
|
||||||
<% if (v.kommentar && v.kommentar.trim()) { %>
|
|
||||||
<span class="text-gray-600 dark:text-gray-300 whitespace-pre-wrap break-words">– <%= v.kommentar %></span>
|
|
||||||
<% } %>
|
|
||||||
</li>
|
|
||||||
<% }); %>
|
<% }); %>
|
||||||
</ol>
|
|
||||||
</div>
|
</div>
|
||||||
<% } %>
|
|
||||||
<% if (hatNotizen) { %>
|
|
||||||
<div class="rounded-lg border-l-4 border-blue-400 dark:border-blue-500 bg-blue-50/70 dark:bg-gray-700/40 px-4 py-3">
|
|
||||||
<div class="flex items-center gap-2 mb-2">
|
|
||||||
<svg class="w-4 h-4 text-blue-500 dark:text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path>
|
|
||||||
</svg>
|
|
||||||
<span class="text-xs font-semibold uppercase tracking-wider text-blue-600 dark:text-blue-400">Notizen</span>
|
|
||||||
</div>
|
</div>
|
||||||
<p class="whitespace-pre-wrap break-words text-base leading-relaxed text-gray-800 dark:text-gray-200"><%= app.notizen %></p>
|
</section>
|
||||||
</div>
|
|
||||||
<% } %>
|
|
||||||
<% if (hatInterne) { %>
|
|
||||||
<div class="rounded-lg border-l-4 border-amber-400 dark:border-amber-500 bg-amber-50/70 dark:bg-gray-700/40 px-4 py-3">
|
|
||||||
<div class="flex items-center gap-2 mb-2">
|
|
||||||
<svg class="w-4 h-4 text-amber-500 dark:text-amber-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"></path>
|
|
||||||
</svg>
|
|
||||||
<span class="text-xs font-semibold uppercase tracking-wider text-amber-600 dark:text-amber-400">Interne Notizen · nicht im PDF</span>
|
|
||||||
</div>
|
|
||||||
<p class="whitespace-pre-wrap break-words text-base leading-relaxed text-gray-800 dark:text-gray-200"><%= app.interne_notizen %></p>
|
|
||||||
</div>
|
|
||||||
<% } %>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<% } %>
|
|
||||||
</tbody>
|
|
||||||
<% }); %>
|
<% }); %>
|
||||||
<% }); %>
|
</div>
|
||||||
<tbody id="searchNoResults" class="bg-white dark:bg-gray-800" style="display:none">
|
|
||||||
<tr>
|
|
||||||
<td colspan="6" class="px-6 py-12 text-center text-gray-500 dark:text-gray-400">
|
|
||||||
<p>Keine Firma gefunden für „<span id="searchNoResultsTerm"></span>".</p>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
<% } else { %>
|
<% } else { %>
|
||||||
<tbody class="bg-white dark:bg-gray-800">
|
<div class="px-6 py-12 text-center text-gray-500 dark:text-gray-400 border-t border-gray-200 dark:border-gray-700">
|
||||||
<tr>
|
|
||||||
<td colspan="6" class="px-6 py-12 text-center text-gray-500 dark:text-gray-400">
|
|
||||||
<p>Keine Bewerbungen gefunden.</p>
|
<p>Keine Bewerbungen gefunden.</p>
|
||||||
<p class="mt-2 text-sm">Klicken Sie auf "Bewerbung hinzufügen", um Ihre erste Bewerbung einzutragen.</p>
|
<p class="mt-2 text-sm">Klicken Sie auf "Bewerbung hinzufügen", um Ihre erste Bewerbung einzutragen.</p>
|
||||||
</td>
|
</div>
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
<% } %>
|
<% } %>
|
||||||
</table>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Letzte Aktivitäten: die jüngsten Statusänderungen quer über alle
|
||||||
|
Bewerbungen — der schnellste Blick darauf, was zuletzt passiert ist,
|
||||||
|
ohne jede Gruppe aufklappen zu müssen. -->
|
||||||
|
<section id="aktivitaetCard" class="bg-white dark:bg-gray-800 rounded-lg shadow-md mt-8">
|
||||||
|
<div class="flex flex-wrap items-baseline justify-between gap-x-4 gap-y-1 px-6 py-4 border-b border-gray-200 dark:border-gray-700">
|
||||||
|
<h2 class="text-lg font-semibold text-gray-800 dark:text-white">Letzte Aktivitäten</h2>
|
||||||
|
<a href="/aktivitaeten" class="text-sm text-blue-600 dark:text-blue-400 hover:underline">Alle</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<% if (aktivitaet && aktivitaet.length) { %>
|
||||||
|
<ul class="divide-y divide-gray-100 dark:divide-gray-700/60">
|
||||||
|
<% aktivitaet.forEach(function (a) {
|
||||||
|
// E-Mail-Einträge bekommen einen eigenen Farbpunkt; bei Status-
|
||||||
|
// änderungen greift die Status-Farbpalette.
|
||||||
|
var dotCls = a.typ === 'email'
|
||||||
|
? (a.direction === 'out' ? 'bg-sky-500' : 'bg-blue-500')
|
||||||
|
: (statusDot[a.status] || 'bg-gray-300');
|
||||||
|
// Kopzeile: verknüpfte Bewerbung -> Firma·Stelle, sonst bei E-Mail
|
||||||
|
// der Absender/Empfänger, sonst Hinweis auf gelöschte Bewerbung.
|
||||||
|
var titel = '', titelSub = '';
|
||||||
|
if (a.bewerbung_id) {
|
||||||
|
titel = a.firma || '(ohne Firma)';
|
||||||
|
titelSub = a.stelle || '';
|
||||||
|
} else if (a.typ === 'email') {
|
||||||
|
titel = a.direction === 'out' ? ('An: ' + (a.to_addr || '?')) : (a.from_addr || a.subject || 'E-Mail');
|
||||||
|
titelSub = '';
|
||||||
|
} else {
|
||||||
|
titel = 'Gelöschte Bewerbung';
|
||||||
|
}
|
||||||
|
%>
|
||||||
|
<li>
|
||||||
|
<!-- Eintrag zu einer noch existierenden Bewerbung ist ein Link, einer zu
|
||||||
|
einer gelöschten oder einer nicht zugeordneten E-Mail bleibt ein Block.
|
||||||
|
Bewusst zwei ausgeschriebene Zweige: den Tag-Namen samt href per <%%= %>
|
||||||
|
zusammenzusetzen escaped die Anführungszeichen und erzeugt Links wie
|
||||||
|
/%22/bewerbung/121%22. -->
|
||||||
|
<% if (a.bewerbung_id) { %>
|
||||||
|
<a href="/bewerbung/<%= a.bewerbung_id %>" class="flex items-start gap-3 px-6 py-3 hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors">
|
||||||
|
<% } else { %>
|
||||||
|
<div class="flex items-start gap-3 px-6 py-3">
|
||||||
|
<% } %>
|
||||||
|
<span class="mt-1.5 inline-block w-2.5 h-2.5 rounded-full shrink-0 <%= dotCls %>"></span>
|
||||||
|
<span class="min-w-0 flex-1">
|
||||||
|
<span class="flex flex-wrap items-baseline gap-x-2 gap-y-0.5">
|
||||||
|
<span class="text-sm font-medium <%= a.bewerbung_id ? 'text-gray-900 dark:text-white' : 'text-gray-500 dark:text-gray-400' %> break-words"><%= titel %></span>
|
||||||
|
<% if (titelSub) { %>
|
||||||
|
<span class="text-sm text-gray-500 dark:text-gray-400 break-words">· <%= titelSub %></span>
|
||||||
|
<% } %>
|
||||||
|
</span>
|
||||||
|
<!-- Status, Datum und Kommentar in einer Zeile: ein rechtsbündiges
|
||||||
|
Datum würde auf dem Handy in eine eigene Zeile umbrechen und
|
||||||
|
dort ohne Bezug stehen. -->
|
||||||
|
<span class="mt-0.5 flex flex-wrap items-baseline gap-x-2 text-sm">
|
||||||
|
<span class="font-medium text-gray-700 dark:text-gray-200"><%= a.status %></span>
|
||||||
|
<% if (a.datum) { %>
|
||||||
|
<span class="text-gray-400 dark:text-gray-500 whitespace-nowrap">· <%= new Date(a.datum).toLocaleDateString('de-DE') %></span>
|
||||||
|
<% } %>
|
||||||
|
<% if (a.kommentar && a.kommentar.trim()) { %>
|
||||||
|
<span class="text-gray-500 dark:text-gray-400 break-words">· <%= a.kommentar %></span>
|
||||||
|
<% } %>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<% if (a.bewerbung_id) { %></a><% } else { %></div><% } %>
|
||||||
|
</li>
|
||||||
|
<% }); %>
|
||||||
|
</ul>
|
||||||
|
<% } else { %>
|
||||||
|
<div class="px-6 py-10 text-center text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
Noch keine Aktivitäten — Statusänderungen und E-Mails erscheinen hier.
|
||||||
|
</div>
|
||||||
|
<% } %>
|
||||||
|
</section>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
// Live company search — filters the applications table by firma/stelle.
|
|
||||||
(function () {
|
(function () {
|
||||||
|
function qsa(sel, ctx) {
|
||||||
|
return Array.prototype.slice.call((ctx || document).querySelectorAll(sel));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Öffnet/schließt einen klappbaren Bereich und dreht sein Chevron mit.
|
||||||
|
function setOpen(collapsible, chevron, open) {
|
||||||
|
if (!collapsible) return;
|
||||||
|
collapsible.classList.toggle('open', open);
|
||||||
|
if (chevron) chevron.classList.toggle('rotate', open);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Status-Gruppen (Gesendet, Eingangsbestätigung …) auf-/zuklappen.
|
||||||
|
qsa('.group-toggle').forEach(function (btn) {
|
||||||
|
btn.addEventListener('click', function () {
|
||||||
|
var body = btn.parentElement.querySelector('.group-body');
|
||||||
|
var chev = btn.querySelector('.chevron');
|
||||||
|
setOpen(body, chev, !body.classList.contains('open'));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Klick auf eine Bewerbungs-Zeile öffnet die ganze Bewerbung
|
||||||
|
// (/bewerbung/:id). Klicks auf die Aktions-Buttons (Merken,
|
||||||
|
// Bearbeiten, Löschen) werden nicht abgefangen.
|
||||||
|
qsa('.app-line[data-bewerbung]').forEach(function (line) {
|
||||||
|
line.addEventListener('click', function (e) {
|
||||||
|
if (e.target.closest('.app-actions')) return;
|
||||||
|
window.location.href = '/bewerbung/' + line.getAttribute('data-bewerbung');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- Volltextsuche (/api/suche): Bewerbungen + E-Mail-Korrespondenz ----
|
||||||
|
// Die Liste unten bleibt der Normalzustand; sobald gesucht wird, tritt an
|
||||||
|
// ihre Stelle die gerankte Trefferliste mit Fundstellen.
|
||||||
var input = document.getElementById('firmaSearch');
|
var input = document.getElementById('firmaSearch');
|
||||||
if (!input) return;
|
if (!input) return;
|
||||||
var clear = document.getElementById('firmaSearchClear');
|
var clear = document.getElementById('firmaSearchClear');
|
||||||
var appRows = Array.prototype.slice.call(document.querySelectorAll('tbody.app-row'));
|
var hint = document.getElementById('firmaSearchHint');
|
||||||
var groupHeaders = Array.prototype.slice.call(document.querySelectorAll('tbody.status-group'));
|
var spinner = document.getElementById('firmaSearchSpinner');
|
||||||
var noResults = document.getElementById('searchNoResults');
|
var panel = document.getElementById('searchPanel');
|
||||||
var noResultsTerm = document.getElementById('searchNoResultsTerm');
|
var results = document.getElementById('searchResults');
|
||||||
|
var summary = document.getElementById('searchSummary');
|
||||||
|
var empty = document.getElementById('searchEmpty');
|
||||||
|
var emptyTerm = document.getElementById('searchEmptyTerm');
|
||||||
|
var fehler = document.getElementById('searchError');
|
||||||
|
var listCard = document.getElementById('listCard');
|
||||||
|
var termineCard = document.getElementById('termineCard');
|
||||||
|
var bewerbungFavoritenCard = document.getElementById('bewerbungFavoritenCard');
|
||||||
|
var aktivitaetCard = document.getElementById('aktivitaetCard');
|
||||||
|
|
||||||
function filter() {
|
var MIN = 2; // kürzere Eingaben liefern nur Rauschen
|
||||||
var q = input.value.trim().toLowerCase();
|
var MARK_START = '\u0002'; // Fundstellen-Marker aus lib/suche.js
|
||||||
if (clear) clear.classList.toggle('hidden', !q);
|
var MARK_END = '\u0003';
|
||||||
|
var timer = null;
|
||||||
|
var laufend = null; // AbortController der offenen Anfrage
|
||||||
|
var aktiv = -1; // per Tastatur ausgewählter Treffer
|
||||||
|
|
||||||
appRows.forEach(function (tb) {
|
function escapeHtml(s) {
|
||||||
var match = !q || (tb.dataset.search || '').indexOf(q) !== -1;
|
return String(s == null ? '' : s)
|
||||||
tb.style.display = match ? '' : 'none';
|
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"').replace(/'/g, ''');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Snippet -> HTML: erst escapen (Mailtext ist Fremdinhalt), dann die
|
||||||
|
// Marker durch <mark> ersetzen. Andersherum wäre es eine XSS-Lücke.
|
||||||
|
function snippetHtml(s) {
|
||||||
|
return escapeHtml(s)
|
||||||
|
.split(escapeHtml(MARK_START)).join('<mark class="bg-yellow-200 dark:bg-yellow-500/40 dark:text-white rounded-sm px-0.5">')
|
||||||
|
.split(escapeHtml(MARK_END)).join('</mark>');
|
||||||
|
}
|
||||||
|
|
||||||
|
function datumDe(iso) {
|
||||||
|
if (!iso) return '';
|
||||||
|
var d = new Date(iso);
|
||||||
|
return isNaN(d) ? '' : d.toLocaleDateString('de-DE');
|
||||||
|
}
|
||||||
|
|
||||||
|
// "Frau Müller <m.mueller@bosch.de>" -> "Frau Müller"
|
||||||
|
function absender(v) {
|
||||||
|
var s = String(v || '').trim();
|
||||||
|
var m = s.match(/^\s*"?([^"<]+?)"?\s*<[^>]+>\s*$/);
|
||||||
|
return (m ? m[1].trim() : s) || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Woher der Treffer stammt — die Herkunft ist bei einer Mailsuche die
|
||||||
|
// eigentliche Information ("steht in der Mail von Frau Müller").
|
||||||
|
function herkunft(f) {
|
||||||
|
if (f.quelle !== 'email' || !f.email) return f.feld_label || 'Bewerbung';
|
||||||
|
var von = absender(f.email.richtung === 'out' ? f.email.an : f.email.von);
|
||||||
|
var d = datumDe(f.email.datum);
|
||||||
|
return 'E-Mail' + (von ? (f.email.richtung === 'out' ? ' an ' : ' von ') + von : '') + (d ? ', ' + d : '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function trefferZeile(t, i) {
|
||||||
|
var b = t.bewerbung || {};
|
||||||
|
var li = document.createElement('li');
|
||||||
|
li.setAttribute('role', 'option');
|
||||||
|
li.dataset.index = String(i);
|
||||||
|
|
||||||
|
var a = document.createElement('a');
|
||||||
|
a.href = t.typ === 'email' && !t.bewerbung_id ? '/postfach' : '/bewerbung/' + t.bewerbung_id;
|
||||||
|
a.className = 'block px-6 py-4 hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors focus:outline-none focus:bg-gray-50 dark:focus:bg-gray-700/50';
|
||||||
|
|
||||||
|
var kopf = document.createElement('div');
|
||||||
|
kopf.className = 'flex flex-wrap items-baseline gap-x-2 gap-y-1';
|
||||||
|
var titel = document.createElement('span');
|
||||||
|
titel.className = 'font-medium text-gray-900 dark:text-white';
|
||||||
|
titel.textContent = t.bewerbung_id ? (b.firma || '(ohne Firma)') : 'E-Mail im Postfach';
|
||||||
|
kopf.appendChild(titel);
|
||||||
|
if (b.stelle) {
|
||||||
|
var stelle = document.createElement('span');
|
||||||
|
stelle.className = 'text-sm text-gray-600 dark:text-gray-300';
|
||||||
|
stelle.textContent = '· ' + b.stelle;
|
||||||
|
kopf.appendChild(stelle);
|
||||||
|
}
|
||||||
|
if (b.status) {
|
||||||
|
var st = document.createElement('span');
|
||||||
|
st.className = 'px-2 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-200';
|
||||||
|
st.textContent = b.status;
|
||||||
|
kopf.appendChild(st);
|
||||||
|
}
|
||||||
|
if (b.datum) {
|
||||||
|
var dt = document.createElement('span');
|
||||||
|
dt.className = 'ml-auto text-xs text-gray-400 dark:text-gray-500 whitespace-nowrap';
|
||||||
|
dt.textContent = datumDe(b.datum);
|
||||||
|
kopf.appendChild(dt);
|
||||||
|
}
|
||||||
|
a.appendChild(kopf);
|
||||||
|
|
||||||
|
(t.fundstellen || []).forEach(function (f) {
|
||||||
|
var zeile = document.createElement('p');
|
||||||
|
zeile.className = 'mt-1.5 text-sm text-gray-600 dark:text-gray-300 flex flex-wrap items-baseline gap-x-2';
|
||||||
|
var wo = document.createElement('span');
|
||||||
|
wo.className = 'shrink-0 text-xs uppercase tracking-wide ' +
|
||||||
|
(f.quelle === 'email' ? 'text-indigo-600 dark:text-indigo-400' : 'text-gray-400 dark:text-gray-500');
|
||||||
|
wo.textContent = herkunft(f);
|
||||||
|
var text = document.createElement('span');
|
||||||
|
text.className = 'min-w-0 break-words';
|
||||||
|
text.innerHTML = snippetHtml(f.snippet); // vorher escaped, siehe snippetHtml
|
||||||
|
zeile.appendChild(wo);
|
||||||
|
zeile.appendChild(text);
|
||||||
|
a.appendChild(zeile);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Refresh each status group: hide headers with no visible rows, update counts.
|
li.appendChild(a);
|
||||||
groupHeaders.forEach(function (gh) {
|
return li;
|
||||||
var visible = 0, node = gh.nextElementSibling;
|
|
||||||
while (node && !node.classList.contains('status-group')) {
|
|
||||||
if (node.classList.contains('app-row') && node.style.display !== 'none') visible++;
|
|
||||||
node = node.nextElementSibling;
|
|
||||||
}
|
}
|
||||||
gh.style.display = (visible === 0 && q) ? 'none' : '';
|
|
||||||
var countEl = gh.querySelector('[data-group-count]');
|
function markiere(i) {
|
||||||
if (countEl) countEl.textContent = visible;
|
var zeilen = qsa('li', results);
|
||||||
|
if (!zeilen.length) return;
|
||||||
|
aktiv = (i + zeilen.length) % zeilen.length;
|
||||||
|
zeilen.forEach(function (li, n) {
|
||||||
|
li.classList.toggle('bg-blue-50', n === aktiv);
|
||||||
|
li.classList.toggle('dark:bg-gray-700/60', n === aktiv);
|
||||||
|
li.setAttribute('aria-selected', n === aktiv ? 'true' : 'false');
|
||||||
});
|
});
|
||||||
|
zeilen[aktiv].scrollIntoView({ block: 'nearest' });
|
||||||
|
}
|
||||||
|
|
||||||
var anyVisible = appRows.some(function (tb) { return tb.style.display !== 'none'; });
|
function zeigeListe() {
|
||||||
if (noResults) {
|
panel.classList.add('hidden');
|
||||||
noResults.style.display = (!anyVisible && q) ? '' : 'none';
|
if (listCard) listCard.classList.remove('hidden');
|
||||||
if (noResultsTerm) noResultsTerm.textContent = input.value.trim();
|
if (termineCard) termineCard.classList.remove('hidden');
|
||||||
|
if (bewerbungFavoritenCard) bewerbungFavoritenCard.classList.remove('hidden');
|
||||||
|
if (aktivitaetCard) aktivitaetCard.classList.remove('hidden');
|
||||||
|
input.setAttribute('aria-expanded', 'false');
|
||||||
|
aktiv = -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function zeigeTreffer(daten) {
|
||||||
|
var treffer = daten.treffer || [];
|
||||||
|
results.innerHTML = '';
|
||||||
|
treffer.forEach(function (t, i) { results.appendChild(trefferZeile(t, i)); });
|
||||||
|
|
||||||
|
summary.textContent = treffer.length
|
||||||
|
? treffer.length + (treffer.length === 1 ? ' Treffer' : ' Treffer') + ' für „' + daten.q.trim() + '"'
|
||||||
|
: 'Keine Treffer';
|
||||||
|
empty.classList.toggle('hidden', treffer.length > 0);
|
||||||
|
if (emptyTerm) emptyTerm.textContent = daten.q.trim();
|
||||||
|
fehler.classList.add('hidden');
|
||||||
|
|
||||||
|
panel.classList.remove('hidden');
|
||||||
|
if (listCard) listCard.classList.add('hidden');
|
||||||
|
if (termineCard) termineCard.classList.add('hidden');
|
||||||
|
if (bewerbungFavoritenCard) bewerbungFavoritenCard.classList.add('hidden');
|
||||||
|
if (aktivitaetCard) aktivitaetCard.classList.add('hidden');
|
||||||
|
input.setAttribute('aria-expanded', 'true');
|
||||||
|
aktiv = -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function zeigeFehler(msg) {
|
||||||
|
results.innerHTML = '';
|
||||||
|
summary.textContent = '';
|
||||||
|
empty.classList.add('hidden');
|
||||||
|
fehler.textContent = msg;
|
||||||
|
fehler.classList.remove('hidden');
|
||||||
|
panel.classList.remove('hidden');
|
||||||
|
if (listCard) listCard.classList.add('hidden');
|
||||||
|
if (termineCard) termineCard.classList.add('hidden');
|
||||||
|
if (bewerbungFavoritenCard) bewerbungFavoritenCard.classList.add('hidden');
|
||||||
|
if (aktivitaetCard) aktivitaetCard.classList.add('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function suchen(q) {
|
||||||
|
if (laufend) laufend.abort(); // veraltete Antwort darf die neue nicht überholen
|
||||||
|
laufend = new AbortController();
|
||||||
|
spinner.classList.remove('hidden');
|
||||||
|
try {
|
||||||
|
var res = await fetch('/api/suche?q=' + encodeURIComponent(q), {
|
||||||
|
headers: { Accept: 'application/json' }, signal: laufend.signal,
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error('Die Suche ist fehlgeschlagen (' + res.status + ').');
|
||||||
|
zeigeTreffer(await res.json());
|
||||||
|
} catch (e) {
|
||||||
|
if (e.name === 'AbortError') return; // durch den nächsten Tastendruck ersetzt
|
||||||
|
zeigeFehler(e.message || 'Die Suche ist fehlgeschlagen.');
|
||||||
|
} finally {
|
||||||
|
spinner.classList.add('hidden');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
input.addEventListener('input', filter);
|
function getippt() {
|
||||||
if (clear) clear.addEventListener('click', function () {
|
var q = input.value.trim();
|
||||||
|
clear.classList.toggle('hidden', !q);
|
||||||
|
if (hint) hint.classList.toggle('hidden', !!q);
|
||||||
|
clearTimeout(timer);
|
||||||
|
if (laufend) { laufend.abort(); laufend = null; }
|
||||||
|
spinner.classList.add('hidden');
|
||||||
|
if (q.length < MIN) { zeigeListe(); return; }
|
||||||
|
// Entprellt: erst tippen lassen, dann eine Anfrage stellen.
|
||||||
|
timer = setTimeout(function () { suchen(q); }, 180);
|
||||||
|
}
|
||||||
|
|
||||||
|
input.addEventListener('input', getippt);
|
||||||
|
|
||||||
|
input.addEventListener('keydown', function (e) {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
input.value = '';
|
input.value = '';
|
||||||
filter();
|
getippt();
|
||||||
|
input.blur();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (panel.classList.contains('hidden')) return;
|
||||||
|
if (e.key === 'ArrowDown') { e.preventDefault(); markiere(aktiv + 1); }
|
||||||
|
else if (e.key === 'ArrowUp') { e.preventDefault(); markiere(aktiv - 1); }
|
||||||
|
else if (e.key === 'Enter') {
|
||||||
|
var zeilen = qsa('li', results);
|
||||||
|
var ziel = zeilen[aktiv >= 0 ? aktiv : 0];
|
||||||
|
var link = ziel && ziel.querySelector('a');
|
||||||
|
if (link) { e.preventDefault(); link.click(); }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
clear.addEventListener('click', function () {
|
||||||
|
input.value = '';
|
||||||
|
getippt();
|
||||||
input.focus();
|
input.focus();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// "/" springt ins Suchfeld — solange man nicht ohnehin schon tippt.
|
||||||
|
document.addEventListener('keydown', function (e) {
|
||||||
|
if (e.key !== '/' || e.metaKey || e.ctrlKey || e.altKey) return;
|
||||||
|
var t = e.target;
|
||||||
|
if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.tagName === 'SELECT' || t.isContentEditable)) return;
|
||||||
|
e.preventDefault();
|
||||||
|
input.focus();
|
||||||
|
input.select();
|
||||||
|
});
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
|
</div><!-- /dashboardWrap -->
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<%- include('partials/footer') %>
|
<%- include('partials/footer') %>
|
||||||
|
|
||||||
<!-- Settings Modal -->
|
<%- include('partials/favorit-script') %>
|
||||||
<div id="settingsModal" class="fixed inset-0 bg-black bg-opacity-50 z-50 hidden">
|
|
||||||
<div class="flex items-center justify-center min-h-screen p-4">
|
|
||||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-xl w-full max-w-md p-6">
|
|
||||||
<div class="flex justify-between items-center mb-6">
|
|
||||||
<h2 class="text-xl font-bold text-gray-800 dark:text-white">Benutzereinstellungen</h2>
|
|
||||||
<button id="closeSettingsModal" class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300">
|
|
||||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<form id="settingsForm" class="space-y-4">
|
|
||||||
<div>
|
|
||||||
<label for="userName" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
|
||||||
Vollständiger Name *
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
id="userName"
|
|
||||||
name="name"
|
|
||||||
required
|
|
||||||
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-800 dark:text-white"
|
|
||||||
placeholder="Max Mustermann"
|
|
||||||
value="<%= settings.name || '' %>">
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label for="userAddress" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
|
||||||
Adresse *
|
|
||||||
</label>
|
|
||||||
<textarea
|
|
||||||
id="userAddress"
|
|
||||||
name="adresse"
|
|
||||||
required
|
|
||||||
rows="2"
|
|
||||||
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-800 dark:text-white"
|
|
||||||
placeholder="Musterstraße 1, 12345 Musterstadt"><%= settings.adresse || '' %></textarea>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label for="customerNumber" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
|
||||||
Jobcenter Kundennummer
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
id="customerNumber"
|
|
||||||
name="kundennummer"
|
|
||||||
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-800 dark:text-white"
|
|
||||||
placeholder="z.B. 123456789"
|
|
||||||
value="<%= settings.kundennummer || '' %>">
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex justify-end space-x-3 pt-4">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
id="cancelSettings"
|
|
||||||
class="px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-md text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors">
|
|
||||||
Abbrechen
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
class="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-md transition-colors">
|
|
||||||
Speichern
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Add/Edit Application Modal -->
|
<!-- Add/Edit Application Modal -->
|
||||||
<div id="applicationModal" class="fixed inset-0 bg-black bg-opacity-50 z-50 hidden">
|
<div id="applicationModal" class="fixed inset-0 bg-black bg-opacity-50 z-50 hidden">
|
||||||
@@ -548,6 +698,15 @@
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||||
|
Labels
|
||||||
|
</label>
|
||||||
|
<div id="applicationLabels">
|
||||||
|
<%- include('partials/labelPicker', { labelOptions: labelOptions, selected: [] }) %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label for="applicationStatus" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
<label for="applicationStatus" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||||
Status (Anfangsstatus)
|
Status (Anfangsstatus)
|
||||||
@@ -679,7 +838,7 @@
|
|||||||
name="month"
|
name="month"
|
||||||
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-800 dark:text-white">
|
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-800 dark:text-white">
|
||||||
<option value="">-- Alle Monate --</option>
|
<option value="">-- Alle Monate --</option>
|
||||||
<% availableMonths.forEach(m => { %>
|
<% exportMonths.forEach(m => { %>
|
||||||
<option value="<%= m.year %>-<%= m.month %>"><%= m.month %> - <%= m.year %></option>
|
<option value="<%= m.year %>-<%= m.month %>"><%= m.month %> - <%= m.year %></option>
|
||||||
<% }); %>
|
<% }); %>
|
||||||
</select>
|
</select>
|
||||||
@@ -694,7 +853,7 @@
|
|||||||
name="year"
|
name="year"
|
||||||
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-800 dark:text-white">
|
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-800 dark:text-white">
|
||||||
<option value="">-- Alle Jahre --</option>
|
<option value="">-- Alle Jahre --</option>
|
||||||
<% const pdfYears = [...new Set(availableMonths.map(m => m.year))].sort().reverse(); %>
|
<% const pdfYears = [...new Set(exportMonths.map(m => m.year))].sort().reverse(); %>
|
||||||
<% pdfYears.forEach(y => { %>
|
<% pdfYears.forEach(y => { %>
|
||||||
<option value="<%= y %>"><%= y %></option>
|
<option value="<%= y %>"><%= y %></option>
|
||||||
<% }); %>
|
<% }); %>
|
||||||
|
|||||||
+48
-35
@@ -3,10 +3,10 @@
|
|||||||
<head>
|
<head>
|
||||||
<%- include('partials/head') %>
|
<%- include('partials/head') %>
|
||||||
</head>
|
</head>
|
||||||
<body class="min-h-screen transition-colors duration-300 bg-gray-50 dark:bg-gray-900 text-gray-900 dark:text-gray-100" id="body">
|
<body class="min-h-screen flex flex-col transition-colors duration-300 bg-gray-50 dark:bg-gray-900 text-gray-900 dark:text-gray-100" id="body">
|
||||||
<%- include('partials/header') %>
|
<%- include('partials/header') %>
|
||||||
|
|
||||||
<main class="container mx-auto px-4 py-8 max-w-5xl">
|
<main class="flex-1 container mx-auto px-4 py-8 max-w-5xl">
|
||||||
<!-- Back link -->
|
<!-- Back link -->
|
||||||
<a href="/" class="inline-flex items-center gap-2 text-sm text-blue-600 dark:text-blue-400 hover:underline mb-6">
|
<a href="/" class="inline-flex items-center gap-2 text-sm text-blue-600 dark:text-blue-400 hover:underline mb-6">
|
||||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
@@ -15,26 +15,35 @@
|
|||||||
Zurück zur Übersicht
|
Zurück zur Übersicht
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
<!-- Tabs: open offers vs. taken-over offers -->
|
<!-- Tabs: open offers · favorites · taken-over offers -->
|
||||||
<div class="flex flex-wrap items-center gap-2 mb-6">
|
<% var favAnsicht = (typeof ansicht !== 'undefined' && ansicht === 'favoriten'); %>
|
||||||
<a href="/jobangebote" class="px-3 py-1.5 rounded-md text-sm font-medium bg-blue-600 text-white shadow-sm">
|
<%- include('partials/jobangebote-tabs', {
|
||||||
Offen (<%= jobangebote.length %>)
|
aktiv: favAnsicht ? 'favoriten' : 'offen',
|
||||||
</a>
|
offenCount: offenCount,
|
||||||
<a href="/jobangebote/uebernommen" class="px-3 py-1.5 rounded-md text-sm font-medium bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-200 border border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors">
|
favoritenCount: favoritenCount,
|
||||||
Übernommen (<%= uebernommenCount %>)
|
uebernommenCount: uebernommenCount,
|
||||||
</a>
|
}) %>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6 mb-8">
|
<%- include('partials/favorit-script') %>
|
||||||
|
|
||||||
|
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6 mb-8" data-fav-card data-fav-view="<%= favAnsicht ? 'favoriten' : 'offen' %>">
|
||||||
<div class="flex flex-wrap items-start justify-between gap-3 mb-2">
|
<div class="flex flex-wrap items-start justify-between gap-3 mb-2">
|
||||||
<div>
|
<div>
|
||||||
<h2 class="text-lg font-semibold text-gray-800 dark:text-white">Jobangebote</h2>
|
<h2 class="text-lg font-semibold text-gray-800 dark:text-white"><%= favAnsicht ? 'Gemerkte Jobangebote' : 'Jobangebote' %></h2>
|
||||||
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||||
|
<% if (favAnsicht) { %>
|
||||||
|
Angebote, die du dir mit dem Stern gemerkt hast — unabhängig davon, ob sie
|
||||||
|
noch offen oder bereits übernommen sind. Ein erneuter Klick auf den Stern
|
||||||
|
entfernt das Angebot wieder aus dieser Liste. Die Merkliste gilt nur für
|
||||||
|
Jobangebote und ist von den gemerkten Bewerbungen getrennt.
|
||||||
|
<% } else { %>
|
||||||
Hier landen Stellen, die von Drittanbietersoftware über die
|
Hier landen Stellen, die von Drittanbietersoftware über die
|
||||||
<code class="px-1 py-0.5 rounded bg-gray-100 dark:bg-gray-700">POST /api/v1/joboffers</code>
|
<code class="px-1 py-0.5 rounded bg-gray-100 dark:bg-gray-700">POST /api/v1/joboffers</code>
|
||||||
REST-Schnittstelle eingespielt werden. Ein Angebot lässt sich per Klick
|
REST-Schnittstelle eingespielt werden. Ein Angebot lässt sich per Klick
|
||||||
als Bewerbung-Entwurf übernehmen. Hier siehst du nur <strong>offene</strong>
|
als Bewerbung-Entwurf übernehmen. Hier siehst du nur <strong>offene</strong>
|
||||||
Angebote — bereits übernommene findest du im Tab „Übernommen“.
|
Angebote, die du dir <em>nicht</em> gemerkt hast — bereits übernommene
|
||||||
|
findest du im Tab „Übernommen“, gemerkte im Tab „Gemerkt“.
|
||||||
|
<% } %>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="shrink-0 flex items-center gap-2">
|
<div class="shrink-0 flex items-center gap-2">
|
||||||
@@ -52,7 +61,9 @@
|
|||||||
<% if (jobangebote && jobangebote.length) { %>
|
<% if (jobangebote && jobangebote.length) { %>
|
||||||
<ul class="mt-4 space-y-4">
|
<ul class="mt-4 space-y-4">
|
||||||
<% jobangebote.forEach(function(j){ %>
|
<% jobangebote.forEach(function(j){ %>
|
||||||
<li class="rounded-lg border border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-700/40 px-4 py-4">
|
<li data-fav-item class="rounded-lg border px-4 py-4 <%= j.favorit
|
||||||
|
? 'border-amber-300 dark:border-amber-400/40 bg-amber-50/60 dark:bg-amber-500/5'
|
||||||
|
: 'border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-700/40' %>">
|
||||||
<div class="flex flex-wrap items-start justify-between gap-3">
|
<div class="flex flex-wrap items-start justify-between gap-3">
|
||||||
<div class="min-w-0 flex-1">
|
<div class="min-w-0 flex-1">
|
||||||
<div class="flex flex-wrap items-center gap-2 mb-1">
|
<div class="flex flex-wrap items-center gap-2 mb-1">
|
||||||
@@ -68,6 +79,9 @@
|
|||||||
</div>
|
</div>
|
||||||
<p class="text-base font-semibold text-gray-800 dark:text-gray-100 break-words"><%= j.firma %></p>
|
<p class="text-base font-semibold text-gray-800 dark:text-gray-100 break-words"><%= j.firma %></p>
|
||||||
<p class="text-sm text-gray-700 dark:text-gray-200 break-words"><%= j.stelle %></p>
|
<p class="text-sm text-gray-700 dark:text-gray-200 break-words"><%= j.stelle %></p>
|
||||||
|
<% if (j.labelsArr && j.labelsArr.length) { %>
|
||||||
|
<div class="flex flex-wrap gap-1 mt-1.5"><%- include('partials/labelChips', { labels: j.labelsArr }) %></div>
|
||||||
|
<% } %>
|
||||||
<% if (j.ort || j.gehalt) { %>
|
<% if (j.ort || j.gehalt) { %>
|
||||||
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||||
<% if (j.ort) { %><%= j.ort %><% } %>
|
<% if (j.ort) { %><%= j.ort %><% } %>
|
||||||
@@ -113,7 +127,7 @@
|
|||||||
<% } %>
|
<% } %>
|
||||||
<% } %>
|
<% } %>
|
||||||
<% if (j.quelle_url) { %>
|
<% if (j.quelle_url) { %>
|
||||||
<a href="<%= j.quelle_url %>" target="_blank" rel="noopener noreferrer"
|
<a href="<%= safeUrl(j.quelle_url) %>" target="_blank" rel="noopener noreferrer"
|
||||||
class="inline-flex items-center gap-1 text-xs text-blue-600 dark:text-blue-400 hover:underline mt-2 break-all">
|
class="inline-flex items-center gap-1 text-xs text-blue-600 dark:text-blue-400 hover:underline mt-2 break-all">
|
||||||
<svg class="w-3.5 h-3.5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"></path></svg>
|
<svg class="w-3.5 h-3.5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"></path></svg>
|
||||||
<span class="truncate"><%= j.quelle_url %></span>
|
<span class="truncate"><%= j.quelle_url %></span>
|
||||||
@@ -136,6 +150,7 @@
|
|||||||
|
|
||||||
<!-- Actions -->
|
<!-- Actions -->
|
||||||
<div class="shrink-0 flex flex-col gap-2 self-start">
|
<div class="shrink-0 flex flex-col gap-2 self-start">
|
||||||
|
<%- include('partials/favorit-button', { j: j }) %>
|
||||||
<% if (j.verknuepfte_bewerbung_id) { %>
|
<% if (j.verknuepfte_bewerbung_id) { %>
|
||||||
<a href="/bewerbung/<%= j.verknuepfte_bewerbung_id %>"
|
<a href="/bewerbung/<%= j.verknuepfte_bewerbung_id %>"
|
||||||
class="inline-flex items-center justify-center gap-1.5 px-3 py-1.5 text-xs bg-blue-600 hover:bg-blue-700 text-white rounded-md transition-colors">
|
class="inline-flex items-center justify-center gap-1.5 px-3 py-1.5 text-xs bg-blue-600 hover:bg-blue-700 text-white rounded-md transition-colors">
|
||||||
@@ -154,6 +169,7 @@
|
|||||||
<% } %>
|
<% } %>
|
||||||
<form action="/jobangebote/<%= j.id %>/delete" method="POST"
|
<form action="/jobangebote/<%= j.id %>/delete" method="POST"
|
||||||
onsubmit="return confirm('Jobangebot „<%= j.firma %> · <%= j.stelle %>“ löschen? Es kommt auf die Blacklist und wird nicht erneut eingespielt.');">
|
onsubmit="return confirm('Jobangebot „<%= j.firma %> · <%= j.stelle %>“ löschen? Es kommt auf die Blacklist und wird nicht erneut eingespielt.');">
|
||||||
|
<% if (favAnsicht) { %><input type="hidden" name="zurueck" value="favoriten"><% } %>
|
||||||
<button type="submit"
|
<button type="submit"
|
||||||
class="inline-flex items-center justify-center gap-1.5 px-3 py-1.5 text-xs border border-gray-300 dark:border-gray-600 text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-md transition-colors w-full">
|
class="inline-flex items-center justify-center gap-1.5 px-3 py-1.5 text-xs border border-gray-300 dark:border-gray-600 text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-md transition-colors w-full">
|
||||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"></path></svg>
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"></path></svg>
|
||||||
@@ -170,6 +186,7 @@
|
|||||||
Beschreibung / Details bearbeiten
|
Beschreibung / Details bearbeiten
|
||||||
</summary>
|
</summary>
|
||||||
<form action="/jobangebote/<%= j.id %>/bearbeiten" method="POST" class="mt-3 space-y-3">
|
<form action="/jobangebote/<%= j.id %>/bearbeiten" method="POST" class="mt-3 space-y-3">
|
||||||
|
<% if (favAnsicht) { %><input type="hidden" name="zurueck" value="favoriten"><% } %>
|
||||||
<div class="grid gap-3 sm:grid-cols-2">
|
<div class="grid gap-3 sm:grid-cols-2">
|
||||||
<label class="block text-xs">
|
<label class="block text-xs">
|
||||||
<span class="text-gray-500 dark:text-gray-400">Firma</span>
|
<span class="text-gray-500 dark:text-gray-400">Firma</span>
|
||||||
@@ -212,6 +229,10 @@
|
|||||||
<span class="text-gray-500 dark:text-gray-400">Stellenbeschreibung <span class="text-gray-400">(beliebig lang — wird bei „Übernehmen“ als Stellenbeschreibung für die KI mitgenommen)</span></span>
|
<span class="text-gray-500 dark:text-gray-400">Stellenbeschreibung <span class="text-gray-400">(beliebig lang — wird bei „Übernehmen“ als Stellenbeschreibung für die KI mitgenommen)</span></span>
|
||||||
<textarea name="beschreibung" rows="10" class="mt-1 w-full rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 px-3 py-2 text-sm leading-relaxed" placeholder="Vollständige Stellenbeschreibung hier einfügen …"><%= j.beschreibung || '' %></textarea>
|
<textarea name="beschreibung" rows="10" class="mt-1 w-full rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 px-3 py-2 text-sm leading-relaxed" placeholder="Vollständige Stellenbeschreibung hier einfügen …"><%= j.beschreibung || '' %></textarea>
|
||||||
</label>
|
</label>
|
||||||
|
<div class="text-xs">
|
||||||
|
<span class="text-gray-500 dark:text-gray-400">Labels</span>
|
||||||
|
<div class="mt-1"><%- include('partials/labelPicker', { labelOptions: labelOptions, selected: j.labelsArr }) %></div>
|
||||||
|
</div>
|
||||||
<button type="submit" class="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs bg-blue-600 hover:bg-blue-700 text-white rounded-md transition-colors">
|
<button type="submit" class="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs bg-blue-600 hover:bg-blue-700 text-white rounded-md transition-colors">
|
||||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path></svg>
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path></svg>
|
||||||
Speichern
|
Speichern
|
||||||
@@ -221,6 +242,17 @@
|
|||||||
</li>
|
</li>
|
||||||
<% }); %>
|
<% }); %>
|
||||||
</ul>
|
</ul>
|
||||||
|
<% } else if (favAnsicht) { %>
|
||||||
|
<div class="text-center py-12">
|
||||||
|
<svg class="w-14 h-14 mx-auto text-gray-300 dark:text-gray-600 mb-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M11.48 3.5l2.6 5.27 5.82.85-4.21 4.1.99 5.79-5.2-2.73-5.2 2.73.99-5.79-4.21-4.1 5.82-.85 2.6-5.27z"></path>
|
||||||
|
</svg>
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400 mb-1">Noch keine Jobangebote gemerkt.</p>
|
||||||
|
<p class="text-xs text-gray-400 dark:text-gray-500">
|
||||||
|
Klicke bei einem Angebot auf <strong>Merken</strong> — es erscheint dann hier
|
||||||
|
und wird aus der offenen Liste ausgeblendet.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
<% } else { %>
|
<% } else { %>
|
||||||
<div class="text-center py-12">
|
<div class="text-center py-12">
|
||||||
<svg class="w-14 h-14 mx-auto text-gray-300 dark:text-gray-600 mb-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-14 h-14 mx-auto text-gray-300 dark:text-gray-600 mb-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
@@ -273,25 +305,6 @@
|
|||||||
|
|
||||||
<script>
|
<script>
|
||||||
(function () {
|
(function () {
|
||||||
const root = document.documentElement;
|
|
||||||
const dm = localStorage.getItem('darkMode');
|
|
||||||
if (dm === 'enabled' || (!dm && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
|
|
||||||
root.classList.add('dark');
|
|
||||||
}
|
|
||||||
const toggle = document.getElementById('darkModeToggle');
|
|
||||||
const sun = document.getElementById('sunIcon');
|
|
||||||
const moon = document.getElementById('moonIcon');
|
|
||||||
function sync() {
|
|
||||||
const d = root.classList.contains('dark');
|
|
||||||
if (sun) sun.classList.toggle('hidden', d);
|
|
||||||
if (moon) moon.classList.toggle('hidden', !d);
|
|
||||||
}
|
|
||||||
sync();
|
|
||||||
if (toggle) toggle.addEventListener('click', () => {
|
|
||||||
root.classList.toggle('dark');
|
|
||||||
localStorage.setItem('darkMode', root.classList.contains('dark') ? 'enabled' : 'disabled');
|
|
||||||
sync();
|
|
||||||
});
|
|
||||||
const cy = document.getElementById('currentYear');
|
const cy = document.getElementById('currentYear');
|
||||||
if (cy) cy.textContent = new Date().getFullYear();
|
if (cy) cy.textContent = new Date().getFullYear();
|
||||||
|
|
||||||
|
|||||||
@@ -3,10 +3,10 @@
|
|||||||
<head>
|
<head>
|
||||||
<%- include('partials/head') %>
|
<%- include('partials/head') %>
|
||||||
</head>
|
</head>
|
||||||
<body class="min-h-screen transition-colors duration-300 bg-gray-50 dark:bg-gray-900 text-gray-900 dark:text-gray-100" id="body">
|
<body class="min-h-screen flex flex-col transition-colors duration-300 bg-gray-50 dark:bg-gray-900 text-gray-900 dark:text-gray-100" id="body">
|
||||||
<%- include('partials/header') %>
|
<%- include('partials/header') %>
|
||||||
|
|
||||||
<main class="container mx-auto px-4 py-8 max-w-5xl">
|
<main class="flex-1 container mx-auto px-4 py-8 max-w-5xl">
|
||||||
<!-- Back link -->
|
<!-- Back link -->
|
||||||
<a href="/jobangebote" class="inline-flex items-center gap-2 text-sm text-blue-600 dark:text-blue-400 hover:underline mb-6">
|
<a href="/jobangebote" class="inline-flex items-center gap-2 text-sm text-blue-600 dark:text-blue-400 hover:underline mb-6">
|
||||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
@@ -15,15 +15,15 @@
|
|||||||
Zurück zu den Jobangeboten
|
Zurück zu den Jobangeboten
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
<!-- Tabs: open offers vs. taken-over offers -->
|
<!-- Tabs: open offers · favorites · taken-over offers -->
|
||||||
<div class="flex flex-wrap items-center gap-2 mb-6">
|
<%- include('partials/jobangebote-tabs', {
|
||||||
<a href="/jobangebote" class="px-3 py-1.5 rounded-md text-sm font-medium bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-200 border border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors">
|
aktiv: 'uebernommen',
|
||||||
Offen (<%= offenCount %>)
|
offenCount: offenCount,
|
||||||
</a>
|
favoritenCount: favoritenCount,
|
||||||
<a href="/jobangebote/uebernommen" class="px-3 py-1.5 rounded-md text-sm font-medium bg-blue-600 text-white shadow-sm">
|
uebernommenCount: uebernommenCount,
|
||||||
Übernommen (<%= uebernommen.length %>)
|
}) %>
|
||||||
</a>
|
|
||||||
</div>
|
<%- include('partials/favorit-script') %>
|
||||||
|
|
||||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6 mb-8">
|
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6 mb-8">
|
||||||
<div class="flex flex-wrap items-start justify-between gap-3 mb-2">
|
<div class="flex flex-wrap items-start justify-between gap-3 mb-2">
|
||||||
@@ -43,7 +43,9 @@
|
|||||||
<% if (uebernommen && uebernommen.length) { %>
|
<% if (uebernommen && uebernommen.length) { %>
|
||||||
<ul class="mt-4 space-y-4">
|
<ul class="mt-4 space-y-4">
|
||||||
<% uebernommen.forEach(function(j){ %>
|
<% uebernommen.forEach(function(j){ %>
|
||||||
<li class="rounded-lg border border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-700/40 px-4 py-4">
|
<li class="rounded-lg border px-4 py-4 <%= j.favorit
|
||||||
|
? 'border-amber-300 dark:border-amber-400/40 bg-amber-50/60 dark:bg-amber-500/5'
|
||||||
|
: 'border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-700/40' %>">
|
||||||
<div class="flex flex-wrap items-start justify-between gap-3">
|
<div class="flex flex-wrap items-start justify-between gap-3">
|
||||||
<div class="min-w-0 flex-1">
|
<div class="min-w-0 flex-1">
|
||||||
<div class="flex flex-wrap items-center gap-2 mb-1">
|
<div class="flex flex-wrap items-center gap-2 mb-1">
|
||||||
@@ -77,7 +79,7 @@
|
|||||||
</p>
|
</p>
|
||||||
<% } %>
|
<% } %>
|
||||||
<% if (j.quelle_url) { %>
|
<% if (j.quelle_url) { %>
|
||||||
<a href="<%= j.quelle_url %>" target="_blank" rel="noopener noreferrer"
|
<a href="<%= safeUrl(j.quelle_url) %>" target="_blank" rel="noopener noreferrer"
|
||||||
class="inline-flex items-center gap-1 text-xs text-blue-600 dark:text-blue-400 hover:underline mt-2 break-all">
|
class="inline-flex items-center gap-1 text-xs text-blue-600 dark:text-blue-400 hover:underline mt-2 break-all">
|
||||||
<svg class="w-3.5 h-3.5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"></path></svg>
|
<svg class="w-3.5 h-3.5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"></path></svg>
|
||||||
<span class="truncate"><%= j.quelle_url %></span>
|
<span class="truncate"><%= j.quelle_url %></span>
|
||||||
@@ -100,6 +102,7 @@
|
|||||||
|
|
||||||
<!-- Actions -->
|
<!-- Actions -->
|
||||||
<div class="shrink-0 flex flex-col gap-2 self-start">
|
<div class="shrink-0 flex flex-col gap-2 self-start">
|
||||||
|
<%- include('partials/favorit-button', { j: j }) %>
|
||||||
<% if (j.verknuepfte_bewerbung_id) { %>
|
<% if (j.verknuepfte_bewerbung_id) { %>
|
||||||
<a href="/bewerbung/<%= j.verknuepfte_bewerbung_id %>"
|
<a href="/bewerbung/<%= j.verknuepfte_bewerbung_id %>"
|
||||||
class="inline-flex items-center justify-center gap-1.5 px-3 py-1.5 text-xs bg-blue-600 hover:bg-blue-700 text-white rounded-md transition-colors">
|
class="inline-flex items-center justify-center gap-1.5 px-3 py-1.5 text-xs bg-blue-600 hover:bg-blue-700 text-white rounded-md transition-colors">
|
||||||
@@ -136,25 +139,6 @@
|
|||||||
|
|
||||||
<script>
|
<script>
|
||||||
(function () {
|
(function () {
|
||||||
const root = document.documentElement;
|
|
||||||
const dm = localStorage.getItem('darkMode');
|
|
||||||
if (dm === 'enabled' || (!dm && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
|
|
||||||
root.classList.add('dark');
|
|
||||||
}
|
|
||||||
const toggle = document.getElementById('darkModeToggle');
|
|
||||||
const sun = document.getElementById('sunIcon');
|
|
||||||
const moon = document.getElementById('moonIcon');
|
|
||||||
function sync() {
|
|
||||||
const d = root.classList.contains('dark');
|
|
||||||
if (sun) sun.classList.toggle('hidden', d);
|
|
||||||
if (moon) moon.classList.toggle('hidden', !d);
|
|
||||||
}
|
|
||||||
sync();
|
|
||||||
if (toggle) toggle.addEventListener('click', () => {
|
|
||||||
root.classList.toggle('dark');
|
|
||||||
localStorage.setItem('darkMode', root.classList.contains('dark') ? 'enabled' : 'disabled');
|
|
||||||
sync();
|
|
||||||
});
|
|
||||||
const cy = document.getElementById('currentYear');
|
const cy = document.getElementById('currentYear');
|
||||||
if (cy) cy.textContent = new Date().getFullYear();
|
if (cy) cy.textContent = new Date().getFullYear();
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -0,0 +1,342 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<%- include('partials/head') %>
|
||||||
|
</head>
|
||||||
|
<body class="min-h-screen flex flex-col transition-colors duration-300 bg-gray-50 dark:bg-gray-900 text-gray-900 dark:text-gray-100" id="body">
|
||||||
|
<%- include('partials/header') %>
|
||||||
|
|
||||||
|
<main class="flex-1 container mx-auto px-4 py-8 max-w-4xl">
|
||||||
|
<div class="mb-6">
|
||||||
|
<h2 class="text-2xl font-bold text-gray-800 dark:text-white">Jobsuche</h2>
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||||
|
Du legst fest, <strong>wo</strong> gesucht wird. <strong>Was</strong> gesucht wird – Rollen und
|
||||||
|
Technologien – leitet die KI aus deinem hinterlegten Lebenslauf ab.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<% if (fremd) { %>
|
||||||
|
<div class="mb-6 rounded-lg border border-amber-300 dark:border-amber-700 bg-amber-50 dark:bg-amber-900/20 p-4">
|
||||||
|
<p class="text-sm text-amber-800 dark:text-amber-200">
|
||||||
|
Du bearbeitest als Administrator das Suchprofil von <strong><%= zielUser.username %></strong>.
|
||||||
|
<a href="/jobsuche" class="underline">Zum eigenen Profil</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<% } %>
|
||||||
|
|
||||||
|
<% if (fehler) { %>
|
||||||
|
<div class="mb-6 rounded-lg border border-red-300 dark:border-red-700 bg-red-50 dark:bg-red-900/20 p-4">
|
||||||
|
<p class="text-sm text-red-800 dark:text-red-200"><%= fehler %></p>
|
||||||
|
</div>
|
||||||
|
<% } %>
|
||||||
|
<% if (hinweis) { %>
|
||||||
|
<div class="mb-6 rounded-lg border border-green-300 dark:border-green-700 bg-green-50 dark:bg-green-900/20 p-4">
|
||||||
|
<p class="text-sm text-green-800 dark:text-green-200"><%= hinweis %></p>
|
||||||
|
</div>
|
||||||
|
<% } %>
|
||||||
|
|
||||||
|
<% if (!hatLebenslauf || !hatOllamaKey) { %>
|
||||||
|
<div class="mb-6 rounded-lg border border-amber-300 dark:border-amber-700 bg-amber-50 dark:bg-amber-900/20 p-4">
|
||||||
|
<p class="text-sm font-medium text-amber-800 dark:text-amber-200 mb-1">Vor dem ersten Suchlauf fehlt noch etwas:</p>
|
||||||
|
<ul class="text-sm text-amber-800 dark:text-amber-200 list-disc list-inside space-y-0.5">
|
||||||
|
<% if (!hatLebenslauf) { %>
|
||||||
|
<li>Kein Lebenslauf hinterlegt – ohne ihn kann die KI dein Suchprofil nicht ableiten.
|
||||||
|
<a href="/vorlagen" class="underline">Unter Vorlagen anlegen</a></li>
|
||||||
|
<% } %>
|
||||||
|
<% if (!hatOllamaKey) { %>
|
||||||
|
<li>Kein Ollama-API-Schlüssel – der Suchlauf läuft über dein eigenes KI-Kontingent.
|
||||||
|
<a href="/einstellungen" class="underline">In den Einstellungen eintragen</a></li>
|
||||||
|
<% } %>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<% } %>
|
||||||
|
|
||||||
|
<form action="/jobsuche" method="POST" class="space-y-6">
|
||||||
|
<% if (fremd) { %><input type="hidden" name="ziel_user" value="<%= zielUser.id %>"><% } %>
|
||||||
|
|
||||||
|
<section class="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6">
|
||||||
|
<h3 class="text-base font-semibold text-gray-800 dark:text-white">Wo gesucht wird</h3>
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1 mb-5">Modus und Orte.</p>
|
||||||
|
|
||||||
|
<div class="space-y-2 mb-5">
|
||||||
|
<% var modusText = {
|
||||||
|
regional: 'Nur Stellen mit Arbeitsort in den unten genannten Städten.',
|
||||||
|
remote: 'Nur Stellen, die zu 100 % im Homeoffice ausübbar sind – deutschlandweit, Firmensitz egal.',
|
||||||
|
beides: 'Zwei Durchgänge: regional in deinen Städten und zusätzlich 100 % Remote deutschlandweit.'
|
||||||
|
}; %>
|
||||||
|
<% modi.forEach(function (m) { %>
|
||||||
|
<label class="flex items-start gap-3 p-3 rounded-md border border-gray-200 dark:border-gray-700 cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-700/40">
|
||||||
|
<input type="radio" name="modus" value="<%= m %>" <%= profil.modus === m ? 'checked' : '' %>
|
||||||
|
class="mt-0.5 border-gray-300 dark:border-gray-600 text-blue-600 focus:ring-blue-500">
|
||||||
|
<span>
|
||||||
|
<span class="block text-sm font-medium text-gray-800 dark:text-white">
|
||||||
|
<%= m === 'regional' ? 'Regional' : (m === 'remote' ? '100 % Remote' : 'Regional + Remote') %>
|
||||||
|
</span>
|
||||||
|
<span class="block text-xs text-gray-500 dark:text-gray-400"><%= modusText[m] %></span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<% }); %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1" for="staedte">Städte</label>
|
||||||
|
<textarea id="staedte" name="staedte" rows="3" spellcheck="false"
|
||||||
|
class="w-full rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 px-3 py-2 text-sm"
|
||||||
|
placeholder="Berlin, Potsdam, Hamburg, München"><%= profil.staedte.join(', ') %></textarea>
|
||||||
|
<p class="text-xs text-gray-400 dark:text-gray-500 mt-1">
|
||||||
|
Kommagetrennt, höchstens <%= maxStaedte %>. Die <strong>erste Stadt gilt als Wohnort</strong> und wird
|
||||||
|
am höchsten priorisiert. Bei „100 % Remote“ werden die Städte ignoriert.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6">
|
||||||
|
<div class="flex flex-wrap items-start justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<h3 class="text-base font-semibold text-gray-800 dark:text-white">Feinschliff</h3>
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||||
|
Unter welchen Titeln gesucht wird – und was die Suche zusätzlich beachten oder verwerfen soll.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button type="button" id="feinschliffKi"
|
||||||
|
<%= (!hatUnterlagen || !hatOllamaKey) ? 'disabled' : '' %>
|
||||||
|
title="<%= !hatUnterlagen ? 'Dafür fehlen Basis-Unterlagen unter Vorlagen.' : (!hatOllamaKey ? 'Dafür fehlt der Ollama-API-Schlüssel in den Einstellungen.' : 'Leitet Vorschläge aus deinen Basis-Unterlagen ab.') %>"
|
||||||
|
class="inline-flex items-center gap-2 px-3 py-1.5 shrink-0 text-sm border border-blue-600 text-blue-600 dark:border-blue-400 dark:text-blue-400 rounded-md transition-colors hover:bg-blue-50 dark:hover:bg-blue-900/30 disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:bg-transparent">
|
||||||
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 3v4M3 5h4M6 17v4m-2-2h4m5-16l2.286 6.857L21 12l-5.714 2.143L13 21l-2.286-6.857L5 12l5.714-2.143L13 3z"></path>
|
||||||
|
</svg>
|
||||||
|
<span id="feinschliffKiText">Per KI vorschlagen</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p id="feinschliffHinweis" class="hidden text-sm mt-3"></p>
|
||||||
|
<p class="text-xs text-gray-400 dark:text-gray-500 mt-3 mb-5">
|
||||||
|
Der KI-Vorschlag wertet deine <a href="/vorlagen" class="underline">Basis-Unterlagen</a> aus und
|
||||||
|
überschreibt alle drei Felder. Gespeichert wird er erst, wenn du unten auf „Speichern“ klickst.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="mb-4">
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1" for="berufsbezeichnungen">Berufsbezeichnungen</label>
|
||||||
|
<textarea id="berufsbezeichnungen" name="berufsbezeichnungen" rows="2"
|
||||||
|
class="w-full rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 px-3 py-2 text-sm"
|
||||||
|
placeholder="z. B. Systemadministrator, IT-Administrator, Fachinformatiker Systemintegration"><%= profil.berufsbezeichnungen %></textarea>
|
||||||
|
<p class="text-xs text-gray-400 dark:text-gray-500 mt-1">
|
||||||
|
<strong>Danach wird gesucht</strong> – was hier fehlt, findet die Suche nicht. Kommagetrennt;
|
||||||
|
gängige Varianten desselben Titels ergänzt die Suche selbst. Leer = die Suche leitet die Titel
|
||||||
|
wie bisher aus deinem Lebenslauf ab.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid gap-4 sm:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1" for="zusatz_begriffe">Zusätzlich berücksichtigen</label>
|
||||||
|
<textarea id="zusatz_begriffe" name="zusatz_begriffe" rows="3"
|
||||||
|
class="w-full rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 px-3 py-2 text-sm"
|
||||||
|
placeholder="z. B. Teilzeit, Rechenzentrum, Systemhaus"><%= profil.zusatz_begriffe %></textarea>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1" for="ausschluesse">Ausschließen</label>
|
||||||
|
<textarea id="ausschluesse" name="ausschluesse" rows="3"
|
||||||
|
class="w-full rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 px-3 py-2 text-sm"
|
||||||
|
placeholder="z. B. Zeitarbeit, Headhunter, Schichtdienst"><%= profil.ausschluesse %></textarea>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6">
|
||||||
|
<div class="flex items-start justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<h3 class="text-base font-semibold text-gray-800 dark:text-white">Zeitplan</h3>
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||||
|
Ist er aktiv, sucht die KI automatisch an den gewählten Tagen zur eingestellten Uhrzeit
|
||||||
|
und legt neue Treffer als Jobangebote an. Deaktivierst du ihn, läuft keine automatische
|
||||||
|
Suche mehr — die eingestellten Wochentage und die Uhrzeit bleiben aber erhalten.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<label class="inline-flex items-center gap-2 shrink-0">
|
||||||
|
<input type="checkbox" name="aktiv" value="1" id="zeitplan_aktiv" <%= profil.aktiv ? 'checked' : '' %>
|
||||||
|
<%= !darfZeitplanAendern ? 'disabled' : '' %>
|
||||||
|
class="rounded border-gray-300 dark:border-gray-600 text-blue-600 focus:ring-blue-500 disabled:opacity-60">
|
||||||
|
<span class="text-sm text-gray-700 dark:text-gray-300">aktiv</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<% if (!darfZeitplanAendern) { %>
|
||||||
|
<p class="text-xs text-amber-700 dark:text-amber-300 mt-3">
|
||||||
|
Der Zeitplan (aktiv, Wochentage, Uhrzeit) wird vom Administrator verwaltet.
|
||||||
|
</p>
|
||||||
|
<% } %>
|
||||||
|
|
||||||
|
<div id="zeitplan_felder" class="flex flex-wrap gap-2 mb-5 mt-5 <%= !profil.aktiv ? 'opacity-60' : '' %>">
|
||||||
|
<% wochentage.forEach(function (t) { %>
|
||||||
|
<label class="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-md border border-gray-300 dark:border-gray-600 text-sm <%= darfZeitplanAendern ? 'cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-700/40' : 'opacity-70 cursor-not-allowed' %>">
|
||||||
|
<input type="checkbox" name="tage" value="<%= t.nr %>"
|
||||||
|
<%= profil.zeitplan_tage.indexOf(t.nr) !== -1 ? 'checked' : '' %>
|
||||||
|
<%= !darfZeitplanAendern ? 'disabled' : '' %>
|
||||||
|
class="rounded border-gray-300 dark:border-gray-600 text-blue-600 focus:ring-blue-500 disabled:opacity-60">
|
||||||
|
<span class="text-gray-700 dark:text-gray-300"><%= t.kurz %></span>
|
||||||
|
</label>
|
||||||
|
<% }); %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1" for="zeitplan_zeit">Uhrzeit</label>
|
||||||
|
<input type="time" id="zeitplan_zeit" name="zeitplan_zeit" value="<%= profil.zeitplan_zeit %>"
|
||||||
|
<%= !darfZeitplanAendern ? 'disabled' : '' %>
|
||||||
|
class="rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 px-3 py-2 text-sm disabled:opacity-60">
|
||||||
|
<p class="text-xs text-gray-400 dark:text-gray-500 mt-1">
|
||||||
|
Der Lauf startet innerhalb weniger Minuten nach dieser Uhrzeit.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div class="flex items-center justify-end gap-3">
|
||||||
|
<a href="/jobsuche<%= fremd ? '?user=' + zielUser.id : '' %>"
|
||||||
|
class="px-4 py-2 text-sm border border-gray-300 dark:border-gray-600 text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-md transition-colors">
|
||||||
|
Verwerfen
|
||||||
|
</a>
|
||||||
|
<button type="submit"
|
||||||
|
class="inline-flex items-center gap-2 px-4 py-2 text-sm bg-blue-600 hover:bg-blue-700 text-white rounded-md transition-colors">
|
||||||
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path>
|
||||||
|
</svg>
|
||||||
|
Speichern
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<section class="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6 mt-6">
|
||||||
|
<div class="flex items-start justify-between gap-4 mb-5">
|
||||||
|
<div>
|
||||||
|
<h3 class="text-base font-semibold text-gray-800 dark:text-white">Suchläufe</h3>
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||||
|
Ein Lauf durchsucht das Web und legt neue Treffer als
|
||||||
|
<a href="/jobangebote" class="text-blue-600 dark:text-blue-400 hover:underline">Jobangebote</a> an.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<% if (darfSuchlaufStarten) { %>
|
||||||
|
<form action="/jobsuche/start" method="POST" class="shrink-0">
|
||||||
|
<% if (fremd) { %><input type="hidden" name="ziel_user" value="<%= zielUser.id %>"><% } %>
|
||||||
|
<button type="submit"
|
||||||
|
class="inline-flex items-center gap-2 px-4 py-2 text-sm bg-green-600 hover:bg-green-700 text-white rounded-md transition-colors">
|
||||||
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"></path>
|
||||||
|
</svg>
|
||||||
|
Jetzt suchen
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
<% } else if (!fremd) { %>
|
||||||
|
<p class="text-xs text-gray-400 dark:text-gray-500 shrink-0 text-right max-w-[12rem]">
|
||||||
|
Manuelle Suchläufe werden vom Administrator verwaltet.
|
||||||
|
</p>
|
||||||
|
<% } %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="laeufe">
|
||||||
|
<%- include('partials/suchlaeufe', { laeufe: laeufe }) %>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<%- include('partials/footer') %>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
const cy = document.getElementById('currentYear');
|
||||||
|
if (cy) cy.textContent = new Date().getFullYear();
|
||||||
|
|
||||||
|
// While a run is queued or in flight, refresh the list so the user sees it
|
||||||
|
// progress without reloading. Stops once nothing is pending any more.
|
||||||
|
const zielUser = <%- fremd ? zielUser.id : 'null' %>;
|
||||||
|
const url = '/jobsuche/laeufe' + (zielUser ? '?user=' + zielUser : '');
|
||||||
|
let timer = null;
|
||||||
|
|
||||||
|
function offen(laeufe) {
|
||||||
|
return laeufe.some(function (l) { return l.status === 'angefordert' || l.status === 'laeuft'; });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function poll() {
|
||||||
|
try {
|
||||||
|
const res = await fetch(url, { headers: { Accept: 'application/json' } });
|
||||||
|
if (!res.ok) return;
|
||||||
|
const laeufe = await res.json();
|
||||||
|
if (!offen(laeufe)) {
|
||||||
|
clearInterval(timer);
|
||||||
|
location.reload(); // fertig -> Ergebnisse serverseitig rendern
|
||||||
|
}
|
||||||
|
} catch (e) { /* nächster Tick */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
const initial = <%- JSON.stringify(laeufe.map(function (l) { return { status: l.status }; })) %>;
|
||||||
|
if (offen(initial)) timer = setInterval(poll, 5000);
|
||||||
|
})();
|
||||||
|
|
||||||
|
// "Per KI vorschlagen": lets the model derive the two guard-rail lists from the
|
||||||
|
// user's Basis-Unterlagen. Only fills the fields — saving stays a deliberate act.
|
||||||
|
(function () {
|
||||||
|
const btn = document.getElementById('feinschliffKi');
|
||||||
|
if (!btn) return;
|
||||||
|
const label = document.getElementById('feinschliffKiText');
|
||||||
|
const hinweis = document.getElementById('feinschliffHinweis');
|
||||||
|
const berufe = document.getElementById('berufsbezeichnungen');
|
||||||
|
const zusatz = document.getElementById('zusatz_begriffe');
|
||||||
|
const ausschluesse = document.getElementById('ausschluesse');
|
||||||
|
const zielUser = <%- fremd ? zielUser.id : 'null' %>;
|
||||||
|
|
||||||
|
const FARBE = {
|
||||||
|
info: 'text-gray-500 dark:text-gray-400',
|
||||||
|
ok: 'text-green-700 dark:text-green-400',
|
||||||
|
fehler: 'text-red-600 dark:text-red-400',
|
||||||
|
};
|
||||||
|
function melde(text, art) {
|
||||||
|
hinweis.textContent = text;
|
||||||
|
hinweis.className = 'text-sm mt-3 ' + FARBE[art];
|
||||||
|
}
|
||||||
|
|
||||||
|
btn.addEventListener('click', async function () {
|
||||||
|
btn.disabled = true;
|
||||||
|
label.textContent = 'KI denkt nach …';
|
||||||
|
melde('Die KI liest deine Basis-Unterlagen – das dauert einen Moment.', 'info');
|
||||||
|
|
||||||
|
const modus = document.querySelector('input[name="modus"]:checked');
|
||||||
|
try {
|
||||||
|
const res = await fetch('/jobsuche/feinschliff', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
ziel_user: zielUser,
|
||||||
|
modus: modus ? modus.value : '',
|
||||||
|
staedte: document.getElementById('staedte').value,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const data = await res.json().catch(function () { return {}; });
|
||||||
|
if (!res.ok) throw new Error(data.error || ('Fehler ' + res.status));
|
||||||
|
|
||||||
|
berufe.value = data.berufsbezeichnungen || '';
|
||||||
|
zusatz.value = data.zusatz_begriffe || '';
|
||||||
|
ausschluesse.value = data.ausschluesse || '';
|
||||||
|
melde('Vorschlag übernommen. Prüfe ihn und klicke auf „Speichern“.', 'ok');
|
||||||
|
} catch (e) {
|
||||||
|
melde(e.message || 'Der KI-Vorschlag ist fehlgeschlagen.', 'fehler');
|
||||||
|
} finally {
|
||||||
|
btn.disabled = false;
|
||||||
|
label.textContent = 'Per KI vorschlagen';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
|
||||||
|
// Zeitplan aktiv/deaktiv: stimmt die Sichtbarkeit der Wochentage + Uhrzeit
|
||||||
|
// mit dem Hauptschalter ab. Die Felder werden nur abgedunkelt, nicht
|
||||||
|
// deaktiviert — so bleiben die eingestellten Wochentage erhalten, wenn der
|
||||||
|
// Zeitplan später wieder aktiviert wird. Ist der Schalter gesperrt (Admin
|
||||||
|
// verwaltet den Zeitplan), greift die serverseitige Vorgabe unverändert.
|
||||||
|
(function () {
|
||||||
|
const aktiv = document.getElementById('zeitplan_aktiv');
|
||||||
|
if (!aktiv || aktiv.disabled) return;
|
||||||
|
const felder = document.getElementById('zeitplan_felder');
|
||||||
|
const zeit = document.getElementById('zeitplan_zeit');
|
||||||
|
function sync() {
|
||||||
|
const aus = !aktiv.checked;
|
||||||
|
if (felder) felder.classList.toggle('opacity-60', aus);
|
||||||
|
if (zeit) zeit.classList.toggle('opacity-60', aus);
|
||||||
|
}
|
||||||
|
sync();
|
||||||
|
aktiv.addEventListener('change', sync);
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="de" class="dark">
|
||||||
|
<head>
|
||||||
|
<%- include('partials/head') %>
|
||||||
|
<style>
|
||||||
|
body { background: linear-gradient(135deg, #1e3a8a 0%, #1f2937 100%); }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body class="min-h-screen flex items-center justify-center px-4">
|
||||||
|
<div class="w-full max-w-sm">
|
||||||
|
<div class="bg-white dark:bg-gray-800 rounded-2xl shadow-2xl p-8">
|
||||||
|
<div class="flex flex-col items-center mb-6">
|
||||||
|
<svg class="w-12 h-12 text-blue-600 dark:text-blue-400 mb-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path>
|
||||||
|
</svg>
|
||||||
|
<h1 class="text-xl font-bold text-gray-800 dark:text-white">NextJobs</h1>
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">Bitte anmelden</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<% if (error) { %>
|
||||||
|
<div class="mb-4 rounded-md bg-red-50 dark:bg-red-900/30 px-4 py-2.5 text-sm text-red-700 dark:text-red-300 ring-1 ring-red-200 dark:ring-red-800">
|
||||||
|
<%= error %>
|
||||||
|
</div>
|
||||||
|
<% } %>
|
||||||
|
|
||||||
|
<form method="POST" action="/login" class="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1" for="username">Benutzername</label>
|
||||||
|
<input id="username" name="username" type="text" required autofocus
|
||||||
|
value="<%= username %>"
|
||||||
|
class="w-full rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 px-3 py-2 text-gray-800 dark:text-white focus:ring-2 focus:ring-blue-500 focus:border-transparent outline-none" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1" for="password">Passwort</label>
|
||||||
|
<input id="password" name="password" type="password" required
|
||||||
|
class="w-full rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 px-3 py-2 text-gray-800 dark:text-white focus:ring-2 focus:ring-blue-500 focus:border-transparent outline-none" />
|
||||||
|
</div>
|
||||||
|
<label class="flex items-center gap-2 cursor-pointer select-none">
|
||||||
|
<input id="dauerhaft" name="dauerhaft" type="checkbox" value="1" <%= dauerhaft ? 'checked' : '' %>
|
||||||
|
class="rounded border-gray-300 dark:border-gray-600 text-blue-600 focus:ring-blue-500" />
|
||||||
|
<span class="text-sm text-gray-700 dark:text-gray-300">Angemeldet bleiben</span>
|
||||||
|
</label>
|
||||||
|
<button type="submit"
|
||||||
|
class="w-full rounded-lg bg-blue-600 hover:bg-blue-700 transition-colors text-white font-medium py-2.5">
|
||||||
|
Anmelden
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
<%
|
||||||
|
// Gemerkte Bewerbungen — Sektion auf der Startseite, direkt unter den
|
||||||
|
// anstehenden Terminen. Zeigt alle favorisierten Bewerbungen unabhängig von
|
||||||
|
// ihrem Status. Wird nur gerendert, wenn mindestens ein Favorit vorhanden ist.
|
||||||
|
//
|
||||||
|
// Platzsparend als Chip-Wolke: nur der Firmenname, nebeneinander (flex-wrap),
|
||||||
|
// jeder Chip ein Link zur Bewerbung. Status/Datum/Stelle entfallen hier — die
|
||||||
|
// Details stehen auf der Bewerbungsseite. Entmerken geht ebenfalls dort (Stern).
|
||||||
|
// Die id wird von der Such-Logik in index.ejs zum Ein-/Ausblenden verwendet.
|
||||||
|
%>
|
||||||
|
<% if (bewerbungFavoriten && bewerbungFavoriten.length) { %>
|
||||||
|
<section id="bewerbungFavoritenCard" class="bg-white dark:bg-gray-800/60 rounded-xl border border-gray-200/80 dark:border-gray-700/60 shadow-sm mb-8 overflow-hidden">
|
||||||
|
<div class="flex flex-wrap items-center gap-2 px-5 py-3.5 border-b border-gray-100 dark:border-gray-700/60">
|
||||||
|
<svg class="h-4 w-4 text-amber-400 dark:text-amber-500" viewBox="0 0 24 24" fill="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M11.48 3.5l2.6 5.27 5.82.85-4.21 4.1.99 5.79-5.2-2.73-5.2 2.73.99-5.79-4.21-4.1 5.82-.85 2.6-5.27z"/></svg>
|
||||||
|
<h2 class="text-sm font-semibold text-gray-700 dark:text-gray-200">Gemerkte Bewerbungen</h2>
|
||||||
|
<span class="ml-auto inline-flex items-center rounded-full bg-gray-100 dark:bg-gray-700/70 px-2 py-0.5 text-xs font-medium text-gray-500 dark:text-gray-400">
|
||||||
|
<%= bewerbungFavoriten.length %>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-wrap gap-2 p-5">
|
||||||
|
<% bewerbungFavoriten.forEach(function(b){ %>
|
||||||
|
<a href="/bewerbung/<%= b.id %>"
|
||||||
|
title="<%= b.firma %>"
|
||||||
|
class="gemerkt-chip inline-flex items-center max-w-[18rem] px-3 py-1.5 rounded-lg border border-gray-200 dark:border-gray-700/70 bg-gray-50 dark:bg-gray-700/30 text-sm font-medium text-gray-700 dark:text-gray-200 hover:bg-white dark:hover:bg-gray-700/60 hover:border-gray-300 dark:hover:border-gray-600 hover:shadow-sm transition-all">
|
||||||
|
<span class="truncate"><%= b.firma %></span>
|
||||||
|
</a>
|
||||||
|
<% }); %>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<% } %>
|
||||||
@@ -0,0 +1,646 @@
|
|||||||
|
<%
|
||||||
|
// Einrichtungsassistent für Nutzer, die noch keine Bewerbung angelegt haben.
|
||||||
|
// Server liefert `setupWizard` mit: aktiv, persoenlich (row),
|
||||||
|
// anschreiben/lebenslauf ({id,inhalt}), hasApiKey, ollamaModel, ollamaHost,
|
||||||
|
// cfg (nicht-geheime Mail-/CalDAV-Werte + hasMailPassword + hasApiToken).
|
||||||
|
// Das Partial rendert nur den Wizard; das umgebende index.ejs blendet
|
||||||
|
// gleichzeitig das Dashboard aus (Markup bleibt im DOM für main.js).
|
||||||
|
var p = setupWizard.persoenlich || {};
|
||||||
|
var anschreibenInhalt = setupWizard.anschreiben ? setupWizard.anschreiben.inhalt : '';
|
||||||
|
var lebenslaufInhalt = setupWizard.lebenslauf ? setupWizard.lebenslauf.inhalt : '';
|
||||||
|
var c = setupWizard.cfg || {};
|
||||||
|
%>
|
||||||
|
<style>
|
||||||
|
/* Fortschrittsanzeige: Verbindungslinie zwischen den Schritt-Punkten. */
|
||||||
|
.einr-steps { position: relative; }
|
||||||
|
.einr-steps::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
top: 14px; /* Mitte der 28px-Punkte */
|
||||||
|
left: 6%;
|
||||||
|
right: 6%;
|
||||||
|
height: 2px;
|
||||||
|
background-color: rgba(255, 255, 255, 0.25);
|
||||||
|
}
|
||||||
|
.einr-step-dot {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
transition: background-color 0.2s ease, transform 0.2s ease;
|
||||||
|
}
|
||||||
|
.einr-step.is-active .einr-step-dot { transform: scale(1.12); }
|
||||||
|
/* Sanftes Einblenden der Schrittinhalte. */
|
||||||
|
.einr-panel { animation: einrFade 0.28s ease; }
|
||||||
|
@keyframes einrFade {
|
||||||
|
from { opacity: 0; transform: translateY(6px); }
|
||||||
|
to { opacity: 1; transform: translateY(0); }
|
||||||
|
}
|
||||||
|
@media (prefers-reduced-motion: reduce) { .einr-panel { animation: none; } }
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<section id="einrichtung" class="mb-10">
|
||||||
|
<div class="mx-auto max-w-3xl overflow-hidden rounded-2xl border border-gray-200/80 bg-white shadow-xl shadow-gray-900/5 dark:border-white/10 dark:bg-gray-800">
|
||||||
|
|
||||||
|
<!-- Kopfbereich mit Verlaufsanzeige -->
|
||||||
|
<div class="relative bg-gradient-to-br from-sky-500 via-blue-600 to-indigo-600 px-5 py-7 text-white sm:px-8">
|
||||||
|
<div class="flex items-start justify-between gap-4">
|
||||||
|
<div class="flex items-start gap-3">
|
||||||
|
<span class="grid h-11 w-11 shrink-0 place-items-center rounded-xl bg-white/15 ring-1 ring-white/25 backdrop-blur">
|
||||||
|
<svg class="h-6 w-6" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M11.42 15.17L17.25 21A2.652 2.652 0 0021 17.25l-5.877-5.877M11.42 15.17l2.496 3.06c.318.392.74.665 1.215.82a3.51 3.51 0 003.99-1.51l1.75-2.93a1.5 1.5 0 00-2.57-1.55l-1.75 2.93a.75.75 0 01-1.05.23l-.4-.27m-3.24-2.29l-2.83-3.5m0 0A3.291 3.291 0 001.87 7.28l.8 1.2m4.4 3.69l-3.6-5.4m1.55 6.6L4 7.6"/>
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
<div>
|
||||||
|
<h1 class="text-xl font-semibold leading-tight sm:text-2xl">NextJobs einrichten</h1>
|
||||||
|
<p class="mt-1 max-w-md text-sm text-white/80">
|
||||||
|
In wenigen Schritten bist du startklar für deine erste Bewerbung.
|
||||||
|
Jeder Schritt lässt sich überspringen und später nachholen.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button type="button" id="einrAusblenden"
|
||||||
|
class="shrink-0 rounded-lg px-3 py-1.5 text-xs font-medium text-white/80 ring-1 ring-white/25 transition hover:bg-white/10 hover:text-white">
|
||||||
|
Ausblenden
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Schrittanzeige (7 Schritte; Labels nur auf >= sm) -->
|
||||||
|
<ol id="einrSteps" class="einr-steps mt-7 flex items-start justify-between gap-1">
|
||||||
|
<% var stepLabels = ['Willkommen','Profil','Vorlagen','KI','E-Mail','API','Abschluss']; %>
|
||||||
|
<% for (var si = 1; si <= 7; si++) { %>
|
||||||
|
<li class="einr-step flex flex-1 flex-col items-center gap-1.5 text-center" data-step="<%= si %>">
|
||||||
|
<span class="einr-step-dot grid h-7 w-7 place-items-center rounded-full bg-white/20 text-xs font-semibold ring-1 ring-white/30"><%= si %></span>
|
||||||
|
<span class="hidden text-[11px] font-medium text-white/80 sm:block"><%= stepLabels[si-1] %></span>
|
||||||
|
</li>
|
||||||
|
<% } %>
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Formular / Schrittinhalte -->
|
||||||
|
<form id="einrForm" class="px-5 py-6 sm:px-8" autocomplete="off" novalidate>
|
||||||
|
|
||||||
|
<!-- Schritt 1: Willkommen -->
|
||||||
|
<div class="einr-panel" data-panel="1">
|
||||||
|
<h2 class="text-lg font-semibold text-gray-800 dark:text-white">Herzlich willkommen!</h2>
|
||||||
|
<p class="mt-2 text-sm leading-relaxed text-gray-600 dark:text-gray-300">
|
||||||
|
NextJobs begleitet dich vom Stellenimport bis zur fertigen Bewerbung — mit
|
||||||
|
automatisch zugeschnittenen Anschreiben und Lebenslauf, E-Mail-Posteingang
|
||||||
|
und Bewerbungskalender. Damit das klappt, hinterlegen wir zuerst die nötigen
|
||||||
|
Daten. Das dauert etwa fünf Minuten.
|
||||||
|
</p>
|
||||||
|
<ul class="mt-5 space-y-2.5 text-sm text-gray-600 dark:text-gray-300">
|
||||||
|
<li class="flex items-start gap-2.5"><svg class="mt-0.5 h-5 w-5 shrink-0 text-blue-500" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/></svg><span><strong class="font-medium text-gray-800 dark:text-white">Persönliche Angaben</strong> — fließen in jede Bewerbung ein.</span></li>
|
||||||
|
<li class="flex items-start gap-2.5"><svg class="mt-0.5 h-5 w-5 shrink-0 text-blue-500" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/></svg><span><strong class="font-medium text-gray-800 dark:text-white">Basis-Unterlagen</strong> — dein Standard-Anschreiben & Lebenslauf als Text.</span></li>
|
||||||
|
<li class="flex items-start gap-2.5"><svg class="mt-0.5 h-5 w-5 shrink-0 text-blue-500" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/></svg><span><strong class="font-medium text-gray-800 dark:text-white">Ollama-KI</strong> — erzeugt daraus die passenden Unterlagen.</span></li>
|
||||||
|
<li class="flex items-start gap-2.5"><svg class="mt-0.5 h-5 w-5 shrink-0 text-blue-500" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/></svg><span><strong class="font-medium text-gray-800 dark:text-white">E-Mail & Kalender</strong> — Posteingang versenden und Termine synchronisieren.</span></li>
|
||||||
|
<li class="flex items-start gap-2.5"><svg class="mt-0.5 h-5 w-5 shrink-0 text-blue-500" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/></svg><span><strong class="font-medium text-gray-800 dark:text-white">REST-API</strong> — optional, für Drittanbietersoftware.</span></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Schritt 2: Persönliche Angaben -->
|
||||||
|
<div class="einr-panel hidden" data-panel="2">
|
||||||
|
<h2 class="text-lg font-semibold text-gray-800 dark:text-white">Persönliche Angaben</h2>
|
||||||
|
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
Diese Daten stehen oben in jedem Anschreiben und Lebenslauf. Pflichtfeld ist nur der Name.
|
||||||
|
</p>
|
||||||
|
<div class="mt-5 space-y-4">
|
||||||
|
<div>
|
||||||
|
<label for="einr-name" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Vollständiger Name <span class="text-red-500">*</span></label>
|
||||||
|
<input type="text" id="einr-name" name="name" required
|
||||||
|
class="mt-1 w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm text-gray-800 dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||||
|
placeholder="Max Mustermann" value="<%= p.name || '' %>">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="einr-adresse" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Adresse</label>
|
||||||
|
<textarea id="einr-adresse" name="adresse" rows="2"
|
||||||
|
class="mt-1 w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm text-gray-800 dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||||
|
placeholder="Musterstraße 1, 12345 Musterstadt"><%= p.adresse || '' %></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="grid gap-4 sm:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<label for="einr-email" class="block text-xs font-medium text-gray-500 dark:text-gray-400">E-Mail</label>
|
||||||
|
<input type="email" id="einr-email" name="email"
|
||||||
|
class="mt-1 w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm text-gray-800 dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||||
|
placeholder="name@example.com" value="<%= p.email || '' %>">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="einr-telefon" class="block text-xs font-medium text-gray-500 dark:text-gray-400">Telefon</label>
|
||||||
|
<input type="text" id="einr-telefon" name="telefon"
|
||||||
|
class="mt-1 w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm text-gray-800 dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||||
|
placeholder="z. B. 030 12345678" value="<%= p.telefon || '' %>">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="einr-ort" class="block text-xs font-medium text-gray-500 dark:text-gray-400">Wohnort</label>
|
||||||
|
<input type="text" id="einr-ort" name="ort"
|
||||||
|
class="mt-1 w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm text-gray-800 dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||||
|
placeholder="z. B. Berlin" value="<%= p.ort || '' %>">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="einr-kundennummer" class="block text-xs font-medium text-gray-500 dark:text-gray-400">Jobcenter-Kundennummer</label>
|
||||||
|
<input type="text" id="einr-kundennummer" name="kundennummer"
|
||||||
|
class="mt-1 w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm text-gray-800 dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||||
|
placeholder="z. B. 123456789" value="<%= p.kundennummer || '' %>">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="einr-webseite" class="block text-xs font-medium text-gray-500 dark:text-gray-400">Webseite</label>
|
||||||
|
<input type="text" id="einr-webseite" name="webseite"
|
||||||
|
class="mt-1 w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm text-gray-800 dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||||
|
placeholder="z. B. nextjobs.cc" value="<%= p.webseite || '' %>">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="einr-geburtsdatum" class="block text-xs font-medium text-gray-500 dark:text-gray-400">Geburtsdatum</label>
|
||||||
|
<input type="text" id="einr-geburtsdatum" name="geburtsdatum"
|
||||||
|
class="mt-1 w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm text-gray-800 dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||||
|
placeholder="TT.MM.JJJJ" value="<%= p.geburtsdatum || '' %>">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Schritt 3: Basis-Unterlagen -->
|
||||||
|
<div class="einr-panel hidden" data-panel="3">
|
||||||
|
<h2 class="text-lg font-semibold text-gray-800 dark:text-white">Basis-Unterlagen</h2>
|
||||||
|
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
Füge dein Standard-Anschreiben und deinen Lebenslauf als Text ein. Die KI
|
||||||
|
nutzt sie als Faktengrundlage und erfindet nichts hinzu.
|
||||||
|
</p>
|
||||||
|
<div class="mt-5 space-y-5">
|
||||||
|
<div>
|
||||||
|
<label for="einr-anschreiben" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Standard-Anschreiben</label>
|
||||||
|
<textarea id="einr-anschreiben" rows="6"
|
||||||
|
class="mt-1 w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm leading-relaxed text-gray-800 dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||||
|
placeholder="Sehr geehrte Damen und Herren, …"><%= anschreibenInhalt %></textarea>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="einr-lebenslauf" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Lebenslauf</label>
|
||||||
|
<textarea id="einr-lebenslauf" rows="8"
|
||||||
|
class="mt-1 w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm leading-relaxed text-gray-800 dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||||
|
placeholder="Persönliche Daten, Berufserfahrung, Ausbildung, Kenntnisse …"><%= lebenslaufInhalt %></textarea>
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-gray-400 dark:text-gray-500">
|
||||||
|
Tipp: Ausführliche Vorlagen lassen sich später unter „Vorlagen“ verfeinern — inkl. Design und Foto.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Schritt 4: Ollama-KI -->
|
||||||
|
<div class="einr-panel hidden" data-panel="4">
|
||||||
|
<h2 class="text-lg font-semibold text-gray-800 dark:text-white">Ollama-KI einrichten</h2>
|
||||||
|
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
Über die Ollama Cloud erzeugt NextJobs automatisch zugeschnittene
|
||||||
|
Bewerbungsunterlagen. Ohne API-Schlüssel bleibt die KI deaktiviert —
|
||||||
|
später unter „Einstellungen“ nachholbar.
|
||||||
|
</p>
|
||||||
|
<div class="mt-5 space-y-4">
|
||||||
|
<div>
|
||||||
|
<label for="einr-apikey" class="block text-sm font-medium text-gray-700 dark:text-gray-300">API-Schlüssel</label>
|
||||||
|
<div class="relative mt-1">
|
||||||
|
<input type="password" id="einr-apikey" name="OLLAMA_API_KEY"
|
||||||
|
class="w-full rounded-md border border-gray-300 bg-white px-3 py-2 pr-10 text-sm text-gray-800 dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||||
|
placeholder="ol-…" autocomplete="off">
|
||||||
|
<button type="button" class="einr-reveal absolute inset-y-0 right-0 flex items-center px-3 text-gray-400 hover:text-gray-600 dark:hover:text-gray-200" data-target="einr-apikey" title="Wert anzeigen/verbergen" tabindex="-1">
|
||||||
|
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"/></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p class="mt-1 text-xs text-gray-400 dark:text-gray-500">
|
||||||
|
Schlüssel holen unter
|
||||||
|
<a href="https://ollama.com/settings/keys" target="_blank" rel="noopener" class="text-blue-600 hover:underline dark:text-sky-400">ollama.com/settings/keys</a>.
|
||||||
|
<% if (setupWizard.hasApiKey) { %><span class="ml-1 text-green-600 dark:text-green-400">· bereits hinterlegt</span><% } %>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="grid gap-4 sm:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<label for="einr-model" class="block text-xs font-medium text-gray-500 dark:text-gray-400">Modell</label>
|
||||||
|
<input type="text" id="einr-model" name="OLLAMA_MODEL"
|
||||||
|
class="mt-1 w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm text-gray-800 dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||||
|
value="<%= setupWizard.ollamaModel || 'glm-5.2:cloud' %>">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="einr-host" class="block text-xs font-medium text-gray-500 dark:text-gray-400">Host</label>
|
||||||
|
<input type="text" id="einr-host" name="OLLAMA_HOST"
|
||||||
|
class="mt-1 w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm text-gray-800 dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||||
|
value="<%= setupWizard.ollamaHost || 'https://ollama.com' %>">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Schritt 5: E-Mail & Kalender -->
|
||||||
|
<div class="einr-panel hidden" data-panel="5">
|
||||||
|
<h2 class="text-lg font-semibold text-gray-800 dark:text-white">E-Mail & Kalender</h2>
|
||||||
|
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
E-Mail-Empfang (IMAP) und -Versand (SMTP) laufen über dein eigenes Postfach.
|
||||||
|
Der Bewerbungskalender nutzt CalDAV und teilt sich Login & Passwort mit
|
||||||
|
der E-Mail. Beides ist optional — ohne Angabe bleiben Postfach und
|
||||||
|
Kalender-Sync deaktiviert.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<!-- E-Mail -->
|
||||||
|
<div class="mt-5">
|
||||||
|
<h3 class="text-sm font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">E-Mail (SMTP / IMAP)</h3>
|
||||||
|
<div class="mt-3 grid gap-4 sm:grid-cols-2">
|
||||||
|
<div class="sm:col-span-2">
|
||||||
|
<label for="einr-mail-host" class="block text-xs font-medium text-gray-500 dark:text-gray-400">SMTP/IMAP Host</label>
|
||||||
|
<input type="text" id="einr-mail-host" class="mt-1 w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm text-gray-800 dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||||
|
placeholder="mail.example.com" value="<%= c.MAIL_HOST || '' %>">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="einr-mail-smtp-port" class="block text-xs font-medium text-gray-500 dark:text-gray-400">SMTP-Port</label>
|
||||||
|
<input type="text" id="einr-mail-smtp-port" class="mt-1 w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm text-gray-800 dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||||
|
value="<%= c.MAIL_SMTP_PORT || '587' %>">
|
||||||
|
<p class="mt-1 text-[11px] text-gray-400 dark:text-gray-500">587 = STARTTLS, 465 = implicit TLS</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="einr-mail-imap-port" class="block text-xs font-medium text-gray-500 dark:text-gray-400">IMAP-Port</label>
|
||||||
|
<input type="text" id="einr-mail-imap-port" class="mt-1 w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm text-gray-800 dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||||
|
value="<%= c.MAIL_IMAP_PORT || '993' %>">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="einr-mail-user" class="block text-xs font-medium text-gray-500 dark:text-gray-400">Benutzername (Login)</label>
|
||||||
|
<input type="text" id="einr-mail-user" class="mt-1 w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm text-gray-800 dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||||
|
placeholder="name@example.com" value="<%= c.MAIL_USER || '' %>">
|
||||||
|
<p class="mt-1 text-[11px] text-gray-400 dark:text-gray-500">Auch CalDAV-Login</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="einr-mail-password" class="block text-xs font-medium text-gray-500 dark:text-gray-400">Passwort</label>
|
||||||
|
<div class="relative mt-1">
|
||||||
|
<input type="password" id="einr-mail-password"
|
||||||
|
class="w-full rounded-md border border-gray-300 bg-white px-3 py-2 pr-10 text-sm text-gray-800 dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||||
|
placeholder="<%= c.hasMailPassword ? 'vorhanden — leer lassen, um nichts zu ändern' : 'Passwort' %>" autocomplete="off">
|
||||||
|
<button type="button" class="einr-reveal absolute inset-y-0 right-0 flex items-center px-3 text-gray-400 hover:text-gray-600 dark:hover:text-gray-200" data-target="einr-mail-password" title="Wert anzeigen/verbergen" tabindex="-1">
|
||||||
|
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"/></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="einr-mail-fromname" class="block text-xs font-medium text-gray-500 dark:text-gray-400">Absendername</label>
|
||||||
|
<input type="text" id="einr-mail-fromname" class="mt-1 w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm text-gray-800 dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||||
|
placeholder="Max Mustermann" value="<%= c.MAIL_FROM_NAME || '' %>">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="einr-mail-from" class="block text-xs font-medium text-gray-500 dark:text-gray-400">Absenderadresse</label>
|
||||||
|
<input type="text" id="einr-mail-from" class="mt-1 w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm text-gray-800 dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||||
|
placeholder="leer = Benutzername" value="<%= c.MAIL_FROM || '' %>">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="einr-mail-mailbox" class="block text-xs font-medium text-gray-500 dark:text-gray-400">IMAP-Postfach</label>
|
||||||
|
<input type="text" id="einr-mail-mailbox" class="mt-1 w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm text-gray-800 dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||||
|
value="<%= c.MAIL_IMAP_MAILBOX || 'INBOX' %>">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="einr-mail-poll" class="block text-xs font-medium text-gray-500 dark:text-gray-400">Abrufintervall (ms)</label>
|
||||||
|
<input type="text" id="einr-mail-poll" class="mt-1 w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm text-gray-800 dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||||
|
value="<%= c.MAIL_POLL_MS || '180000' %>">
|
||||||
|
<p class="mt-1 text-[11px] text-gray-400 dark:text-gray-500">greift nach Neustart</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- CalDAV -->
|
||||||
|
<div class="mt-7">
|
||||||
|
<h3 class="text-sm font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Bewerbungskalender (CalDAV)</h3>
|
||||||
|
<div class="mt-3 grid gap-4 sm:grid-cols-2">
|
||||||
|
<div class="sm:col-span-2">
|
||||||
|
<label for="einr-caldav-url" class="block text-xs font-medium text-gray-500 dark:text-gray-400">Kalender-URL</label>
|
||||||
|
<input type="text" id="einr-caldav-url" class="mt-1 w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm text-gray-800 dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||||
|
placeholder="https://mail.example.com/SOGo/dav/name@…/Calendar/XXXX/" value="<%= c.CALDAV_URL || '' %>">
|
||||||
|
<p class="mt-1 text-[11px] text-gray-400 dark:text-gray-500">Voll-URL der Sammlung, mit abschließendem /. Anmeldung über Benutzername/Passwort oben.</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="einr-caldav-alarm" class="block text-xs font-medium text-gray-500 dark:text-gray-400">Erinnerung (Min. vor Termin)</label>
|
||||||
|
<input type="text" id="einr-caldav-alarm" class="mt-1 w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm text-gray-800 dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||||
|
value="<%= c.CALDAV_ALARM_MIN || '60' %>">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="einr-caldav-poll" class="block text-xs font-medium text-gray-500 dark:text-gray-400">Sync-Intervall (ms)</label>
|
||||||
|
<input type="text" id="einr-caldav-poll" class="mt-1 w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm text-gray-800 dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||||
|
value="<%= c.CALDAV_POLL_MS || '300000' %>">
|
||||||
|
<p class="mt-1 text-[11px] text-gray-400 dark:text-gray-500">greift nach Neustart</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Schritt 6: REST-API -->
|
||||||
|
<div class="einr-panel hidden" data-panel="6">
|
||||||
|
<h2 class="text-lg font-semibold text-gray-800 dark:text-white">REST-API (optional)</h2>
|
||||||
|
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
Ist ein Token gesetzt, ist <code class="rounded bg-gray-100 px-1 dark:bg-gray-700">/api/v1</code>
|
||||||
|
für dich aktiv und erwartet den Wert im Header
|
||||||
|
<code class="rounded bg-gray-100 px-1 dark:bg-gray-700">X-API-Key</code>.
|
||||||
|
Der Token identifiziert dein Konto — Anfragen sehen und ändern nur deine
|
||||||
|
eigenen Daten. Swagger unter <a href="/swagger" class="text-blue-600 hover:underline dark:text-sky-400">/swagger</a>.
|
||||||
|
</p>
|
||||||
|
<div class="mt-5">
|
||||||
|
<label for="einr-api-token" class="block text-sm font-medium text-gray-700 dark:text-gray-300">API-Token</label>
|
||||||
|
<div class="relative mt-1">
|
||||||
|
<input type="password" id="einr-api-token"
|
||||||
|
class="w-full rounded-md border border-gray-300 bg-white px-3 py-2 pr-10 text-sm text-gray-800 dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||||
|
placeholder="<%= c.hasApiToken ? 'vorhanden — leer lassen, um nichts zu ändern' : 'Token erzeugen oder eintragen' %>" autocomplete="off">
|
||||||
|
<button type="button" class="einr-reveal absolute inset-y-0 right-0 flex items-center px-3 text-gray-400 hover:text-gray-600 dark:hover:text-gray-200" data-target="einr-api-token" title="Wert anzeigen/verbergen" tabindex="-1">
|
||||||
|
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"/></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="mt-2 flex flex-wrap items-center gap-2">
|
||||||
|
<button type="button" id="einr-token-gen"
|
||||||
|
class="inline-flex items-center gap-1.5 rounded-md border border-gray-300 px-3 py-1.5 text-xs text-gray-700 transition hover:bg-gray-100 dark:border-gray-600 dark:text-gray-200 dark:hover:bg-gray-700">
|
||||||
|
<svg class="h-3.5 w-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/></svg>
|
||||||
|
Neu generieren
|
||||||
|
</button>
|
||||||
|
<button type="button" id="einr-token-copy"
|
||||||
|
class="inline-flex items-center gap-1.5 rounded-md border border-gray-300 px-3 py-1.5 text-xs text-gray-700 transition hover:bg-gray-100 dark:border-gray-600 dark:text-gray-200 dark:hover:bg-gray-700">
|
||||||
|
<svg class="h-3.5 w-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"/></svg>
|
||||||
|
Kopieren
|
||||||
|
</button>
|
||||||
|
<span id="einr-token-hint" class="hidden text-xs text-amber-600 dark:text-amber-400"></span>
|
||||||
|
<% if (c.hasApiToken) { %><span class="text-xs text-green-600 dark:text-green-400">· bereits hinterlegt</span><% } %>
|
||||||
|
</div>
|
||||||
|
<p class="mt-2 text-[11px] text-gray-400 dark:text-gray-500">Leer lassen = vorhandenen Token nicht anfassen. Ein neuer Token macht den alten sofort ungültig.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Schritt 7: Abschluss -->
|
||||||
|
<div class="einr-panel hidden" data-panel="7">
|
||||||
|
<h2 class="text-lg font-semibold text-gray-800 dark:text-white">Fast geschafft!</h2>
|
||||||
|
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
Hier ist dein Stand. Fehlende Punkte lassen sich jederzeit unter
|
||||||
|
„Vorlagen“ oder „Einstellungen“ nachholen.
|
||||||
|
</p>
|
||||||
|
<ul class="mt-5 divide-y divide-gray-100 dark:divide-gray-700/60 overflow-hidden rounded-lg border border-gray-200 dark:border-gray-700">
|
||||||
|
<li class="flex items-center gap-3 px-4 py-3" data-check="profil"><span class="einr-ico grid h-6 w-6 place-items-center rounded-full"></span><span class="text-sm text-gray-700 dark:text-gray-200">Persönliche Angaben</span></li>
|
||||||
|
<li class="flex items-center gap-3 px-4 py-3" data-check="anschreiben"><span class="einr-ico grid h-6 w-6 place-items-center rounded-full"></span><span class="text-sm text-gray-700 dark:text-gray-200">Standard-Anschreiben hinterlegt</span></li>
|
||||||
|
<li class="flex items-center gap-3 px-4 py-3" data-check="lebenslauf"><span class="einr-ico grid h-6 w-6 place-items-center rounded-full"></span><span class="text-sm text-gray-700 dark:text-gray-200">Lebenslauf hinterlegt</span></li>
|
||||||
|
<li class="flex items-center gap-3 px-4 py-3" data-check="ki"><span class="einr-ico grid h-6 w-6 place-items-center rounded-full"></span><span class="text-sm text-gray-700 dark:text-gray-200">Ollama-KI aktiv</span></li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<p class="mt-5 text-xs font-semibold uppercase tracking-wide text-gray-400 dark:text-gray-500">Weitere Verbindungen (optional)</p>
|
||||||
|
<ul class="mt-2 divide-y divide-gray-100 dark:divide-gray-700/60 overflow-hidden rounded-lg border border-gray-200 dark:border-gray-700">
|
||||||
|
<li class="flex items-center gap-3 px-4 py-3" data-opt="mail"><span class="einr-ico grid h-6 w-6 place-items-center rounded-full"></span><span class="text-sm text-gray-700 dark:text-gray-200">E-Mail (SMTP/IMAP)</span></li>
|
||||||
|
<li class="flex items-center gap-3 px-4 py-3" data-opt="kalender"><span class="einr-ico grid h-6 w-6 place-items-center rounded-full"></span><span class="text-sm text-gray-700 dark:text-gray-200">Bewerbungskalender (CalDAV)</span></li>
|
||||||
|
<li class="flex items-center gap-3 px-4 py-3" data-opt="api"><span class="einr-ico grid h-6 w-6 place-items-center rounded-full"></span><span class="text-sm text-gray-700 dark:text-gray-200">REST-API-Token</span></li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<div class="mt-5 rounded-lg bg-blue-50 p-4 text-sm text-blue-800 dark:bg-blue-500/10 dark:text-blue-200">
|
||||||
|
<p class="font-medium">Empfehlung: automatische Stellensuche einrichten</p>
|
||||||
|
<p class="mt-1 text-blue-700/90 dark:text-blue-200/80">
|
||||||
|
Lege unter „Stellensuche → Suchprofil & Läufe“ fest, wo und wonach
|
||||||
|
automatisch gesucht wird. So landen passende Stellenangebote direkt hier.
|
||||||
|
</p>
|
||||||
|
<div class="mt-3 flex flex-wrap gap-2">
|
||||||
|
<a href="/jobsuche" class="inline-flex items-center gap-1.5 rounded-md bg-blue-600 px-3 py-1.5 text-xs font-medium text-white transition hover:bg-blue-700">Suchprofil öffnen</a>
|
||||||
|
<a href="/vorlagen" class="inline-flex items-center gap-1.5 rounded-md bg-white px-3 py-1.5 text-xs font-medium text-blue-600 ring-1 ring-blue-200 transition hover:bg-blue-50 dark:bg-transparent dark:text-blue-200 dark:ring-blue-400/40 dark:hover:bg-white/5">Vorlagen verfeinern</a>
|
||||||
|
<a href="/einstellungen" class="inline-flex items-center gap-1.5 rounded-md bg-white px-3 py-1.5 text-xs font-medium text-blue-600 ring-1 ring-blue-200 transition hover:bg-blue-50 dark:bg-transparent dark:text-blue-200 dark:ring-blue-400/40 dark:hover:bg-white/5">Alle Einstellungen</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Fuß-Navigation -->
|
||||||
|
<div class="mt-8 flex flex-wrap items-center justify-between gap-3 border-t border-gray-200 pt-5 dark:border-gray-700">
|
||||||
|
<button type="button" id="einrZurueck"
|
||||||
|
class="inline-flex items-center gap-1.5 rounded-md px-3.5 py-2 text-sm font-medium text-gray-600 transition hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-700">
|
||||||
|
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"/></svg>
|
||||||
|
Zurück
|
||||||
|
</button>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<button type="button" id="einrDirekt"
|
||||||
|
class="hidden text-sm font-medium text-blue-600 hover:underline dark:text-sky-400">
|
||||||
|
Bewerbung manuell anlegen
|
||||||
|
</button>
|
||||||
|
<button type="button" id="einrUeberspringen"
|
||||||
|
class="rounded-md px-3.5 py-2 text-sm font-medium text-gray-600 transition hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-700">
|
||||||
|
Überspringen
|
||||||
|
</button>
|
||||||
|
<button type="button" id="einrWeiter"
|
||||||
|
class="inline-flex items-center gap-1.5 rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white shadow-sm transition hover:bg-blue-700 disabled:cursor-not-allowed disabled:opacity-60">
|
||||||
|
<span id="einrWeiterLabel">Los geht's</span>
|
||||||
|
<svg id="einrWeiterIcon" class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/></svg>
|
||||||
|
<svg id="einrSpinner" class="hidden h-4 w-4 animate-spin" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"/><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z"/></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p id="einrFehler" class="mt-3 hidden text-sm text-red-600 dark:text-red-400"></p>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
var form = document.getElementById('einrForm');
|
||||||
|
if (!form) return;
|
||||||
|
|
||||||
|
var MAX = 7;
|
||||||
|
var step = 1;
|
||||||
|
var panels = {};
|
||||||
|
Array.prototype.slice.call(form.querySelectorAll('.einr-panel')).forEach(function (el) {
|
||||||
|
panels[el.getAttribute('data-panel')] = el;
|
||||||
|
});
|
||||||
|
var stepEls = Array.prototype.slice.call(document.querySelectorAll('#einrSteps .einr-step'));
|
||||||
|
|
||||||
|
// Vorhandene Secrets kennen wir nur als Flag — der Wert steht nie im Formular.
|
||||||
|
var VORHANDEN = {
|
||||||
|
apiKey: <%= setupWizard.hasApiKey ? 'true' : 'false' %>,
|
||||||
|
mailPassword: <%= (c.hasMailPassword) ? 'true' : 'false' %>,
|
||||||
|
apiToken: <%= (c.hasApiToken) ? 'true' : 'false' %>
|
||||||
|
};
|
||||||
|
|
||||||
|
var btnZurueck = document.getElementById('einrZurueck');
|
||||||
|
var btnUeberspringen = document.getElementById('einrUeberspringen');
|
||||||
|
var btnWeiter = document.getElementById('einrWeiter');
|
||||||
|
var lblWeiter = document.getElementById('einrWeiterLabel');
|
||||||
|
var iconWeiter = document.getElementById('einrWeiterIcon');
|
||||||
|
var spinner = document.getElementById('einrSpinner');
|
||||||
|
var btnDirekt = document.getElementById('einrDirekt');
|
||||||
|
var btnAusblenden = document.getElementById('einrAusblenden');
|
||||||
|
var fehler = document.getElementById('einrFehler');
|
||||||
|
|
||||||
|
var labels = { 1: 'Los geht\'s', 2: 'Weiter', 3: 'Weiter', 4: 'Weiter', 5: 'Weiter', 6: 'Weiter', 7: 'Abschließen' };
|
||||||
|
|
||||||
|
function v(sel) { var el = form.querySelector(sel); return el ? el.value : ''; }
|
||||||
|
function showFehler(msg) { fehler.textContent = msg || ''; fehler.classList.toggle('hidden', !msg); }
|
||||||
|
function setBusy(busy) {
|
||||||
|
btnWeiter.disabled = busy;
|
||||||
|
spinner.classList.toggle('hidden', !busy);
|
||||||
|
iconWeiter.classList.toggle('hidden', busy);
|
||||||
|
}
|
||||||
|
function check(r) { if (!r.ok || !r.json.success) throw new Error(r.json.error || 'Speichern fehlgeschlagen'); }
|
||||||
|
|
||||||
|
function render() {
|
||||||
|
Object.keys(panels).forEach(function (n) { panels[n].classList.toggle('hidden', String(n) !== String(step)); });
|
||||||
|
stepEls.forEach(function (el) {
|
||||||
|
var n = Number(el.getAttribute('data-step'));
|
||||||
|
var dot = el.querySelector('.einr-step-dot');
|
||||||
|
el.classList.toggle('is-active', n === step);
|
||||||
|
if (n < step) {
|
||||||
|
dot.style.backgroundColor = 'rgba(255,255,255,0.92)';
|
||||||
|
dot.style.color = '#1d4ed8';
|
||||||
|
dot.innerHTML = '<svg class="h-3.5 w-3.5" fill="none" stroke="currentColor" stroke-width="3" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7"/></svg>';
|
||||||
|
} else if (n === step) {
|
||||||
|
dot.style.backgroundColor = '#ffffff';
|
||||||
|
dot.style.color = '#1d4ed8';
|
||||||
|
dot.textContent = String(n);
|
||||||
|
} else {
|
||||||
|
dot.style.backgroundColor = '';
|
||||||
|
dot.style.color = '';
|
||||||
|
dot.textContent = String(n);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
btnZurueck.classList.toggle('hidden', step === 1);
|
||||||
|
btnUeberspringen.classList.toggle('hidden', step === 1 || step === MAX);
|
||||||
|
btnDirekt.classList.toggle('hidden', step !== 1 && step !== MAX);
|
||||||
|
lblWeiter.textContent = labels[step] || 'Weiter';
|
||||||
|
}
|
||||||
|
|
||||||
|
function gehZu(n) {
|
||||||
|
step = Math.max(1, Math.min(MAX, n));
|
||||||
|
if (step === MAX) { aktualiereStatus(); }
|
||||||
|
render();
|
||||||
|
var sec = document.getElementById('einrichtung');
|
||||||
|
if (sec) sec.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ico für Pflicht-Checkliste: grün = erledigt, orange = offen.
|
||||||
|
function setzeCheck(name, ok) {
|
||||||
|
var li = form.querySelector('[data-check="' + name + '"]');
|
||||||
|
if (!li) return;
|
||||||
|
var ico = li.querySelector('.einr-ico');
|
||||||
|
ico.style.backgroundColor = ok ? 'rgba(16,185,129,0.12)' : 'rgba(245,158,11,0.12)';
|
||||||
|
ico.style.color = ok ? '#059669' : '#d97706';
|
||||||
|
ico.innerHTML = ok
|
||||||
|
? '<svg class="h-3.5 w-3.5" fill="none" stroke="currentColor" stroke-width="3" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7"/></svg>'
|
||||||
|
: '<svg class="h-3.5 w-3.5" fill="none" stroke="currentColor" stroke-width="2.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M12 8v4m0 4h.01"/></svg>';
|
||||||
|
}
|
||||||
|
// ico für optionale Verbindungen: grün = eingerichtet, neutral = optional/offen.
|
||||||
|
function setzeOpt(name, ok) {
|
||||||
|
var li = form.querySelector('[data-opt="' + name + '"]');
|
||||||
|
if (!li) return;
|
||||||
|
var ico = li.querySelector('.einr-ico');
|
||||||
|
ico.style.backgroundColor = ok ? 'rgba(16,185,129,0.12)' : 'rgba(148,163,184,0.15)';
|
||||||
|
ico.style.color = ok ? '#059669' : '#94a3b8';
|
||||||
|
ico.innerHTML = ok
|
||||||
|
? '<svg class="h-3.5 w-3.5" fill="none" stroke="currentColor" stroke-width="3" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7"/></svg>'
|
||||||
|
: '<svg class="h-3.5 w-3.5" fill="none" stroke="currentColor" stroke-width="2.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M5 12h14"/></svg>';
|
||||||
|
}
|
||||||
|
|
||||||
|
function aktualiereStatus() {
|
||||||
|
setzeCheck('profil', !!v('#einr-name').trim());
|
||||||
|
setzeCheck('anschreiben', !!v('#einr-anschreiben').trim());
|
||||||
|
setzeCheck('lebenslauf', !!v('#einr-lebenslauf').trim());
|
||||||
|
setzeCheck('ki', !!v('#einr-apikey').trim() || VORHANDEN.apiKey);
|
||||||
|
setzeOpt('mail', !!v('#einr-mail-host').trim());
|
||||||
|
setzeOpt('kalender', !!v('#einr-caldav-url').trim());
|
||||||
|
setzeOpt('api', !!v('#einr-api-token').trim() || VORHANDEN.apiToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
function post(url, data) {
|
||||||
|
var body = new URLSearchParams();
|
||||||
|
Object.keys(data).forEach(function (k) { body.append(k, data[k]); });
|
||||||
|
return fetch(url, { method: 'POST', body: body, headers: { 'X-Requested-With': 'fetch' } })
|
||||||
|
.then(function (r) { return r.json().then(function (j) { return { ok: r.ok, json: j }; }); });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Speichert den Inhalt des aktuellen Schritts vor dem Weiterspringen.
|
||||||
|
function speichern(stepNr) {
|
||||||
|
if (stepNr === 2) {
|
||||||
|
if (!v('#einr-name').trim()) {
|
||||||
|
showFehler('Bitte gib mindestens deinen Namen ein — das ist das einzige Pflichtfeld.');
|
||||||
|
return Promise.reject();
|
||||||
|
}
|
||||||
|
return post('/api/einrichtung/persoenlich', {
|
||||||
|
name: v('#einr-name'), adresse: v('#einr-adresse'), kundennummer: v('#einr-kundennummer'),
|
||||||
|
email: v('#einr-email'), telefon: v('#einr-telefon'), ort: v('#einr-ort'),
|
||||||
|
webseite: v('#einr-webseite'), geburtsdatum: v('#einr-geburtsdatum')
|
||||||
|
}).then(check);
|
||||||
|
}
|
||||||
|
if (stepNr === 3) {
|
||||||
|
return post('/api/einrichtung/basis', { typ: 'Anschreiben', inhalt: v('#einr-anschreiben') })
|
||||||
|
.then(function () { return post('/api/einrichtung/basis', { typ: 'Lebenslauf', inhalt: v('#einr-lebenslauf') }); })
|
||||||
|
.then(check);
|
||||||
|
}
|
||||||
|
if (stepNr === 4) {
|
||||||
|
return post('/api/einrichtung/ollama', {
|
||||||
|
OLLAMA_API_KEY: v('#einr-apikey'), OLLAMA_MODEL: v('#einr-model'), OLLAMA_HOST: v('#einr-host')
|
||||||
|
}).then(check);
|
||||||
|
}
|
||||||
|
if (stepNr === 5) {
|
||||||
|
var data = {
|
||||||
|
MAIL_HOST: v('#einr-mail-host'), MAIL_SMTP_PORT: v('#einr-mail-smtp-port'),
|
||||||
|
MAIL_IMAP_PORT: v('#einr-mail-imap-port'), MAIL_USER: v('#einr-mail-user'),
|
||||||
|
MAIL_FROM_NAME: v('#einr-mail-fromname'), MAIL_FROM: v('#einr-mail-from'),
|
||||||
|
MAIL_IMAP_MAILBOX: v('#einr-mail-mailbox'), MAIL_POLL_MS: v('#einr-mail-poll'),
|
||||||
|
CALDAV_URL: v('#einr-caldav-url'), CALDAV_ALARM_MIN: v('#einr-caldav-alarm'),
|
||||||
|
CALDAV_POLL_MS: v('#einr-caldav-poll')
|
||||||
|
};
|
||||||
|
var pw = v('#einr-mail-password');
|
||||||
|
if (pw) data.MAIL_PASSWORD = pw; // leer = vorhandenes Passwort nicht überschreiben
|
||||||
|
return post('/api/einrichtung/config', data).then(check);
|
||||||
|
}
|
||||||
|
if (stepNr === 6) {
|
||||||
|
var token = v('#einr-api-token');
|
||||||
|
var data = {};
|
||||||
|
if (token) data.API_TOKEN = token; // leer = vorhandenen Token nicht anfassen
|
||||||
|
return post('/api/einrichtung/config', data).then(check);
|
||||||
|
}
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
|
||||||
|
btnWeiter.addEventListener('click', function () {
|
||||||
|
showFehler('');
|
||||||
|
if (step === MAX) {
|
||||||
|
post('/api/einrichtung/verstecken', {}).then(function () { window.location.href = '/'; });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setBusy(true);
|
||||||
|
speichern(step).then(function () {
|
||||||
|
// Nach dem Speichern ggf. „vorhanden"-Flags aktualisieren, damit die
|
||||||
|
// Abschluss-Checkliste korrekt steht (z. B. neu erzeugter API-Token).
|
||||||
|
if (step === 4 && v('#einr-apikey')) VORHANDEN.apiKey = VORHANDEN.apiKey || !!v('#einr-apikey').trim();
|
||||||
|
if (step === 5 && v('#einr-mail-password')) VORHANDEN.mailPassword = VORHANDEN.mailPassword || !!v('#einr-mail-password').trim();
|
||||||
|
if (step === 6 && v('#einr-api-token')) VORHANDEN.apiToken = VORHANDEN.apiToken || !!v('#einr-api-token').trim();
|
||||||
|
setBusy(false);
|
||||||
|
gehZu(step + 1);
|
||||||
|
}).catch(function (e) {
|
||||||
|
setBusy(false);
|
||||||
|
if (e && e.message) showFehler(e.message);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
btnZurueck.addEventListener('click', function () { showFehler(''); gehZu(step - 1); });
|
||||||
|
btnUeberspringen.addEventListener('click', function () { showFehler(''); gehZu(step + 1); });
|
||||||
|
btnDirekt.addEventListener('click', function () { if (typeof window.openAddApplicationModal === 'function') window.openAddApplicationModal(); });
|
||||||
|
btnAusblenden.addEventListener('click', function () { post('/api/einrichtung/verstecken', {}).then(function () { window.location.href = '/'; }); });
|
||||||
|
|
||||||
|
// Reveal-Toggle für alle Passwort-/Secret-Felder (ein shared Handler).
|
||||||
|
Array.prototype.slice.call(form.querySelectorAll('.einr-reveal')).forEach(function (btn) {
|
||||||
|
btn.addEventListener('click', function () {
|
||||||
|
var inp = document.getElementById(btn.getAttribute('data-target'));
|
||||||
|
if (inp) inp.type = inp.type === 'password' ? 'text' : 'password';
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// API-Token neu generieren (32 Byte hex, clientseitig CSPRNG).
|
||||||
|
var gen = document.getElementById('einr-token-gen');
|
||||||
|
var copy = document.getElementById('einr-token-copy');
|
||||||
|
var hinweis = document.getElementById('einr-token-hint');
|
||||||
|
function tokenHint(text, ok) {
|
||||||
|
hinweis.textContent = text;
|
||||||
|
hinweis.classList.remove('hidden');
|
||||||
|
hinweis.classList.toggle('text-amber-600', !ok); hinweis.classList.toggle('dark:text-amber-400', !ok);
|
||||||
|
hinweis.classList.toggle('text-green-600', ok); hinweis.classList.toggle('dark:text-green-400', ok);
|
||||||
|
}
|
||||||
|
if (gen) gen.addEventListener('click', function () {
|
||||||
|
var inp = document.getElementById('einr-api-token');
|
||||||
|
if (!inp) return;
|
||||||
|
var bytes = new Uint8Array(32); crypto.getRandomValues(bytes);
|
||||||
|
inp.value = Array.from(bytes).map(function (b) { return b.toString(16).padStart(2, '0'); }).join('');
|
||||||
|
inp.type = 'text';
|
||||||
|
tokenHint('Neuer Token erzeugt — zum Aktivieren „Weiter". Der alte wird damit ungültig.', false);
|
||||||
|
});
|
||||||
|
if (copy) copy.addEventListener('click', async function () {
|
||||||
|
var inp = document.getElementById('einr-api-token');
|
||||||
|
if (!inp || !inp.value) return;
|
||||||
|
try { await navigator.clipboard.writeText(inp.value); tokenHint('In die Zwischenablage kopiert.', true); }
|
||||||
|
catch (e) { inp.type = 'text'; inp.select(); tokenHint('Kopieren nicht möglich — Wert ist markiert.', false); }
|
||||||
|
});
|
||||||
|
|
||||||
|
render();
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
<%
|
||||||
|
// Stern-Button zum Merken/Entmerken. Generisch einsetzbar für Jobangebote
|
||||||
|
// (Standard-Endpunkt /jobangebote/:id/favorit) und Bewerbungen — dann `endpoint`
|
||||||
|
// mitgeben. Entweder `j` (legacy, braucht id + favorit) oder direkt `id`/`favorit`.
|
||||||
|
// Zustand wird über die Klasse .is-fav gesteuert (definiert in favorit-script).
|
||||||
|
// Mit `compact` wird ein reines Icon-Button (für Listenzeilen). Das Skript aus
|
||||||
|
// partials/favorit-script bedient Klicks per Delegation — nur einmal pro Seite
|
||||||
|
// einbinden.
|
||||||
|
var favItem = (typeof item !== 'undefined' && item) ? item : (typeof j !== 'undefined' ? j : null);
|
||||||
|
var favId = (typeof id !== 'undefined' && id != null) ? id : (favItem && favItem.id);
|
||||||
|
var favAktiv = !!(typeof favorit !== 'undefined' ? favorit : (favItem && favItem.favorit));
|
||||||
|
var favEndpoint = (typeof endpoint !== 'undefined' && endpoint)
|
||||||
|
? endpoint
|
||||||
|
: ('/jobangebote/' + encodeURIComponent(favId) + '/favorit');
|
||||||
|
var favCompact = !!(typeof compact !== 'undefined' && compact);
|
||||||
|
%>
|
||||||
|
<button type="button" class="fav-star<%= favAktiv ? ' is-fav' : '' %> inline-flex items-center justify-center gap-1.5 rounded-md <%= favCompact ? 'p-1' : 'px-3 py-1.5 border' %> text-xs font-medium transition-colors"
|
||||||
|
data-favorit="<%= favId %>" data-aktiv="<%= favAktiv ? 1 : 0 %>" data-endpoint="<%= favEndpoint %>"
|
||||||
|
title="<%= favAktiv ? 'Favorit entfernen' : 'Als Favorit merken' %>"
|
||||||
|
aria-pressed="<%= favAktiv ? 'true' : 'false' %>">
|
||||||
|
<svg class="fav-ico h-4 w-4" viewBox="0 0 24 24" fill="<%= favAktiv ? 'currentColor' : 'none' %>" stroke="currentColor" stroke-width="2">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M11.48 3.5l2.6 5.27 5.82.85-4.21 4.1.99 5.79-5.2-2.73-5.2 2.73.99-5.79-4.21-4.1 5.82-.85 2.6-5.27z"/>
|
||||||
|
</svg>
|
||||||
|
<% if (!favCompact) { %><span class="fav-label"><%= favAktiv ? 'Gemerkt' : 'Merken' %></span><% } else { %><span class="fav-label hidden"><%= favAktiv ? 'Gemerkt' : 'Merken' %></span><% } %>
|
||||||
|
</button>
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
<style>
|
||||||
|
/* Stern-Button: neutral grau, gemerkt amber. Klasse .is-fav steuert den
|
||||||
|
Zustand; das Skript unten schaltet sie per Klick um (optimistisch). */
|
||||||
|
.fav-star { color: rgb(107 114 128); } /* gray-500 */
|
||||||
|
.fav-star:hover { background-color: rgb(243 244 246); } /* gray-100 */
|
||||||
|
.dark .fav-star { color: rgb(209 213 219); }
|
||||||
|
.dark .fav-star:hover { background-color: rgb(55 65 81); } /* gray-700 */
|
||||||
|
.fav-star.is-fav {
|
||||||
|
color: rgb(217 119 6); /* amber-600 */
|
||||||
|
background-color: rgb(255 251 235); /* amber-50 */
|
||||||
|
border-color: rgb(252 211 77); /* amber-300 */
|
||||||
|
}
|
||||||
|
.dark .fav-star.is-fav {
|
||||||
|
color: rgb(252 211 77); /* amber-300 */
|
||||||
|
background-color: rgba(245, 158, 11, 0.10);
|
||||||
|
border-color: rgba(251, 191, 36, 0.40);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
if (window.__favStarBound) return;
|
||||||
|
window.__favStarBound = true;
|
||||||
|
|
||||||
|
document.addEventListener('click', function (e) {
|
||||||
|
var btn = e.target.closest('[data-favorit]');
|
||||||
|
if (!btn) return;
|
||||||
|
e.preventDefault();
|
||||||
|
var id = btn.getAttribute('data-favorit');
|
||||||
|
var aktiv = btn.getAttribute('data-aktiv') === '1';
|
||||||
|
var endpoint = btn.getAttribute('data-endpoint') || ('/jobangebote/' + encodeURIComponent(id) + '/favorit');
|
||||||
|
|
||||||
|
// Optimistisch umschalten, sofort sichtbar.
|
||||||
|
setzeBtn(btn, !aktiv);
|
||||||
|
|
||||||
|
fetch(endpoint, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'X-Requested-With': 'fetch' }
|
||||||
|
})
|
||||||
|
.then(function (r) { return r.json().then(function (j) { return { ok: r.ok, json: j }; }); })
|
||||||
|
.then(function (r) {
|
||||||
|
if (r.ok && r.json && r.json.success) {
|
||||||
|
setzeBtn(btn, !!r.json.favorit);
|
||||||
|
// Zeile ausblenden, sobald sie nicht mehr in die aktuelle
|
||||||
|
// Ansicht gehört: auf der Offen-Seite beim Merken (favorit=1),
|
||||||
|
// auf der Gemerkt-Seite / Startseite beim Entfernen (favorit=0).
|
||||||
|
entferneFavoritZeile(btn, !!r.json.favorit);
|
||||||
|
} else {
|
||||||
|
setzeBtn(btn, aktiv); // Server hat abgelehnt -> zurückrollen
|
||||||
|
if (r.json && r.json.error) console.warn('Favorit:', r.json.error);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(function () { setzeBtn(btn, aktiv); });
|
||||||
|
});
|
||||||
|
|
||||||
|
function setzeBtn(btn, aktiv) {
|
||||||
|
btn.setAttribute('data-aktiv', aktiv ? '1' : '0');
|
||||||
|
btn.setAttribute('aria-pressed', aktiv ? 'true' : 'false');
|
||||||
|
btn.setAttribute('title', aktiv ? 'Favorit entfernen' : 'Als Favorit merken');
|
||||||
|
btn.classList.toggle('is-fav', !!aktiv);
|
||||||
|
var ico = btn.querySelector('.fav-ico');
|
||||||
|
if (ico) ico.setAttribute('fill', aktiv ? 'currentColor' : 'none');
|
||||||
|
var lbl = btn.querySelector('.fav-label');
|
||||||
|
if (lbl) lbl.textContent = aktiv ? 'Gemerkt' : 'Merken';
|
||||||
|
}
|
||||||
|
|
||||||
|
function entferneFavoritZeile(btn, istFavorit) {
|
||||||
|
var item = btn.closest('[data-fav-item]');
|
||||||
|
if (!item) return; // keine ausblendbare Zeile -> nur optisch umschalten
|
||||||
|
var card = btn.closest('[data-fav-card]') || document.getElementById('favoritenCard');
|
||||||
|
// Offen-Seite: Zeile verschwindet beim Merken (favorit=1).
|
||||||
|
// Gemerkt-Seite / Startseite: Zeile verschwindet beim Entfernen (favorit=0).
|
||||||
|
var view = card && card.getAttribute('data-fav-view');
|
||||||
|
var entfernenBeiFavorit = (view === 'offen');
|
||||||
|
if (istFavorit !== entfernenBeiFavorit) return;
|
||||||
|
item.remove();
|
||||||
|
// Auf der Offen-Seite bleibt die Sektion samt Überschrift stehen —
|
||||||
|
// dort wird nur die einzelne Zeile entfernt. Auf der Gemerkt-Seite
|
||||||
|
// und Startseite räumen wir die leere Sektion ganz ein.
|
||||||
|
if (view === 'offen') return;
|
||||||
|
if (card && !card.querySelectorAll('[data-fav-item]').length) {
|
||||||
|
card.style.transition = 'opacity 0.2s ease';
|
||||||
|
card.style.opacity = '0';
|
||||||
|
setTimeout(function () { card.remove(); }, 180);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
@@ -1,8 +1,27 @@
|
|||||||
<footer class="bg-gray-100 dark:bg-gray-800 border-t border-gray-200 dark:border-gray-700 mt-12 py-6">
|
<footer class="mt-12 border-t border-gray-200/80 dark:border-white/5">
|
||||||
<div class="container mx-auto px-4 text-center">
|
<div class="container mx-auto px-4 py-8 text-center">
|
||||||
<p class="text-gray-600 dark:text-gray-400 text-sm">
|
<p class="text-xs text-gray-400 dark:text-slate-500">
|
||||||
Bewerbungs-Tracker für Jobcenter Grundsicherung |
|
NextJobs · © nextjobs.cc <span id="currentYear"><%= new Date().getFullYear() %></span>
|
||||||
<span id="currentYear"></span>
|
</p>
|
||||||
|
<p class="mt-2 text-xs">
|
||||||
|
<a href="/plugin/nextjobs-stellen-import.zip"
|
||||||
|
class="inline-flex items-center gap-1 text-gray-500 dark:text-slate-400 hover:text-blue-600 dark:hover:text-blue-400 underline underline-offset-2"
|
||||||
|
title="Browser-Erweiterung (Chrome/Edge) herunterladen">
|
||||||
|
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v2a2 2 0 002 2h12a2 2 0 002-2v-2M7 10l5 5m0 0l5-5m-5 5V4"></path>
|
||||||
|
</svg>
|
||||||
|
Browser-Plugin herunterladen
|
||||||
|
</a>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
|
<!-- Floating button: open the KI chat assistant -->
|
||||||
|
<a href="/chat"
|
||||||
|
class="fixed bottom-5 right-5 z-40 flex items-center gap-2 rounded-full bg-blue-600 hover:bg-blue-700 text-white shadow-lg px-4 py-3 text-sm font-medium transition-colors"
|
||||||
|
title="KI-Bewerbungs-Assistent öffnen">
|
||||||
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.86 9.86 0 01-4-.8L3 20l1.3-3.9A7.97 7.97 0 013 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"></path>
|
||||||
|
</svg>
|
||||||
|
<span class="hidden sm:inline">KI-Chat</span>
|
||||||
|
</a>
|
||||||
|
|||||||
+15
-3
@@ -1,11 +1,23 @@
|
|||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>Bewerbungs-Tracker</title>
|
<meta name="theme-color" content="#2563eb">
|
||||||
|
<title>NextJobs</title>
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
|
||||||
|
<!-- Apply the dark class before first paint to avoid a light-mode flash (FOUC).
|
||||||
|
Theme follows the browser/OS setting (prefers-color-scheme) — no manual toggle. -->
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
try {
|
||||||
|
if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
|
||||||
|
document.documentElement.classList.add('dark');
|
||||||
|
}
|
||||||
|
} catch (e) { /* ignore */ }
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
<!-- Tailwind CSS CDN -->
|
<!-- Tailwind CSS CDN -->
|
||||||
<script src="https://cdn.tailwindcss.com"></script>
|
<script src="https://cdn.tailwindcss.com"></script>
|
||||||
<script>
|
<script>
|
||||||
// Toggle dark mode via the `.dark` class (default is `media`, i.e. OS-driven)
|
|
||||||
tailwind.config = { darkMode: 'class' };
|
tailwind.config = { darkMode: 'class' };
|
||||||
</script>
|
</script>
|
||||||
<!-- Custom CSS -->
|
<!-- Custom CSS -->
|
||||||
<link rel="stylesheet" href="/css/styles.css">
|
<link rel="stylesheet" href="/css/styles.css?v=<%= assetVersion %>">
|
||||||
|
|||||||
+362
-116
@@ -1,117 +1,368 @@
|
|||||||
<header class="bg-gradient-to-r from-blue-700 to-blue-900 dark:from-gray-800 dark:to-gray-900 shadow-lg">
|
<%
|
||||||
<div class="container mx-auto px-4 py-4">
|
// Current path (res.locals.pfad, set in the auth middleware) drives the active
|
||||||
<div class="flex items-center justify-between">
|
// state. The old header had none — you could never tell which section you were
|
||||||
<a href="/" class="flex items-center space-x-3">
|
// in. Copied into a differently named local: re-declaring `pfad` here would
|
||||||
<svg class="w-8 h-8 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
// shadow the EJS local under `with(locals)`.
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path>
|
var aktPfad = (typeof pfad !== 'undefined' && pfad) ? String(pfad) : '/';
|
||||||
|
|
||||||
|
// Bewerbungen owns the dashboard and every single application page.
|
||||||
|
var istBewerbungen = (aktPfad === '/' || aktPfad.indexOf('/bewerbung') === 0);
|
||||||
|
// Offene Angebote only — "übernommen" lives under Stellensuche, with the rest
|
||||||
|
// of the search machinery.
|
||||||
|
var istAngebote = (aktPfad === '/jobangebote');
|
||||||
|
var istPostfach = (aktPfad.indexOf('/postfach') === 0);
|
||||||
|
var istVorlagen = (aktPfad.indexOf('/vorlagen') === 0);
|
||||||
|
var istStatistik = (aktPfad.indexOf('/statistik') === 0);
|
||||||
|
// The search machinery: how offers get found, what was taken, what is blocked.
|
||||||
|
var istSuchprofil = (aktPfad.indexOf('/jobsuche') === 0);
|
||||||
|
var istUebernommen = (aktPfad.indexOf('/jobangebote/uebernommen') === 0);
|
||||||
|
var istBlacklist = (aktPfad.indexOf('/blacklist') === 0);
|
||||||
|
var istStellensuche = istSuchprofil || istUebernommen || istBlacklist;
|
||||||
|
|
||||||
|
var istEinstellungen = (aktPfad.indexOf('/einstellungen') === 0);
|
||||||
|
var istAdmin = (aktPfad.indexOf('/admin') === 0);
|
||||||
|
|
||||||
|
var angemeldet = (typeof user !== 'undefined' && user);
|
||||||
|
var initiale = angemeldet ? String(user.username || '?').charAt(0).toUpperCase() : '?';
|
||||||
|
%>
|
||||||
|
<!-- The bar carries no surface of its own: it takes the page background, so it
|
||||||
|
reads as part of the page rather than as a lid on top of it. Held apart from
|
||||||
|
the content only by a hairline and, once scrolled, the faintest shadow.
|
||||||
|
Slightly translucent + blurred so content passing underneath stays quiet. -->
|
||||||
|
<header id="siteHeader"
|
||||||
|
class="sticky top-0 z-50 border-b border-gray-200/80 bg-gray-50/85 backdrop-blur-md transition-shadow duration-300 dark:border-white/5 dark:bg-gray-900/85">
|
||||||
|
<div class="container mx-auto px-4">
|
||||||
|
<div class="flex h-16 items-center gap-3">
|
||||||
|
|
||||||
|
<!-- Brand -->
|
||||||
|
<a href="/" class="nav-brand group flex shrink-0 items-center gap-2.5 rounded-lg">
|
||||||
|
<span class="grid h-9 w-9 place-items-center rounded-xl bg-gradient-to-br from-sky-400 to-blue-600 shadow-sm shadow-blue-600/20 ring-1 ring-black/5">
|
||||||
|
<svg class="h-5 w-5 text-white" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path>
|
||||||
</svg>
|
</svg>
|
||||||
<h1 class="text-xl font-bold text-white">Bewerbungs-Tracker</h1>
|
</span>
|
||||||
|
<span class="whitespace-nowrap text-[15px] font-semibold leading-none tracking-tight text-gray-900 dark:text-white">
|
||||||
|
NextJobs
|
||||||
|
</span>
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
<div class="flex items-center space-x-4">
|
<!-- Primary navigation (desktop), right-aligned next to the account
|
||||||
<!-- Notification bell: unread received e-mails (incl. auto-assigned replies) -->
|
cluster. Order follows the actual workflow: finden -> bewerben ->
|
||||||
<div class="relative" id="notifWrap">
|
antworten. -->
|
||||||
<button id="notifBtn" type="button"
|
<nav class="ml-auto hidden items-center gap-1 lg:flex" aria-label="Hauptnavigation">
|
||||||
class="relative flex items-center p-2 rounded-full bg-white/20 hover:bg-white/30 transition-colors text-white"
|
|
||||||
aria-label="Benachrichtigungen" title="Ungelesene E-Mails">
|
<a href="/" class="nav-item <%= istBewerbungen ? 'is-active' : '' %>">
|
||||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<span>Bewerbungen</span>
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9"></path>
|
<span class="nav-rail"></span>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<a href="/jobangebote" class="nav-item <%= istAngebote ? 'is-active' : '' %>">
|
||||||
|
<span>Jobangebote</span>
|
||||||
|
<span class="js-badge-angebote nav-badge nav-badge--green hidden">0</span>
|
||||||
|
<span class="nav-rail"></span>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<!-- Stellensuche: everything that produces or filters offers. -->
|
||||||
|
<div class="relative">
|
||||||
|
<button type="button" data-dd="ddSuche" aria-expanded="false" aria-haspopup="true"
|
||||||
|
class="nav-item <%= istStellensuche ? 'is-active' : '' %>">
|
||||||
|
<span>Stellensuche</span>
|
||||||
|
<svg class="dd-chevron h-3.5 w-3.5 text-slate-500 transition-transform duration-200" fill="none" stroke="currentColor" stroke-width="2.5" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M19 9l-7 7-7-7"></path>
|
||||||
</svg>
|
</svg>
|
||||||
<span id="notifBadge" class="hidden absolute -top-1 -right-1 min-w-[18px] h-[18px] px-1 flex items-center justify-center rounded-full bg-red-500 text-white text-[10px] font-bold ring-2 ring-blue-800 dark:ring-gray-900">0</span>
|
<span class="nav-rail"></span>
|
||||||
</button>
|
</button>
|
||||||
<div id="notifPanel" class="hidden absolute right-0 mt-2 w-80 max-w-[calc(100vw-2rem)] max-h-96 overflow-y-auto rounded-lg bg-white dark:bg-gray-800 shadow-2xl ring-1 ring-black/10 dark:ring-white/10 z-50 text-left">
|
|
||||||
<div class="flex items-center justify-between px-4 py-2.5 border-b border-gray-100 dark:border-gray-700 sticky top-0 bg-white dark:bg-gray-800">
|
<div id="ddSuche" class="dd-panel hidden w-80">
|
||||||
|
<a href="/jobsuche" class="dd-item <%= istSuchprofil ? 'is-active' : '' %>">
|
||||||
|
<svg class="dd-icon" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"></path>
|
||||||
|
</svg>
|
||||||
|
<span>
|
||||||
|
<span class="dd-title">Suchprofil & Läufe</span>
|
||||||
|
<span class="dd-desc">Wo und wann automatisch gesucht wird</span>
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
<a href="/jobangebote/uebernommen" class="dd-item <%= istUebernommen ? 'is-active' : '' %>">
|
||||||
|
<svg class="dd-icon" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12l2 2 4-4M7.835 4.697a3.42 3.42 0 001.946-.806 3.42 3.42 0 014.438 0 3.42 3.42 0 001.946.806 3.42 3.42 0 013.138 3.138 3.42 3.42 0 00.806 1.946 3.42 3.42 0 010 4.438 3.42 3.42 0 00-.806 1.946 3.42 3.42 0 01-3.138 3.138 3.42 3.42 0 00-1.946.806 3.42 3.42 0 01-4.438 0 3.42 3.42 0 00-1.946-.806 3.42 3.42 0 01-3.138-3.138 3.42 3.42 0 00-.806-1.946 3.42 3.42 0 010-4.438 3.42 3.42 0 00.806-1.946 3.42 3.42 0 013.138-3.138z"></path>
|
||||||
|
</svg>
|
||||||
|
<span>
|
||||||
|
<span class="dd-title">Übernommene Angebote</span>
|
||||||
|
<span class="dd-desc">Bereits in eine Bewerbung überführt</span>
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
<a href="/blacklist" class="dd-item <%= istBlacklist ? 'is-active' : '' %>">
|
||||||
|
<svg class="dd-icon" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636"></path>
|
||||||
|
</svg>
|
||||||
|
<span>
|
||||||
|
<span class="dd-title">Blacklist</span>
|
||||||
|
<span class="dd-desc">Taucht in keinem Suchlauf wieder auf</span>
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<a href="/postfach" class="nav-item <%= istPostfach ? 'is-active' : '' %>">
|
||||||
|
<span>Postfach</span>
|
||||||
|
<span class="js-badge-postfach nav-badge nav-badge--red hidden">0</span>
|
||||||
|
<span class="nav-rail"></span>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<a href="/vorlagen" class="nav-item <%= istVorlagen ? 'is-active' : '' %>">
|
||||||
|
<span>Vorlagen</span>
|
||||||
|
<span class="nav-rail"></span>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<a href="/statistik" class="nav-item <%= istStatistik ? 'is-active' : '' %>">
|
||||||
|
<span>Statistik</span>
|
||||||
|
<span class="nav-rail"></span>
|
||||||
|
</a>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<!-- Right cluster: notifications + account. Below lg the nav is hidden,
|
||||||
|
so `ml-auto` keeps this pinned right; from lg the nav already claims
|
||||||
|
the free space and the cluster just follows it. -->
|
||||||
|
<div class="ml-auto flex items-center gap-1.5 lg:ml-3">
|
||||||
|
|
||||||
|
<!-- Notifications. `dd-anchor`: auf schmalen Schirmen spannt sich das
|
||||||
|
Panel über den Header statt am Knopf zu kleben (siehe styles.css). -->
|
||||||
|
<div class="dd-anchor relative">
|
||||||
|
<button id="notifBtn" type="button" data-dd="notifPanel" aria-expanded="false"
|
||||||
|
class="icon-btn" aria-label="Benachrichtigungen" title="Ungelesene E-Mails">
|
||||||
|
<svg class="h-5 w-5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9"></path>
|
||||||
|
</svg>
|
||||||
|
<span id="notifBadge" class="dot-badge dot-badge--red hidden">0</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div id="notifPanel" class="dd-panel hidden w-80 max-w-[calc(100vw-2rem)] p-0">
|
||||||
|
<div class="sticky top-0 flex items-center justify-between rounded-t-xl border-b border-gray-100 bg-white px-4 py-2.5 dark:border-white/5 dark:bg-slate-800">
|
||||||
<span class="text-sm font-semibold text-gray-800 dark:text-white">Benachrichtigungen</span>
|
<span class="text-sm font-semibold text-gray-800 dark:text-white">Benachrichtigungen</span>
|
||||||
<button id="notifMarkAll" type="button" class="hidden text-xs text-blue-600 dark:text-blue-400 hover:underline">Alle gelesen</button>
|
<button id="notifMarkAll" type="button" class="hidden text-xs font-medium text-blue-600 hover:underline dark:text-sky-400">Alle gelesen</button>
|
||||||
|
</div>
|
||||||
|
<ul id="notifList" class="max-h-96 divide-y divide-gray-100 overflow-y-auto dark:divide-white/5"></ul>
|
||||||
|
<div id="notifEmpty" class="px-4 py-10 text-center text-sm text-gray-400 dark:text-slate-500">
|
||||||
|
Keine ungelesenen Nachrichten
|
||||||
</div>
|
</div>
|
||||||
<ul id="notifList" class="divide-y divide-gray-100 dark:divide-gray-700"></ul>
|
|
||||||
<div id="notifEmpty" class="px-4 py-8 text-center text-sm text-gray-400 dark:text-gray-500">Keine ungelesenen Nachrichten</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Postfach (unlinked incoming e-mails) link -->
|
<% if (angemeldet) { %>
|
||||||
<a href="/postfach"
|
<!-- Account -->
|
||||||
class="relative flex items-center gap-1.5 px-3 py-2 rounded-md bg-white/20 hover:bg-white/30 transition-colors text-white text-sm font-medium"
|
<div class="dd-anchor relative">
|
||||||
title="E-Mails im Postfach, die noch keiner Bewerbung zugeordnet sind">
|
<button type="button" data-dd="ddUser" aria-expanded="false" aria-haspopup="true"
|
||||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
class="nav-item !gap-2 !py-1.5 !pl-1.5 !pr-2">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"></path>
|
<span class="grid h-7 w-7 place-items-center rounded-full bg-blue-600/10 text-[11px] font-semibold text-blue-700 ring-1 ring-blue-600/20 dark:bg-white/10 dark:text-white dark:ring-white/15">
|
||||||
</svg>
|
<%= initiale %>
|
||||||
<span class="hidden sm:inline">Postfach</span>
|
</span>
|
||||||
<span id="unassignedBadge" class="hidden absolute -top-1 -right-1 min-w-[18px] h-[18px] px-1 flex items-center justify-center rounded-full bg-red-500 text-white text-[10px] font-bold ring-2 ring-blue-800 dark:ring-gray-900">0</span>
|
<span class="hidden max-w-[10rem] truncate font-medium md:inline"><%= user.username %></span>
|
||||||
</a>
|
<svg class="dd-chevron hidden h-3.5 w-3.5 text-slate-500 transition-transform duration-200 md:block" fill="none" stroke="currentColor" stroke-width="2.5" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M19 9l-7 7-7-7"></path>
|
||||||
<!-- Jobangebote (ingested via third-party REST API) link -->
|
|
||||||
<a href="/jobangebote"
|
|
||||||
id="jobangeboteLink"
|
|
||||||
class="relative flex items-center gap-1.5 px-3 py-2 rounded-md bg-white/20 hover:bg-white/30 transition-colors text-white text-sm font-medium"
|
|
||||||
title="Über die REST-API eingespielte Jobangebote">
|
|
||||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 13.255A23.931 23.931 0 0112 15c-3.183 0-6.22-.62-9-1.745M16 6V4a2 2 0 00-2-2h-4a2 2 0 00-2 2v2m4 6h.01M5 20h14a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"></path>
|
|
||||||
</svg>
|
|
||||||
<span class="hidden sm:inline">Jobangebote</span>
|
|
||||||
<span id="jobangeboteBadge" class="hidden absolute -top-1 -right-1 min-w-[18px] h-[18px] px-1 flex items-center justify-center rounded-full bg-green-500 text-white text-[10px] font-bold ring-2 ring-blue-800 dark:ring-gray-900">0</span>
|
|
||||||
</a>
|
|
||||||
|
|
||||||
<!-- Blacklist (blocked job offers) link -->
|
|
||||||
<a href="/blacklist"
|
|
||||||
class="flex items-center gap-1.5 px-3 py-2 rounded-md bg-white/20 hover:bg-white/30 transition-colors text-white text-sm font-medium"
|
|
||||||
title="Geblockte Jobangebote (Blacklist) verwalten">
|
|
||||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636"></path>
|
|
||||||
</svg>
|
|
||||||
<span class="hidden sm:inline">Blacklist</span>
|
|
||||||
</a>
|
|
||||||
|
|
||||||
<!-- Vorlagen (base documents) link -->
|
|
||||||
<a href="/vorlagen"
|
|
||||||
class="flex items-center gap-1.5 px-3 py-2 rounded-md bg-white/20 hover:bg-white/30 transition-colors text-white text-sm font-medium"
|
|
||||||
title="Basis-Unterlagen für die KI-Generierung verwalten">
|
|
||||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path>
|
|
||||||
</svg>
|
|
||||||
<span class="hidden sm:inline">Vorlagen</span>
|
|
||||||
</a>
|
|
||||||
|
|
||||||
<!-- Dark mode toggle -->
|
|
||||||
<button
|
|
||||||
id="darkModeToggle"
|
|
||||||
class="p-2 rounded-full bg-white/20 hover:bg-white/30 transition-colors text-white"
|
|
||||||
aria-label="Dark Mode umschalten"
|
|
||||||
>
|
|
||||||
<svg id="sunIcon" class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z"></path>
|
|
||||||
</svg>
|
|
||||||
<svg id="moonIcon" class="w-5 h-5 hidden" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z"></path>
|
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<!-- Settings button -->
|
<div id="ddUser" class="dd-panel hidden w-60">
|
||||||
<% if (typeof hideSettings === 'undefined' || !hideSettings) { %>
|
<div class="border-b border-gray-100 px-3 pb-2.5 pt-1 dark:border-white/5">
|
||||||
<button
|
<p class="truncate text-sm font-semibold text-gray-800 dark:text-white"><%= user.username %></p>
|
||||||
id="settingsBtn"
|
<p class="text-xs text-gray-500 dark:text-slate-400">
|
||||||
class="p-2 rounded-full bg-white/20 hover:bg-white/30 transition-colors text-white"
|
<%= user.is_admin ? 'Administrator' : 'Benutzer' %>
|
||||||
aria-label="Einstellungen"
|
</p>
|
||||||
>
|
</div>
|
||||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<div class="pt-1">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"></path>
|
<a href="/einstellungen" class="dd-item <%= istEinstellungen ? 'is-active' : '' %>">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path>
|
<svg class="dd-icon" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.066 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.066c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.066-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"></path>
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
<span class="dd-title">Einstellungen</span>
|
||||||
|
</a>
|
||||||
|
<% if (user.is_admin) { %>
|
||||||
|
<a href="/admin" class="dd-item <%= istAdmin ? 'is-active' : '' %>">
|
||||||
|
<svg class="dd-icon" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M17 20h5v-2a4 4 0 00-3-3.87M9 20H4v-2a4 4 0 013-3.87m6-2a4 4 0 100-8 4 4 0 000 8z"></path>
|
||||||
|
</svg>
|
||||||
|
<span class="dd-title">Benutzer verwalten</span>
|
||||||
|
</a>
|
||||||
<% } %>
|
<% } %>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="mt-1 border-t border-gray-100 pt-1 dark:border-white/5">
|
||||||
|
<a href="/logout" class="dd-item dd-item--danger">
|
||||||
|
<svg class="dd-icon" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1"></path>
|
||||||
|
</svg>
|
||||||
|
<span class="dd-title">Abmelden</span>
|
||||||
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
<% } %>
|
||||||
|
|
||||||
|
<!-- Mobile menu -->
|
||||||
|
<button type="button" id="burgerBtn" aria-expanded="false" aria-controls="mobileNav"
|
||||||
|
class="icon-btn lg:hidden" aria-label="Menü">
|
||||||
|
<svg id="burgerOpen" class="h-5 w-5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M4 6h16M4 12h16M4 18h16"></path>
|
||||||
|
</svg>
|
||||||
|
<svg id="burgerClose" class="hidden h-5 w-5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12"></path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<% if (typeof impersonator !== 'undefined' && impersonator) { %>
|
||||||
|
<!-- Impersonation banner: unmistakable and always present while an admin is
|
||||||
|
acting as another user. The "switch back" form is a real POST so it works
|
||||||
|
without JS; admin rights are suspended until the switch back. -->
|
||||||
|
<div class="border-t border-amber-300/60 bg-amber-50/95 backdrop-blur-md dark:border-amber-400/20 dark:bg-amber-500/10">
|
||||||
|
<div class="container mx-auto flex flex-wrap items-center gap-x-3 gap-y-1 px-4 py-2 text-sm text-amber-800 dark:text-amber-200">
|
||||||
|
<svg class="h-4 w-4 shrink-0" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14v7m-4-4h8"></path>
|
||||||
|
</svg>
|
||||||
|
<span class="font-medium">Impersonation aktiv:</span>
|
||||||
|
<span>du handelst als <strong class="font-semibold"><%= user.username %></strong> (eingeloggt durch <strong class="font-semibold"><%= impersonator.username %></strong>). Admin-Aktionen sind pausiert.</span>
|
||||||
|
<form method="POST" action="/admin/impersonate/stop" class="ml-auto">
|
||||||
|
<button type="submit" class="inline-flex items-center gap-1.5 rounded-md bg-amber-600 hover:bg-amber-700 px-3 py-1.5 text-xs font-medium text-white transition-colors">
|
||||||
|
<svg class="h-3.5 w-3.5" fill="none" stroke="currentColor" stroke-width="2.5" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M10 19l-7-7m0 0l7-7m-7 7h18"></path>
|
||||||
|
</svg>
|
||||||
|
Zurück zum Admin
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% } %>
|
||||||
|
|
||||||
|
<!-- Mobile navigation: the same structure, spelled out. The old header just
|
||||||
|
dropped the labels and left seven unlabelled icons. -->
|
||||||
|
<div id="mobileNav" class="hidden border-t border-gray-200/80 bg-gray-50 lg:hidden dark:border-white/5 dark:bg-gray-900">
|
||||||
|
<nav class="container mx-auto space-y-4 px-4 py-4" aria-label="Hauptnavigation (mobil)">
|
||||||
|
<div class="space-y-0.5">
|
||||||
|
<a href="/" class="m-item <%= istBewerbungen ? 'is-active' : '' %>">Bewerbungen</a>
|
||||||
|
<a href="/jobangebote" class="m-item <%= istAngebote ? 'is-active' : '' %>">
|
||||||
|
Jobangebote
|
||||||
|
<span class="js-badge-angebote nav-badge nav-badge--green ml-auto hidden">0</span>
|
||||||
|
</a>
|
||||||
|
<a href="/postfach" class="m-item <%= istPostfach ? 'is-active' : '' %>">
|
||||||
|
Postfach
|
||||||
|
<span class="js-badge-postfach nav-badge nav-badge--red ml-auto hidden">0</span>
|
||||||
|
</a>
|
||||||
|
<a href="/vorlagen" class="m-item <%= istVorlagen ? 'is-active' : '' %>">Vorlagen</a>
|
||||||
|
<a href="/statistik" class="m-item <%= istStatistik ? 'is-active' : '' %>">Statistik</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-0.5">
|
||||||
|
<p class="m-label">Stellensuche</p>
|
||||||
|
<a href="/jobsuche" class="m-item <%= istSuchprofil ? 'is-active' : '' %>">Suchprofil & Läufe</a>
|
||||||
|
<a href="/jobangebote/uebernommen" class="m-item <%= istUebernommen ? 'is-active' : '' %>">Übernommene Angebote</a>
|
||||||
|
<a href="/blacklist" class="m-item <%= istBlacklist ? 'is-active' : '' %>">Blacklist</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<% if (angemeldet) { %>
|
||||||
|
<div class="space-y-0.5">
|
||||||
|
<p class="m-label">Konto</p>
|
||||||
|
<p class="px-3 pb-1 text-sm text-gray-900 dark:text-white">
|
||||||
|
<%= user.username %>
|
||||||
|
<span class="text-gray-500 dark:text-slate-500">· <%= user.is_admin ? 'Administrator' : 'Benutzer' %></span>
|
||||||
|
</p>
|
||||||
|
<a href="/einstellungen" class="m-item <%= istEinstellungen ? 'is-active' : '' %>">Einstellungen</a>
|
||||||
|
<% if (user.is_admin) { %>
|
||||||
|
<a href="/admin" class="m-item <%= istAdmin ? 'is-active' : '' %>">Benutzer verwalten</a>
|
||||||
|
<% } %>
|
||||||
|
<a href="/logout" class="m-item !text-rose-600 hover:!bg-rose-50 dark:!text-rose-300 dark:hover:!bg-rose-500/10">Abmelden</a>
|
||||||
|
</div>
|
||||||
|
<% } %>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
// Notification bell: unread received e-mails (new replies + orphan mail).
|
// --- Dropdowns (Stellensuche, Konto, Benachrichtigungen) ---------------
|
||||||
|
// One controller for all of them: click toggles, opening one closes the
|
||||||
|
// others, Escape and outside-click close, aria-expanded stays truthful.
|
||||||
|
(function () {
|
||||||
|
var trigger = Array.prototype.slice.call(document.querySelectorAll('[data-dd]'));
|
||||||
|
|
||||||
|
function panelOf(btn) { return document.getElementById(btn.getAttribute('data-dd')); }
|
||||||
|
|
||||||
|
function close(btn) {
|
||||||
|
var p = panelOf(btn);
|
||||||
|
if (!p) return;
|
||||||
|
p.classList.add('hidden');
|
||||||
|
btn.setAttribute('aria-expanded', 'false');
|
||||||
|
var chev = btn.querySelector('.dd-chevron');
|
||||||
|
if (chev) chev.classList.remove('rotate-180');
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeAll(ausser) {
|
||||||
|
trigger.forEach(function (b) { if (b !== ausser) close(b); });
|
||||||
|
}
|
||||||
|
|
||||||
|
trigger.forEach(function (btn) {
|
||||||
|
btn.addEventListener('click', function (e) {
|
||||||
|
e.stopPropagation();
|
||||||
|
var p = panelOf(btn);
|
||||||
|
if (!p) return;
|
||||||
|
var offen = !p.classList.contains('hidden');
|
||||||
|
closeAll(btn);
|
||||||
|
if (offen) { close(btn); return; }
|
||||||
|
p.classList.remove('hidden');
|
||||||
|
btn.setAttribute('aria-expanded', 'true');
|
||||||
|
var chev = btn.querySelector('.dd-chevron');
|
||||||
|
if (chev) chev.classList.add('rotate-180');
|
||||||
|
btn.dispatchEvent(new CustomEvent('dd:open'));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener('click', function (e) {
|
||||||
|
trigger.forEach(function (btn) {
|
||||||
|
var p = panelOf(btn);
|
||||||
|
if (p && !p.classList.contains('hidden') && !p.contains(e.target) && !btn.contains(e.target)) {
|
||||||
|
close(btn);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener('keydown', function (e) {
|
||||||
|
if (e.key === 'Escape') closeAll(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Mobile menu ---
|
||||||
|
var burger = document.getElementById('burgerBtn');
|
||||||
|
var mob = document.getElementById('mobileNav');
|
||||||
|
if (burger && mob) {
|
||||||
|
burger.addEventListener('click', function (e) {
|
||||||
|
e.stopPropagation();
|
||||||
|
var offen = mob.classList.toggle('hidden');
|
||||||
|
burger.setAttribute('aria-expanded', String(!offen));
|
||||||
|
document.getElementById('burgerOpen').classList.toggle('hidden', !offen);
|
||||||
|
document.getElementById('burgerClose').classList.toggle('hidden', offen);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// The bar has no surface of its own, so once content starts sliding
|
||||||
|
// underneath it needs the faintest separation — a hairline shadow, not
|
||||||
|
// a drop shadow.
|
||||||
|
var head = document.getElementById('siteHeader');
|
||||||
|
function onScroll() {
|
||||||
|
if (!head) return;
|
||||||
|
head.classList.toggle('is-scrolled', window.scrollY > 4);
|
||||||
|
}
|
||||||
|
window.addEventListener('scroll', onScroll, { passive: true });
|
||||||
|
onScroll();
|
||||||
|
})();
|
||||||
|
|
||||||
|
// --- Notification bell: unread received e-mails ------------------------
|
||||||
(function () {
|
(function () {
|
||||||
var btn = document.getElementById('notifBtn');
|
var btn = document.getElementById('notifBtn');
|
||||||
var panel = document.getElementById('notifPanel');
|
|
||||||
var badge = document.getElementById('notifBadge');
|
var badge = document.getElementById('notifBadge');
|
||||||
var list = document.getElementById('notifList');
|
var list = document.getElementById('notifList');
|
||||||
var empty = document.getElementById('notifEmpty');
|
var empty = document.getElementById('notifEmpty');
|
||||||
var markAll = document.getElementById('notifMarkAll');
|
var markAll = document.getElementById('notifMarkAll');
|
||||||
if (!btn) return;
|
if (!btn) return;
|
||||||
|
|
||||||
function esc(s) { var d = document.createElement('div'); d.textContent = (s == null ? '' : String(s)); return d.innerHTML; }
|
function esc(s) { var d = document.createElement('div'); d.textContent = (s == null ? '' : String(s)); return d.innerHTML; }
|
||||||
function fmtDate(d) {
|
function fmtDate(d) {
|
||||||
if (!d) return '';
|
if (!d) return '';
|
||||||
@@ -134,15 +385,15 @@
|
|||||||
var li = document.createElement('li');
|
var li = document.createElement('li');
|
||||||
var a = document.createElement('a');
|
var a = document.createElement('a');
|
||||||
a.href = it.url;
|
a.href = it.url;
|
||||||
a.className = 'block px-4 py-3 hover:bg-gray-50 dark:hover:bg-gray-700/60 transition-colors';
|
a.className = 'block px-4 py-3 transition-colors hover:bg-gray-50 dark:hover:bg-white/5';
|
||||||
a.innerHTML =
|
a.innerHTML =
|
||||||
'<div class="flex items-center justify-between gap-2">'
|
'<div class="flex items-center justify-between gap-2">'
|
||||||
+ '<span class="text-sm font-medium text-gray-800 dark:text-gray-100 truncate">' + esc(it.from) + '</span>'
|
+ '<span class="truncate text-sm font-medium text-gray-800 dark:text-gray-100">' + esc(it.from) + '</span>'
|
||||||
+ '<span class="text-[11px] text-gray-400 shrink-0">' + esc(fmtDate(it.date)) + '</span>'
|
+ '<span class="shrink-0 text-[11px] text-gray-400">' + esc(fmtDate(it.date)) + '</span>'
|
||||||
+ '</div>'
|
+ '</div>'
|
||||||
+ '<div class="text-sm text-gray-700 dark:text-gray-200 truncate">' + esc(it.subject) + '</div>'
|
+ '<div class="truncate text-sm text-gray-700 dark:text-gray-200">' + esc(it.subject) + '</div>'
|
||||||
+ (it.snippet ? '<div class="text-xs text-gray-500 dark:text-gray-400 truncate mt-0.5">' + esc(it.snippet) + '</div>' : '')
|
+ (it.snippet ? '<div class="mt-0.5 truncate text-xs text-gray-500 dark:text-slate-400">' + esc(it.snippet) + '</div>' : '')
|
||||||
+ (it.kontext ? '<div class="text-[11px] text-blue-600 dark:text-blue-400 truncate mt-0.5">' + esc(it.kontext) + '</div>' : '');
|
+ (it.kontext ? '<div class="mt-0.5 truncate text-[11px] text-blue-600 dark:text-sky-400">' + esc(it.kontext) + '</div>' : '');
|
||||||
li.appendChild(a); list.appendChild(li);
|
li.appendChild(a); list.appendChild(li);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -150,16 +401,8 @@
|
|||||||
fetch('/api/notifications', { cache: 'no-store' })
|
fetch('/api/notifications', { cache: 'no-store' })
|
||||||
.then(function (r) { return r.json(); }).then(render).catch(function () { /* ignore */ });
|
.then(function (r) { return r.json(); }).then(render).catch(function () { /* ignore */ });
|
||||||
}
|
}
|
||||||
btn.addEventListener('click', function (e) {
|
|
||||||
e.stopPropagation();
|
btn.addEventListener('dd:open', load); // refresh whenever the panel opens
|
||||||
panel.classList.toggle('hidden');
|
|
||||||
if (!panel.classList.contains('hidden')) load();
|
|
||||||
});
|
|
||||||
document.addEventListener('click', function (e) {
|
|
||||||
if (!panel.classList.contains('hidden') && !panel.contains(e.target) && !btn.contains(e.target)) {
|
|
||||||
panel.classList.add('hidden');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
markAll.addEventListener('click', function (e) {
|
markAll.addEventListener('click', function (e) {
|
||||||
e.preventDefault(); e.stopPropagation();
|
e.preventDefault(); e.stopPropagation();
|
||||||
fetch('/api/emails/mark-all-read', { method: 'POST' })
|
fetch('/api/emails/mark-all-read', { method: 'POST' })
|
||||||
@@ -168,19 +411,22 @@
|
|||||||
load();
|
load();
|
||||||
setInterval(load, 60000);
|
setInterval(load, 60000);
|
||||||
})();
|
})();
|
||||||
// Populate the Postfach badge with the count of unlinked incoming e-mails.
|
|
||||||
|
// --- Counters: unlinked mail + open job offers -------------------------
|
||||||
|
// Class-based, because each count now shows in both the desktop bar and
|
||||||
|
// the mobile menu.
|
||||||
(function () {
|
(function () {
|
||||||
fetch('/api/emails/unassigned-count').then(function (r) { return r.json(); }).then(function (d) {
|
function setzen(selektor, count) {
|
||||||
var b = document.getElementById('unassignedBadge');
|
if (!(count > 0)) return;
|
||||||
if (b && d && d.count > 0) { b.textContent = d.count; b.classList.remove('hidden'); }
|
document.querySelectorAll(selektor).forEach(function (el) {
|
||||||
}).catch(function () { /* ignore — badge stays hidden */ });
|
el.textContent = count > 99 ? '99+' : count;
|
||||||
})();
|
el.classList.remove('hidden');
|
||||||
// Jobangebote badge: count of open offers ingested via the REST API.
|
});
|
||||||
(function () {
|
}
|
||||||
fetch('/jobangebote/anzahl-offen').then(function (r) { return r.json(); }).then(function (d) {
|
fetch('/api/emails/unassigned-count').then(function (r) { return r.json(); })
|
||||||
var b = document.getElementById('jobangeboteBadge');
|
.then(function (d) { setzen('.js-badge-postfach', d && d.count); }).catch(function () { /* ignore */ });
|
||||||
if (b && d && d.count > 0) { b.textContent = d.count; b.classList.remove('hidden'); }
|
fetch('/jobangebote/anzahl-offen').then(function (r) { return r.json(); })
|
||||||
}).catch(function () { /* ignore */ });
|
.then(function (d) { setzen('.js-badge-angebote', d && d.count); }).catch(function () { /* ignore */ });
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
</header>
|
</header>
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
<%
|
||||||
|
// Tab-Leiste der Jobangebote-Seiten. `aktiv` ist einer von 'offen',
|
||||||
|
// 'favoriten', 'uebernommen'. Der Favoriten-Tab zeigt gemerkte Angebote quer
|
||||||
|
// über alle Status und wird nur eingeblendet, wenn es Favoriten gibt (oder er
|
||||||
|
// gerade offen ist) — leere Tabs braucht niemand.
|
||||||
|
var tabs = [
|
||||||
|
{ key: 'offen', href: '/jobangebote', label: 'Offen', count: offenCount },
|
||||||
|
{ key: 'favoriten', href: '/jobangebote/favoriten', label: 'Gemerkt', count: favoritenCount, stern: true },
|
||||||
|
{ key: 'uebernommen', href: '/jobangebote/uebernommen', label: 'Übernommen', count: uebernommenCount },
|
||||||
|
].filter(function (t) { return t.key !== 'favoriten' || t.count > 0 || aktiv === 'favoriten'; });
|
||||||
|
%>
|
||||||
|
<div class="flex flex-wrap items-center gap-2 mb-6">
|
||||||
|
<% tabs.forEach(function (t) { %>
|
||||||
|
<a href="<%= t.href %>"
|
||||||
|
<%= aktiv === t.key ? 'aria-current="page"' : '' %>
|
||||||
|
class="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-md text-sm font-medium transition-colors <%= aktiv === t.key
|
||||||
|
? 'bg-blue-600 text-white shadow-sm'
|
||||||
|
: 'bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-200 border border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-700' %>">
|
||||||
|
<% if (t.stern) { %>
|
||||||
|
<svg class="h-4 w-4 <%= aktiv === t.key ? 'text-white' : 'text-amber-500' %>" viewBox="0 0 24 24" fill="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M11.48 3.5l2.6 5.27 5.82.85-4.21 4.1.99 5.79-5.2-2.73-5.2 2.73.99-5.79-4.21-4.1 5.82-.85 2.6-5.27z"/></svg>
|
||||||
|
<% } %>
|
||||||
|
<%= t.label %> (<%= t.count %>)
|
||||||
|
</a>
|
||||||
|
<% }); %>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
<%
|
||||||
|
// Display-only label chips. Expects local `labels` (array of label strings).
|
||||||
|
var _labelClasses = {
|
||||||
|
'Regional': 'bg-sky-100 text-sky-800 dark:bg-sky-900/50 dark:text-sky-200',
|
||||||
|
'Remote-Deutschlandweit': 'bg-emerald-100 text-emerald-800 dark:bg-emerald-900/50 dark:text-emerald-200',
|
||||||
|
'Homeoffice-Deutschlandweit': 'bg-violet-100 text-violet-800 dark:bg-violet-900/50 dark:text-violet-200'
|
||||||
|
};
|
||||||
|
var _labels = (typeof labels !== 'undefined' && labels) ? labels : [];
|
||||||
|
%>
|
||||||
|
<% _labels.forEach(function (lbl) { %>
|
||||||
|
<span class="inline-flex items-center px-2 py-0.5 rounded-full text-[11px] font-medium leading-tight <%= _labelClasses[lbl] || 'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-200' %>"><%= lbl %></span>
|
||||||
|
<% }); %>
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
<%
|
||||||
|
// Multi-select label picker (checkboxes named "labels"). Expects locals:
|
||||||
|
// labelOptions – array of all selectable labels
|
||||||
|
// selected – array of currently selected labels (optional)
|
||||||
|
var _opts = (typeof labelOptions !== 'undefined' && labelOptions) ? labelOptions : [];
|
||||||
|
var _sel = (typeof selected !== 'undefined' && selected) ? selected : [];
|
||||||
|
%>
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
<% _opts.forEach(function (opt) { %>
|
||||||
|
<label class="inline-flex items-center gap-2 px-3 py-1.5 rounded-full border cursor-pointer text-sm transition-colors border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700">
|
||||||
|
<input type="checkbox" name="labels" value="<%= opt %>" <%= _sel.indexOf(opt) !== -1 ? 'checked' : '' %>
|
||||||
|
class="rounded border-gray-300 dark:border-gray-500 text-blue-600 focus:ring-blue-500">
|
||||||
|
<span class="text-gray-700 dark:text-gray-200"><%= opt %></span>
|
||||||
|
</label>
|
||||||
|
<% }); %>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
<% if (!laeufe.length) { %>
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400">Noch kein Suchlauf durchgeführt.</p>
|
||||||
|
<% } else { %>
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr class="text-left text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400 border-b border-gray-200 dark:border-gray-700">
|
||||||
|
<th class="py-2 pr-4 font-medium">Zeitpunkt</th>
|
||||||
|
<th class="py-2 pr-4 font-medium">Auslöser</th>
|
||||||
|
<th class="py-2 pr-4 font-medium">Status</th>
|
||||||
|
<th class="py-2 pr-4 font-medium">Ergebnis</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<% laeufe.forEach(function (l) {
|
||||||
|
var badge = {
|
||||||
|
angefordert: 'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-200',
|
||||||
|
laeuft: 'bg-blue-100 text-blue-800 dark:bg-blue-900/40 dark:text-blue-300',
|
||||||
|
fertig: 'bg-green-100 text-green-800 dark:bg-green-900/40 dark:text-green-300',
|
||||||
|
fehler: 'bg-red-100 text-red-800 dark:bg-red-900/40 dark:text-red-300'
|
||||||
|
}[l.status] || 'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-200';
|
||||||
|
var text = {
|
||||||
|
angefordert: 'wartet', laeuft: 'läuft…', fertig: 'fertig', fehler: 'Fehler'
|
||||||
|
}[l.status] || l.status;
|
||||||
|
var zeit = l.gestartet_at || l.angefordert_at;
|
||||||
|
%>
|
||||||
|
<tr class="border-b border-gray-100 dark:border-gray-700/60">
|
||||||
|
<td class="py-2 pr-4 text-gray-700 dark:text-gray-300 whitespace-nowrap">
|
||||||
|
<%= zeit ? new Date(zeit + 'Z').toLocaleString('de-DE', { dateStyle: 'short', timeStyle: 'short' }) : '–' %>
|
||||||
|
</td>
|
||||||
|
<td class="py-2 pr-4 text-gray-500 dark:text-gray-400">
|
||||||
|
<%= l.ausloeser === 'zeitplan' ? 'Zeitplan' : 'manuell' %>
|
||||||
|
</td>
|
||||||
|
<td class="py-2 pr-4">
|
||||||
|
<span class="inline-block px-2 py-0.5 rounded text-xs font-medium <%= badge %>"><%= text %></span>
|
||||||
|
</td>
|
||||||
|
<td class="py-2 pr-4 text-gray-700 dark:text-gray-300">
|
||||||
|
<% if (l.status === 'fertig') { %>
|
||||||
|
<span class="font-medium text-green-700 dark:text-green-400"><%= l.neu || 0 %> neu</span>,
|
||||||
|
<%= l.dubletten || 0 %> Dubletten, <%= l.verworfen || 0 %> verworfen
|
||||||
|
<% } else if (l.status === 'fehler') { %>
|
||||||
|
<span class="text-red-700 dark:text-red-400" title="<%= l.fehler || '' %>">
|
||||||
|
<%= (l.fehler || 'Unbekannter Fehler').slice(0, 80) %>
|
||||||
|
</span>
|
||||||
|
<% } else { %>
|
||||||
|
<span class="text-gray-400 dark:text-gray-500">–</span>
|
||||||
|
<% } %>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<% }); %>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<% } %>
|
||||||
+4
-23
@@ -3,10 +3,10 @@
|
|||||||
<head>
|
<head>
|
||||||
<%- include('partials/head') %>
|
<%- include('partials/head') %>
|
||||||
</head>
|
</head>
|
||||||
<body class="min-h-screen transition-colors duration-300 bg-gray-50 dark:bg-gray-900 text-gray-900 dark:text-gray-100" id="body">
|
<body class="min-h-screen flex flex-col transition-colors duration-300 bg-gray-50 dark:bg-gray-900 text-gray-900 dark:text-gray-100" id="body">
|
||||||
<%- include('partials/header', { hideSettings: true }) %>
|
<%- include('partials/header', { hideSettings: true }) %>
|
||||||
|
|
||||||
<main class="container mx-auto px-4 py-8 max-w-4xl">
|
<main class="flex-1 container mx-auto px-4 py-8 max-w-4xl">
|
||||||
<!-- Back link -->
|
<!-- Back link -->
|
||||||
<a href="/" class="inline-flex items-center gap-2 text-sm text-blue-600 dark:text-blue-400 hover:underline mb-6">
|
<a href="/" class="inline-flex items-center gap-2 text-sm text-blue-600 dark:text-blue-400 hover:underline mb-6">
|
||||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
@@ -57,12 +57,12 @@
|
|||||||
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-gray-600 text-white">Empfangen</span>
|
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-gray-600 text-white">Empfangen</span>
|
||||||
<span class="text-sm text-gray-700 dark:text-gray-200 truncate">Von: <%= e.from_addr || '(unbekannt)' %></span>
|
<span class="text-sm text-gray-700 dark:text-gray-200 truncate">Von: <%= e.from_addr || '(unbekannt)' %></span>
|
||||||
</div>
|
</div>
|
||||||
<span class="text-xs text-gray-500 dark:text-gray-400 shrink-0"><%= e.email_date ? new Date(e.email_date).toLocaleString('de-DE') : '' %></span>
|
<span class="text-xs text-gray-500 dark:text-gray-400 shrink-0"><%= e.email_date ? new Date(e.email_date).toLocaleString('de-DE', { timeZone: 'Europe/Berlin' }) : '' %></span>
|
||||||
</div>
|
</div>
|
||||||
<p class="text-sm font-medium text-gray-800 dark:text-gray-100 mb-1"><%= e.subject || '(kein Betreff)' %></p>
|
<p class="text-sm font-medium text-gray-800 dark:text-gray-100 mb-1"><%= e.subject || '(kein Betreff)' %></p>
|
||||||
<% if (e.display_srcdoc) { %>
|
<% if (e.display_srcdoc) { %>
|
||||||
<iframe class="email-html-frame w-full border border-gray-200 dark:border-gray-600 rounded bg-white" style="height:6rem"
|
<iframe class="email-html-frame w-full border border-gray-200 dark:border-gray-600 rounded bg-white" style="height:6rem"
|
||||||
sandbox="allow-same-origin allow-popups allow-popups-to-escape-sandbox" referrerpolicy="no-referrer"
|
sandbox="allow-same-origin" referrerpolicy="no-referrer"
|
||||||
srcdoc="<%= e.display_srcdoc %>"></iframe>
|
srcdoc="<%= e.display_srcdoc %>"></iframe>
|
||||||
<% } else { %>
|
<% } else { %>
|
||||||
<p class="email-body whitespace-pre-wrap break-words text-sm leading-relaxed text-gray-700 dark:text-gray-300 max-h-40 overflow-hidden"><%= e.body_text || '' %></p>
|
<p class="email-body whitespace-pre-wrap break-words text-sm leading-relaxed text-gray-700 dark:text-gray-300 max-h-40 overflow-hidden"><%= e.body_text || '' %></p>
|
||||||
@@ -130,25 +130,6 @@
|
|||||||
</script>
|
</script>
|
||||||
<script>
|
<script>
|
||||||
(function () {
|
(function () {
|
||||||
const root = document.documentElement;
|
|
||||||
const dm = localStorage.getItem('darkMode');
|
|
||||||
if (dm === 'enabled' || (!dm && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
|
|
||||||
root.classList.add('dark');
|
|
||||||
}
|
|
||||||
const toggle = document.getElementById('darkModeToggle');
|
|
||||||
const sun = document.getElementById('sunIcon');
|
|
||||||
const moon = document.getElementById('moonIcon');
|
|
||||||
function sync() {
|
|
||||||
const d = root.classList.contains('dark');
|
|
||||||
if (sun) sun.classList.toggle('hidden', d);
|
|
||||||
if (moon) moon.classList.toggle('hidden', !d);
|
|
||||||
}
|
|
||||||
sync();
|
|
||||||
if (toggle) toggle.addEventListener('click', () => {
|
|
||||||
root.classList.toggle('dark');
|
|
||||||
localStorage.setItem('darkMode', root.classList.contains('dark') ? 'enabled' : 'disabled');
|
|
||||||
sync();
|
|
||||||
});
|
|
||||||
const cy = document.getElementById('currentYear');
|
const cy = document.getElementById('currentYear');
|
||||||
if (cy) cy.textContent = new Date().getFullYear();
|
if (cy) cy.textContent = new Date().getFullYear();
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,207 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<%- include('partials/head') %>
|
||||||
|
</head>
|
||||||
|
<body class="min-h-screen flex flex-col transition-colors duration-300 bg-gray-50 dark:bg-gray-900 text-gray-900 dark:text-gray-100" id="body">
|
||||||
|
<%- include('partials/header') %>
|
||||||
|
|
||||||
|
<main class="flex-1 container mx-auto px-4 py-8 max-w-4xl">
|
||||||
|
<!-- Back link -->
|
||||||
|
<a href="/" class="inline-flex items-center gap-2 text-sm text-blue-600 dark:text-blue-400 hover:underline mb-6">
|
||||||
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 19l-7-7m0 0l7-7m-7 7h18"></path>
|
||||||
|
</svg>
|
||||||
|
Zurück zur Übersicht
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6 mb-8">
|
||||||
|
<div class="flex items-start gap-3">
|
||||||
|
<svg class="w-6 h-6 text-gray-500 dark:text-gray-400 shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.066 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.066c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.066-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"></path>
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path>
|
||||||
|
</svg>
|
||||||
|
<div>
|
||||||
|
<h2 class="text-lg font-semibold text-gray-800 dark:text-white">Einstellungen</h2>
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1 max-w-2xl">
|
||||||
|
Hier liegen alle Werte, die früher über die <code class="px-1 rounded bg-gray-100 dark:bg-gray-700">.env</code>-Datei
|
||||||
|
gesetzt wurden – Ollama, E-Mail, Kalender und die REST-API. Gespeichert wird in der Datenbank;
|
||||||
|
Änderungen wirken <em>sofort</em>, ein Neustart ist nicht nötig. Lediglich die Abruf-/Sync-Intervalle
|
||||||
|
(E-Mail & Kalender) greifen erst nach einem Neustart.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form action="/einstellungen" method="POST" class="space-y-6">
|
||||||
|
<% felder.forEach(function(sektion){ %>
|
||||||
|
<section class="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6">
|
||||||
|
<h3 class="text-base font-semibold text-gray-800 dark:text-white"><%= sektion.titel %></h3>
|
||||||
|
<% if (sektion.beschreibung) { %>
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1 mb-5 max-w-2xl"><%= sektion.beschreibung %></p>
|
||||||
|
<% } else { %>
|
||||||
|
<div class="mb-5"></div>
|
||||||
|
<% } %>
|
||||||
|
|
||||||
|
<div class="grid gap-4 sm:grid-cols-2">
|
||||||
|
<% sektion.items.forEach(function(f){
|
||||||
|
var val = werte[f.key] != null ? werte[f.key] : '';
|
||||||
|
var inputType = f.secret ? 'password' : 'text';
|
||||||
|
var inputClass = 'w-full rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 px-3 py-2 text-sm pr-10';
|
||||||
|
var gewaehlt = String(val).split(',').map(function(s){ return s.trim(); });
|
||||||
|
// Feste Optionen aus der Feld-Definition oder – bei „optionsFrom“ –
|
||||||
|
// die zur Renderzeit geladenen Listen (z. B. die eigenen Anlagen).
|
||||||
|
var optionen = f.options || (f.optionsFrom ? (auswahllisten[f.optionsFrom] || []) : []);
|
||||||
|
%>
|
||||||
|
<% if (f.type === 'checkboxes') { %>
|
||||||
|
<div class="sm:col-span-2">
|
||||||
|
<span class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||||
|
<%= f.label %>
|
||||||
|
<code class="ml-1 text-[11px] font-normal text-gray-400 dark:text-gray-500"><%= f.key %></code>
|
||||||
|
</span>
|
||||||
|
<!-- Leerer Wert, damit „nichts angehakt“ auch gespeichert wird. -->
|
||||||
|
<input type="hidden" name="<%= f.key %>" value="">
|
||||||
|
<% if (!optionen.length) { %>
|
||||||
|
<p class="text-sm text-gray-400 dark:text-gray-500"><%= f.leerHinweis || 'Keine Auswahl verfügbar.' %></p>
|
||||||
|
<% } else { %>
|
||||||
|
<div class="flex flex-wrap gap-x-6 gap-y-2">
|
||||||
|
<% optionen.forEach(function(o){ %>
|
||||||
|
<label class="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
|
||||||
|
<input type="checkbox" name="<%= f.key %>" value="<%= o.value %>"
|
||||||
|
<%= gewaehlt.includes(String(o.value)) ? 'checked' : '' %>
|
||||||
|
class="rounded border-gray-300 dark:border-gray-600 text-blue-600">
|
||||||
|
<%= o.label %>
|
||||||
|
</label>
|
||||||
|
<% }); %>
|
||||||
|
</div>
|
||||||
|
<% } %>
|
||||||
|
<% if (f.help) { %>
|
||||||
|
<p class="text-xs text-gray-400 dark:text-gray-500 mt-2"><%= f.help %></p>
|
||||||
|
<% } %>
|
||||||
|
</div>
|
||||||
|
<% return; } %>
|
||||||
|
<div class="<%= f.secret ? 'sm:col-span-2' : '' %>">
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1" for="cfg-<%= f.key %>">
|
||||||
|
<%= f.label %>
|
||||||
|
<code class="ml-1 text-[11px] font-normal text-gray-400 dark:text-gray-500"><%= f.key %></code>
|
||||||
|
</label>
|
||||||
|
<div class="relative">
|
||||||
|
<input id="cfg-<%= f.key %>" name="<%= f.key %>" type="<%= inputType %>" autocomplete="off"
|
||||||
|
spellcheck="false" class="<%= inputClass %>" value="<%= val %>">
|
||||||
|
<% if (f.secret) { %>
|
||||||
|
<button type="button" data-toggle="cfg-<%= f.key %>"
|
||||||
|
class="absolute inset-y-0 right-0 flex items-center px-3 text-gray-400 hover:text-gray-600 dark:hover:text-gray-200"
|
||||||
|
title="Wert anzeigen/verbergen" tabindex="-1">
|
||||||
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path>
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"></path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<% } %>
|
||||||
|
</div>
|
||||||
|
<% if (f.generate) { %>
|
||||||
|
<div class="flex items-center gap-2 mt-2">
|
||||||
|
<button type="button" data-generate="cfg-<%= f.key %>"
|
||||||
|
class="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-md transition-colors">
|
||||||
|
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"></path>
|
||||||
|
</svg>
|
||||||
|
Neu generieren
|
||||||
|
</button>
|
||||||
|
<button type="button" data-copy="cfg-<%= f.key %>"
|
||||||
|
class="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-md transition-colors">
|
||||||
|
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"></path>
|
||||||
|
</svg>
|
||||||
|
Kopieren
|
||||||
|
</button>
|
||||||
|
<span data-hint="cfg-<%= f.key %>" class="text-xs text-amber-600 dark:text-amber-400 hidden"></span>
|
||||||
|
</div>
|
||||||
|
<% } %>
|
||||||
|
<% if (f.help) { %>
|
||||||
|
<p class="text-xs text-gray-400 dark:text-gray-500 mt-1"><%= f.help %></p>
|
||||||
|
<% } %>
|
||||||
|
</div>
|
||||||
|
<% }); %>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<% }); %>
|
||||||
|
|
||||||
|
<div class="flex items-center justify-end gap-3">
|
||||||
|
<a href="/einstellungen" class="px-4 py-2 text-sm border border-gray-300 dark:border-gray-600 text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-md transition-colors">
|
||||||
|
Verwerfen
|
||||||
|
</a>
|
||||||
|
<button type="submit"
|
||||||
|
class="inline-flex items-center gap-2 px-4 py-2 text-sm bg-blue-600 hover:bg-blue-700 text-white rounded-md transition-colors">
|
||||||
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path>
|
||||||
|
</svg>
|
||||||
|
Speichern
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<%- include('partials/footer') %>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
const cy = document.getElementById('currentYear');
|
||||||
|
if (cy) cy.textContent = new Date().getFullYear();
|
||||||
|
|
||||||
|
// Toggle reveal for secret (password) fields.
|
||||||
|
document.querySelectorAll('[data-toggle]').forEach(function (btn) {
|
||||||
|
btn.addEventListener('click', function () {
|
||||||
|
const input = document.getElementById(btn.getAttribute('data-toggle'));
|
||||||
|
if (input) input.type = input.type === 'password' ? 'text' : 'password';
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function hint(id, text, tone) {
|
||||||
|
const el = document.querySelector('[data-hint="' + id + '"]');
|
||||||
|
if (!el) return;
|
||||||
|
el.textContent = text;
|
||||||
|
el.classList.remove('hidden');
|
||||||
|
el.classList.toggle('text-amber-600', tone !== 'ok');
|
||||||
|
el.classList.toggle('dark:text-amber-400', tone !== 'ok');
|
||||||
|
el.classList.toggle('text-green-600', tone === 'ok');
|
||||||
|
el.classList.toggle('dark:text-green-400', tone === 'ok');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate a fresh random token (32 bytes hex, from the browser CSPRNG).
|
||||||
|
// It only fills the field — nothing changes until the form is saved,
|
||||||
|
// so an accidental click can still be discarded.
|
||||||
|
document.querySelectorAll('[data-generate]').forEach(function (btn) {
|
||||||
|
btn.addEventListener('click', function () {
|
||||||
|
const id = btn.getAttribute('data-generate');
|
||||||
|
const input = document.getElementById(id);
|
||||||
|
if (!input) return;
|
||||||
|
const bytes = new Uint8Array(32);
|
||||||
|
crypto.getRandomValues(bytes);
|
||||||
|
input.value = Array.from(bytes).map(function (b) {
|
||||||
|
return b.toString(16).padStart(2, '0');
|
||||||
|
}).join('');
|
||||||
|
input.type = 'text'; // reveal, so it can be copied down
|
||||||
|
hint(id, 'Neuer Token erzeugt — zum Aktivieren speichern. Der alte wird damit ungültig.');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
document.querySelectorAll('[data-copy]').forEach(function (btn) {
|
||||||
|
btn.addEventListener('click', async function () {
|
||||||
|
const id = btn.getAttribute('data-copy');
|
||||||
|
const input = document.getElementById(id);
|
||||||
|
if (!input || !input.value) return;
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(input.value);
|
||||||
|
hint(id, 'In die Zwischenablage kopiert.', 'ok');
|
||||||
|
} catch (e) {
|
||||||
|
input.type = 'text';
|
||||||
|
input.select();
|
||||||
|
hint(id, 'Kopieren nicht möglich — Wert ist markiert, bitte manuell kopieren.');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,357 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<%- include('partials/head') %>
|
||||||
|
<style>
|
||||||
|
/* ── Statistics page — scoped chart primitives ─────────────────────────
|
||||||
|
The cards themselves are plain Tailwind; these classes build the bars,
|
||||||
|
the trend and the funnel so the markup stays readable. Everything keys
|
||||||
|
off --stat-accent so a single declaration re-tints a row. */
|
||||||
|
:root { --stat-accent: #2563eb; }
|
||||||
|
.stat-empty {
|
||||||
|
padding: 2.5rem 1rem;
|
||||||
|
text-align: center;
|
||||||
|
color: rgb(100 116 139);
|
||||||
|
}
|
||||||
|
.dark .stat-empty { color: rgb(148 163 184); }
|
||||||
|
|
||||||
|
/* KPI tile */
|
||||||
|
.kpi {
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: 1rem;
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid rgb(226 232 240);
|
||||||
|
box-shadow: 0 1px 2px rgba(15, 23, 42, .04);
|
||||||
|
padding: 1.25rem 1.25rem 1.1rem;
|
||||||
|
transition: transform .18s ease, box-shadow .18s ease;
|
||||||
|
}
|
||||||
|
.kpi:hover { transform: translateY(-2px); box-shadow: 0 12px 24px -12px rgba(15,23,42,.18); }
|
||||||
|
.dark .kpi { background: rgb(30 41 59); border-color: rgba(255,255,255,.06); }
|
||||||
|
.kpi-ico {
|
||||||
|
display: grid; place-items: center;
|
||||||
|
width: 2.5rem; height: 2.5rem; border-radius: .75rem;
|
||||||
|
color: #fff; margin-bottom: .9rem;
|
||||||
|
}
|
||||||
|
.kpi-val { font-size: 1.875rem; font-weight: 700; letter-spacing: -.02em; line-height: 1.1; }
|
||||||
|
.kpi-lab { margin-top: .25rem; font-size: .8rem; color: rgb(100 116 139); }
|
||||||
|
.dark .kpi-lab { color: rgb(148 163 184); }
|
||||||
|
.kpi-sub { margin-top: .35rem; font-size: .72rem; font-weight: 500; }
|
||||||
|
.kpi-glow {
|
||||||
|
position: absolute; right: -1.5rem; top: -1.5rem;
|
||||||
|
width: 5rem; height: 5rem; border-radius: 9999px;
|
||||||
|
filter: blur(28px); opacity: .35;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Generic card */
|
||||||
|
.stat-card {
|
||||||
|
border-radius: 1rem;
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid rgb(226 232 240);
|
||||||
|
box-shadow: 0 1px 2px rgba(15, 23, 42, .04);
|
||||||
|
padding: 1.25rem 1.5rem 1.5rem;
|
||||||
|
}
|
||||||
|
.dark .stat-card { background: rgb(30 41 59); border-color: rgba(255,255,255,.06); }
|
||||||
|
.stat-card h2 { font-size: 1rem; font-weight: 600; }
|
||||||
|
.stat-card .sub { font-size: .78rem; color: rgb(100 116 139); margin-top: .15rem; }
|
||||||
|
.dark .stat-card .sub { color: rgb(148 163 184); }
|
||||||
|
|
||||||
|
/* Horizontal bars (Art / Status / Ort) */
|
||||||
|
.bar-row { display: grid; grid-template-columns: minmax(0,9rem) 1fr 2.5rem; align-items: center; gap: .75rem; padding: .35rem 0; }
|
||||||
|
.bar-label { font-size: .82rem; color: rgb(71 85 105); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||||
|
.dark .bar-label { color: rgb(203 213 225); }
|
||||||
|
.bar-track { position: relative; height: .6rem; border-radius: 9999px; background: rgb(241 245 249); overflow: hidden; }
|
||||||
|
.dark .bar-track { background: rgba(255,255,255,.07); }
|
||||||
|
.bar-fill { position: absolute; inset: 0 auto 0 0; border-radius: 9999px; background: var(--stat-accent); width: 0; animation: bar-grow .6s cubic-bezier(.2,.8,.2,1) forwards; }
|
||||||
|
.bar-value { font-size: .8rem; font-weight: 600; text-align: right; color: rgb(30 41 52); }
|
||||||
|
.dark .bar-value { color: #fff; }
|
||||||
|
@keyframes bar-grow { from { width: 0; } }
|
||||||
|
|
||||||
|
/* Status swatch */
|
||||||
|
.swatch { display: inline-block; width: .55rem; height: .55rem; border-radius: 9999px; margin-right: .4rem; background: var(--stat-accent); vertical-align: middle; }
|
||||||
|
|
||||||
|
/* Trend (monthly) — SVG fills its box, bars keep aspect via viewBox */
|
||||||
|
.trend-wrap { width: 100%; }
|
||||||
|
.trend-wrap svg { width: 100%; height: auto; display: block; }
|
||||||
|
.trend-bar { transition: opacity .15s ease; }
|
||||||
|
.trend-bar:hover { opacity: .85; }
|
||||||
|
.trend-axis { fill: rgb(148 163 184); font-size: 10px; }
|
||||||
|
.dark .trend-axis { fill: rgb(100 116 139); }
|
||||||
|
.trend-grid { stroke: rgb(226 232 240); stroke-width: 1; }
|
||||||
|
.dark .trend-grid { stroke: rgba(255,255,255,.07); }
|
||||||
|
|
||||||
|
/* Funnel */
|
||||||
|
.funnel-row { margin-bottom: .65rem; }
|
||||||
|
.funnel-top { display: flex; justify-content: space-between; align-items: baseline; margin-bottom: .25rem; }
|
||||||
|
.funnel-label { font-size: .82rem; font-weight: 500; color: rgb(51 65 85); }
|
||||||
|
.dark .funnel-label { color: rgb(203 213 225); }
|
||||||
|
.funnel-meta { font-size: .75rem; color: rgb(100 116 139); }
|
||||||
|
.dark .funnel-meta { color: rgb(148 163 184); }
|
||||||
|
.funnel-track { position: relative; height: 1.85rem; border-radius: .6rem; background: rgb(241 245 249); overflow: hidden; }
|
||||||
|
.dark .funnel-track { background: rgba(255,255,255,.06); }
|
||||||
|
.funnel-fill { position: absolute; inset: 0 auto 0 0; border-radius: .6rem; display: grid; place-items: end; padding-right: .65rem; font-size: .8rem; font-weight: 700; color: #fff; width: 0; animation: bar-grow .7s cubic-bezier(.2,.8,.2,1) forwards; }
|
||||||
|
.funnel-fill span { transform: translateY(-1px); }
|
||||||
|
|
||||||
|
/* Activity timeline */
|
||||||
|
.tl { position: relative; padding-left: 1.5rem; }
|
||||||
|
.tl::before { content: ''; position: absolute; left: .4rem; top: .35rem; bottom: .35rem; width: 2px; background: rgb(226 232 240); }
|
||||||
|
.dark .tl::before { background: rgba(255,255,255,.1); }
|
||||||
|
.tl-item { position: relative; padding: .35rem 0 .9rem; }
|
||||||
|
.tl-item::before { content: ''; position: absolute; left: -1.15rem; top: .55rem; width: .65rem; height: .65rem; border-radius: 9999px; background: var(--stat-accent); box-shadow: 0 0 0 3px #fff; }
|
||||||
|
.dark .tl-item::before { box-shadow: 0 0 0 3px rgb(30 41 59); }
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.bar-fill, .funnel-fill { animation: none; width: var(--w); }
|
||||||
|
.kpi:hover { transform: none; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body class="min-h-screen flex flex-col transition-colors duration-300 bg-gray-50 dark:bg-gray-900 text-gray-900 dark:text-gray-100">
|
||||||
|
<%- include('partials/header') %>
|
||||||
|
|
||||||
|
<main class="flex-1 container mx-auto px-4 py-8">
|
||||||
|
<!-- Page heading -->
|
||||||
|
<div class="mb-8 flex flex-wrap items-end justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-2xl font-bold tracking-tight text-gray-900 dark:text-white">Statistik</h1>
|
||||||
|
<p class="mt-1 text-sm text-gray-500 dark:text-slate-400">Auswertung all deiner Bewerbungen — Verlauf, Erfolgsquote und Verteilungen.</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2 rounded-full bg-white dark:bg-gray-800 border border-gray-200 dark:border-white/5 px-4 py-2 text-sm shadow-sm">
|
||||||
|
<svg class="h-4 w-4 text-blue-600 dark:text-sky-400" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M9 19V6l12-3v13M9 19c0 1.66-1.34 3-3 3s-3-1.34-3-3 1.34-3 3-3 3 1.34 3 3zm12-3c0 1.66-1.34 3-3 3s-3-1.34-3-3 1.34-3 3-3 3 1.34 3 3z"></path></svg>
|
||||||
|
<span class="font-medium text-gray-700 dark:text-gray-200"><%= total %> Bewerbungen</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<% if (total === 0) { %>
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-empty">
|
||||||
|
<svg class="mx-auto mb-3 h-10 w-10 text-gray-300 dark:text-gray-600" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M3 13h2l3 9 4-16 3 9h6"></path></svg>
|
||||||
|
<p class="text-base font-medium text-gray-600 dark:text-gray-300">Noch keine Bewerbungen erfasst</p>
|
||||||
|
<p class="mt-1 text-sm">Sobald du Bewerbungen anlegst, erscheint hier deine komplette Auswertung.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% } else { %>
|
||||||
|
|
||||||
|
<!-- KPI cards -->
|
||||||
|
<div class="grid grid-cols-2 md:grid-cols-3 xl:grid-cols-6 gap-4 mb-8">
|
||||||
|
<div class="kpi">
|
||||||
|
<div class="kpi-glow" style="background:#2563eb"></div>
|
||||||
|
<div class="kpi-ico" style="background:linear-gradient(135deg,#3b82f6,#2563eb)">
|
||||||
|
<svg class="h-5 w-5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path></svg>
|
||||||
|
</div>
|
||||||
|
<div class="kpi-val text-gray-900 dark:text-white"><%= total %></div>
|
||||||
|
<div class="kpi-lab">Bewerbungen gesamt</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="kpi">
|
||||||
|
<div class="kpi-glow" style="background:#0ea5e9"></div>
|
||||||
|
<div class="kpi-ico" style="background:linear-gradient(135deg,#38bdf8,#0ea5e9)">
|
||||||
|
<svg class="h-5 w-5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"></path></svg>
|
||||||
|
</div>
|
||||||
|
<div class="kpi-val text-gray-900 dark:text-white"><%= kpis.gesendet %></div>
|
||||||
|
<div class="kpi-lab">Versendet</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="kpi">
|
||||||
|
<div class="kpi-glow" style="background:#10b981"></div>
|
||||||
|
<div class="kpi-ico" style="background:linear-gradient(135deg,#34d399,#10b981)">
|
||||||
|
<svg class="h-5 w-5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"></path></svg>
|
||||||
|
</div>
|
||||||
|
<div class="kpi-val text-emerald-600 dark:text-emerald-400"><%= kpis.eingestellt %></div>
|
||||||
|
<div class="kpi-lab">Einstellungen</div>
|
||||||
|
<div class="kpi-sub text-emerald-600/80 dark:text-emerald-400/80"><%= kpis.erfolgsquote %>% Erfolgsquote</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="kpi">
|
||||||
|
<div class="kpi-glow" style="background:#8b5cf6"></div>
|
||||||
|
<div class="kpi-ico" style="background:linear-gradient(135deg,#a78bfa,#8b5cf6)">
|
||||||
|
<svg class="h-5 w-5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M17 20h5v-2a4 4 0 00-3-3.87M9 20H4v-2a4 4 0 013-3.87m6-2a4 4 0 100-8 4 4 0 000 8z"></path></svg>
|
||||||
|
</div>
|
||||||
|
<div class="kpi-val text-violet-600 dark:text-violet-300"><%= kpis.gespraech %></div>
|
||||||
|
<div class="kpi-lab">Vorstellungsgespräche</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="kpi">
|
||||||
|
<div class="kpi-glow" style="background:#f43f5e"></div>
|
||||||
|
<div class="kpi-ico" style="background:linear-gradient(135deg,#fb7185,#f43f5e)">
|
||||||
|
<svg class="h-5 w-5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12"></path></svg>
|
||||||
|
</div>
|
||||||
|
<div class="kpi-val text-rose-600 dark:text-rose-400"><%= kpis.absagen %></div>
|
||||||
|
<div class="kpi-lab">Absagen</div>
|
||||||
|
<div class="kpi-sub text-gray-500 dark:text-slate-400"><%= kpis.antwortquote %>% Antwortquote</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="kpi">
|
||||||
|
<div class="kpi-glow" style="background:#f59e0b"></div>
|
||||||
|
<div class="kpi-ico" style="background:linear-gradient(135deg,#fbbf24,#f59e0b)">
|
||||||
|
<svg class="h-5 w-5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"></path></svg>
|
||||||
|
</div>
|
||||||
|
<div class="kpi-val text-gray-900 dark:text-white"><%= kpis.avgProMonat %></div>
|
||||||
|
<div class="kpi-lab">Ø pro Monat</div>
|
||||||
|
<div class="kpi-sub text-gray-500 dark:text-slate-400">über <%= kpis.monateAktiv %> Monate</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Monthly trend -->
|
||||||
|
<div class="stat-card mb-8">
|
||||||
|
<h2 class="text-gray-900 dark:text-white">Bewerbungen pro Monat</h2>
|
||||||
|
<p class="sub mb-5">Wie viele Bewerbungen in jedem Monat erstellt wurden.</p>
|
||||||
|
<%
|
||||||
|
const maxM = Math.max(1, ...byMonth.map(m => m.count));
|
||||||
|
const VB = 1000, BH = 320; // viewBox dimensions
|
||||||
|
const padL = 28, padR = 10, padB = 26, padT = 10;
|
||||||
|
const innerW = VB - padL - padR;
|
||||||
|
const innerH = BH - padT - padB;
|
||||||
|
const slot = byMonth.length ? innerW / byMonth.length : innerW;
|
||||||
|
const bw = Math.min(slot * .62, 40);
|
||||||
|
const monthName = (ym) => { const [, mo] = ym.split('-'); return ['Jan','Feb','Mär','Apr','Mai','Jun','Jul','Aug','Sep','Okt','Nov','Dez'][Number(mo)-1] || ''; };
|
||||||
|
const yearOf = (ym) => ym.split('-')[0];
|
||||||
|
let prevYear = '';
|
||||||
|
%>
|
||||||
|
<div class="trend-wrap">
|
||||||
|
<svg viewBox="0 0 <%= VB %> <%= BH %>" preserveAspectRatio="none" role="img" aria-label="Bewerbungen pro Monat">
|
||||||
|
<% for (let gy = 1; gy <= 4; gy++) { const y = padT + innerH * (1 - gy/4); const val = Math.round(maxM * gy/4); %>
|
||||||
|
<line class="trend-grid" x1="<%= padL %>" y1="<%= y %>" x2="<%= VB - padR %>" y2="<%= y %>"></line>
|
||||||
|
<text class="trend-axis" x="<%= padL-6 %>" y="<%= y+3 %>" text-anchor="end"><%= val %></text>
|
||||||
|
<% } %>
|
||||||
|
<% byMonth.forEach((m, i) => { const h = (m.count / maxM) * innerH; const x = padL + slot * i + (slot - bw)/2; const y = padT + innerH - h; const yr = yearOf(m.ym); const showYear = yr !== prevYear; prevYear = yr; %>
|
||||||
|
<rect class="trend-bar" x="<%= x %>" y="<%= y %>" width="<%= bw %>" height="<%= Math.max(h, 0.5) %>" rx="3" fill="url(#trendGrad)"></rect>
|
||||||
|
<text class="trend-axis" x="<%= x + bw/2 %>" y="<%= BH - 9 %>" text-anchor="middle"><%= monthName(m.ym) %></text>
|
||||||
|
<% if (showYear) { %><text class="trend-axis" x="<%= x + bw/2 %>" y="<%= BH - 1 %>" text-anchor="middle" style="font-size:8px;opacity:.7"><%= yr %></text><% } %>
|
||||||
|
<title><%= m.ym %>: <%= m.count %> Bewerbung<%= m.count===1?'':'en' %></title>
|
||||||
|
<% }); %>
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="trendGrad" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0%" stop-color="#3b82f6"></stop>
|
||||||
|
<stop offset="100%" stop-color="#0ea5e9"></stop>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-8">
|
||||||
|
<!-- By Status -->
|
||||||
|
<div class="stat-card">
|
||||||
|
<h2 class="text-gray-900 dark:text-white">Verteilung nach Status</h2>
|
||||||
|
<p class="sub mb-4">Aktueller Status jeder Bewerbung.</p>
|
||||||
|
<% if (byStatus.length) {
|
||||||
|
const maxS = Math.max(...byStatus.map(s => s.count));
|
||||||
|
const statusColor = (s) => ({
|
||||||
|
'Entwurf':'#94a3b8','Gesendet':'#3b82f6','Eingangsbestätigung':'#0ea5e9','In Bearbeitung':'#6366f1',
|
||||||
|
'Interessiert':'#8b5cf6','Telefonat':'#f472b6','Warten auf Rückmeldung':'#a3a3a3','Warten auf meine Antwort':'#a3a3a3',
|
||||||
|
'Vorstellungsgespräch':'#10b981','Vertragsverhandlung':'#84cc16','Absage':'#f43f5e','Absage von meiner Seite':'#f97316',
|
||||||
|
'Einstellung':'#059669','Keine Rückmeldung':'#cbd5e1'
|
||||||
|
}[s] || '#2563eb');
|
||||||
|
%>
|
||||||
|
<% byStatus.forEach(s => { const pct = Math.round((s.count / maxS) * 100); %>
|
||||||
|
<div class="bar-row">
|
||||||
|
<span class="bar-label"><span class="swatch" style="background:<%= statusColor(s.status) %>"></span><%= s.status %></span>
|
||||||
|
<div class="bar-track"><div class="bar-fill" style="--w:<%= pct %>%;width:<%= pct %>%;background:<%= statusColor(s.status) %>"></div></div>
|
||||||
|
<span class="bar-value"><%= s.count %></span>
|
||||||
|
</div>
|
||||||
|
<% }); %>
|
||||||
|
<% } else { %><div class="stat-empty">Keine Daten</div><% } %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- By Art -->
|
||||||
|
<div class="stat-card">
|
||||||
|
<h2 class="text-gray-900 dark:text-white">Verteilung nach Bewerbungsart</h2>
|
||||||
|
<p class="sub mb-4">Über welchen Kanal die Bewerbung lief.</p>
|
||||||
|
<% if (byArt.length) { const maxA = Math.max(...byArt.map(a => a.count)); const artColor = ['#2563eb','#0ea5e9','#10b981','#8b5cf6','#f59e0b','#f43f5e','#6366f1','#ec4899','#14b8a6']; %>
|
||||||
|
<% byArt.forEach((a, i) => { const pct = Math.round((a.count / maxA) * 100); %>
|
||||||
|
<div class="bar-row">
|
||||||
|
<span class="bar-label"><%= a.art %></span>
|
||||||
|
<div class="bar-track"><div class="bar-fill" style="--w:<%= pct %>%;width:<%= pct %>%;background:<%= artColor[i % artColor.length] %>"></div></div>
|
||||||
|
<span class="bar-value"><%= a.count %></span>
|
||||||
|
</div>
|
||||||
|
<% }); %>
|
||||||
|
<% } else { %><div class="stat-empty">Keine Daten</div><% } %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-8">
|
||||||
|
<!-- Funnel -->
|
||||||
|
<div class="stat-card">
|
||||||
|
<h2 class="text-gray-900 dark:text-white">Bewerbungs-Funnel</h2>
|
||||||
|
<p class="sub mb-5">Wie viele Bewerbungen jede Stufe je erreicht haben.</p>
|
||||||
|
<% const topF = Math.max(1, funnel[0].count);
|
||||||
|
const fColor = ['#2563eb','#3b82f6','#0ea5e9','#10b981','#059669'];
|
||||||
|
funnel.forEach((f, i) => { const pct = Math.round((f.count / topF) * 100); const conv = i === 0 ? '' : ' · ' + Math.round((f.count / Math.max(1, funnel[0].count) * 100)) + '%'; %>
|
||||||
|
<div class="funnel-row">
|
||||||
|
<div class="funnel-top">
|
||||||
|
<span class="funnel-label"><%= f.label %></span>
|
||||||
|
<span class="funnel-meta"><%= f.count %><%= conv %></span>
|
||||||
|
</div>
|
||||||
|
<div class="funnel-track">
|
||||||
|
<div class="funnel-fill" style="--w:<%= pct %>%;width:<%= pct %>%;background:<%= fColor[i] %>"><span><%= f.count %></span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% }); %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- By Ort -->
|
||||||
|
<div class="stat-card">
|
||||||
|
<h2 class="text-gray-900 dark:text-white">Top Standorte</h2>
|
||||||
|
<p class="sub mb-4">Wo du dich am häufigsten bewirbst.</p>
|
||||||
|
<% if (byOrt.length) { const maxO = Math.max(...byOrt.map(o => o.count)); const ortColor = ['#6366f1','#0ea5e9','#10b981','#f59e0b','#f43f5e','#8b5cf6','#14b8a6','#ec4899','#3b82f6','#a3a3a3']; %>
|
||||||
|
<% byOrt.forEach((o, i) => { const pct = Math.round((o.count / maxO) * 100); %>
|
||||||
|
<div class="bar-row" style="grid-template-columns:minmax(0,11rem) 1fr 2.5rem">
|
||||||
|
<span class="bar-label"><%= o.ort %></span>
|
||||||
|
<div class="bar-track"><div class="bar-fill" style="--w:<%= pct %>%;width:<%= pct %>%;background:<%= ortColor[i % ortColor.length] %>"></div></div>
|
||||||
|
<span class="bar-value"><%= o.count %></span>
|
||||||
|
</div>
|
||||||
|
<% }); %>
|
||||||
|
<% } else { %><div class="stat-empty">Keine Standorte erfasst</div><% } %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Recent activity -->
|
||||||
|
<div class="stat-card">
|
||||||
|
<h2 id="aktivitaet" class="text-gray-900 dark:text-white">Letzte Aktivität</h2>
|
||||||
|
<p class="sub mb-5">
|
||||||
|
Die jüngsten Statusänderungen und E-Mails deiner Bewerbungen.
|
||||||
|
<a href="/aktivitaeten" class="text-blue-600 dark:text-sky-400 hover:underline">Alle</a>
|
||||||
|
</p>
|
||||||
|
<% if (aktivitaet.length) {
|
||||||
|
const statusColor = (s) => ({
|
||||||
|
'Entwurf':'#94a3b8','Gesendet':'#3b82f6','Eingangsbestätigung':'#0ea5e9','In Bearbeitung':'#6366f1',
|
||||||
|
'Interessiert':'#8b5cf6','Telefonat':'#f472b6','Warten auf Rückmeldung':'#a3a3a3','Warten auf meine Antwort':'#a3a3a3',
|
||||||
|
'Vorstellungsgespräch':'#10b981','Vertragsverhandlung':'#84cc16','Absage':'#f43f5e','Absage von meiner Seite':'#f97316',
|
||||||
|
'Einstellung':'#059669','Keine Rückmeldung':'#cbd5e1'
|
||||||
|
}[s] || '#2563eb');
|
||||||
|
const emailColor = (a) => a.direction === 'out' ? '#0ea5e9' : '#3b82f6';
|
||||||
|
%>
|
||||||
|
<div class="tl">
|
||||||
|
<% aktivitaet.forEach(a => {
|
||||||
|
const accent = a.typ === 'email' ? emailColor(a) : statusColor(a.status);
|
||||||
|
let titel = '', titelSub = '';
|
||||||
|
if (a.bewerbung_id) { titel = a.firma || '(ohne Firma)'; titelSub = a.stelle || ''; }
|
||||||
|
else if (a.typ === 'email') { titel = a.direction === 'out' ? ('An: ' + (a.to_addr || '?')) : (a.from_addr || a.subject || 'E-Mail'); }
|
||||||
|
else { titel = 'Gelöschte Bewerbung'; }
|
||||||
|
%>
|
||||||
|
<div class="tl-item" style="--stat-accent:<%= accent %>">
|
||||||
|
<div class="flex flex-wrap items-baseline gap-x-2 gap-y-0.5">
|
||||||
|
<% if (a.bewerbung_id) { %>
|
||||||
|
<a href="/bewerbung/<%= a.bewerbung_id %>" class="text-sm font-medium text-gray-900 dark:text-white hover:underline"><%= titel %></a>
|
||||||
|
<% if (titelSub) { %><span class="text-sm text-gray-500 dark:text-slate-400">· <%= titelSub %></span><% } %>
|
||||||
|
<% } else { %>
|
||||||
|
<span class="text-sm font-medium text-gray-700 dark:text-gray-200"><%= titel %></span>
|
||||||
|
<% } %>
|
||||||
|
</div>
|
||||||
|
<div class="mt-0.5 flex flex-wrap items-center gap-x-2 text-sm">
|
||||||
|
<span class="inline-flex items-center gap-1.5 font-medium" style="color:<%= accent %>">
|
||||||
|
<span class="swatch" style="margin:0"></span><%= a.status %>
|
||||||
|
</span>
|
||||||
|
<% if (a.datum) { %><span class="text-gray-400 dark:text-slate-500">· <%= a.datum %></span><% } %>
|
||||||
|
<% if (a.kommentar) { %><span class="text-gray-500 dark:text-slate-400 truncate">· <%= a.kommentar %></span><% } %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% }); %>
|
||||||
|
</div>
|
||||||
|
<% } else { %><div class="stat-empty">Noch keine Aktivitäten — Statusänderungen und E-Mails erscheinen hier.</div><% } %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<% } %>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
+250
-25
@@ -3,10 +3,10 @@
|
|||||||
<head>
|
<head>
|
||||||
<%- include('partials/head') %>
|
<%- include('partials/head') %>
|
||||||
</head>
|
</head>
|
||||||
<body class="min-h-screen transition-colors duration-300 bg-gray-50 dark:bg-gray-900 text-gray-900 dark:text-gray-100" id="body">
|
<body class="min-h-screen flex flex-col transition-colors duration-300 bg-gray-50 dark:bg-gray-900 text-gray-900 dark:text-gray-100" id="body">
|
||||||
<%- include('partials/header', { hideSettings: true }) %>
|
<%- include('partials/header', { hideSettings: true }) %>
|
||||||
|
|
||||||
<main class="container mx-auto px-4 py-8 max-w-3xl">
|
<main class="flex-1 container mx-auto px-4 py-8 max-w-3xl">
|
||||||
<!-- Back link -->
|
<!-- Back link -->
|
||||||
<a href="/" class="inline-flex items-center gap-2 text-sm text-blue-600 dark:text-blue-400 hover:underline mb-6">
|
<a href="/" class="inline-flex items-center gap-2 text-sm text-blue-600 dark:text-blue-400 hover:underline mb-6">
|
||||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
@@ -15,6 +15,14 @@
|
|||||||
Zurück zur Übersicht
|
Zurück zur Übersicht
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
|
<!-- Abgewiesener Upload (zu groß, falscher Dateityp): ohne diesen Banner
|
||||||
|
käme die Seite kommentarlos ohne die Datei zurück. -->
|
||||||
|
<% if (typeof fehler !== 'undefined' && fehler) { %>
|
||||||
|
<div class="mb-6 rounded-lg border border-red-300 dark:border-red-700 bg-red-50 dark:bg-red-900/20 p-4" role="alert">
|
||||||
|
<p class="text-sm text-red-800 dark:text-red-200"><%= fehler %></p>
|
||||||
|
</div>
|
||||||
|
<% } %>
|
||||||
|
|
||||||
<h1 class="text-2xl font-bold text-gray-800 dark:text-white mb-2">Basis-Unterlagen (Vorlagen)</h1>
|
<h1 class="text-2xl font-bold text-gray-800 dark:text-white mb-2">Basis-Unterlagen (Vorlagen)</h1>
|
||||||
<p class="text-sm text-gray-500 dark:text-gray-400 mb-6">
|
<p class="text-sm text-gray-500 dark:text-gray-400 mb-6">
|
||||||
Diese Unterlagen dienen der KI als Faktengrundlage. Beim Import einer Stelle über die Browser-Erweiterung
|
Diese Unterlagen dienen der KI als Faktengrundlage. Beim Import einer Stelle über die Browser-Erweiterung
|
||||||
@@ -32,14 +40,89 @@
|
|||||||
</svg>
|
</svg>
|
||||||
<div class="text-sm text-amber-800 dark:text-amber-200">
|
<div class="text-sm text-amber-800 dark:text-amber-200">
|
||||||
<p class="font-semibold">Kein API-Schlüssel konfiguriert</p>
|
<p class="font-semibold">Kein API-Schlüssel konfiguriert</p>
|
||||||
<p>Setze die Umgebungsvariable <code class="px-1 rounded bg-amber-100 dark:bg-amber-800">OLLAMA_API_KEY</code>
|
<p>Trage den Ollama-API-Schlüssel unter
|
||||||
(z. B. in einer <code class="px-1 rounded bg-amber-100 dark:bg-amber-800">.env</code>-Datei),
|
<a href="/einstellungen" class="underline font-medium hover:no-underline">Einstellungen</a>
|
||||||
damit Bewerbungsunterlagen automatisch generiert werden können.</p>
|
ein, damit Bewerbungsunterlagen automatisch generiert werden können.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<% } %>
|
<% } %>
|
||||||
|
|
||||||
|
<!-- Persönliche Angaben (formerly the index-page settings modal) -->
|
||||||
|
<h2 id="persoenlich" class="text-xl font-bold text-gray-800 dark:text-white mt-10 mb-2 scroll-mt-4">Persönliche Angaben</h2>
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400 mb-6">
|
||||||
|
Deine Kontaktdaten fließen in jede Bewerbung ein – oben in Lebenslauf und Anschreiben
|
||||||
|
sowie in die Kontaktleiste. Die KI übernimmt sie unverändert und erfindet nichts mehr;
|
||||||
|
sie müssen dafür nicht länger in den Basis-Unterlagen stehen.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<section class="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6 mb-8">
|
||||||
|
<form action="/vorlagen/persoenlich" method="POST" class="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label for="name" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||||
|
Vollständiger Name *
|
||||||
|
</label>
|
||||||
|
<input type="text" id="name" name="name" required
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-800 dark:text-white"
|
||||||
|
placeholder="Max Mustermann" value="<%= settings.name || '' %>">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="adresse" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||||
|
Adresse
|
||||||
|
</label>
|
||||||
|
<textarea id="adresse" name="adresse" rows="2"
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-800 dark:text-white"
|
||||||
|
placeholder="Musterstraße 1, 12345 Musterstadt"><%= settings.adresse || '' %></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid gap-4 sm:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<label for="email" class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">E-Mail</label>
|
||||||
|
<input type="email" id="email" name="email"
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-800 dark:text-white"
|
||||||
|
placeholder="name@example.com" value="<%= settings.email || '' %>">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="telefon" class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Telefon</label>
|
||||||
|
<input type="text" id="telefon" name="telefon"
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-800 dark:text-white"
|
||||||
|
placeholder="z. B. 030 12345678" value="<%= settings.telefon || '' %>">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="ort" class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Wohnort</label>
|
||||||
|
<input type="text" id="ort" name="ort"
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-800 dark:text-white"
|
||||||
|
placeholder="z. B. Berlin" value="<%= settings.ort || '' %>">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="webseite" class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Webseite</label>
|
||||||
|
<input type="text" id="webseite" name="webseite"
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-800 dark:text-white"
|
||||||
|
placeholder="z. B. nextjobs.cc" value="<%= settings.webseite || '' %>">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="geburtsdatum" class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Geburtsdatum</label>
|
||||||
|
<input type="text" id="geburtsdatum" name="geburtsdatum"
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-800 dark:text-white"
|
||||||
|
placeholder="TT.MM.JJJJ" value="<%= settings.geburtsdatum || '' %>">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="kundennummer" class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Jobcenter-Kundennummer</label>
|
||||||
|
<input type="text" id="kundennummer" name="kundennummer"
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-800 dark:text-white"
|
||||||
|
placeholder="z. B. 123456789" value="<%= settings.kundennummer || '' %>">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex justify-end">
|
||||||
|
<button type="submit" class="px-4 py-2 bg-green-600 hover:bg-green-700 text-white rounded-md transition-colors">
|
||||||
|
Speichern
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
<!-- Existing base documents -->
|
<!-- Existing base documents -->
|
||||||
<section class="space-y-4 mb-8">
|
<section class="space-y-4 mb-8">
|
||||||
<% if (basisDokumente.length === 0) { %>
|
<% if (basisDokumente.length === 0) { %>
|
||||||
@@ -165,7 +248,9 @@
|
|||||||
<form action="/anlagen" method="POST" enctype="multipart/form-data" class="border-t border-gray-200 dark:border-gray-700 pt-4 mt-4 space-y-3">
|
<form action="/anlagen" method="POST" enctype="multipart/form-data" class="border-t border-gray-200 dark:border-gray-700 pt-4 mt-4 space-y-3">
|
||||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Datei (PDF)</label>
|
<label class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">
|
||||||
|
Datei (PDF) · max. <%= typeof anhangMaxMb !== 'undefined' ? anhangMaxMb : 15 %> MB
|
||||||
|
</label>
|
||||||
<input type="file" name="datei" accept="application/pdf,image/*" required
|
<input type="file" name="datei" accept="application/pdf,image/*" required
|
||||||
class="w-full text-sm text-gray-700 dark:text-gray-300 file:mr-3 file:py-2 file:px-3 file:rounded-md file:border-0 file:bg-blue-600 file:text-white hover:file:bg-blue-700">
|
class="w-full text-sm text-gray-700 dark:text-gray-300 file:mr-3 file:py-2 file:px-3 file:rounded-md file:border-0 file:bg-blue-600 file:text-white hover:file:bg-blue-700">
|
||||||
</div>
|
</div>
|
||||||
@@ -251,34 +336,174 @@
|
|||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<!-- Design of the generated PDFs -->
|
||||||
|
<h2 id="design" class="text-xl font-bold text-gray-800 dark:text-white mt-10 mb-2 scroll-mt-4">Design der Unterlagen</h2>
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400 mb-6">
|
||||||
|
Aussehen von Anschreiben und Lebenslauf. <strong>Das Anschreiben übernimmt immer das Layout des
|
||||||
|
Lebenslaufs</strong>, damit beide Dokumente als ein Set ankommen. Der Lebenslauf passt in jedem Layout
|
||||||
|
garantiert auf eine Seite. Die Vorschau zeigt Musterinhalte mit deinen Kontaktdaten, Foto und deiner
|
||||||
|
Unterschrift, ohne dass die KI läuft.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="rounded-lg bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 px-4 py-3 mb-6 text-sm text-blue-900 dark:text-blue-200">
|
||||||
|
<p><strong>Klassisch mit Sidebar</strong> – zurückhaltend, monochrom, für klassische Bewerbungen (Standard).</p>
|
||||||
|
<p class="mt-1"><strong>Social Media</strong> – verspielt: getönte Seite, runde Karten, Herzchen, Schreibschrift.
|
||||||
|
Bewusst auffällig – passend für kreative und Social-Media-Stellen, nicht für die Bank.</p>
|
||||||
|
<p class="mt-1"><strong>Sunny</strong> – plakativ: weiße Seite, sehr große Überschriften, pastellige Farbbänder,
|
||||||
|
ein Foto, das aus dem Kopfband ragt. Auffällig, aber aufgeräumt – für Marketing, Medien und Design.</p>
|
||||||
|
<p class="mt-1"><strong>Tupfen</strong> – dezent mit Augenzwinkern: weiße Seite, Name groß und mittig, luftiger
|
||||||
|
Blocksatz – und verstreute Punkte in den Rändern als einzige Farbe. Der Lebenslauf greift sie als
|
||||||
|
Abschnittsmarker und Sprach-Skala wieder auf. Passt überall, wirkt trotzdem nicht wie von der Stange.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section class="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6 mb-8">
|
||||||
|
<form action="/vorlagen/design" method="POST" class="space-y-4" id="designForm">
|
||||||
|
<div class="grid gap-4 sm:grid-cols-2">
|
||||||
|
<% designFelder.forEach(f => { %>
|
||||||
|
<div data-feld="<%= f.key %>" <%- f.nurLayout ? 'data-nur-layout="' + f.nurLayout + '"' : '' %>>
|
||||||
|
<label class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1"><%= f.label %></label>
|
||||||
|
<select name="<%= f.key %>"
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-900 dark:text-white">
|
||||||
|
<% f.optionen.forEach(o => { %>
|
||||||
|
<option value="<%= o.key %>" <%= o.gewaehlt ? 'selected' : '' %>><%= o.label %></option>
|
||||||
|
<% }) %>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<% }) %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- The hidden field must come first: when the box is checked both
|
||||||
|
values are submitted and the *last* one wins. -->
|
||||||
|
<input type="hidden" name="foto_anzeigen" value="0">
|
||||||
|
<label class="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
|
||||||
|
<input type="checkbox" name="foto_anzeigen" value="1" <%= designFotoAn ? 'checked' : '' %>
|
||||||
|
class="rounded border-gray-300 dark:border-gray-600">
|
||||||
|
Bewerberfoto im Lebenslauf anzeigen
|
||||||
|
<% if (!hasFoto) { %>
|
||||||
|
<span class="text-xs text-gray-400 dark:text-gray-500">(noch kein Foto hinterlegt)</span>
|
||||||
|
<% } %>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div class="flex flex-wrap items-center gap-3 border-t border-gray-200 dark:border-gray-700 pt-4">
|
||||||
|
<button type="submit" class="px-4 py-2 bg-green-600 hover:bg-green-700 text-white rounded-md transition-colors">
|
||||||
|
Speichern
|
||||||
|
</button>
|
||||||
|
<button type="button" id="vorschauAnschreiben"
|
||||||
|
class="px-4 py-2 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-200 rounded-md hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors">
|
||||||
|
Vorschau Anschreiben
|
||||||
|
</button>
|
||||||
|
<button type="button" id="vorschauLebenslauf"
|
||||||
|
class="px-4 py-2 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-200 rounded-md hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors">
|
||||||
|
Vorschau Lebenslauf
|
||||||
|
</button>
|
||||||
|
<% if (designAngepasst) { %>
|
||||||
|
<button type="submit" formaction="/vorlagen/design/reset" formnovalidate
|
||||||
|
onclick="return confirm('Design auf die Standardwerte zurücksetzen?');"
|
||||||
|
class="text-sm text-gray-600 dark:text-gray-400 hover:underline">
|
||||||
|
Auf Standard zurücksetzen
|
||||||
|
</button>
|
||||||
|
<% } %>
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-gray-400 dark:text-gray-500">
|
||||||
|
Die Vorschau zeigt die aktuell <em>ausgewählten</em> Werte – auch ungespeicherte.
|
||||||
|
</p>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Editable KI prompts -->
|
||||||
|
<h2 id="prompts" class="text-xl font-bold text-gray-800 dark:text-white mt-10 mb-2 scroll-mt-4">KI-Prompts</h2>
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400 mb-6">
|
||||||
|
Hier steht, <em>wie</em> die KI schreibt: Rolle, Tonfall und Regeln für Unterlagen, Chat und E-Mails.
|
||||||
|
Du kannst jeden Text anpassen – etwa um zu duzen, Formulierungen zu verbieten oder eine andere
|
||||||
|
Sprache zu verlangen. Der technische Aufbau (Aufbau des Lebenslaufs, Felder der E-Mail) ist fest
|
||||||
|
verdrahtet und wird davon nicht berührt, damit nichts kaputtgehen kann.
|
||||||
|
Mit <strong>Zurücksetzen</strong> holst du jederzeit den Standardtext zurück.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<section class="space-y-4 mb-8">
|
||||||
|
<% prompts.forEach(p => { %>
|
||||||
|
<details class="bg-white dark:bg-gray-800 rounded-lg shadow-md overflow-hidden" <%= p.angepasst ? 'open' : '' %>>
|
||||||
|
<summary class="cursor-pointer select-none px-6 py-4 flex items-center gap-3">
|
||||||
|
<span class="font-semibold text-gray-800 dark:text-white"><%= p.titel %></span>
|
||||||
|
<% if (p.angepasst) { %>
|
||||||
|
<span class="text-xs px-2 py-0.5 rounded-full bg-blue-100 text-blue-800 dark:bg-blue-900/50 dark:text-blue-300">angepasst</span>
|
||||||
|
<% } else { %>
|
||||||
|
<span class="text-xs px-2 py-0.5 rounded-full bg-gray-100 text-gray-500 dark:bg-gray-700 dark:text-gray-400">Standard</span>
|
||||||
|
<% } %>
|
||||||
|
</summary>
|
||||||
|
|
||||||
|
<div class="px-6 pb-6 border-t border-gray-200 dark:border-gray-700 pt-4">
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400 mb-3"><%= p.beschreibung %></p>
|
||||||
|
|
||||||
|
<% if (p.platzhalter && p.platzhalter.length) { %>
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400 mb-3">
|
||||||
|
Platzhalter:
|
||||||
|
<% p.platzhalter.forEach(ph => { %>
|
||||||
|
<code class="px-1 rounded bg-gray-100 dark:bg-gray-700">{{<%= ph %>}}</code>
|
||||||
|
<% }) %>
|
||||||
|
– wird beim Generieren automatisch ersetzt (Name aus den Einstellungen).
|
||||||
|
</p>
|
||||||
|
<% } %>
|
||||||
|
|
||||||
|
<form action="/vorlagen/prompts/<%= p.key %>" method="POST" class="space-y-3">
|
||||||
|
<textarea name="inhalt" rows="9"
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-900 dark:text-white font-mono text-xs leading-relaxed"><%= p.inhalt %></textarea>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<button type="submit" class="px-4 py-2 bg-green-600 hover:bg-green-700 text-white rounded-md transition-colors">
|
||||||
|
Speichern
|
||||||
|
</button>
|
||||||
|
<% if (p.angepasst) { %>
|
||||||
|
<button type="submit" formaction="/vorlagen/prompts/<%= p.key %>/reset"
|
||||||
|
formnovalidate
|
||||||
|
onclick="return confirm('Diesen Prompt auf den Standardtext zurücksetzen? Deine Änderungen gehen verloren.');"
|
||||||
|
class="text-sm text-gray-600 dark:text-gray-400 hover:underline">
|
||||||
|
Auf Standard zurücksetzen
|
||||||
|
</button>
|
||||||
|
<% } %>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
<% }) %>
|
||||||
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<%- include('partials/footer') %>
|
<%- include('partials/footer') %>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
(function () {
|
(function () {
|
||||||
const root = document.documentElement;
|
|
||||||
const dm = localStorage.getItem('darkMode');
|
|
||||||
if (dm === 'enabled' || (!dm && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
|
|
||||||
root.classList.add('dark');
|
|
||||||
}
|
|
||||||
const toggle = document.getElementById('darkModeToggle');
|
|
||||||
const sun = document.getElementById('sunIcon');
|
|
||||||
const moon = document.getElementById('moonIcon');
|
|
||||||
function sync() {
|
|
||||||
const d = root.classList.contains('dark');
|
|
||||||
if (sun) sun.classList.toggle('hidden', d);
|
|
||||||
if (moon) moon.classList.toggle('hidden', !d);
|
|
||||||
}
|
|
||||||
sync();
|
|
||||||
if (toggle) toggle.addEventListener('click', () => {
|
|
||||||
root.classList.toggle('dark');
|
|
||||||
localStorage.setItem('darkMode', root.classList.contains('dark') ? 'enabled' : 'disabled');
|
|
||||||
sync();
|
|
||||||
});
|
|
||||||
const cy = document.getElementById('currentYear');
|
const cy = document.getElementById('currentYear');
|
||||||
if (cy) cy.textContent = new Date().getFullYear();
|
if (cy) cy.textContent = new Date().getFullYear();
|
||||||
})();
|
})();
|
||||||
|
|
||||||
|
// Design preview: open the sample PDF with the values currently selected
|
||||||
|
// in the form (not just the saved ones), so a choice can be judged before
|
||||||
|
// it is committed.
|
||||||
|
(function () {
|
||||||
|
const form = document.getElementById('designForm');
|
||||||
|
if (!form) return;
|
||||||
|
function oeffne(welches) {
|
||||||
|
const q = new URLSearchParams(new FormData(form)).toString();
|
||||||
|
window.open('/vorlagen/design/vorschau/' + welches + '.pdf?' + q, '_blank');
|
||||||
|
}
|
||||||
|
const a = document.getElementById('vorschauAnschreiben');
|
||||||
|
const l = document.getElementById('vorschauLebenslauf');
|
||||||
|
if (a) a.addEventListener('click', () => oeffne('anschreiben'));
|
||||||
|
if (l) l.addEventListener('click', () => oeffne('lebenslauf'));
|
||||||
|
|
||||||
|
// Hide fields that only apply to one layout (e.g. the sidebar tint has
|
||||||
|
// no meaning in the social layout).
|
||||||
|
const layoutSel = form.querySelector('[name="layout"]');
|
||||||
|
function syncFelder() {
|
||||||
|
const aktiv = layoutSel ? layoutSel.value : 'sidebar';
|
||||||
|
form.querySelectorAll('[data-nur-layout]').forEach((el) => {
|
||||||
|
el.style.display = el.dataset.nurLayout === aktiv ? '' : 'none';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (layoutSel) layoutSel.addEventListener('change', syncFelder);
|
||||||
|
syncFelder();
|
||||||
|
})();
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
Reference in New Issue
Block a user