Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8648b4f4d1 | ||
|
|
bdf44eec04 | ||
|
|
0101660bd7 | ||
|
|
5f435d030e | ||
|
|
ee915742bf | ||
|
|
4acd81afb5 | ||
|
|
afe18e5b96 | ||
|
|
4fa2a34447 | ||
|
|
3371455255 | ||
|
|
09af98193f | ||
|
|
a30ecbc553 | ||
|
|
029df89265 | ||
|
|
7d3d026d1d | ||
|
|
e234641c36 |
@@ -12,3 +12,4 @@ docs/reference/*.docx
|
|||||||
docs/reference/*.pdf
|
docs/reference/*.pdf
|
||||||
docs/design/stitch-reference
|
docs/design/stitch-reference
|
||||||
docs/design/validation
|
docs/design/validation
|
||||||
|
artifacts
|
||||||
|
|||||||
@@ -51,6 +51,24 @@ OLLAMA_BASE_URL=http://ollama:11434
|
|||||||
OLLAMA_MODEL=
|
OLLAMA_MODEL=
|
||||||
OLLAMA_TIMEOUT_SECONDS=60
|
OLLAMA_TIMEOUT_SECONDS=60
|
||||||
|
|
||||||
|
# Microsoft Entra ID (Azure AD) SSO. Laat ENTRA_ID_ENABLED=0 om SSO uit te zetten.
|
||||||
|
# Registreer een app in Entra ID en zet de redirect-URI op <host>/auth/entra/callback/.
|
||||||
|
ENTRA_ID_ENABLED=0
|
||||||
|
ENTRA_CLIENT_ID=
|
||||||
|
ENTRA_CLIENT_SECRET=CHANGE_ME_entra_client_secret
|
||||||
|
ENTRA_TENANT_ID=
|
||||||
|
# Optioneel: overschrijf de redirect-URI (anders automatisch afgeleid van de host).
|
||||||
|
ENTRA_REDIRECT_URI=
|
||||||
|
# Optioneel: beperk tot bepaalde e-maildomeinen, komma-gescheiden (bv. organisatie.nl).
|
||||||
|
ENTRA_ALLOWED_DOMAINS=
|
||||||
|
ENTRA_SCOPES=User.Read
|
||||||
|
|
||||||
|
# Demo-toegang: knop "Bekijk demo" logt in als afgeschermd gastaccount met een
|
||||||
|
# fictieve, gevulde demo-omgeving. Zet op 0 om de knop te verbergen.
|
||||||
|
DEMO_MODE_ENABLED=1
|
||||||
|
DEMO_USERNAME=demo
|
||||||
|
DEMO_EMAIL=demo@vacatureradar.local
|
||||||
|
|
||||||
# Beveiliging
|
# Beveiliging
|
||||||
SESSION_COOKIE_SECURE=0
|
SESSION_COOKIE_SECURE=0
|
||||||
CSRF_COOKIE_SECURE=0
|
CSRF_COOKIE_SECURE=0
|
||||||
|
|||||||
@@ -28,6 +28,13 @@ Lees niet blind alle referentiedocumenten. Gebruik `docs/quality/TRACEABILITY_MA
|
|||||||
- Geen productieafhankelijkheid toevoegen zonder duidelijke noodzaak, lockfile-update, tests en documentatie.
|
- Geen productieafhankelijkheid toevoegen zonder duidelijke noodzaak, lockfile-update, tests en documentatie.
|
||||||
- Geen secrets, tokens, echte e-mailadressen of persoonlijke gegevens committen.
|
- Geen secrets, tokens, echte e-mailadressen of persoonlijke gegevens committen.
|
||||||
|
|
||||||
|
## Frontendcontract
|
||||||
|
|
||||||
|
- De productiefrontend volgt `docs/design/STITCH_FRONTEND_CONTRACT.md`; Stitch is de visuele bron van waarheid, de Django-backend blijft leidend voor werking en data.
|
||||||
|
- Gebruik uitsluitend echte of eerlijk als onbekend gemarkeerde data. Geen demo-KPI's, nepstatussen, dode controls, auto-apply of externe runtimeassets.
|
||||||
|
- Kernflows blijven server-rendered en bruikbaar zonder JavaScript; afwijkingen voor security, toegankelijkheid en ontbrekende data worden gedocumenteerd.
|
||||||
|
- Na een frontendmigratie blijft geen parallelle legacy-shell, ongebruikte componentstijl of verborgen oude UI bestaan.
|
||||||
|
|
||||||
## Codeconventies
|
## Codeconventies
|
||||||
|
|
||||||
- Python 3.12/3.13; type hints op publieke services.
|
- Python 3.12/3.13; type hints op publieke services.
|
||||||
|
|||||||
@@ -2,6 +2,70 @@
|
|||||||
|
|
||||||
Alle betekenisvolle wijzigingen worden hier bijgehouden. Het project volgt voorlopig een pre-1.0 semantische versieaanpak.
|
Alle betekenisvolle wijzigingen worden hier bijgehouden. Het project volgt voorlopig een pre-1.0 semantische versieaanpak.
|
||||||
|
|
||||||
|
## 0.3.0 — 2026-07-22
|
||||||
|
|
||||||
|
### Gewijzigd
|
||||||
|
|
||||||
|
- De voormalige MVP-frontend is volledig vervangen door één high-fidelity, server-rendered Stitch Intelligence Cockpit met nieuwe lokale tokens, shell, SVG-iconen, responsive lagen en progressive enhancement.
|
||||||
|
- Alle negen Stitch-schermen zijn functioneel gekoppeld: dashboard, vacatureverkenner/detail, sollicitatiepipeline, zoekprofiel, brongezondheid, automatisering, activiteit en werkgeversintelligence.
|
||||||
|
- Login, skillsradar, dossieredit, foutpagina's, empty states en messages gebruiken dezelfde componenttaal; de normale productiefrontend heeft geen legacyflag of parallelle shell.
|
||||||
|
- Nieuwe read services leveren echte dashboard-, vacature-, activiteit-, werkgever- en automationdata; vacaturefilters tonen standaard alleen aantoonbaar IT-relevante kansen.
|
||||||
|
- Sollicitatiestatus is een gebruikersgescopeerde CSRF-POST met audittrail. Niet-bestaande runtime- of marktdata blijft expliciet onbekend in plaats van gesimuleerd.
|
||||||
|
- Bronhealth gebruikt één geslicete prefetch voor alle bronnen; het automatiseringsscherm daalde in de representatieve dataset van 63 naar 23 queries.
|
||||||
|
|
||||||
|
### Verificatie
|
||||||
|
|
||||||
|
- De geïntegreerde Codex-browser controleerde alle negen schermen op desktop en 390 px, plus kernflows op 360, 768, 1024, 1280, 1440, 1920 en 2560 px zonder documentoverflow, kapotte assets of consolewaarschuwingen.
|
||||||
|
- Elf screenshots staan onder `artifacts/visual-validation/`; dark/light, mobiele drawer en toegankelijke actielabels zijn functioneel getest.
|
||||||
|
- Gerichte regressies, Ruff, Django system check, migratiecheck en de queryregressietest zijn groen; de volledige gate-uitkomst staat in `docs/ai/PROJECT_STATE.md`.
|
||||||
|
|
||||||
|
## 0.2.13 — 2026-07-22
|
||||||
|
|
||||||
|
### Gewijzigd
|
||||||
|
|
||||||
|
- Ervaringsjaren en senioriteitslabels zijn volledig uit de deterministische én AI-score gehaald; de profielwaarde blijft uitsluitend informatieve context en bestaande senioriteitsgewichten worden via migratie verwijderd.
|
||||||
|
- De nieuwe persoonlijke Skillsradar analyseert alleen actieve, IT-relevante, niet-uitgesloten vacatures van de huidige profielversie en negeert vacatures waarvan alle bronaliassen uitgeschakeld zijn.
|
||||||
|
- Per technologie of capability toont de radar vacaturevolume, profieldekking, skillveldbewijs versus tekstinferentie, onderliggende vacatures en een niet-blokkerende mogelijke leer-/bijscholingsstap.
|
||||||
|
- Categorie- en dekkingsfilters maken de marktvraag doorzoekbaar; de profielcheckboxes zijn uitgebreid met actuele Microsoft-, netwerk/security-, observability-, platform-, ITSM- en adoptieskills.
|
||||||
|
- De Stitch-cockpit gebruikt een aparte vraag-/leerkansencompositie met adaptieve kaarten, context-rail en tweekoloms skillfeed op ultrawide.
|
||||||
|
|
||||||
|
### Verificatie
|
||||||
|
|
||||||
|
- 240 tests geslaagd, 2 optionele Playwrightvarianten overgeslagen en 84,73% branch-aware codedekking; Ruff, Django, migraties, taakledger en repositoryvalidatie zijn groen.
|
||||||
|
- De geïntegreerde browsercontrole bevestigde filtering en bronselectie plus layouts op 2560×1440, 1440×900 en 390×844 zonder horizontale paginaoverflow of consolewaarschuwingen.
|
||||||
|
|
||||||
|
## 0.2.12 — 2026-07-22
|
||||||
|
|
||||||
|
### Gewijzigd
|
||||||
|
|
||||||
|
- De zoekprofieleditor biedt gegroepeerde selecties voor field service, infrastructuur, Modern Workplace, netwerk/security, back-up/monitoring, virtualisatie, VoIP en scripting.
|
||||||
|
- Relevante IT-ervaring is configureerbaar als ervaringsband en wordt alleen met expliciet gevraagde vacaturejaren vergeleken.
|
||||||
|
- Een breed cv-profiel verwatert de skillscore niet onbeperkt: vier aangetroffen skills volstaan voor de volledige skillcomponent en niet-vermelde voorkeurskills zijn geen bewezen tekort.
|
||||||
|
- Ambigue field-/implementatietitels vereisen naast de titel een sterk IT-contextsignaal in de vacaturetekst; korte en meervoudige uitsluittermen gebruiken woordgrenzen.
|
||||||
|
- De mobiele categoriekaarten stapelen op één kolom en behouden de Sticky-opslagactie zonder horizontale paginaoverflow.
|
||||||
|
|
||||||
|
### Verificatie
|
||||||
|
|
||||||
|
- 232 tests geslaagd, 2 optionele Playwrightvarianten overgeslagen en 84,59% branch-aware codedekking; Ruff, Django, migraties, taakledger en repositoryvalidatie zijn groen.
|
||||||
|
- De geïntegreerde browsercontrole bevestigde de gegroepeerde editor op ultrawide en 390 px, de ervaringsband, lokale profielselecties en afwezigheid van horizontale paginaoverflow.
|
||||||
|
- Persoonlijke cv-inhoud is alleen gebruikt om de lokale gebruikersprofielen af te stellen en is niet in code, fixtures, tests of documentatie opgenomen.
|
||||||
|
|
||||||
|
## 0.2.11 — 2026-07-22
|
||||||
|
|
||||||
|
### Gewijzigd
|
||||||
|
|
||||||
|
- De standaardradar toont alleen titelmatig aantoonbare IT-vacatures en laat verborgen/hard uitgesloten matches weg; het volledige bron- en scorearchief blijft expliciet bereikbaar.
|
||||||
|
- Vacatures worden standaard op de laatste persoonlijke matchscore gerangschikt en tonen scorebewijs, datakwaliteit, aandachtspunt en directe acties in de lijst.
|
||||||
|
- Vandaag gebruikt uitsluitend de laatste gebruikersgescopeerde score en telt alleen persoonlijke open dossiers.
|
||||||
|
- Dashboard, vacaturelijst en detail volgen de Stitch-panelhiërarchie met context-rails, rijkere evidencekaarten en een adaptieve contentbreedte tot 2240 px.
|
||||||
|
- Op 1920 en 2560 px krijgen resultaatfeeds twee kolommen; op 320 px stapelen filters, sectiekoppen en acties zonder horizontale overflow.
|
||||||
|
- Feedbackterugkeer is tegen externe redirects afgeschermd en `/favicon.ico` gebruikt het lokale SVG-favicon.
|
||||||
|
|
||||||
|
### Verificatie
|
||||||
|
|
||||||
|
- 225 tests geslaagd, 2 optionele Playwrightvarianten overgeslagen en 84,53% branch-aware codedekking; Ruff, Django, migraties, taakledger en repositoryvalidatie zijn groen.
|
||||||
|
- De geïntegreerde browsermatrix controleerde zes kernroutes en zes breedtes van 320 tot 2560 px, dark/light, mobiele navigatie en de radar-/archiefflow zonder horizontale paginaoverflow.
|
||||||
|
|
||||||
## 0.2.10 — 2026-07-22
|
## 0.2.10 — 2026-07-22
|
||||||
|
|
||||||
### Gewijzigd
|
### Gewijzigd
|
||||||
@@ -11,12 +75,14 @@ Alle betekenisvolle wijzigingen worden hier bijgehouden. Het project volgt voorl
|
|||||||
- Twaalf interessante regionale werkgevers zijn als afzonderlijke, niet-scanbare waaklijst toegevoegd, ook wanneer zij momenteel geen lokale vacature publiceren.
|
- Twaalf interessante regionale werkgevers zijn als afzonderlijke, niet-scanbare waaklijst toegevoegd, ook wanneer zij momenteel geen lokale vacature publiceren.
|
||||||
- De bronnenpagina toont 21 actieve bronnen, 12 waaklijstwerkgevers en de actieve GeoNames-afstandsstatus; waaklijstbronnen vallen buiten bulkacties en geplande scans.
|
- De bronnenpagina toont 21 actieve bronnen, 12 waaklijstwerkgevers en de actieve GeoNames-afstandsstatus; waaklijstbronnen vallen buiten bulkacties en geplande scans.
|
||||||
- HTTP-, robots- en IMAP-verbindingen gebruiken expliciet de gepinde `certifi`-CA-bundel met verplichte hostnaam- en certificaatcontrole. Gemeente Mol en Ziekenhuis Geel werken daarmee zonder TLS-bypass.
|
- HTTP-, robots- en IMAP-verbindingen gebruiken expliciet de gepinde `certifi`-CA-bundel met verplichte hostnaam- en certificaatcontrole. Gemeente Mol en Ziekenhuis Geel werken daarmee zonder TLS-bypass.
|
||||||
|
- Celery gebruikt expliciet queue `default`, zodat bronruns en andere standaardtaken daadwerkelijk door de productie-worker worden geconsumeerd.
|
||||||
|
- De fetcher controleert het werkelijk verbonden peer-IP en accepteert daardoor veilige publieke CDN-rotatie zonder de SSRF-blokkade voor private adressen te versoepelen.
|
||||||
|
|
||||||
### Verificatie
|
### Verificatie
|
||||||
|
|
||||||
- De 8 nieuwe actieve bronnen leverden samen 53 kaarten per cyclus en voltooiden elk twee livecycli zonder parserwarnings; de tweede cycli maakten geen nieuwe dubbelen aan.
|
- De 8 nieuwe actieve bronnen leverden samen 53 kaarten per cyclus en voltooiden elk twee livecycli zonder parserwarnings; de tweede cycli maakten geen nieuwe dubbelen aan.
|
||||||
- GeoNames valideerde 2.781 unieke Belgische postcode-/gemeenterijen; bron- en afgeleide SHA-256-checksums zijn vastgelegd en het actieve profiel is opnieuw gescoord.
|
- GeoNames valideerde 2.781 unieke Belgische postcode-/gemeenterijen; bron- en afgeleide SHA-256-checksums zijn vastgelegd en het actieve profiel is opnieuw gescoord.
|
||||||
- 215 tests geslaagd, 2 optionele Playwrightvarianten overgeslagen en 84,42% branch-aware codedekking; Ruff, Django, migraties, taakledger en repositoryvalidatie zijn groen.
|
- 218 tests geslaagd, 2 optionele Playwrightvarianten overgeslagen en 84,46% branch-aware codedekking; Ruff, Django, migraties, taakledger en repositoryvalidatie zijn groen.
|
||||||
- De Codex-browsercontrole bevestigde 21 actieve bronnen, 12 waaklijstkaarten, 8 nieuwe actieve werkgevers, één `h1`, een lege console en geen horizontale overflow op desktop of 390 px.
|
- De Codex-browsercontrole bevestigde 21 actieve bronnen, 12 waaklijstkaarten, 8 nieuwe actieve werkgevers, één `h1`, een lege console en geen horizontale overflow op desktop of 390 px.
|
||||||
|
|
||||||
## 0.2.9 — 2026-07-22
|
## 0.2.9 — 2026-07-22
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
# syntax=docker/dockerfile:1.7
|
||||||
|
FROM python:3.13-slim-trixie
|
||||||
|
|
||||||
|
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||||
|
PYTHONUNBUFFERED=1 \
|
||||||
|
UV_COMPILE_BYTECODE=1 \
|
||||||
|
UV_LINK_MODE=copy \
|
||||||
|
UV_DEFAULT_INDEX=https://pypi.org/simple \
|
||||||
|
PATH="/app/.venv/bin:$PATH"
|
||||||
|
|
||||||
|
RUN apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends curl ca-certificates libpq5 postgresql redis-server supervisor \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
COPY --from=ghcr.io/astral-sh/uv:0.10.0 /uv /uvx /bin/
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
RUN addgroup --system app \
|
||||||
|
&& adduser --system --ingroup app --home /app --no-create-home app
|
||||||
|
|
||||||
|
COPY pyproject.toml uv.lock ./
|
||||||
|
RUN uv sync --frozen --no-dev --no-install-project
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
RUN uv sync --frozen --no-dev \
|
||||||
|
&& chmod +x scripts/*.sh \
|
||||||
|
&& python manage.py collectstatic --noinput \
|
||||||
|
&& mkdir -p /app/media /app/logs /app/local /var/log/supervisor \
|
||||||
|
&& chown -R app:app /app
|
||||||
|
|
||||||
|
EXPOSE 8080
|
||||||
|
ENTRYPOINT ["/app/scripts/unraid_aio_entrypoint.sh"]
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
# VacatureRadar
|
# VacatureRadar
|
||||||
|
|
||||||
VacatureRadar is een persoonlijke, vrijwel autonome vacature-assistent voor toegestane publieke werkgeversbronnen, publieke ATS-pagina's, RSS/Atom en vacaturemails. De applicatie verzamelt vacatures, normaliseert en dedupliceert ze, past harde filters toe, berekent een uitlegbare matchscore en bundelt de beste resultaten in een dagelijkse digest.
|
VacatureRadar is een persoonlijke, vrijwel autonome vacature-assistent voor toegestane publieke werkgeversbronnen, publieke ATS-pagina's, RSS/Atom en vacaturemails. De applicatie verzamelt vacatures, normaliseert en dedupliceert ze, past harde filters toe, berekent een uitlegbare matchscore en presenteert vacatures, skillsvraag, werkgevers, opvolging en bronstatus in een Stitch-gebaseerde Intelligence Cockpit.
|
||||||
|
|
||||||
De repository bevat zowel een **werkende MVP-kern** als de volledige product-, architectuur-, UX-, beveiligings-, operationele en agentische overdrachtsdocumentatie om het project verder autonoom af te bouwen.
|
De repository bevat zowel een **werkende MVP-kern** als de volledige product-, architectuur-, UX-, beveiligings-, operationele en agentische overdrachtsdocumentatie om het project verder autonoom af te bouwen.
|
||||||
|
|
||||||
@@ -8,7 +8,7 @@ De repository bevat zowel een **werkende MVP-kern** als de volledige product-, a
|
|||||||
|
|
||||||
Reeds geïmplementeerd:
|
Reeds geïmplementeerd:
|
||||||
|
|
||||||
- Django 5.2-modulaire monoliet met Nederlandstalige responsive dark/light Intelligence Cockpit;
|
- Django 5.2-modulaire monoliet met de volledige Nederlandstalige, responsive dark/light Stitch Intelligence Cockpit;
|
||||||
- PostgreSQL in productie en SQLite voor lokale ontwikkeling;
|
- PostgreSQL in productie en SQLite voor lokale ontwikkeling;
|
||||||
- Celery-worker en scheduler via Redis;
|
- Celery-worker en scheduler via Redis;
|
||||||
- veilige HTTP-fetcher met domeinbeleid, redirecthercontrole, contentlimieten en SSRF-blokkering;
|
- veilige HTTP-fetcher met domeinbeleid, redirecthercontrole, contentlimieten en SSRF-blokkering;
|
||||||
@@ -17,19 +17,29 @@ Reeds geïmplementeerd:
|
|||||||
- exacte en fuzzy deduplicatie;
|
- exacte en fuzzy deduplicatie;
|
||||||
- deterministische kenmerken, harde uitsluitregels en uitlegbare scoring;
|
- deterministische kenmerken, harde uitsluitregels en uitlegbare scoring;
|
||||||
- zoekprofielen met revisiehistoriek en begrensde feedbackaanpassing;
|
- zoekprofielen met revisiehistoriek en begrensde feedbackaanpassing;
|
||||||
- dagelijkse digests, vacaturelevenscyclus en sollicitatiedossiers;
|
- dagelijkse digests, vacaturelevenscyclus, auditbare sollicitatiepipeline en activiteitstijdlijn;
|
||||||
|
- werkgeversintelligence, persoonlijke skillsradar en eerlijke operationele automatiseringsstatus;
|
||||||
- optionele lokale Ollama-analyse met prompt-injectionguardrails;
|
- optionele lokale Ollama-analyse met prompt-injectionguardrails;
|
||||||
- Docker Compose voor lokaal gebruik en een Unraid-variant;
|
- Docker Compose voor lokaal gebruik en een Unraid-variant;
|
||||||
- tests, fixtures, CI, back-up-/herstelscripts en healthchecks.
|
- tests, fixtures, CI, back-up-/herstelscripts en healthchecks.
|
||||||
|
|
||||||
Nog niet als productievoltooid beschouwd:
|
Nog niet als productievoltooid beschouwd:
|
||||||
|
|
||||||
- live broninventaris en bronvoorwaarden per concrete werkgever;
|
- periodieke menselijke voorwaardenreview voor bijkomende live werkgeversbronnen;
|
||||||
- een gekozen actuele geodataset met licentie;
|
|
||||||
- productiespecifieke secrets, mailboxcredentials, domein/TLS en containerregistry.
|
- productiespecifieke secrets, mailboxcredentials, domein/TLS en containerregistry.
|
||||||
|
|
||||||
Deze punten staan als uitvoerbare taken in [`docs/ai/BACKLOG.yaml`](docs/ai/BACKLOG.yaml). Externe credentials blokkeren nooit de rest van het werk: taken worden met mocks en interfaces afgewerkt en daarna als `blocked-external` gedocumenteerd waar echte configuratie nodig is.
|
Deze punten staan als uitvoerbare taken in [`docs/ai/BACKLOG.yaml`](docs/ai/BACKLOG.yaml). Externe credentials blokkeren nooit de rest van het werk: taken worden met mocks en interfaces afgewerkt en daarna als `blocked-external` gedocumenteerd waar echte configuratie nodig is.
|
||||||
|
|
||||||
|
## Frontend en visuele validatie
|
||||||
|
|
||||||
|
De enige normale browserfrontend is de server-rendered Stitch Intelligence Cockpit. Alle negen referentieschermen zijn aan echte Django-data gekoppeld; er is geen SPA, runtime-CDN, demo-KPI of oude parallelle shell. De ontwerpcontracten en gemotiveerde afwijkingen staan in:
|
||||||
|
|
||||||
|
- [`STITCH_FRONTEND_CONTRACT.md`](docs/design/STITCH_FRONTEND_CONTRACT.md);
|
||||||
|
- [`STITCH_INTEGRATION_PLAN.md`](docs/design/STITCH_INTEGRATION_PLAN.md);
|
||||||
|
- [`STITCH_INTEGRATION_REPORT.md`](docs/design/STITCH_INTEGRATION_REPORT.md).
|
||||||
|
|
||||||
|
De elf desktop-/mobielbeelden staan in `artifacts/visual-validation/` en zijn uitgesloten van de productiecontainer. De browsermatrix bestrijkt 360 tot 2560 px.
|
||||||
|
|
||||||
Release-productiepad:
|
Release-productiepad:
|
||||||
|
|
||||||
- `./scripts/release_verify.sh` bouwt de smoke-, backup/restore- en artifactcontrole;
|
- `./scripts/release_verify.sh` bouwt de smoke-, backup/restore- en artifactcontrole;
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
"""Authenticatie-backends voor VacatureRadar.
|
||||||
|
|
||||||
|
Sta inloggen toe met e-mailadres *of* gebruikersnaam, zodat het Stitch-loginscherm
|
||||||
|
(dat om een e-mailadres vraagt) blijft werken zonder het bestaande admin-login op
|
||||||
|
gebruikersnaam te breken.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
from django.contrib.auth.backends import ModelBackend
|
||||||
|
from django.db.models import Q
|
||||||
|
|
||||||
|
|
||||||
|
class EmailOrUsernameModelBackend(ModelBackend):
|
||||||
|
"""Zoekt de gebruiker op via gebruikersnaam of (case-insensitief) e-mailadres."""
|
||||||
|
|
||||||
|
def authenticate(self, request, username=None, password=None, **kwargs):
|
||||||
|
user_model = get_user_model()
|
||||||
|
if username is None:
|
||||||
|
username = kwargs.get(user_model.USERNAME_FIELD)
|
||||||
|
if username is None or password is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
user = user_model.objects.get(
|
||||||
|
Q(**{f"{user_model.USERNAME_FIELD}__iexact": username})
|
||||||
|
| Q(email__iexact=username)
|
||||||
|
)
|
||||||
|
except user_model.DoesNotExist:
|
||||||
|
# Draai toch een hash om timing-aanvallen (user-enumeratie) te beperken.
|
||||||
|
user_model().set_password(password)
|
||||||
|
return None
|
||||||
|
except user_model.MultipleObjectsReturned:
|
||||||
|
# Meerdere accounts met dezelfde e-mail: val terug op exacte username-match.
|
||||||
|
user = (
|
||||||
|
user_model.objects.filter(
|
||||||
|
**{f"{user_model.USERNAME_FIELD}__iexact": username}
|
||||||
|
)
|
||||||
|
.order_by("id")
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if user is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if user.check_password(password) and self.user_can_authenticate(user):
|
||||||
|
return user
|
||||||
|
return None
|
||||||
@@ -10,7 +10,7 @@ from apps.profiles.models import SearchProfile
|
|||||||
def navigation_context(request: HttpRequest) -> dict[str, Any]:
|
def navigation_context(request: HttpRequest) -> dict[str, Any]:
|
||||||
context = {
|
context = {
|
||||||
"app_name": "VacatureRadar",
|
"app_name": "VacatureRadar",
|
||||||
"app_version": "0.2.2",
|
"app_version": "0.3.0",
|
||||||
}
|
}
|
||||||
if request.user.is_authenticated:
|
if request.user.is_authenticated:
|
||||||
context["navigation_profile"] = (
|
context["navigation_profile"] = (
|
||||||
|
|||||||
@@ -0,0 +1,107 @@
|
|||||||
|
"""Microsoft Entra ID (Azure AD) SSO via de OAuth2 authorization-code flow.
|
||||||
|
|
||||||
|
De ``msal``-bibliotheek wordt bewust *lazy* geïmporteerd: zolang SSO uit staat
|
||||||
|
(``ENTRA_ID_ENABLED=0``) hoeft het pakket niet geïnstalleerd te zijn en blijven de
|
||||||
|
bestaande tests en de kernapplicatie volledig werken.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
|
||||||
|
|
||||||
|
class EntraConfigError(RuntimeError):
|
||||||
|
"""Entra ID is ingeschakeld maar niet (volledig) geconfigureerd."""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class EntraConfig:
|
||||||
|
client_id: str
|
||||||
|
client_secret: str
|
||||||
|
tenant_id: str
|
||||||
|
scopes: list[str]
|
||||||
|
allowed_domains: tuple[str, ...]
|
||||||
|
redirect_uri: str | None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def authority(self) -> str:
|
||||||
|
return f"https://login.microsoftonline.com/{self.tenant_id}"
|
||||||
|
|
||||||
|
|
||||||
|
def entra_enabled() -> bool:
|
||||||
|
return bool(getattr(settings, "ENTRA_ID_ENABLED", False))
|
||||||
|
|
||||||
|
|
||||||
|
def load_config() -> EntraConfig:
|
||||||
|
client_id = getattr(settings, "ENTRA_CLIENT_ID", "") or ""
|
||||||
|
client_secret = getattr(settings, "ENTRA_CLIENT_SECRET", "") or ""
|
||||||
|
tenant_id = getattr(settings, "ENTRA_TENANT_ID", "") or ""
|
||||||
|
if not (client_id and client_secret and tenant_id):
|
||||||
|
raise EntraConfigError(
|
||||||
|
"Entra ID staat aan maar ENTRA_CLIENT_ID, ENTRA_CLIENT_SECRET en "
|
||||||
|
"ENTRA_TENANT_ID zijn niet allemaal ingesteld."
|
||||||
|
)
|
||||||
|
scopes = list(getattr(settings, "ENTRA_SCOPES", ["User.Read"]))
|
||||||
|
allowed = tuple(
|
||||||
|
d.lower().lstrip("@")
|
||||||
|
for d in getattr(settings, "ENTRA_ALLOWED_DOMAINS", [])
|
||||||
|
if d
|
||||||
|
)
|
||||||
|
return EntraConfig(
|
||||||
|
client_id=client_id,
|
||||||
|
client_secret=client_secret,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
scopes=scopes,
|
||||||
|
allowed_domains=allowed,
|
||||||
|
redirect_uri=getattr(settings, "ENTRA_REDIRECT_URI", "") or None,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _build_app(config: EntraConfig):
|
||||||
|
try:
|
||||||
|
import msal # lazy import: alleen nodig als SSO aan staat
|
||||||
|
except ModuleNotFoundError as exc: # pragma: no cover - hangt van omgeving af
|
||||||
|
raise EntraConfigError(
|
||||||
|
"Het pakket 'msal' is niet geïnstalleerd. Voeg het toe (zie pyproject.toml) "
|
||||||
|
"om Entra ID SSO te gebruiken."
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
return msal.ConfidentialClientApplication(
|
||||||
|
client_id=config.client_id,
|
||||||
|
client_credential=config.client_secret,
|
||||||
|
authority=config.authority,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def build_auth_flow(config: EntraConfig, redirect_uri: str) -> dict:
|
||||||
|
"""Start de authorization-code flow en geef het flow-dict terug (bewaar in sessie)."""
|
||||||
|
app = _build_app(config)
|
||||||
|
return app.initiate_auth_code_flow(config.scopes, redirect_uri=redirect_uri)
|
||||||
|
|
||||||
|
|
||||||
|
def redeem_auth_code(config: EntraConfig, flow: dict, auth_response: dict) -> dict:
|
||||||
|
"""Wissel de teruggekeerde code in voor tokens en claims."""
|
||||||
|
app = _build_app(config)
|
||||||
|
return app.acquire_token_by_auth_code_flow(flow, auth_response)
|
||||||
|
|
||||||
|
|
||||||
|
def extract_identity(token_result: dict) -> tuple[str, str]:
|
||||||
|
"""Haal (e-mailadres, weergavenaam) uit de id-token-claims."""
|
||||||
|
claims = token_result.get("id_token_claims") or {}
|
||||||
|
email = (
|
||||||
|
claims.get("preferred_username")
|
||||||
|
or claims.get("email")
|
||||||
|
or claims.get("upn")
|
||||||
|
or ""
|
||||||
|
).strip()
|
||||||
|
name = (claims.get("name") or "").strip()
|
||||||
|
return email, name
|
||||||
|
|
||||||
|
|
||||||
|
def domain_allowed(config: EntraConfig, email: str) -> bool:
|
||||||
|
if not config.allowed_domains:
|
||||||
|
return True
|
||||||
|
domain = email.rsplit("@", 1)[-1].lower()
|
||||||
|
return domain in config.allowed_domains
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
"""Formulieren voor authenticatie."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from django.contrib.auth.forms import AuthenticationForm
|
||||||
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
|
|
||||||
|
class EmailAuthenticationForm(AuthenticationForm):
|
||||||
|
"""Loginformulier dat het ``username``-veld presenteert als e-mailadres.
|
||||||
|
|
||||||
|
Het veld blijft ``username`` heten zodat ``LoginView`` en de authenticatie-backend
|
||||||
|
onveranderd werken; alleen label, invoertype en foutmelding zijn aangepast.
|
||||||
|
"""
|
||||||
|
|
||||||
|
error_messages = {
|
||||||
|
**AuthenticationForm.error_messages,
|
||||||
|
"invalid_login": _("E-mailadres of wachtwoord klopt niet."),
|
||||||
|
}
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
field = self.fields["username"]
|
||||||
|
field.label = _("E-mailadres")
|
||||||
|
field.widget.attrs.update(
|
||||||
|
{
|
||||||
|
"type": "email",
|
||||||
|
"autocomplete": "username",
|
||||||
|
"autocapitalize": "none",
|
||||||
|
"spellcheck": "false",
|
||||||
|
"placeholder": "naam@organisatie.nl",
|
||||||
|
}
|
||||||
|
)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Read services for the application cockpit."""
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import timedelta
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db.models import Count, Q, Sum
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
from apps.jobs.models import JobPosting, ScoreRun
|
||||||
|
from apps.jobs.services.relevance import it_relevance_query
|
||||||
|
from apps.notifications.models import DigestOutbox, ReminderOutbox
|
||||||
|
from apps.profiles.models import SearchProfile
|
||||||
|
from apps.sources.models import Source, SourceRun
|
||||||
|
|
||||||
|
from ..health import readiness
|
||||||
|
|
||||||
|
|
||||||
|
def build_automation_cockpit() -> dict[str, Any]:
|
||||||
|
"""Build an honest operational read model without probing unsafe hidden endpoints."""
|
||||||
|
|
||||||
|
since = timezone.now() - timedelta(hours=24)
|
||||||
|
recent_runs = SourceRun.objects.filter(started_at__gte=since)
|
||||||
|
run_metrics = recent_runs.aggregate(
|
||||||
|
discovered=Sum("discovered_count"),
|
||||||
|
extracted=Sum("extracted_count"),
|
||||||
|
created=Sum("created_count"),
|
||||||
|
updated=Sum("updated_count"),
|
||||||
|
duplicates=Sum("duplicate_count"),
|
||||||
|
failures=Count("id", filter=Q(status=SourceRun.Status.FAILED)),
|
||||||
|
)
|
||||||
|
notification_counts = {
|
||||||
|
"pending": DigestOutbox.objects.filter(status=DigestOutbox.Status.PENDING).count()
|
||||||
|
+ ReminderOutbox.objects.filter(status=ReminderOutbox.Status.PENDING).count(),
|
||||||
|
"failed": DigestOutbox.objects.filter(status=DigestOutbox.Status.FAILED).count()
|
||||||
|
+ ReminderOutbox.objects.filter(status=ReminderOutbox.Status.FAILED).count(),
|
||||||
|
"sent_24h": DigestOutbox.objects.filter(
|
||||||
|
status=DigestOutbox.Status.SENT, sent_at__gte=since
|
||||||
|
).count()
|
||||||
|
+ ReminderOutbox.objects.filter(
|
||||||
|
status=ReminderOutbox.Status.SENT, sent_at__gte=since
|
||||||
|
).count(),
|
||||||
|
}
|
||||||
|
source_total = Source.objects.count()
|
||||||
|
source_active = Source.objects.filter(status=Source.Status.ACTIVE).count()
|
||||||
|
active_jobs = JobPosting.objects.filter(status=JobPosting.Status.ACTIVE)
|
||||||
|
phase_rows = [
|
||||||
|
{"key": "discover", "label": "Ontdekken", "value": source_total, "unit": "bronnen"},
|
||||||
|
{"key": "fetch", "label": "Ophalen", "value": recent_runs.count(), "unit": "runs / 24u"},
|
||||||
|
{
|
||||||
|
"key": "extract",
|
||||||
|
"label": "Extraheren",
|
||||||
|
"value": run_metrics["extracted"] or 0,
|
||||||
|
"unit": "items",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "normalize",
|
||||||
|
"label": "Normaliseren",
|
||||||
|
"value": (run_metrics["created"] or 0) + (run_metrics["updated"] or 0),
|
||||||
|
"unit": "records",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "dedupe",
|
||||||
|
"label": "Dedupliceren",
|
||||||
|
"value": run_metrics["duplicates"] or 0,
|
||||||
|
"unit": "herkend",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "filter",
|
||||||
|
"label": "IT-filter",
|
||||||
|
"value": active_jobs.exclude(it_relevance_query()).count(),
|
||||||
|
"unit": "uit beeld",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "analyze",
|
||||||
|
"label": "Analyseren",
|
||||||
|
"value": ScoreRun.objects.filter(created_at__gte=since).count(),
|
||||||
|
"unit": "scores / 24u",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "notify",
|
||||||
|
"label": "Notificeren",
|
||||||
|
"value": notification_counts["sent_24h"],
|
||||||
|
"unit": "verzonden / 24u",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
return {
|
||||||
|
"health": readiness(),
|
||||||
|
"source_total": source_total,
|
||||||
|
"source_active": source_active,
|
||||||
|
"run_metrics": run_metrics,
|
||||||
|
"run_count": recent_runs.count(),
|
||||||
|
"active_jobs": active_jobs.filter(it_relevance_query()).count(),
|
||||||
|
"review_jobs": ScoreRun.objects.filter(
|
||||||
|
recommendation__in=[ScoreRun.Recommendation.STRONG, ScoreRun.Recommendation.POSSIBLE],
|
||||||
|
job__status=JobPosting.Status.ACTIVE,
|
||||||
|
).count(),
|
||||||
|
"notification_counts": notification_counts,
|
||||||
|
"phase_rows": phase_rows,
|
||||||
|
"recent_runs": list(
|
||||||
|
SourceRun.objects.select_related("source").order_by("-started_at")[:10]
|
||||||
|
),
|
||||||
|
"source_rows": Source.objects.annotate(run_count=Count("runs")).order_by("name")[:12],
|
||||||
|
"queue_state": "Eager/lokaal"
|
||||||
|
if settings.CELERY_TASK_ALWAYS_EAGER
|
||||||
|
else "Geconfigureerd, worker niet gemeten",
|
||||||
|
"ai_state": "Geconfigureerd"
|
||||||
|
if settings.OLLAMA_ENABLED and settings.OLLAMA_MODEL
|
||||||
|
else "Niet geconfigureerd",
|
||||||
|
"ai_profiles": SearchProfile.objects.filter(ai_scoring_enabled=True).count(),
|
||||||
|
"browser_state": "Geen aparte browserextractor geconfigureerd",
|
||||||
|
}
|
||||||
@@ -1,24 +1,55 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.contrib import messages
|
from django.contrib import messages
|
||||||
|
from django.contrib.auth import get_user_model, login
|
||||||
from django.contrib.auth.mixins import LoginRequiredMixin
|
from django.contrib.auth.mixins import LoginRequiredMixin
|
||||||
from django.contrib.auth.views import LoginView
|
from django.contrib.auth.views import LoginView
|
||||||
from django.db.models import Count, Q
|
|
||||||
from django.http import JsonResponse
|
from django.http import JsonResponse
|
||||||
from django.utils import timezone
|
from django.shortcuts import redirect
|
||||||
|
from django.urls import reverse
|
||||||
|
from django.utils.http import url_has_allowed_host_and_scheme
|
||||||
|
from django.views.decorators.http import require_POST
|
||||||
from django.views.generic import TemplateView
|
from django.views.generic import TemplateView
|
||||||
|
|
||||||
|
from apps.core import entra as entra_sso
|
||||||
|
from apps.core.forms import EmailAuthenticationForm
|
||||||
from apps.core.rate_limit import clear_rate_limit, is_rate_limited, register_rate_limit_failure
|
from apps.core.rate_limit import clear_rate_limit, is_rate_limited, register_rate_limit_failure
|
||||||
from apps.jobs.models import Application, JobPosting, ScoreRun
|
from apps.jobs.services.cockpit import build_dashboard_cockpit
|
||||||
from apps.sources.models import Source
|
from apps.profiles.models import SearchProfile
|
||||||
from apps.sources.services.health import collect_source_health
|
from apps.sources.services.health import collect_source_health
|
||||||
|
|
||||||
from .health import readiness
|
from .health import readiness
|
||||||
|
from .services.automation import build_automation_cockpit
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
SSO_BACKEND = "apps.core.auth_backends.EmailOrUsernameModelBackend"
|
||||||
|
SESSION_ENTRA_FLOW = "entra_auth_flow"
|
||||||
|
SESSION_ENTRA_NEXT = "entra_next"
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_next(request, candidate: str | None) -> str | None:
|
||||||
|
if candidate and url_has_allowed_host_and_scheme(
|
||||||
|
candidate,
|
||||||
|
allowed_hosts={request.get_host()},
|
||||||
|
require_https=request.is_secure(),
|
||||||
|
):
|
||||||
|
return candidate
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
class SecurityAwareLoginView(LoginView):
|
class SecurityAwareLoginView(LoginView):
|
||||||
template_name = "registration/login.html"
|
template_name = "registration/login.html"
|
||||||
|
authentication_form = EmailAuthenticationForm
|
||||||
|
|
||||||
|
def get_context_data(self, **kwargs):
|
||||||
|
context = super().get_context_data(**kwargs)
|
||||||
|
context["entra_enabled"] = entra_sso.entra_enabled()
|
||||||
|
context["demo_enabled"] = bool(getattr(settings, "DEMO_MODE_ENABLED", False))
|
||||||
|
return context
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _remaining_minutes(seconds: int) -> str:
|
def _remaining_minutes(seconds: int) -> str:
|
||||||
@@ -69,6 +100,142 @@ class SecurityAwareLoginView(LoginView):
|
|||||||
return super().form_valid(form)
|
return super().form_valid(form)
|
||||||
|
|
||||||
|
|
||||||
|
def _split_name(full_name: str) -> tuple[str, str]:
|
||||||
|
parts = full_name.split()
|
||||||
|
if not parts:
|
||||||
|
return "", ""
|
||||||
|
if len(parts) == 1:
|
||||||
|
return parts[0][:150], ""
|
||||||
|
return parts[0][:150], " ".join(parts[1:])[:150]
|
||||||
|
|
||||||
|
|
||||||
|
def _unique_username(base: str) -> str:
|
||||||
|
user_model = get_user_model()
|
||||||
|
base = (base or "gebruiker")[:140].strip() or "gebruiker"
|
||||||
|
candidate = base
|
||||||
|
suffix = 1
|
||||||
|
while user_model.objects.filter(username__iexact=candidate).exists():
|
||||||
|
suffix += 1
|
||||||
|
candidate = f"{base}-{suffix}"
|
||||||
|
return candidate
|
||||||
|
|
||||||
|
|
||||||
|
def _get_or_create_sso_user(email: str, full_name: str):
|
||||||
|
user_model = get_user_model()
|
||||||
|
user = user_model.objects.filter(email__iexact=email).order_by("id").first()
|
||||||
|
first, last = _split_name(full_name)
|
||||||
|
if user is None:
|
||||||
|
user = user_model(
|
||||||
|
username=_unique_username(email.split("@", 1)[0]),
|
||||||
|
email=email,
|
||||||
|
first_name=first,
|
||||||
|
last_name=last,
|
||||||
|
)
|
||||||
|
user.set_unusable_password()
|
||||||
|
user.save()
|
||||||
|
elif full_name and not (user.first_name or user.last_name):
|
||||||
|
user.first_name, user.last_name = first, last
|
||||||
|
user.save(update_fields=["first_name", "last_name"])
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
@require_POST
|
||||||
|
def entra_login(request):
|
||||||
|
"""Start de Entra ID authorization-code flow."""
|
||||||
|
if not entra_sso.entra_enabled():
|
||||||
|
messages.error(request, "Inloggen via Entra ID is niet ingeschakeld.")
|
||||||
|
return redirect("login")
|
||||||
|
try:
|
||||||
|
config = entra_sso.load_config()
|
||||||
|
redirect_uri = config.redirect_uri or request.build_absolute_uri(
|
||||||
|
reverse("entra-callback")
|
||||||
|
)
|
||||||
|
flow = entra_sso.build_auth_flow(config, redirect_uri)
|
||||||
|
except entra_sso.EntraConfigError as exc:
|
||||||
|
logger.warning("Entra ID niet beschikbaar: %s", exc)
|
||||||
|
messages.error(request, "Inloggen via Entra ID is nu niet beschikbaar.")
|
||||||
|
return redirect("login")
|
||||||
|
|
||||||
|
request.session[SESSION_ENTRA_FLOW] = flow
|
||||||
|
next_url = _safe_next(request, request.POST.get("next"))
|
||||||
|
if next_url:
|
||||||
|
request.session[SESSION_ENTRA_NEXT] = next_url
|
||||||
|
return redirect(flow["auth_uri"])
|
||||||
|
|
||||||
|
|
||||||
|
def entra_callback(request):
|
||||||
|
"""Verwerk de terugkeer van Microsoft na de SSO-flow."""
|
||||||
|
if not entra_sso.entra_enabled():
|
||||||
|
return redirect("login")
|
||||||
|
|
||||||
|
flow = request.session.pop(SESSION_ENTRA_FLOW, None)
|
||||||
|
next_url = request.session.pop(SESSION_ENTRA_NEXT, None)
|
||||||
|
if not flow:
|
||||||
|
messages.error(request, "De Entra ID-sessie is verlopen. Probeer opnieuw.")
|
||||||
|
return redirect("login")
|
||||||
|
|
||||||
|
try:
|
||||||
|
config = entra_sso.load_config()
|
||||||
|
result = entra_sso.redeem_auth_code(config, flow, request.GET.dict())
|
||||||
|
except entra_sso.EntraConfigError as exc:
|
||||||
|
logger.warning("Entra ID niet beschikbaar: %s", exc)
|
||||||
|
messages.error(request, "Inloggen via Entra ID is nu niet beschikbaar.")
|
||||||
|
return redirect("login")
|
||||||
|
|
||||||
|
if "error" in result:
|
||||||
|
logger.warning(
|
||||||
|
"Entra ID-fout: %s", result.get("error_description") or result.get("error")
|
||||||
|
)
|
||||||
|
messages.error(request, "Inloggen via Entra ID is mislukt.")
|
||||||
|
return redirect("login")
|
||||||
|
|
||||||
|
email, name = entra_sso.extract_identity(result)
|
||||||
|
if not email:
|
||||||
|
messages.error(request, "Entra ID gaf geen e-mailadres terug.")
|
||||||
|
return redirect("login")
|
||||||
|
if not entra_sso.domain_allowed(config, email):
|
||||||
|
messages.error(request, "Dit Entra ID-account mag hier niet inloggen.")
|
||||||
|
return redirect("login")
|
||||||
|
|
||||||
|
user = _get_or_create_sso_user(email, name)
|
||||||
|
login(request, user, backend=SSO_BACKEND)
|
||||||
|
clear_rate_limit(request, namespace="login")
|
||||||
|
return redirect(next_url or settings.LOGIN_REDIRECT_URL)
|
||||||
|
|
||||||
|
|
||||||
|
@require_POST
|
||||||
|
def demo_login(request):
|
||||||
|
"""Log in als afgeschermd demo-account."""
|
||||||
|
if not getattr(settings, "DEMO_MODE_ENABLED", False):
|
||||||
|
messages.error(request, "De demomodus is niet ingeschakeld.")
|
||||||
|
return redirect("login")
|
||||||
|
|
||||||
|
user_model = get_user_model()
|
||||||
|
user, created = user_model.objects.get_or_create(
|
||||||
|
username=settings.DEMO_USERNAME,
|
||||||
|
defaults={
|
||||||
|
"email": settings.DEMO_EMAIL,
|
||||||
|
"first_name": "Demo",
|
||||||
|
"last_name": "Gebruiker",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if created:
|
||||||
|
user.set_unusable_password()
|
||||||
|
user.save()
|
||||||
|
|
||||||
|
try:
|
||||||
|
from apps.jobs.services.demo_seed import seed_demo_environment
|
||||||
|
|
||||||
|
seed_demo_environment(user)
|
||||||
|
except Exception:
|
||||||
|
# De demo mag nooit de login blokkeren.
|
||||||
|
logger.exception("Kon de demo-omgeving niet seeden")
|
||||||
|
|
||||||
|
login(request, user, backend=SSO_BACKEND)
|
||||||
|
next_url = _safe_next(request, request.POST.get("next"))
|
||||||
|
return redirect(next_url or settings.LOGIN_REDIRECT_URL)
|
||||||
|
|
||||||
|
|
||||||
def health_live(request):
|
def health_live(request):
|
||||||
return JsonResponse({"ok": True, "service": "vacatureradar"})
|
return JsonResponse({"ok": True, "service": "vacatureradar"})
|
||||||
|
|
||||||
@@ -83,44 +250,9 @@ class TodayView(LoginRequiredMixin, TemplateView):
|
|||||||
|
|
||||||
def get_context_data(self, **kwargs):
|
def get_context_data(self, **kwargs):
|
||||||
context = super().get_context_data(**kwargs)
|
context = super().get_context_data(**kwargs)
|
||||||
latest_scores = (
|
profile = SearchProfile.objects.filter(user=self.request.user, is_active=True).first()
|
||||||
ScoreRun.objects.select_related("job", "job__employer", "profile")
|
context.update(build_dashboard_cockpit(self.request.user, profile))
|
||||||
.filter(job__status=JobPosting.Status.ACTIVE, hard_exclusions=[])
|
context["active_profile"] = profile
|
||||||
.order_by("-score", "-created_at")
|
|
||||||
)
|
|
||||||
# Eén score per vacature, zonder PostgreSQL-specifieke DISTINCT ON.
|
|
||||||
seen: set[str] = set()
|
|
||||||
cards = []
|
|
||||||
for score in latest_scores[:250]:
|
|
||||||
key = str(score.job_id)
|
|
||||||
if key in seen:
|
|
||||||
continue
|
|
||||||
seen.add(key)
|
|
||||||
cards.append(score)
|
|
||||||
if len(cards) >= 20:
|
|
||||||
break
|
|
||||||
context.update(
|
|
||||||
{
|
|
||||||
"score_cards": cards,
|
|
||||||
"priority_applications": Application.objects.filter(user=self.request.user)
|
|
||||||
.exclude(status__in=[Application.Status.REJECTED, Application.Status.WITHDRAWN])
|
|
||||||
.select_related("job", "job__employer")
|
|
||||||
.order_by("follow_up_date", "-updated_at")[:3],
|
|
||||||
"active_jobs": JobPosting.objects.filter(status=JobPosting.Status.ACTIVE).count(),
|
|
||||||
"new_today": JobPosting.objects.filter(
|
|
||||||
first_seen__date=timezone.localdate()
|
|
||||||
).count(),
|
|
||||||
"applications_open": Application.objects.exclude(
|
|
||||||
status__in=[Application.Status.REJECTED, Application.Status.WITHDRAWN]
|
|
||||||
).count(),
|
|
||||||
"source_counts": Source.objects.exclude(
|
|
||||||
domain="jobs.example.org", status=Source.Status.DISABLED
|
|
||||||
).aggregate(
|
|
||||||
total=Count("id"),
|
|
||||||
unhealthy=Count("id", filter=~Q(status=Source.Status.ACTIVE)),
|
|
||||||
),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
return context
|
return context
|
||||||
|
|
||||||
|
|
||||||
@@ -129,8 +261,6 @@ class SystemStatusView(LoginRequiredMixin, TemplateView):
|
|||||||
|
|
||||||
def get_context_data(self, **kwargs):
|
def get_context_data(self, **kwargs):
|
||||||
context = super().get_context_data(**kwargs)
|
context = super().get_context_data(**kwargs)
|
||||||
context["health"] = readiness()
|
context.update(build_automation_cockpit())
|
||||||
context["source_summary"] = Source.objects.values("status").annotate(total=Count("id"))
|
|
||||||
context["job_summary"] = JobPosting.objects.values("status").annotate(total=Count("id"))
|
|
||||||
context["source_health"] = collect_source_health()
|
context["source_health"] = collect_source_health()
|
||||||
return context
|
return context
|
||||||
|
|||||||
@@ -0,0 +1,159 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from django.core.paginator import Page, Paginator
|
||||||
|
from django.urls import reverse
|
||||||
|
|
||||||
|
from apps.notifications.models import DigestOutbox, ReminderOutbox
|
||||||
|
from apps.profiles.models import ProfileRevision
|
||||||
|
from apps.sources.models import SourceRun
|
||||||
|
|
||||||
|
from ..models import ApplicationTimelineEvent, Feedback, JobVersion
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ActivityEvent:
|
||||||
|
occurred_at: datetime
|
||||||
|
event_type: str
|
||||||
|
title: str
|
||||||
|
detail: str
|
||||||
|
tone: str
|
||||||
|
url: str
|
||||||
|
object_label: str
|
||||||
|
|
||||||
|
|
||||||
|
ACTIVITY_FILTERS = (
|
||||||
|
("all", "Alle activiteit"),
|
||||||
|
("source", "Bronruns"),
|
||||||
|
("job", "Vacatures"),
|
||||||
|
("application", "Sollicitaties"),
|
||||||
|
("feedback", "Feedback"),
|
||||||
|
("notification", "Notificaties"),
|
||||||
|
("profile", "Profiel"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _source_events() -> list[ActivityEvent]:
|
||||||
|
events = []
|
||||||
|
for run in SourceRun.objects.select_related("source").order_by("-started_at")[:100]:
|
||||||
|
detail = (
|
||||||
|
f"{run.extracted_count} geëxtraheerd · {run.created_count} nieuw · "
|
||||||
|
f"{run.duplicate_count} duplicaat"
|
||||||
|
)
|
||||||
|
if run.error_category:
|
||||||
|
detail = f"{run.error_category}: {run.error_message or 'geen foutdetail'}"
|
||||||
|
events.append(
|
||||||
|
ActivityEvent(
|
||||||
|
run.started_at,
|
||||||
|
"source",
|
||||||
|
f"Bronrun {run.get_status_display().lower()}",
|
||||||
|
detail,
|
||||||
|
"positive" if run.status == SourceRun.Status.SUCCESS else "attention",
|
||||||
|
reverse("sources:list") + f"#source-details-{run.source_id}",
|
||||||
|
run.source.name,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return events
|
||||||
|
|
||||||
|
|
||||||
|
def _job_events() -> list[ActivityEvent]:
|
||||||
|
return [
|
||||||
|
ActivityEvent(
|
||||||
|
version.created_at,
|
||||||
|
"job",
|
||||||
|
"Vacature-inhoud gewijzigd" if version.changed_fields else "Vacatureversie opgeslagen",
|
||||||
|
", ".join(version.changed_fields)
|
||||||
|
if version.changed_fields
|
||||||
|
else "Bronmoment vastgelegd",
|
||||||
|
"neutral",
|
||||||
|
reverse("jobs:detail", kwargs={"pk": version.job_id}),
|
||||||
|
version.job.original_title,
|
||||||
|
)
|
||||||
|
for version in JobVersion.objects.select_related("job").order_by("-created_at")[:100]
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def build_activity_page(
|
||||||
|
*, user, event_type: str = "all", query: str = "", page: int | str = 1, per_page: int = 20
|
||||||
|
) -> Page:
|
||||||
|
"""Combine bounded trustworthy events into a user-safe read-only timeline."""
|
||||||
|
|
||||||
|
events: list[ActivityEvent] = []
|
||||||
|
if event_type in {"all", "source"}:
|
||||||
|
events.extend(_source_events())
|
||||||
|
if event_type in {"all", "job"}:
|
||||||
|
events.extend(_job_events())
|
||||||
|
if event_type in {"all", "feedback"}:
|
||||||
|
for item in (
|
||||||
|
Feedback.objects.filter(user=user).select_related("job").order_by("-created_at")[:100]
|
||||||
|
):
|
||||||
|
events.append(
|
||||||
|
ActivityEvent(
|
||||||
|
item.created_at,
|
||||||
|
"feedback",
|
||||||
|
item.get_action_display(),
|
||||||
|
item.reason or "Gebruikersactie opgeslagen",
|
||||||
|
"neutral",
|
||||||
|
reverse("jobs:detail", kwargs={"pk": item.job_id}),
|
||||||
|
item.job.original_title,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if event_type in {"all", "application"}:
|
||||||
|
for item in (
|
||||||
|
ApplicationTimelineEvent.objects.filter(user=user)
|
||||||
|
.select_related("application__job")
|
||||||
|
.order_by("-created_at")[:100]
|
||||||
|
):
|
||||||
|
events.append(
|
||||||
|
ActivityEvent(
|
||||||
|
item.created_at,
|
||||||
|
"application",
|
||||||
|
item.get_event_type_display(),
|
||||||
|
"Sollicitatiedossier bijgewerkt",
|
||||||
|
"positive" if item.event_type == item.EventType.STATUS_CHANGED else "neutral",
|
||||||
|
reverse("jobs:application-edit", kwargs={"pk": item.application_id}),
|
||||||
|
item.application.job.original_title,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if event_type in {"all", "notification"}:
|
||||||
|
for model, label in ((DigestOutbox, "Samenvatting"), (ReminderOutbox, "Herinnering")):
|
||||||
|
for item in model.objects.filter(profile__user=user).order_by("-created_at")[:60]:
|
||||||
|
events.append(
|
||||||
|
ActivityEvent(
|
||||||
|
item.created_at,
|
||||||
|
"notification",
|
||||||
|
f"{label} {item.get_status_display().lower()}",
|
||||||
|
item.subject,
|
||||||
|
"positive" if item.status == item.Status.SENT else "attention",
|
||||||
|
reverse("dashboard:system"),
|
||||||
|
label,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if event_type in {"all", "profile"}:
|
||||||
|
for item in (
|
||||||
|
ProfileRevision.objects.filter(profile__user=user)
|
||||||
|
.select_related("profile")
|
||||||
|
.order_by("-created_at")[:60]
|
||||||
|
):
|
||||||
|
events.append(
|
||||||
|
ActivityEvent(
|
||||||
|
item.created_at,
|
||||||
|
"profile",
|
||||||
|
f"Zoekprofiel versie {item.version}",
|
||||||
|
item.reason or "Profielconfiguratie opgeslagen",
|
||||||
|
"neutral",
|
||||||
|
reverse("profiles:edit", kwargs={"pk": item.profile_id}),
|
||||||
|
item.profile.name,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
normalized_query = query.strip().casefold()
|
||||||
|
if normalized_query:
|
||||||
|
events = [
|
||||||
|
item
|
||||||
|
for item in events
|
||||||
|
if normalized_query in f"{item.title} {item.detail} {item.object_label}".casefold()
|
||||||
|
]
|
||||||
|
events.sort(key=lambda item: item.occurred_at, reverse=True)
|
||||||
|
return Paginator(events, per_page).get_page(page)
|
||||||
@@ -274,6 +274,31 @@ def track_application_changes(
|
|||||||
return events
|
return events
|
||||||
|
|
||||||
|
|
||||||
|
def change_application_status(*, application: Application, user, status: str) -> Application:
|
||||||
|
"""Persist an accessible pipeline status change and its audit event."""
|
||||||
|
|
||||||
|
valid_statuses = {value for value, _label in Application.Status.choices}
|
||||||
|
if status not in valid_statuses:
|
||||||
|
raise ValueError("Ongeldige sollicitatiestatus.")
|
||||||
|
previous = application.status
|
||||||
|
if previous == status:
|
||||||
|
return application
|
||||||
|
with transaction.atomic():
|
||||||
|
application.status = status
|
||||||
|
update_fields = ["status", "updated_at"]
|
||||||
|
if status == Application.Status.APPLIED and application.applied_at is None:
|
||||||
|
application.applied_at = timezone.now()
|
||||||
|
update_fields.append("applied_at")
|
||||||
|
application.save(update_fields=update_fields)
|
||||||
|
_record_timeline_event(
|
||||||
|
application=application,
|
||||||
|
user=user,
|
||||||
|
event_type=ApplicationTimelineEvent.EventType.STATUS_CHANGED,
|
||||||
|
metadata={"from": previous, "to": status},
|
||||||
|
)
|
||||||
|
return application
|
||||||
|
|
||||||
|
|
||||||
def build_print_html(application: Application) -> str:
|
def build_print_html(application: Application) -> str:
|
||||||
snapshot = application.snapshot if isinstance(application.snapshot, dict) else {}
|
snapshot = application.snapshot if isinstance(application.snapshot, dict) else {}
|
||||||
job = application.job
|
job = application.job
|
||||||
|
|||||||
@@ -0,0 +1,177 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
from django.db.models import (
|
||||||
|
CharField,
|
||||||
|
Count,
|
||||||
|
DateTimeField,
|
||||||
|
DecimalField,
|
||||||
|
F,
|
||||||
|
JSONField,
|
||||||
|
OuterRef,
|
||||||
|
Q,
|
||||||
|
QuerySet,
|
||||||
|
Subquery,
|
||||||
|
)
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
from apps.profiles.models import SearchProfile
|
||||||
|
from apps.sources.models import Source, SourceRun
|
||||||
|
|
||||||
|
from ..models import Application, ApplicationTimelineEvent, JobPosting, ScoreRun
|
||||||
|
from .relevance import it_relevance_query
|
||||||
|
|
||||||
|
User = get_user_model()
|
||||||
|
|
||||||
|
|
||||||
|
def annotate_latest_profile_score(
|
||||||
|
queryset: QuerySet[JobPosting], profile: SearchProfile | None
|
||||||
|
) -> QuerySet[JobPosting]:
|
||||||
|
"""Annotate jobs with one deterministic latest score for the active profile."""
|
||||||
|
|
||||||
|
if profile is None:
|
||||||
|
return queryset
|
||||||
|
latest = ScoreRun.objects.filter(job=OuterRef("pk"), profile=profile).order_by("-created_at")
|
||||||
|
return queryset.annotate(
|
||||||
|
match_score=Subquery(
|
||||||
|
latest.values("score")[:1],
|
||||||
|
output_field=DecimalField(max_digits=5, decimal_places=2),
|
||||||
|
),
|
||||||
|
match_confidence=Subquery(
|
||||||
|
latest.values("confidence")[:1],
|
||||||
|
output_field=DecimalField(max_digits=4, decimal_places=3),
|
||||||
|
),
|
||||||
|
match_recommendation=Subquery(
|
||||||
|
latest.values("recommendation")[:1], output_field=CharField()
|
||||||
|
),
|
||||||
|
match_positives=Subquery(latest.values("positives")[:1], output_field=JSONField()),
|
||||||
|
match_concerns=Subquery(latest.values("concerns")[:1], output_field=JSONField()),
|
||||||
|
match_hard_exclusions=Subquery(
|
||||||
|
latest.values("hard_exclusions")[:1], output_field=JSONField()
|
||||||
|
),
|
||||||
|
match_scored_at=Subquery(latest.values("created_at")[:1], output_field=DateTimeField()),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _recent_dashboard_activity(user: User) -> list[dict[str, Any]]:
|
||||||
|
activity: list[dict[str, Any]] = []
|
||||||
|
for run in SourceRun.objects.select_related("source").order_by("-started_at")[:4]:
|
||||||
|
activity.append(
|
||||||
|
{
|
||||||
|
"occurred_at": run.started_at,
|
||||||
|
"kind": "source",
|
||||||
|
"title": f"Bronrun {run.get_status_display().lower()}",
|
||||||
|
"detail": run.source.name,
|
||||||
|
"tone": "positive" if run.status == SourceRun.Status.SUCCESS else "attention",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
for event in (
|
||||||
|
ApplicationTimelineEvent.objects.filter(user=user)
|
||||||
|
.select_related("application__job")
|
||||||
|
.order_by("-created_at")[:4]
|
||||||
|
):
|
||||||
|
activity.append(
|
||||||
|
{
|
||||||
|
"occurred_at": event.created_at,
|
||||||
|
"kind": "application",
|
||||||
|
"title": event.get_event_type_display(),
|
||||||
|
"detail": event.application.job.original_title,
|
||||||
|
"tone": "neutral",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return sorted(activity, key=lambda item: item["occurred_at"], reverse=True)[:5]
|
||||||
|
|
||||||
|
|
||||||
|
def build_dashboard_cockpit(user: User, profile: SearchProfile | None) -> dict[str, Any]:
|
||||||
|
"""Build truthful dashboard metrics and bounded read lists."""
|
||||||
|
|
||||||
|
active_jobs = JobPosting.objects.filter(status=JobPosting.Status.ACTIVE)
|
||||||
|
active_it_jobs = active_jobs.filter(it_relevance_query())
|
||||||
|
today = timezone.localdate()
|
||||||
|
latest_scores = ScoreRun.objects.none()
|
||||||
|
if profile is not None:
|
||||||
|
latest_score_id = (
|
||||||
|
ScoreRun.objects.filter(job_id=OuterRef("job_id"), profile=profile)
|
||||||
|
.order_by("-created_at")
|
||||||
|
.values("pk")[:1]
|
||||||
|
)
|
||||||
|
latest_scores = (
|
||||||
|
ScoreRun.objects.select_related("job", "job__employer")
|
||||||
|
.filter(
|
||||||
|
profile=profile,
|
||||||
|
pk=Subquery(latest_score_id),
|
||||||
|
job__status=JobPosting.Status.ACTIVE,
|
||||||
|
recommendation__in=[
|
||||||
|
ScoreRun.Recommendation.STRONG,
|
||||||
|
ScoreRun.Recommendation.POSSIBLE,
|
||||||
|
],
|
||||||
|
hard_exclusions=[],
|
||||||
|
)
|
||||||
|
.filter(it_relevance_query("job__"))
|
||||||
|
)
|
||||||
|
|
||||||
|
sources = Source.objects.exclude(domain="jobs.example.org", status=Source.Status.DISABLED)
|
||||||
|
source_counts = sources.aggregate(
|
||||||
|
total=Count("id"),
|
||||||
|
active=Count("id", filter=Q(status=Source.Status.ACTIVE)),
|
||||||
|
attention=Count("id", filter=~Q(status=Source.Status.ACTIVE)),
|
||||||
|
)
|
||||||
|
open_applications = Application.objects.filter(user=user).exclude(
|
||||||
|
status__in=[Application.Status.REJECTED, Application.Status.WITHDRAWN]
|
||||||
|
)
|
||||||
|
new_today = active_it_jobs.filter(first_seen__date=today).count()
|
||||||
|
strong_today = latest_scores.filter(
|
||||||
|
recommendation=ScoreRun.Recommendation.STRONG,
|
||||||
|
job__first_seen__date=today,
|
||||||
|
).count()
|
||||||
|
filtered_count = active_jobs.exclude(it_relevance_query()).count()
|
||||||
|
duplicate_count = JobPosting.objects.filter(status=JobPosting.Status.DUPLICATE).count()
|
||||||
|
|
||||||
|
if strong_today:
|
||||||
|
day_conclusion = f"{strong_today} sterke match(es) verdienen vandaag eerst je aandacht."
|
||||||
|
elif new_today:
|
||||||
|
day_conclusion = f"De radar vond vandaag {new_today} nieuwe relevante vacature(s)."
|
||||||
|
else:
|
||||||
|
day_conclusion = "Geen nieuw sterk signaal; je bestaande opvolging blijft prioritair."
|
||||||
|
|
||||||
|
return {
|
||||||
|
"score_cards": list(latest_scores.order_by("-score", "-created_at")[:8]),
|
||||||
|
"priority_applications": list(
|
||||||
|
open_applications.select_related("job", "job__employer").order_by(
|
||||||
|
F("follow_up_date").asc(nulls_last=True), "-updated_at"
|
||||||
|
)[:4]
|
||||||
|
),
|
||||||
|
"active_jobs": active_it_jobs.count(),
|
||||||
|
"filtered_non_it_jobs": filtered_count,
|
||||||
|
"duplicate_jobs": duplicate_count,
|
||||||
|
"new_today": new_today,
|
||||||
|
"strong_today": strong_today,
|
||||||
|
"applications_open": open_applications.count(),
|
||||||
|
"source_counts": source_counts,
|
||||||
|
"recent_activity": _recent_dashboard_activity(user),
|
||||||
|
"day_conclusion": day_conclusion,
|
||||||
|
"latest_scan": SourceRun.objects.order_by("-started_at").first(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def build_job_intelligence(
|
||||||
|
*, job: JobPosting, profile: SearchProfile | None, user: User
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Return the bounded, user-scoped read model for explorer and detail."""
|
||||||
|
|
||||||
|
score = (
|
||||||
|
ScoreRun.objects.filter(job=job, profile=profile).order_by("-created_at").first()
|
||||||
|
if profile
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"score": score,
|
||||||
|
"application": Application.objects.filter(user=user, job=job).first(),
|
||||||
|
"duplicate_count": job.duplicates.count(),
|
||||||
|
"source_count": job.source_aliases.count(),
|
||||||
|
"version_count": job.versions.count(),
|
||||||
|
"latest_versions": list(job.versions.all()[:5]),
|
||||||
|
"provenance_rows": list(job.provenance.select_related("source_alias__source")[:12]),
|
||||||
|
}
|
||||||
@@ -0,0 +1,269 @@
|
|||||||
|
"""Vult een fictieve demo-omgeving voor het demo-account.
|
||||||
|
|
||||||
|
Wordt aangeroepen bij 'Bekijk demo' zodat de gebruiker meteen een geloofwaardige,
|
||||||
|
gevulde cockpit ziet (profiel, vacatures, matchscores en een sollicitatiepijplijn)
|
||||||
|
in plaats van een leeg dashboard. Volledig idempotent: eenmaal geseed, wordt er
|
||||||
|
niet opnieuw geseed.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
from datetime import timedelta
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
from apps.profiles.models import SearchProfile
|
||||||
|
|
||||||
|
_DEMO_PROFILE_NAME = "Demo · IT-infrastructuur Limburg"
|
||||||
|
|
||||||
|
# (titel, werkgever, domein, gemeente, regio, werkplek, skills_required,
|
||||||
|
# skills_preferred, score, recommendation, positives, concerns)
|
||||||
|
_DEMO_JOBS = [
|
||||||
|
(
|
||||||
|
"Infrastructure Engineer",
|
||||||
|
"CloudNexus",
|
||||||
|
"cloudnexus.example",
|
||||||
|
"Hasselt",
|
||||||
|
"Limburg",
|
||||||
|
"hybrid",
|
||||||
|
["Microsoft 365", "VMware", "Azure"],
|
||||||
|
["Terraform"],
|
||||||
|
Decimal("92.4"),
|
||||||
|
"strong",
|
||||||
|
["Dichtbij Hasselt", "Sterke match op Microsoft 365 en VMware", "Hybride werk"],
|
||||||
|
[],
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"Cloud Architect",
|
||||||
|
"Skylink Solutions",
|
||||||
|
"skylink.example",
|
||||||
|
"Remote",
|
||||||
|
"Vlaanderen",
|
||||||
|
"remote",
|
||||||
|
["Azure", "Kubernetes"],
|
||||||
|
["Bicep", "Landing Zones"],
|
||||||
|
Decimal("88.1"),
|
||||||
|
"strong",
|
||||||
|
["Volledig remote", "Azure-zwaartepunt sluit aan"],
|
||||||
|
["Iets seniorder dan gevraagd"],
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"Systeembeheerder Microsoft 365",
|
||||||
|
"Gemeente Genk",
|
||||||
|
"genk.example",
|
||||||
|
"Genk",
|
||||||
|
"Limburg",
|
||||||
|
"on_site",
|
||||||
|
["Microsoft 365", "Active Directory"],
|
||||||
|
["Intune"],
|
||||||
|
Decimal("83.6"),
|
||||||
|
"strong",
|
||||||
|
["Publieke sector met stabiliteit", "M365-focus"],
|
||||||
|
["Volledig op locatie"],
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"DevOps Engineer",
|
||||||
|
"Datastroom BV",
|
||||||
|
"datastroom.example",
|
||||||
|
"Leuven",
|
||||||
|
"Vlaams-Brabant",
|
||||||
|
"hybrid",
|
||||||
|
["CI/CD", "Docker", "Azure"],
|
||||||
|
["GitLab", "Observability"],
|
||||||
|
Decimal("77.9"),
|
||||||
|
"possible",
|
||||||
|
["Moderne toolchain", "Hybride"],
|
||||||
|
["Reistijd naar Leuven"],
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"Network Engineer",
|
||||||
|
"Noorder Telecom",
|
||||||
|
"noordertelecom.example",
|
||||||
|
"Antwerpen",
|
||||||
|
"Antwerpen",
|
||||||
|
"on_site",
|
||||||
|
["Cisco", "Netwerkbeheer"],
|
||||||
|
["Fortinet"],
|
||||||
|
Decimal("70.4"),
|
||||||
|
"possible",
|
||||||
|
["Stevige netwerkrol"],
|
||||||
|
["Weinig cloudcomponent", "Op locatie in Antwerpen"],
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"IT Support Specialist",
|
||||||
|
"MediCare Groep",
|
||||||
|
"medicare.example",
|
||||||
|
"Diepenbeek",
|
||||||
|
"Limburg",
|
||||||
|
"on_site",
|
||||||
|
["Servicedesk", "Windows"],
|
||||||
|
["ITIL"],
|
||||||
|
Decimal("62.3"),
|
||||||
|
"weak",
|
||||||
|
["Dichtbij"],
|
||||||
|
["Sterk supportgericht", "Weinig infrastructuurverdieping"],
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _digest(*parts: str) -> str:
|
||||||
|
return hashlib.sha256("::".join(parts).encode("utf-8")).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def demo_environment_seeded(user) -> bool:
|
||||||
|
profile = SearchProfile.objects.filter(user=user, name=_DEMO_PROFILE_NAME).first()
|
||||||
|
if profile is None:
|
||||||
|
return False
|
||||||
|
from apps.jobs.models import ScoreRun
|
||||||
|
|
||||||
|
return ScoreRun.objects.filter(profile=profile).exists()
|
||||||
|
|
||||||
|
|
||||||
|
def seed_demo_environment(user) -> None:
|
||||||
|
"""Maak (idempotent) een fictieve demo-omgeving voor ``user``."""
|
||||||
|
if demo_environment_seeded(user):
|
||||||
|
return
|
||||||
|
|
||||||
|
from apps.jobs.models import Application, Employer, JobPosting, ScoreRun
|
||||||
|
|
||||||
|
profile, _ = SearchProfile.objects.get_or_create(
|
||||||
|
user=user,
|
||||||
|
name=_DEMO_PROFILE_NAME,
|
||||||
|
defaults={
|
||||||
|
"is_active": True,
|
||||||
|
"home_municipality": "Hasselt",
|
||||||
|
"home_latitude": Decimal("50.930700"),
|
||||||
|
"home_longitude": Decimal("5.332500"),
|
||||||
|
"max_distance_km": 60,
|
||||||
|
"experience_years": 5,
|
||||||
|
"learning_enabled": True,
|
||||||
|
"desired_titles": ["infrastructure engineer", "systeembeheerder", "cloud engineer"],
|
||||||
|
"excluded_titles": ["sales", "recruiter"],
|
||||||
|
"desired_skills": ["Microsoft 365", "VMware", "Azure"],
|
||||||
|
"allowed_employment_types": ["full_time", "permanent"],
|
||||||
|
"preferred_workplace": ["hybrid", "remote", "on_site"],
|
||||||
|
"preferred_regions": ["Limburg", "Vlaams-Brabant"],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
SearchProfile.objects.filter(user=user).exclude(pk=profile.pk).update(is_active=False)
|
||||||
|
if not profile.is_active:
|
||||||
|
profile.is_active = True
|
||||||
|
profile.save(update_fields=["is_active"])
|
||||||
|
|
||||||
|
now = timezone.now()
|
||||||
|
workplace_map = {
|
||||||
|
"hybrid": JobPosting.Workplace.HYBRID,
|
||||||
|
"remote": JobPosting.Workplace.REMOTE,
|
||||||
|
"on_site": JobPosting.Workplace.ON_SITE,
|
||||||
|
}
|
||||||
|
created_jobs: list = []
|
||||||
|
|
||||||
|
for index, (
|
||||||
|
title,
|
||||||
|
employer_name,
|
||||||
|
domain,
|
||||||
|
municipality,
|
||||||
|
region,
|
||||||
|
workplace,
|
||||||
|
req_skills,
|
||||||
|
pref_skills,
|
||||||
|
score,
|
||||||
|
recommendation,
|
||||||
|
positives,
|
||||||
|
concerns,
|
||||||
|
) in enumerate(_DEMO_JOBS):
|
||||||
|
employer, _ = Employer.objects.get_or_create(
|
||||||
|
normalized_name=employer_name.lower(),
|
||||||
|
defaults={
|
||||||
|
"name": employer_name,
|
||||||
|
"domain": domain,
|
||||||
|
"is_direct_employer": True,
|
||||||
|
"is_recruiter": False,
|
||||||
|
"confidence": 0.92,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
slug = _digest("demo-job", str(index), title, employer_name)
|
||||||
|
job, job_created = JobPosting.objects.get_or_create(
|
||||||
|
canonical_key=slug,
|
||||||
|
defaults={
|
||||||
|
"employer": employer,
|
||||||
|
"original_title": title,
|
||||||
|
"normalized_title": title.lower(),
|
||||||
|
"job_family": "infrastructure",
|
||||||
|
"canonical_url": f"https://{domain}/vacatures/{slug[:12]}",
|
||||||
|
"content_hash": _digest("demo-content", slug),
|
||||||
|
"description_text": (
|
||||||
|
f"{title} bij {employer_name} in {municipality}. "
|
||||||
|
"Fictieve demovacature voor de VacatureRadar-demo-omgeving."
|
||||||
|
),
|
||||||
|
"raw_location": f"{municipality}, {region}",
|
||||||
|
"region": region,
|
||||||
|
"municipality": municipality,
|
||||||
|
"workplace_type": workplace_map[workplace],
|
||||||
|
"employment_types": ["full_time", "permanent"],
|
||||||
|
"skills_required": req_skills,
|
||||||
|
"skills_preferred": pref_skills,
|
||||||
|
"direct_employer": True,
|
||||||
|
"recruiter": False,
|
||||||
|
"extraction_confidence": 0.9,
|
||||||
|
"analysis_features": {
|
||||||
|
"support_ratio": 0.1,
|
||||||
|
"public_sector_signal": 1.0 if "Gemeente" in employer_name else 0.0,
|
||||||
|
"experience_years_max": 6,
|
||||||
|
},
|
||||||
|
"status": JobPosting.Status.ACTIVE,
|
||||||
|
"first_seen": now - timedelta(days=index + 1),
|
||||||
|
"last_seen": now,
|
||||||
|
"date_posted": now - timedelta(days=index + 1),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
created_jobs.append(job)
|
||||||
|
|
||||||
|
ScoreRun.objects.create(
|
||||||
|
job=job,
|
||||||
|
profile=profile,
|
||||||
|
profile_version=profile.version,
|
||||||
|
score=score,
|
||||||
|
confidence=Decimal("0.78"),
|
||||||
|
recommendation=recommendation,
|
||||||
|
components={
|
||||||
|
"content": float(score) * 0.45,
|
||||||
|
"skills": float(score) * 0.30,
|
||||||
|
"location": float(score) * 0.25,
|
||||||
|
},
|
||||||
|
positives=positives,
|
||||||
|
concerns=concerns,
|
||||||
|
hard_exclusions=[],
|
||||||
|
evidence={"demo": True},
|
||||||
|
model_version="demo-seed",
|
||||||
|
prompt_version="1.0.0",
|
||||||
|
created_at=now - timedelta(hours=index),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Een kleine sollicitatiepijplijn voor de demo.
|
||||||
|
if created_jobs:
|
||||||
|
Application.objects.get_or_create(
|
||||||
|
user=user,
|
||||||
|
job=created_jobs[0],
|
||||||
|
defaults={
|
||||||
|
"status": Application.Status.INTERVIEW,
|
||||||
|
"applied_at": now - timedelta(days=6),
|
||||||
|
"follow_up_date": (now + timedelta(days=2)).date(),
|
||||||
|
"contact_name": "Sofie Vandael",
|
||||||
|
"contact_email": "sofie.vandael@cloudnexus.example",
|
||||||
|
"notes": "Eerste gesprek gepland. Demo-data.",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if len(created_jobs) > 3:
|
||||||
|
Application.objects.get_or_create(
|
||||||
|
user=user,
|
||||||
|
job=created_jobs[3],
|
||||||
|
defaults={
|
||||||
|
"status": Application.Status.APPLIED,
|
||||||
|
"applied_at": now - timedelta(days=2),
|
||||||
|
"follow_up_date": (now + timedelta(days=5)).date(),
|
||||||
|
"notes": "Sollicitatie verstuurd. Demo-data.",
|
||||||
|
},
|
||||||
|
)
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from django.db.models import Avg, Count, Max, Q, QuerySet
|
||||||
|
|
||||||
|
from apps.profiles.models import SearchProfile
|
||||||
|
from apps.sources.models import Source
|
||||||
|
|
||||||
|
from ..models import Application, Employer, JobPosting, ScoreRun
|
||||||
|
from .cockpit import annotate_latest_profile_score
|
||||||
|
from .relevance import it_relevance_query
|
||||||
|
|
||||||
|
|
||||||
|
def employer_index_queryset(*, user, profile: SearchProfile | None) -> QuerySet[Employer]:
|
||||||
|
score_filter = Q(jobs__scores__profile=profile) if profile else Q(pk__isnull=True)
|
||||||
|
return Employer.objects.annotate(
|
||||||
|
active_vacancies=Count(
|
||||||
|
"jobs", filter=Q(jobs__status=JobPosting.Status.ACTIVE), distinct=True
|
||||||
|
),
|
||||||
|
relevant_vacancies=Count(
|
||||||
|
"jobs",
|
||||||
|
filter=Q(jobs__status=JobPosting.Status.ACTIVE) & it_relevance_query("jobs__"),
|
||||||
|
distinct=True,
|
||||||
|
),
|
||||||
|
previous_applications=Count(
|
||||||
|
"jobs__applications",
|
||||||
|
filter=Q(jobs__applications__user=user),
|
||||||
|
distinct=True,
|
||||||
|
),
|
||||||
|
source_count=Count("jobs__source_aliases__source", distinct=True),
|
||||||
|
latest_job_at=Max("jobs__first_seen"),
|
||||||
|
average_match=Avg("jobs__scores__score", filter=score_filter),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def filter_employers(
|
||||||
|
queryset: QuerySet[Employer], *, query: str = "", channel: str = "", active_only: bool = False
|
||||||
|
) -> QuerySet[Employer]:
|
||||||
|
if query.strip():
|
||||||
|
queryset = queryset.filter(
|
||||||
|
Q(name__icontains=query.strip())
|
||||||
|
| Q(domain__icontains=query.strip())
|
||||||
|
| Q(jobs__raw_location__icontains=query.strip())
|
||||||
|
)
|
||||||
|
if channel == "direct":
|
||||||
|
queryset = queryset.filter(is_direct_employer=True, is_recruiter=False)
|
||||||
|
elif channel == "recruiter":
|
||||||
|
queryset = queryset.filter(is_recruiter=True)
|
||||||
|
if active_only:
|
||||||
|
queryset = queryset.filter(active_vacancies__gt=0)
|
||||||
|
return queryset.order_by("-relevant_vacancies", "-active_vacancies", "name").distinct()
|
||||||
|
|
||||||
|
|
||||||
|
def build_employer_detail(
|
||||||
|
*, employer: Employer, user, profile: SearchProfile | None
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
jobs = annotate_latest_profile_score(
|
||||||
|
employer.jobs.select_related("employer").all(), profile
|
||||||
|
).order_by("-first_seen")
|
||||||
|
sources = Source.objects.filter(job_aliases__job__employer=employer).distinct().order_by("name")
|
||||||
|
locations = list(
|
||||||
|
employer.jobs.exclude(raw_location="")
|
||||||
|
.order_by("raw_location")
|
||||||
|
.values_list("raw_location", flat=True)
|
||||||
|
.distinct()[:8]
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"employer": employer,
|
||||||
|
"recent_jobs": list(jobs[:12]),
|
||||||
|
"active_jobs": list(jobs.filter(status=JobPosting.Status.ACTIVE)[:8]),
|
||||||
|
"applications": list(
|
||||||
|
Application.objects.filter(user=user, job__employer=employer)
|
||||||
|
.select_related("job")
|
||||||
|
.order_by("-updated_at")[:8]
|
||||||
|
),
|
||||||
|
"sources": list(sources[:10]),
|
||||||
|
"locations": locations,
|
||||||
|
"match_history": list(
|
||||||
|
ScoreRun.objects.filter(profile=profile, job__employer=employer)
|
||||||
|
.select_related("job")
|
||||||
|
.order_by("-created_at")[:20]
|
||||||
|
)
|
||||||
|
if profile
|
||||||
|
else [],
|
||||||
|
}
|
||||||
@@ -22,7 +22,6 @@ LEARNING_FEATURES = {
|
|||||||
"location",
|
"location",
|
||||||
"conditions",
|
"conditions",
|
||||||
"employer",
|
"employer",
|
||||||
"seniority",
|
|
||||||
"preferences",
|
"preferences",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -49,13 +49,21 @@ EMPLOYMENT_MAP = {
|
|||||||
"vast": "permanent",
|
"vast": "permanent",
|
||||||
}
|
}
|
||||||
TITLE_FAMILIES = {
|
TITLE_FAMILIES = {
|
||||||
|
"it field engineer": "field-support",
|
||||||
|
"field service engineer": "field-support",
|
||||||
|
"it engineer": "infrastructure",
|
||||||
"system engineer": "infrastructure",
|
"system engineer": "infrastructure",
|
||||||
|
"system network engineer": "infrastructure",
|
||||||
|
"system administrator": "infrastructure",
|
||||||
"systeembeheerder": "infrastructure",
|
"systeembeheerder": "infrastructure",
|
||||||
"infrastructure engineer": "infrastructure",
|
"infrastructure engineer": "infrastructure",
|
||||||
"network engineer": "network",
|
"network engineer": "network",
|
||||||
|
"network administrator": "network",
|
||||||
"netwerkbeheerder": "network",
|
"netwerkbeheerder": "network",
|
||||||
"workplace engineer": "workplace",
|
"workplace engineer": "workplace",
|
||||||
"support engineer": "support",
|
"support engineer": "support",
|
||||||
|
"it technician": "support",
|
||||||
|
"pc technician": "support",
|
||||||
"helpdesk": "support",
|
"helpdesk": "support",
|
||||||
"developer": "software-development",
|
"developer": "software-development",
|
||||||
"data engineer": "data",
|
"data engineer": "data",
|
||||||
|
|||||||
@@ -0,0 +1,260 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
from django.db.models import Q
|
||||||
|
|
||||||
|
from apps.profiles.models import SearchProfile
|
||||||
|
|
||||||
|
from .normalization import normalize_token
|
||||||
|
|
||||||
|
# Deliberately title-led: employer pages regularly mention digital systems in otherwise
|
||||||
|
# non-IT vacancies. A description hit alone must therefore never make a vacancy relevant.
|
||||||
|
IT_TITLE_TERMS = (
|
||||||
|
"it",
|
||||||
|
"ict",
|
||||||
|
"informatica",
|
||||||
|
"software",
|
||||||
|
"development engineer",
|
||||||
|
"devops",
|
||||||
|
"secops",
|
||||||
|
"cloud",
|
||||||
|
"data engineer",
|
||||||
|
"data scientist",
|
||||||
|
"data steward",
|
||||||
|
"database",
|
||||||
|
"dba",
|
||||||
|
"business intelligence",
|
||||||
|
"bi analyst",
|
||||||
|
"system engineer",
|
||||||
|
"systems engineer",
|
||||||
|
"system network engineer",
|
||||||
|
"system administrator",
|
||||||
|
"systeembeheer",
|
||||||
|
"infrastructure engineer",
|
||||||
|
"infrastructuur engineer",
|
||||||
|
"network engineer",
|
||||||
|
"network administrator",
|
||||||
|
"network architect",
|
||||||
|
"netwerkbeheer",
|
||||||
|
"netwerk engineer",
|
||||||
|
"cybersecurity",
|
||||||
|
"cyber security",
|
||||||
|
"security engineer",
|
||||||
|
"security architect",
|
||||||
|
"security analyst",
|
||||||
|
"information security",
|
||||||
|
"informatiebeveiliging",
|
||||||
|
"workplace engineer",
|
||||||
|
"endpoint engineer",
|
||||||
|
"microsoft 365 engineer",
|
||||||
|
"m365 engineer",
|
||||||
|
"intune engineer",
|
||||||
|
"modern workplace",
|
||||||
|
"digital workplace",
|
||||||
|
"service desk",
|
||||||
|
"servicedesk",
|
||||||
|
"helpdesk",
|
||||||
|
"support engineer",
|
||||||
|
"support specialist",
|
||||||
|
"it technician",
|
||||||
|
"pc technician",
|
||||||
|
"application lead",
|
||||||
|
"application engineer",
|
||||||
|
"application manager",
|
||||||
|
"application specialist",
|
||||||
|
"applicatiebeheer",
|
||||||
|
"platform engineer",
|
||||||
|
"platform expert",
|
||||||
|
"solution architect",
|
||||||
|
"solutions architect",
|
||||||
|
"technical architect",
|
||||||
|
"technisch architect",
|
||||||
|
"functional analyst",
|
||||||
|
"functioneel analist",
|
||||||
|
"business analyst",
|
||||||
|
"product owner",
|
||||||
|
"web developer",
|
||||||
|
"mobile developer",
|
||||||
|
"low-code developer",
|
||||||
|
"low code developer",
|
||||||
|
"api developer",
|
||||||
|
"idm developer",
|
||||||
|
"power platform",
|
||||||
|
"forgerock",
|
||||||
|
"embedded",
|
||||||
|
"c#",
|
||||||
|
"front end",
|
||||||
|
"frontend",
|
||||||
|
"back end",
|
||||||
|
"backend",
|
||||||
|
"full stack",
|
||||||
|
"fullstack",
|
||||||
|
"php",
|
||||||
|
".net",
|
||||||
|
"java",
|
||||||
|
"python",
|
||||||
|
"powershell",
|
||||||
|
"azure",
|
||||||
|
"aws",
|
||||||
|
"microsoft 365",
|
||||||
|
"linux",
|
||||||
|
"windows server",
|
||||||
|
"kubernetes",
|
||||||
|
"docker",
|
||||||
|
"terraform",
|
||||||
|
"erp",
|
||||||
|
"sap",
|
||||||
|
"machine learning",
|
||||||
|
"ai engineer",
|
||||||
|
"artificial intelligence",
|
||||||
|
"image processing",
|
||||||
|
"computer vision",
|
||||||
|
"qa engineer",
|
||||||
|
"test automation",
|
||||||
|
)
|
||||||
|
|
||||||
|
# These engineering titles occur both in IT and in unrelated technical sectors. They are
|
||||||
|
# accepted only when the title supplies the role context and the description independently
|
||||||
|
# supplies a strong IT signal. A description signal by itself remains insufficient.
|
||||||
|
CONTEXTUAL_IT_TITLE_TERMS = (
|
||||||
|
"field engineer",
|
||||||
|
"field service engineer",
|
||||||
|
"implementation engineer",
|
||||||
|
"implementation consultant",
|
||||||
|
"service engineer",
|
||||||
|
)
|
||||||
|
|
||||||
|
IT_DESCRIPTION_TERMS = (
|
||||||
|
"microsoft 365",
|
||||||
|
"m365",
|
||||||
|
"windows server",
|
||||||
|
"active directory",
|
||||||
|
"entra id",
|
||||||
|
"intune",
|
||||||
|
"autopilot",
|
||||||
|
"exchange online",
|
||||||
|
"sharepoint",
|
||||||
|
"vmware",
|
||||||
|
"proxmox",
|
||||||
|
"hyper-v",
|
||||||
|
"networking",
|
||||||
|
"netwerkbeheer",
|
||||||
|
"tcp/ip",
|
||||||
|
"vlan",
|
||||||
|
"vpn",
|
||||||
|
"dhcp",
|
||||||
|
"dns",
|
||||||
|
"firewall",
|
||||||
|
"switches",
|
||||||
|
"routers",
|
||||||
|
"workstations",
|
||||||
|
"desktops",
|
||||||
|
"laptops",
|
||||||
|
"voip",
|
||||||
|
"3cx",
|
||||||
|
"powershell",
|
||||||
|
)
|
||||||
|
|
||||||
|
# These phrases can contain IT vocabulary while describing commercial, recruitment,
|
||||||
|
# or educational-design work. They therefore override positive title signals.
|
||||||
|
NON_IT_TITLE_TERMS = (
|
||||||
|
"business developer",
|
||||||
|
"business development",
|
||||||
|
"it recruitment",
|
||||||
|
"it recruiter",
|
||||||
|
"learning designer",
|
||||||
|
)
|
||||||
|
|
||||||
|
IT_PROFILE_TERMS = (*IT_TITLE_TERMS, "networking", "security", "active directory", "intune")
|
||||||
|
|
||||||
|
|
||||||
|
def _term_pattern(term: str) -> str:
|
||||||
|
escaped = re.escape(term).replace(r"\ ", r"[\s/_-]+")
|
||||||
|
return rf"(?<!\w){escaped}(?!\w)"
|
||||||
|
|
||||||
|
|
||||||
|
IT_TITLE_PATTERN = "(?:" + "|".join(_term_pattern(term) for term in IT_TITLE_TERMS) + ")"
|
||||||
|
NON_IT_TITLE_PATTERN = "(?:" + "|".join(_term_pattern(term) for term in NON_IT_TITLE_TERMS) + ")"
|
||||||
|
CONTEXTUAL_IT_TITLE_PATTERN = (
|
||||||
|
"(?:" + "|".join(_term_pattern(term) for term in CONTEXTUAL_IT_TITLE_TERMS) + ")"
|
||||||
|
)
|
||||||
|
IT_DESCRIPTION_PATTERN = (
|
||||||
|
"(?:" + "|".join(_term_pattern(term) for term in IT_DESCRIPTION_TERMS) + ")"
|
||||||
|
)
|
||||||
|
_IT_TITLE_RE = re.compile(IT_TITLE_PATTERN, re.IGNORECASE)
|
||||||
|
_NON_IT_TITLE_RE = re.compile(NON_IT_TITLE_PATTERN, re.IGNORECASE)
|
||||||
|
_CONTEXTUAL_IT_TITLE_RE = re.compile(CONTEXTUAL_IT_TITLE_PATTERN, re.IGNORECASE)
|
||||||
|
_IT_DESCRIPTION_RE = re.compile(IT_DESCRIPTION_PATTERN, re.IGNORECASE)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ItRelevanceAssessment:
|
||||||
|
relevant: bool
|
||||||
|
signals: tuple[str, ...]
|
||||||
|
reason: str
|
||||||
|
|
||||||
|
|
||||||
|
def assess_it_relevance(title: str, description: str = "") -> ItRelevanceAssessment:
|
||||||
|
normalized_title = normalize_token(title)
|
||||||
|
blocked_signal = _NON_IT_TITLE_RE.search(normalized_title)
|
||||||
|
if blocked_signal:
|
||||||
|
return ItRelevanceAssessment(
|
||||||
|
relevant=False,
|
||||||
|
signals=(),
|
||||||
|
reason=f"Niet-technische titelcontext: {blocked_signal.group(0)}.",
|
||||||
|
)
|
||||||
|
signals = tuple(
|
||||||
|
term
|
||||||
|
for term in IT_TITLE_TERMS
|
||||||
|
if re.search(_term_pattern(normalize_token(term)), normalized_title, re.IGNORECASE)
|
||||||
|
)
|
||||||
|
if signals:
|
||||||
|
return ItRelevanceAssessment(
|
||||||
|
relevant=True,
|
||||||
|
signals=signals[:4],
|
||||||
|
reason="IT-signaal in functietitel: " + ", ".join(signals[:4]),
|
||||||
|
)
|
||||||
|
contextual_title = _CONTEXTUAL_IT_TITLE_RE.search(normalized_title)
|
||||||
|
if contextual_title:
|
||||||
|
description_signal = _IT_DESCRIPTION_RE.search(normalize_token(description))
|
||||||
|
if description_signal:
|
||||||
|
return ItRelevanceAssessment(
|
||||||
|
relevant=True,
|
||||||
|
signals=(contextual_title.group(0), description_signal.group(0)),
|
||||||
|
reason=(
|
||||||
|
"IT-context bevestigd via ambigue functietitel en vacaturetekst: "
|
||||||
|
f"{contextual_title.group(0)}, {description_signal.group(0)}."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return ItRelevanceAssessment(
|
||||||
|
relevant=False,
|
||||||
|
signals=(),
|
||||||
|
reason="Ambigue technische functietitel zonder aantoonbare IT-context.",
|
||||||
|
)
|
||||||
|
return ItRelevanceAssessment(
|
||||||
|
relevant=False,
|
||||||
|
signals=(),
|
||||||
|
reason="Geen aantoonbaar IT-signaal in de functietitel.",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def profile_requires_it_focus(profile: SearchProfile) -> bool:
|
||||||
|
configured = " ".join([profile.name, *profile.desired_titles, *profile.desired_skills])
|
||||||
|
normalized = normalize_token(configured)
|
||||||
|
return any(
|
||||||
|
re.search(_term_pattern(normalize_token(term)), normalized, re.IGNORECASE)
|
||||||
|
for term in IT_PROFILE_TERMS
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def it_relevance_query(prefix: str = "") -> Q:
|
||||||
|
"""Return the database equivalent of the conservative title-led classifier."""
|
||||||
|
clear_title = Q(**{f"{prefix}original_title__iregex": IT_TITLE_PATTERN})
|
||||||
|
contextual_title = Q(**{f"{prefix}original_title__iregex": CONTEXTUAL_IT_TITLE_PATTERN}) & Q(
|
||||||
|
**{f"{prefix}description_text__iregex": IT_DESCRIPTION_PATTERN}
|
||||||
|
)
|
||||||
|
return (clear_title | contextual_title) & ~Q(
|
||||||
|
**{f"{prefix}original_title__iregex": NON_IT_TITLE_PATTERN}
|
||||||
|
)
|
||||||
@@ -14,6 +14,8 @@ from apps.jobs.services.geocoding import resolve_cached_location
|
|||||||
from apps.profiles.models import SearchProfile
|
from apps.profiles.models import SearchProfile
|
||||||
|
|
||||||
from .normalization import normalize_token
|
from .normalization import normalize_token
|
||||||
|
from .relevance import assess_it_relevance, profile_requires_it_focus
|
||||||
|
from .skill_terms import contains_term, skill_is_present
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
@@ -51,6 +53,7 @@ class DistanceAssessment:
|
|||||||
|
|
||||||
EXACT_DISTANCE_CONF_THRESHOLD = 0.80
|
EXACT_DISTANCE_CONF_THRESHOLD = 0.80
|
||||||
AI_MAX_WEIGHT = 20.0
|
AI_MAX_WEIGHT = 20.0
|
||||||
|
SKILL_MATCH_TARGET = 4
|
||||||
|
|
||||||
|
|
||||||
def _similarity(left: str, right: str) -> float:
|
def _similarity(left: str, right: str) -> float:
|
||||||
@@ -112,6 +115,14 @@ def _title_fit(job: JobPosting, profile: SearchProfile) -> float:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _contains_term(text: str, term: str, *, allow_plural: bool = False) -> bool:
|
||||||
|
return contains_term(text, term, allow_plural=allow_plural)
|
||||||
|
|
||||||
|
|
||||||
|
def _skill_is_present(skill: str, text: str) -> bool:
|
||||||
|
return skill_is_present(skill, text)
|
||||||
|
|
||||||
|
|
||||||
def _skill_fit(job: JobPosting, profile: SearchProfile) -> tuple[float, list[str], list[str]]:
|
def _skill_fit(job: JobPosting, profile: SearchProfile) -> tuple[float, list[str], list[str]]:
|
||||||
desired = {normalize_token(skill) for skill in profile.desired_skills if skill}
|
desired = {normalize_token(skill) for skill in profile.desired_skills if skill}
|
||||||
if not desired:
|
if not desired:
|
||||||
@@ -119,9 +130,10 @@ def _skill_fit(job: JobPosting, profile: SearchProfile) -> tuple[float, list[str
|
|||||||
text = normalize_token(
|
text = normalize_token(
|
||||||
" ".join(job.skills_required + job.skills_preferred) + " " + job.description_text
|
" ".join(job.skills_required + job.skills_preferred) + " " + job.description_text
|
||||||
)
|
)
|
||||||
present = sorted(skill for skill in desired if skill and skill in text)
|
present = sorted(skill for skill in desired if _skill_is_present(skill, text))
|
||||||
missing = sorted(desired - set(present))
|
missing = sorted(desired - set(present))
|
||||||
return len(present) / len(desired), present, missing
|
evidence_target = min(SKILL_MATCH_TARGET, len(desired))
|
||||||
|
return min(1.0, len(present) / evidence_target), present, missing
|
||||||
|
|
||||||
|
|
||||||
def _distance(job: JobPosting, profile: SearchProfile) -> DistanceAssessment:
|
def _distance(job: JobPosting, profile: SearchProfile) -> DistanceAssessment:
|
||||||
@@ -172,7 +184,7 @@ def _hard_exclusions(
|
|||||||
configured_terms = list(profile.excluded_titles)
|
configured_terms = list(profile.excluded_titles)
|
||||||
configured_terms += list(profile.hard_rules.get("excluded_title_terms", []))
|
configured_terms += list(profile.hard_rules.get("excluded_title_terms", []))
|
||||||
for term in configured_terms:
|
for term in configured_terms:
|
||||||
if normalize_token(term) and normalize_token(term) in title:
|
if _contains_term(title, normalize_token(term), allow_plural=True):
|
||||||
reasons.append(f"Uitgesloten titelterm: {term}")
|
reasons.append(f"Uitgesloten titelterm: {term}")
|
||||||
|
|
||||||
excluded_types = set(profile.hard_rules.get("excluded_employment_types", []))
|
excluded_types = set(profile.hard_rules.get("excluded_employment_types", []))
|
||||||
@@ -227,6 +239,9 @@ def _hard_exclusions(
|
|||||||
conflicts = sorted(excluded_skills.intersection(explicit_job_skills))
|
conflicts = sorted(excluded_skills.intersection(explicit_job_skills))
|
||||||
if conflicts:
|
if conflicts:
|
||||||
reasons.append("Uitgesloten verplichte skill: " + ", ".join(conflicts))
|
reasons.append("Uitgesloten verplichte skill: " + ", ".join(conflicts))
|
||||||
|
it_relevance = assess_it_relevance(job.original_title, job.description_text)
|
||||||
|
if profile_requires_it_focus(profile) and not it_relevance.relevant:
|
||||||
|
reasons.append(it_relevance.reason)
|
||||||
return reasons, distance.distance_confidence
|
return reasons, distance.distance_confidence
|
||||||
|
|
||||||
|
|
||||||
@@ -244,20 +259,10 @@ def _ai_feature_score(features: dict[str, Any]) -> float:
|
|||||||
support_ratio = float(features.get("support_ratio") or 0.0)
|
support_ratio = float(features.get("support_ratio") or 0.0)
|
||||||
consultancy_ratio = float(features.get("consultancy_ratio") or 0.0)
|
consultancy_ratio = float(features.get("consultancy_ratio") or 0.0)
|
||||||
travel_ratio = float(features.get("travel_ratio") or 0.0)
|
travel_ratio = float(features.get("travel_ratio") or 0.0)
|
||||||
seniority = str(features.get("seniority") or "").strip().lower()
|
|
||||||
seniority_boost = {
|
|
||||||
"junior": 0.0,
|
|
||||||
"medior": 0.08,
|
|
||||||
"senior": 0.12,
|
|
||||||
"lead": 0.14,
|
|
||||||
"expert": 0.16,
|
|
||||||
"unknown": 0.03,
|
|
||||||
"": 0.03,
|
|
||||||
}.get(seniority, 0.05)
|
|
||||||
score = (
|
score = (
|
||||||
0.5 * (1.0 - support_ratio) + 0.25 * (1.0 - consultancy_ratio) + 0.15 * (1.0 - travel_ratio)
|
0.5 * (1.0 - support_ratio) + 0.25 * (1.0 - consultancy_ratio) + 0.15 * (1.0 - travel_ratio)
|
||||||
)
|
)
|
||||||
return max(0.0, min(1.0, score + seniority_boost))
|
return max(0.0, min(1.0, score))
|
||||||
|
|
||||||
|
|
||||||
def _analyze_with_ai(job: JobPosting, profile: SearchProfile) -> tuple[AiAnalysis, float, float]:
|
def _analyze_with_ai(job: JobPosting, profile: SearchProfile) -> tuple[AiAnalysis, float, float]:
|
||||||
@@ -292,6 +297,7 @@ def _analyze_with_ai(job: JobPosting, profile: SearchProfile) -> tuple[AiAnalysi
|
|||||||
def calculate_score(job: JobPosting, profile: SearchProfile) -> ScoreResult:
|
def calculate_score(job: JobPosting, profile: SearchProfile) -> ScoreResult:
|
||||||
distance = _distance(job, profile)
|
distance = _distance(job, profile)
|
||||||
exclusions, distance_confidence = _hard_exclusions(job, profile, distance)
|
exclusions, distance_confidence = _hard_exclusions(job, profile, distance)
|
||||||
|
it_relevance = assess_it_relevance(job.original_title, job.description_text)
|
||||||
title_fit = _title_fit(job, profile)
|
title_fit = _title_fit(job, profile)
|
||||||
skill_fit, present_skills, missing_skills = _skill_fit(job, profile)
|
skill_fit, present_skills, missing_skills = _skill_fit(job, profile)
|
||||||
features = job.analysis_features or {}
|
features = job.analysis_features or {}
|
||||||
@@ -321,12 +327,6 @@ def calculate_score(job: JobPosting, profile: SearchProfile) -> ScoreResult:
|
|||||||
else:
|
else:
|
||||||
conditions_fit = 0.65
|
conditions_fit = 0.65
|
||||||
employer_fit = 1.0 if job.direct_employer and not job.recruiter else 0.45
|
employer_fit = 1.0 if job.direct_employer and not job.recruiter else 0.45
|
||||||
experience_years = features.get("experience_years_max")
|
|
||||||
seniority_fit = (
|
|
||||||
0.75
|
|
||||||
if experience_years is None
|
|
||||||
else max(0.25, 1.0 - max(0, int(experience_years) - 5) * 0.1)
|
|
||||||
)
|
|
||||||
if profile.preferred_workplace:
|
if profile.preferred_workplace:
|
||||||
preference_fit = 1.0 if job.workplace_type in profile.preferred_workplace else 0.45
|
preference_fit = 1.0 if job.workplace_type in profile.preferred_workplace else 0.45
|
||||||
else:
|
else:
|
||||||
@@ -342,7 +342,6 @@ def calculate_score(job: JobPosting, profile: SearchProfile) -> ScoreResult:
|
|||||||
"location": location_fit,
|
"location": location_fit,
|
||||||
"conditions": conditions_fit,
|
"conditions": conditions_fit,
|
||||||
"employer": employer_fit,
|
"employer": employer_fit,
|
||||||
"seniority": seniority_fit,
|
|
||||||
"preferences": preference_fit,
|
"preferences": preference_fit,
|
||||||
}
|
}
|
||||||
ai_analysis, ai_component, ai_weight = _analyze_with_ai(job, profile)
|
ai_analysis, ai_component, ai_weight = _analyze_with_ai(job, profile)
|
||||||
@@ -381,6 +380,8 @@ def calculate_score(job: JobPosting, profile: SearchProfile) -> ScoreResult:
|
|||||||
concerns: list[str] = []
|
concerns: list[str] = []
|
||||||
if title_fit >= 0.75:
|
if title_fit >= 0.75:
|
||||||
positives.append("Functietitel sluit goed aan op het zoekprofiel.")
|
positives.append("Functietitel sluit goed aan op het zoekprofiel.")
|
||||||
|
if it_relevance.relevant:
|
||||||
|
positives.append("IT-focus bevestigd via de functietitel.")
|
||||||
if present_skills:
|
if present_skills:
|
||||||
positives.append("Herkenbare skills: " + ", ".join(present_skills[:6]))
|
positives.append("Herkenbare skills: " + ", ".join(present_skills[:6]))
|
||||||
if job.direct_employer and not job.recruiter:
|
if job.direct_employer and not job.recruiter:
|
||||||
@@ -402,8 +403,6 @@ def calculate_score(job: JobPosting, profile: SearchProfile) -> ScoreResult:
|
|||||||
)
|
)
|
||||||
if support_ratio >= 0.5:
|
if support_ratio >= 0.5:
|
||||||
concerns.append("Vacature bevat sterke first-line/helpdesksignalen.")
|
concerns.append("Vacature bevat sterke first-line/helpdesksignalen.")
|
||||||
if missing_skills:
|
|
||||||
concerns.append("Niet duidelijk teruggevonden: " + ", ".join(missing_skills[:6]))
|
|
||||||
if (
|
if (
|
||||||
distance.exact_distance_km is None
|
distance.exact_distance_km is None
|
||||||
and distance.has_distance_data
|
and distance.has_distance_data
|
||||||
@@ -435,6 +434,20 @@ def calculate_score(job: JobPosting, profile: SearchProfile) -> ScoreResult:
|
|||||||
concerns=concerns,
|
concerns=concerns,
|
||||||
hard_exclusions=exclusions,
|
hard_exclusions=exclusions,
|
||||||
evidence={
|
evidence={
|
||||||
|
"it_relevance": {
|
||||||
|
"relevant": it_relevance.relevant,
|
||||||
|
"signals": list(it_relevance.signals),
|
||||||
|
"reason": it_relevance.reason,
|
||||||
|
},
|
||||||
|
"skills": {
|
||||||
|
"matched": present_skills,
|
||||||
|
"configured_count": len(present_skills) + len(missing_skills),
|
||||||
|
"match_target": min(
|
||||||
|
SKILL_MATCH_TARGET,
|
||||||
|
len(present_skills) + len(missing_skills),
|
||||||
|
),
|
||||||
|
"not_observed": missing_skills,
|
||||||
|
},
|
||||||
"distance_km": distance.exact_distance_km,
|
"distance_km": distance.exact_distance_km,
|
||||||
"distance_has_data": distance.has_distance_data,
|
"distance_has_data": distance.has_distance_data,
|
||||||
"distance_exact": distance.exact_distance_km is not None,
|
"distance_exact": distance.exact_distance_km is not None,
|
||||||
|
|||||||
@@ -0,0 +1,252 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
from django.db.models import CharField, Exists, OuterRef, Q, QuerySet, Subquery
|
||||||
|
from django.utils.text import slugify
|
||||||
|
|
||||||
|
from apps.jobs.models import JobPosting, JobSourceAlias, ScoreRun
|
||||||
|
from apps.profiles.models import SearchProfile
|
||||||
|
from apps.profiles.taxonomy import SKILL_CHOICES
|
||||||
|
from apps.sources.models import Source
|
||||||
|
|
||||||
|
from .normalization import normalize_token
|
||||||
|
from .relevance import it_relevance_query
|
||||||
|
from .skill_terms import canonical_skill_key, skill_is_present
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class SkillDefinition:
|
||||||
|
key: str
|
||||||
|
label: str
|
||||||
|
category: str
|
||||||
|
category_key: str
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class SkillJobExample:
|
||||||
|
id: str
|
||||||
|
title: str
|
||||||
|
employer: str
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class SkillDemandItem:
|
||||||
|
key: str
|
||||||
|
label: str
|
||||||
|
category: str
|
||||||
|
category_key: str
|
||||||
|
vacancy_count: int
|
||||||
|
share_percent: int
|
||||||
|
explicit_count: int
|
||||||
|
inferred_count: int
|
||||||
|
covered: bool
|
||||||
|
learning_focus: str
|
||||||
|
search_term: str
|
||||||
|
examples: tuple[SkillJobExample, ...]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class SkillDemandReport:
|
||||||
|
total_jobs: int
|
||||||
|
jobs_with_signals: int
|
||||||
|
signal_count: int
|
||||||
|
covered_count: int
|
||||||
|
gap_count: int
|
||||||
|
coverage_percent: int
|
||||||
|
items: tuple[SkillDemandItem, ...]
|
||||||
|
categories: tuple[tuple[str, str], ...]
|
||||||
|
selected_category: str
|
||||||
|
selected_coverage: str
|
||||||
|
|
||||||
|
|
||||||
|
def skill_catalog() -> tuple[SkillDefinition, ...]:
|
||||||
|
return tuple(
|
||||||
|
SkillDefinition(
|
||||||
|
key=normalize_token(value),
|
||||||
|
label=label,
|
||||||
|
category=category,
|
||||||
|
category_key=slugify(category),
|
||||||
|
)
|
||||||
|
for category, choices in SKILL_CHOICES
|
||||||
|
for value, label in choices
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def relevant_jobs_for_profile(profile: SearchProfile) -> QuerySet[JobPosting]:
|
||||||
|
latest_score = ScoreRun.objects.filter(
|
||||||
|
job=OuterRef("pk"),
|
||||||
|
profile=profile,
|
||||||
|
profile_version=profile.version,
|
||||||
|
).order_by("-created_at")
|
||||||
|
source_aliases = JobSourceAlias.objects.filter(job=OuterRef("pk"))
|
||||||
|
active_source_aliases = source_aliases.filter(source__status=Source.Status.ACTIVE)
|
||||||
|
return (
|
||||||
|
JobPosting.objects.select_related("employer")
|
||||||
|
.filter(status=JobPosting.Status.ACTIVE)
|
||||||
|
.filter(it_relevance_query())
|
||||||
|
.annotate(
|
||||||
|
profile_recommendation=Subquery(
|
||||||
|
latest_score.values("recommendation")[:1], output_field=CharField()
|
||||||
|
),
|
||||||
|
has_source_alias=Exists(source_aliases),
|
||||||
|
has_active_source_alias=Exists(active_source_aliases),
|
||||||
|
)
|
||||||
|
.filter(
|
||||||
|
profile_recommendation__in=(
|
||||||
|
ScoreRun.Recommendation.STRONG,
|
||||||
|
ScoreRun.Recommendation.POSSIBLE,
|
||||||
|
ScoreRun.Recommendation.WEAK,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.filter(Q(has_source_alias=False) | Q(has_active_source_alias=True))
|
||||||
|
.order_by("-first_seen")
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _structured_skill_text(job: JobPosting) -> str:
|
||||||
|
return normalize_token(
|
||||||
|
" ".join(str(value) for value in [*job.skills_required, *job.skills_preferred])
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _inferred_skill_text(job: JobPosting) -> str:
|
||||||
|
requirements = " ".join(str(value) for value in job.requirements)
|
||||||
|
return normalize_token(f"{job.original_title} {requirements} {job.description_text}")
|
||||||
|
|
||||||
|
|
||||||
|
def _learning_focus(definition: SkillDefinition) -> str:
|
||||||
|
focuses = {
|
||||||
|
"Microsoft & endpoint": (
|
||||||
|
"Oefen een praktische beheer- of migratiecase en leg de relevante "
|
||||||
|
"Microsoft Learn-modules vast."
|
||||||
|
),
|
||||||
|
"Netwerk & security": (
|
||||||
|
"Bouw een kleine labcase rond configuratie, troubleshooting en beveiligde toegang."
|
||||||
|
),
|
||||||
|
"Back-up & monitoring": (
|
||||||
|
"Oefen detectie, herstel en rapportering met een reproduceerbare homelabcase."
|
||||||
|
),
|
||||||
|
"Virtualisatie & platform": (
|
||||||
|
"Maak een deployment- of platformlab en documenteer beschikbaarheid en herstel."
|
||||||
|
),
|
||||||
|
"IT-servicemanagement": (
|
||||||
|
"Koppel de methodiek aan een concrete incident-, problem- of changecase."
|
||||||
|
),
|
||||||
|
"Digitale werkplek & adoptie": (
|
||||||
|
"Werk een kleine governance- of adoptiecase uit met meetbare gebruikersimpact."
|
||||||
|
),
|
||||||
|
"Automation & scripting": (
|
||||||
|
"Automatiseer één herkenbare beheertaak en publiceer een veilig voorbeeldscript."
|
||||||
|
),
|
||||||
|
"VoIP & telefonie": "Simuleer configuratie en troubleshooting in een kleine telefoniecase.",
|
||||||
|
"Field service & uitvoering": (
|
||||||
|
"Documenteer een end-to-end interventie: diagnose, oplossing en overdracht."
|
||||||
|
),
|
||||||
|
}
|
||||||
|
return focuses.get(
|
||||||
|
definition.category,
|
||||||
|
"Maak een kleine praktijkcase en leg vast welke vacature-eis je ermee kunt aantonen.",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _profile_skill_keys(profile: SearchProfile, catalog: tuple[SkillDefinition, ...]) -> set[str]:
|
||||||
|
catalog_keys = {definition.key for definition in catalog}
|
||||||
|
return {
|
||||||
|
canonical
|
||||||
|
for value in profile.desired_skills
|
||||||
|
if (canonical := canonical_skill_key(str(value), catalog_keys)) is not None
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def build_skill_demand_report(
|
||||||
|
profile: SearchProfile,
|
||||||
|
*,
|
||||||
|
category: str = "all",
|
||||||
|
coverage: str = "all",
|
||||||
|
jobs: QuerySet[JobPosting] | list[JobPosting] | None = None,
|
||||||
|
) -> SkillDemandReport:
|
||||||
|
catalog = skill_catalog()
|
||||||
|
valid_categories = {definition.category_key for definition in catalog}
|
||||||
|
selected_category = category if category in valid_categories else "all"
|
||||||
|
selected_coverage = coverage if coverage in {"all", "gap", "covered"} else "all"
|
||||||
|
job_list = list(relevant_jobs_for_profile(profile) if jobs is None else jobs)
|
||||||
|
profile_skills = _profile_skill_keys(profile, catalog)
|
||||||
|
items: list[SkillDemandItem] = []
|
||||||
|
jobs_with_signals: set[str] = set()
|
||||||
|
|
||||||
|
for definition in catalog:
|
||||||
|
examples: list[SkillJobExample] = []
|
||||||
|
explicit_count = 0
|
||||||
|
inferred_count = 0
|
||||||
|
for job in job_list:
|
||||||
|
explicit = skill_is_present(definition.key, _structured_skill_text(job))
|
||||||
|
inferred = skill_is_present(definition.key, _inferred_skill_text(job))
|
||||||
|
if not explicit and not inferred:
|
||||||
|
continue
|
||||||
|
if explicit:
|
||||||
|
explicit_count += 1
|
||||||
|
else:
|
||||||
|
inferred_count += 1
|
||||||
|
jobs_with_signals.add(str(job.pk))
|
||||||
|
if len(examples) < 3:
|
||||||
|
examples.append(
|
||||||
|
SkillJobExample(
|
||||||
|
id=str(job.pk),
|
||||||
|
title=job.original_title,
|
||||||
|
employer=job.employer_name,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
vacancy_count = explicit_count + inferred_count
|
||||||
|
if vacancy_count == 0:
|
||||||
|
continue
|
||||||
|
items.append(
|
||||||
|
SkillDemandItem(
|
||||||
|
key=definition.key,
|
||||||
|
label=definition.label,
|
||||||
|
category=definition.category,
|
||||||
|
category_key=definition.category_key,
|
||||||
|
vacancy_count=vacancy_count,
|
||||||
|
share_percent=round(vacancy_count / len(job_list) * 100) if job_list else 0,
|
||||||
|
explicit_count=explicit_count,
|
||||||
|
inferred_count=inferred_count,
|
||||||
|
covered=definition.key in profile_skills,
|
||||||
|
learning_focus=_learning_focus(definition),
|
||||||
|
search_term=definition.key,
|
||||||
|
examples=tuple(examples),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
ranked = sorted(
|
||||||
|
items,
|
||||||
|
key=lambda item: (-item.vacancy_count, -item.explicit_count, item.label.casefold()),
|
||||||
|
)
|
||||||
|
covered_count = sum(item.covered for item in ranked)
|
||||||
|
gap_count = len(ranked) - covered_count
|
||||||
|
filtered = tuple(
|
||||||
|
item
|
||||||
|
for item in ranked
|
||||||
|
if (selected_category == "all" or item.category_key == selected_category)
|
||||||
|
and (
|
||||||
|
selected_coverage == "all"
|
||||||
|
or (selected_coverage == "covered" and item.covered)
|
||||||
|
or (selected_coverage == "gap" and not item.covered)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
categories = tuple(
|
||||||
|
(slugify(category_label), category_label)
|
||||||
|
for category_label, _choices in SKILL_CHOICES
|
||||||
|
if any(item.category == category_label for item in ranked)
|
||||||
|
)
|
||||||
|
return SkillDemandReport(
|
||||||
|
total_jobs=len(job_list),
|
||||||
|
jobs_with_signals=len(jobs_with_signals),
|
||||||
|
signal_count=len(ranked),
|
||||||
|
covered_count=covered_count,
|
||||||
|
gap_count=gap_count,
|
||||||
|
coverage_percent=round(covered_count / len(ranked) * 100) if ranked else 0,
|
||||||
|
items=filtered,
|
||||||
|
categories=categories,
|
||||||
|
selected_category=selected_category,
|
||||||
|
selected_coverage=selected_coverage,
|
||||||
|
)
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
|
||||||
|
from apps.jobs.services.normalization import normalize_token
|
||||||
|
|
||||||
|
SKILL_ALIASES: dict[str, tuple[str, ...]] = {
|
||||||
|
"microsoft 365": ("m365", "office 365", "o365"),
|
||||||
|
"microsoft teams": ("ms teams",),
|
||||||
|
"entra id": ("azure active directory", "azure ad"),
|
||||||
|
"intune": ("microsoft intune", "endpoint manager"),
|
||||||
|
"autopilot": ("windows autopilot",),
|
||||||
|
"group policy": ("group policy object", "gpo"),
|
||||||
|
"dynamics 365": ("microsoft dynamics 365", "d365"),
|
||||||
|
"microsoft copilot": ("copilot for microsoft 365", "m365 copilot"),
|
||||||
|
"microsoft defender": ("defender for endpoint", "microsoft defender for endpoint"),
|
||||||
|
"microsoft sentinel": ("azure sentinel", "sentinel siem"),
|
||||||
|
"conditional access": ("voorwaardelijke toegang",),
|
||||||
|
"networking": ("network", "netwerk", "lan", "wan"),
|
||||||
|
"routing": ("routering",),
|
||||||
|
"switching": ("network switches", "switches"),
|
||||||
|
"sd-wan": ("sd wan",),
|
||||||
|
"firewalls": ("firewall",),
|
||||||
|
"fortinet": ("fortigate",),
|
||||||
|
"palo alto": ("palo alto networks",),
|
||||||
|
"wi-fi": ("wifi", "wireless"),
|
||||||
|
"backup and restore": ("backup", "back-up", "restore"),
|
||||||
|
"disaster recovery": ("business continuity", "bcp", "dr plan"),
|
||||||
|
"monitoring": ("infrastructure monitoring", "system monitoring"),
|
||||||
|
"observability": ("telemetry", "tracing"),
|
||||||
|
"high availability": ("hoogbeschikbaarheid", "ha architecture"),
|
||||||
|
"vmware": ("vsphere", "esxi"),
|
||||||
|
"ci/cd": ("continuous integration", "continuous delivery", "ci cd"),
|
||||||
|
"deployment": ("deployments", "software deployment", "uitrol"),
|
||||||
|
"reliability": ("platform reliability", "site reliability", "sre"),
|
||||||
|
"scalability": ("scalable", "schaalbaarheid"),
|
||||||
|
"voip": ("voice over ip", "telefonie", "telephony", "3cx", "innovaphone"),
|
||||||
|
"teams telephony": ("teams phone", "teams telefonie", "teams voice"),
|
||||||
|
"onsite support": ("on-site support", "support op locatie", "field support"),
|
||||||
|
"second line support": ("second-line support", "2nd line", "tweedelijnssupport"),
|
||||||
|
"installations": ("installatie", "installaties", "roll-out", "rollout"),
|
||||||
|
"migrations": ("migratie", "migraties"),
|
||||||
|
"technical documentation": ("technische documentatie",),
|
||||||
|
"customer support": ("klantondersteuning", "user support"),
|
||||||
|
"incident management": ("incidentbeheer", "incident response", "escalations"),
|
||||||
|
"problem management": ("probleembeheer", "root cause analysis"),
|
||||||
|
"change management": ("wijzigingsbeheer", "organizational change"),
|
||||||
|
"itil": ("itil 4", "it service management"),
|
||||||
|
"servicenow": ("service now",),
|
||||||
|
"jira service management": ("jira service desk", "jsm"),
|
||||||
|
"digital workplace": ("digitale werkplek",),
|
||||||
|
"m365 governance": ("microsoft 365 governance", "office 365 governance"),
|
||||||
|
"document management": ("documentbeheer", "document management system", "dms"),
|
||||||
|
"data governance": ("data governance", "datagovernance"),
|
||||||
|
"user adoption": ("gebruikersadoptie", "technology adoption"),
|
||||||
|
"training and workshops": ("user training", "workshops", "training geven"),
|
||||||
|
"customer experience": ("customer satisfaction", "csat", "nps"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def contains_term(text: str, term: str, *, allow_plural: bool = False) -> bool:
|
||||||
|
normalized_term = normalize_token(term)
|
||||||
|
if not normalized_term:
|
||||||
|
return False
|
||||||
|
pattern = re.escape(normalized_term).replace(r"\ ", r"[\s/_-]+")
|
||||||
|
if allow_plural and normalized_term[-1].isalpha() and not normalized_term.endswith("s"):
|
||||||
|
pattern += "s?"
|
||||||
|
return re.search(rf"(?<!\w){pattern}(?!\w)", text, re.IGNORECASE) is not None
|
||||||
|
|
||||||
|
|
||||||
|
def skill_terms(skill: str) -> tuple[str, ...]:
|
||||||
|
normalized = normalize_token(skill)
|
||||||
|
return (normalized, *SKILL_ALIASES.get(normalized, ()))
|
||||||
|
|
||||||
|
|
||||||
|
def skill_is_present(skill: str, text: str) -> bool:
|
||||||
|
return any(contains_term(text, candidate) for candidate in skill_terms(skill))
|
||||||
|
|
||||||
|
|
||||||
|
def canonical_skill_key(value: str, catalog_keys: set[str]) -> str | None:
|
||||||
|
normalized = normalize_token(value)
|
||||||
|
if normalized in catalog_keys:
|
||||||
|
return normalized
|
||||||
|
for key in catalog_keys:
|
||||||
|
if normalized in {normalize_token(term) for term in SKILL_ALIASES.get(key, ())}:
|
||||||
|
return key
|
||||||
|
return None
|
||||||
@@ -1,20 +1,30 @@
|
|||||||
from django.urls import path
|
from django.urls import path
|
||||||
|
|
||||||
from .views import (
|
from .views import (
|
||||||
|
ActivityListView,
|
||||||
ApplicationListView,
|
ApplicationListView,
|
||||||
ApplicationUpdateView,
|
ApplicationUpdateView,
|
||||||
|
EmployerDetailView,
|
||||||
|
EmployerListView,
|
||||||
JobDetailView,
|
JobDetailView,
|
||||||
JobListView,
|
JobListView,
|
||||||
|
SkillInsightsView,
|
||||||
application_delete,
|
application_delete,
|
||||||
application_export,
|
application_export,
|
||||||
application_print,
|
application_print,
|
||||||
|
application_status,
|
||||||
job_feedback,
|
job_feedback,
|
||||||
)
|
)
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path("", JobListView.as_view(), name="list"),
|
path("", JobListView.as_view(), name="list"),
|
||||||
|
path("skills/", SkillInsightsView.as_view(), name="skill-insights"),
|
||||||
|
path("activity/", ActivityListView.as_view(), name="activity"),
|
||||||
|
path("employers/", EmployerListView.as_view(), name="employers"),
|
||||||
|
path("employers/<int:pk>/", EmployerDetailView.as_view(), name="employer-detail"),
|
||||||
path("applications/", ApplicationListView.as_view(), name="applications"),
|
path("applications/", ApplicationListView.as_view(), name="applications"),
|
||||||
path("applications/<int:pk>/", ApplicationUpdateView.as_view(), name="application-edit"),
|
path("applications/<int:pk>/", ApplicationUpdateView.as_view(), name="application-edit"),
|
||||||
|
path("applications/<int:pk>/status/", application_status, name="application-status"),
|
||||||
path("applications/<int:pk>/export/", application_export, name="application-export"),
|
path("applications/<int:pk>/export/", application_export, name="application-export"),
|
||||||
path("applications/<int:pk>/print/", application_print, name="application-print"),
|
path("applications/<int:pk>/print/", application_print, name="application-print"),
|
||||||
path("applications/<int:pk>/delete/", application_delete, name="application-delete"),
|
path("applications/<int:pk>/delete/", application_delete, name="application-delete"),
|
||||||
|
|||||||
@@ -3,24 +3,141 @@ from __future__ import annotations
|
|||||||
from django.contrib import messages
|
from django.contrib import messages
|
||||||
from django.contrib.auth.decorators import login_required
|
from django.contrib.auth.decorators import login_required
|
||||||
from django.contrib.auth.mixins import LoginRequiredMixin
|
from django.contrib.auth.mixins import LoginRequiredMixin
|
||||||
from django.db.models import CharField, DecimalField, OuterRef, Q, Subquery
|
from django.db.models import Count, F, Q
|
||||||
from django.http import Http404, HttpResponse
|
from django.http import Http404, HttpResponse
|
||||||
from django.shortcuts import get_object_or_404, redirect
|
from django.shortcuts import get_object_or_404, redirect
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
|
from django.utils import timezone
|
||||||
|
from django.utils.http import url_has_allowed_host_and_scheme
|
||||||
from django.views.decorators.http import require_POST
|
from django.views.decorators.http import require_POST
|
||||||
from django.views.generic import DetailView, ListView, UpdateView
|
from django.views.generic import DetailView, ListView, TemplateView, UpdateView
|
||||||
|
|
||||||
from apps.profiles.models import SearchProfile
|
from apps.profiles.models import SearchProfile
|
||||||
|
|
||||||
from .forms import ApplicationForm
|
from .forms import ApplicationForm
|
||||||
from .models import Application, Feedback, JobPosting, ScoreRun
|
from .models import Application, Employer, Feedback, JobPosting, ScoreRun
|
||||||
|
from .services.activity import ACTIVITY_FILTERS, build_activity_page
|
||||||
from .services.applications import (
|
from .services.applications import (
|
||||||
build_application_export,
|
build_application_export,
|
||||||
build_print_html,
|
build_print_html,
|
||||||
|
change_application_status,
|
||||||
delete_application_dossier,
|
delete_application_dossier,
|
||||||
track_application_changes,
|
track_application_changes,
|
||||||
)
|
)
|
||||||
|
from .services.cockpit import annotate_latest_profile_score, build_job_intelligence
|
||||||
|
from .services.employer_intelligence import (
|
||||||
|
build_employer_detail,
|
||||||
|
employer_index_queryset,
|
||||||
|
filter_employers,
|
||||||
|
)
|
||||||
from .services.feedback import record_feedback
|
from .services.feedback import record_feedback
|
||||||
|
from .services.relevance import it_relevance_query
|
||||||
|
from .services.skill_demand import build_skill_demand_report
|
||||||
|
|
||||||
|
|
||||||
|
class SkillInsightsView(LoginRequiredMixin, TemplateView):
|
||||||
|
template_name = "jobs/skill_insights.html"
|
||||||
|
|
||||||
|
def get_context_data(self, **kwargs):
|
||||||
|
context = super().get_context_data(**kwargs)
|
||||||
|
profile = SearchProfile.objects.filter(user=self.request.user, is_active=True).first()
|
||||||
|
context["active_profile"] = profile
|
||||||
|
context["report"] = (
|
||||||
|
build_skill_demand_report(
|
||||||
|
profile,
|
||||||
|
category=self.request.GET.get("category", "all"),
|
||||||
|
coverage=self.request.GET.get("coverage", "all"),
|
||||||
|
)
|
||||||
|
if profile
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
return context
|
||||||
|
|
||||||
|
|
||||||
|
class ActivityListView(LoginRequiredMixin, TemplateView):
|
||||||
|
template_name = "activity/list.html"
|
||||||
|
|
||||||
|
def get_context_data(self, **kwargs):
|
||||||
|
context = super().get_context_data(**kwargs)
|
||||||
|
event_type = self.request.GET.get("type", "all")
|
||||||
|
valid_types = {value for value, _label in ACTIVITY_FILTERS}
|
||||||
|
if event_type not in valid_types:
|
||||||
|
event_type = "all"
|
||||||
|
context.update(
|
||||||
|
{
|
||||||
|
"event_page": build_activity_page(
|
||||||
|
user=self.request.user,
|
||||||
|
event_type=event_type,
|
||||||
|
query=self.request.GET.get("q", ""),
|
||||||
|
page=self.request.GET.get("page", 1),
|
||||||
|
),
|
||||||
|
"activity_filters": ACTIVITY_FILTERS,
|
||||||
|
"current_type": event_type,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return context
|
||||||
|
|
||||||
|
|
||||||
|
class EmployerListView(LoginRequiredMixin, ListView):
|
||||||
|
model = Employer
|
||||||
|
template_name = "employers/list.html"
|
||||||
|
context_object_name = "employers"
|
||||||
|
paginate_by = 40
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
self.profile = SearchProfile.objects.filter(user=self.request.user, is_active=True).first()
|
||||||
|
return filter_employers(
|
||||||
|
employer_index_queryset(user=self.request.user, profile=self.profile),
|
||||||
|
query=self.request.GET.get("q", ""),
|
||||||
|
channel=self.request.GET.get("channel", ""),
|
||||||
|
active_only=self.request.GET.get("active") == "1",
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_context_data(self, **kwargs):
|
||||||
|
context = super().get_context_data(**kwargs)
|
||||||
|
employers = list(context["employers"])
|
||||||
|
selected_id = self.request.GET.get("selected", "")
|
||||||
|
selected = next((item for item in employers if str(item.pk) == selected_id), None)
|
||||||
|
if selected is None and employers:
|
||||||
|
selected = employers[0]
|
||||||
|
context.update(
|
||||||
|
{
|
||||||
|
"selected_employer": selected,
|
||||||
|
"selected_detail": build_employer_detail(
|
||||||
|
employer=selected, user=self.request.user, profile=self.profile
|
||||||
|
)
|
||||||
|
if selected
|
||||||
|
else None,
|
||||||
|
"active_profile": self.profile,
|
||||||
|
"employer_total": Employer.objects.count(),
|
||||||
|
"employer_active_total": Employer.objects.filter(
|
||||||
|
jobs__status=JobPosting.Status.ACTIVE
|
||||||
|
)
|
||||||
|
.distinct()
|
||||||
|
.count(),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return context
|
||||||
|
|
||||||
|
|
||||||
|
class EmployerDetailView(LoginRequiredMixin, DetailView):
|
||||||
|
model = Employer
|
||||||
|
template_name = "employers/detail.html"
|
||||||
|
context_object_name = "employer"
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
self.profile = SearchProfile.objects.filter(user=self.request.user, is_active=True).first()
|
||||||
|
return employer_index_queryset(user=self.request.user, profile=self.profile)
|
||||||
|
|
||||||
|
def get_context_data(self, **kwargs):
|
||||||
|
context = super().get_context_data(**kwargs)
|
||||||
|
context.update(
|
||||||
|
build_employer_detail(
|
||||||
|
employer=self.object, user=self.request.user, profile=self.profile
|
||||||
|
)
|
||||||
|
)
|
||||||
|
context["active_profile"] = self.profile
|
||||||
|
return context
|
||||||
|
|
||||||
|
|
||||||
class JobListView(LoginRequiredMixin, ListView):
|
class JobListView(LoginRequiredMixin, ListView):
|
||||||
@@ -29,29 +146,30 @@ class JobListView(LoginRequiredMixin, ListView):
|
|||||||
context_object_name = "jobs"
|
context_object_name = "jobs"
|
||||||
paginate_by = 30
|
paginate_by = 30
|
||||||
|
|
||||||
|
VALID_RECOMMENDATIONS = {
|
||||||
|
ScoreRun.Recommendation.STRONG,
|
||||||
|
ScoreRun.Recommendation.POSSIBLE,
|
||||||
|
ScoreRun.Recommendation.WEAK,
|
||||||
|
ScoreRun.Recommendation.HIDDEN,
|
||||||
|
}
|
||||||
|
VALID_MATCH_FILTERS = {*VALID_RECOMMENDATIONS, "radar", "all"}
|
||||||
|
VALID_SORTS = {"match", "newest", "closing"}
|
||||||
|
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
queryset = JobPosting.objects.select_related("employer").all()
|
queryset = JobPosting.objects.select_related("employer").annotate(
|
||||||
|
duplicate_count=Count("duplicates", distinct=True)
|
||||||
|
)
|
||||||
profile = SearchProfile.objects.filter(user=self.request.user, is_active=True).first()
|
profile = SearchProfile.objects.filter(user=self.request.user, is_active=True).first()
|
||||||
if profile:
|
queryset = annotate_latest_profile_score(queryset, profile)
|
||||||
latest_score = ScoreRun.objects.filter(job=OuterRef("pk"), profile=profile).order_by(
|
|
||||||
"-created_at"
|
|
||||||
)
|
|
||||||
queryset = queryset.annotate(
|
|
||||||
match_score=Subquery(
|
|
||||||
latest_score.values("score")[:1],
|
|
||||||
output_field=DecimalField(max_digits=5, decimal_places=2),
|
|
||||||
),
|
|
||||||
match_confidence=Subquery(
|
|
||||||
latest_score.values("confidence")[:1],
|
|
||||||
output_field=DecimalField(max_digits=4, decimal_places=3),
|
|
||||||
),
|
|
||||||
match_recommendation=Subquery(
|
|
||||||
latest_score.values("recommendation")[:1], output_field=CharField()
|
|
||||||
),
|
|
||||||
)
|
|
||||||
query = self.request.GET.get("q", "").strip()
|
query = self.request.GET.get("q", "").strip()
|
||||||
status = self.request.GET.get("status", "active").strip()
|
status = self.request.GET.get("status", "active").strip()
|
||||||
workplace = self.request.GET.get("workplace", "").strip()
|
workplace = self.request.GET.get("workplace", "").strip()
|
||||||
|
channel = self.request.GET.get("channel", "").strip()
|
||||||
|
focus = self.request.GET.get("focus", "it").strip()
|
||||||
|
recommendation = self.request.GET.get("match", "radar").strip()
|
||||||
|
sort = self.request.GET.get("sort", "match").strip()
|
||||||
|
if recommendation not in self.VALID_MATCH_FILTERS:
|
||||||
|
recommendation = "radar"
|
||||||
if query:
|
if query:
|
||||||
queryset = queryset.filter(
|
queryset = queryset.filter(
|
||||||
Q(original_title__icontains=query)
|
Q(original_title__icontains=query)
|
||||||
@@ -63,7 +181,68 @@ class JobListView(LoginRequiredMixin, ListView):
|
|||||||
queryset = queryset.filter(status=status)
|
queryset = queryset.filter(status=status)
|
||||||
if workplace:
|
if workplace:
|
||||||
queryset = queryset.filter(workplace_type=workplace)
|
queryset = queryset.filter(workplace_type=workplace)
|
||||||
return queryset.order_by("-first_seen")
|
if channel == "direct":
|
||||||
|
queryset = queryset.filter(direct_employer=True, recruiter=False)
|
||||||
|
elif channel == "recruiter":
|
||||||
|
queryset = queryset.filter(recruiter=True)
|
||||||
|
if focus != "all":
|
||||||
|
queryset = queryset.filter(it_relevance_query())
|
||||||
|
if profile and recommendation == "radar":
|
||||||
|
queryset = queryset.filter(
|
||||||
|
Q(
|
||||||
|
match_recommendation__in=[
|
||||||
|
ScoreRun.Recommendation.STRONG,
|
||||||
|
ScoreRun.Recommendation.POSSIBLE,
|
||||||
|
ScoreRun.Recommendation.WEAK,
|
||||||
|
]
|
||||||
|
)
|
||||||
|
| Q(match_recommendation__isnull=True)
|
||||||
|
)
|
||||||
|
elif profile and recommendation in self.VALID_RECOMMENDATIONS:
|
||||||
|
queryset = queryset.filter(match_recommendation=recommendation)
|
||||||
|
if sort not in self.VALID_SORTS:
|
||||||
|
sort = "match"
|
||||||
|
if sort == "newest":
|
||||||
|
return queryset.order_by("-first_seen", F("match_score").desc(nulls_last=True))
|
||||||
|
if sort == "closing":
|
||||||
|
return queryset.order_by(F("valid_through").asc(nulls_last=True), "-first_seen")
|
||||||
|
return queryset.order_by(F("match_score").desc(nulls_last=True), "-first_seen")
|
||||||
|
|
||||||
|
def get_context_data(self, **kwargs):
|
||||||
|
context = super().get_context_data(**kwargs)
|
||||||
|
active_jobs = JobPosting.objects.filter(status=JobPosting.Status.ACTIVE)
|
||||||
|
it_jobs = active_jobs.filter(it_relevance_query())
|
||||||
|
context.update(
|
||||||
|
{
|
||||||
|
"active_profile": SearchProfile.objects.filter(
|
||||||
|
user=self.request.user, is_active=True
|
||||||
|
).first(),
|
||||||
|
"active_job_count": active_jobs.count(),
|
||||||
|
"it_job_count": it_jobs.count(),
|
||||||
|
"filtered_non_it_count": active_jobs.exclude(it_relevance_query()).count(),
|
||||||
|
"current_focus": self.request.GET.get("focus", "it"),
|
||||||
|
"current_match": (
|
||||||
|
self.request.GET.get("match", "radar")
|
||||||
|
if self.request.GET.get("match", "radar") in self.VALID_MATCH_FILTERS
|
||||||
|
else "radar"
|
||||||
|
),
|
||||||
|
"current_sort": self.request.GET.get("sort", "match"),
|
||||||
|
"current_channel": self.request.GET.get("channel", ""),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
jobs = list(context["jobs"])
|
||||||
|
selected_id = self.request.GET.get("selected", "")
|
||||||
|
selected_job = next((job for job in jobs if str(job.pk) == selected_id), None)
|
||||||
|
if selected_job is None and jobs:
|
||||||
|
selected_job = jobs[0]
|
||||||
|
context["selected_job"] = selected_job
|
||||||
|
if selected_job is not None:
|
||||||
|
context["selected_intelligence"] = build_job_intelligence(
|
||||||
|
job=selected_job,
|
||||||
|
profile=context["active_profile"],
|
||||||
|
user=self.request.user,
|
||||||
|
)
|
||||||
|
return context
|
||||||
|
|
||||||
|
|
||||||
class JobDetailView(LoginRequiredMixin, DetailView):
|
class JobDetailView(LoginRequiredMixin, DetailView):
|
||||||
@@ -79,16 +258,9 @@ class JobDetailView(LoginRequiredMixin, DetailView):
|
|||||||
def get_context_data(self, **kwargs):
|
def get_context_data(self, **kwargs):
|
||||||
context = super().get_context_data(**kwargs)
|
context = super().get_context_data(**kwargs)
|
||||||
profile = SearchProfile.objects.filter(user=self.request.user, is_active=True).first()
|
profile = SearchProfile.objects.filter(user=self.request.user, is_active=True).first()
|
||||||
context["score"] = (
|
context.update(
|
||||||
ScoreRun.objects.filter(job=self.object, profile=profile)
|
build_job_intelligence(job=self.object, profile=profile, user=self.request.user)
|
||||||
.order_by("-created_at")
|
|
||||||
.first()
|
|
||||||
if profile
|
|
||||||
else None
|
|
||||||
)
|
)
|
||||||
context["application"] = Application.objects.filter(
|
|
||||||
user=self.request.user, job=self.object
|
|
||||||
).first()
|
|
||||||
context["latest_feedback"] = Feedback.objects.filter(
|
context["latest_feedback"] = Feedback.objects.filter(
|
||||||
user=self.request.user, job=self.object
|
user=self.request.user, job=self.object
|
||||||
).first()
|
).first()
|
||||||
@@ -113,7 +285,13 @@ def job_feedback(request, pk):
|
|||||||
)
|
)
|
||||||
messages.success(request, "Actie opgeslagen.")
|
messages.success(request, "Actie opgeslagen.")
|
||||||
next_url = request.POST.get("next")
|
next_url = request.POST.get("next")
|
||||||
return redirect(next_url or reverse("jobs:detail", kwargs={"pk": pk}))
|
if next_url and url_has_allowed_host_and_scheme(
|
||||||
|
next_url,
|
||||||
|
allowed_hosts={request.get_host()},
|
||||||
|
require_https=request.is_secure(),
|
||||||
|
):
|
||||||
|
return redirect(next_url)
|
||||||
|
return redirect(reverse("jobs:detail", kwargs={"pk": pk}))
|
||||||
|
|
||||||
|
|
||||||
class ApplicationListView(LoginRequiredMixin, ListView):
|
class ApplicationListView(LoginRequiredMixin, ListView):
|
||||||
@@ -122,8 +300,10 @@ class ApplicationListView(LoginRequiredMixin, ListView):
|
|||||||
context_object_name = "applications"
|
context_object_name = "applications"
|
||||||
|
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
return Application.objects.filter(user=self.request.user).select_related(
|
return (
|
||||||
"job", "job__employer"
|
Application.objects.filter(user=self.request.user)
|
||||||
|
.select_related("job", "job__employer")
|
||||||
|
.prefetch_related("timeline_events")
|
||||||
)
|
)
|
||||||
|
|
||||||
def get_context_data(self, **kwargs):
|
def get_context_data(self, **kwargs):
|
||||||
@@ -138,6 +318,16 @@ class ApplicationListView(LoginRequiredMixin, ListView):
|
|||||||
}
|
}
|
||||||
for status, label in Application.Status.choices
|
for status, label in Application.Status.choices
|
||||||
]
|
]
|
||||||
|
context["status_choices"] = Application.Status.choices
|
||||||
|
context["open_count"] = sum(
|
||||||
|
item.status not in {Application.Status.REJECTED, Application.Status.WITHDRAWN}
|
||||||
|
for item in applications
|
||||||
|
)
|
||||||
|
context["follow_up_count"] = sum(
|
||||||
|
bool(item.follow_up_date and item.follow_up_date <= timezone.localdate())
|
||||||
|
for item in applications
|
||||||
|
)
|
||||||
|
context["today"] = timezone.localdate()
|
||||||
return context
|
return context
|
||||||
|
|
||||||
|
|
||||||
@@ -147,7 +337,11 @@ class ApplicationUpdateView(LoginRequiredMixin, UpdateView):
|
|||||||
template_name = "applications/edit.html"
|
template_name = "applications/edit.html"
|
||||||
|
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
return Application.objects.filter(user=self.request.user).select_related("job")
|
return (
|
||||||
|
Application.objects.filter(user=self.request.user)
|
||||||
|
.select_related("job", "job__employer")
|
||||||
|
.prefetch_related("timeline_events")
|
||||||
|
)
|
||||||
|
|
||||||
def form_valid(self, form):
|
def form_valid(self, form):
|
||||||
previous = (
|
previous = (
|
||||||
@@ -185,6 +379,23 @@ def application_export(request, pk: int):
|
|||||||
return response
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
@require_POST
|
||||||
|
def application_status(request, pk: int):
|
||||||
|
application = get_object_or_404(Application, pk=pk, user=request.user)
|
||||||
|
try:
|
||||||
|
change_application_status(
|
||||||
|
application=application,
|
||||||
|
user=request.user,
|
||||||
|
status=request.POST.get("status", ""),
|
||||||
|
)
|
||||||
|
except ValueError as exc:
|
||||||
|
messages.error(request, str(exc))
|
||||||
|
else:
|
||||||
|
messages.success(request, "Pipelinestatus bijgewerkt.")
|
||||||
|
return redirect("jobs:applications")
|
||||||
|
|
||||||
|
|
||||||
@login_required
|
@login_required
|
||||||
def application_print(request, pk: int):
|
def application_print(request, pk: int):
|
||||||
application = get_object_or_404(
|
application = get_object_or_404(
|
||||||
|
|||||||
@@ -3,13 +3,12 @@ from __future__ import annotations
|
|||||||
|
|
||||||
def default_weights() -> dict[str, float]:
|
def default_weights() -> dict[str, float]:
|
||||||
return {
|
return {
|
||||||
"content": 25.0,
|
"content": 30.0,
|
||||||
"skills": 20.0,
|
"skills": 25.0,
|
||||||
"location": 15.0,
|
"location": 20.0,
|
||||||
"conditions": 10.0,
|
"conditions": 10.0,
|
||||||
"employer": 10.0,
|
"employer": 10.0,
|
||||||
"seniority": 10.0,
|
"preferences": 5.0,
|
||||||
"preferences": 10.0,
|
|
||||||
"ai": 0.0,
|
"ai": 0.0,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,55 +5,12 @@ import re
|
|||||||
from django import forms
|
from django import forms
|
||||||
|
|
||||||
from .models import SearchProfile
|
from .models import SearchProfile
|
||||||
|
from .taxonomy import (
|
||||||
TITLE_CHOICES = (
|
EXCLUDED_SKILL_CHOICES,
|
||||||
("system engineer", "System engineer"),
|
EXCLUDED_TITLE_CHOICES,
|
||||||
("infrastructure engineer", "Infrastructure engineer"),
|
SKILL_CHOICES,
|
||||||
("cloud engineer", "Cloud engineer"),
|
TITLE_CHOICES,
|
||||||
("devops engineer", "DevOps engineer"),
|
iter_choices,
|
||||||
("network engineer", "Network engineer"),
|
|
||||||
("security engineer", "Security engineer"),
|
|
||||||
("workplace engineer", "Workplace engineer"),
|
|
||||||
("support engineer", "Support engineer"),
|
|
||||||
("software engineer", "Software engineer"),
|
|
||||||
("data engineer", "Data engineer"),
|
|
||||||
("solution architect", "Solution architect"),
|
|
||||||
("project manager", "Projectmanager"),
|
|
||||||
)
|
|
||||||
|
|
||||||
EXCLUDED_TITLE_CHOICES = (
|
|
||||||
("sales", "Sales"),
|
|
||||||
("recruiter", "Recruiter"),
|
|
||||||
("account manager", "Accountmanager"),
|
|
||||||
("callcenter", "Callcenter"),
|
|
||||||
("stage", "Stage"),
|
|
||||||
("student", "Studentenjob"),
|
|
||||||
)
|
|
||||||
|
|
||||||
SKILL_CHOICES = (
|
|
||||||
("azure", "Microsoft Azure"),
|
|
||||||
("aws", "AWS"),
|
|
||||||
("microsoft 365", "Microsoft 365"),
|
|
||||||
("active directory", "Active Directory"),
|
|
||||||
("entra id", "Entra ID"),
|
|
||||||
("intune", "Microsoft Intune"),
|
|
||||||
("linux", "Linux"),
|
|
||||||
("windows server", "Windows Server"),
|
|
||||||
("networking", "Netwerken"),
|
|
||||||
("security", "Security"),
|
|
||||||
("python", "Python"),
|
|
||||||
("powershell", "PowerShell"),
|
|
||||||
("terraform", "Terraform"),
|
|
||||||
("docker", "Docker"),
|
|
||||||
("kubernetes", "Kubernetes"),
|
|
||||||
("ci/cd", "CI/CD"),
|
|
||||||
)
|
|
||||||
|
|
||||||
EXCLUDED_SKILL_CHOICES = (
|
|
||||||
("cold calling", "Cold calling"),
|
|
||||||
("door to door", "Deur-aan-deurverkoop"),
|
|
||||||
("commission only", "Alleen commissieloon"),
|
|
||||||
("night shift", "Nachtwerk"),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
EMPLOYMENT_CHOICES = (
|
EMPLOYMENT_CHOICES = (
|
||||||
@@ -91,27 +48,31 @@ class SearchProfileForm(forms.ModelForm):
|
|||||||
label="Gewenste functietitels",
|
label="Gewenste functietitels",
|
||||||
required=False,
|
required=False,
|
||||||
choices=TITLE_CHOICES,
|
choices=TITLE_CHOICES,
|
||||||
widget=forms.CheckboxSelectMultiple,
|
widget=forms.CheckboxSelectMultiple(attrs={"class": "grouped-choices"}),
|
||||||
help_text="Selecteer alle rollen die bij je zoekrichting passen.",
|
help_text="Selecteer alle rollen die bij je zoekrichting passen; de beste titelmatch telt.",
|
||||||
)
|
)
|
||||||
excluded_titles = forms.MultipleChoiceField(
|
excluded_titles = forms.MultipleChoiceField(
|
||||||
label="Uitgesloten functietitels",
|
label="Uitgesloten functietitels",
|
||||||
required=False,
|
required=False,
|
||||||
choices=EXCLUDED_TITLE_CHOICES,
|
choices=EXCLUDED_TITLE_CHOICES,
|
||||||
widget=forms.CheckboxSelectMultiple,
|
widget=forms.CheckboxSelectMultiple(attrs={"class": "grouped-choices"}),
|
||||||
help_text="Vacatures met deze titelwoorden worden hard uitgesloten.",
|
help_text="Vacatures met deze titelwoorden worden hard uitgesloten.",
|
||||||
)
|
)
|
||||||
desired_skills = forms.MultipleChoiceField(
|
desired_skills = forms.MultipleChoiceField(
|
||||||
label="Gewenste skills",
|
label="Gewenste skills",
|
||||||
required=False,
|
required=False,
|
||||||
choices=SKILL_CHOICES,
|
choices=SKILL_CHOICES,
|
||||||
widget=forms.CheckboxSelectMultiple,
|
widget=forms.CheckboxSelectMultiple(attrs={"class": "grouped-choices"}),
|
||||||
|
help_text=(
|
||||||
|
"Kies je herkenbare cv-skills ruim. Maximaal vier aangetroffen skills volstaan voor "
|
||||||
|
"de volledige skillcomponent; extra keuzes verwateren je score niet."
|
||||||
|
),
|
||||||
)
|
)
|
||||||
excluded_skills = forms.MultipleChoiceField(
|
excluded_skills = forms.MultipleChoiceField(
|
||||||
label="Uitgesloten kenmerken",
|
label="Uitgesloten kenmerken",
|
||||||
required=False,
|
required=False,
|
||||||
choices=EXCLUDED_SKILL_CHOICES,
|
choices=EXCLUDED_SKILL_CHOICES,
|
||||||
widget=forms.CheckboxSelectMultiple,
|
widget=forms.CheckboxSelectMultiple(attrs={"class": "grouped-choices"}),
|
||||||
)
|
)
|
||||||
allowed_employment_types = forms.MultipleChoiceField(
|
allowed_employment_types = forms.MultipleChoiceField(
|
||||||
label="Toegestane contractvormen",
|
label="Toegestane contractvormen",
|
||||||
@@ -145,6 +106,7 @@ class SearchProfileForm(forms.ModelForm):
|
|||||||
"is_active",
|
"is_active",
|
||||||
"home_postal_code",
|
"home_postal_code",
|
||||||
"max_distance_km",
|
"max_distance_km",
|
||||||
|
"experience_years",
|
||||||
"desired_titles",
|
"desired_titles",
|
||||||
"excluded_titles",
|
"excluded_titles",
|
||||||
"desired_skills",
|
"desired_skills",
|
||||||
@@ -163,6 +125,7 @@ class SearchProfileForm(forms.ModelForm):
|
|||||||
"is_active": "Actief profiel",
|
"is_active": "Actief profiel",
|
||||||
"home_postal_code": "Thuispostcode",
|
"home_postal_code": "Thuispostcode",
|
||||||
"max_distance_km": "Maximale afstand (km)",
|
"max_distance_km": "Maximale afstand (km)",
|
||||||
|
"experience_years": "Relevante IT-ervaring",
|
||||||
"recommendation_threshold": "Aanbevelingsdrempel",
|
"recommendation_threshold": "Aanbevelingsdrempel",
|
||||||
"top_match_threshold": "Topmatchdrempel",
|
"top_match_threshold": "Topmatchdrempel",
|
||||||
"digest_time": "Tijdstip dagelijkse samenvatting",
|
"digest_time": "Tijdstip dagelijkse samenvatting",
|
||||||
@@ -173,6 +136,10 @@ class SearchProfileForm(forms.ModelForm):
|
|||||||
"Vier cijfers volstaan. Gemeente en coördinaten worden veilig afgeleid wanneer "
|
"Vier cijfers volstaan. Gemeente en coördinaten worden veilig afgeleid wanneer "
|
||||||
"lokale geodata beschikbaar is."
|
"lokale geodata beschikbaar is."
|
||||||
),
|
),
|
||||||
|
"experience_years": (
|
||||||
|
"Optionele profielcontext. Deze waarde telt nooit mee voor je matchscore en "
|
||||||
|
"kan een vacature niet uitsluiten."
|
||||||
|
),
|
||||||
"learning_enabled": "Past alleen zachte voorkeuren aan; harde regels blijven vast.",
|
"learning_enabled": "Past alleen zachte voorkeuren aan; harde regels blijven vast.",
|
||||||
}
|
}
|
||||||
widgets = {
|
widgets = {
|
||||||
@@ -203,14 +170,15 @@ class SearchProfileForm(forms.ModelForm):
|
|||||||
field = self.fields[field_name]
|
field = self.fields[field_name]
|
||||||
stored = getattr(self.instance, field_name, [])
|
stored = getattr(self.instance, field_name, [])
|
||||||
existing_by_casefold = {
|
existing_by_casefold = {
|
||||||
str(value).casefold(): str(value) for value, _label in field.choices
|
str(value).casefold(): str(value) for value, _label in iter_choices(field.choices)
|
||||||
}
|
}
|
||||||
extra = [
|
extra = [
|
||||||
(str(value), str(value))
|
(str(value), str(value))
|
||||||
for value in stored
|
for value in stored
|
||||||
if str(value).casefold() not in existing_by_casefold
|
if str(value).casefold() not in existing_by_casefold
|
||||||
]
|
]
|
||||||
field.choices = [*field.choices, *extra]
|
if extra:
|
||||||
|
field.choices = [*field.choices, ("Eerder opgeslagen", tuple(extra))]
|
||||||
field.initial = [
|
field.initial = [
|
||||||
existing_by_casefold.get(str(value).casefold(), str(value)) for value in stored
|
existing_by_casefold.get(str(value).casefold(), str(value)) for value in stored
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 5.2.16 on 2026-07-22 13:52
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('profiles', '0004_reminder_settings'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='searchprofile',
|
||||||
|
name='experience_years',
|
||||||
|
field=models.PositiveSmallIntegerField(choices=[(0, 'Niet ingesteld'), (1, 'Minder dan 2 jaar'), (3, '2 tot 4 jaar'), (5, '5 tot 7 jaar'), (8, '8 tot 9 jaar'), (10, '10 tot 14 jaar'), (15, '15 jaar of meer')], default=0),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
def remove_seniority_weight(apps, schema_editor):
|
||||||
|
search_profile = apps.get_model("profiles", "SearchProfile")
|
||||||
|
for profile in search_profile.objects.all().iterator():
|
||||||
|
weights = dict(profile.weights or {})
|
||||||
|
if "seniority" not in weights:
|
||||||
|
continue
|
||||||
|
weights.pop("seniority", None)
|
||||||
|
profile.weights = weights
|
||||||
|
profile.save(update_fields=["weights"])
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("profiles", "0005_searchprofile_experience_years"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RunPython(remove_seniority_weight, migrations.RunPython.noop),
|
||||||
|
]
|
||||||
@@ -16,6 +16,16 @@ from .defaults import (
|
|||||||
default_weights,
|
default_weights,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
EXPERIENCE_YEARS_CHOICES = (
|
||||||
|
(0, "Niet ingesteld"),
|
||||||
|
(1, "Minder dan 2 jaar"),
|
||||||
|
(3, "2 tot 4 jaar"),
|
||||||
|
(5, "5 tot 7 jaar"),
|
||||||
|
(8, "8 tot 9 jaar"),
|
||||||
|
(10, "10 tot 14 jaar"),
|
||||||
|
(15, "15 jaar of meer"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class SearchProfile(TimeStampedModel):
|
class SearchProfile(TimeStampedModel):
|
||||||
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE)
|
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE)
|
||||||
@@ -29,6 +39,10 @@ class SearchProfile(TimeStampedModel):
|
|||||||
home_latitude = models.DecimalField(max_digits=9, decimal_places=6, null=True, blank=True)
|
home_latitude = models.DecimalField(max_digits=9, decimal_places=6, null=True, blank=True)
|
||||||
home_longitude = models.DecimalField(max_digits=9, decimal_places=6, null=True, blank=True)
|
home_longitude = models.DecimalField(max_digits=9, decimal_places=6, null=True, blank=True)
|
||||||
max_distance_km = models.PositiveIntegerField(default=45)
|
max_distance_km = models.PositiveIntegerField(default=45)
|
||||||
|
experience_years = models.PositiveSmallIntegerField(
|
||||||
|
choices=EXPERIENCE_YEARS_CHOICES,
|
||||||
|
default=0,
|
||||||
|
)
|
||||||
|
|
||||||
desired_titles = models.JSONField(default=list, blank=True)
|
desired_titles = models.JSONField(default=list, blank=True)
|
||||||
excluded_titles = models.JSONField(default=list, blank=True)
|
excluded_titles = models.JSONField(default=list, blank=True)
|
||||||
@@ -105,6 +119,7 @@ class SearchProfile(TimeStampedModel):
|
|||||||
if self.home_longitude is not None
|
if self.home_longitude is not None
|
||||||
else None,
|
else None,
|
||||||
"max_distance_km": self.max_distance_km,
|
"max_distance_km": self.max_distance_km,
|
||||||
|
"experience_years": self.experience_years,
|
||||||
"desired_titles": self.desired_titles,
|
"desired_titles": self.desired_titles,
|
||||||
"excluded_titles": self.excluded_titles,
|
"excluded_titles": self.excluded_titles,
|
||||||
"desired_skills": self.desired_skills,
|
"desired_skills": self.desired_skills,
|
||||||
|
|||||||
@@ -0,0 +1,316 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Iterable, Iterator, Sequence
|
||||||
|
|
||||||
|
Choice = tuple[str, str]
|
||||||
|
ChoiceGroup = tuple[str, tuple[Choice, ...]]
|
||||||
|
|
||||||
|
|
||||||
|
TITLE_CHOICES: tuple[ChoiceGroup, ...] = (
|
||||||
|
(
|
||||||
|
"Field service & support",
|
||||||
|
(
|
||||||
|
("it field engineer", "IT Field Engineer"),
|
||||||
|
("field service engineer", "Field Service Engineer"),
|
||||||
|
("it engineer", "IT Engineer"),
|
||||||
|
("it support engineer", "IT Support Engineer"),
|
||||||
|
("support engineer", "Support Engineer"),
|
||||||
|
("onsite support engineer", "On-site Support Engineer"),
|
||||||
|
("service desk engineer", "Service Desk Engineer"),
|
||||||
|
("technical support engineer", "Technical Support Engineer"),
|
||||||
|
("it technician", "IT Technician"),
|
||||||
|
("pc technician", "PC Technician"),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"Systemen & infrastructuur",
|
||||||
|
(
|
||||||
|
("system engineer", "System Engineer"),
|
||||||
|
("system network engineer", "System & Network Engineer"),
|
||||||
|
("infrastructure engineer", "Infrastructure Engineer"),
|
||||||
|
("system administrator", "System Administrator"),
|
||||||
|
("systeembeheerder", "Systeembeheerder"),
|
||||||
|
("network engineer", "Network Engineer"),
|
||||||
|
("network administrator", "Network Administrator"),
|
||||||
|
("netwerkbeheerder", "Netwerkbeheerder"),
|
||||||
|
("infrastructure consultant", "Infrastructure Consultant"),
|
||||||
|
("implementation engineer", "Implementation Engineer"),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"Modern Workplace & endpoint",
|
||||||
|
(
|
||||||
|
("workplace engineer", "Workplace Engineer"),
|
||||||
|
("modern workplace engineer", "Modern Workplace Engineer"),
|
||||||
|
("microsoft 365 engineer", "Microsoft 365 Engineer"),
|
||||||
|
("microsoft 365 consultant", "Microsoft 365 Consultant"),
|
||||||
|
("modern workplace consultant", "Modern Workplace Consultant"),
|
||||||
|
("endpoint engineer", "Endpoint Engineer"),
|
||||||
|
("intune engineer", "Intune Engineer"),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"Andere IT-richtingen",
|
||||||
|
(
|
||||||
|
("cloud engineer", "Cloud Engineer"),
|
||||||
|
("devops engineer", "DevOps Engineer"),
|
||||||
|
("security engineer", "Security Engineer"),
|
||||||
|
("platform engineer", "Platform Engineer"),
|
||||||
|
("software engineer", "Software Engineer"),
|
||||||
|
("data engineer", "Data Engineer"),
|
||||||
|
("solution architect", "Solution Architect"),
|
||||||
|
("project manager", "IT-projectmanager"),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
EXCLUDED_TITLE_CHOICES: tuple[ChoiceGroup, ...] = (
|
||||||
|
(
|
||||||
|
"Commercieel & werving",
|
||||||
|
(
|
||||||
|
("sales", "Sales"),
|
||||||
|
("recruiter", "Recruiter"),
|
||||||
|
("account manager", "Accountmanager"),
|
||||||
|
("business developer", "Business Developer"),
|
||||||
|
("callcenter", "Callcenter"),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"Software, data & analyse",
|
||||||
|
(
|
||||||
|
("software developer", "Software Developer"),
|
||||||
|
("software engineer", "Software Engineer"),
|
||||||
|
("web developer", "Web Developer"),
|
||||||
|
("developer", "Developmentfuncties (breed)"),
|
||||||
|
("software", "Softwarefuncties (breed)"),
|
||||||
|
("backend", "Backendfuncties"),
|
||||||
|
("frontend", "Frontendfuncties"),
|
||||||
|
("php", "PHP-ontwikkeling"),
|
||||||
|
("data engineer", "Data Engineer"),
|
||||||
|
("data scientist", "Data Scientist"),
|
||||||
|
("data", "Datafuncties (breed)"),
|
||||||
|
("ai engineer", "AI Engineer"),
|
||||||
|
("ai", "AI-functies (breed)"),
|
||||||
|
("machine learning", "Machine Learning"),
|
||||||
|
("functional analyst", "Functional Analyst"),
|
||||||
|
("functioneel analist", "Functioneel Analist"),
|
||||||
|
("business analyst", "Business Analyst"),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"Management & starters",
|
||||||
|
(
|
||||||
|
("product owner", "Product Owner"),
|
||||||
|
("project manager", "Projectmanager"),
|
||||||
|
("architect", "Architectrollen"),
|
||||||
|
("service delivery manager", "Service Delivery Manager"),
|
||||||
|
("team lead", "Teamlead"),
|
||||||
|
("head of", "Head-of-rollen"),
|
||||||
|
("procurement", "Procurement"),
|
||||||
|
("stage", "Stage"),
|
||||||
|
("internship", "Internship"),
|
||||||
|
("student", "Studentenjob"),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"Niet-IT-techniek",
|
||||||
|
(
|
||||||
|
("electromechanical", "Elektromechanica"),
|
||||||
|
("maintenance technician", "Onderhoudstechnieker"),
|
||||||
|
("process engineer", "Process Engineer"),
|
||||||
|
("automation engineer", "Automation Engineer"),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"Onderzoek & beeldverwerking",
|
||||||
|
(
|
||||||
|
("research", "Onderzoeksfuncties"),
|
||||||
|
("scientist", "Scientist"),
|
||||||
|
("image processing", "Beeldverwerking"),
|
||||||
|
("remote sensing", "Remote sensing"),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"Cloud & businessplatformen",
|
||||||
|
(
|
||||||
|
("cloud", "Cloudfuncties (breed)"),
|
||||||
|
("devops", "DevOps"),
|
||||||
|
("azure consultant", "Azure Consultant"),
|
||||||
|
("sap", "SAP"),
|
||||||
|
("power platform", "Power Platform"),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"Onderwijs",
|
||||||
|
(
|
||||||
|
("lecturer", "Lecturer"),
|
||||||
|
("lector", "Lector"),
|
||||||
|
("docent", "Docent"),
|
||||||
|
("gastdocent", "Gastdocent"),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
SKILL_CHOICES: tuple[ChoiceGroup, ...] = (
|
||||||
|
(
|
||||||
|
"Field service & uitvoering",
|
||||||
|
(
|
||||||
|
("troubleshooting", "Troubleshooting"),
|
||||||
|
("onsite support", "On-site support"),
|
||||||
|
("hardware", "Hardware & randapparatuur"),
|
||||||
|
("installations", "Installaties & roll-outs"),
|
||||||
|
("migrations", "Migraties"),
|
||||||
|
("technical documentation", "Technische documentatie"),
|
||||||
|
("customer support", "Klantondersteuning"),
|
||||||
|
("second line support", "Tweede- en derdelijnssupport"),
|
||||||
|
("customer experience", "Klanttevredenheid / CSAT"),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"Microsoft & endpoint",
|
||||||
|
(
|
||||||
|
("microsoft 365", "Microsoft 365"),
|
||||||
|
("exchange online", "Exchange Online"),
|
||||||
|
("sharepoint", "SharePoint"),
|
||||||
|
("microsoft teams", "Microsoft Teams"),
|
||||||
|
("entra id", "Entra ID"),
|
||||||
|
("intune", "Microsoft Intune"),
|
||||||
|
("autopilot", "Windows Autopilot"),
|
||||||
|
("windows server", "Windows Server"),
|
||||||
|
("active directory", "Active Directory"),
|
||||||
|
("group policy", "Group Policy / GPO"),
|
||||||
|
("dynamics 365", "Microsoft Dynamics 365"),
|
||||||
|
("microsoft copilot", "Microsoft Copilot"),
|
||||||
|
("microsoft defender", "Microsoft Defender"),
|
||||||
|
("microsoft sentinel", "Microsoft Sentinel"),
|
||||||
|
("conditional access", "Conditional Access"),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"Netwerk & security",
|
||||||
|
(
|
||||||
|
("networking", "Netwerken"),
|
||||||
|
("tcp/ip", "TCP/IP"),
|
||||||
|
("vlan", "VLAN"),
|
||||||
|
("vpn", "VPN"),
|
||||||
|
("dhcp", "DHCP"),
|
||||||
|
("dns", "DNS"),
|
||||||
|
("firewalls", "Firewalls"),
|
||||||
|
("watchguard", "WatchGuard"),
|
||||||
|
("cisco", "Cisco"),
|
||||||
|
("ubiquiti", "Ubiquiti"),
|
||||||
|
("wi-fi", "Wi-Fi"),
|
||||||
|
("routing", "Routing"),
|
||||||
|
("switching", "Switching"),
|
||||||
|
("sd-wan", "SD-WAN"),
|
||||||
|
("fortinet", "Fortinet / FortiGate"),
|
||||||
|
("palo alto", "Palo Alto Networks"),
|
||||||
|
("aruba", "HPE Aruba"),
|
||||||
|
("meraki", "Cisco Meraki"),
|
||||||
|
("sophos", "Sophos"),
|
||||||
|
("zero trust", "Zero Trust"),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"Back-up & monitoring",
|
||||||
|
(
|
||||||
|
("backup and restore", "Back-up & restore"),
|
||||||
|
("veeam", "Veeam"),
|
||||||
|
("windows server backup", "Windows Server Backup"),
|
||||||
|
("synology", "Synology"),
|
||||||
|
("zabbix", "Zabbix"),
|
||||||
|
("monitoring", "Infrastructuurmonitoring"),
|
||||||
|
("observability", "Observability"),
|
||||||
|
("high availability", "High availability"),
|
||||||
|
("disaster recovery", "Disaster recovery"),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"Virtualisatie & platform",
|
||||||
|
(
|
||||||
|
("vmware", "VMware"),
|
||||||
|
("proxmox", "Proxmox"),
|
||||||
|
("hyper-v", "Hyper-V"),
|
||||||
|
("docker", "Docker"),
|
||||||
|
("azure", "Microsoft Azure"),
|
||||||
|
("aws", "AWS"),
|
||||||
|
("linux", "Linux"),
|
||||||
|
("kubernetes", "Kubernetes"),
|
||||||
|
("terraform", "Terraform"),
|
||||||
|
("ci/cd", "CI/CD"),
|
||||||
|
("deployment", "Deployment & uitrol"),
|
||||||
|
("reliability", "Platform reliability"),
|
||||||
|
("scalability", "Schaalbaarheid"),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"VoIP & telefonie",
|
||||||
|
(
|
||||||
|
("voip", "VoIP"),
|
||||||
|
("3cx", "3CX"),
|
||||||
|
("innovaphone", "Innovaphone"),
|
||||||
|
("teams telephony", "Teams-telefonie"),
|
||||||
|
("yealink", "Yealink"),
|
||||||
|
("snom", "Snom"),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"Automation & scripting",
|
||||||
|
(
|
||||||
|
("powershell", "PowerShell"),
|
||||||
|
("bash", "Bash"),
|
||||||
|
("python", "Python"),
|
||||||
|
("c#", "C#"),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"IT-servicemanagement",
|
||||||
|
(
|
||||||
|
("itil", "ITIL / IT-servicemanagement"),
|
||||||
|
("incident management", "Incidentmanagement"),
|
||||||
|
("problem management", "Problemmanagement"),
|
||||||
|
("change management", "Changemanagement"),
|
||||||
|
("servicenow", "ServiceNow"),
|
||||||
|
("jira service management", "Jira Service Management"),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"Digitale werkplek & adoptie",
|
||||||
|
(
|
||||||
|
("digital workplace", "Digital Workplace"),
|
||||||
|
("m365 governance", "Microsoft 365-governance"),
|
||||||
|
("document management", "Documentmanagement"),
|
||||||
|
("data governance", "Datagovernance"),
|
||||||
|
("user adoption", "Gebruikersadoptie"),
|
||||||
|
("training and workshops", "Training & workshops"),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
EXCLUDED_SKILL_CHOICES: tuple[ChoiceGroup, ...] = (
|
||||||
|
(
|
||||||
|
"Commerciële voorwaarden",
|
||||||
|
(
|
||||||
|
("cold calling", "Cold calling"),
|
||||||
|
("door to door", "Deur-aan-deurverkoop"),
|
||||||
|
("commission only", "Alleen commissieloon"),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"Werkregeling",
|
||||||
|
(
|
||||||
|
("night shift", "Nachtwerk"),
|
||||||
|
("rotating shifts", "Ploegendienst"),
|
||||||
|
("on-call duty", "Structurele wachtdienst"),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def iter_choices(choices: Iterable[Choice | tuple[str, Sequence[Choice]]]) -> Iterator[Choice]:
|
||||||
|
"""Flatten Django optgroups while retaining compatibility with legacy flat choices."""
|
||||||
|
for value, label_or_choices in choices:
|
||||||
|
if isinstance(label_or_choices, tuple | list):
|
||||||
|
yield from iter_choices(label_or_choices)
|
||||||
|
else:
|
||||||
|
yield str(value), str(label_or_choices)
|
||||||
@@ -6,6 +6,8 @@ from django.shortcuts import get_object_or_404, redirect
|
|||||||
from django.urls import reverse_lazy
|
from django.urls import reverse_lazy
|
||||||
from django.views.generic import ListView, UpdateView
|
from django.views.generic import ListView, UpdateView
|
||||||
|
|
||||||
|
from apps.jobs.models import JobPosting, ScoreRun
|
||||||
|
|
||||||
from .forms import SearchProfileForm
|
from .forms import SearchProfileForm
|
||||||
from .models import SearchProfile
|
from .models import SearchProfile
|
||||||
from .services import resolve_profile_home_location, save_profile_revision
|
from .services import resolve_profile_home_location, save_profile_revision
|
||||||
@@ -29,6 +31,21 @@ class ProfileUpdateView(LoginRequiredMixin, UpdateView):
|
|||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
return SearchProfile.objects.filter(user=self.request.user)
|
return SearchProfile.objects.filter(user=self.request.user)
|
||||||
|
|
||||||
|
def get_context_data(self, **kwargs):
|
||||||
|
context = super().get_context_data(**kwargs)
|
||||||
|
context["preview_score"] = (
|
||||||
|
ScoreRun.objects.filter(
|
||||||
|
profile=self.object,
|
||||||
|
job__status=JobPosting.Status.ACTIVE,
|
||||||
|
hard_exclusions=[],
|
||||||
|
)
|
||||||
|
.select_related("job", "job__employer")
|
||||||
|
.order_by("-created_at")
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
context["recent_revisions"] = list(self.object.revisions.all()[:4])
|
||||||
|
return context
|
||||||
|
|
||||||
def form_valid(self, form):
|
def form_valid(self, form):
|
||||||
response = super().form_valid(form)
|
response = super().form_valid(form)
|
||||||
location_result = resolve_profile_home_location(self.object)
|
location_result = resolve_profile_home_location(self.object)
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import hashlib
|
import hashlib
|
||||||
|
import ipaddress
|
||||||
from collections.abc import Mapping
|
from collections.abc import Mapping
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
@@ -53,6 +54,38 @@ class FetchTimeoutError(FetchError):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _connected_peer_ip(
|
||||||
|
response: httpx.Response,
|
||||||
|
) -> ipaddress.IPv4Address | ipaddress.IPv6Address | None:
|
||||||
|
"""Return the actual remote peer when httpcore exposes its network stream."""
|
||||||
|
stream = response.extensions.get("network_stream")
|
||||||
|
if stream is None or not hasattr(stream, "get_extra_info"):
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
server_address = stream.get_extra_info("server_addr")
|
||||||
|
raw_address = server_address[0] if server_address else None
|
||||||
|
return ipaddress.ip_address(raw_address) if raw_address else None
|
||||||
|
except (IndexError, TypeError, ValueError, OSError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_connected_peer(
|
||||||
|
response: httpx.Response,
|
||||||
|
*,
|
||||||
|
before: ValidatedUrl,
|
||||||
|
after: ValidatedUrl,
|
||||||
|
) -> None:
|
||||||
|
peer_ip = _connected_peer_ip(response)
|
||||||
|
if peer_ip is not None:
|
||||||
|
if not peer_ip.is_global:
|
||||||
|
raise FetchError(f"Niet-publiek verbonden IP-adres geblokkeerd: {peer_ip}")
|
||||||
|
return
|
||||||
|
# Mock/custom transports do not always expose the peer socket. Keep the conservative
|
||||||
|
# DNS-overlap fallback for those transports.
|
||||||
|
if not set(before.addresses).intersection(after.addresses):
|
||||||
|
raise FetchError("DNS-rebindcontrole faalde bij het benaderen van bron.")
|
||||||
|
|
||||||
|
|
||||||
def parse_retry_after(value: str | None) -> int | None:
|
def parse_retry_after(value: str | None) -> int | None:
|
||||||
if not value:
|
if not value:
|
||||||
return None
|
return None
|
||||||
@@ -127,26 +160,22 @@ def fetch_url(
|
|||||||
verify=trusted_tls_context(),
|
verify=trusted_tls_context(),
|
||||||
)
|
)
|
||||||
current_url = url
|
current_url = url
|
||||||
previous_validation: ValidatedUrl | None = None
|
|
||||||
try:
|
try:
|
||||||
for _ in range(settings.FETCHER_MAX_REDIRECTS + 1):
|
for _ in range(settings.FETCHER_MAX_REDIRECTS + 1):
|
||||||
validation = validate_public_url(
|
validation = validate_public_url(
|
||||||
current_url,
|
current_url,
|
||||||
allow_nonstandard_ports=settings.FETCHER_ALLOW_NONSTANDARD_PORTS,
|
allow_nonstandard_ports=settings.FETCHER_ALLOW_NONSTANDARD_PORTS,
|
||||||
)
|
)
|
||||||
if (
|
|
||||||
previous_validation is not None
|
|
||||||
and validation.hostname == previous_validation.hostname
|
|
||||||
and not set(validation.addresses).intersection(previous_validation.addresses)
|
|
||||||
):
|
|
||||||
raise FetchError("DNS-rebindcontrole faalde bij redirect naar dezelfde host.")
|
|
||||||
response = http_client.get(current_url, headers=headers)
|
response = http_client.get(current_url, headers=headers)
|
||||||
post_validation = validate_public_url(
|
post_validation = validate_public_url(
|
||||||
str(response.url) if response.url else current_url,
|
str(response.url) if response.url else current_url,
|
||||||
allow_nonstandard_ports=settings.FETCHER_ALLOW_NONSTANDARD_PORTS,
|
allow_nonstandard_ports=settings.FETCHER_ALLOW_NONSTANDARD_PORTS,
|
||||||
)
|
)
|
||||||
if not set(validation.addresses).intersection(set(post_validation.addresses)):
|
_validate_connected_peer(
|
||||||
raise FetchError("DNS-rebindcontrole faalde bij het benaderen van bron.")
|
response,
|
||||||
|
before=validation,
|
||||||
|
after=post_validation,
|
||||||
|
)
|
||||||
if response.status_code in {301, 302, 303, 307, 308}:
|
if response.status_code in {301, 302, 303, 307, 308}:
|
||||||
location = response.headers.get("location")
|
location = response.headers.get("location")
|
||||||
if not location:
|
if not location:
|
||||||
@@ -155,7 +184,6 @@ def fetch_url(
|
|||||||
next_decision = assess_url(current_url, source=source)
|
next_decision = assess_url(current_url, source=source)
|
||||||
if not next_decision.allowed:
|
if not next_decision.allowed:
|
||||||
raise PolicyBlockedError(next_decision.reason)
|
raise PolicyBlockedError(next_decision.reason)
|
||||||
previous_validation = validation
|
|
||||||
continue
|
continue
|
||||||
if response.status_code == 304:
|
if response.status_code == 304:
|
||||||
return FetchedDocument(url, current_url, 304, dict(response.headers), b"")
|
return FetchedDocument(url, current_url, 304, dict(response.headers), b"")
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ from collections.abc import Iterable
|
|||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
from django.db.models import Prefetch
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
|
|
||||||
from apps.sources.models import Source, SourcePolicyReview, SourceRun
|
from apps.sources.models import Source, SourcePolicyReview, SourceRun
|
||||||
@@ -214,12 +215,17 @@ def _determine_health_action(runs: list[SourceRun]) -> tuple[str | None, str | N
|
|||||||
def collect_source_health(
|
def collect_source_health(
|
||||||
*, runs_to_consider: int = SOURCE_HEALTH_RUN_WINDOW
|
*, runs_to_consider: int = SOURCE_HEALTH_RUN_WINDOW
|
||||||
) -> list[SourceHealth]:
|
) -> list[SourceHealth]:
|
||||||
sources = Source.objects.order_by("name").all()
|
sources = Source.objects.prefetch_related(
|
||||||
|
Prefetch(
|
||||||
|
"runs",
|
||||||
|
queryset=SourceRun.objects.order_by("-started_at")[:runs_to_consider],
|
||||||
|
to_attr="health_runs",
|
||||||
|
)
|
||||||
|
).order_by("name")
|
||||||
rows: list[SourceHealth] = []
|
rows: list[SourceHealth] = []
|
||||||
|
|
||||||
for source in sources:
|
for source in sources:
|
||||||
run_queryset = SourceRun.objects.filter(source=source).order_by("-started_at")
|
runs = source.health_runs
|
||||||
runs = list(run_queryset[:runs_to_consider])
|
|
||||||
monitored_runs = len(runs)
|
monitored_runs = len(runs)
|
||||||
success_runs = [run for run in runs if run.status == SourceRun.Status.SUCCESS]
|
success_runs = [run for run in runs if run.status == SourceRun.Status.SUCCESS]
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ from django.conf import settings
|
|||||||
from django.contrib import messages
|
from django.contrib import messages
|
||||||
from django.contrib.auth.decorators import login_required
|
from django.contrib.auth.decorators import login_required
|
||||||
from django.contrib.auth.mixins import LoginRequiredMixin
|
from django.contrib.auth.mixins import LoginRequiredMixin
|
||||||
from django.db.models import Count, Q
|
from django.db.models import Count, Prefetch, Q
|
||||||
from django.http import HttpRequest
|
from django.http import HttpRequest
|
||||||
from django.shortcuts import get_object_or_404, redirect, render
|
from django.shortcuts import get_object_or_404, redirect, render
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
@@ -17,7 +17,7 @@ from apps.core.rate_limit import clear_rate_limit, is_rate_limited, register_rat
|
|||||||
from apps.profiles.models import SearchProfile
|
from apps.profiles.models import SearchProfile
|
||||||
|
|
||||||
from .forms import MailboxConnectionForm, ManualImportForm
|
from .forms import MailboxConnectionForm, ManualImportForm
|
||||||
from .models import MailboxConnection, Source, SourcePolicyReview
|
from .models import MailboxConnection, Source, SourcePolicyReview, SourceRun
|
||||||
from .platforms import PLATFORM_ALERTS
|
from .platforms import PLATFORM_ALERTS
|
||||||
from .services.mailbox_connections import MailboxCredentialError, save_mailbox_connection
|
from .services.mailbox_connections import MailboxCredentialError, save_mailbox_connection
|
||||||
from .services.manual_import import ManualImportError, ManualImportSummary, import_manual_source
|
from .services.manual_import import ManualImportError, ManualImportSummary, import_manual_source
|
||||||
@@ -27,7 +27,14 @@ from .tasks import fetch_source, poll_mailbox
|
|||||||
|
|
||||||
def _source_list_queryset():
|
def _source_list_queryset():
|
||||||
return (
|
return (
|
||||||
Source.objects.prefetch_related("policy_reviews")
|
Source.objects.prefetch_related(
|
||||||
|
"policy_reviews",
|
||||||
|
Prefetch(
|
||||||
|
"runs",
|
||||||
|
queryset=SourceRun.objects.order_by("-started_at")[:1],
|
||||||
|
to_attr="latest_runs",
|
||||||
|
),
|
||||||
|
)
|
||||||
.exclude(domain="jobs.example.org", status=Source.Status.DISABLED)
|
.exclude(domain="jobs.example.org", status=Source.Status.DISABLED)
|
||||||
.filter(
|
.filter(
|
||||||
Q(metadata__hidden_from_source_list__isnull=True)
|
Q(metadata__hidden_from_source_list__isnull=True)
|
||||||
@@ -69,6 +76,9 @@ def _manual_import_context(
|
|||||||
"active_source_count": Source.objects.filter(status=Source.Status.ACTIVE).count(),
|
"active_source_count": Source.objects.filter(status=Source.Status.ACTIVE).count(),
|
||||||
"source_total_count": base_queryset.count(),
|
"source_total_count": base_queryset.count(),
|
||||||
"review_pending_count": Source.objects.filter(policy=Source.Policy.REVIEW).count(),
|
"review_pending_count": Source.objects.filter(policy=Source.Policy.REVIEW).count(),
|
||||||
|
"failed_source_count": Source.objects.filter(
|
||||||
|
Q(status=Source.Status.QUARANTINED) | Q(failure_count__gt=0)
|
||||||
|
).count(),
|
||||||
"watchlist_sources": watchlist_queryset,
|
"watchlist_sources": watchlist_queryset,
|
||||||
"watchlist_count": watchlist_queryset.count(),
|
"watchlist_count": watchlist_queryset.count(),
|
||||||
"manual_import_form": form,
|
"manual_import_form": form,
|
||||||
|
|||||||
|
After Width: | Height: | Size: 73 KiB |
|
After Width: | Height: | Size: 74 KiB |
|
After Width: | Height: | Size: 97 KiB |
|
After Width: | Height: | Size: 110 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 91 KiB |
|
After Width: | Height: | Size: 104 KiB |
|
After Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 102 KiB |
|
After Width: | Height: | Size: 108 KiB |
|
After Width: | Height: | Size: 100 KiB |
@@ -190,6 +190,28 @@ LOGIN_URL = "login"
|
|||||||
LOGIN_REDIRECT_URL = "dashboard:today"
|
LOGIN_REDIRECT_URL = "dashboard:today"
|
||||||
LOGOUT_REDIRECT_URL = "login"
|
LOGOUT_REDIRECT_URL = "login"
|
||||||
|
|
||||||
|
# Inloggen kan met gebruikersnaam of e-mailadres. ModelBackend blijft als fallback.
|
||||||
|
AUTHENTICATION_BACKENDS = [
|
||||||
|
"apps.core.auth_backends.EmailOrUsernameModelBackend",
|
||||||
|
"django.contrib.auth.backends.ModelBackend",
|
||||||
|
]
|
||||||
|
PASSWORD_RESET_TIMEOUT = int(os.getenv("PASSWORD_RESET_TIMEOUT", str(3 * 24 * 3600)))
|
||||||
|
|
||||||
|
# Microsoft Entra ID (Azure AD) SSO. Uit tenzij expliciet geconfigureerd.
|
||||||
|
ENTRA_ID_ENABLED = env_bool("ENTRA_ID_ENABLED", False)
|
||||||
|
ENTRA_CLIENT_ID = os.getenv("ENTRA_CLIENT_ID", "")
|
||||||
|
ENTRA_CLIENT_SECRET = os.getenv("ENTRA_CLIENT_SECRET", "")
|
||||||
|
ENTRA_TENANT_ID = os.getenv("ENTRA_TENANT_ID", "")
|
||||||
|
ENTRA_REDIRECT_URI = os.getenv("ENTRA_REDIRECT_URI", "")
|
||||||
|
ENTRA_SCOPES = env_list("ENTRA_SCOPES", "User.Read")
|
||||||
|
ENTRA_ALLOWED_DOMAINS = env_list("ENTRA_ALLOWED_DOMAINS")
|
||||||
|
|
||||||
|
# Demo-toegang: logt in als afgeschermd gastaccount met een fictieve, gevulde
|
||||||
|
# demo-omgeving. Standaard aan zodat de knop 'Bekijk demo' zichtbaar is.
|
||||||
|
DEMO_MODE_ENABLED = env_bool("DEMO_MODE_ENABLED", True)
|
||||||
|
DEMO_USERNAME = os.getenv("DEMO_USERNAME", "demo")
|
||||||
|
DEMO_EMAIL = os.getenv("DEMO_EMAIL", "demo@vacatureradar.local")
|
||||||
|
|
||||||
EMAIL_BACKEND = os.getenv("EMAIL_BACKEND", "django.core.mail.backends.console.EmailBackend")
|
EMAIL_BACKEND = os.getenv("EMAIL_BACKEND", "django.core.mail.backends.console.EmailBackend")
|
||||||
DEFAULT_FROM_EMAIL = os.getenv("DEFAULT_FROM_EMAIL", "VacatureRadar <vacatureradar@localhost>")
|
DEFAULT_FROM_EMAIL = os.getenv("DEFAULT_FROM_EMAIL", "VacatureRadar <vacatureradar@localhost>")
|
||||||
EMAIL_HOST = os.getenv("SMTP_HOST", "")
|
EMAIL_HOST = os.getenv("SMTP_HOST", "")
|
||||||
@@ -227,6 +249,7 @@ CELERY_TASK_ALWAYS_EAGER = env_bool("CELERY_TASK_ALWAYS_EAGER", False)
|
|||||||
CELERY_TASK_EAGER_PROPAGATES = True
|
CELERY_TASK_EAGER_PROPAGATES = True
|
||||||
CELERY_TASK_ACKS_LATE = True
|
CELERY_TASK_ACKS_LATE = True
|
||||||
CELERY_WORKER_PREFETCH_MULTIPLIER = 1
|
CELERY_WORKER_PREFETCH_MULTIPLIER = 1
|
||||||
|
CELERY_TASK_DEFAULT_QUEUE = "default"
|
||||||
CELERY_TASK_TIME_LIMIT = 300
|
CELERY_TASK_TIME_LIMIT = 300
|
||||||
CELERY_TASK_SOFT_TIME_LIMIT = 270
|
CELERY_TASK_SOFT_TIME_LIMIT = 270
|
||||||
CELERY_TIMEZONE = TIME_ZONE
|
CELERY_TIMEZONE = TIME_ZONE
|
||||||
|
|||||||
@@ -4,11 +4,23 @@ from django.conf import settings
|
|||||||
from django.conf.urls.static import static
|
from django.conf.urls.static import static
|
||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
from django.contrib.auth import views as auth_views
|
from django.contrib.auth import views as auth_views
|
||||||
|
from django.templatetags.static import static as static_url
|
||||||
from django.urls import include, path
|
from django.urls import include, path
|
||||||
|
from django.views.generic import RedirectView
|
||||||
|
|
||||||
from apps.core.views import SecurityAwareLoginView
|
from apps.core.views import (
|
||||||
|
SecurityAwareLoginView,
|
||||||
|
demo_login,
|
||||||
|
entra_callback,
|
||||||
|
entra_login,
|
||||||
|
)
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
|
path(
|
||||||
|
"favicon.ico",
|
||||||
|
RedirectView.as_view(url=static_url("favicon.svg"), permanent=True),
|
||||||
|
name="favicon",
|
||||||
|
),
|
||||||
path("admin/", admin.site.urls),
|
path("admin/", admin.site.urls),
|
||||||
path(
|
path(
|
||||||
"login/",
|
"login/",
|
||||||
@@ -16,6 +28,31 @@ urlpatterns = [
|
|||||||
name="login",
|
name="login",
|
||||||
),
|
),
|
||||||
path("logout/", auth_views.LogoutView.as_view(), name="logout"),
|
path("logout/", auth_views.LogoutView.as_view(), name="logout"),
|
||||||
|
# Wachtwoordherstel (Django's ingebouwde flow, eigen templates).
|
||||||
|
path(
|
||||||
|
"wachtwoord/herstellen/",
|
||||||
|
auth_views.PasswordResetView.as_view(),
|
||||||
|
name="password_reset",
|
||||||
|
),
|
||||||
|
path(
|
||||||
|
"wachtwoord/herstellen/verzonden/",
|
||||||
|
auth_views.PasswordResetDoneView.as_view(),
|
||||||
|
name="password_reset_done",
|
||||||
|
),
|
||||||
|
path(
|
||||||
|
"wachtwoord/herstellen/<uidb64>/<token>/",
|
||||||
|
auth_views.PasswordResetConfirmView.as_view(),
|
||||||
|
name="password_reset_confirm",
|
||||||
|
),
|
||||||
|
path(
|
||||||
|
"wachtwoord/herstellen/klaar/",
|
||||||
|
auth_views.PasswordResetCompleteView.as_view(),
|
||||||
|
name="password_reset_complete",
|
||||||
|
),
|
||||||
|
# Entra ID SSO en demo-toegang.
|
||||||
|
path("auth/entra/login/", entra_login, name="entra-login"),
|
||||||
|
path("auth/entra/callback/", entra_callback, name="entra-callback"),
|
||||||
|
path("auth/demo/", demo_login, name="demo-login"),
|
||||||
path("", include(("apps.core.urls", "dashboard"), namespace="dashboard")),
|
path("", include(("apps.core.urls", "dashboard"), namespace="dashboard")),
|
||||||
path("jobs/", include(("apps.jobs.urls", "jobs"), namespace="jobs")),
|
path("jobs/", include(("apps.jobs.urls", "jobs"), namespace="jobs")),
|
||||||
path("profiles/", include(("apps.profiles.urls", "profiles"), namespace="profiles")),
|
path("profiles/", include(("apps.profiles.urls", "profiles"), namespace="profiles")),
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
# Unraid-installatiebestanden
|
# Unraid-installatiebestanden
|
||||||
|
|
||||||
De composebron staat in de repositoryroot als `docker-compose.unraid.yml`. Volg eerst [`docs/operations/UNRAID_DEPLOYMENT.md`](../../docs/operations/UNRAID_DEPLOYMENT.md).
|
De composebron staat in de repositoryroot als `docker-compose.unraid.yml`. Deze bouwt één Dockerman-container via `Dockerfile.unraid`; interne processen worden door Supervisor bewaakt. Volg eerst [`docs/operations/UNRAID_DEPLOYMENT.md`](../../docs/operations/UNRAID_DEPLOYMENT.md).
|
||||||
|
|
||||||
Deze directory is gereserveerd voor toekomstige Unraid-template-XML en release-specifieke voorbeelden. Commit hier geen ingevulde `.env`, app-password, registrypulltoken of databaseback-up.
|
Deze directory is gereserveerd voor toekomstige Unraid-template-XML en release-specifieke voorbeelden. Commit hier geen ingevulde `.env`, app-password, registrypulltoken of databaseback-up.
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
[supervisord]
|
||||||
|
nodaemon=true
|
||||||
|
logfile=/dev/null
|
||||||
|
logfile_maxbytes=0
|
||||||
|
pidfile=/tmp/supervisord.pid
|
||||||
|
|
||||||
|
[program:postgres]
|
||||||
|
command=/usr/lib/postgresql/17/bin/postgres -D /app/local/postgres-aio17
|
||||||
|
user=postgres
|
||||||
|
priority=10
|
||||||
|
autorestart=true
|
||||||
|
stdout_logfile=/dev/stdout
|
||||||
|
stdout_logfile_maxbytes=0
|
||||||
|
stderr_logfile=/dev/stderr
|
||||||
|
stderr_logfile_maxbytes=0
|
||||||
|
|
||||||
|
[program:redis]
|
||||||
|
command=/usr/bin/redis-server --dir /app/local/redis-aio --save 60 1 --loglevel warning --protected-mode yes --bind 127.0.0.1
|
||||||
|
user=redis
|
||||||
|
priority=20
|
||||||
|
autorestart=true
|
||||||
|
stdout_logfile=/dev/stdout
|
||||||
|
stdout_logfile_maxbytes=0
|
||||||
|
stderr_logfile=/dev/stderr
|
||||||
|
stderr_logfile_maxbytes=0
|
||||||
|
|
||||||
|
[program:web]
|
||||||
|
command=/app/.venv/bin/gunicorn config.wsgi:application --bind 0.0.0.0:8080 --workers 2 --threads 4 --timeout 90 --no-control-socket
|
||||||
|
directory=/app
|
||||||
|
user=app
|
||||||
|
priority=40
|
||||||
|
autorestart=true
|
||||||
|
stdout_logfile=/dev/stdout
|
||||||
|
stdout_logfile_maxbytes=0
|
||||||
|
stderr_logfile=/dev/stderr
|
||||||
|
stderr_logfile_maxbytes=0
|
||||||
|
|
||||||
|
[program:worker]
|
||||||
|
command=/app/.venv/bin/celery -A config worker -l INFO -Q high,default,low --concurrency=2
|
||||||
|
directory=/app
|
||||||
|
user=app
|
||||||
|
priority=50
|
||||||
|
autorestart=true
|
||||||
|
stdout_logfile=/dev/stdout
|
||||||
|
stdout_logfile_maxbytes=0
|
||||||
|
stderr_logfile=/dev/stderr
|
||||||
|
stderr_logfile_maxbytes=0
|
||||||
|
|
||||||
|
[program:scheduler]
|
||||||
|
command=/app/.venv/bin/celery -A config beat -l INFO --schedule /tmp/celerybeat-schedule
|
||||||
|
directory=/app
|
||||||
|
user=app
|
||||||
|
priority=60
|
||||||
|
autorestart=true
|
||||||
|
stdout_logfile=/dev/stdout
|
||||||
|
stdout_logfile_maxbytes=0
|
||||||
|
stderr_logfile=/dev/stderr
|
||||||
|
stderr_logfile_maxbytes=0
|
||||||
@@ -1,76 +1,38 @@
|
|||||||
# Compose-bron van waarheid met paden voor een Unraid-installatie.
|
# Eén Dockerman-container met intern bewaakte PostgreSQL-, Redis-, web- en Celery-processen.
|
||||||
# Lees deployment/unraid/README.md vóór gebruik en wijzig secrets.
|
|
||||||
name: vacatureradar
|
name: vacatureradar
|
||||||
|
|
||||||
x-app: &app
|
|
||||||
image: ghcr.io/CHANGE_ME/vacatureradar:latest
|
|
||||||
user: "10001:10001"
|
|
||||||
read_only: true
|
|
||||||
tmpfs:
|
|
||||||
- /tmp:rw,noexec,nosuid,size=128m
|
|
||||||
- /var/tmp:rw,noexec,nosuid,size=128m
|
|
||||||
- /app/.cache:rw,noexec,nosuid,size=128m
|
|
||||||
env_file:
|
|
||||||
- /mnt/user/appdata/vacatureradar/config/.env
|
|
||||||
restart: unless-stopped
|
|
||||||
security_opt:
|
|
||||||
- no-new-privileges:true
|
|
||||||
volumes:
|
|
||||||
- /mnt/user/appdata/vacatureradar/media:/app/media
|
|
||||||
- /mnt/user/appdata/vacatureradar/logs:/app/logs
|
|
||||||
- /mnt/user/appdata/vacatureradar/local:/app/local
|
|
||||||
networks: [internal]
|
|
||||||
depends_on:
|
|
||||||
postgres: {condition: service_healthy}
|
|
||||||
redis: {condition: service_healthy}
|
|
||||||
|
|
||||||
services:
|
services:
|
||||||
web:
|
app:
|
||||||
<<: *app
|
build:
|
||||||
ports: ["1226:8080"]
|
context: .
|
||||||
worker:
|
dockerfile: Dockerfile.unraid
|
||||||
<<: *app
|
image: vacatureradar:unraid
|
||||||
|
container_name: VacatureRadar
|
||||||
|
env_file:
|
||||||
|
- /mnt/user/appdata/vacatureradar/source/.env
|
||||||
environment:
|
environment:
|
||||||
VACATURERADAR_RUN_MIGRATIONS: "0"
|
DATABASE_URL: postgresql://vacatureradar:${POSTGRES_PASSWORD}@127.0.0.1:5432/vacatureradar
|
||||||
cap_drop:
|
POSTGRES_HOST: 127.0.0.1
|
||||||
- ALL
|
REDIS_URL: redis://127.0.0.1:6379/0
|
||||||
command: ["celery", "-A", "config", "worker", "-l", "INFO", "-Q", "high,default,low", "--concurrency=2"]
|
VACATURERADAR_RESTORE_DUMP: /app/local/backups/aio-migration.dump
|
||||||
scheduler:
|
|
||||||
<<: *app
|
|
||||||
environment:
|
|
||||||
VACATURERADAR_RUN_MIGRATIONS: "0"
|
|
||||||
cap_drop:
|
|
||||||
- ALL
|
|
||||||
command: ["celery", "-A", "config", "beat", "-l", "INFO", "--schedule", "/tmp/celerybeat-schedule"]
|
|
||||||
postgres:
|
|
||||||
image: postgres:17.5-bookworm
|
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
env_file: [/mnt/user/appdata/vacatureradar/config/.env]
|
security_opt:
|
||||||
volumes: [/mnt/user/appdata/vacatureradar/postgres:/var/lib/postgresql/data]
|
- no-new-privileges:true
|
||||||
networks: [internal]
|
ports:
|
||||||
|
- "1226:8080"
|
||||||
|
volumes:
|
||||||
|
- /mnt/user/appdata/vacatureradar/source/local/media:/app/media
|
||||||
|
- /mnt/user/appdata/vacatureradar/source/local/logs:/app/logs
|
||||||
|
- /mnt/user/appdata/vacatureradar/source/local:/app/local
|
||||||
|
labels:
|
||||||
|
net.unraid.docker.managed: dockerman
|
||||||
|
net.unraid.docker.webui: http://[IP]:[PORT:8080]/
|
||||||
|
net.unraid.docker.icon: http://[IP]:[PORT:8080]/static/favicon.svg
|
||||||
|
org.opencontainers.image.title: VacatureRadar
|
||||||
|
org.opencontainers.image.description: Persoonlijke vacature-intelligence voor de Belgische arbeidsmarkt
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "pg_isready -U vacatureradar -d vacatureradar"]
|
test: ["CMD", "python", "scripts/healthcheck.py", "http://127.0.0.1:8080/health/ready/"]
|
||||||
interval: 10s
|
interval: 30s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 10
|
retries: 5
|
||||||
redis:
|
start_period: 60s
|
||||||
image: redis:7.4.2-alpine
|
|
||||||
restart: unless-stopped
|
|
||||||
command: ["redis-server", "--save", "60", "1", "--loglevel", "warning"]
|
|
||||||
volumes: [/mnt/user/appdata/vacatureradar/redis:/data]
|
|
||||||
networks: [internal]
|
|
||||||
healthcheck:
|
|
||||||
test: ["CMD", "redis-cli", "ping"]
|
|
||||||
interval: 10s
|
|
||||||
timeout: 5s
|
|
||||||
retries: 10
|
|
||||||
ollama:
|
|
||||||
image: ollama/ollama:0.9.6
|
|
||||||
profiles: ["ai"]
|
|
||||||
restart: unless-stopped
|
|
||||||
volumes: [/mnt/user/appdata/vacatureradar/ollama:/root/.ollama]
|
|
||||||
networks: [internal]
|
|
||||||
|
|
||||||
networks:
|
|
||||||
internal:
|
|
||||||
driver: bridge
|
|
||||||
|
|||||||
@@ -1444,6 +1444,310 @@ tasks:
|
|||||||
note: GeoNames België postcodedataset 2026-04-03 onder CC BY 4.0 gereviewd, checksums vastgelegd, 2781 unieke
|
note: GeoNames België postcodedataset 2026-04-03 onder CC BY 4.0 gereviewd, checksums vastgelegd, 2781 unieke
|
||||||
rijen gevalideerd en 5562 lookuprecords geïmporteerd; 2400 Mol opgelost naar 51.1919, 5.1166 en actieve profielscores
|
rijen gevalideerd en 5562 lookuprecords geïmporteerd; 2400 Mol opgelost naar 51.1919, 5.1166 en actieve profielscores
|
||||||
herberekend.
|
herberekend.
|
||||||
|
- id: VR-206
|
||||||
|
title: Premium Stitch-cockpit en deterministische IT-focus
|
||||||
|
status: done
|
||||||
|
priority: P0
|
||||||
|
requirement_ids:
|
||||||
|
- PR-040
|
||||||
|
- PR-050
|
||||||
|
- PR-051
|
||||||
|
- NFR-004
|
||||||
|
- NFR-008
|
||||||
|
depends_on:
|
||||||
|
- VR-119
|
||||||
|
- VR-120
|
||||||
|
- VR-205
|
||||||
|
summary: Audit de volledige gebruikersflow in code en browser, voorkom dat niet-IT-vacatures de dagelijkse radar
|
||||||
|
vervuilen en benut de Stitch-compositie veel dominanter op mobiel, desktop en widescreen.
|
||||||
|
acceptance_criteria:
|
||||||
|
- Een deterministische en uitlegbare IT-relevantieregel heeft voorrang op scoring en AI; niet-IT-vacatures zijn
|
||||||
|
standaard afwezig uit Vandaag en de vacaturefeed maar blijven via een expliciete archiefkeuze inspecteerbaar.
|
||||||
|
- De vacaturefeed gebruikt echte score-evidence, bruikbare relevantie-/match-/sorteerfilters en toont standaard
|
||||||
|
de meest relevante IT-kansen in plaats van louter de nieuwste bronrecords.
|
||||||
|
- Dashboard, vacaturelijst en vacaturedetail nemen de bruikbare Stitch-panelhiërarchie, informatiedichtheid en
|
||||||
|
actiepatronen over zonder fictieve metrics, quick-apply of externe frontendafhankelijkheden.
|
||||||
|
- De shell en kernlayouts schalen inhoudelijk tussen 320 en 2560 px; widescreen voegt bruikbare kolommen/panelen
|
||||||
|
toe en laat de inhoud niet op een smalle 1280px-strook staan.
|
||||||
|
- Focus, headings, labels, lege toestanden, dark/light contrast, mobiele navigatie en reduced motion blijven toegankelijk;
|
||||||
|
kernroutes hebben geen horizontale paginaoverflow of browserconsolefouten.
|
||||||
|
verification:
|
||||||
|
- uv run pytest tests/unit/test_relevance.py tests/unit/test_scoring.py tests/integration/test_views.py tests/e2e/test_vr114_browser_and_accessibility.py
|
||||||
|
- ./scripts/codex_verify.sh
|
||||||
|
- Browsermatrix op 2560x1440, 1920x1080, 1440x900, 1024x768, 390x844 en 320x800
|
||||||
|
primary_paths:
|
||||||
|
- apps/jobs/services/relevance.py
|
||||||
|
- apps/jobs/services/scoring.py
|
||||||
|
- apps/jobs/views.py
|
||||||
|
- templates/dashboard/today.html
|
||||||
|
- templates/jobs/list.html
|
||||||
|
- templates/jobs/detail.html
|
||||||
|
- static/css/app.css
|
||||||
|
- docs/design/STITCH_INTEGRATION_PLAN.md
|
||||||
|
result:
|
||||||
|
completed_at: '2026-07-22'
|
||||||
|
note: 'Volledige code- en browseraudit: deterministische IT-focus, laatste gebruikersscore, premium Stitch-feed/detail
|
||||||
|
en adaptieve 320-2560px-layout; 225 tests groen en browsermatrix geslaagd.'
|
||||||
|
- id: VR-207
|
||||||
|
title: Cv-gestuurd field- en infrastructuurprofiel
|
||||||
|
status: done
|
||||||
|
priority: P0
|
||||||
|
requirement_ids:
|
||||||
|
- PR-002
|
||||||
|
- PR-040
|
||||||
|
- PR-041
|
||||||
|
- NFR-002
|
||||||
|
- NFR-004
|
||||||
|
depends_on:
|
||||||
|
- VR-206
|
||||||
|
summary: Breid de begeleide profielconfiguratie uit voor een ervaren IT field-/infrastructuurprofiel en voorkom
|
||||||
|
dat een brede, relevante skillset de matchscore kunstmatig verlaagt. Persoonlijke cv-data blijft uitsluitend
|
||||||
|
in de gebruikersdatabase en komt niet in repositoryfixtures of documentatie.
|
||||||
|
acceptance_criteria:
|
||||||
|
- Rollen en skills zijn in begrijpelijke categorieën selecteerbaar voor field service, infrastructuur, Microsoft
|
||||||
|
365/endpoint, netwerk/security, back-up/monitoring, virtualisatie, VoIP en automation.
|
||||||
|
- Relevante IT-ervaring is via een selectielijst configureerbaar en wordt deterministisch vergeleken met expliciet
|
||||||
|
gevraagde ervaringsjaren; onbekende ervaring leidt niet tot uitsluiting.
|
||||||
|
- Ambigue field-/implementatietitels gelden alleen als IT wanneer de vacaturetekst tevens een sterk technisch
|
||||||
|
contextsignaal bevat; niet-IT field service blijft gefilterd.
|
||||||
|
- Meer relevante cv-skills aanvinken verwatert de skillscore niet onbeperkt en niet-vermelde voorkeurskills worden
|
||||||
|
niet als bewezen tekort gepresenteerd.
|
||||||
|
- Bestaande vrije profielwaarden blijven selecteerbaar en persoonlijke gegevens uit cv's worden niet gecommit.
|
||||||
|
verification:
|
||||||
|
- uv run pytest tests/unit/test_relevance.py tests/unit/test_scoring.py tests/unit/test_profiles.py tests/integration/test_profile_preferences.py
|
||||||
|
- uv run python manage.py makemigrations --check --dry-run
|
||||||
|
- ./scripts/codex_verify.sh
|
||||||
|
primary_paths:
|
||||||
|
- apps/profiles/forms.py
|
||||||
|
- apps/profiles/models.py
|
||||||
|
- apps/profiles/taxonomy.py
|
||||||
|
- apps/jobs/services/relevance.py
|
||||||
|
- apps/jobs/services/scoring.py
|
||||||
|
- templates/profiles/edit.html
|
||||||
|
- static/css/app.css
|
||||||
|
result:
|
||||||
|
completed_at: '2026-07-22'
|
||||||
|
note: Cv-gestuurde gegroepeerde profielconfiguratie, ervaringsband, begrensde skill-evidence, contextgebonden
|
||||||
|
fieldrelevantie en lokale profielrescore geverifieerd; 232 tests groen en browsercontrole op ultrawide/390
|
||||||
|
px geslaagd.
|
||||||
|
- id: VR-208
|
||||||
|
title: Skillsvraag- en leerkansenradar zonder ervaringsscore
|
||||||
|
status: done
|
||||||
|
priority: P0
|
||||||
|
requirement_ids:
|
||||||
|
- PR-002
|
||||||
|
- PR-040
|
||||||
|
- PR-041
|
||||||
|
- PR-043
|
||||||
|
- PR-050
|
||||||
|
- NFR-004
|
||||||
|
depends_on:
|
||||||
|
- VR-207
|
||||||
|
summary: Maak ervaringsduur volledig informatief en bouw uit de actuele, relevante vacatures een uitlegbare marktvraaganalyse
|
||||||
|
met profieldekking, skillsfilters en concrete bijleerkansen.
|
||||||
|
acceptance_criteria:
|
||||||
|
- Ervaringsjaren en senioriteitslabels hebben geen invloed op score, aanbeveling of harde uitsluiting; de profielwaarde
|
||||||
|
is uitsluitend informatieve context en bestaande senioriteitsgewichten worden veilig genegeerd en opgeruimd.
|
||||||
|
- De analyse gebruikt alleen actieve IT-vacatures die voor het actieve profiel niet hard uitgesloten zijn en blijft
|
||||||
|
strikt gebruikersgescopeerd.
|
||||||
|
- Een deterministische, uitbreidbare skillcatalogus toont per technologie of capability in hoeveel vacatures een
|
||||||
|
expliciet tekstsignaal voorkomt en onderscheidt profieldekking van mogelijke leer-/bijscholingskansen.
|
||||||
|
- Het profiel biedt extra relevante skills als gegroepeerde checkboxes; de inzichtspagina biedt categorie- en
|
||||||
|
dekkingsfilters en links naar de onderliggende vacatures.
|
||||||
|
- Feiten, tekstinferenties en onbekende gegevens zijn in de Nederlandstalige UI duidelijk onderscheiden; lege
|
||||||
|
toestanden en layouts werken van 320 tot 2560 px zonder horizontale paginaoverflow.
|
||||||
|
verification:
|
||||||
|
- uv run pytest tests/unit/test_scoring.py tests/unit/test_skill_demand.py tests/integration/test_skill_insights.py
|
||||||
|
tests/integration/test_profile_preferences.py
|
||||||
|
- uv run python manage.py makemigrations --check --dry-run
|
||||||
|
- ./scripts/codex_verify.sh
|
||||||
|
- Browsercontrole op 2560x1440, 1440x900 en 390x844
|
||||||
|
primary_paths:
|
||||||
|
- apps/jobs/services/scoring.py
|
||||||
|
- apps/jobs/services/skill_demand.py
|
||||||
|
- apps/jobs/views.py
|
||||||
|
- apps/profiles/taxonomy.py
|
||||||
|
- templates/jobs/skill_insights.html
|
||||||
|
- static/css/app.css
|
||||||
|
- docs/architecture/SCORING_ENGINE.md
|
||||||
|
result:
|
||||||
|
completed_at: '2026-07-22'
|
||||||
|
note: Ervarings- en senioriteitsinvloed uit score en learning verwijderd; gebruikersgescopeerde Skillsradar
|
||||||
|
met actuele bronselectie, vraagbewijs, profieldekking, leerkansen, uitgebreide skillfilters en 2560/1440/390-browsercontrole
|
||||||
|
voltooid.
|
||||||
|
- id: VR-209
|
||||||
|
title: Stitch frontendcontract, audit en cockpit-shell
|
||||||
|
status: done
|
||||||
|
priority: P0
|
||||||
|
requirement_ids:
|
||||||
|
- PR-040
|
||||||
|
- PR-050
|
||||||
|
- PR-051
|
||||||
|
- NFR-004
|
||||||
|
- NFR-008
|
||||||
|
depends_on:
|
||||||
|
- VR-208
|
||||||
|
summary: Leg de volledige negen-schermenaudit en fidelitymatrix vast en vervang het globale design system, de
|
||||||
|
shell, navigatie en generieke browserstates door de Stitch Intelligence Cockpit.
|
||||||
|
acceptance_criteria:
|
||||||
|
- Contract en integratieplan bevatten alle negen schermen, afmetingen, componenten, route/data-mapping, fidelitydoelen,
|
||||||
|
responsive/accessibilityrisico's en legacy-opruiming.
|
||||||
|
- De enige productieshell gebruikt de lokale Stitch-tokens, compacte navigatie, contextbar, mobile drawer, skiplink,
|
||||||
|
focusstaten en echte statusdata zonder externe runtimeassets.
|
||||||
|
- Login, meldingen, lege toestanden en generieke foutstates gebruiken dezelfde visuele taal en blijven zonder
|
||||||
|
JavaScript bruikbaar.
|
||||||
|
verification:
|
||||||
|
- uv run pytest tests/integration/test_views.py tests/e2e/test_vr114_browser_and_accessibility.py
|
||||||
|
- uv run ruff check .
|
||||||
|
primary_paths:
|
||||||
|
- docs/design/STITCH_FRONTEND_CONTRACT.md
|
||||||
|
- docs/design/STITCH_INTEGRATION_PLAN.md
|
||||||
|
- templates/base.html
|
||||||
|
- static/css/app.css
|
||||||
|
- static/js/app.js
|
||||||
|
result:
|
||||||
|
completed_at: '2026-07-22'
|
||||||
|
note: Volledige negen-schermenaudit, fidelitymatrix, bindend frontendcontract en nieuwe lokale Stitch-cockpit-shell
|
||||||
|
met gesplitst design system, mobile navigatie en generieke foutstates; relevante tests groen.
|
||||||
|
- id: VR-210
|
||||||
|
title: Stitch dashboard en vacature-intelligence
|
||||||
|
status: done
|
||||||
|
priority: P0
|
||||||
|
requirement_ids:
|
||||||
|
- PR-040
|
||||||
|
- PR-041
|
||||||
|
- PR-050
|
||||||
|
- NFR-004
|
||||||
|
depends_on:
|
||||||
|
- VR-209
|
||||||
|
summary: Vervang dashboard, vacatureverkenner en detail door de echte Stitch-composities met database-aggregaties,
|
||||||
|
uitlegbare scores, provenance en veilige acties.
|
||||||
|
acceptance_criteria:
|
||||||
|
- Dashboard toont echte dag-, match-, filter-, duplicaat-, sollicitatie-, activiteit- en brongezondheidsdata met
|
||||||
|
beste matches als dominante feed.
|
||||||
|
- De explorer heeft serverfilters, actieve chips, paginering en een brede master/detailcompositie die querystring
|
||||||
|
en browser-back behoudt.
|
||||||
|
- Het detail toont scorebewijs, inhoud, provenance, versies, duplicaten, sollicitatiestatus en een responsive
|
||||||
|
actiedock; ervaring telt nergens als scorefactor en externe links blijven veilig.
|
||||||
|
verification:
|
||||||
|
- uv run pytest tests/integration/test_views.py tests/unit/test_scoring.py tests/unit/test_relevance.py
|
||||||
|
primary_paths:
|
||||||
|
- apps/core/views.py
|
||||||
|
- apps/jobs/views.py
|
||||||
|
- apps/jobs/services/cockpit.py
|
||||||
|
- templates/dashboard/today.html
|
||||||
|
- templates/jobs/list.html
|
||||||
|
- templates/jobs/detail.html
|
||||||
|
result:
|
||||||
|
completed_at: '2026-07-22'
|
||||||
|
note: Dashboard, vacatureverkenner en detail volledig naar Stitch-composities vervangen met echte cockpitaggregaties,
|
||||||
|
laatste profielscore, master/detailselectie, provenance, versies, duplicaten en veilige acties; relevante
|
||||||
|
scoring/relevance/viewtests groen.
|
||||||
|
- id: VR-211
|
||||||
|
title: Stitch pipeline, radarprofiel en brongezondheid
|
||||||
|
status: done
|
||||||
|
priority: P0
|
||||||
|
requirement_ids:
|
||||||
|
- PR-002
|
||||||
|
- PR-050
|
||||||
|
- PR-051
|
||||||
|
- NFR-004
|
||||||
|
depends_on:
|
||||||
|
- VR-210
|
||||||
|
summary: Vervang sollicitaties, zoekprofiel en bronnen door de functionele Stitch-pipeline, radarconfigurator
|
||||||
|
en source-healthinterface.
|
||||||
|
acceptance_criteria:
|
||||||
|
- Sollicitaties bieden kanban en lijstfallback, gebruikersgescopeerde POST-statuswijziging, deadlines, contact-
|
||||||
|
en timelinecontext.
|
||||||
|
- Het zoekprofiel groepeert echte selecties, toont revisie-informatie en een server-rendered preview uit de laatste
|
||||||
|
echte match.
|
||||||
|
- Bronnen tonen policy, runs, fout/succes, volgende planning, parser, tellingen, retry en bestaande beveiligde
|
||||||
|
import-/mailboxflows.
|
||||||
|
verification:
|
||||||
|
- uv run pytest tests/integration/test_applications.py tests/integration/test_profile_preferences.py tests/integration/test_sources_manual_import.py
|
||||||
|
tests/integration/test_source_health.py
|
||||||
|
primary_paths:
|
||||||
|
- apps/jobs/views.py
|
||||||
|
- apps/profiles/views.py
|
||||||
|
- apps/sources/views.py
|
||||||
|
- templates/applications
|
||||||
|
- templates/profiles
|
||||||
|
- templates/sources/list.html
|
||||||
|
result:
|
||||||
|
completed_at: '2026-07-22'
|
||||||
|
note: Stitch-kanban met gescopeerde auditbare status-POST, dossiereditor en tijdlijn; gegroepeerde radarconfigurator
|
||||||
|
met echte scorepreview/revisies; health-first bronnenconsole met policy/runbewijs, retry, mailbox- en importflows.
|
||||||
|
Relevante tests groen.
|
||||||
|
- id: VR-212
|
||||||
|
title: Stitch automation, activiteit en werkgeversintelligence
|
||||||
|
status: done
|
||||||
|
priority: P0
|
||||||
|
requirement_ids:
|
||||||
|
- PR-040
|
||||||
|
- PR-050
|
||||||
|
- PR-051
|
||||||
|
- NFR-002
|
||||||
|
- NFR-004
|
||||||
|
depends_on:
|
||||||
|
- VR-211
|
||||||
|
summary: Bouw de ontbrekende activiteit- en werkgeversroutes en vervang systeemstatus door een echte afgeleide
|
||||||
|
automationcockpit.
|
||||||
|
acceptance_criteria:
|
||||||
|
- Automation toont echte pipeline-, run-, score-, notificatie-, scheduling-, database- en configuratiestatus en
|
||||||
|
labelt niet-beschikbare infrastructuur eerlijk.
|
||||||
|
- Activiteit combineert betrouwbare, gebruikersgescopeerde gebeurtenissen in een filterbare, gelinkte en gepagineerde
|
||||||
|
tijdlijn.
|
||||||
|
- Werkgevers index/detail aggregeren echte werkgever-, vacature-, score-, bron- en sollicitatiedata zonder CRM-
|
||||||
|
of marktclaims te verzinnen.
|
||||||
|
verification:
|
||||||
|
- uv run pytest tests/integration/test_intelligence_views.py tests/integration/test_views.py
|
||||||
|
primary_paths:
|
||||||
|
- apps/jobs/services/activity.py
|
||||||
|
- apps/jobs/services/employer_intelligence.py
|
||||||
|
- apps/jobs/views.py
|
||||||
|
- apps/jobs/urls.py
|
||||||
|
- apps/core/views.py
|
||||||
|
- templates/activity
|
||||||
|
- templates/employers
|
||||||
|
- templates/system/status.html
|
||||||
|
result:
|
||||||
|
completed_at: '2026-07-22'
|
||||||
|
note: Echte automationcockpit met 24u-pipeline en eerlijke runtimeconfiguratie; gepagineerde/filterbare activiteitreadservice;
|
||||||
|
werkgeversindex/detail met vacature-, score-, bron- en dossieraggregaties. Nieuwe routes, templates en scope-tests
|
||||||
|
groen.
|
||||||
|
- id: VR-213
|
||||||
|
title: Stitch responsive validatie en legacyverwijdering
|
||||||
|
status: done
|
||||||
|
priority: P0
|
||||||
|
requirement_ids:
|
||||||
|
- NFR-004
|
||||||
|
- NFR-008
|
||||||
|
- NFR-009
|
||||||
|
depends_on:
|
||||||
|
- VR-212
|
||||||
|
summary: Rond de high-fidelity replacement af met browsermatrix, accessibility, elf screenshots, legacy-audit,
|
||||||
|
documentatie en volledige regressiegate.
|
||||||
|
acceptance_criteria:
|
||||||
|
- Alle negen schermen en kernflows zijn visueel en functioneel gecontroleerd tussen 360 en 1440 px, met aanvullende
|
||||||
|
ultrawidecontrole en schone browserconsole.
|
||||||
|
- Elf verplichte validatiescreenshots bestaan; majeure afwijkingen, accessibility- en querybevindingen staan in
|
||||||
|
het eindrapport.
|
||||||
|
- Oude visuele selectors, dode partials/JS en parallelle shellmarkup zijn verwijderd; de Stitch-frontend is de
|
||||||
|
enige normale productiefrontend.
|
||||||
|
- De volledige verificatiegate slaagt en backlog, projectstatus, traceability, README en changelog zijn bijgewerkt.
|
||||||
|
verification:
|
||||||
|
- ./scripts/codex_verify.sh
|
||||||
|
- Browsermatrix 1440x900, 1280x800, 1024x768, 768x1024, 390x844 en 360x800
|
||||||
|
primary_paths:
|
||||||
|
- static/css/app.css
|
||||||
|
- static/js/app.js
|
||||||
|
- tests/e2e
|
||||||
|
- artifacts/visual-validation
|
||||||
|
- docs/design/STITCH_INTEGRATION_REPORT.md
|
||||||
|
result:
|
||||||
|
completed_at: '2026-07-22'
|
||||||
|
note: Alle negen Stitch-schermen zijn browsergevalideerd van 360 tot 2560 px; elf screenshots, accessibility-/legacy-/queryaudit
|
||||||
|
en documentatie afgerond. Volledige gate groen met 248 tests, 2 optionele skips en 84,40% coverage.
|
||||||
- id: VR-301
|
- id: VR-301
|
||||||
title: Multi-tenant SaaS, teams en billing
|
title: Multi-tenant SaaS, teams en billing
|
||||||
status: deferred
|
status: deferred
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# Projectstatus
|
# Projectstatus
|
||||||
|
|
||||||
- Laatst bijgewerkt: 2026-07-22
|
- Laatst bijgewerkt: 2026-07-25
|
||||||
- Repositoryversie: 0.2.10 Kempen-werkgeversradar en GeoNames
|
- Repositoryversie: 0.3.0 volledige Stitch Intelligence Cockpit
|
||||||
- Uitvoeringsmodus: autonome backlog
|
- Uitvoeringsmodus: autonome backlog
|
||||||
- Eerste uitvoerbare taak: controleer met `python scripts/backlog.py next`
|
- Eerste uitvoerbare taak: controleer met `python scripts/backlog.py next`
|
||||||
|
|
||||||
@@ -14,11 +14,33 @@ De repository bevat een uitvoerbare Django-MVP met:
|
|||||||
- JSON-LD-, HTML-, RSS/Atom- en e-mailadapters;
|
- JSON-LD-, HTML-, RSS/Atom- en e-mailadapters;
|
||||||
- normalisatie, sanitization, werkgeverresolutie, dedupe, aliassen, provenance en versies;
|
- normalisatie, sanitization, werkgeverresolutie, dedupe, aliassen, provenance en versies;
|
||||||
- deterministische features, harde exclusions, scores en optionele uitgeschakelde Ollama-boundary;
|
- deterministische features, harde exclusions, scores en optionele uitgeschakelde Ollama-boundary;
|
||||||
- responsive dark/light cockpitdashboard, vacaturelijst/detail, sollicitatiepipeline, profiel-, bron- en systeemschermen;
|
- volledige responsive dark/light Stitch-cockpit met negen functionele hoofdschermen, skillsradar, login en foutstates;
|
||||||
- feedbackacties, applicationdossiers, lifecycle en dagelijkse digest-outbox;
|
- feedbackacties, applicationdossiers, lifecycle en dagelijkse digest-outbox;
|
||||||
- Celeryworker/scheduler, healthchecks, Docker/Unraidconfiguratie en back-up-/herstelscripts;
|
- Celeryworker/scheduler, healthchecks, Docker/Unraidconfiguratie en back-up-/herstelscripts;
|
||||||
- offline unit-, integratie- en securitytests.
|
- offline unit-, integratie- en securitytests.
|
||||||
|
|
||||||
|
## Laatste frontendvervanging
|
||||||
|
|
||||||
|
VR-209 tot en met VR-213 vervangen de oude browserfrontend integraal:
|
||||||
|
|
||||||
|
- één nieuwe lokale Stitch-shell en design system zonder CDN of clientframework;
|
||||||
|
- echte dashboard-/vacature-intelligence, sollicitatiepipeline, profielconfigurator en source-healthinterface;
|
||||||
|
- nieuwe activiteit- en werkgeversroutes/read models en een eerlijke automationcockpit;
|
||||||
|
- standaard IT-relevantie, persoonlijke scorecontext en user-scoped mutaties blijven intact;
|
||||||
|
- browsermatrix van 360 tot 2560 px, elf actuele screenshots, schone console en geen documentoverflow;
|
||||||
|
- bronhealth-N+1 verwijderd: twee queries voor alle bron/runmetingen, 23 totale queries voor het representatieve automationscherm.
|
||||||
|
|
||||||
|
Het volledige scherm- en afwijkingsrapport staat in `docs/design/STITCH_INTEGRATION_REPORT.md`.
|
||||||
|
|
||||||
|
VR-209–213-verificatie op 2026-07-22:
|
||||||
|
|
||||||
|
- 248 tests geslaagd, 2 optionele Playwrightvarianten overgeslagen en 84,40% branch-aware codedekking;
|
||||||
|
- Ruff, Django system check, migratiecheck, backlogvalidatie en repository-/documentvalidatie geslaagd via `scripts/codex_verify.sh`;
|
||||||
|
- alle negen routes in de geïntegreerde browser gecontroleerd zonder consolewaarschuwingen, kapotte assets of documentoverflow;
|
||||||
|
- browsermatrix: 360×800, 390×844, 768×1024, 1024×768, 1280×800, 1440×900, 1920×1080 en 2560×1440;
|
||||||
|
- elf actuele beelden onder `artifacts/visual-validation/`; artifacts en Stitch-referenties blijven buiten de productieimage;
|
||||||
|
- volgende uitvoerbare taak: geen; alleen de bestaande externe credentials-/infrastructuurblokkades en uitgestelde productkeuzes resteren.
|
||||||
|
|
||||||
## Laatste geverifieerde baseline
|
## Laatste geverifieerde baseline
|
||||||
|
|
||||||
Release-audit op 2026-07-21:
|
Release-audit op 2026-07-21:
|
||||||
@@ -30,6 +52,7 @@ Release-audit op 2026-07-21:
|
|||||||
- backlog- en repositoryvalidatie geslaagd;
|
- backlog- en repositoryvalidatie geslaagd;
|
||||||
- de lockfile gebruikt publieke PyPI-bronnen in plaats van een niet-overdraagbare interne registry;
|
- de lockfile gebruikt publieke PyPI-bronnen in plaats van een niet-overdraagbare interne registry;
|
||||||
- releaseblokkers hersteld in template-rendering, JSON/BOM-verwerking, leases, feedback, dossierautorisatie, ATS-provenance, employer-resolutie en DNS-rebindcontrole;
|
- releaseblokkers hersteld in template-rendering, JSON/BOM-verwerking, leases, feedback, dossierautorisatie, ATS-provenance, employer-resolutie en DNS-rebindcontrole;
|
||||||
|
- de productie-fetcher valideert het verbonden peer-IP, zodat legitieme CDN-DNS-rotatie bij DAF en Xplore Group niet als rebind wordt afgewezen terwijl private peers geblokkeerd blijven;
|
||||||
- een echte Dockerimagebuild op de Unraid-server, migratiecontrole en live LAN-smoke voor liveness, readiness, login en static assets zijn geslaagd;
|
- een echte Dockerimagebuild op de Unraid-server, migratiecontrole en live LAN-smoke voor liveness, readiness, login en static assets zijn geslaagd;
|
||||||
- deploymenthardening voorkomt gelijktijdige migraties door worker/scheduler, schakelt de Gunicorn-controlsocket uit voor de read-only container en houdt de lokale healthcheckhost toegestaan.
|
- deploymenthardening voorkomt gelijktijdige migraties door worker/scheduler, schakelt de Gunicorn-controlsocket uit voor de read-only container en houdt de lokale healthcheckhost toegestaan.
|
||||||
|
|
||||||
@@ -119,25 +142,75 @@ VR-126-herverificatie op 2026-07-22:
|
|||||||
|
|
||||||
VR-201/VR-205-herverificatie op 2026-07-22:
|
VR-201/VR-205-herverificatie op 2026-07-22:
|
||||||
|
|
||||||
- 215 geslaagde tests en 2 optioneel overgeslagen pytest-Playwrightvarianten; HTML/a11y-fallback geslaagd;
|
- 218 geslaagde tests en 2 optioneel overgeslagen pytest-Playwrightvarianten; HTML/a11y-fallback geslaagd;
|
||||||
- 84,42% branch-aware codedekking;
|
- 84,46% branch-aware codedekking;
|
||||||
- Ruff, Django system check, migratiecontrole, taakledger en repositoryvalidatie geslaagd via `scripts/codex_verify.sh`;
|
- Ruff, Django system check, migratiecontrole, taakledger en repositoryvalidatie geslaagd via `scripts/codex_verify.sh`;
|
||||||
- GeoNames België 2026-04-03 is onder CC BY 4.0 met checksums gedocumenteerd, 2.781 unieke invoerrijen zijn gevalideerd en 2400 Mol is opgelost naar het benaderende postcodecentrum 51.1919, 5.1166;
|
- GeoNames België 2026-04-03 is onder CC BY 4.0 met checksums gedocumenteerd, 2.781 unieke invoerrijen zijn gevalideerd en 2400 Mol is opgelost naar het benaderende postcodecentrum 51.1919, 5.1166;
|
||||||
- Gemeente Mol en zeven bijkomende Kempen-werkgevers passeerden elk twee livecycli zonder parserwarnings; samen leverden zij 53 kaarten per cyclus;
|
- Gemeente Mol en zeven bijkomende Kempen-werkgevers passeerden elk twee livecycli zonder parserwarnings; samen leverden zij 53 kaarten per cyclus;
|
||||||
- twaalf regionale werkgevers staan afzonderlijk als niet-scanbare waaklijst in de UI, ook wanneer zij momenteel geen vacature hebben;
|
- twaalf regionale werkgevers staan afzonderlijk als niet-scanbare waaklijst in de UI, ook wanneer zij momenteel geen vacature hebben;
|
||||||
- Codex-browsercontrole bevestigde 21 actieve bronnen, 12 waaklijstkaarten, de GeoNames-status, één `h1`, geen horizontale overflow op desktop of 390 px en geen consolefouten;
|
- Codex-browsercontrole bevestigde 21 actieve bronnen, 12 waaklijstkaarten, de GeoNames-status, één `h1`, geen horizontale overflow op desktop of 390 px en geen consolefouten;
|
||||||
|
- de Unraid-deploy gebruikt expliciet Celery-queue `default`, overeenkomstig de queues die de productie-worker consumeert;
|
||||||
|
- de fetcher valideert het werkelijk verbonden publieke peer-IP, zodat legitieme CDN-rotatie van DAF en Xplore Group veilig wordt ondersteund;
|
||||||
- taakledger bevat 44 taken: 38 `done`, 3 `blocked-external`, 3 `deferred` en 0 `ready`.
|
- taakledger bevat 44 taken: 38 `done`, 3 `blocked-external`, 3 `deferred` en 0 `ready`.
|
||||||
|
|
||||||
|
VR-206-herverificatie op 2026-07-22:
|
||||||
|
|
||||||
|
- 225 geslaagde tests en 2 optioneel overgeslagen pytest-Playwrightvarianten; HTML/a11y-fallback geslaagd;
|
||||||
|
- 84,53% branch-aware codedekking; Ruff, Django system check, migratiecontrole, taakledger en repositoryvalidatie geslaagd via `scripts/codex_verify.sh`;
|
||||||
|
- de standaardradar gebruikt een deterministische titelgestuurde IT-grens en laat verborgen/hard uitgesloten matches weg, met expliciete archiefopties voor volledige inspectie;
|
||||||
|
- de lokale auditinventaris telt 336 actieve records: 108 aantoonbare IT-titels, 228 gefilterde niet-IT-records en 102 kaarten in de persoonlijke radarselectie;
|
||||||
|
- de Stitch-cockpit benut maximaal 2240 px met tweekoloms matchfeeds op ultrawide; de zes kernroutes zijn handmatig gecontroleerd op 320, 390, 1024, 1440, 1920 en 2560 px zonder horizontale paginaoverflow;
|
||||||
|
- dark/light, gelabelde velden, mobiele drawer, score-/confidencecopy, archieffilters en inline bewaren zijn in de geïntegreerde browser gecontroleerd.
|
||||||
|
|
||||||
|
VR-207-herverificatie op 2026-07-22:
|
||||||
|
|
||||||
|
- 232 geslaagde tests en 2 optioneel overgeslagen pytest-Playwrightvarianten; HTML/a11y-fallback geslaagd;
|
||||||
|
- 84,59% branch-aware codedekking; Ruff, Django system check, migratiecontrole, taakledger en repositoryvalidatie geslaagd via `scripts/codex_verify.sh`;
|
||||||
|
- het profiel bevat gegroepeerde field-/infrastructuur-, Microsoft/endpoint-, netwerk/security-, back-up/monitoring-, virtualisatie-, VoIP- en scriptingkeuzes plus een ervaringsband;
|
||||||
|
- de skillcomponent bereikt zijn maximum bij vier aangetroffen cv-skills en presenteert niet-vermelde voorkeurskills niet meer als tekort;
|
||||||
|
- ambigue field-/implementatietitels vereisen een onafhankelijk IT-contextsignaal en titeluitsluitingen respecteren woordgrenzen en eenvoudige Engelse meervouden;
|
||||||
|
- de lokale actieve auditprofielen zijn zonder persoonsgegevens in Git afgestemd op 10–14 jaar ervaring, 27 relevante roltitels en 46 cv-skills; na herscoring blijven 14 plausibele field-/infrastructuurkansen zichtbaar;
|
||||||
|
- geïntegreerde browsercontrole bevestigde de categorie-editor op ultrawide en 390 px, één mobiele categoriekolom, geldige profielselecties en geen horizontale paginaoverflow.
|
||||||
|
|
||||||
|
VR-208-herverificatie op 2026-07-22:
|
||||||
|
|
||||||
|
- 240 geslaagde tests en 2 optioneel overgeslagen pytest-Playwrightvarianten; HTML/a11y-fallback geslaagd;
|
||||||
|
- 84,73% branch-aware codedekking; Ruff, Django system check, migratiecontrole, taakledger en repositoryvalidatie geslaagd via `scripts/codex_verify.sh`;
|
||||||
|
- ervaringsduur en senioriteitslabels hebben geen invloed meer op deterministische score, AI-score, aanbeveling, uitsluiting of feedbacklearning; migratie 0006 ruimt het oude gewicht op;
|
||||||
|
- de persoonlijke Skillsradar analyseert alleen actieve IT-vacatures met een niet-verborgen score voor de actuele profielversie en laat records van uitsluitend uitgeschakelde bronnen buiten beschouwing;
|
||||||
|
- de lokale radar toont 9 onderzochte vacatures, 5 herkenbare vraagsignalen, 2 gedekte skills en 3 mogelijke leerkansen zonder een tekstsignaal als harde eis te presenteren;
|
||||||
|
- geïntegreerde browsercontrole bevestigde filters, 88 profielskillcheckboxes, één `h1`, lege console en geen horizontale paginaoverflow op 2560×1440, 1440×900 en 390×844.
|
||||||
|
|
||||||
## Deploymentstatus
|
## Deploymentstatus
|
||||||
|
|
||||||
- `main` is via de bestaande Gitea-SSH-sleutel naar `NuklearRabbit/VacatureRadar` gepusht.
|
- `main` is via de bestaande Gitea-SSH-sleutel naar `NuklearRabbit/VacatureRadar` gepusht.
|
||||||
- De applicatie draait op de Unraid-server via `http://192.168.10.150:1226/` met PostgreSQL, Redis, web, worker en scheduler.
|
- De applicatie draait op de Unraid-server via `http://192.168.10.150:1226/` als één Dockerman-container; Supervisor bewaakt intern PostgreSQL 17, Redis, web, worker en scheduler.
|
||||||
|
- De Dockerman-tegel gebruikt de lokale VacatureRadar-favicon en opent rechtstreeks de WebUI op poort 1226.
|
||||||
- De server-side `.env` heeft rechten `0600`; gegenereerde secrets zijn niet naar Git of logs gekopieerd.
|
- De server-side `.env` heeft rechten `0600`; gegenereerde secrets zijn niet naar Git of logs gekopieerd.
|
||||||
- De self-hosted Gitea Actions-runner is niet actief. Deze sessie gebruikte daarom een directe, commitgebonden SSH-deploy; push-to-deploy vereist nog runnerherstel/registratie.
|
- De self-hosted Gitea Actions-runner is niet actief. Deze sessie gebruikte daarom een directe, commitgebonden SSH-deploy; push-to-deploy vereist nog runnerherstel/registratie.
|
||||||
- De huidige uitrol is alleen geschikt voor het beheerde LAN. Publieke productie vereist eerst een gekozen domein, reverse proxy/TLS en `DJANGO_DEBUG=0` met secure cookies.
|
- De huidige uitrol is alleen geschikt voor het beheerde LAN. Publieke productie vereist eerst een gekozen domein, reverse proxy/TLS en `DJANGO_DEBUG=0` met secure cookies.
|
||||||
|
|
||||||
## Laatste uitgevoerde backlogtaak
|
## Laatste uitgevoerde backlogtaak
|
||||||
|
|
||||||
|
- 2026-07-22: `VR-208` voltooid.
|
||||||
|
- Ervaringsjaren zijn alleen nog profielcontext; alle score-, recommendation-, concern-, evidence- en learninginvloed is verwijderd.
|
||||||
|
- Een gebruikersgescopeerde vraaganalyse brengt actuele skillfrequentie, bewijskwaliteit, profieldekking en niet-blokkerende leer-/bijscholingskansen in kaart.
|
||||||
|
- De profielcatalogus en nieuwe responsieve Skillsradar ondersteunen Microsoft, netwerk/security, monitoring/platform, ITSM en digitale adoptie met begeleide filters.
|
||||||
|
- Volgende taak: geen uitvoerbare `ready` taak; `VR-202`, `VR-203` en `VR-204` blijven `blocked-external`.
|
||||||
|
|
||||||
|
- 2026-07-22: `VR-207` voltooid.
|
||||||
|
- De twee aangeleverde cv's zijn visueel en tekstueel lokaal beoordeeld; alleen afgeleide technische profielkeuzes zijn in de lokale gebruikersdatabase opgeslagen.
|
||||||
|
- Relevante ervaring, rollen, skills en doelgerichte uitsluitingen zijn toegepast en alle 336 actieve vacatures zijn opnieuw gescoord voor beide lokale auditprofielen.
|
||||||
|
- De profiel- en scorelogica is regressiegedekt; afwezige cv-skills verlagen een breed profiel niet onbeperkt en worden niet als bewezen tekort getoond.
|
||||||
|
- Volgende taak: geen uitvoerbare `ready` taak; `VR-202`, `VR-203` en `VR-204` blijven `blocked-external`.
|
||||||
|
|
||||||
|
- 2026-07-22: `VR-206` voltooid.
|
||||||
|
- Niet-IT-vacatures en commercieel/recruitmentmisleidende IT-titels zijn standaard uit Vandaag en de vacaturefeed verwijderd zonder bronhistoriek te wissen.
|
||||||
|
- Matchsortering, radar-/archieffilters, score-evidence en gebruikersgescopeerde dashboardscores maken de dagelijkse selectie beslisbaar.
|
||||||
|
- De Stitch-dominante shell, feeds, details en context-rails schalen van 320 tot 2560 px en gebruiken op ultrawide twee resultaatkolommen.
|
||||||
|
- Een open redirect in feedbackterugkeer, 320 px bronoverflow en de ontbrekende faviconfallback zijn als auditbevindingen mee opgelost.
|
||||||
|
- Volgende taak: geen uitvoerbare `ready` taak; `VR-202`, `VR-203` en `VR-204` blijven `blocked-external`.
|
||||||
|
|
||||||
- 2026-07-22: `VR-201` en `VR-205` voltooid.
|
- 2026-07-22: `VR-201` en `VR-205` voltooid.
|
||||||
- De officiële GeoNames-postcodedataset lost postcode 2400 op naar Mol en levert indicatieve postcodecentrumafstanden; licentie, versie, reviewdatum en twee SHA-256-checksums zijn vastgelegd.
|
- De officiële GeoNames-postcodedataset lost postcode 2400 op naar Mol en levert indicatieve postcodecentrumafstanden; licentie, versie, reviewdatum en twee SHA-256-checksums zijn vastgelegd.
|
||||||
- Gemeente Mol, Ziekenhuis Geel, lokaal bestuur Geel, Stad Turnhout, Group Renotec, Ravago, Sanofi Geel en DAF Trucks Westerlo zijn na fixtures, negatieve routechecks en twee livecycli actief.
|
- Gemeente Mol, Ziekenhuis Geel, lokaal bestuur Geel, Stad Turnhout, Group Renotec, Ravago, Sanofi Geel en DAF Trucks Westerlo zijn na fixtures, negatieve routechecks en twee livecycli actief.
|
||||||
@@ -319,7 +392,6 @@ Deze blokkeren onafhankelijke code niet:
|
|||||||
- Unraid-domein, DNS, TLS en reverse proxy;
|
- Unraid-domein, DNS, TLS en reverse proxy;
|
||||||
- containerregistry en credentials;
|
- containerregistry en credentials;
|
||||||
- actieve/voor deze repository geregistreerde Gitea Actions-runner;
|
- actieve/voor deze repository geregistreerde Gitea Actions-runner;
|
||||||
- keuze/verkrijging van een gelicenseerde actuele Belgische postcode-/gemeentedataset.
|
|
||||||
|
|
||||||
## Bekende verificatiebeperking van de aangeleverde basis
|
## Bekende verificatiebeperking van de aangeleverde basis
|
||||||
|
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ Ondersteund of gepland:
|
|||||||
- uitgesloten regio/gemeente;
|
- uitgesloten regio/gemeente;
|
||||||
- afstand boven maximum, behalve remote;
|
- afstand boven maximum, behalve remote;
|
||||||
- uitgesloten verplichte skill;
|
- uitgesloten verplichte skill;
|
||||||
|
- een titelgestuurde IT-grens; ambigue field-/implementatietitels vereisen daarnaast een sterk IT-contextsignaal in de vacaturetekst;
|
||||||
- expliciete rijbewijs-/reis-/taalvereisten wanneer betrouwbaar geëxtraheerd;
|
- expliciete rijbewijs-/reis-/taalvereisten wanneer betrouwbaar geëxtraheerd;
|
||||||
- verlopen of niet-actieve vacature buiten de ranking.
|
- verlopen of niet-actieve vacature buiten de ranking.
|
||||||
|
|
||||||
@@ -35,15 +36,16 @@ Standaardgewichten, totaal 100:
|
|||||||
|
|
||||||
| Component | Gewicht | Kernsignalen |
|
| Component | Gewicht | Kernsignalen |
|
||||||
|---|---:|---|
|
|---|---:|---|
|
||||||
| Content | 25 | titelovereenkomst, taken, weinig ongewenste support |
|
| Content | 30 | titelovereenkomst, taken, weinig ongewenste support |
|
||||||
| Skills | 20 | gewenste skills in expliciete velden/tekst |
|
| Skills | 25 | gewenste skills in expliciete velden/tekst |
|
||||||
| Locatie | 15 | afstand, remote/hybrid, voorkeursregio |
|
| Locatie | 20 | afstand, remote/hybrid, voorkeursregio |
|
||||||
| Voorwaarden | 10 | toegestane contract-/werkvorm |
|
| Voorwaarden | 10 | toegestane contract-/werkvorm |
|
||||||
| Werkgever | 10 | directe bron versus recruiter |
|
| Werkgever | 10 | directe bron versus recruiter |
|
||||||
| Senioriteit | 10 | gevraagde ervaring versus profiel |
|
| Voorkeuren | 5 | publieke sector en andere soft signals |
|
||||||
| Voorkeuren | 10 | publieke sector en andere soft signals |
|
|
||||||
|
|
||||||
Bij aangepaste gewichten worden componentbijdragen genormaliseerd op de totale som.
|
Bij aangepaste gewichten worden componentbijdragen genormaliseerd op de totale som.
|
||||||
|
- De profielervaring is uitsluitend informatieve context. Gevraagde ervaringsjaren en senioriteitslabels veranderen de score, aanbeveling en uitsluitingen nooit.
|
||||||
|
- Een breed cv-profiel wordt niet bestraft omdat veel relevante skills zijn geselecteerd: maximaal vier aangetroffen profielskills volstaan voor de volledige skillcomponent. Niet-aangetroffen voorkeurskills blijven evidence, maar worden niet als bewezen tekort of concern gepresenteerd.
|
||||||
- AI-boost is opt-in via profielinstelling en telt alleen mee als profielgewicht > 0.
|
- AI-boost is opt-in via profielinstelling en telt alleen mee als profielgewicht > 0.
|
||||||
- De AI-component is softwarematig begrensd (maximale bijdrage via `ai`-gewicht) om dominantie te vermijden.
|
- De AI-component is softwarematig begrensd (maximale bijdrage via `ai`-gewicht) om dominantie te vermijden.
|
||||||
|
|
||||||
@@ -91,6 +93,7 @@ Een score bewaart:
|
|||||||
Copyregels:
|
Copyregels:
|
||||||
|
|
||||||
- zeg "niet teruggevonden" in plaats van "ontbreekt" wanneer brondata onvolledig is;
|
- zeg "niet teruggevonden" in plaats van "ontbreekt" wanneer brondata onvolledig is;
|
||||||
|
- presenteer niet-vermelde cv-skills niet als tekort; alleen expliciet aangetroffen skills zijn positieve evidence;
|
||||||
- label inference als inference;
|
- label inference als inference;
|
||||||
- toon maximaal enkele kernredenen bovenaan en volledige details uitklapbaar;
|
- toon maximaal enkele kernredenen bovenaan en volledige details uitklapbaar;
|
||||||
- geef geen kanspercentage op aanwerving zonder gevalideerd model.
|
- geef geen kanspercentage op aanwerving zonder gevalideerd model.
|
||||||
@@ -119,7 +122,7 @@ Offline evaluatie is beschikbaar via `scripts/feedback_learning_report.py` met f
|
|||||||
Toegestaan:
|
Toegestaan:
|
||||||
|
|
||||||
- support-/consultancy-/travelratio schatten;
|
- support-/consultancy-/travelratio schatten;
|
||||||
- senioritylabel;
|
- senioritylabel als informatieve extractie, zonder score-invloed;
|
||||||
- korte Nederlandse samenvatting;
|
- korte Nederlandse samenvatting;
|
||||||
- evidencefragmenten selecteren;
|
- evidencefragmenten selecteren;
|
||||||
- ambiguïteitswarnings.
|
- ambiguïteitswarnings.
|
||||||
|
|||||||
@@ -1,73 +1,42 @@
|
|||||||
# Toegankelijkheid
|
# Toegankelijkheid
|
||||||
|
|
||||||
Doel: WCAG 2.2 AA voor de kernflows.
|
Doel: WCAG 2.2 AA voor alle kernflows van de server-rendered Stitch-cockpit.
|
||||||
|
|
||||||
## Semantiek
|
## Productiecontract
|
||||||
|
|
||||||
- één `h1` per pagina, hiërarchische headings;
|
- Skiplink, `aside`/`nav`, `header`, één `main` en één `h1` per pagina.
|
||||||
- navigatie in `<nav>` met duidelijke accessible name;
|
- Headingniveaus volgen de visuele hiërarchie; tabulaire data gebruikt echte headers.
|
||||||
- lijsten/tabellen voor werkelijke lijst-/tabulaire data;
|
- Formulieren hebben expliciete labels, hints, gekoppelde errors en servervalidatie.
|
||||||
- formulieren met expliciete `<label>`;
|
- Status gebruikt tekst naast kleur; score en datakwaliteit zijn afzonderlijk benoemd.
|
||||||
- statusmetadata als definition list waar passend;
|
- Icon-only controls hebben een toegankelijke naam; lokale decoratieve SVG's zijn verborgen.
|
||||||
- externe links herkenbaar in tekst/accessible label.
|
- Externe vacaturetekst is gesanitized; links gebruiken veilige attributen en geen verborgen automatische actie.
|
||||||
|
|
||||||
## Toetsenbord
|
## Toetsenbord en progressive enhancement
|
||||||
|
|
||||||
Alle functies moeten zonder muis werken. Focusvolgorde volgt visuele volgorde. `:focus-visible` heeft een contrastvolle ring. Geen keyboard traps. Na server-POST landt focus logisch via heading/message; toekomstige modals herstellen focus.
|
Alle routes, filters, feedback, dossiers, statuswijzigingen en profiel-/bronforms werken zonder JavaScript. JavaScript verbetert uitsluitend drawer, disclosure, viewtoggle, zoekfocus en thema. De mobiele drawer beheert `aria-expanded`, focus en sluitactie. Focus is zichtbaar; er zijn geen tijdslimieten of keyboard traps. `prefers-reduced-motion` reduceert alle transities.
|
||||||
|
|
||||||
## Kleur en contrast
|
## Contrast, zoom en reflow
|
||||||
|
|
||||||
- bodytekst minimaal 4,5:1;
|
De donkere en lichte tokensets bewaren tekst-, focus- en componentcontrast. Metadata is gedempt maar niet de enige informatiedrager. Content heeft geen vaste teksthoogte. Op 360 px en bij 200% zoom blijft het document binnen het viewport; alleen intrinsiek brede pipeline-/tabeldata gebruikt een gelabelde interne scroller.
|
||||||
- grote tekst minimaal 3:1;
|
|
||||||
- focus en componentgrenzen minimaal 3:1 waar vereist;
|
|
||||||
- status nooit alleen via kleur;
|
|
||||||
- light/dark afzonderlijk toetsen.
|
|
||||||
|
|
||||||
## Zoom en reflow
|
## Browservalidatie VR-213
|
||||||
|
|
||||||
- bruikbaar op 320 CSS px breed;
|
Op 22 juli 2026 zijn de negen hoofdervaringen met echte lokale data gecontroleerd:
|
||||||
- 200% zoom zonder verlies van acties/tekst;
|
|
||||||
- geen vaste hoogte voor tekstkaarten;
|
|
||||||
- lange URL's en woorden breken;
|
|
||||||
- horizontale scroll alleen voor intrinsiek brede data met alternatief.
|
|
||||||
|
|
||||||
## Forms en fouten
|
- viewports: 1440×900, 1280×800, 1024×768, 768×1024, 390×844 en 360×800;
|
||||||
|
- aanvullende widescreencontrole: 1920×1080 en 2560×1440;
|
||||||
|
- op iedere gecontroleerde route: één `h1`, skiplink, hoofdlandmark, geen kapotte images en geen documentbrede overflow;
|
||||||
|
- mobiele sidebar is gesloten buiten gebruik, opent met `aria-expanded=true` en sluit terug naar `false`;
|
||||||
|
- dark/light wisselt met de correcte actienaam; de initiële naam volgt nu ook een opgeslagen voorkeur;
|
||||||
|
- pipeline en automationtabellen scrollen lokaal zonder de pagina te verbreden;
|
||||||
|
- browserconsole bevatte geen errors of warnings.
|
||||||
|
|
||||||
- foutsummary bovenaan bij meerdere fouten;
|
De elf bewijsscreenshots staan onder `artifacts/visual-validation/`. De optionele pytest-Playwrightvariant blijft lokaal overgeslagen wanneer Playwright niet geïnstalleerd is; de HTML/a11y-probe en de geïntegreerde Codex-browseraudit vormen samen het uitgevoerde bewijs.
|
||||||
- inline fout bij veld;
|
|
||||||
- `aria-invalid` en `aria-describedby`;
|
|
||||||
- voorbeeld en vereiste syntax als hint;
|
|
||||||
- geen placeholder als enige label;
|
|
||||||
- validatie niet alleen on blur.
|
|
||||||
|
|
||||||
## Dynamiek
|
## Handmatige reviewchecklist
|
||||||
|
|
||||||
- server-rendering heeft geen verborgen loading state;
|
1. Tabvolgorde en zichtbare focus op dashboard, detail, profiel en pipeline.
|
||||||
- async updates gebruiken live regions spaarzaam;
|
2. Screenreader-smoke voor shelllandmarks, scorelabels en formuliererrors.
|
||||||
- successmessages niet alleen tijdelijk visueel;
|
3. Dark/light contrast en status zonder kleur.
|
||||||
- motion respecteert reduced motion;
|
4. 200% zoom, lange titels, lege/unknown states en 360 px reflow.
|
||||||
- tijdslimieten bestaan niet in gebruikersflows.
|
5. Fout/success na POST en terugkeer naar een logisch heading- of messagepunt.
|
||||||
|
|
||||||
## Vacaturetekst
|
|
||||||
|
|
||||||
Gesanitized HTML behoudt headings, paragraphs, lists en links waar veilig. Verwijder scripts, forms, iframes, eventhandlers en gevaarlijke URL-schema's. Voeg waar nodig `rel="noopener noreferrer"` toe aan externe links.
|
|
||||||
|
|
||||||
## Testen
|
|
||||||
|
|
||||||
Per zichtbare taak:
|
|
||||||
|
|
||||||
1. keyboard-only;
|
|
||||||
2. browser zoom/reflow;
|
|
||||||
3. automatische HTML/accessibilityscan waar beschikbaar;
|
|
||||||
4. screenreader smoke test voor dashboard, vacaturedetail en profiel;
|
|
||||||
5. contrastcontrole;
|
|
||||||
6. test met lange/lege/unknown content.
|
|
||||||
|
|
||||||
## VR-118 browservalidatie
|
|
||||||
|
|
||||||
- Dashboard gecontroleerd op 1440×900, 1280×800, 1024×768, 768×1024, 390×844 en 360×800.
|
|
||||||
- Op alle zes viewports: exact één `h1`, geen documentbrede horizontale overflow en de juiste desktop/mobile navigatiestatus.
|
|
||||||
- Mobiel menu opent met `aria-expanded=true`, sluit met Escape/sluitactie en is buiten de focus-/accessibilityflow wanneer gesloten.
|
|
||||||
- Themawisselaar blijft bruikbaar wanneer browseropslag niet beschikbaar is; de UI geeft dan geen consolefout.
|
|
||||||
- Dashboard, vacaturelijst/detail, sollicitatiepipeline, profielen/editor, bronnen en systeem zijn met echte seeded data en zonder externe assets gevalideerd.
|
|
||||||
- Screenshots staan onder `docs/design/validation/` en zijn via `.dockerignore` uitgesloten van de productiecontainer.
|
|
||||||
|
|||||||
@@ -1,129 +1,58 @@
|
|||||||
# Design system
|
# VacatureRadar design system
|
||||||
|
|
||||||
> Geactualiseerd voor VR-118. De volledige Stitch-mapping en bewuste afwijkingen staan in
|
Dit document beschrijft de enige normale productiefrontend vanaf versie 0.3.0. De visuele bron van waarheid is de Stitch Intelligence Cockpit; het bindende contract en de schermmapping staan in `STITCH_FRONTEND_CONTRACT.md` en `STITCH_INTEGRATION_REPORT.md`.
|
||||||
> `docs/design/STITCH_INTEGRATION_PLAN.md`.
|
|
||||||
|
|
||||||
## 1. Merkgevoel
|
## Visuele identiteit
|
||||||
|
|
||||||
Rustig, betrouwbaar, helder en technisch precies. De interface voelt als een professionele intelligence-cockpit:
|
De standaardervaring is een donkere, technische navycockpit. Signal Cyan markeert focus en primaire acties, Emerald aantoonbaar positieve of gezonde status, Intelligence Indigo automatisering en Amber/Coral aandacht of fout. Tonale oppervlakken en fijne borders vormen de hiërarchie; glows zijn beperkt tot focus en sterke signalen.
|
||||||
donker marineblauw als standaard, signaalcyaan voor primaire interactie en tonale lagen in plaats van zware schaduwen.
|
|
||||||
De lichte variant behoudt dezelfde hiërarchie. Geen confetti, fictieve grafieken, eindeloze badges of agressieve notificatiekleuren.
|
|
||||||
|
|
||||||
## 2. Visuele tokens
|
De productietokens staan uitsluitend in `static/css/tokens.css`:
|
||||||
|
|
||||||
De implementatie staat in `static/css/app.css`. Gebruik semantische custom properties in plaats van losse kleuren in componenten.
|
- oppervlakken: `--surface-lowest`, `--surface`, `--surface-low`, `--surface-container`, `--surface-high`, `--surface-highest`, `--surface-bright`;
|
||||||
|
- tekst: `--text`, `--text-strong`, `--text-muted`, `--text-faint`;
|
||||||
|
- signalen: `--cyan`, `--cyan-dim`, `--emerald`, `--indigo`, `--amber`, `--coral`, `--danger`;
|
||||||
|
- structuur: `--outline`, `--outline-strong`, `--sidebar-width`, `--topbar-height`, `--content-gutter`, radii en `--shadow-signal`.
|
||||||
|
|
||||||
Geïmplementeerde tokenrollen omvatten onder meer:
|
De light variant gebruikt dezelfde semantische tokens en informatiehiërarchie. Component-CSS bevat geen eigen hardcoded palette; uitzonderingen zijn alleen documentmetadata zoals `theme-color` en e-mailtemplates buiten de cockpit.
|
||||||
|
|
||||||
```css
|
## Typografie en iconen
|
||||||
--bg
|
|
||||||
--bg-deep
|
|
||||||
--surface
|
|
||||||
--surface-low
|
|
||||||
--surface-high
|
|
||||||
--surface-bright
|
|
||||||
--text
|
|
||||||
--text-soft
|
|
||||||
--muted
|
|
||||||
--border
|
|
||||||
--border-strong
|
|
||||||
--primary
|
|
||||||
--primary-strong
|
|
||||||
--positive
|
|
||||||
--warning
|
|
||||||
--danger
|
|
||||||
--signal-glow
|
|
||||||
--radius-sm
|
|
||||||
--radius
|
|
||||||
```
|
|
||||||
|
|
||||||
Donkere modus is de productstandaard. De gebruiker kan lokaal wisselen via `data-theme`; opslag is progressief en
|
- Headlines: lokale systeemstack die Geist benadert (`Segoe UI Variable Display`, Aptos Display, Segoe UI).
|
||||||
mag in een afgeschermde browsercontext uitvallen zonder de bediening te breken.
|
- Body: `Segoe UI Variable Text`, Aptos en systeemfallbacks.
|
||||||
|
- Technische metadata: Cascadia Mono, SFMono/Consolas.
|
||||||
|
- Geen externe font- of iconrequest; iconen komen uit één lokale inline SVG-sprite.
|
||||||
|
- Score, datum en metadata blijven tekstueel leesbaar en steunen nooit alleen op kleur of vorm.
|
||||||
|
|
||||||
## 3. Typografie
|
## Layoutsysteem
|
||||||
|
|
||||||
- systeemfontstack, geen externe fontrequest;
|
- Vaste technische sidebar van 256 px en contexttopbar op desktop.
|
||||||
- basis 16px;
|
- Flexibel datacanvas met een maximum van 2240 px; op 1920/2560 px benutten feeds en intelligencepanelen de extra breedte.
|
||||||
- body line-height ongeveer 1,55;
|
- Bij 1180 px stapelen de zwaarste rails; bij 820 px wordt de sidebar een drawer met mobiele tabbar; bij 560 px worden cards, forms en intelligenceblokken éénkoloms.
|
||||||
- compacte metadata 0,8–0,9rem maar nooit onder bruikbare leesgrootte;
|
- Kanban, pipeline en datatabellen mogen alleen binnen een gelabelde lokale container horizontaal scrollen. Het document zelf heeft geen horizontale overflow.
|
||||||
- titels met duidelijke schaal, niet alleen gewicht;
|
- Sticky actions houden rekening met de mobiele tabbar en bedekken geen essentiële informatie.
|
||||||
- cijfers voor scores mogen tabular nums gebruiken.
|
|
||||||
|
|
||||||
## 4. Spacing en layout
|
## Kerncomponenten
|
||||||
|
|
||||||
- 4/8px-gebaseerde schaal;
|
- `cockpit-sidebar`, `cockpit-topbar`, `mobile-tabbar`: globale shell en navigatie.
|
||||||
- contentcontainer maximaal 1600px zodat de cockpit op brede schermen bruikbaar blijft;
|
- `cockpit-heading`, `section-heading`, `eyebrow`, `section-kicker`: informatiehiërarchie.
|
||||||
- kaarten met consistente padding 20–24px desktop, 16px mobiel;
|
- `stat-card`, `panel`, `tonal-panel`, `result-card`: tonale contentlagen.
|
||||||
- minimaal 44×44px touch targets voor primaire interactie;
|
- `pill-*`, `status-beacon`: status met zichtbare tekst naast kleur.
|
||||||
- vaste zijbalk van 272px boven 900px, toegankelijke uitschuifnavigatie daaronder;
|
- `intelligence-score`, `hero-score`, `score-components`: match en datakwaliteit als afzonderlijke begrippen.
|
||||||
- grids breken onder 1180px, 900px en 680px logisch af;
|
- `filter-console`, `active-filters`: server-side filters en deelbare querystrings.
|
||||||
- vaste/sticky elementen mogen content niet bedekken.
|
- `pipeline-board`, `activity-timeline`, `data-table-wrap`: taakgerichte datapatronen met toegankelijke fallback.
|
||||||
|
- `form-section`, `choice-field`, `profile-preview`: echte Django-formulieren met begeleide selecties en server-rendered bewijs.
|
||||||
|
- `empty-state`, `toast-stack`, fouttemplates: consistente lege, success-, fout- en onbekende staten.
|
||||||
|
|
||||||
## 5. Componenten
|
## Interactie
|
||||||
|
|
||||||
### Button
|
Vanilla JavaScript is alleen progressive enhancement voor drawer, disclosures, viewtoggle, zoekshortcut en thema. Alle links, filters, formulieren, statuswijzigingen en mutaties werken server-side. Motion is functioneel, kort en uitgeschakeld via `prefers-reduced-motion`.
|
||||||
|
|
||||||
Varianten:
|
## Reviewchecklist
|
||||||
|
|
||||||
- primary — één primaire actie per context;
|
- één duidelijke primaire taak en één `h1` per pagina;
|
||||||
- secondary — normale navigatie/opslaan;
|
- echte data of expliciet “Niet gemeten/Onbekend/Niet geconfigureerd”;
|
||||||
- ghost — lage nadruk;
|
- geen controls zonder serveractie;
|
||||||
- danger — destructief met bevestiging;
|
- consistente focusring, tekstlabels en minimaal bruikbare touch targets;
|
||||||
- compact icon+label — vacaturefeedback.
|
- reflow zonder documentoverflow op 360 px en dynamische benutting tot 2560 px;
|
||||||
|
- lange Nederlandse labels, werkgeversnamen en URL's breken veilig;
|
||||||
States: hover, focus-visible, active, disabled, busy. Disabled blijft leesbaar en verklaarbaar.
|
- geen externe runtimeassets, oude shell of parallel frontendpad.
|
||||||
|
|
||||||
### Badge/status pill
|
|
||||||
|
|
||||||
Bevat altijd tekst. Rollen: strong, possible, weak, hidden, active, review, deny, quarantined, expired. Gebruik niet alleen rood/groen.
|
|
||||||
|
|
||||||
### Card
|
|
||||||
|
|
||||||
Semantische header/body/footer. Hele kaart niet klikbaar wanneer er meerdere interne acties zijn; titel/link is de primaire link.
|
|
||||||
|
|
||||||
### Score
|
|
||||||
|
|
||||||
Score = getal/100 + recommendationtekst. Confidence wordt als “Datakwaliteit hoog/middel/laag” of exact percentage in detail getoond. Geen cirkeldiagram nodig voor één waarde.
|
|
||||||
|
|
||||||
### Form
|
|
||||||
|
|
||||||
- label boven veld;
|
|
||||||
- hint vóór fout;
|
|
||||||
- fout gekoppeld via `aria-describedby`;
|
|
||||||
- required expliciet;
|
|
||||||
- JSON-achtige lijstvelden uiteindelijk vervangen door chips/multi-select;
|
|
||||||
- opslaan bevestigt, maar verliest scroll/focus niet onnodig.
|
|
||||||
|
|
||||||
### Alert
|
|
||||||
|
|
||||||
Info/success/warning/error met icoon, titel en tekst. Alerts zijn `role=status` of `role=alert` afhankelijk van urgentie.
|
|
||||||
|
|
||||||
### Table/list
|
|
||||||
|
|
||||||
Kolomkoppen semantisch; op mobiel cards of stacked definition list. Actiekolom niet te breed.
|
|
||||||
|
|
||||||
## 6. Motion
|
|
||||||
|
|
||||||
- alleen functionele transities 120–200ms;
|
|
||||||
- respecteer `prefers-reduced-motion`;
|
|
||||||
- geen automatische carrousels, pulserende scores of decoratieve parallax.
|
|
||||||
|
|
||||||
## 7. Iconen
|
|
||||||
|
|
||||||
Gebruik eenvoudige inline SVG's met `aria-hidden=true` naast zichtbare tekst. Geen externe iconfont. Een icon-only button vereist accessible label/tooltips.
|
|
||||||
|
|
||||||
## 8. Datavisualisatie
|
|
||||||
|
|
||||||
Gebruik balken voor scorecomponenten met numerieke tekst. Kleur ondersteunt, maar label/waarde draagt betekenis. Grafieken zijn niet nodig zolang data laag-volume is.
|
|
||||||
|
|
||||||
## 9. Designreviewchecklist
|
|
||||||
|
|
||||||
- primaire taak binnen één scherm duidelijk;
|
|
||||||
- geen ruwe technische identifiers in dagelijkse UI;
|
|
||||||
- empty/error/loading states aanwezig;
|
|
||||||
- focusvolgorde logisch;
|
|
||||||
- contrast en zoom 200% gecontroleerd;
|
|
||||||
- mobiel 320px breed bruikbaar;
|
|
||||||
- lange titels/werkgevers/URL's breken veilig;
|
|
||||||
- Nederlands consistent;
|
|
||||||
- scoreverklaring niet verstopt achter kleur.
|
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
# Stitch frontendcontract
|
||||||
|
|
||||||
|
## Bindende productregel
|
||||||
|
|
||||||
|
De normale VacatureRadar-productiefrontend is de functionele vertaling van de negen schermen uit `docs/design/stitch-reference/`. `screen.png` bepaalt compositie en visuele uitkomst, `code.html` verduidelijkt structuur en interacties en `intelligence_cockpit/DESIGN.md` bepaalt de visuele tokens. De voormalige MVP-frontend heeft geen behoudsprioriteit.
|
||||||
|
|
||||||
|
Backend-, beveiligings- en toegankelijkheidsregels blijven altijd leidend. Een Stitch-element wordt alleen aangepast wanneer echte data ontbreekt, een actie onveilig of niet ondersteund is, Nederlandse content meer ruimte vraagt of WCAG 2.2 AA dat vereist. Zo'n afwijking wordt in `STITCH_INTEGRATION_REPORT.md` gemotiveerd.
|
||||||
|
|
||||||
|
## Verplichte visuele taal
|
||||||
|
|
||||||
|
- Donkere navy cockpit met `#060e20`, `#0b1326`, `#131b2e`, `#171f33`, `#222a3d` en `#2d3449` als tonale lagen.
|
||||||
|
- Signal Cyan `#00f5ff` voor primaire acties en focus, Emerald `#4edea3` voor aantoonbaar gezonde/positieve status, indigo voor automation en koraal/amber voor fout of aandacht.
|
||||||
|
- Compacte technische zijbalk, contextuele topbar, brede datacanvas en adaptieve intelligence-rails.
|
||||||
|
- Geist-achtige systeemheadline, Inter-achtige systeembody en JetBrains Mono-achtige systeemmono; geen externe fontrequest.
|
||||||
|
- 8px basisritme, 16/24/32px stackafstand, fijne borders, kleine radii en terughoudende glows. Tonale hiërarchie vervangt zware shadows.
|
||||||
|
- Lokale inline SVG-iconen. Geen Material Symbols-, Tailwind-, font- of andere runtime-CDN.
|
||||||
|
|
||||||
|
## Functioneel contract
|
||||||
|
|
||||||
|
- Alle cijfers zijn database-aggregaties of tonen expliciet `Nog niet gemeten`, `Onbekend` of `Niet geconfigureerd`.
|
||||||
|
- Alle mutaties zijn POST + CSRF, gebruikersgescopeerd en gebruiken bestaande services waar bedrijfsbeslissingen nodig zijn.
|
||||||
|
- Vacature-HTML is alleen de gesanitized backendwaarde; externe links gebruiken `noopener noreferrer nofollow` en openen uitsluitend na een expliciete gebruikersactie.
|
||||||
|
- Geen automatische sollicitatie, platformloginbot, CAPTCHA-omzeiling, drag-and-drop zonder persistente fallback of control zonder werkend effect.
|
||||||
|
- Named URLs, querystrings, browser-back, paginering en kernflows werken zonder JavaScript. JavaScript verbetert alleen navigatie, disclosures, tabs, dialogen, viewtoggles en micro-interacties.
|
||||||
|
|
||||||
|
## Schermcontract
|
||||||
|
|
||||||
|
De volgende pagina's zijn verplicht en gebruiken echte data:
|
||||||
|
|
||||||
|
1. Dashboard: dagconclusie, matches, filters/deduplicatie, sollicitatieopvolging, activiteit en bronstatus.
|
||||||
|
2. Vacatureverkenner: master/detail op breed scherm, server-side filters, scorebewijs en snelle veilige acties.
|
||||||
|
3. Vacaturedetail: jobhero, score-intelligence, inhoud, provenance, versies, duplicaten en sticky acties.
|
||||||
|
4. Sollicitatiepipeline: toegankelijke kanban en lijstfallback met persistente statuswijziging.
|
||||||
|
5. Zoekprofiel: gegroepeerde radarconfigurator met echte server-rendered preview en revisie-informatie.
|
||||||
|
6. Bronnen: brongezondheid, policies, runtelemetrie, retry/review en beveiligde imports/mailboxen.
|
||||||
|
7. Automatisering: echte pipeline-, queue-, database-, notificatie- en bronstatus zonder gesimuleerde liveclaims.
|
||||||
|
8. Activiteit: gepagineerde, filterbare tijdlijn uit bestaande auditwaardige modellen.
|
||||||
|
9. Werkgevers: index/detail uit genormaliseerde werkgever-, vacature-, score-, bron- en sollicitatiedata.
|
||||||
|
|
||||||
|
Ook login, foutpagina's, lege toestanden en generieke formulieren gebruiken dezelfde visuele taal.
|
||||||
|
|
||||||
|
## Responsive en toegankelijkheid
|
||||||
|
|
||||||
|
- Desktop volgt de Stitch-silhouette op 1440×900 en 1280×800; 1920/2560px gebruikt extra kolommen of ruimte en geen smalle gecentreerde strook.
|
||||||
|
- Op 1024/768px worden rails logisch gestapeld. Op 390/360/320px wordt de zijbalk een focusveilige drawer, tabellen krijgen kaart- of horizontale scrollpresentatie en sticky acties blijven bruikbaar.
|
||||||
|
- Eén `h1`, geldige landmarks, skiplink, zichtbare `:focus-visible`, tekst naast statuskleur, expliciete labels/fouten, 44px touchdoelen waar haalbaar, reduced motion en bruikbaarheid bij 200% zoom.
|
||||||
|
|
||||||
|
## Afrondingscontract
|
||||||
|
|
||||||
|
Een frontendfase is pas klaar na relevante tests en een diffreview. De volledige vervanging vereist elf browserbeelden onder `artifacts/visual-validation/`, een schone console op de kernroutes, een legacyzoekaudit, `scripts/codex_verify.sh`, bijgewerkte backlog/traceability/projectstatus en `STITCH_INTEGRATION_REPORT.md`. Er bestaat daarna geen feature flag of alternatieve normale route naar de oude frontend.
|
||||||
@@ -1,68 +1,74 @@
|
|||||||
# Stitch-integratieplan
|
# Stitch-integratieplan
|
||||||
|
|
||||||
## Doel en bronhiërarchie
|
## Auditbaseline
|
||||||
|
|
||||||
Deze integratie vertaalt het Stitch-project **VacatureRadar Intelligence Cockpit** naar de bestaande Django-applicatie. De functionele repository blijft leidend voor routes, data, beveiliging, gebruikersscope en acties. Stitch levert de visuele taal en compositie, niet de domeinwaarheid.
|
De ontvangen ZIP `stitch_vacatureradar_intelligence_cockpit.zip` is geïnventariseerd en bytegelijk aan de blijvende referenties onder `docs/design/stitch-reference/`. Er is geen afzonderlijke aanvullende `design.md` aangetroffen. De referenties worden niet als runtimefrontend geserveerd en zijn via `.dockerignore` uit de productieimage gehouden.
|
||||||
|
|
||||||
De volledige ontvangen referentie staat in `docs/design/stitch-reference/`. Deze map en de validatiescreenshots zijn uitgesloten van de productiecontainer via `.dockerignore`; er worden geen externe Tailwind-, font-, icoon-, afbeelding- of JavaScript-CDN's aan de applicatie toegevoegd.
|
| Stitch-scherm | Afmeting | Dominante compositie | Productieroute / template |
|
||||||
|
| --- | ---: | --- | --- |
|
||||||
|
| Dashboard overzicht | 1480×1600 | KPI-strip, brede matchfeed, actie-/activiteitsrail | `/` · `dashboard/today.html` |
|
||||||
|
| Nieuwe matches | 1600×1550 | filterbar, masterlijst en geselecteerde intelligence | `/jobs/` · `jobs/list.html` |
|
||||||
|
| Vacature intelligence detail | 1600×1525 | hero, scorepaneel, inhoudtabs, intelligence-rail en actiedock | `/jobs/<uuid>/` · `jobs/detail.html` |
|
||||||
|
| Sollicitatie pipeline | 1600×1280 | horizontale kanban met lijsttoggle | `/jobs/applications/` · `applications/list.html` |
|
||||||
|
| Zoekprofiel editor | 1600×1280 | configuratiestappen met vaste radarpreview | `/profiles/<id>/` · `profiles/edit.html` |
|
||||||
|
| Bronnenbeheer & gezondheid | 1600×1340 | bronstatusrijen, disclosures en integriteitspanelen | `/sources/` · `sources/list.html` |
|
||||||
|
| Automatisering centrum | 1540×1600 | pipelinefasen, operationele KPI's, brontabel en lograil | `/system/` · `system/status.html` |
|
||||||
|
| Activiteitenlogboek | 1600×1441 | filterbare technische tijdlijn met samenvatting | `/jobs/activity/` · `activity/list.html` |
|
||||||
|
| Werkgevers intelligence | 1600×931 | employer table met intelligence-detailrail | `/jobs/employers/` en `/jobs/employers/<id>/` · employer templates |
|
||||||
|
|
||||||
## VR-119: compositie als primaire bron
|
## Tokens en herbruikbare bouwstenen
|
||||||
|
|
||||||
Na productfeedback is de integratie aangescherpt: Stitch bepaalt nu niet alleen kleur, vorm en typografie, maar ook de dominante schermcompositie en informatiedichtheid. Alleen aantoonbaar onveilige of fictieve onderdelen worden vervangen door een functioneel equivalent met echte repositorydata.
|
De negen HTML-referenties gebruiken dezelfde palette- en typografieschaal: darkest/surface `#060e20`/`#0b1326`, vijf tonale containers, Signal Cyan `#00f5ff`, Emerald `#4edea3`, lichte hoofdtekst `#dae2fd`, gedempte tekst `#b9caca`, koraal `#ffb4ab`, 8px basisritme en 2–8px radii. Headings zijn 20/32px, body 14/16px en metadata 12/13px mono. Productie vertaalt dit naar CSS custom properties en lokale SVG's.
|
||||||
|
|
||||||
- De utilitybar neemt de brede zoekbalk en directe profielactie over.
|
Herbruikbare componenten: app-shell, nav-link, contextbar, page-heading, KPI-tile, tonal-panel, status-chip, score-ring/tile, data-table, intelligence-rail, timeline-event, filterbar/drawer, disclosure, empty state, form-section, sticky action dock en mobile bottom navigation. De componenten dragen semantiek; paginatemplates leveren alleen domeindata en compositie.
|
||||||
- Het dashboard gebruikt een enkelkoloms matchfeed met vaste radar-/actierail; één resultaat blijft daardoor een volwaardig primair signaal.
|
|
||||||
- De vacaturelijst toont een dichte intelligencefeed met de laatste gebruikersgescopeerde score via databaseannotaties.
|
|
||||||
- Het detail combineert scorehero, drie echte kernmetingen, analyse en een vaste menselijke actierail.
|
|
||||||
- Pipeline, profieleditor, bronkaarten en systeemtelemetrie volgen de Stitch-kolommen en panelhiërarchie.
|
|
||||||
- Handmatige import blijft volledig beschikbaar, maar staat compact achter een disclosure zodat brongezondheid visueel voorrang krijgt.
|
|
||||||
|
|
||||||
De bedoelde “circa 90% Stitch” slaat hiermee op overgenomen visuele structuur en interactiehiërarchie, niet op het kopiëren van fictieve content, externe assets, auto-apply of routes waarvoor het domeinmodel geen betrouwbare data bezit.
|
## Data- en routeaudit
|
||||||
|
|
||||||
## Visuele richting
|
| Component | Echte databron / service | Ontbrekend of aangepaste presentatie |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Dag-KPI's en beste matches | `JobPosting`, laatste `ScoreRun`, `Application`, `SourceRun`; nieuwe cockpit-queryservice | Geen fictieve trendpercentages; onbekende meting krijgt tekststatus. |
|
||||||
|
| Verkenner | `JobPosting` met laatste profielscore, relevance-query, filters en paginering | Stitch quick-apply wordt detail/dossier/externe bronactie. |
|
||||||
|
| Detailintelligence | `ScoreRun`, `JobSourceAlias`, `FieldProvenance`, `JobVersion`, `Feedback`, `Application` | Geen ervarings-/senioriteitsscore; geen kaart of salaris wanneer onbekend. |
|
||||||
|
| Pipeline | gebruikersgescopeerde `Application` en `ApplicationTimelineEvent` | Geen drag-and-dropvereiste; statuswijziging krijgt toegankelijke POST-selectie. |
|
||||||
|
| Radarconfiguratie | `SearchProfileForm`, profielrevisies, laatste scores | Preview is server-rendered uit laatste echte match; geen demo-vacature. |
|
||||||
|
| Brongezondheid | `Source`, laatste `SourceRun`, `collect_source_health`, mailboxverbindingen | Geen algemene healthyclaim zonder runbewijs; technische fouttekst begrensd. |
|
||||||
|
| Automatisering | readiness, `SourceRun`, jobs, scores, notificatie-outbox en configuratie | Uptime/worker/AI/browserstatus alleen indien afleidbaar, anders niet geconfigureerd. |
|
||||||
|
| Activiteit | nieuwe queryservice over `SourceRun`, `JobVersion`, `Feedback`, `ApplicationTimelineEvent`, outbox en `ProfileRevision` | Modellen vormen één read-only tijdlijn; geen nieuw generiek auditmodel nodig. |
|
||||||
|
| Werkgevers | nieuwe queryservice over `Employer`, jobs, scores, aliases, sources en applications | Sector/locatie/notities alleen indien opgeslagen; geen CRM- of contactpersoonfinder. |
|
||||||
|
|
||||||
- Donkere cockpit als standaard: diep marineblauw, tonale panelen, fijne slate-randen, signaalcyaan voor primaire interactie, emerald voor aantoonbaar gezonde status en koraal/amber voor fouten of aandacht.
|
De twee nieuwe domeinervaringen worden read-only geïmplementeerd in `apps/jobs/services/activity.py` en `apps/jobs/services/employer_intelligence.py`. Dit houdt aggregatielogica uit views en vermijdt een migratie. Querysets gebruiken annotaties, `select_related`, `prefetch_related` en begrensde lijsten om N+1 te voorkomen.
|
||||||
- Een volwaardige lichte variant met dezelfde semantische tokens en contrastverhoudingen.
|
|
||||||
- Systeemfonts als lokale, snelle fallback voor Geist/Inter; monospace systeemfonts voor metadata. Geen netwerkfonts.
|
|
||||||
- Een vaste desktopzijbalk en compacte utilitybar, op mobiel een toegankelijke uitschuifnavigatie met overlay en focusbehoud.
|
|
||||||
- Dichte informatieweergave met een consistente 8px-ritmiek, zonder decoratieve data die niet uit het domeinmodel komt.
|
|
||||||
|
|
||||||
## Schermmapping
|
## Fidelitymatrix
|
||||||
|
|
||||||
| Stitch-scherm | Django-route/template | Integratie | Bewuste aanpassing |
|
| Scherm | Stitch-element | Echte implementatie | Fidelitydoel | Noodzakelijke aanpassing | Verificatie |
|
||||||
| --- | --- | --- | --- |
|
| --- | --- | --- | --- | --- | --- |
|
||||||
| Dashboard Overzicht | `/`, `dashboard/today.html` | KPI-strip, sterke-matchkaarten, bronstatus en snelle route-acties | Geen verzonnen trendgrafiek, notificaties of scannerstatus; alleen actuele databasecijfers. |
|
| Dashboard | vier KPI's + matchfeed + rail | actuele job/score/source/application-aggregaties | zeer hoog | trends alleen bij meetbaar tijdvenster | desktop/mobile screenshot + contexttests |
|
||||||
| Nieuwe Matches | `/jobs/`, `jobs/list.html` | Filterwerkbalk en responsive vacature-intelligentielijst | Geen client-side master/detail-SPA; detail blijft een eigen, deelbare route. |
|
| Nieuwe matches | split explorer | gepagineerde serverlijst met geselecteerd echt resultaat | zeer hoog | URL blijft deelbaar; geen SPA-state | filters/back/pagination tests + screenshot |
|
||||||
| Vacature Intelligentie Detail | `/jobs/<uuid>/`, `jobs/detail.html` | Scorehero, componentanalyse, bronhistorie en sticky acties | Geen quick/direct apply, logo of kaart zonder betrouwbare brondata. Externe vacature opent alleen expliciet in een nieuw tabblad. |
|
| Detail | hero, score rail, tabs, dock | scorebewijs, provenance, versies, veilige acties | hoog | geen nepbeeld, quick apply of ervaringseffect | safe-link/sanitization tests + screenshots |
|
||||||
| Sollicitatie Pipeline | `/jobs/applications/`, `applications/list.html` | Statuskolommen op breed scherm, lineaire kaarten op smal scherm | Alleen bestaande dossierstatussen en gebruikersgescopeerde data. Geen drag-and-drop zonder backendcontract. |
|
| Pipeline | kanban + lijsttoggle | statuskolommen en echte POST statusselectie | zeer hoog | geen drag-and-drop als enige invoer | POST/user-scope tests + screenshot |
|
||||||
| Zoekprofiel Editor | `/profiles/` en `/profiles/<id>/` | Profielsummary, gegroepeerde editor, hulp- en risicotekst | Geen live scorepreview die servervalidatie kan tegenspreken. JSON-velden blijven functioneel maar krijgen duidelijke uitleg. |
|
| Zoekprofiel | secties + live preview | gegroepeerde widgets en laatste echte match | hoog | preview ververst na server-save | form/revision tests + screenshot |
|
||||||
| Bronnenbeheer & Gezondheid | `/sources/`, `sources/list.html` | KPI's, importpaneel, bronkaarten, uitklapbare runinformatie en bulkacties | Bestaande policy-, SSRF-, rate-limit- en CSRF-flows blijven intact. Geen algemene “gezond”-claim zonder readinessdata. |
|
| Bronnen | health rows + details | bronkaart/tabel, runbewijs, retry en imports | zeer hoog | policy/securityflows blijven zichtbaar | retry/policy tests + screenshot |
|
||||||
| Automatisering Centrum | `/system/`, `system/status.html` | Readiness, echte bron-/vacaturestatus en veilige beheeropdrachten | Geen gefingeerde worker uptime, latency, cronprogressie of logregels. |
|
| Automatisering | pipeline en logs | afgeleide fasestatus, run- en outboxtelemetrie | hoog | onbekende infrastructuur eerlijk gelabeld | view-context tests + screenshot |
|
||||||
| Activiteitenlogboek | geen zelfstandige route | Recente, betrouwbare gebruikersactiviteit wordt op dashboard/dossiers getoond waar het model die al bezit | Geen samengestelde auditfeed: de huidige modellen hebben geen uniform, volledig gebruikersgescopeerd eventcontract. Dit wordt niet geforceerd. |
|
| Activiteit | filtertijdlijn | gepagineerde multi-model read service | zeer hoog | alleen objecttypen met betrouwbare auditdata | filter/scope/pagination tests + screenshot |
|
||||||
| Werkgevers Intelligentie | geen zelfstandige route | Werkgever blijft context op vacature- en sollicitatiekaarten | Geen “intelligence”-pagina zolang contact-, reputatie- en aggregatievelden niet betrouwbaar bestaan; dit voorkomt fictieve inzichten. |
|
| Werkgevers | tabel + drawer | index met geselecteerde detailrail en aparte detail-URL | zeer hoog | geen AI-contactfinder of verzonnen sector | aggregate/scope/empty tests + screenshot |
|
||||||
|
|
||||||
## Componenten en templatestructuur
|
## Responsive strategie en risico's
|
||||||
|
|
||||||
- `base.html`: skiplink, merkblok, utilitybar, desktop/mobile navigatie, gebruikerspaneel, meldingen en themawisselaar.
|
De desktop shell gebruikt een 256px sidebar en een flexibele canvas tot ultrawide. Dashboard, explorer en detail schakelen bij 1180–1360px van twee/three-column naar gestapeld; op ultrawide worden rails vast en feeds ruimer. Kanban behoudt gecontroleerde horizontale inhoudscroll, niet paginascroll. Tabellen krijgen bij mobiel semantische kaartregels of een gelabelde scrollcontainer. De profielpreview wordt onder de editor geplaatst; de vacatureselectie opent als volledige detailkaart.
|
||||||
- `templates/components/`: herbruikbare SVG-iconen, paginakop, lege toestand, statuschip en vacaturekaart waar template-includes de duplicatie daadwerkelijk verlagen.
|
|
||||||
- `static/css/app.css`: uitsluitend semantische tokens en componentklassen; dark/light via `data-theme`, met systeemvoorkeur als fallback.
|
|
||||||
- `static/js/app.js`: alleen navigatie, details-disclosures en themaopslag. Alle kernacties blijven normale links of formulieren.
|
|
||||||
|
|
||||||
## Functionele en veiligheidsgrenzen
|
Belangrijkste accessibilityrisico's uit het prototype zijn icon-only controls, kleur-only status, lage metadata-contrastwaarden, vaste hoogtes, horizontale kanban en JavaScript-only toggles. Productie voegt namen, zichtbare statustekst, voldoende contrast, natuurlijke hoogte, keyboardbediening, serverfallbacks, focusmanagement en `prefers-reduced-motion` toe.
|
||||||
|
|
||||||
- Alle mutaties blijven POST + CSRF en gebruiken de bestaande services/views.
|
## Migratievolgorde en legacy-opruiming
|
||||||
- Sanitized vacature-HTML blijft het enige HTML-fragment dat met `safe` wordt gerenderd.
|
|
||||||
- Externe URL's behouden `noopener noreferrer nofollow` en worden nooit automatisch geopend.
|
|
||||||
- Bronimport, retry en bulkreview veranderen inhoudelijk niet.
|
|
||||||
- Er worden geen secrets, persoonsgegevens, nepstatistieken, externe assets of nieuwe productieafhankelijkheden toegevoegd.
|
|
||||||
|
|
||||||
## Responsive en toegankelijkheid
|
1. Contract, tokens, icon sprite en volledig nieuwe shell.
|
||||||
|
2. Dashboard, explorer en detail plus benodigde querycontext.
|
||||||
|
3. Pipeline, zoekprofiel en bronnen zonder bestaande POST/contracts te breken.
|
||||||
|
4. Automatisering, activiteit en werkgevers met nieuwe read services/routes.
|
||||||
|
5. Login, fouttemplates, mobile/adaptive lagen, browsercontrole en accessibility.
|
||||||
|
6. Verwijder oude selectors, dubbele cards/panels, achterhaalde JS en screenshots die niet tot de huidige validatie horen; zoek alle productietemplates na op de oude shell.
|
||||||
|
|
||||||
- Validatiebreedtes: 1440×900, 1280×800, 1024×768, 768×1024, 390×844 en 360×800.
|
`templates/base.html`, alle niet-e-mailtemplates onder `templates/`, `static/css/app.css` en `static/js/app.js` worden vervangen of fundamenteel herschikt. E-mailtemplates blijven buiten de cockpit omdat zij geen browserfrontend zijn. Named routes en bestaande formulier-/securitycontracts blijven behouden.
|
||||||
- Eén `h1` per pagina, semantische landmarks, zichtbare focus, toetsenbordbediening, 44px touchdoelen waar praktisch, tekstlabels naast statuskleur en een robuuste skiplink.
|
|
||||||
- Tabellen schakelen op smalle schermen naar kaartachtige rijen of blijven gecontroleerd horizontaal scrolbaar wanneer kolomvergelijking essentieel is.
|
|
||||||
- Animatie is subtiel en volledig uitgeschakeld bij `prefers-reduced-motion`.
|
|
||||||
|
|
||||||
## Verificatie
|
## Gate en artifacts
|
||||||
|
|
||||||
Na elke implementatiefase worden formattering, Ruff en relevante tests uitgevoerd. De afronding vereist `scripts/codex_verify.sh`, responsieve browserflows, console-/netwerkcontrole en screenshots per hoofdroute in beide thema's waar dat het ontwerp valideert.
|
Per fase: Ruff format/check, relevante tests, diffreview en logische commit. Eindmatrix: 1440×900, 1280×800, 1024×768, 768×1024, 390×844 en 360×800; aanvullende 1920/2560px ultrawidecontrole. De elf verplichte beelden komen onder `artifacts/visual-validation/`; het eindrapport legt fidelity, afwijkingen, consolebevindingen, accessibility, queries en testresultaten vast.
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
# Stitch-integratierapport
|
||||||
|
|
||||||
|
## Resultaat
|
||||||
|
|
||||||
|
VacatureRadar 0.3.0 gebruikt één normale productiefrontend: de functionele Django-vertaling van de negen Stitch Intelligence Cockpit-schermen. De voormalige MVP-shell, monolithische stylesheet en pagina-indelingen zijn vervangen. Backendregels, named routes, user scoping, CSRF, sanitization, bronbeleid en servervalidatie blijven leidend.
|
||||||
|
|
||||||
|
Startcommit: `029df89`. Branch: `codex/stitch-frontend-replacement`.
|
||||||
|
|
||||||
|
## Schermmapping en fidelity
|
||||||
|
|
||||||
|
| Stitch-scherm | Productie-implementatie | Fidelity | Bewuste afwijking |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| Dashboard overzicht | `/` · `templates/dashboard/today.html` | Zeer hoog | Geen fictieve trends; dagconclusie en KPI's zijn echte aggregaties. |
|
||||||
|
| Nieuwe matches | `/jobs/` · `templates/jobs/list.html` | Zeer hoog | Server-side master/detail en paginering vervangen prototype-state. |
|
||||||
|
| Vacature intelligence detail | `/jobs/<uuid>/` · `templates/jobs/detail.html` | Hoog | Geen quick apply, kaart of salaris wanneer backenddata ontbreekt. |
|
||||||
|
| Sollicitatie pipeline | `/jobs/applications/` · applicationtemplates | Zeer hoog | Select+POST en lijstfallback in plaats van drag-only kanban. |
|
||||||
|
| Zoekprofiel editor | `/profiles/<id>/` · profieltemplates | Hoog | Preview gebruikt laatste echte score en ververst na server-save. |
|
||||||
|
| Bronnenbeheer & gezondheid | `/sources/` · `templates/sources/list.html` | Zeer hoog | Veiligheids-, import- en mailboxflows blijven zichtbaar en progressief. |
|
||||||
|
| Automatisering centrum | `/system/` · `templates/system/status.html` | Hoog | Worker/AI/browserstatus is eerlijk onbekend of niet geconfigureerd. |
|
||||||
|
| Activiteitenlogboek | `/jobs/activity/` · `templates/activity/list.html` | Zeer hoog | Multi-model read service; geen nieuw fictief auditmodel. |
|
||||||
|
| Werkgevers intelligence | `/jobs/employers/` en detail · employertemplates | Zeer hoog | Geen verzonnen sector, contactfinder of CRM-data. |
|
||||||
|
|
||||||
|
Ook login, 403/404/500, messages, empty states, dossieredit en skillsradar gebruiken dezelfde componenttaal.
|
||||||
|
|
||||||
|
## Implementatie
|
||||||
|
|
||||||
|
### Templates en assets
|
||||||
|
|
||||||
|
Vervangen of fundamenteel herschikt: `base.html`, dashboard, jobs list/detail/skills, applications list/edit, profiles list/edit, sources list, system status en registration login. Nieuw: activity, employer index/detail en generieke fouttemplates.
|
||||||
|
|
||||||
|
`static/css/app.css` is alleen de ingang voor `tokens.css`, `base.css`, `layout.css`, `components.css`, `pages.css` en `responsive.css`. `static/js/app.js` bevat alleen progressive enhancement. Iconografie is één lokale SVG-sprite. Er zijn geen externe fonts, iconfonts, Tailwind/CDN of clientframeworks.
|
||||||
|
|
||||||
|
### Services, views en routes
|
||||||
|
|
||||||
|
- `apps/jobs/services/cockpit.py`: dashboard- en vacature-intelligence.
|
||||||
|
- `apps/jobs/services/activity.py`: begrensde, user-safe multi-model tijdlijn.
|
||||||
|
- `apps/jobs/services/employer_intelligence.py`: werkgeveraggregaties en detailreadmodel.
|
||||||
|
- `apps/core/services/automation.py`: eerlijke operationele cockpit.
|
||||||
|
- `apps/jobs/services/applications.py`: auditbare statuswijziging via POST.
|
||||||
|
- Nieuwe named routes: `jobs:activity`, `jobs:employers`, `jobs:employer-detail`, `jobs:application-status`.
|
||||||
|
|
||||||
|
Er waren geen nieuwe modellen of migraties nodig. Bestaande bronhealth is geoptimaliseerd met een per-bron geslicete prefetch.
|
||||||
|
|
||||||
|
## Echte data en security
|
||||||
|
|
||||||
|
Alle KPI's komen uit `JobPosting`, laatste `ScoreRun`, `Application`, `Source`, `SourceRun`, outbox- of profieldata. Persoonlijke feedback, dossiers, notificaties en profielrevisies zijn gebruikersgescopeerd. Vacature-HTML blijft de gesanitized backendwaarde. Externe links en POST-acties behouden bestaande securitycontracts. Er is geen automatische sollicitatie, platformloginbot, CAPTCHA-omzeiling of directe denylistcrawl toegevoegd.
|
||||||
|
|
||||||
|
## Responsive en browservalidatie
|
||||||
|
|
||||||
|
Gecontroleerd met de echte app en representatieve lokale fixtures op 360×800, 390×844, 768×1024, 1024×768, 1280×800 en 1440×900; dashboard, explorer en werkgevers ook op 1920×1080 en 2560×1440. Alle negen schermen zijn op desktop en 390 px doorlopen.
|
||||||
|
|
||||||
|
Resultaat:
|
||||||
|
|
||||||
|
- geen documentbrede horizontale overflow;
|
||||||
|
- mobiele sidebar en tabbar functioneren;
|
||||||
|
- profile preview respecteert de gridkolom;
|
||||||
|
- brede automationpipeline en tabellen scrollen alleen intern;
|
||||||
|
- geen kapotte images;
|
||||||
|
- geen browserconsole-errors of warnings;
|
||||||
|
- dark/light actielabel en opgeslagen voorkeur zijn consistent.
|
||||||
|
|
||||||
|
Screenshots:
|
||||||
|
|
||||||
|
- `artifacts/visual-validation/dashboard-desktop.png`
|
||||||
|
- `artifacts/visual-validation/matches-desktop.png`
|
||||||
|
- `artifacts/visual-validation/job-detail-desktop.png`
|
||||||
|
- `artifacts/visual-validation/applications-desktop.png`
|
||||||
|
- `artifacts/visual-validation/profile-desktop.png`
|
||||||
|
- `artifacts/visual-validation/sources-desktop.png`
|
||||||
|
- `artifacts/visual-validation/automation-desktop.png`
|
||||||
|
- `artifacts/visual-validation/activity-desktop.png`
|
||||||
|
- `artifacts/visual-validation/employers-desktop.png`
|
||||||
|
- `artifacts/visual-validation/dashboard-mobile.png`
|
||||||
|
- `artifacts/visual-validation/job-detail-mobile.png`
|
||||||
|
|
||||||
|
De afbeeldingen en Stitch-referenties zijn via `.dockerignore` uit de productieimage gehouden.
|
||||||
|
|
||||||
|
## Accessibility
|
||||||
|
|
||||||
|
De shell heeft skiplink en expliciete landmarks; iedere hoofdroute heeft één `h1`. Status gebruikt tekst, formulieren behouden echte labels/servererrors, mutaties werken zonder JavaScript en motion respecteert reduced motion. De drawer is met toetsenbord-/ARIA-state getest. Wide data heeft focusbare lokale scrollcontainers. De HTML/a11y-probe slaagt; de optionele externe pytest-Playwrightvariant is alleen beschikbaar wanneer Playwright lokaal geïnstalleerd is.
|
||||||
|
|
||||||
|
## Performance
|
||||||
|
|
||||||
|
Representatieve volledige paginaquerymetingen: dashboard 16, explorer 17, detail 15, pipeline 5, profiel 6, bronnen 14, activiteit 10 en werkgevers 14. Automation daalde door het verwijderen van de bronhealth-N+1 van 63 naar 23 queries. `collect_source_health()` is regressiegedekt op exact twee queries voor meerdere bronnen. Grote tijdlijnen en detailsets zijn begrensd; lijsten gebruiken annotaties, `select_related` en prefetching.
|
||||||
|
|
||||||
|
## Legacy-audit
|
||||||
|
|
||||||
|
Er is geen oude shell, alternatieve feature flag of parallelle frontendroute. Oude componentselectors, kleuren en monolithische pagina-opbouw zijn verwijderd. Alleen HTML-e-mailtemplates behouden bewust hun zelfstandige inline mailstijl; zij zijn geen browserfrontend. Bestaande historische validatiebeelden blijven documentair bewijs, maar worden niet gerenderd of in de productiecontainer opgenomen.
|
||||||
|
|
||||||
|
## Verificatie en beperkingen
|
||||||
|
|
||||||
|
De volledige `scripts/codex_verify.sh`-gate is zowel vóór als na ledgerafronding groen: Ruff, Django system check, `makemigrations --check --dry-run`, 248 geslaagde tests, 2 optioneel overgeslagen Playwrightvarianten, 84,40% branch-aware coverage, backlogvalidatie en repository-/documentvalidatie.
|
||||||
|
|
||||||
|
Bekende externe beperkingen wijzigen de frontend niet: live mailbox-/SMTP-credentials en productie-infrastructuur blijven afzonderlijke `blocked-external` taken. Niet-bestaande backenddata wordt bewust als onbekend getoond.
|
||||||
@@ -1,165 +1,67 @@
|
|||||||
# UX-specificatie
|
# UX-specificatie
|
||||||
|
|
||||||
## 1. Ervaringsprincipe
|
## Ervaringsprincipe
|
||||||
|
|
||||||
VacatureRadar voelt niet als een zoekmachine maar als een rustige persoonlijke inbox. De gebruiker ziet eerst beslisbare informatie en pas daarna technische details. Bronbeheer en parserproblemen blijven beschikbaar, maar domineren de dagelijkse flow niet.
|
VacatureRadar is een persoonlijke intelligence-cockpit: eerst beslisbare informatie, daarna bewijs en technische details. Het product claimt alleen wat uit echte vacatures, scores, bronruns en gebruikersacties kan worden afgeleid. Ervaring en senioriteitslabels zijn context, nooit een blokkerende scorefactor.
|
||||||
|
|
||||||
## 2. Informatiearchitectuur
|
## Informatiearchitectuur
|
||||||
|
|
||||||
Hoofdnavigatie:
|
De desktopnavigatie groepeert:
|
||||||
|
|
||||||
1. **Vandaag** — beste matches en kernstatus;
|
1. Overzicht;
|
||||||
2. **Vacatures** — volledige doorzoekbare lijst;
|
2. Nieuwe matches en het volledige vacaturearchief;
|
||||||
3. **Sollicitaties** — dossiers en opvolging;
|
3. Skillsradar;
|
||||||
4. **Profiel** — voorkeuren en drempels;
|
4. Sollicitatiepipeline;
|
||||||
5. **Bronnen** — source health en retries;
|
5. Werkgeversradar;
|
||||||
6. **Systeem** — health, versies en beheercontext.
|
6. Zoekprofiel;
|
||||||
|
7. Bronnen;
|
||||||
|
8. Automatisering;
|
||||||
|
9. Activiteit.
|
||||||
|
|
||||||
Op mobiel wordt dit een compacte uitklapbare navigatie; primaire vacatureacties blijven binnen duimbereik.
|
Mobiel blijven Overzicht, Matches, Pipeline en Profiel in de vaste tabbar; alle routes blijven bereikbaar via de toegankelijke drawer.
|
||||||
|
|
||||||
## 3. Dashboard “Vandaag”
|
## Schermgedrag
|
||||||
|
|
||||||
### Doel
|
### Overzicht
|
||||||
|
|
||||||
Binnen 30 seconden weten of er iets relevants is.
|
Toont echte dag-KPI's, beste recente radarselectie, dagconclusie, open opvolging, recente auditgebeurtenissen en bronstatus. Beste matches zijn visueel dominant; onbekende trends worden niet gesimuleerd.
|
||||||
|
|
||||||
### Volgorde
|
### Vacatureverkenner en detail
|
||||||
|
|
||||||
1. paginatitel + korte samenvatting;
|
De verkenner combineert serverfilters, sortering, actieve filterchips, paginering en op breed scherm een geselecteerd intelligencepaneel. Standaard worden alleen aantoonbaar IT-relevante, niet hard uitgesloten vacatures getoond. Het volledige bronarchief blijft expliciet bereikbaar.
|
||||||
2. KPI's: nieuwe vandaag, actieve vacatures, open dossiers, ongezonde bronnen;
|
|
||||||
3. kaarten, descending score;
|
|
||||||
4. lege toestand met concrete uitleg;
|
|
||||||
5. subtiele link naar alle vacatures/bronnen.
|
|
||||||
|
|
||||||
### Matchkaart
|
Het detail scheidt matchscore, recommendation en datakwaliteit; toont componentbewijs, positieve signalen, concerns, skills, vacaturetekst, provenance, bronnen, versies en dossierstatus. Externe bronlinks zijn veilig en nooit een automatische sollicitatie.
|
||||||
|
|
||||||
Boven de vouw:
|
### Sollicitatiepipeline
|
||||||
|
|
||||||
- recommendationlabel;
|
Kanban is de visuele standaard; de lijstweergave en statusselectie zijn de toegankelijke fallback. Statuswijzigingen zijn echte CSRF-beveiligde POST-acties en schrijven een tijdlijnevent. Dossieredit toont snapshot, contact, notities, documentenstatus en activiteit.
|
||||||
- score als `86/100` en confidence apart;
|
|
||||||
- functietitel;
|
|
||||||
- werkgever;
|
|
||||||
- locatie + werkvorm;
|
|
||||||
- maximaal twee kernpluspunten;
|
|
||||||
- maximaal één belangrijkste concern;
|
|
||||||
- acties: bekijken, interessant, bewaren, verbergen, gesolliciteerd.
|
|
||||||
|
|
||||||
Regels:
|
### Zoekprofiel en skillsradar
|
||||||
|
|
||||||
- geen score zonder label/uitleg;
|
De configurator groepeert profielbasis, rollen, technologiesignalen, regio, werkvorm en overige voorkeuren. Postcode volstaat; gemeente en coördinaten komen uit de gelicenseerde lokale dataset. Rollen en skills zijn selecties/checkboxes, geen ruwe JSON. De preview gebruikt de laatste echte score en revisiehistoriek.
|
||||||
- onbekende data als “Niet vermeld” of “Nog niet berekend”;
|
|
||||||
- hard uitgesloten jobs niet op Vandaag;
|
|
||||||
- dubbele aliassen niet als aparte kaart.
|
|
||||||
|
|
||||||
## 4. Vacaturelijst
|
De skillsradar toont vraagvolume, profieldekking, expliciet skillveldbewijs versus tekstsignalen en niet-blokkerende leerkansen.
|
||||||
|
|
||||||
Filters:
|
### Bronnen en automatisering
|
||||||
|
|
||||||
- vrije tekst;
|
Bronnen tonen policy en operationele status apart, plus laatste run, volgende planning, parser, tellingen, retry, imports en gebruikersgescopeerde platformmailboxen. Directe crawling van denylistplatformen blijft verboden.
|
||||||
- status;
|
|
||||||
- recommendation/scoreband;
|
|
||||||
- werkvorm;
|
|
||||||
- regio;
|
|
||||||
- werkgeverstype;
|
|
||||||
- feedbackstatus.
|
|
||||||
|
|
||||||
Desktop gebruikt een compacte filterbalk; mobiel een disclosurepanel. Filters blijven in querystring zodat bookmarks en back navigation werken.
|
Automatisering toont de echte 24-uursketen, database/readiness, bronruns, scoring en notificaties. Worker-, AI- of browserstatus wordt als niet gemeten/niet geconfigureerd gelabeld wanneer geen betrouwbare probe bestaat.
|
||||||
|
|
||||||
Tabel of kaarten kiezen op breedte. Geen horizontale scroll voor kernvelden.
|
### Activiteit en werkgevers
|
||||||
|
|
||||||
## 5. Vacaturedetail
|
Activiteit is een gepagineerde, filterbare tijdlijn uit bronruns, vacatureversies, feedback, dossiers, notificaties en profielrevisies. Persoonlijke events zijn gebruikersgescopeerd.
|
||||||
|
|
||||||
Secties:
|
Werkgevers combineert genormaliseerde werkgever-, vacature-, score-, bron- en dossierdata in een index/detailervaring. Sector, contact of marktclaims worden niet verzonnen.
|
||||||
|
|
||||||
1. titel, werkgever, status, canonical link;
|
## States en copy
|
||||||
2. score-overzicht met componenten;
|
|
||||||
3. “Waarom aanbevolen” en aandachtspunten;
|
|
||||||
4. harde uitsluitingen indien via directe link geopend;
|
|
||||||
5. vacaturetekst (gesanitized);
|
|
||||||
6. voorwaarden/skills/locatie;
|
|
||||||
7. bronnen en provenance;
|
|
||||||
8. wijzigingshistorie;
|
|
||||||
9. sollicitatiestatus/notities.
|
|
||||||
|
|
||||||
Bronbewijs wordt technisch maar scanbaar getoond: veld, methode, confidence, excerpt en bron.
|
- Empty: verklaart waarom en biedt één relevante vervolgstap.
|
||||||
|
- Unknown: “Niet vermeld”, “Niet gemeten” of “Niet geconfigureerd”.
|
||||||
|
- Error/partial/stale: toont impact en een veilige retry waar ondersteund, nooit een stacktrace.
|
||||||
|
- Success: servermessage in een `aria-live` toast en blijvende nieuwe toestand.
|
||||||
|
- Copy is Nederlands, direct en niet-oordelend: “Mogelijke match” en “Niet teruggevonden” in plaats van absolute geschiktheidsclaims.
|
||||||
|
|
||||||
## 6. Profielwizard/editor
|
## Responsiveness
|
||||||
|
|
||||||
De eerste onboarding wordt in maximaal vijf logische stappen opgezet:
|
Desktop bewaart de Stitch-kolomverhoudingen. Tablet stapelt intelligence-rails onder de hoofdtaak. Mobiel herschikt kaarten en forms; wide data gebruikt een lokale scroller. De browsermatrix omvat 360×800, 390×844, 768×1024, 1024×768, 1280×800, 1440×900 en aanvullende 1920/2560px-controles.
|
||||||
|
|
||||||
1. rollen en ongewenste titels;
|
|
||||||
2. skills en ongewenste verplichte skills;
|
|
||||||
3. regio, afstand en werkvorm;
|
|
||||||
4. contract en employerpreferences;
|
|
||||||
5. digest en samenvatting.
|
|
||||||
|
|
||||||
De bestaande editor mag één pagina blijven voor MVP, maar groepeert velden visueel en geeft voorbeeldsyntax. JSON mag in de interne modelvorm bestaan; de eindgebruiker ziet uiteindelijk chips/checkboxes, geen ruwe JSON.
|
|
||||||
|
|
||||||
Opslaan toont:
|
|
||||||
|
|
||||||
- welke harde regels actief zijn;
|
|
||||||
- scoregewichten in begrijpelijke labels;
|
|
||||||
- profielversie;
|
|
||||||
- mogelijkheid om learning uit/reset te zetten.
|
|
||||||
|
|
||||||
## 7. Bronnen
|
|
||||||
|
|
||||||
Bronkaart/-rij toont:
|
|
||||||
|
|
||||||
- naam en domein;
|
|
||||||
- type;
|
|
||||||
- policy en operationele status apart;
|
|
||||||
- laatste succes/fout;
|
|
||||||
- volgende run;
|
|
||||||
- parser/confidence;
|
|
||||||
- laatste tellingen;
|
|
||||||
- retryknop indien policy niet deny.
|
|
||||||
|
|
||||||
Kleur is nooit de enige indicator. `deny`, `review`, `quarantined` hebben tekst en icoon/patroon.
|
|
||||||
|
|
||||||
## 8. Sollicitaties
|
|
||||||
|
|
||||||
Kanban is niet noodzakelijk. Een duidelijke lijst met status, werkgever, functie, applied date, follow-up date en eerstvolgende actie is beter voor laag volume.
|
|
||||||
|
|
||||||
Dossierdetail/editor bevat:
|
|
||||||
|
|
||||||
- immutable vacaturesnapshot;
|
|
||||||
- huidige bronlink/status;
|
|
||||||
- contact;
|
|
||||||
- notities;
|
|
||||||
- documentenmanifest;
|
|
||||||
- tijdlijn van statuswijzigingen (backlog);
|
|
||||||
- opvolgdatum.
|
|
||||||
|
|
||||||
## 9. Meldingen
|
|
||||||
|
|
||||||
Digestcopy:
|
|
||||||
|
|
||||||
```text
|
|
||||||
2 sterke matches
|
|
||||||
4 mogelijke matches
|
|
||||||
11 automatisch uitgesloten
|
|
||||||
1 opgeslagen vacature sluit binnenkort
|
|
||||||
```
|
|
||||||
|
|
||||||
Elke vacature heeft titel, werkgever, locatie, score, twee redenen, één aandachtspunt en originele link. Geen volledige vacaturetekst in mail.
|
|
||||||
|
|
||||||
## 10. States
|
|
||||||
|
|
||||||
Iedere pagina specificeert:
|
|
||||||
|
|
||||||
- loading: alleen nodig bij async enhancement; serverrender toont normale response;
|
|
||||||
- empty: leg uit waarom en geef één relevante actie;
|
|
||||||
- error: concrete fout + veilige retry, geen stacktrace;
|
|
||||||
- partial: bron/resultaten beschikbaar met warning;
|
|
||||||
- stale: toon laatste succesvolle tijd en dat vernieuwing faalde;
|
|
||||||
- unauthorized: login en geen informatielek.
|
|
||||||
|
|
||||||
## 11. Copyrichtlijnen
|
|
||||||
|
|
||||||
- Nederlands, direct en niet-oordelend;
|
|
||||||
- “Mogelijke match” in plaats van “Je bent geschikt”;
|
|
||||||
- “Niet teruggevonden” in plaats van “Je mist”;
|
|
||||||
- “Rechtstreekse werkgeversbron” in plaats van absolute garantie;
|
|
||||||
- percentages alleen wanneer betekenis duidelijk is;
|
|
||||||
- technische bronfout in beheer, eenvoudige impacttekst op dashboard.
|
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
# VR-206 volledige UX-, logica- en browseraudit
|
||||||
|
|
||||||
|
Datum: 2026-07-22
|
||||||
|
|
||||||
|
## Uitgangssituatie
|
||||||
|
|
||||||
|
De productiecontrole toonde 242 actieve vacatures. De standaardlijst sorteerde hoofdzakelijk op
|
||||||
|
`first_seen` en liet elke actieve vacature zien. Daardoor stonden onder meer facilitaire techniek,
|
||||||
|
logopedie, vroedkunde en niet-IT-onderzoek tussen de persoonlijke IT-resultaten. Op 2560 px bleef
|
||||||
|
de inhoud bovendien op ongeveer 1280 px begrensd, waardoor de Stitch-cockpit een brede lege zone
|
||||||
|
overliet.
|
||||||
|
|
||||||
|
## Bevindingen en oplossing
|
||||||
|
|
||||||
|
| Prioriteit | Bevinding | Oplossing |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| P0 | Niet-IT-vacatures bereikten Vandaag en de standaardfeed. | Titelgestuurde, deterministische IT-classificatie vóór score/AI; niet-IT blijft alleen in het expliciete volledige archief. |
|
||||||
|
| P0 | Vandaag gebruikte niet overal de laatste gebruikersgescopeerde score en open dossiercijfers waren niet volledig gebruikersgescopeerd. | Laatste score per vacature/profiel via subquery; profiel, scores en dossiers zijn aan de aangemelde gebruiker gebonden. |
|
||||||
|
| P1 | Verborgen/hard uitgesloten IT-vacatures stonden hoog door hun ruwe score. | Nieuwe standaardkeuze `Radarselectie` laat verborgen resultaten weg; `Verborgen` en `Alle IT-scores` blijven bewuste inspectiekeuzes. |
|
||||||
|
| P1 | De lijst sorteerde op bronrecency in plaats van persoonlijke besliswaarde. | Standaard matchscore aflopend, met alternatieven voor nieuwste en sluitingsdatum. |
|
||||||
|
| P1 | Stitch werd visueel gebruikt, maar ultrawide bleef een smalle strook. | Adaptieve contentbreedte tot 2240 px, tweekoloms feeds vanaf 1850 px en context-/actierails op breed desktop. |
|
||||||
|
| P1 | Vacaturekaarten boden weinig scorebewijs. | Score, datakwaliteit, eerste pluspunt, eerste aandachtspunt, status, werkmodel en directe actie staan in de feed. |
|
||||||
|
| P2 | Confidencewaarden wisselden tussen breuken en percentages. | Alle dagelijkse score- en extractiekwaliteit wordt als percentage getoond. |
|
||||||
|
| P2 | Inline feedback accepteerde een willekeurige `next`-URL. | Terugkeer alleen naar dezelfde host, met negatieve regressietest. |
|
||||||
|
| P2 | De bronpagina liep op 320 px uit door statuschips naast lange sectiekoppen. | Sectiekoppen stapelen onder 680 px; de 320 px matrix heeft geen horizontale overflow. |
|
||||||
|
| P2 | Sommige browsers vroegen `/favicon.ico` en kregen een 404. | Lokale permanente fallback naar het bestaande SVG-favicon. |
|
||||||
|
|
||||||
|
## Relevantieregel
|
||||||
|
|
||||||
|
De classifier is bewust conservatief en titelgestuurd. Een beschrijving die toevallig “digitale
|
||||||
|
systemen” vermeldt, maakt een zorg- of facilitaire functie niet relevant. Duidelijke IT-families
|
||||||
|
zoals infrastructuur, netwerk, cloud, security, software, data, applicatiebeheer en workplace
|
||||||
|
worden toegelaten. Commerciële of recruitmentcontext die IT-woorden bevat, wordt expliciet
|
||||||
|
afgewezen. Dezelfde regexcontracten worden in Python en in de databasequery gebruikt.
|
||||||
|
|
||||||
|
Op de lokale auditdataset met 336 actieve records bleven 108 titelmatig aantoonbare IT-vacatures
|
||||||
|
over en werden 228 niet-IT-records uit de standaardfocus gehouden. Na uitsluiting van verborgen
|
||||||
|
matches bevatte de persoonlijke radarselectie 102 kaarten. Het volledige archief blijft 336
|
||||||
|
records bevatten: er wordt geen bronhistoriek gewist.
|
||||||
|
|
||||||
|
## Stitch-vertaling
|
||||||
|
|
||||||
|
- Dashboard: KPI-strip, beslisbare matchfeed en vaste radar-/actierail.
|
||||||
|
- Vacaturelijst: brede commandofilter, compacte evidencekaarten, scorehiërarchie en radarcontext.
|
||||||
|
- Detail: scorehero, intelligence-strip, bronhistorie en menselijke actierail.
|
||||||
|
- Shell: vaste desktopnavigatie, compacte utilitybar, lokale iconen en mobiele drawer.
|
||||||
|
- Ultrawide: twee inhoudskolommen plus context; geen kunstmatig uitgerekte tekstregels of fictieve grafieken.
|
||||||
|
|
||||||
|
Alle cijfers en signalen komen uit de database. Quick apply, automatische sollicitatie,
|
||||||
|
platformloginbots, fictieve trendgrafieken en externe frontendassets blijven bewust afwezig.
|
||||||
|
|
||||||
|
## Browsermatrix
|
||||||
|
|
||||||
|
Handmatig gecontroleerd in de geïntegreerde browser op 320×800, 390×844, 1024×768, 1440×900,
|
||||||
|
1920×1080 en 2560×1440. De zes kernroutes (`/`, `/jobs/`, `/jobs/applications/`, `/profiles/`,
|
||||||
|
`/sources/`, `/system/`) hebben één `h1`, gelabelde formuliervelden en geen horizontale
|
||||||
|
paginaoverflow. De vacaturefeed schakelt van één kolom naar twee kolommen op ultrawide en gebruikt
|
||||||
|
daar 2240 px van de beschikbare ruimte. Mobiele navigatie opent en sluit met correcte
|
||||||
|
`aria-expanded`-status.
|
||||||
|
|
||||||
|
## Resterend operationeel punt
|
||||||
|
|
||||||
|
Na deployment moet de bestaande productie-inventaris opnieuw gescoord worden zodat opgeslagen
|
||||||
|
score-evidence eveneens het nieuwe `it_relevance`-blok bevat. De viewfilter werkt onmiddellijk en
|
||||||
|
onafhankelijk daarvan; het herscoren verbetert de uitleg en blijvende `hidden`-status.
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
# Unraid-deployment
|
# Unraid-deployment
|
||||||
|
|
||||||
Deze handleiding gebruikt `docker-compose.unraid.yml` als declaratieve bron van waarheid. Unraid-installaties verschillen in gebruikte Compose Manager, reverse proxy en shares; behoud de hieronder genoemde volumes, secrets en netwerkgrenzen ook wanneer de UI de services afzonderlijk aanmaakt.
|
Deze handleiding gebruikt `docker-compose.unraid.yml` als declaratieve bron van waarheid. De Unraid-variant bundelt web, worker, scheduler, PostgreSQL en Redis bewust in één Dockerman-container met Supervisor als procesbewaker. De gewone `docker-compose.yml` behoudt de gescheiden productieprocessen voor andere hosts.
|
||||||
|
|
||||||
## Vooraf
|
## Vooraf
|
||||||
|
|
||||||
@@ -10,17 +10,25 @@ Maak deze directories:
|
|||||||
/mnt/user/appdata/vacatureradar/config
|
/mnt/user/appdata/vacatureradar/config
|
||||||
/mnt/user/appdata/vacatureradar/media
|
/mnt/user/appdata/vacatureradar/media
|
||||||
/mnt/user/appdata/vacatureradar/logs
|
/mnt/user/appdata/vacatureradar/logs
|
||||||
/mnt/user/appdata/vacatureradar/postgres
|
/mnt/user/appdata/vacatureradar/source/local/postgres-aio17
|
||||||
/mnt/user/appdata/vacatureradar/redis
|
/mnt/user/appdata/vacatureradar/source/local/redis-aio
|
||||||
/mnt/user/appdata/vacatureradar/ollama # alleen bij lokale AI
|
/mnt/user/appdata/vacatureradar/ollama # alleen bij lokale AI
|
||||||
/mnt/user/backups/vacatureradar
|
/mnt/user/backups/vacatureradar
|
||||||
```
|
```
|
||||||
|
|
||||||
Gebruik bij voorkeur een cache-backed `appdata`-share en neem die afzonderlijk in je back-upregime op. De PostgreSQL-datadir mag niet gelijktijdig door file-syncsoftware worden gemuteerd.
|
Gebruik bij voorkeur een cache-backed `appdata`-share en neem die afzonderlijk in je back-upregime op. De PostgreSQL-datadir mag niet gelijktijdig door file-syncsoftware worden gemuteerd.
|
||||||
|
|
||||||
|
## Dockerman
|
||||||
|
|
||||||
|
De container verschijnt als **VacatureRadar** en bevat metadata voor:
|
||||||
|
|
||||||
|
- WebUI: `http://[IP]:1226/`;
|
||||||
|
- icoon: het lokale VacatureRadar-favicon op `/static/favicon.svg`;
|
||||||
|
- één herstart- en healthcheckpunt voor alle interne processen.
|
||||||
|
|
||||||
## Imagekeuze
|
## Imagekeuze
|
||||||
|
|
||||||
De Unraid-compose bevat bewust `ghcr.io/CHANGE_ME/vacatureradar:latest`. Codex kan code, Dockerfile en CI afwerken zonder registrycredentials, maar voor productie moet een van deze paden worden gekozen:
|
De Unraid-compose bouwt lokaal met `Dockerfile.unraid` en tagt de image als `vacatureradar:unraid`. Pin voor reproduceerbare releases aanvullend een commitgebonden tag.
|
||||||
|
|
||||||
1. bouw lokaal op de Unraid-host en vervang `image:` door een vaste lokale tag;
|
1. bouw lokaal op de Unraid-host en vervang `image:` door een vaste lokale tag;
|
||||||
2. publiceer vanuit CI naar een private registry en pin op een immutable digest;
|
2. publiceer vanuit CI naar een private registry en pin op een immutable digest;
|
||||||
@@ -61,9 +69,7 @@ SESSION_COOKIE_SECURE=1 CSRF_COOKIE_SECURE=1 SECURE_SSL_REDIRECT=1 \
|
|||||||
bash scripts/deploy_docker.sh
|
bash scripts/deploy_docker.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
3. Start eerst PostgreSQL en Redis.
|
3. Start de ene appcontainer; de entrypoint initialiseert PostgreSQL, voert migraties uit en start daarna alle processen onder Supervisor.
|
||||||
4. Start web; de entrypoint voert migraties uit.
|
|
||||||
5. Start worker en scheduler.
|
|
||||||
6. Controleer `GET /health/live/` en `GET /health/ready/`.
|
6. Controleer `GET /health/live/` en `GET /health/ready/`.
|
||||||
6. Voer eenmalig in de webcontainer uit:
|
6. Voer eenmalig in de webcontainer uit:
|
||||||
|
|
||||||
@@ -144,10 +150,10 @@ Pin een modelnaam in `OLLAMA_MODEL`; zonder model blijft `OLLAMA_ENABLED=0`. Ste
|
|||||||
2. Lees `CHANGELOG.md` en de releaseartefacten.
|
2. Lees `CHANGELOG.md` en de releaseartefacten.
|
||||||
3. Als de release nog niet geverifieerd is, voer `./scripts/release_verify.sh` lokaal uit.
|
3. Als de release nog niet geverifieerd is, voer `./scripts/release_verify.sh` lokaal uit.
|
||||||
4. Pull/bouw de nieuwe immutable image.
|
4. Pull/bouw de nieuwe immutable image.
|
||||||
5. Stop scheduler en worker, daarna web.
|
5. Stop de appcontainer.
|
||||||
6. Start web en laat migraties uitvoeren.
|
6. Start de nieuwe appcontainer en laat de entrypoint migraties uitvoeren.
|
||||||
7. Controleer health, login en release-smoke-rapport.
|
7. Controleer health, login en release-smoke-rapport.
|
||||||
8. Start worker en scheduler.
|
8. Controleer in de containerlogs dat web, worker en scheduler actief zijn.
|
||||||
9. Bewaar de vorige image/digest totdat één volledige schedulercyclus goed verliep.
|
9. Bewaar de vorige image/digest totdat één volledige schedulercyclus goed verliep.
|
||||||
|
|
||||||
## Rollback
|
## Rollback
|
||||||
|
|||||||
@@ -97,7 +97,7 @@ De IDs zijn stabiel en worden gebruikt in tests en traceability.
|
|||||||
### Matching en ranking
|
### Matching en ranking
|
||||||
|
|
||||||
- **PR-040** — Harde regels worden vóór scoring toegepast en zijn uitlegbaar.
|
- **PR-040** — Harde regels worden vóór scoring toegepast en zijn uitlegbaar.
|
||||||
- **PR-041** — Scorecomponenten omvatten inhoud, skills, locatie, voorwaarden, werkgever, senioriteit en voorkeuren.
|
- **PR-041** — Scorecomponenten omvatten inhoud, skills, locatie, voorwaarden, werkgever en voorkeuren. Ervaringsduur en senioriteitslabels blijven uitsluitend informatieve context en beïnvloeden score, aanbeveling of uitsluiting niet.
|
||||||
- **PR-042** — Onbekende data verlaagt confidence en wordt niet automatisch als negatief feit behandeld.
|
- **PR-042** — Onbekende data verlaagt confidence en wordt niet automatisch als negatief feit behandeld.
|
||||||
- **PR-043** — Iedere score bewaart componenten, positives, concerns, uitsluitingen en evidence.
|
- **PR-043** — Iedere score bewaart componenten, positives, concerns, uitsluitingen en evidence.
|
||||||
- **PR-044** — Profielversie en model-/promptversie worden aan een score gekoppeld.
|
- **PR-044** — Profielversie en model-/promptversie worden aan een score gekoppeld.
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ Django/Celery
|
|||||||
| ID | Dreiging | Impact | Mitigaties | Verificatie |
|
| ID | Dreiging | Impact | Mitigaties | Verificatie |
|
||||||
|---|---|---|---|---|
|
|---|---|---|---|---|
|
||||||
| T-01 | SSRF via bron- of vacature-URL | toegang tot LAN, metadata of beheerinterfaces | schema/host/IP/poortcontrole, DNS-resolutie, redirecthercontrole, denylist, geen credentials in URL | `tests/security/test_url_security.py`, `test_policy.py`, fetchertests |
|
| T-01 | SSRF via bron- of vacature-URL | toegang tot LAN, metadata of beheerinterfaces | schema/host/IP/poortcontrole, DNS-resolutie, redirecthercontrole, denylist, geen credentials in URL | `tests/security/test_url_security.py`, `test_policy.py`, fetchertests |
|
||||||
| T-02 | DNS rebinding/tijd-van-controle | request bereikt ander IP dan beoordeeld | iedere fetch en redirect valideert opnieuw DNS-resolutie en vereist consistente IP-set-overlap | `VR-115`, `tests/unit/test_fetcher.py`, aanvullende fetcher negative tests |
|
| T-02 | DNS rebinding/tijd-van-controle | request bereikt ander IP dan beoordeeld | iedere fetch en redirect valideert DNS opnieuw; het werkelijk verbonden peer-IP wordt als publiek gecontroleerd wanneer de transportlaag dit exposeert, anders geldt conservatieve DNS-set-overlap | `VR-115`, `tests/unit/test_fetcher.py`, aanvullende fetcher negative tests |
|
||||||
| T-03 | XSS/HTML-injectie | sessiediefstal of ongewenste navigatie | Bleach allowlist, platte tekst, template autoescaping, CSP/securityheaders | `tests/security/test_sanitize.py`, viewtests |
|
| T-03 | XSS/HTML-injectie | sessiediefstal of ongewenste navigatie | Bleach allowlist, platte tekst, template autoescaping, CSP/securityheaders | `tests/security/test_sanitize.py`, viewtests |
|
||||||
| T-04 | Prompt injection in vacature | AI verandert beleid of voert tools uit | vacaturetekst in data-envelope, schema-output, evidence-validatie, cachefouten met foutcategorie, geen toolrechten, deterministische harde regels, AI optioneel | AI-tests, ADR-0003, `VR-109` evaluaties |
|
| T-04 | Prompt injection in vacature | AI verandert beleid of voert tools uit | vacaturetekst in data-envelope, schema-output, evidence-validatie, cachefouten met foutcategorie, geen toolrechten, deterministische harde regels, AI optioneel | AI-tests, ADR-0003, `VR-109` evaluaties |
|
||||||
| T-05 | Malafide/oversized document | geheugen/CPU/diskuitputting | contenttype-, byte-, timeout- en redirectlimieten; korte retentie | fetchertests, source-runmetrics |
|
| T-05 | Malafide/oversized document | geheugen/CPU/diskuitputting | contenttype-, byte-, timeout- en redirectlimieten; korte retentie | fetchertests, source-runmetrics |
|
||||||
@@ -80,6 +80,7 @@ Django/Celery
|
|||||||
| T-38 | Cross-user mailboxbeheer, dubbele polls of IMAP naar private host | credentialmisbruik, duplicaten, toegang tot interne diensten | alle views scopen op gebruiker en vereisen CSRF; unieke user+platformkoppeling; transactionele lease; hostvorm en publieke DNS/IP-validatie vóór TLS-IMAP | `tests/integration/test_mailbox_connections.py`, `tests/unit/test_mailbox_credentials.py`, `apps/sources/services/imap_import.py`, `VR-123` |
|
| T-38 | Cross-user mailboxbeheer, dubbele polls of IMAP naar private host | credentialmisbruik, duplicaten, toegang tot interne diensten | alle views scopen op gebruiker en vereisen CSRF; unieke user+platformkoppeling; transactionele lease; hostvorm en publieke DNS/IP-validatie vóór TLS-IMAP | `tests/integration/test_mailbox_connections.py`, `tests/unit/test_mailbox_credentials.py`, `apps/sources/services/imap_import.py`, `VR-123` |
|
||||||
| T-39 | Mol-/Kempenbron vermengt een andere werkgever, campus of vestiging | foutieve afstand, phishing of buitenregionale resultaten | exacte HTTPS-host/pad/GUID-routes; same-host details; expliciete plaatsfilters voor regionale en gedeelde lijsten; postcodecentra blijven als benadering herkenbaar; waaklijstbronnen worden niet gescand | `tests/unit/test_regional_adapters.py`, `tests/integration/test_tasks_and_more_views.py`, `VR-126/201` |
|
| T-39 | Mol-/Kempenbron vermengt een andere werkgever, campus of vestiging | foutieve afstand, phishing of buitenregionale resultaten | exacte HTTPS-host/pad/GUID-routes; same-host details; expliciete plaatsfilters voor regionale en gedeelde lijsten; postcodecentra blijven als benadering herkenbaar; waaklijstbronnen worden niet gescand | `tests/unit/test_regional_adapters.py`, `tests/integration/test_tasks_and_more_views.py`, `VR-126/201` |
|
||||||
| T-40 | Verouderde lokale CA-store of ongeldige serverketen lokt een TLS-bypass uit | onderschepte vacature- of mailboxdata en credentialdiefstal | expliciet gepinde `certifi`-CA-bundel voor HTTP, robots en IMAP; hostnaamcontrole en `CERT_REQUIRED`; nooit `verify=False`; dependency- en negatieve TLS-tests bij update | `tests/unit/test_tls.py`, `tests/security/test_robots.py`, `apps/sources/services/tls.py`, `VR-201` |
|
| T-40 | Verouderde lokale CA-store of ongeldige serverketen lokt een TLS-bypass uit | onderschepte vacature- of mailboxdata en credentialdiefstal | expliciet gepinde `certifi`-CA-bundel voor HTTP, robots en IMAP; hostnaamcontrole en `CERT_REQUIRED`; nooit `verify=False`; dependency- en negatieve TLS-tests bij update | `tests/unit/test_tls.py`, `tests/security/test_robots.py`, `apps/sources/services/tls.py`, `VR-201` |
|
||||||
|
| T-41 | Gemodificeerde `next`-parameter na vacaturefeedback | open redirect naar phishingdomein na een geldige gebruikersactie | terugkeer-URL moet dezelfde host en het actuele HTTPS-beleid respecteren; anders veilige detailfallback | `tests/integration/test_tasks_and_more_views.py`, `VR-206` |
|
||||||
|
|
||||||
## Misbruikscenario's
|
## Misbruikscenario's
|
||||||
|
|
||||||
@@ -112,7 +113,7 @@ Een wijziging aan netwerk, mail, AI, rendering, auth, exports of uploads vereist
|
|||||||
|
|
||||||
## Resterende risico's
|
## Resterende risico's
|
||||||
|
|
||||||
- Een applicatielaag kan DNS rebinding niet volledig uitsluiten zonder transportniveau-IP-pinning; dit staat in `VR-115`.
|
- De fetcher controleert het werkelijk verbonden peer-IP wanneer httpcore dat exposeert. Een custom transport dat geen peerinformatie aanbiedt valt terug op DNS-set-overlap; volledige IP-pinning blijft een aanvullend egress-hardeningniveau.
|
||||||
- Brute-force of herhaalde foutieve mutatiepogingen blijven mogelijk, maar zijn beperkt door in-memory rate limiting en blokkades op login/manual-import.
|
- Brute-force of herhaalde foutieve mutatiepogingen blijven mogelijk, maar zijn beperkt door in-memory rate limiting en blokkades op login/manual-import.
|
||||||
- Bronvoorwaarden vereisen menselijke/externe review per domein; automatisering kan dat niet juridisch beslissen.
|
- Bronvoorwaarden vereisen menselijke/externe review per domein; automatisering kan dat niet juridisch beslissen.
|
||||||
- Fuzzy dedupe en featureheuristieken kunnen inhoudelijk verkeerd zijn; provenance, feedback en benchmarkevaluaties beperken maar elimineren dit niet.
|
- Fuzzy dedupe en featureheuristieken kunnen inhoudelijk verkeerd zijn; provenance, feedback en benchmarkevaluaties beperken maar elimineren dit niet.
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ Legenda: **I** = geïmplementeerd en getest in de huidige MVP; **P** = gedeeltel
|
|||||||
| ID | Status | Primaire implementatie | Bewijs/tests | Vervolgtaak |
|
| ID | Status | Primaire implementatie | Bewijs/tests | Vervolgtaak |
|
||||||
|---|---|---|---|---|
|
|---|---|---|---|---|
|
||||||
| PR-001 | I | `profiles/models.py`, forms/views | `test_profiles.py`, `test_views.py` | — |
|
| PR-001 | I | `profiles/models.py`, forms/views | `test_profiles.py`, `test_views.py` | — |
|
||||||
| PR-002 | I | `SearchProfile`, defaults/form, geocodeparser | `tests/unit/test_geocoding.py`, `tests/integration/test_import_geodata.py`, profiel- en viewtests | VR-105 verfijnt afstand |
|
| PR-002 | I | `SearchProfile`, begeleide skilltaxonomie, informatieve ervaringscontext, defaults/form en geocodeparser | `tests/unit/test_geocoding.py`, `tests/integration/test_profile_preferences.py`, profiel- en viewtests | VR-105/VR-207/VR-208 |
|
||||||
| PR-003 | I | `ProfileRevision`, profile services | `test_profiles.py` | — |
|
| PR-003 | I | `ProfileRevision`, profile services | `test_profiles.py` | — |
|
||||||
| PR-004 | I | begrensde feedbackservice/velden | `tests/unit/test_feedback_learning.py`, `test_profiles.py` | VR-110 uitgevoerd |
|
| PR-004 | I | begrensde feedbackservice/velden | `tests/unit/test_feedback_learning.py`, `test_profiles.py` | VR-110 uitgevoerd |
|
||||||
| PR-005 | I | `bootstrap_instance`, defaults | task/viewtests | — |
|
| PR-005 | I | `bootstrap_instance`, defaults | task/viewtests | — |
|
||||||
@@ -32,14 +32,14 @@ Legenda: **I** = geïmplementeerd en getest in de huidige MVP; **P** = gedeeltel
|
|||||||
| PR-031 | I | conservatieve fuzzy dedupe | `tests/unit/test_dedupe_lifecycle_feedback.py`, `scripts/benchmark.py`, `tests/benchmark/test_benchmark.py` | VR-116 benchmark |
|
| PR-031 | I | conservatieve fuzzy dedupe | `tests/unit/test_dedupe_lifecycle_feedback.py`, `scripts/benchmark.py`, `tests/benchmark/test_benchmark.py` | VR-116 benchmark |
|
||||||
| PR-032 | I | direct-sourcepreferentie/alias | pipeline- en dedupetests | VR-108 resolver |
|
| PR-032 | I | direct-sourcepreferentie/alias | pipeline- en dedupetests | VR-108 resolver |
|
||||||
| PR-033 | I | canonical constraints/transacties | replaytests | — |
|
| PR-033 | I | canonical constraints/transacties | replaytests | — |
|
||||||
| PR-040 | I | features/scoring | scoringtests | — |
|
| PR-040 | I | features/scoring + deterministische IT-relevantie vóór AI, inclusief contextgebonden fieldtitels | `tests/unit/test_scoring.py`, `tests/unit/test_relevance.py`, viewtests | VR-206/VR-207 |
|
||||||
| PR-041 | I | scorecomponenten | `tests/unit/test_features_distance.py`, `tests/unit/test_scoring.py` | VR-105 afgerond |
|
| PR-041 | I | scorecomponenten met begrensde skill-evidence; ervaring/senioriteit aantoonbaar zonder score-invloed | `tests/unit/test_features_distance.py`, `tests/unit/test_scoring.py` | VR-105/VR-207/VR-208 |
|
||||||
| PR-042 | I | unknown/confidencegedrag | `tests/unit/test_features_distance.py`, `tests/unit/test_scoring.py` | VR-105 |
|
| PR-042 | I | unknown/confidencegedrag | `tests/unit/test_features_distance.py`, `tests/unit/test_scoring.py` | VR-105 |
|
||||||
| PR-043 | I | `ScoreRun` | pipeline/scoringtests | — |
|
| PR-043 | I | `ScoreRun` plus uitlegbare skillvraag-/dekkingsanalyse | pipeline-/scoringtests, `tests/unit/test_skill_demand.py` | VR-208 |
|
||||||
| PR-044 | I | profiel/modelversievelden | scoring/profiletests | VR-109 promptversie |
|
| PR-044 | I | profiel/modelversievelden | scoring/profiletests | VR-109 promptversie |
|
||||||
| PR-045 | I | optionele Ollamaservice/fallback | `test_ai.py` | VR-109 |
|
| PR-045 | I | optionele Ollamaservice/fallback | `test_ai.py` | VR-109 |
|
||||||
| PR-046 | I | geen tools + guardrails | AI/securitytests | VR-109/115 evaluaties |
|
| PR-046 | I | geen tools + guardrails | AI/securitytests | VR-109/115 evaluaties |
|
||||||
| PR-050 | I | dashboard view/template + Stitch-dominante cockpitshell/feed/rail | viewtests, VR-119 browsermatrix | VR-114/118/119 e2e/a11y |
|
| PR-050 | I | dashboard/list/skillsradar + Stitch-dominante cockpitshell/feed/rail en ultrawide compositie | viewtests, skill-insighttests, VR-119/206/208 browsermatrix | VR-114/118/119/206/208 e2e/a11y |
|
||||||
| PR-051 | I | detailview/template/provenance + scorehero/intelligence-strip | viewtests, VR-119 screenshots | VR-114/118/119 |
|
| PR-051 | I | detailview/template/provenance + scorehero/intelligence-strip | viewtests, VR-119 screenshots | VR-114/118/119 |
|
||||||
| PR-052 | I | feedbackendpoint/UI | feedback/viewtests | — |
|
| PR-052 | I | feedbackendpoint/UI | feedback/viewtests | — |
|
||||||
| PR-053 | I | applicationmodel/serviceflow | `tests/integration/test_dedupe_lifecycle_feedback.py`, `tests/integration/test_applications.py` | VR-113 uitgebreid dossier |
|
| PR-053 | I | applicationmodel/serviceflow | `tests/integration/test_dedupe_lifecycle_feedback.py`, `tests/integration/test_applications.py` | VR-113 uitgebreid dossier |
|
||||||
@@ -59,11 +59,11 @@ Legenda: **I** = geïmplementeerd en getest in de huidige MVP; **P** = gedeeltel
|
|||||||
| NFR-001 | I | policy-, URL- en fetchersecuritytests + settings/rate-limit tests | `tests/unit/test_fetcher.py`, `tests/unit/test_rate_limit.py`, `tests/unit/test_settings_security.py`, `tests/integration/test_views.py`, `tests/integration/test_sources_manual_import.py` |
|
| NFR-001 | I | policy-, URL- en fetchersecuritytests + settings/rate-limit tests | `tests/unit/test_fetcher.py`, `tests/unit/test_rate_limit.py`, `tests/unit/test_settings_security.py`, `tests/integration/test_views.py`, `tests/integration/test_sources_manual_import.py` |
|
||||||
| NFR-002 | P | raw-retentie, minimale modellen, privacyrunbooks | VR-113/115 |
|
| NFR-002 | P | raw-retentie, minimale modellen, privacyrunbooks | VR-113/115 |
|
||||||
| NFR-003 | I/P | replay-, mail- en outboxtests; per-bronfouten | VR-103/112 |
|
| NFR-003 | I/P | replay-, mail- en outboxtests; per-bronfouten | VR-103/112 |
|
||||||
| NFR-004 | I | ScoreRun, provenance, UI-copy | VR-109 uitgebreid evidence |
|
| NFR-004 | I | ScoreRun, provenance, skillvraag met expliciet veld-/tekstonderscheid en UI-copy | VR-109/208 uitgebreid evidence |
|
||||||
| NFR-005 | I | domeinapps/services/adapters/ADRs | continue review |
|
| NFR-005 | I | domeinapps/services/adapters/ADRs | continue review |
|
||||||
| NFR-006 | I | offline pytest + coveragegate | VR-114 verbreedt e2e |
|
| NFR-006 | I | offline pytest + coveragegate | VR-114 verbreedt e2e |
|
||||||
| NFR-007 | P | `scripts/benchmark.py`, `tests/benchmark/test_benchmark.py` | VR-116 |
|
| NFR-007 | P | `scripts/benchmark.py`, `tests/benchmark/test_benchmark.py` | VR-116 |
|
||||||
| NFR-008 | I | semantische Stitch-dominante templates, zes viewports, dark/light en toetsenbordmenu | `tests/e2e/test_vr114_browser_and_accessibility.py`, `docs/design/validation/`, VR-118/119 |
|
| NFR-008 | I | volledige negen-schermen-Stitch-cockpit, acht viewports, dark/light, toetsenborddrawer en elf actuele screenshots | `tests/e2e/test_vr114_browser_and_accessibility.py`, `tests/integration/test_intelligence_views.py`, `artifacts/visual-validation/`, VR-209–213 |
|
||||||
| NFR-009 | I | lokale SQLite en Docker/Unraidconfig, release-smoke en restore | VR-117 afgerond |
|
| NFR-009 | I | lokale SQLite en Docker/Unraidconfig, release-smoke en restore | VR-117 afgerond |
|
||||||
| NFR-010 | I | `apps/sources/services/health.py`, `apps/sources/tasks.py`, `apps/core/views.py`, `config/settings.py` | `tests/integration/test_source_health.py` | VR-112 observability |
|
| NFR-010 | I | `apps/sources/services/health.py`, `apps/sources/tasks.py`, `apps/core/views.py`, `config/settings.py` | `tests/integration/test_source_health.py` | VR-112 observability |
|
||||||
|
|
||||||
@@ -98,5 +98,7 @@ Iedere backlogtaak die een requirement wijzigt, werkt in dezelfde commit deze ma
|
|||||||
- VR-126 herankert profiel en broncatalogus op postcode 2400 Mol en 40 km. De host-, pad- en locatiefilters voor SCK CEN, Cipal Schaubroeck, VanRoey, NTX en Thomas More worden gedekt door regionale adaptertests, source-viewtests, live bronruns en de bijgewerkte threat T-39.
|
- VR-126 herankert profiel en broncatalogus op postcode 2400 Mol en 40 km. De host-, pad- en locatiefilters voor SCK CEN, Cipal Schaubroeck, VanRoey, NTX en Thomas More worden gedekt door regionale adaptertests, source-viewtests, live bronruns en de bijgewerkte threat T-39.
|
||||||
- VR-201 activeert Gemeente Mol en zeven bijkomende Kempen-werkgevers na menselijke voorwaardenreview, fixtures, fail-closed locatie-/routefilters en twee livecycli. Twaalf interessante werkgevers zonder stabiele actieve ingang staan afzonderlijk en niet-scanbaar op de UI-waaklijst. Bewijs staat in de livecatalogus, seeddata, `KempenEmployerAdapter`, bronruns en adapter-/seed-/viewtests.
|
- VR-201 activeert Gemeente Mol en zeven bijkomende Kempen-werkgevers na menselijke voorwaardenreview, fixtures, fail-closed locatie-/routefilters en twee livecycli. Twaalf interessante werkgevers zonder stabiele actieve ingang staan afzonderlijk en niet-scanbaar op de UI-waaklijst. Bewijs staat in de livecatalogus, seeddata, `KempenEmployerAdapter`, bronruns en adapter-/seed-/viewtests.
|
||||||
- VR-205 laadt de officiële Belgische GeoNames-postcodedataset 2026-04-03 onder CC BY 4.0, documenteert bron- en afgeleide checksums en lost 2400 Mol op naar het benaderende postcodecentrum. Bewijs staat in `config-data/geodata/README.local.md`, het importlog, geocodingtests en de opnieuw berekende profiel-vacaturescores.
|
- VR-205 laadt de officiële Belgische GeoNames-postcodedataset 2026-04-03 onder CC BY 4.0, documenteert bron- en afgeleide checksums en lost 2400 Mol op naar het benaderende postcodecentrum. Bewijs staat in `config-data/geodata/README.local.md`, het importlog, geocodingtests en de opnieuw berekende profiel-vacaturescores.
|
||||||
|
- VR-206 voegt een titelgestuurde IT-relevantiegrens vóór scoring/AI toe, maakt de persoonlijke radarselectie de standaard en benut Stitch-panelen dynamisch tot 2560 px. Bewijs staat in `apps/jobs/services/relevance.py`, de score-/viewtests en `docs/design/validation/VR-206_AUDIT.md`.
|
||||||
|
- VR-207 breidt het profiel uit met gegroepeerde field-/infrastructuurrollen, cv-skills en een ervaringsband. Skill-evidence is op vier treffers begrensd, afwezigheid wordt niet als tekort gepresenteerd en ambigue fieldtitels vereisen een onafhankelijk IT-contextsignaal. Persoonlijke cv-waarden staan uitsluitend in de lokale gebruikersdatabase; repositorytests en documentatie blijven gesaneerd.
|
||||||
|
- VR-208 maakt die ervaringsband uitsluitend informatief en verwijdert ook senioriteitsinvloed uit de AI-component en feedbacklearning. De gebruikersgescopeerde Skillsradar analyseert actuele niet-uitgesloten IT-vacatures van actieve bronnen, onderscheidt skillveldbewijs van tekstinferentie en toont profieldekking en mogelijke leerkansen met categorie-/dekkingsfilters.
|
||||||
|
- VR-209–213 vervangen de volledige browserfrontend door de negen Stitch Intelligence Cockpit-composities. De nieuwe shell, dashboard/vacature-intelligence, pipeline/profiel/bronnen, automation/activiteit/werkgevers en responsive validatie zijn gekoppeld aan echte read models en serveracties. Bewijs staat in `docs/design/STITCH_INTEGRATION_REPORT.md`, route-/scope-/querytests en de elf beelden onder `artifacts/visual-validation/`.
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ dependencies = [
|
|||||||
"gunicorn==26.0.0",
|
"gunicorn==26.0.0",
|
||||||
"whitenoise==6.11.0",
|
"whitenoise==6.11.0",
|
||||||
"cryptography==49.0.0",
|
"cryptography==49.0.0",
|
||||||
|
"msal==1.37.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
pg_data=/app/local/postgres-aio17
|
||||||
|
redis_data=/app/local/redis-aio
|
||||||
|
pg_bin="$(dirname "$(readlink -f "$(command -v pg_config)")")"
|
||||||
|
fresh_database=0
|
||||||
|
|
||||||
|
mkdir -p "$pg_data" "$redis_data" /app/media /app/logs /tmp/celery
|
||||||
|
chown -R postgres:postgres "$pg_data"
|
||||||
|
chown -R redis:redis "$redis_data"
|
||||||
|
chown -R app:app /app/media /app/logs /tmp/celery
|
||||||
|
|
||||||
|
if [[ ! -s "$pg_data/PG_VERSION" ]]; then
|
||||||
|
runuser -u postgres -- "$pg_bin/initdb" -D "$pg_data" --auth-local=trust --auth-host=scram-sha-256
|
||||||
|
fresh_database=1
|
||||||
|
fi
|
||||||
|
|
||||||
|
runuser -u postgres -- "$pg_bin/pg_ctl" -D "$pg_data" -o "-c listen_addresses=127.0.0.1" -w start
|
||||||
|
db_name="${POSTGRES_DB:-vacatureradar}"
|
||||||
|
db_user="${POSTGRES_USER:-vacatureradar}"
|
||||||
|
db_password="${POSTGRES_PASSWORD:?POSTGRES_PASSWORD ontbreekt}"
|
||||||
|
export DATABASE_URL="postgresql://${db_user}:${db_password}@127.0.0.1:5432/${db_name}"
|
||||||
|
export POSTGRES_HOST=127.0.0.1
|
||||||
|
export REDIS_URL=redis://127.0.0.1:6379/0
|
||||||
|
|
||||||
|
runuser -u postgres -- psql --set=ON_ERROR_STOP=1 --set=db_name="$db_name" --set=db_user="$db_user" --set=db_password="$db_password" postgres <<'SQL'
|
||||||
|
SELECT format('CREATE ROLE %I LOGIN PASSWORD %L', :'db_user', :'db_password')
|
||||||
|
WHERE NOT EXISTS (SELECT FROM pg_roles WHERE rolname = :'db_user') \gexec
|
||||||
|
SELECT format('ALTER ROLE %I PASSWORD %L', :'db_user', :'db_password') \gexec
|
||||||
|
SELECT format('CREATE DATABASE %I OWNER %I', :'db_name', :'db_user')
|
||||||
|
WHERE NOT EXISTS (SELECT FROM pg_database WHERE datname = :'db_name') \gexec
|
||||||
|
SQL
|
||||||
|
if [[ "$fresh_database" == "1" && -n "${VACATURERADAR_RESTORE_DUMP:-}" && -s "${VACATURERADAR_RESTORE_DUMP}" ]]; then
|
||||||
|
runuser -u postgres -- pg_restore --no-owner --role="$db_user" --dbname="$db_name" "${VACATURERADAR_RESTORE_DUMP}"
|
||||||
|
fi
|
||||||
|
runuser -u postgres -- "$pg_bin/pg_ctl" -D "$pg_data" -m fast -w stop
|
||||||
|
|
||||||
|
runuser -u postgres -- "$pg_bin/pg_ctl" -D "$pg_data" -o "-c listen_addresses=127.0.0.1" -w start
|
||||||
|
runuser -u app -- python manage.py migrate --noinput
|
||||||
|
if [[ "${VACATURERADAR_AUTO_BOOTSTRAP:-0}" == "1" ]]; then
|
||||||
|
runuser -u app -- python manage.py bootstrap_instance || true
|
||||||
|
fi
|
||||||
|
runuser -u postgres -- "$pg_bin/pg_ctl" -D "$pg_data" -m fast -w stop
|
||||||
|
|
||||||
|
exec /usr/bin/supervisord -c /app/deployment/unraid/supervisord.conf
|
||||||
@@ -83,7 +83,12 @@ def validate_yaml_contracts() -> None:
|
|||||||
compose = parse_yaml(relative)
|
compose = parse_yaml(relative)
|
||||||
if not isinstance(compose, dict) or not isinstance(compose.get("services"), dict):
|
if not isinstance(compose, dict) or not isinstance(compose.get("services"), dict):
|
||||||
raise ValidationError(f"Composebestand mist services: {relative}")
|
raise ValidationError(f"Composebestand mist services: {relative}")
|
||||||
for service in ("web", "worker", "scheduler", "postgres", "redis"):
|
required_services = (
|
||||||
|
("app",)
|
||||||
|
if relative == "docker-compose.unraid.yml"
|
||||||
|
else ("web", "worker", "scheduler", "postgres", "redis")
|
||||||
|
)
|
||||||
|
for service in required_services:
|
||||||
if service not in compose["services"]:
|
if service not in compose["services"]:
|
||||||
raise ValidationError(f"{relative} mist service {service}")
|
raise ValidationError(f"{relative} mist service {service}")
|
||||||
|
|
||||||
|
|||||||
@@ -1,591 +1,6 @@
|
|||||||
:root,
|
@import url("tokens.css");
|
||||||
:root[data-theme="dark"] {
|
@import url("base.css");
|
||||||
color-scheme: dark;
|
@import url("layout.css");
|
||||||
--bg: #0b1326;
|
@import url("components.css");
|
||||||
--bg-deep: #060e20;
|
@import url("pages.css");
|
||||||
--surface: #171f33;
|
@import url("responsive.css");
|
||||||
--surface-low: #131b2e;
|
|
||||||
--surface-high: #222a3d;
|
|
||||||
--surface-bright: #2d3449;
|
|
||||||
--text: #dae2fd;
|
|
||||||
--text-soft: #b9caca;
|
|
||||||
--muted: #849495;
|
|
||||||
--border: #3a494a;
|
|
||||||
--border-strong: #59696a;
|
|
||||||
--primary: #00f5ff;
|
|
||||||
--primary-strong: #63f7ff;
|
|
||||||
--primary-ink: #003739;
|
|
||||||
--primary-soft: rgba(0, 245, 255, .1);
|
|
||||||
--positive: #4edea3;
|
|
||||||
--positive-soft: rgba(78, 222, 163, .11);
|
|
||||||
--warning: #ffca69;
|
|
||||||
--warning-soft: rgba(255, 202, 105, .11);
|
|
||||||
--danger: #ff8f87;
|
|
||||||
--danger-soft: rgba(255, 143, 135, .11);
|
|
||||||
--indigo: #9ea5ff;
|
|
||||||
--shadow: 0 18px 55px rgba(0, 0, 0, .24);
|
|
||||||
--signal-glow: 0 0 0 1px rgba(22, 229, 238, .12), 0 0 28px rgba(22, 229, 238, .07);
|
|
||||||
--radius: 8px;
|
|
||||||
--radius-sm: 4px;
|
|
||||||
--sidebar: 256px;
|
|
||||||
--utility: 64px;
|
|
||||||
}
|
|
||||||
|
|
||||||
:root[data-theme="light"] {
|
|
||||||
color-scheme: light;
|
|
||||||
--bg: #f3f6fa;
|
|
||||||
--bg-deep: #e9eef5;
|
|
||||||
--surface: #ffffff;
|
|
||||||
--surface-low: #f7f9fc;
|
|
||||||
--surface-high: #edf2f7;
|
|
||||||
--surface-bright: #e2e9f2;
|
|
||||||
--text: #142033;
|
|
||||||
--text-soft: #2c3c52;
|
|
||||||
--muted: #617187;
|
|
||||||
--border: #d7e0eb;
|
|
||||||
--border-strong: #aebdce;
|
|
||||||
--primary: #007f87;
|
|
||||||
--primary-strong: #005e64;
|
|
||||||
--primary-ink: #f4ffff;
|
|
||||||
--primary-soft: rgba(0, 127, 135, .09);
|
|
||||||
--positive: #087f5b;
|
|
||||||
--positive-soft: rgba(8, 127, 91, .1);
|
|
||||||
--warning: #8b5b00;
|
|
||||||
--warning-soft: rgba(183, 119, 0, .11);
|
|
||||||
--danger: #b42318;
|
|
||||||
--danger-soft: rgba(180, 35, 24, .09);
|
|
||||||
--indigo: #4e56c8;
|
|
||||||
--shadow: 0 16px 44px rgba(27, 43, 65, .08);
|
|
||||||
--signal-glow: 0 0 0 1px rgba(0, 127, 135, .12), 0 12px 34px rgba(0, 70, 75, .08);
|
|
||||||
}
|
|
||||||
|
|
||||||
* { box-sizing: border-box; }
|
|
||||||
html { min-height: 100%; scroll-behavior: smooth; }
|
|
||||||
body {
|
|
||||||
min-width: 320px;
|
|
||||||
min-height: 100vh;
|
|
||||||
margin: 0;
|
|
||||||
overflow-x: hidden;
|
|
||||||
background:
|
|
||||||
linear-gradient(rgba(110, 150, 190, .025) 1px, transparent 1px),
|
|
||||||
linear-gradient(90deg, rgba(110, 150, 190, .025) 1px, transparent 1px),
|
|
||||||
var(--bg);
|
|
||||||
background-size: 48px 48px;
|
|
||||||
color: var(--text);
|
|
||||||
font: 15px/1.55 Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
|
||||||
-webkit-font-smoothing: antialiased;
|
|
||||||
}
|
|
||||||
a { color: var(--primary-strong); text-decoration: none; }
|
|
||||||
a:hover { text-decoration: underline; }
|
|
||||||
button, input, select, textarea { font: inherit; }
|
|
||||||
button { cursor: pointer; }
|
|
||||||
code, pre, .eyebrow, .pill, .meta-label, .nav-group-label, .job-meta, .utility-title, .kpi-label {
|
|
||||||
font-family: ui-monospace, SFMono-Regular, Consolas, "Liberation Mono", monospace;
|
|
||||||
}
|
|
||||||
pre { overflow: auto; margin: 0; border-radius: var(--radius-sm); background: var(--bg-deep); padding: 18px; border: 1px solid var(--border); }
|
|
||||||
svg { fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; }
|
|
||||||
.icon { width: 20px; height: 20px; flex: 0 0 auto; }
|
|
||||||
.icon-sprite { position: absolute; width: 0; height: 0; overflow: hidden; }
|
|
||||||
.skip-link { position: fixed; left: 12px; top: -90px; z-index: 1000; padding: 11px 15px; background: var(--primary); color: var(--primary-ink); border-radius: var(--radius-sm); font-weight: 800; }
|
|
||||||
.skip-link:focus { top: 12px; }
|
|
||||||
.sr-only { position: absolute !important; width: 1px !important; height: 1px !important; padding: 0 !important; margin: -1px !important; overflow: hidden !important; clip: rect(0, 0, 0, 0) !important; white-space: nowrap !important; border: 0 !important; }
|
|
||||||
.mobile-only { display: none !important; }
|
|
||||||
|
|
||||||
.app-frame { min-height: 100vh; }
|
|
||||||
.sidebar {
|
|
||||||
position: fixed;
|
|
||||||
inset: 0 auto 0 0;
|
|
||||||
z-index: 60;
|
|
||||||
width: var(--sidebar);
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
padding: 18px 14px;
|
|
||||||
background: color-mix(in srgb, var(--bg-deep) 96%, transparent);
|
|
||||||
border-right: 1px solid var(--border);
|
|
||||||
box-shadow: 12px 0 42px rgba(0, 0, 0, .08);
|
|
||||||
}
|
|
||||||
.sidebar-brand { display: flex; align-items: center; justify-content: space-between; padding: 2px 6px 22px; }
|
|
||||||
.brand { min-width: 0; display: flex; align-items: center; gap: 12px; color: var(--text); }
|
|
||||||
.brand:hover { text-decoration: none; }
|
|
||||||
.brand > span:last-child { min-width: 0; display: grid; line-height: 1.2; }
|
|
||||||
.brand strong { font-size: 18px; letter-spacing: -.025em; }
|
|
||||||
.brand small { margin-top: 3px; color: var(--muted); font: 10px/1.2 ui-monospace, SFMono-Regular, Consolas, monospace; letter-spacing: .1em; text-transform: uppercase; }
|
|
||||||
.brand-mark { width: 42px; height: 42px; display: inline-grid; place-items: center; border-radius: 7px; background: var(--primary); color: var(--primary-ink); box-shadow: 0 0 24px color-mix(in srgb, var(--primary) 25%, transparent); }
|
|
||||||
.brand-mark .icon { width: 25px; height: 25px; stroke-width: 1.6; }
|
|
||||||
.nav-stack { min-height: 0; overflow-y: auto; display: grid; align-content: start; gap: 4px; padding: 4px 0; }
|
|
||||||
.nav-group-label { margin: 18px 13px 5px; color: var(--muted); font-size: 10px; text-transform: uppercase; letter-spacing: .13em; }
|
|
||||||
.nav-group-label:first-child { margin-top: 3px; }
|
|
||||||
.nav-link { position: relative; display: grid; grid-template-columns: 24px minmax(0, 1fr); align-items: center; gap: 12px; min-height: 54px; padding: 8px 12px; border: 1px solid transparent; border-radius: 7px; color: var(--muted); }
|
|
||||||
.nav-link > span { min-width: 0; display: grid; color: var(--text-soft); font-weight: 700; line-height: 1.25; }
|
|
||||||
.nav-link small { color: var(--muted); font-size: 11px; font-weight: 400; }
|
|
||||||
.nav-link:hover { background: var(--surface); border-color: var(--border); text-decoration: none; color: var(--primary); }
|
|
||||||
.nav-link.active { background: linear-gradient(90deg, var(--primary-soft), transparent); border-color: color-mix(in srgb, var(--primary) 25%, var(--border)); color: var(--primary); box-shadow: inset 3px 0 var(--primary); }
|
|
||||||
.nav-link.active > span, .nav-link.active small { color: var(--primary-strong); }
|
|
||||||
.sidebar-user { margin-top: auto; min-width: 0; display: grid; grid-template-columns: 38px minmax(0, 1fr) 10px; align-items: center; gap: 10px; padding: 15px 8px 2px; border-top: 1px solid var(--border); }
|
|
||||||
.sidebar-user > span:nth-child(2) { min-width: 0; display: grid; line-height: 1.25; }
|
|
||||||
.sidebar-user strong, .sidebar-user small { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
||||||
.sidebar-user strong { font-size: 13px; }
|
|
||||||
.sidebar-user small { color: var(--muted); font-size: 11px; }
|
|
||||||
.avatar { width: 38px; height: 38px; display: grid; place-items: center; border: 1px solid var(--border-strong); border-radius: 50%; background: var(--surface-high); color: var(--primary-strong); font-weight: 800; }
|
|
||||||
.status-dot, .signal-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--positive); box-shadow: 0 0 0 4px var(--positive-soft); }
|
|
||||||
.workspace { min-height: 100vh; margin-left: var(--sidebar); }
|
|
||||||
.utilitybar { position: sticky; top: 0; z-index: 40; height: var(--utility); display: flex; align-items: center; justify-content: space-between; gap: 20px; padding: 0 clamp(18px, 3vw, 38px); background: color-mix(in srgb, var(--bg) 86%, transparent); border-bottom: 1px solid var(--border); backdrop-filter: blur(16px); }
|
|
||||||
.utilitybar-start, .utility-actions, .utility-title, .utility-link { display: flex; align-items: center; }
|
|
||||||
.utility-title { gap: 10px; color: var(--muted); font-size: 11px; text-transform: uppercase; letter-spacing: .08em; }
|
|
||||||
.utility-actions { gap: 8px; }
|
|
||||||
.utility-actions form { margin: 0; }
|
|
||||||
.utility-link, .icon-button { min-height: 42px; border: 1px solid transparent; border-radius: 6px; background: transparent; color: var(--muted); }
|
|
||||||
.utility-link { gap: 8px; padding: 8px 10px; font-weight: 650; }
|
|
||||||
button.utility-link { width: auto; }
|
|
||||||
.utility-link:hover, .icon-button:hover { border-color: var(--border); background: var(--surface-high); color: var(--text); text-decoration: none; }
|
|
||||||
.icon-button { width: 42px; display: inline-grid; place-items: center; padding: 0; }
|
|
||||||
.theme-icon-light { display: none; }
|
|
||||||
:root[data-theme="light"] .theme-icon-dark { display: none; }
|
|
||||||
:root[data-theme="light"] .theme-icon-light { display: block; }
|
|
||||||
.nav-scrim { display: none; }
|
|
||||||
.main-content { width: 100%; max-width: 1280px; margin: 0 auto; padding: 32px 24px 72px; }
|
|
||||||
|
|
||||||
.page-header { display: flex; align-items: flex-end; justify-content: space-between; gap: 26px; margin-bottom: 28px; }
|
|
||||||
.page-header h1, .job-hero h1 { margin: 0; font-size: clamp(30px, 3.2vw, 42px); line-height: 1.08; letter-spacing: -.035em; }
|
|
||||||
.page-header p { max-width: 760px; margin: 9px 0 0; color: var(--muted); }
|
|
||||||
.eyebrow { margin: 0 0 9px !important; color: var(--primary) !important; font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: .13em; }
|
|
||||||
.page-actions { display: flex; flex-wrap: wrap; gap: 9px; }
|
|
||||||
.section-heading { display: flex; align-items: end; justify-content: space-between; gap: 20px; margin: 34px 0 17px; }
|
|
||||||
.section-heading h2 { margin: 0; font-size: 23px; letter-spacing: -.025em; }
|
|
||||||
.section-heading p { margin: 5px 0 0; color: var(--muted); }
|
|
||||||
.section-kicker { color: var(--primary); font: 11px ui-monospace, SFMono-Regular, Consolas, monospace; text-transform: uppercase; letter-spacing: .11em; }
|
|
||||||
.title-row { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; }
|
|
||||||
|
|
||||||
.button { min-height: 42px; display: inline-flex; align-items: center; justify-content: center; gap: 8px; padding: 8px 14px; border: 1px solid transparent; border-radius: 6px; background: transparent; color: var(--text); font-weight: 750; line-height: 1.2; text-decoration: none; }
|
|
||||||
.button:hover { text-decoration: none; transform: translateY(-1px); }
|
|
||||||
.button:focus-visible, .icon-button:focus-visible, .utility-link:focus-visible, input:focus-visible, select:focus-visible, textarea:focus-visible, summary:focus-visible, a:focus-visible { outline: 3px solid color-mix(in srgb, var(--primary) 40%, transparent); outline-offset: 3px; }
|
|
||||||
.button-primary { background: var(--primary); color: var(--primary-ink); box-shadow: 0 8px 22px color-mix(in srgb, var(--primary) 16%, transparent); }
|
|
||||||
.button-primary:hover { background: var(--primary-strong); }
|
|
||||||
.button-secondary { background: var(--surface-high); border-color: var(--border); }
|
|
||||||
.button-secondary:hover, .button-ghost:hover { background: var(--surface-bright); border-color: var(--border-strong); }
|
|
||||||
.button-ghost { border-color: var(--border); color: var(--text-soft); }
|
|
||||||
.button-link { color: var(--primary-strong); background: var(--primary-soft); border-color: color-mix(in srgb, var(--primary) 20%, var(--border)); }
|
|
||||||
.button-danger { color: var(--danger); border-color: color-mix(in srgb, var(--danger) 30%, var(--border)); }
|
|
||||||
.button-block { width: 100%; }
|
|
||||||
.button-small { min-height: 34px; padding: 5px 10px; font-size: 13px; }
|
|
||||||
|
|
||||||
.stats-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 12px; }
|
|
||||||
.stat-card, .panel, .job-card, .auth-card, .filter-bar, .table-wrap, .source-card { background: color-mix(in srgb, var(--surface) 95%, transparent); border: 1px solid var(--border); border-radius: var(--radius); box-shadow: var(--shadow); }
|
|
||||||
.stat-card { position: relative; min-height: 126px; display: grid; align-content: center; gap: 2px; padding: 20px; overflow: hidden; }
|
|
||||||
.stat-card::after { content: ""; position: absolute; right: -38px; bottom: -48px; width: 110px; height: 110px; border: 1px solid color-mix(in srgb, var(--primary) 12%, transparent); border-radius: 50%; }
|
|
||||||
.stat-card span, .stat-card small { position: relative; z-index: 1; color: var(--muted); }
|
|
||||||
.stat-card strong { position: relative; z-index: 1; margin: 2px 0; font-size: 34px; line-height: 1; letter-spacing: -.045em; }
|
|
||||||
.stat-card.signal { border-color: color-mix(in srgb, var(--primary) 35%, var(--border)); box-shadow: var(--signal-glow); }
|
|
||||||
.kpi-label { font-size: 10px; text-transform: uppercase; letter-spacing: .09em; }
|
|
||||||
.panel { padding: clamp(19px, 2.5vw, 30px); margin-bottom: 18px; }
|
|
||||||
.panel > h2:first-child, .panel > h3:first-child { margin-top: 0; }
|
|
||||||
.panel-subtle { background: var(--surface-low); box-shadow: none; }
|
|
||||||
|
|
||||||
.dashboard-grid { display: grid; grid-template-columns: minmax(0, 1fr) 320px; gap: 20px; align-items: start; }
|
|
||||||
.dashboard-rail { position: sticky; top: calc(var(--utility) + 20px); }
|
|
||||||
.radar-summary { position: relative; overflow: hidden; background: radial-gradient(circle at 88% 10%, var(--primary-soft), transparent 35%), var(--surface); }
|
|
||||||
.radar-summary::after { content: ""; position: absolute; right: -50px; top: -55px; width: 190px; height: 190px; border: 1px solid color-mix(in srgb, var(--primary) 18%, transparent); border-radius: 50%; box-shadow: inset 0 0 0 38px color-mix(in srgb, var(--primary) 2%, transparent), inset 0 0 0 74px color-mix(in srgb, var(--primary) 3%, transparent); pointer-events: none; }
|
|
||||||
.radar-summary > * { position: relative; z-index: 1; }
|
|
||||||
.rail-list { display: grid; gap: 10px; margin: 16px 0 0; }
|
|
||||||
.rail-item { display: flex; align-items: center; justify-content: space-between; gap: 14px; padding: 12px 0; border-top: 1px solid var(--border); }
|
|
||||||
.rail-item:first-child { border-top: 0; }
|
|
||||||
.rail-item span { color: var(--muted); }
|
|
||||||
|
|
||||||
.job-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 15px; }
|
|
||||||
.job-card { position: relative; display: flex; flex-direction: column; gap: 15px; padding: 20px; transition: border-color .16s ease, transform .16s ease, box-shadow .16s ease; }
|
|
||||||
.job-card:hover { border-color: var(--border-strong); transform: translateY(-2px); }
|
|
||||||
.job-card.strong-match { border-color: color-mix(in srgb, var(--primary) 38%, var(--border)); box-shadow: var(--signal-glow); background: linear-gradient(145deg, var(--primary-soft), transparent 45%), var(--surface); }
|
|
||||||
.job-card-head { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 15px; align-items: start; }
|
|
||||||
.job-card h2, .job-card h3 { margin: 6px 0 3px; font-size: 20px; line-height: 1.24; letter-spacing: -.02em; }
|
|
||||||
.job-card h2 a, .job-card h3 a { color: var(--text); }
|
|
||||||
.job-card p { margin: 0; color: var(--muted); }
|
|
||||||
.score-block { min-width: 72px; display: grid; justify-items: end; }
|
|
||||||
.score-block strong { color: var(--primary); font-size: 27px; line-height: 1; letter-spacing: -.04em; }
|
|
||||||
.score-block small { margin-top: 5px; color: var(--muted); font: 9px ui-monospace, SFMono-Regular, Consolas, monospace; text-transform: uppercase; letter-spacing: .06em; }
|
|
||||||
.job-meta { display: flex; flex-wrap: wrap; gap: 7px; color: var(--muted); font-size: 11px; }
|
|
||||||
.job-meta span { display: inline-flex; align-items: center; gap: 5px; padding: 5px 7px; border-radius: 4px; background: var(--surface-high); border: 1px solid color-mix(in srgb, var(--border) 70%, transparent); }
|
|
||||||
.job-meta .icon { width: 13px; height: 13px; }
|
|
||||||
.job-actions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: auto; }
|
|
||||||
.pill { display: inline-flex; align-items: center; gap: 6px; width: fit-content; padding: 4px 8px; border: 1px solid var(--border); border-radius: 999px; background: var(--surface-high); color: var(--muted); font-size: 10px; font-weight: 750; text-transform: uppercase; letter-spacing: .04em; }
|
|
||||||
.pill::before { content: ""; width: 6px; height: 6px; border-radius: 50%; background: currentColor; }
|
|
||||||
.pill-strong, .pill-allow, .pill-active, .pill-success, .pill-offer { background: var(--positive-soft); border-color: color-mix(in srgb, var(--positive) 24%, var(--border)); color: var(--positive); }
|
|
||||||
.pill-possible, .pill-review, .pill-uncertain, .pill-partial, .pill-interview { background: var(--warning-soft); border-color: color-mix(in srgb, var(--warning) 24%, var(--border)); color: var(--warning); }
|
|
||||||
.pill-hidden, .pill-deny, .pill-failed, .pill-quarantined, .pill-rejected { background: var(--danger-soft); border-color: color-mix(in srgb, var(--danger) 24%, var(--border)); color: var(--danger); }
|
|
||||||
.pill-new, .pill-applied, .pill-trial { background: var(--primary-soft); border-color: color-mix(in srgb, var(--primary) 24%, var(--border)); color: var(--primary-strong); }
|
|
||||||
.compact-list { margin: 0; padding-left: 20px; }
|
|
||||||
.compact-list li + li { margin-top: 6px; }
|
|
||||||
.compact-list.positive li::marker { color: var(--positive); }
|
|
||||||
.compact-list.caution li::marker { color: var(--warning); }
|
|
||||||
.attention { padding: 10px 12px; border-left: 3px solid var(--warning); border-radius: 4px; background: var(--warning-soft); color: var(--warning) !important; font-size: 13px; }
|
|
||||||
|
|
||||||
.filter-bar { display: flex; align-items: end; gap: 10px; padding: 14px; margin-bottom: 18px; box-shadow: none; }
|
|
||||||
.filter-bar label { display: grid; gap: 5px; }
|
|
||||||
.filter-bar .grow { flex: 1; }
|
|
||||||
.field-label { color: var(--muted); font: 10px ui-monospace, SFMono-Regular, Consolas, monospace; text-transform: uppercase; letter-spacing: .07em; }
|
|
||||||
input, select, textarea { width: 100%; min-height: 43px; padding: 9px 11px; border: 1px solid var(--border); border-radius: 5px; background: var(--surface-low); color: var(--text); }
|
|
||||||
input[type="checkbox"], input[type="radio"] { width: 22px; min-height: 22px; height: 22px; padding: 0; accent-color: var(--primary); }
|
|
||||||
input::placeholder, textarea::placeholder { color: color-mix(in srgb, var(--muted) 78%, transparent); }
|
|
||||||
textarea { resize: vertical; }
|
|
||||||
.table-wrap { overflow-x: auto; }
|
|
||||||
.data-table { width: 100%; border-collapse: collapse; min-width: 760px; }
|
|
||||||
.data-table th { text-align: left; padding: 12px 15px; background: var(--surface-low); color: var(--muted); font: 10px ui-monospace, SFMono-Regular, Consolas, monospace; text-transform: uppercase; letter-spacing: .07em; }
|
|
||||||
.data-table td { padding: 14px 15px; border-top: 1px solid var(--border); vertical-align: middle; }
|
|
||||||
.data-table tbody tr:hover { background: var(--surface-low); }
|
|
||||||
.data-table td small, .table-title + small { display: block; margin-top: 3px; color: var(--muted); }
|
|
||||||
.table-title { color: var(--text); font-weight: 760; }
|
|
||||||
.pagination { display: flex; justify-content: center; align-items: center; gap: 18px; padding: 22px; }
|
|
||||||
|
|
||||||
.detail-layout { display: grid; grid-template-columns: minmax(0, 1fr) 320px; gap: 20px; align-items: start; }
|
|
||||||
.back-link { display: inline-flex; align-items: center; gap: 6px; margin-bottom: 15px; color: var(--muted); }
|
|
||||||
.job-hero { position: relative; overflow: hidden; display: flex; justify-content: space-between; gap: 28px; padding: clamp(24px, 4vw, 40px); margin-bottom: 18px; border: 1px solid color-mix(in srgb, var(--primary) 30%, var(--border)); border-radius: var(--radius); background: radial-gradient(circle at 88% 25%, var(--primary-soft), transparent 32%), linear-gradient(145deg, var(--surface), var(--surface-low)); box-shadow: var(--signal-glow); }
|
|
||||||
.hero-subtitle { margin: 11px 0 15px; color: var(--text-soft); font-size: 17px; }
|
|
||||||
.hero-score { min-width: 136px; display: grid; justify-items: end; align-content: start; }
|
|
||||||
.hero-score strong { color: var(--primary); font-size: 54px; line-height: .95; letter-spacing: -.06em; }
|
|
||||||
.hero-score span { margin-top: 8px; color: var(--primary-strong); font: 10px ui-monospace, SFMono-Regular, Consolas, monospace; text-transform: uppercase; letter-spacing: .08em; }
|
|
||||||
.hero-score small { margin-top: 3px; color: var(--muted); }
|
|
||||||
.sticky-card { position: sticky; top: calc(var(--utility) + 20px); padding: 20px; background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); box-shadow: var(--shadow); }
|
|
||||||
.sticky-card h2 { margin: 0 0 4px; }
|
|
||||||
.sticky-card > p { margin-top: 0; color: var(--muted); }
|
|
||||||
.stack-actions { display: grid; gap: 9px; }
|
|
||||||
.hide-form { display: grid; gap: 8px; padding-top: 9px; border-top: 1px solid var(--border); }
|
|
||||||
.hide-form label { color: var(--muted); font-size: 12px; }
|
|
||||||
.score-components { display: grid; gap: 13px; margin: 18px 0 25px; }
|
|
||||||
.score-components > div { display: grid; grid-template-columns: 120px minmax(120px, 1fr) 52px; gap: 12px; align-items: center; }
|
|
||||||
.score-components strong { text-align: right; }
|
|
||||||
.progress { height: 7px; overflow: hidden; border-radius: 999px; background: var(--surface-high); }
|
|
||||||
.progress i { display: block; height: 100%; min-width: 2px; border-radius: inherit; background: linear-gradient(90deg, var(--primary), var(--indigo)); }
|
|
||||||
.two-columns { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 22px; }
|
|
||||||
.prose { max-width: 82ch; color: var(--text-soft); }
|
|
||||||
.prose h2, .prose h3, .prose h4 { margin-top: 1.5em; color: var(--text); }
|
|
||||||
.definition-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; }
|
|
||||||
.definition-grid div { padding: 12px; border: 1px solid var(--border); border-radius: 5px; background: var(--surface-low); }
|
|
||||||
.definition-grid dt { color: var(--muted); font: 10px ui-monospace, SFMono-Regular, Consolas, monospace; text-transform: uppercase; }
|
|
||||||
.definition-grid dd { margin: 4px 0 0; font-weight: 700; }
|
|
||||||
.source-list { list-style: none; padding: 0; }
|
|
||||||
.source-list li { display: flex; justify-content: space-between; gap: 16px; padding: 11px 0; border-top: 1px solid var(--border); }
|
|
||||||
.source-list span { color: var(--muted); font-size: 12px; }
|
|
||||||
|
|
||||||
.pipeline { display: grid; grid-template-columns: repeat(6, minmax(210px, 1fr)); gap: 12px; overflow-x: auto; padding: 1px 1px 14px; scroll-snap-type: x proximity; }
|
|
||||||
.pipeline-column { min-height: 320px; padding: 12px; border: 1px solid var(--border); border-radius: var(--radius); background: var(--surface-low); scroll-snap-align: start; }
|
|
||||||
.pipeline-head { display: flex; justify-content: space-between; align-items: center; gap: 10px; padding: 3px 3px 12px; }
|
|
||||||
.pipeline-head h2 { margin: 0; font-size: 14px; }
|
|
||||||
.pipeline-count { min-width: 25px; height: 25px; display: grid; place-items: center; border: 1px solid var(--border); border-radius: 50%; color: var(--muted); font-size: 11px; }
|
|
||||||
.pipeline-cards { display: grid; gap: 9px; }
|
|
||||||
.pipeline-card { padding: 14px; border: 1px solid var(--border); border-radius: 6px; background: var(--surface); box-shadow: 0 8px 20px rgba(0, 0, 0, .08); }
|
|
||||||
.pipeline-card h3 { margin: 8px 0 3px; font-size: 15px; line-height: 1.3; }
|
|
||||||
.pipeline-card p { margin: 0 0 12px; color: var(--muted); font-size: 12px; }
|
|
||||||
.pipeline-empty { padding: 20px 8px; color: var(--muted); font-size: 12px; text-align: center; border: 1px dashed var(--border); border-radius: 5px; }
|
|
||||||
|
|
||||||
.card-list { display: grid; gap: 12px; }
|
|
||||||
.profile-card, .application-card { display: flex; justify-content: space-between; align-items: center; gap: 24px; margin: 0; }
|
|
||||||
.profile-card h2, .application-card h2 { margin: 5px 0; }
|
|
||||||
.form-panel { max-width: 1100px; }
|
|
||||||
.form-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 17px; }
|
|
||||||
.form-grid label { display: grid; align-content: start; gap: 6px; color: var(--muted); font-size: 13px; font-weight: 650; }
|
|
||||||
.form-grid label > input, .form-grid label > select, .form-grid label > textarea { color: var(--text); font-weight: 400; }
|
|
||||||
.choice-field { min-width: 0; margin: 0; padding: 0; border: 0; }
|
|
||||||
.choice-field legend { margin-bottom: 9px; color: var(--muted); font-size: 13px; font-weight: 700; }
|
|
||||||
.choice-field > div { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 9px; }
|
|
||||||
.choice-field > div > div { min-width: 0; }
|
|
||||||
.choice-field label { display: flex; align-items: center; gap: 9px; min-height: 44px; padding: 9px 11px; border: 1px solid var(--border); border-radius: 10px; background: color-mix(in srgb, var(--surface) 92%, transparent); color: var(--text); font-size: 13px; font-weight: 600; cursor: pointer; }
|
|
||||||
.choice-field label:has(input:checked) { border-color: color-mix(in srgb, var(--primary) 62%, var(--border)); background: color-mix(in srgb, var(--primary) 10%, var(--surface)); }
|
|
||||||
.choice-field small { display: block; margin-top: 7px; }
|
|
||||||
.form-section { grid-column: 1 / -1; margin: 12px 0 -2px; padding-top: 18px; border-top: 1px solid var(--border); }
|
|
||||||
.form-section h2 { margin: 0; font-size: 17px; }
|
|
||||||
.form-section p { margin: 4px 0 0; color: var(--muted); font-size: 12px; }
|
|
||||||
.span-2 { grid-column: 1 / -1; }
|
|
||||||
.field-error { color: var(--danger); }
|
|
||||||
.form-actions { position: sticky; bottom: 12px; z-index: 5; display: flex; justify-content: flex-end; gap: 10px; margin-top: 24px; padding: 12px; border: 1px solid var(--border); border-radius: 7px; background: color-mix(in srgb, var(--surface) 92%, transparent); backdrop-filter: blur(12px); }
|
|
||||||
|
|
||||||
.source-overview { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; margin-bottom: 20px; }
|
|
||||||
.source-alert-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; margin-top: 16px; }
|
|
||||||
.source-alert-card { display: flex; align-items: flex-end; justify-content: space-between; gap: 18px; padding: 16px; border: 1px solid var(--border); border-radius: var(--radius-sm); background: var(--surface-soft); }
|
|
||||||
.source-alert-card p { margin: 6px 0 0; color: var(--muted); }
|
|
||||||
.source-alert-card .button { flex: 0 0 auto; }
|
|
||||||
.source-alert-card small { display: block; margin-top: 8px; color: var(--muted); }
|
|
||||||
.source-alert-card > .job-actions { flex: 0 0 auto; justify-content: flex-end; }
|
|
||||||
.mailbox-disclosure { margin-top: 16px; }
|
|
||||||
.mailbox-form-grid { margin-bottom: 14px; }
|
|
||||||
.checkbox-row { display: inline-flex; align-items: center; gap: 9px; color: var(--text); font-size: 14px; font-weight: 700; }
|
|
||||||
.checkbox-row input { width: 18px; height: 18px; accent-color: var(--accent); }
|
|
||||||
.platform-help-grid { margin-top: 12px; }
|
|
||||||
.button:disabled { cursor: not-allowed; opacity: .48; }
|
|
||||||
.source-card { overflow: hidden; box-shadow: none; }
|
|
||||||
.source-card-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 20px; padding: 18px; }
|
|
||||||
.source-identity { min-width: 0; display: flex; gap: 13px; }
|
|
||||||
.source-icon { width: 42px; height: 42px; display: grid; place-items: center; flex: 0 0 auto; border: 1px solid var(--border); border-radius: 6px; background: var(--surface-high); color: var(--primary); }
|
|
||||||
.source-identity h2 { margin: 0; font-size: 17px; }
|
|
||||||
.source-identity p { margin: 4px 0 0; color: var(--muted); font-size: 12px; overflow-wrap: anywhere; }
|
|
||||||
.source-actions { display: flex; align-items: center; gap: 10px; }
|
|
||||||
.source-details { padding: 18px; border-top: 1px solid var(--border); background: var(--surface-low); }
|
|
||||||
.source-details-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 18px; }
|
|
||||||
.technical-log { max-height: 180px; overflow: auto; padding: 14px; border: 1px solid var(--border); border-radius: 5px; background: var(--bg-deep); color: var(--muted); font: 11px/1.6 ui-monospace, SFMono-Regular, Consolas, monospace; overflow-wrap: anywhere; }
|
|
||||||
.disclosure-icon { transition: transform .16s ease; }
|
|
||||||
[aria-expanded="true"] .disclosure-icon { transform: rotate(180deg); }
|
|
||||||
.import-layout { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px; }
|
|
||||||
|
|
||||||
.messages { display: grid; gap: 8px; margin-bottom: 18px; }
|
|
||||||
.message { padding: 12px 14px; border: 1px solid color-mix(in srgb, var(--primary) 24%, var(--border)); border-left: 3px solid var(--primary); border-radius: 5px; background: var(--primary-soft); color: var(--primary-strong); }
|
|
||||||
.message.success { background: var(--positive-soft); border-color: color-mix(in srgb, var(--positive) 25%, var(--border)); border-left-color: var(--positive); color: var(--positive); }
|
|
||||||
.message.warning { background: var(--warning-soft); border-color: color-mix(in srgb, var(--warning) 25%, var(--border)); border-left-color: var(--warning); color: var(--warning); }
|
|
||||||
.message.error { background: var(--danger-soft); border-color: color-mix(in srgb, var(--danger) 25%, var(--border)); border-left-color: var(--danger); color: var(--danger); }
|
|
||||||
.empty-state { grid-column: 1 / -1; padding: 48px 24px; text-align: center; border: 1px dashed var(--border-strong); border-radius: var(--radius); background: var(--surface-low); }
|
|
||||||
.empty-state h2 { margin-bottom: 6px; }
|
|
||||||
.empty-state p { max-width: 600px; margin: 0 auto 16px; color: var(--muted); }
|
|
||||||
.empty-icon { width: 54px; height: 54px; display: grid; place-items: center; margin: 0 auto 14px; border: 1px solid var(--border); border-radius: 50%; color: var(--primary); background: var(--primary-soft); }
|
|
||||||
.empty-icon .icon { width: 27px; height: 27px; }
|
|
||||||
.muted { color: var(--muted); }
|
|
||||||
.small { font-size: 12px; }
|
|
||||||
.auth-main { min-height: 100vh; display: grid; place-items: center; padding: 24px; background: radial-gradient(circle at 18% 14%, var(--primary-soft), transparent 32%), radial-gradient(circle at 83% 85%, rgba(104, 112, 255, .08), transparent 28%), var(--bg); }
|
|
||||||
.auth-card { width: min(450px, 100%); padding: 30px; }
|
|
||||||
.auth-brand { display: flex; align-items: center; gap: 14px; margin-bottom: 24px; }
|
|
||||||
.auth-card h1 { margin: 0; }
|
|
||||||
.auth-card p { margin: 4px 0; color: var(--muted); }
|
|
||||||
.stack { display: grid; gap: 16px; }
|
|
||||||
.stack label { display: grid; gap: 6px; font-weight: 650; }
|
|
||||||
|
|
||||||
@media (max-width: 1180px) {
|
|
||||||
.stats-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
|
||||||
.dashboard-grid { grid-template-columns: 1fr; }
|
|
||||||
.dashboard-rail { position: static; display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 15px; }
|
|
||||||
.detail-layout { grid-template-columns: 1fr; }
|
|
||||||
.sticky-card { position: static; }
|
|
||||||
.source-overview { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 900px) {
|
|
||||||
.mobile-only { display: inline-grid !important; }
|
|
||||||
.sidebar { visibility: hidden; transform: translateX(-105%); transition: transform .18s ease, visibility 0s linear .18s; }
|
|
||||||
.workspace { margin-left: 0; }
|
|
||||||
.nav-open .sidebar { visibility: visible; transform: translateX(0); transition-delay: 0s; }
|
|
||||||
.nav-scrim { position: fixed; inset: 0; z-index: 50; width: 100%; height: 100%; border: 0; background: rgba(1, 8, 17, .62); backdrop-filter: blur(2px); }
|
|
||||||
.nav-open .nav-scrim { display: block; }
|
|
||||||
.utilitybar { padding: 0 16px; }
|
|
||||||
.main-content { padding-top: 26px; }
|
|
||||||
.job-grid { grid-template-columns: 1fr; }
|
|
||||||
.dashboard-rail { grid-template-columns: 1fr 1fr; }
|
|
||||||
.pipeline { grid-template-columns: repeat(6, 245px); }
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 680px) {
|
|
||||||
.desktop-label { display: none; }
|
|
||||||
.utility-link { width: 42px; justify-content: center; padding: 0; border: 1px solid transparent; }
|
|
||||||
.main-content { padding: 22px 14px 54px; }
|
|
||||||
.page-header, .profile-card, .application-card, .job-hero { align-items: stretch; flex-direction: column; }
|
|
||||||
.page-header .button, .page-actions { width: 100%; }
|
|
||||||
.page-actions .button { flex: 1; }
|
|
||||||
.stats-grid, .two-columns, .form-grid, .definition-grid, .source-overview, .source-alert-grid, .source-details-grid, .import-layout, .dashboard-rail { grid-template-columns: 1fr; }
|
|
||||||
.source-alert-card { align-items: flex-start; flex-direction: column; }
|
|
||||||
.filter-bar { align-items: stretch; flex-direction: column; }
|
|
||||||
.span-2, .form-section { grid-column: auto; }
|
|
||||||
.job-card-head { grid-template-columns: 1fr; }
|
|
||||||
.score-block { justify-items: start; }
|
|
||||||
.job-hero { gap: 20px; }
|
|
||||||
.hero-score { min-width: 0; justify-items: start; }
|
|
||||||
.hero-score strong { font-size: 46px; }
|
|
||||||
.score-components > div { grid-template-columns: 86px minmax(80px, 1fr) 40px; font-size: 12px; }
|
|
||||||
.source-list li { flex-direction: column; gap: 3px; }
|
|
||||||
.source-card-head { flex-direction: column; }
|
|
||||||
.source-actions { width: 100%; justify-content: space-between; }
|
|
||||||
.choice-field > div { grid-template-columns: 1fr; }
|
|
||||||
.form-actions { bottom: 8px; }
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 420px) {
|
|
||||||
.utility-title span:last-child { display: none; }
|
|
||||||
.stats-grid { gap: 9px; }
|
|
||||||
.stat-card { min-height: 112px; padding: 16px; }
|
|
||||||
.stat-card strong { font-size: 29px; }
|
|
||||||
.page-header h1, .job-hero h1 { font-size: 30px; }
|
|
||||||
.panel, .job-card { padding: 17px; }
|
|
||||||
.job-actions form, .job-actions .button { flex: 1 1 auto; }
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
|
||||||
*, *::before, *::after { scroll-behavior: auto !important; transition: none !important; animation: none !important; }
|
|
||||||
}
|
|
||||||
|
|
||||||
/* VR-119: Stitch-compositie is leidend voor shell, feeds en intelligencepanelen. */
|
|
||||||
.utilitybar-start { min-width: 0; flex: 1; gap: 18px; }
|
|
||||||
.global-search { position: relative; width: min(440px, 44vw); }
|
|
||||||
.global-search .icon { position: absolute; left: 13px; top: 50%; z-index: 1; width: 17px; height: 17px; transform: translateY(-50%); color: var(--muted); pointer-events: none; }
|
|
||||||
.global-search input { min-height: 42px; padding-left: 42px; background: var(--surface-low); }
|
|
||||||
.utility-radar { white-space: nowrap; }
|
|
||||||
.utility-cta { min-height: 40px; font-size: 13px; }
|
|
||||||
.section-link, .view-indicator { display: inline-flex; align-items: center; gap: 8px; }
|
|
||||||
.section-link .icon, .view-indicator .icon { width: 17px; height: 17px; }
|
|
||||||
.view-indicator { min-height: 42px; padding: 8px 13px; border: 1px solid var(--border); border-radius: var(--radius-sm); background: var(--surface-low); color: var(--text-soft); font: 12px ui-monospace, SFMono-Regular, Consolas, monospace; text-transform: uppercase; letter-spacing: .06em; }
|
|
||||||
|
|
||||||
.dashboard-header { align-items: end; }
|
|
||||||
.dashboard-header strong { color: var(--primary); }
|
|
||||||
.radar-status { display: inline-flex; align-items: center; gap: 9px; max-width: 430px; padding: 10px 14px; border: 1px solid var(--border); border-radius: 999px; background: var(--surface-low); color: var(--text-soft); font: 11px ui-monospace, SFMono-Regular, Consolas, monospace; }
|
|
||||||
.radar-status a { margin-left: 3px; }
|
|
||||||
.dashboard-grid { grid-template-columns: minmax(0, 1fr) 310px; gap: 28px; }
|
|
||||||
.dashboard-matches { min-width: 0; }
|
|
||||||
.job-feed, .results-feed { display: grid; gap: 12px; }
|
|
||||||
.feed-card { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: stretch; padding: 0; gap: 0; overflow: hidden; }
|
|
||||||
.job-card-main { min-width: 0; display: grid; gap: 14px; padding: 20px; }
|
|
||||||
.feed-card-actions { min-width: 152px; display: flex; flex-direction: column; align-items: stretch; justify-content: center; gap: 9px; padding: 18px; border-left: 1px solid var(--border); background: var(--surface-low); }
|
|
||||||
.feed-card-actions form { margin: 0; }
|
|
||||||
.feed-card-actions .save-button { width: 100%; border-color: var(--border); }
|
|
||||||
.confidence-label { color: var(--muted); font: 10px ui-monospace, SFMono-Regular, Consolas, monospace; text-transform: uppercase; letter-spacing: .06em; }
|
|
||||||
.signal-list { display: grid; gap: 7px; margin: 0; padding: 0; list-style: none; color: var(--text-soft); }
|
|
||||||
.signal-list li { position: relative; padding-left: 20px; }
|
|
||||||
.signal-list li::before { content: ""; position: absolute; left: 2px; top: .62em; width: 6px; height: 6px; border-radius: 50%; background: var(--positive); }
|
|
||||||
.rail-panel { padding: 22px; }
|
|
||||||
.rail-panel h2 { margin: 8px 0 10px; font-size: 20px; }
|
|
||||||
.rail-application-list { display: grid; margin: 8px 0 16px; border-top: 1px solid var(--border); }
|
|
||||||
.rail-application { min-width: 0; display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 12px 0; border-bottom: 1px solid var(--border); color: var(--text); }
|
|
||||||
.rail-application:hover { color: var(--primary-strong); text-decoration: none; }
|
|
||||||
.rail-application > span { min-width: 0; display: grid; }
|
|
||||||
.rail-application strong, .rail-application small { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
||||||
.rail-application small { margin-top: 3px; color: var(--muted); }
|
|
||||||
.rail-application time { color: var(--warning); font: 11px ui-monospace, SFMono-Regular, Consolas, monospace; }
|
|
||||||
.compact-empty { min-height: 235px; display: grid; place-content: center; }
|
|
||||||
|
|
||||||
.filter-console { display: grid; grid-template-columns: minmax(250px, 1fr) 170px 170px auto; align-items: end; gap: 10px; padding: 16px; margin-bottom: 26px; border: 1px solid var(--border); border-radius: var(--radius); background: var(--surface); }
|
|
||||||
.filter-console label { display: grid; gap: 6px; }
|
|
||||||
.input-with-icon { position: relative; display: block; }
|
|
||||||
.input-with-icon .icon { position: absolute; left: 12px; top: 50%; width: 16px; height: 16px; transform: translateY(-50%); color: var(--muted); }
|
|
||||||
.input-with-icon input { padding-left: 38px; }
|
|
||||||
.results-toolbar { display: flex; align-items: end; justify-content: space-between; gap: 16px; margin: 0 0 14px; }
|
|
||||||
.results-toolbar h2 { margin: 4px 0 0; font-size: 20px; }
|
|
||||||
.result-card { position: relative; min-width: 0; display: grid; grid-template-columns: 4px minmax(0, 1fr) auto; overflow: hidden; border: 1px solid var(--border); border-radius: var(--radius); background: var(--surface); transition: border-color .16s ease, transform .16s ease; }
|
|
||||||
.result-card:hover { border-color: var(--border-strong); transform: translateY(-1px); }
|
|
||||||
.result-card.strong-match { border-color: color-mix(in srgb, var(--primary) 38%, var(--border)); box-shadow: var(--signal-glow); }
|
|
||||||
.result-accent { background: var(--surface-bright); }
|
|
||||||
.result-card.strong-match .result-accent { background: var(--primary); }
|
|
||||||
.result-body { min-width: 0; display: grid; gap: 13px; padding: 19px 22px; }
|
|
||||||
.result-heading { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 18px; }
|
|
||||||
.result-heading h2 { margin: 7px 0 2px; font-size: 21px; line-height: 1.25; }
|
|
||||||
.result-heading h2 a { color: var(--text); }
|
|
||||||
.result-heading p, .result-summary { margin: 0; color: var(--muted); }
|
|
||||||
.result-summary { max-width: 78ch; font-size: 13px; }
|
|
||||||
.result-actions { min-width: 174px; display: grid; place-content: center; padding: 18px; border-left: 1px solid var(--border); background: var(--surface-low); }
|
|
||||||
|
|
||||||
.intelligence-strip { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; margin: 0 0 18px; }
|
|
||||||
.intelligence-strip > div { min-height: 110px; display: grid; align-content: center; gap: 4px; padding: 18px; border: 1px solid var(--border); border-radius: var(--radius); background: var(--surface); }
|
|
||||||
.intelligence-strip strong { color: var(--primary-strong); font-size: 24px; letter-spacing: -.025em; }
|
|
||||||
.intelligence-strip small { color: var(--muted); }
|
|
||||||
|
|
||||||
.pipeline-page-header { align-items: end; }
|
|
||||||
.pipeline-page-header h1 { font-size: clamp(30px, 3vw, 40px); }
|
|
||||||
.pipeline { min-height: calc(100vh - 250px); grid-template-columns: repeat(6, minmax(270px, 1fr)); gap: 16px; }
|
|
||||||
.pipeline-column { min-height: 460px; padding: 14px; background: color-mix(in srgb, var(--surface-low) 72%, transparent); }
|
|
||||||
.pipeline-head h2 { display: flex; align-items: center; gap: 8px; font-family: ui-monospace, SFMono-Regular, Consolas, monospace; font-weight: 500; }
|
|
||||||
.column-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--border-strong); }
|
|
||||||
.pipeline-column:nth-child(2) .column-dot, .pipeline-column:nth-child(3) .column-dot { background: var(--primary); }
|
|
||||||
.pipeline-column:nth-child(4) .column-dot { background: var(--positive); }
|
|
||||||
.pipeline-column:nth-child(5) .column-dot, .pipeline-column:nth-child(6) .column-dot { background: var(--danger); }
|
|
||||||
.pipeline-card { padding: 17px; background: var(--surface); }
|
|
||||||
|
|
||||||
.editor-layout { display: grid; grid-template-columns: minmax(0, 1fr) 300px; gap: 22px; align-items: start; }
|
|
||||||
.profile-editor { max-width: none; padding: 28px; }
|
|
||||||
.profile-editor .form-section { display: flex; align-items: center; gap: 12px; margin-top: 20px; }
|
|
||||||
.profile-editor .form-section:first-child { margin-top: 0; padding-top: 0; border-top: 0; }
|
|
||||||
.form-section-icon { width: 38px; height: 38px; display: grid; place-items: center; flex: 0 0 auto; border: 1px solid var(--border); border-radius: var(--radius-sm); background: var(--surface-high); color: var(--primary); }
|
|
||||||
.editor-rail { position: sticky; top: calc(var(--utility) + 20px); }
|
|
||||||
.radar-preview { position: relative; overflow: hidden; }
|
|
||||||
.radar-preview::after { content: ""; position: absolute; right: -45px; top: -45px; width: 150px; height: 150px; border: 1px solid color-mix(in srgb, var(--primary) 15%, transparent); border-radius: 50%; box-shadow: inset 0 0 0 32px color-mix(in srgb, var(--primary) 3%, transparent), inset 0 0 0 62px color-mix(in srgb, var(--primary) 3%, transparent); pointer-events: none; }
|
|
||||||
.radar-preview > * { position: relative; z-index: 1; }
|
|
||||||
.preview-score { display: grid; margin: 20px 0; padding: 17px; border: 1px solid color-mix(in srgb, var(--primary) 30%, var(--border)); border-radius: var(--radius); background: var(--bg-deep); }
|
|
||||||
.preview-score strong { color: var(--primary); font-size: 36px; line-height: 1; }
|
|
||||||
.preview-score span { margin-top: 5px; color: var(--muted); font: 10px ui-monospace, SFMono-Regular, Consolas, monospace; text-transform: uppercase; }
|
|
||||||
|
|
||||||
.import-disclosure { padding: 0; overflow: hidden; }
|
|
||||||
.import-disclosure > summary { min-height: 82px; display: grid; grid-template-columns: 44px minmax(0, 1fr) auto; align-items: center; gap: 14px; padding: 16px 20px; cursor: pointer; list-style: none; }
|
|
||||||
.import-disclosure > summary::-webkit-details-marker { display: none; }
|
|
||||||
.import-disclosure > summary > span:nth-child(2) { display: grid; gap: 3px; }
|
|
||||||
.import-disclosure > summary strong { font-size: 17px; }
|
|
||||||
.import-disclosure > summary small { color: var(--muted); }
|
|
||||||
.import-disclosure[open] > summary { border-bottom: 1px solid var(--border); }
|
|
||||||
.import-disclosure[open] > summary .disclosure-icon { transform: rotate(180deg); }
|
|
||||||
.import-disclosure-body { padding: 20px; }
|
|
||||||
.source-list-heading { align-items: end; }
|
|
||||||
.inline-bulk-action { display: flex; align-items: end; gap: 8px; }
|
|
||||||
.inline-bulk-action select { min-width: 170px; }
|
|
||||||
.source-card { border-left: 3px solid var(--border-strong); }
|
|
||||||
.source-card:has(.pill-success) { border-left-color: var(--positive); }
|
|
||||||
.source-card:has(.pill-failed) { border-left-color: var(--danger); }
|
|
||||||
.source-card:has(.pill-review), .source-card:has(.pill-partial) { border-left-color: var(--warning); }
|
|
||||||
|
|
||||||
.system-hero { display: grid; grid-template-columns: minmax(300px, 1.25fr) minmax(300px, 1fr); gap: 14px; }
|
|
||||||
.system-readiness { min-height: 134px; display: grid; grid-template-columns: 48px minmax(0, 1fr) 10px; align-items: center; gap: 16px; padding: 22px; border: 1px solid var(--border); border-radius: var(--radius); background: var(--surface); }
|
|
||||||
.system-readiness.is-healthy { border-color: color-mix(in srgb, var(--positive) 35%, var(--border)); }
|
|
||||||
.system-readiness h2 { margin: 5px 0 2px; font-size: 22px; }
|
|
||||||
.system-readiness p { margin: 0; color: var(--muted); }
|
|
||||||
.system-counts { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; }
|
|
||||||
.system-counts article { display: grid; align-content: center; gap: 6px; padding: 17px; border: 1px solid var(--border); border-radius: var(--radius); background: var(--surface-low); }
|
|
||||||
.system-counts strong { font-size: 25px; }
|
|
||||||
.health-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; }
|
|
||||||
.health-card { padding: 19px; border: 1px solid var(--border); border-radius: var(--radius); background: var(--surface); }
|
|
||||||
.health-card-head { display: flex; align-items: start; justify-content: space-between; gap: 12px; }
|
|
||||||
.health-card h3 { margin: 5px 0 0; font-size: 17px; }
|
|
||||||
.health-card dl { display: grid; grid-template-columns: repeat(2, 1fr); gap: 8px; margin: 17px 0 0; }
|
|
||||||
.health-card dl div { padding: 9px; border: 1px solid var(--border); border-radius: var(--radius-sm); background: var(--surface-low); }
|
|
||||||
.health-card dt { color: var(--muted); font: 9px ui-monospace, SFMono-Regular, Consolas, monospace; text-transform: uppercase; }
|
|
||||||
.health-card dd { margin: 3px 0 0; font-weight: 750; }
|
|
||||||
.command-panel { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 14px; }
|
|
||||||
.command-panel > div { display: grid; gap: 6px; }
|
|
||||||
.command-panel pre { height: 100%; }
|
|
||||||
|
|
||||||
@media (max-width: 1180px) {
|
|
||||||
.utility-radar { display: none; }
|
|
||||||
.dashboard-grid { grid-template-columns: minmax(0, 1fr) 280px; }
|
|
||||||
.dashboard-rail { position: sticky; display: block; }
|
|
||||||
.editor-layout { grid-template-columns: 1fr; }
|
|
||||||
.editor-rail { position: static; display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 14px; }
|
|
||||||
.health-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 900px) {
|
|
||||||
.main-content { padding-inline: 20px; }
|
|
||||||
.global-search { width: min(420px, 55vw); }
|
|
||||||
.utility-cta { width: 42px; padding: 0; }
|
|
||||||
.dashboard-grid { grid-template-columns: 1fr; }
|
|
||||||
.dashboard-rail { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); position: static; }
|
|
||||||
.feed-card { grid-template-columns: 1fr; }
|
|
||||||
.feed-card-actions { min-width: 0; flex-direction: row; justify-content: flex-start; border-top: 1px solid var(--border); border-left: 0; }
|
|
||||||
.feed-card-actions .save-button { width: 42px; }
|
|
||||||
.filter-console { grid-template-columns: minmax(0, 1fr) repeat(2, 150px); }
|
|
||||||
.filter-console .button { grid-column: 1 / -1; }
|
|
||||||
.system-hero { grid-template-columns: 1fr; }
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 680px) {
|
|
||||||
.main-content { padding-inline: 14px; }
|
|
||||||
.utilitybar { gap: 8px; padding-inline: 12px; }
|
|
||||||
.utilitybar-start { gap: 8px; }
|
|
||||||
.global-search { width: min(100%, 260px); }
|
|
||||||
.global-search input { min-height: 40px; }
|
|
||||||
.dashboard-header { align-items: stretch; }
|
|
||||||
.radar-status { width: 100%; max-width: none; flex-wrap: wrap; border-radius: var(--radius); }
|
|
||||||
.dashboard-rail, .editor-rail, .health-grid { grid-template-columns: 1fr; }
|
|
||||||
.feed-card-actions .button { flex: 1; }
|
|
||||||
.filter-console { grid-template-columns: 1fr; }
|
|
||||||
.filter-console .button { grid-column: auto; }
|
|
||||||
.results-toolbar, .source-list-heading { align-items: stretch; flex-direction: column; }
|
|
||||||
.result-card { grid-template-columns: 3px minmax(0, 1fr); }
|
|
||||||
.result-actions { grid-column: 2; min-width: 0; place-content: stretch; padding-top: 0; border-top: 0; border-left: 0; background: var(--surface); }
|
|
||||||
.result-heading { grid-template-columns: 1fr; }
|
|
||||||
.result-heading .score-block { justify-items: start; }
|
|
||||||
.intelligence-strip { grid-template-columns: 1fr; }
|
|
||||||
.intelligence-strip > div { min-height: 92px; }
|
|
||||||
.pipeline { grid-template-columns: repeat(6, 258px); min-height: 410px; }
|
|
||||||
.pipeline-column { min-height: 380px; }
|
|
||||||
.profile-editor { padding: 18px; }
|
|
||||||
.inline-bulk-action { align-items: stretch; flex-direction: column; }
|
|
||||||
.inline-bulk-action select { min-width: 0; }
|
|
||||||
.system-counts, .command-panel { grid-template-columns: 1fr; }
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 420px) {
|
|
||||||
.global-search { width: 46vw; }
|
|
||||||
.global-search input::placeholder { color: transparent; }
|
|
||||||
.utility-actions { gap: 2px; }
|
|
||||||
.utility-actions .icon-button, .utility-actions .utility-link, .utility-cta { width: 38px; min-height: 38px; }
|
|
||||||
.radar-status span:nth-child(3), .radar-status a { display: none; }
|
|
||||||
.feed-card-actions { padding: 13px; }
|
|
||||||
.result-body { padding: 17px; }
|
|
||||||
.result-actions { padding: 0 17px 17px; }
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,572 @@
|
|||||||
|
/* VacatureRadar — Authenticatieschermen (login, wachtwoordherstel).
|
||||||
|
* Lokale reproductie van het Stitch "Intelligence Cockpit" ontwerp.
|
||||||
|
* Bewust CDN-vrij (geen Tailwind/Google Fonts) zodat de pagina local-first blijft
|
||||||
|
* en de toegankelijkheidstest (geen externe assets) blijft slagen.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* Zelf-gehoste fonts (Geist — © Vercel/basement.studio; JetBrains Mono — OFL).
|
||||||
|
* Woff2-bestanden in static/fonts/, geen externe CDN nodig. */
|
||||||
|
@font-face {
|
||||||
|
font-family: "Geist";
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 400;
|
||||||
|
font-display: swap;
|
||||||
|
src: url("../fonts/geist-400.woff2") format("woff2");
|
||||||
|
}
|
||||||
|
@font-face {
|
||||||
|
font-family: "Geist";
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 600;
|
||||||
|
font-display: swap;
|
||||||
|
src: url("../fonts/geist-600.woff2") format("woff2");
|
||||||
|
}
|
||||||
|
@font-face {
|
||||||
|
font-family: "Geist";
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 900;
|
||||||
|
font-display: swap;
|
||||||
|
src: url("../fonts/geist-900.woff2") format("woff2");
|
||||||
|
}
|
||||||
|
@font-face {
|
||||||
|
font-family: "JetBrains Mono";
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 500;
|
||||||
|
font-display: swap;
|
||||||
|
src: url("../fonts/jetbrains-mono-500.woff2") format("woff2");
|
||||||
|
}
|
||||||
|
|
||||||
|
:root {
|
||||||
|
/* Kleuren overgenomen uit het project (tokens.css, lichte thema). */
|
||||||
|
/* Surfaces */
|
||||||
|
--surface: #e6eff2;
|
||||||
|
--surface-dim: #cadadd;
|
||||||
|
--surface-container-lowest: #ffffff;
|
||||||
|
--surface-container-low: #edf4f5;
|
||||||
|
--surface-container: #f5f9fa;
|
||||||
|
--surface-container-high: #e3edef;
|
||||||
|
--surface-container-highest: #d6e3e5;
|
||||||
|
--on-surface: #12262b;
|
||||||
|
--on-surface-variant: #475e63;
|
||||||
|
--outline: #64787d;
|
||||||
|
--outline-variant: #b7c9cc;
|
||||||
|
|
||||||
|
/* Brand / primary (cyaan) */
|
||||||
|
--primary: #007c83;
|
||||||
|
--on-primary: #ffffff;
|
||||||
|
--primary-container: #007c83;
|
||||||
|
--primary-fixed-dim: #006b71;
|
||||||
|
|
||||||
|
/* Secondary + semantic */
|
||||||
|
--secondary-container: #d6f5e7;
|
||||||
|
--on-secondary-container: #087a55;
|
||||||
|
--error: #a82d27;
|
||||||
|
--on-error: #ffffff;
|
||||||
|
--error-container: #fbe6e4;
|
||||||
|
--on-error-container: #7a1f1a;
|
||||||
|
--success: #087a55;
|
||||||
|
--success-container: #d6f5e7;
|
||||||
|
--on-success-container: #053d2c;
|
||||||
|
|
||||||
|
/* Type */
|
||||||
|
--font-sans: "Geist", "Inter", system-ui, -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||||
|
--font-mono: "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||||
|
|
||||||
|
/* Shape */
|
||||||
|
--r-input: 4px;
|
||||||
|
--r-card: 8px;
|
||||||
|
--r-pill: 999px;
|
||||||
|
|
||||||
|
/* Rhythm */
|
||||||
|
--stack-sm: 8px;
|
||||||
|
--stack-md: 16px;
|
||||||
|
--stack-lg: 24px;
|
||||||
|
|
||||||
|
--shadow-card: 0 4px 12px rgba(0, 0, 0, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
html,
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.auth-page {
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 24px 16px;
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
color: var(--on-surface);
|
||||||
|
background-color: var(--surface);
|
||||||
|
background-image: radial-gradient(var(--surface-container-highest) 0.5px, transparent 0.5px);
|
||||||
|
background-size: 24px 24px;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
text-rendering: optimizeLegibility;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-shell {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 28rem; /* 448px, komt overeen met max-w-md */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Merkidentiteit boven de kaart */
|
||||||
|
.auth-brand {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 32px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-brand-logo {
|
||||||
|
display: block;
|
||||||
|
width: 64px;
|
||||||
|
height: 64px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
border-radius: 16px;
|
||||||
|
box-shadow: 0 10px 24px rgba(0, 124, 131, 0.28), 0 2px 6px rgba(18, 38, 43, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-brand-name {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 30px;
|
||||||
|
line-height: 38px;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
font-weight: 900;
|
||||||
|
color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-brand-tagline {
|
||||||
|
margin: 4px 0 0;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 20px;
|
||||||
|
color: var(--on-surface-variant);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Kaart */
|
||||||
|
.auth-card {
|
||||||
|
background: var(--surface-container-lowest);
|
||||||
|
border: 1px solid var(--outline-variant);
|
||||||
|
border-radius: var(--r-card);
|
||||||
|
box-shadow: var(--shadow-card);
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 640px) {
|
||||||
|
.auth-card {
|
||||||
|
padding: 40px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-card h1,
|
||||||
|
.auth-card h2.auth-heading {
|
||||||
|
margin: 0 0 24px;
|
||||||
|
font-size: 20px;
|
||||||
|
line-height: 28px;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--on-surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-lead {
|
||||||
|
margin: -12px 0 24px;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 20px;
|
||||||
|
color: var(--on-surface-variant);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Formulier */
|
||||||
|
.auth-form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-label-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field > label,
|
||||||
|
.field-label-row > label {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 16px;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--on-surface-variant);
|
||||||
|
}
|
||||||
|
|
||||||
|
.field > label {
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-link {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 14px;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
color: var(--primary);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-link:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-wrap {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 10px 16px;
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 20px;
|
||||||
|
color: var(--on-surface);
|
||||||
|
background: var(--surface-container-low);
|
||||||
|
border: 1px solid var(--outline-variant);
|
||||||
|
border-radius: var(--r-input);
|
||||||
|
outline: none;
|
||||||
|
transition: border-color 0.15s ease, box-shadow 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-wrap .auth-input {
|
||||||
|
padding-right: 44px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-input::placeholder {
|
||||||
|
color: var(--outline);
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-input:focus {
|
||||||
|
border-color: var(--primary);
|
||||||
|
box-shadow: 0 0 0 2px rgba(0, 124, 131, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-input[aria-invalid="true"] {
|
||||||
|
border-color: var(--error);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pw-toggle {
|
||||||
|
position: absolute;
|
||||||
|
right: 6px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 34px;
|
||||||
|
height: 34px;
|
||||||
|
padding: 0;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--outline);
|
||||||
|
border-radius: var(--r-input);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pw-toggle:hover {
|
||||||
|
color: var(--on-surface-variant);
|
||||||
|
background: var(--surface-container-high);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pw-toggle .icon {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-error {
|
||||||
|
margin-top: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 16px;
|
||||||
|
color: var(--error);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Knoppen */
|
||||||
|
.btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
width: 100%;
|
||||||
|
padding: 12px 24px;
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
border-radius: var(--r-input);
|
||||||
|
border: 1px solid transparent;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.15s ease, transform 0.1s ease, border-color 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:active {
|
||||||
|
transform: scale(0.98);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn .icon {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background: var(--primary-container);
|
||||||
|
color: var(--on-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover {
|
||||||
|
background: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-sso {
|
||||||
|
background: var(--surface);
|
||||||
|
color: var(--on-surface);
|
||||||
|
border-color: var(--outline-variant);
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 400;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-sso:hover {
|
||||||
|
background: var(--surface-container-high);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-sso svg {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn .spinner {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
animation: auth-spin 0.8s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes auth-spin {
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Scheidingslijn */
|
||||||
|
.auth-separator {
|
||||||
|
position: relative;
|
||||||
|
text-align: center;
|
||||||
|
padding: 4px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-separator::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
height: 1px;
|
||||||
|
background: var(--outline-variant);
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-separator span {
|
||||||
|
position: relative;
|
||||||
|
padding: 0 8px;
|
||||||
|
background: var(--surface-container-lowest);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 11px;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--on-surface-variant);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Demo / secundaire actie */
|
||||||
|
.auth-secondary {
|
||||||
|
margin-top: 32px;
|
||||||
|
padding-top: 24px;
|
||||||
|
border-top: 1px solid var(--outline-variant);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-secondary p {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--on-surface-variant);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-ghost {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
width: auto;
|
||||||
|
padding: 6px 14px;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
border-radius: var(--r-pill);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 12px;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
color: var(--primary);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-ghost:hover {
|
||||||
|
background: rgba(0, 124, 131, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-ghost .icon {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-ghost.inline-form {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Meldingen */
|
||||||
|
.auth-alert {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 10px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
border-radius: var(--r-input);
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-alert .icon {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
margin-top: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-alert.is-error {
|
||||||
|
background: var(--error-container);
|
||||||
|
color: var(--on-error-container);
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-alert.is-success {
|
||||||
|
background: var(--success-container);
|
||||||
|
color: var(--on-success-container);
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-alert.is-info {
|
||||||
|
background: var(--secondary-container);
|
||||||
|
color: var(--on-secondary-container);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Voettekst */
|
||||||
|
.auth-footer {
|
||||||
|
margin-top: 32px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-footer-links {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 11px;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-footer-links a {
|
||||||
|
color: var(--on-surface-variant);
|
||||||
|
text-decoration: none;
|
||||||
|
transition: color 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-footer-links a:hover {
|
||||||
|
color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-footer-links .sep {
|
||||||
|
color: var(--outline-variant);
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 4px 12px;
|
||||||
|
border-radius: var(--r-pill);
|
||||||
|
background: var(--secondary-container);
|
||||||
|
color: var(--on-secondary-container);
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-badge .icon {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-badge span {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 11px;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Terug-link op subpagina's */
|
||||||
|
.auth-back {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
margin-top: 20px;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 12px;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
color: var(--on-surface-variant);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-back:hover {
|
||||||
|
color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-back .icon {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
transform: rotate(180deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sr-only {
|
||||||
|
position: absolute;
|
||||||
|
width: 1px;
|
||||||
|
height: 1px;
|
||||||
|
padding: 0;
|
||||||
|
margin: -1px;
|
||||||
|
overflow: hidden;
|
||||||
|
clip: rect(0, 0, 0, 0);
|
||||||
|
white-space: nowrap;
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.btn,
|
||||||
|
.btn:active,
|
||||||
|
.btn .spinner,
|
||||||
|
.btn-ghost,
|
||||||
|
.auth-input {
|
||||||
|
transition: none;
|
||||||
|
animation: none;
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
*, *::before, *::after { box-sizing: border-box; }
|
||||||
|
html { min-width: 320px; background: var(--surface); scroll-behavior: smooth; }
|
||||||
|
body { margin: 0; min-height: 100vh; overflow-x: hidden; background: var(--surface); color: var(--text); font: 400 15px/1.55 var(--font-body); }
|
||||||
|
body::before { position: fixed; inset: 0; z-index: -1; content: ""; pointer-events: none; background: radial-gradient(circle at 84% 0%, rgba(0,245,255,.055), transparent 31rem), radial-gradient(circle at 35% 100%, rgba(99,102,241,.04), transparent 36rem); }
|
||||||
|
a { color: inherit; text-decoration: none; }
|
||||||
|
a:hover { color: var(--text-strong); }
|
||||||
|
button, input, select, textarea { color: inherit; font: inherit; }
|
||||||
|
button, summary, select { cursor: pointer; }
|
||||||
|
button { border: 0; }
|
||||||
|
img, svg { max-width: 100%; }
|
||||||
|
h1, h2, h3, h4, p { margin-block-start: 0; }
|
||||||
|
h1, h2, h3, h4 { color: var(--text-strong); font-family: var(--font-headline); line-height: 1.2; }
|
||||||
|
h1 { margin-bottom: 8px; font-size: clamp(28px, 2.1vw, 38px); letter-spacing: -.035em; }
|
||||||
|
h2 { margin-bottom: 8px; font-size: 20px; letter-spacing: -.015em; }
|
||||||
|
h3 { margin-bottom: 6px; font-size: 16px; }
|
||||||
|
p { margin-bottom: 12px; }
|
||||||
|
small { color: var(--text-muted); }
|
||||||
|
code, pre, kbd { font-family: var(--font-mono); }
|
||||||
|
pre { overflow: auto; }
|
||||||
|
hr { border: 0; border-top: 1px solid color-mix(in srgb, var(--outline) 55%, transparent); }
|
||||||
|
::selection { background: var(--cyan); color: var(--cyan-ink); }
|
||||||
|
:focus-visible { outline: 2px solid var(--cyan); outline-offset: 3px; }
|
||||||
|
::placeholder { color: var(--text-faint); opacity: 1; }
|
||||||
|
::-webkit-scrollbar { width: 8px; height: 8px; }
|
||||||
|
::-webkit-scrollbar-track { background: var(--surface-lowest); }
|
||||||
|
::-webkit-scrollbar-thumb { background: var(--surface-highest); border-radius: 8px; }
|
||||||
|
.icon { width: 20px; height: 20px; flex: 0 0 auto; fill: none; stroke: currentColor; stroke-linecap: round; stroke-linejoin: round; stroke-width: 1.7; }
|
||||||
|
.icon-sprite { position: absolute; width: 0; height: 0; overflow: hidden; }
|
||||||
|
.sr-only { position: absolute !important; width: 1px !important; height: 1px !important; padding: 0 !important; margin: -1px !important; overflow: hidden !important; clip: rect(0,0,0,0) !important; white-space: nowrap !important; border: 0 !important; }
|
||||||
|
.skip-link { position: fixed; z-index: 1000; top: 10px; left: 10px; padding: 10px 14px; transform: translateY(-160%); background: var(--cyan); color: var(--cyan-ink); font-weight: 700; }
|
||||||
|
.skip-link:focus { transform: translateY(0); }
|
||||||
|
.muted { color: var(--text-muted); }
|
||||||
|
.small { font-size: 13px; }
|
||||||
|
.mobile-only { display: none; }
|
||||||
|
[hidden] { display: none !important; }
|
||||||
|
.theme-icon-light { display: none; }
|
||||||
|
:root[data-theme="light"] .theme-icon-dark { display: none; }
|
||||||
|
:root[data-theme="light"] .theme-icon-light { display: block; }
|
||||||
|
|
||||||
|
input:not([type="checkbox"]):not([type="radio"]):not([type="range"]), select, textarea {
|
||||||
|
width: 100%; min-height: 44px; padding: 10px 12px; border: 1px solid color-mix(in srgb, var(--outline) 72%, transparent); border-radius: var(--radius-sm); outline: 0; background: var(--surface-low); transition: border-color .16s, background .16s, box-shadow .16s;
|
||||||
|
}
|
||||||
|
textarea { min-height: 130px; resize: vertical; }
|
||||||
|
input:hover, select:hover, textarea:hover { border-color: var(--outline-strong); }
|
||||||
|
input:focus, select:focus, textarea:focus { border-color: var(--cyan); box-shadow: 0 0 0 3px color-mix(in srgb, var(--cyan) 12%, transparent); }
|
||||||
|
input[type="checkbox"], input[type="radio"] { width: 18px; height: 18px; margin: 0; accent-color: var(--cyan); }
|
||||||
|
label { display: grid; gap: 7px; color: var(--text); }
|
||||||
|
.field-label, label > span:first-child { color: var(--text-muted); font: 600 11px/1.2 var(--font-mono); letter-spacing: .075em; text-transform: uppercase; }
|
||||||
|
.field-error { color: var(--coral); }
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
.button { display: inline-flex; min-height: 42px; align-items: center; justify-content: center; gap: 8px; padding: 9px 14px; border: 1px solid transparent; border-radius: var(--radius-sm); font: 650 13px/1 var(--font-body); transition: transform .14s, filter .14s, background .14s, border-color .14s; }
|
||||||
|
.button:hover { transform: translateY(-1px); }
|
||||||
|
.button:active { transform: scale(.98); }
|
||||||
|
.button-primary { border-color: var(--cyan); background: var(--cyan); color: var(--cyan-ink); box-shadow: 0 0 18px color-mix(in srgb, var(--cyan) 12%, transparent); }
|
||||||
|
.button-primary:hover { color: var(--cyan-ink); filter: brightness(1.06); }
|
||||||
|
.button-secondary { border-color: color-mix(in srgb, var(--cyan) 36%, var(--outline)); background: color-mix(in srgb, var(--cyan) 9%, var(--surface-high)); color: var(--text-strong); }
|
||||||
|
.button-ghost { border-color: color-mix(in srgb, var(--outline) 62%, transparent); background: var(--surface-high); color: var(--text); }
|
||||||
|
.button-link, .section-link { color: var(--cyan-dim); }
|
||||||
|
.button-danger { border-color: color-mix(in srgb, var(--danger) 55%, transparent); background: color-mix(in srgb, var(--danger) 14%, var(--surface-high)); color: var(--coral); }
|
||||||
|
.button-block { width: 100%; }
|
||||||
|
.button[disabled] { cursor: not-allowed; opacity: .48; filter: grayscale(.5); }
|
||||||
|
.icon-button { display: inline-grid; width: 42px; height: 42px; flex: 0 0 auto; place-items: center; border: 1px solid transparent; border-radius: var(--radius-sm); background: transparent; color: var(--text-muted); }
|
||||||
|
.icon-button:hover { border-color: color-mix(in srgb, var(--outline) 50%, transparent); background: var(--surface-high); color: var(--text-strong); }
|
||||||
|
.avatar { display: grid; width: 35px; height: 35px; flex: 0 0 auto; place-items: center; border: 1px solid var(--outline); border-radius: 50%; background: var(--surface-highest); color: var(--text-strong); font: 700 12px var(--font-mono); }
|
||||||
|
.avatar-small { width: 32px; height: 32px; }
|
||||||
|
.command-search { display: flex; width: min(560px, 62vw); min-height: 42px; align-items: center; gap: 10px; padding: 0 11px; border: 1px solid color-mix(in srgb, var(--outline) 56%, transparent); border-radius: var(--radius-sm); background: var(--surface-low); }
|
||||||
|
.command-search input { min-height: 38px !important; padding: 0 !important; border: 0 !important; background: transparent !important; box-shadow: none !important; }
|
||||||
|
.command-search kbd { padding: 2px 6px; border: 1px solid var(--outline); border-radius: 3px; color: var(--text-faint); font-size: 10px; }
|
||||||
|
.system-live { display: flex; min-height: 38px; align-items: center; gap: 8px; padding: 0 10px; color: var(--emerald); font: 600 11px var(--font-mono); letter-spacing: .07em; text-transform: uppercase; }
|
||||||
|
.status-beacon { width: 8px; height: 8px; flex: 0 0 auto; border-radius: 50%; background: var(--text-faint); }
|
||||||
|
.status-beacon.is-live { background: var(--emerald); box-shadow: 0 0 0 4px color-mix(in srgb, var(--emerald) 10%, transparent), 0 0 12px color-mix(in srgb, var(--emerald) 50%, transparent); }
|
||||||
|
.account-menu { position: relative; }
|
||||||
|
.account-menu summary { display: flex; min-height: 42px; align-items: center; gap: 5px; list-style: none; }
|
||||||
|
.account-menu summary::-webkit-details-marker { display: none; }
|
||||||
|
.account-popover { position: absolute; top: calc(100% + 12px); right: 0; display: grid; width: 230px; gap: 5px; padding: 14px; border: 1px solid var(--outline); background: var(--surface-container); box-shadow: 0 16px 50px rgba(0,0,0,.28); }
|
||||||
|
.account-popover strong, .account-popover small { display: block; }
|
||||||
|
.account-popover a, .account-popover button { display: flex; width: 100%; min-height: 40px; align-items: center; gap: 8px; margin-top: 8px; padding: 8px; background: transparent; color: var(--text); text-align: left; }
|
||||||
|
.account-popover a:hover, .account-popover button:hover { background: var(--surface-high); }
|
||||||
|
.panel, .tonal-panel, .job-card, .result-card, .source-card, .health-card, .profile-card, .form-panel { border: 1px solid color-mix(in srgb, var(--outline) 48%, transparent); border-radius: var(--radius-md); background: var(--surface-container); }
|
||||||
|
.panel, .tonal-panel, .health-card, .form-panel { padding: 20px; }
|
||||||
|
.panel-subtle { background: var(--surface-low); }
|
||||||
|
.strong-match { border-color: color-mix(in srgb, var(--cyan) 35%, var(--outline)); box-shadow: var(--shadow-signal); }
|
||||||
|
.stat-card { position: relative; min-height: 128px; overflow: hidden; padding: 18px; border: 1px solid color-mix(in srgb, var(--outline) 48%, transparent); background: linear-gradient(145deg, color-mix(in srgb, var(--surface-container) 92%, var(--cyan)), var(--surface-container)); }
|
||||||
|
.stat-card::after { position: absolute; right: -28px; bottom: -45px; width: 100px; height: 100px; content: ""; border: 1px solid color-mix(in srgb, var(--cyan) 17%, transparent); border-radius: 50%; }
|
||||||
|
.stat-card.signal { border-color: color-mix(in srgb, var(--cyan) 45%, var(--outline)); }
|
||||||
|
.stat-card strong { display: block; margin: 13px 0 3px; color: var(--text-strong); font: 650 34px/1 var(--font-headline); }
|
||||||
|
.stat-card small { font: 11px var(--font-mono); }
|
||||||
|
.pill { display: inline-flex; min-height: 24px; align-items: center; gap: 6px; padding: 4px 8px; border: 1px solid var(--outline); border-radius: var(--radius-xs); background: var(--surface-high); color: var(--text-muted); font: 600 10px/1 var(--font-mono); letter-spacing: .055em; text-transform: uppercase; }
|
||||||
|
.pill-success, .pill-active, .pill-strong { border-color: color-mix(in srgb, var(--emerald) 44%, transparent); background: color-mix(in srgb, var(--emerald) 10%, var(--surface-high)); color: var(--emerald); }
|
||||||
|
.pill-review, .pill-partial, .pill-possible { border-color: color-mix(in srgb, var(--amber) 44%, transparent); background: color-mix(in srgb, var(--amber) 9%, var(--surface-high)); color: var(--amber); }
|
||||||
|
.pill-failed, .pill-hidden, .pill-error { border-color: color-mix(in srgb, var(--coral) 44%, transparent); background: color-mix(in srgb, var(--coral) 9%, var(--surface-high)); color: var(--coral); }
|
||||||
|
.pill-it, .pill-new, .pill-active { color: var(--cyan-dim); }
|
||||||
|
.title-row { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; }
|
||||||
|
.score-block { min-width: 76px; text-align: right; }
|
||||||
|
.score-block strong { display: block; color: var(--cyan); font: 650 28px/1 var(--font-headline); }
|
||||||
|
.score-block small { display: block; margin-top: 5px; font: 10px var(--font-mono); text-transform: uppercase; }
|
||||||
|
.score-pending strong { color: var(--text-faint); }
|
||||||
|
.job-meta { display: flex; flex-wrap: wrap; gap: 8px 16px; color: var(--text-muted); font-size: 13px; }
|
||||||
|
.job-meta span { display: inline-flex; align-items: center; gap: 6px; }
|
||||||
|
.job-meta .icon { width: 15px; height: 15px; }
|
||||||
|
.signal-list, .compact-list { margin: 12px 0 0; padding-left: 20px; }
|
||||||
|
.signal-list li::marker { color: var(--emerald); }
|
||||||
|
.attention { padding: 9px 11px; border-left: 2px solid var(--amber); background: color-mix(in srgb, var(--amber) 6%, var(--surface-high)); color: var(--text-muted); font-size: 13px; }
|
||||||
|
.empty-state { display: grid; min-height: 240px; place-items: center; align-content: center; padding: 32px; border: 1px dashed var(--outline); background: var(--surface-low); text-align: center; }
|
||||||
|
.compact-empty { min-height: 180px; }
|
||||||
|
.empty-icon { display: grid; width: 48px; height: 48px; place-items: center; margin-bottom: 14px; border: 1px solid var(--outline); color: var(--cyan); }
|
||||||
|
.toast-stack { position: fixed; z-index: 100; top: 84px; right: 20px; display: grid; width: min(420px, calc(100vw - 40px)); gap: 8px; }
|
||||||
|
.toast { display: flex; align-items: flex-start; gap: 10px; padding: 13px 15px; border: 1px solid color-mix(in srgb, var(--cyan) 40%, var(--outline)); background: color-mix(in srgb, var(--surface-container) 94%, transparent); color: var(--text-strong); box-shadow: 0 14px 40px rgba(0,0,0,.25); backdrop-filter: blur(14px); }
|
||||||
|
.toast.error { border-color: color-mix(in srgb, var(--coral) 50%, var(--outline)); }
|
||||||
|
.message { padding: 11px 13px; border-left: 2px solid var(--cyan); background: var(--surface-high); }
|
||||||
|
.message.error { border-color: var(--coral); color: var(--coral); }
|
||||||
|
.technical-log { max-height: 210px; overflow: auto; padding: 12px; background: var(--surface-lowest); color: var(--text-muted); font: 11px/1.6 var(--font-mono); }
|
||||||
|
.disclosure-icon { transition: transform .18s; }
|
||||||
|
[aria-expanded="true"] .disclosure-icon, details[open] > summary .disclosure-icon { transform: rotate(180deg); }
|
||||||
|
.auth-card { width: min(460px, 100%); padding: 30px; border: 1px solid color-mix(in srgb, var(--cyan) 25%, var(--outline)); background: color-mix(in srgb, var(--surface-container) 94%, transparent); box-shadow: 0 30px 90px rgba(0,0,0,.3), var(--shadow-signal); }
|
||||||
|
.auth-brand { display: flex; align-items: flex-start; gap: 15px; margin-bottom: 26px; }
|
||||||
|
.auth-brand .brand-radar, .auth-brand .brand-mark { display: grid; width: 48px; height: 48px; flex: 0 0 auto; place-items: center; border: 1px solid var(--cyan); color: var(--cyan); }
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
.cockpit { min-height: 100vh; }
|
||||||
|
.cockpit-sidebar { position: fixed; inset: 0 auto 0 0; z-index: 50; display: flex; width: var(--sidebar-width); flex-direction: column; border-right: 1px solid color-mix(in srgb, var(--outline) 38%, transparent); background: var(--surface-lowest); }
|
||||||
|
.sidebar-heading { display: flex; min-height: var(--topbar-height); align-items: center; justify-content: space-between; padding: 14px 20px; border-bottom: 1px solid color-mix(in srgb, var(--outline) 25%, transparent); }
|
||||||
|
.cockpit-brand { display: flex; min-width: 0; align-items: center; gap: 11px; }
|
||||||
|
.cockpit-brand strong { display: block; color: var(--text-strong); font: 700 18px/1.1 var(--font-headline); letter-spacing: -.025em; }
|
||||||
|
.cockpit-brand small { display: block; margin-top: 5px; color: var(--text-faint); font: 500 10px/1 var(--font-mono); letter-spacing: .13em; text-transform: uppercase; }
|
||||||
|
.brand-radar { display: grid; width: 35px; height: 35px; place-items: center; border: 1px solid color-mix(in srgb, var(--cyan) 50%, transparent); border-radius: var(--radius-md); background: color-mix(in srgb, var(--cyan) 12%, transparent); color: var(--cyan); box-shadow: var(--shadow-signal); }
|
||||||
|
.cockpit-nav { flex: 1; overflow-y: auto; padding: 19px 12px; }
|
||||||
|
.nav-caption { margin: 18px 12px 7px; color: var(--text-faint); font: 600 10px/1 var(--font-mono); letter-spacing: .16em; text-transform: uppercase; }
|
||||||
|
.nav-caption:first-child { margin-top: 0; }
|
||||||
|
.nav-item { position: relative; display: flex; min-height: 43px; align-items: center; gap: 13px; margin: 2px 0; padding: 9px 12px; border-radius: var(--radius-sm); color: var(--text-muted); font: 500 13px/1.2 var(--font-mono); transition: color .15s, background .15s, transform .15s; }
|
||||||
|
.nav-item:hover { transform: translateX(2px); background: var(--surface-low); color: var(--text-strong); }
|
||||||
|
.nav-item.is-active { background: color-mix(in srgb, var(--cyan) 12%, var(--surface-container)); color: var(--text-strong); }
|
||||||
|
.nav-item.is-active::after { position: absolute; inset: 0 -1px 0 auto; width: 2px; content: ""; background: var(--cyan); box-shadow: 0 0 12px var(--cyan); }
|
||||||
|
.nav-item.is-active .icon { color: var(--cyan); }
|
||||||
|
.sidebar-radar-card { display: flex; align-items: center; gap: 10px; margin: 0 12px 12px; padding: 12px; border: 1px solid color-mix(in srgb, var(--outline) 42%, transparent); background: var(--surface-low); }
|
||||||
|
.sidebar-radar-card strong, .sidebar-radar-card small { display: block; }
|
||||||
|
.sidebar-radar-card strong { font-size: 12px; }
|
||||||
|
.sidebar-radar-card small { margin-top: 3px; font: 10px/1.2 var(--font-mono); }
|
||||||
|
.sidebar-account { display: flex; align-items: center; gap: 10px; padding: 15px 18px; border-top: 1px solid color-mix(in srgb, var(--outline) 28%, transparent); background: var(--surface-low); }
|
||||||
|
.sidebar-account strong, .sidebar-account small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.sidebar-account small { font: 10px var(--font-mono); text-transform: uppercase; }
|
||||||
|
.cockpit-workspace { min-height: 100vh; margin-left: var(--sidebar-width); }
|
||||||
|
.cockpit-topbar { position: fixed; inset: 0 0 auto var(--sidebar-width); z-index: 40; display: flex; height: var(--topbar-height); align-items: center; justify-content: space-between; gap: 20px; padding: 0 var(--content-gutter); border-bottom: 1px solid color-mix(in srgb, var(--outline) 32%, transparent); background: color-mix(in srgb, var(--surface) 88%, transparent); backdrop-filter: blur(18px); }
|
||||||
|
.topbar-start, .topbar-actions { display: flex; min-width: 0; align-items: center; gap: 12px; }
|
||||||
|
.topbar-start { flex: 1; }
|
||||||
|
.cockpit-main { width: 100%; min-height: 100vh; padding: calc(var(--topbar-height) + 36px) var(--content-gutter) 56px; }
|
||||||
|
.cockpit-main > * { width: 100%; max-width: 2240px; margin-right: auto; margin-left: auto; }
|
||||||
|
.nav-scrim, .mobile-tabbar { display: none; }
|
||||||
|
.auth-shell { display: grid; min-height: 100vh; place-items: center; padding: 24px; background: radial-gradient(circle at 50% 12%, rgba(0,245,255,.09), transparent 27rem), var(--surface-lowest); }
|
||||||
|
.page-header, .cockpit-heading { display: flex; align-items: flex-end; justify-content: space-between; gap: 24px; margin-bottom: 26px; }
|
||||||
|
.page-header p:last-child, .cockpit-heading p:last-child { max-width: 780px; margin-bottom: 0; color: var(--text-muted); }
|
||||||
|
.eyebrow, .section-kicker, .kpi-label { display: block; margin-bottom: 8px; color: var(--cyan-dim); font: 600 11px/1.1 var(--font-mono); letter-spacing: .13em; text-transform: uppercase; }
|
||||||
|
.section-heading { display: flex; align-items: end; justify-content: space-between; gap: 20px; margin: 30px 0 15px; }
|
||||||
|
.section-heading p { margin-bottom: 0; color: var(--text-muted); }
|
||||||
|
.page-actions, .job-actions, .form-actions, .stack-actions { display: flex; flex-wrap: wrap; align-items: center; gap: 9px; }
|
||||||
|
.form-actions { justify-content: flex-end; margin-top: 22px; }
|
||||||
|
.stack { display: grid; gap: 16px; }
|
||||||
|
.form-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 18px; }
|
||||||
|
.span-2 { grid-column: span 2; }
|
||||||
|
.card-list { display: grid; gap: 12px; }
|
||||||
|
.stats-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 12px; margin-bottom: 26px; }
|
||||||
|
.dashboard-grid { display: grid; grid-template-columns: minmax(0, 1.7fr) minmax(290px, .65fr); align-items: start; gap: 22px; }
|
||||||
|
.dashboard-rail { position: sticky; top: calc(var(--topbar-height) + 22px); display: grid; gap: 14px; }
|
||||||
|
.results-layout { display: grid; grid-template-columns: minmax(0, 1fr) minmax(280px, .38fr); align-items: start; gap: 20px; }
|
||||||
|
.results-feed, .job-feed { display: grid; gap: 12px; }
|
||||||
|
.definition-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; }
|
||||||
|
.health-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 14px; }
|
||||||
@@ -0,0 +1,380 @@
|
|||||||
|
.radar-status { display: flex; min-height: 40px; align-items: center; gap: 8px; padding: 9px 12px; border: 1px solid color-mix(in srgb, var(--outline) 50%, transparent); background: var(--surface-low); color: var(--text-muted); font: 11px var(--font-mono); }
|
||||||
|
.rail-panel { padding: 18px; }
|
||||||
|
.rail-list { display: grid; margin-top: 15px; }
|
||||||
|
.rail-item, .rail-application { display: flex; min-height: 46px; align-items: center; justify-content: space-between; gap: 12px; border-top: 1px solid color-mix(in srgb, var(--outline) 34%, transparent); }
|
||||||
|
.rail-application strong, .rail-application small { display: block; }
|
||||||
|
.rail-application small { margin-top: 2px; }
|
||||||
|
.feed-card, .result-card { position: relative; display: grid; grid-template-columns: minmax(0, 1fr) auto; overflow: hidden; }
|
||||||
|
.job-card-main, .result-body { padding: 20px; }
|
||||||
|
.job-card-head, .result-heading { display: flex; justify-content: space-between; gap: 18px; }
|
||||||
|
.job-card h3, .result-card h2 { margin: 9px 0 5px; font-size: 19px; }
|
||||||
|
.feed-card-actions, .result-actions { display: flex; min-width: 150px; flex-direction: column; justify-content: center; gap: 9px; padding: 16px; border-left: 1px solid color-mix(in srgb, var(--outline) 34%, transparent); background: var(--surface-low); }
|
||||||
|
.result-accent { position: absolute; inset: 0 auto 0 0; width: 2px; background: var(--cyan); opacity: .5; }
|
||||||
|
.result-summary { margin: 12px 0 0; color: var(--text-muted); }
|
||||||
|
.evidence-line { display: flex; align-items: flex-start; gap: 8px; margin: 12px 0 0; padding: 9px 10px; background: var(--surface-high); color: var(--text-muted); font-size: 13px; }
|
||||||
|
.positive-evidence { border-left: 2px solid var(--emerald); }
|
||||||
|
.concern-evidence { border-left: 2px solid var(--amber); }
|
||||||
|
.filter-console { display: grid; grid-template-columns: minmax(220px, 1.8fr) repeat(5, minmax(125px, 1fr)) auto; align-items: end; gap: 10px; margin-bottom: 24px; padding: 15px; border: 1px solid color-mix(in srgb, var(--outline) 48%, transparent); background: var(--surface-low); }
|
||||||
|
.input-with-icon { position: relative; display: block; }
|
||||||
|
.input-with-icon .icon { position: absolute; z-index: 1; top: 12px; left: 11px; color: var(--text-faint); }
|
||||||
|
.input-with-icon input { padding-left: 39px !important; }
|
||||||
|
.results-toolbar { display: flex; align-items: end; justify-content: space-between; gap: 16px; margin-bottom: 12px; }
|
||||||
|
.view-indicator { display: inline-flex; align-items: center; gap: 7px; color: var(--text-muted); font: 11px var(--font-mono); }
|
||||||
|
.pagination { display: flex; justify-content: center; gap: 8px; margin-top: 20px; }
|
||||||
|
.job-detail-grid { display: grid; grid-template-columns: minmax(0, 1fr) 340px; align-items: start; gap: 20px; }
|
||||||
|
.detail-sidebar { position: sticky; top: calc(var(--topbar-height) + 20px); display: grid; gap: 13px; }
|
||||||
|
.detail-hero { position: relative; overflow: hidden; padding: 26px; border: 1px solid color-mix(in srgb, var(--cyan) 25%, var(--outline)); background: linear-gradient(135deg, color-mix(in srgb, var(--surface-container) 90%, var(--cyan)), var(--surface-low)); }
|
||||||
|
.detail-hero::after { position: absolute; top: -110px; right: -70px; width: 300px; height: 300px; content: ""; border: 1px solid color-mix(in srgb, var(--cyan) 14%, transparent); border-radius: 50%; box-shadow: inset 0 0 0 42px color-mix(in srgb, var(--cyan) 2%, transparent), inset 0 0 0 83px color-mix(in srgb, var(--cyan) 3%, transparent); }
|
||||||
|
.detail-hero > * { position: relative; z-index: 1; }
|
||||||
|
.detail-section { margin-top: 14px; padding: 22px; border: 1px solid color-mix(in srgb, var(--outline) 42%, transparent); background: var(--surface-container); }
|
||||||
|
.sticky-actions { position: sticky; bottom: 16px; z-index: 20; display: flex; justify-content: center; gap: 8px; width: fit-content; max-width: calc(100vw - 32px); margin: 20px auto 0; padding: 8px; border: 1px solid var(--outline); background: color-mix(in srgb, var(--surface-lowest) 90%, transparent); backdrop-filter: blur(16px); }
|
||||||
|
.pipeline-board { display: grid; grid-auto-columns: minmax(270px, 1fr); grid-auto-flow: column; gap: 12px; overflow-x: auto; padding-bottom: 12px; }
|
||||||
|
.pipeline-column { min-height: 370px; padding: 12px; border: 1px solid color-mix(in srgb, var(--outline) 42%, transparent); background: var(--surface-low); }
|
||||||
|
.pipeline-column-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 12px; padding: 4px; font: 600 11px var(--font-mono); letter-spacing: .08em; text-transform: uppercase; }
|
||||||
|
.application-card { margin-bottom: 10px; padding: 15px; border: 1px solid color-mix(in srgb, var(--outline) 52%, transparent); background: var(--surface-container); }
|
||||||
|
.application-card h3 { margin: 6px 0; }
|
||||||
|
.profile-editor-layout { display: grid; grid-template-columns: minmax(0, 1fr) 330px; align-items: start; gap: 20px; }
|
||||||
|
.profile-preview { position: sticky; top: calc(var(--topbar-height) + 20px); }
|
||||||
|
.form-section { margin-bottom: 14px; padding: 20px; border: 1px solid color-mix(in srgb, var(--outline) 45%, transparent); background: var(--surface-container); }
|
||||||
|
.grouped-choices, .grouped-choices > div { display: grid; gap: 8px; }
|
||||||
|
.grouped-choices > div > label, ul.grouped-choices label { display: flex; min-height: 40px; grid-template-columns: auto 1fr; align-items: center; gap: 9px; padding: 8px 10px; border: 1px solid color-mix(in srgb, var(--outline) 38%, transparent); background: var(--surface-low); font: 500 13px var(--font-body); text-transform: none; }
|
||||||
|
.source-card { overflow: hidden; }
|
||||||
|
.source-card-head { display: flex; align-items: center; justify-content: space-between; gap: 18px; padding: 16px 18px; }
|
||||||
|
.source-identity { display: flex; min-width: 0; align-items: center; gap: 13px; }
|
||||||
|
.source-identity h2 { margin: 0; font-size: 17px; }
|
||||||
|
.source-icon { display: grid; width: 42px; height: 42px; flex: 0 0 auto; place-items: center; border: 1px solid color-mix(in srgb, var(--outline) 65%, transparent); background: var(--surface-high); color: var(--cyan); }
|
||||||
|
.source-actions { display: flex; align-items: center; gap: 10px; }
|
||||||
|
.source-details { padding: 15px 18px; border-top: 1px solid color-mix(in srgb, var(--outline) 35%, transparent); background: var(--surface-low); }
|
||||||
|
.source-details-grid { display: grid; grid-template-columns: 1.3fr 1fr; gap: 16px; }
|
||||||
|
.source-alert-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; }
|
||||||
|
.source-alert-card { display: flex; align-items: center; justify-content: space-between; gap: 14px; padding: 14px; border: 1px solid color-mix(in srgb, var(--outline) 40%, transparent); background: var(--surface-low); }
|
||||||
|
.import-disclosure { padding: 0; overflow: hidden; }
|
||||||
|
.import-disclosure > summary { display: flex; align-items: center; gap: 12px; padding: 17px; list-style: none; }
|
||||||
|
.import-disclosure > summary > span:nth-child(2) { display: grid; flex: 1; }
|
||||||
|
.import-disclosure-body { padding: 18px; border-top: 1px solid color-mix(in srgb, var(--outline) 34%, transparent); }
|
||||||
|
.import-layout, .mailbox-form-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 14px; }
|
||||||
|
.health-card dl, .definition-grid { margin: 14px 0 0; }
|
||||||
|
.health-card dl { display: grid; grid-template-columns: repeat(2, 1fr); gap: 8px; }
|
||||||
|
.health-card dt, .definition-grid dt { color: var(--text-faint); font: 10px var(--font-mono); text-transform: uppercase; }
|
||||||
|
.health-card dd, .definition-grid dd { margin: 3px 0 0; color: var(--text-strong); }
|
||||||
|
.system-hero { display: grid; grid-template-columns: minmax(0, 1.3fr) minmax(0, .7fr); gap: 12px; }
|
||||||
|
.system-readiness { display: flex; align-items: center; gap: 15px; padding: 21px; border: 1px solid color-mix(in srgb, var(--outline) 50%, transparent); background: var(--surface-container); }
|
||||||
|
.system-readiness .status-beacon { margin-left: auto; }
|
||||||
|
.system-counts { display: grid; grid-template-columns: repeat(2, 1fr); gap: 8px; }
|
||||||
|
.system-counts article { padding: 14px; border: 1px solid color-mix(in srgb, var(--outline) 40%, transparent); background: var(--surface-low); }
|
||||||
|
.system-counts strong { display: block; margin-top: 8px; font: 650 24px var(--font-headline); }
|
||||||
|
.command-panel { display: grid; grid-template-columns: repeat(2, 1fr); gap: 12px; }
|
||||||
|
.command-panel pre { margin: 6px 0 0; padding: 10px; background: var(--surface-lowest); color: var(--emerald); }
|
||||||
|
.auth-card .stack label { color: var(--text); font-size: 13px; }
|
||||||
|
.login-cockpit { display: grid; width: min(1120px, 100%); min-height: min(720px, calc(100vh - 48px)); grid-template-columns: minmax(0, 1.15fr) minmax(380px, .85fr); overflow: hidden; border: 1px solid color-mix(in srgb, var(--outline) 58%, transparent); background: var(--surface-low); box-shadow: 0 38px 120px rgba(0,0,0,.42), var(--shadow-signal); }
|
||||||
|
.login-intelligence { position: relative; display: flex; overflow: hidden; flex-direction: column; justify-content: space-between; padding: clamp(28px, 4vw, 58px); border-right: 1px solid color-mix(in srgb, var(--cyan) 20%, var(--outline)); background: radial-gradient(circle at 72% 35%, color-mix(in srgb, var(--cyan) 9%, transparent), transparent 15rem), linear-gradient(145deg, color-mix(in srgb, var(--surface-container) 90%, var(--indigo)), var(--surface-lowest)); }
|
||||||
|
.login-brandline { position: relative; z-index: 2; display: flex; align-items: center; gap: 12px; }
|
||||||
|
.login-brandline strong, .login-brandline small { display: block; }
|
||||||
|
.login-brandline strong { color: var(--text-strong); font: 700 19px var(--font-headline); }
|
||||||
|
.login-brandline small { margin-top: 4px; font: 10px var(--font-mono); letter-spacing: .13em; text-transform: uppercase; }
|
||||||
|
.login-radar { position: absolute; top: 18%; right: -6%; width: min(31vw, 390px); aspect-ratio: 1; border: 1px solid color-mix(in srgb, var(--cyan) 22%, transparent); border-radius: 50%; opacity: .75; }
|
||||||
|
.login-radar::before, .login-radar::after, .login-radar span { position: absolute; inset: 15%; content: ""; border: 1px solid color-mix(in srgb, var(--cyan) 18%, transparent); border-radius: 50%; }
|
||||||
|
.login-radar::after { inset: 31%; }
|
||||||
|
.login-radar span:nth-child(1) { inset: 46% 0 auto 0; border: 0; border-top: 1px solid color-mix(in srgb, var(--cyan) 22%, transparent); border-radius: 0; transform: rotate(-24deg); transform-origin: center; }
|
||||||
|
.login-radar span:nth-child(2) { inset: 0 auto 0 50%; border: 0; border-left: 1px solid color-mix(in srgb, var(--cyan) 18%, transparent); border-radius: 0; }
|
||||||
|
.login-radar i { position: absolute; top: 38%; left: 32%; width: 9px; height: 9px; border-radius: 50%; background: var(--cyan); box-shadow: 0 0 0 6px color-mix(in srgb, var(--cyan) 10%, transparent), 0 0 22px var(--cyan); }
|
||||||
|
.login-intro { position: relative; z-index: 2; max-width: 570px; margin-top: auto; }
|
||||||
|
.login-intro h1 { max-width: 560px; margin-bottom: 18px; font-size: clamp(35px, 4.3vw, 62px); line-height: .98; }
|
||||||
|
.login-intro > p:last-child { max-width: 520px; color: var(--text-muted); font-size: 16px; line-height: 1.6; }
|
||||||
|
.login-signals { position: relative; z-index: 2; display: flex; flex-wrap: wrap; gap: 10px 18px; margin: 28px 0 0; padding: 22px 0 0; border-top: 1px solid color-mix(in srgb, var(--outline) 36%, transparent); list-style: none; color: var(--text-muted); font: 10px var(--font-mono); text-transform: uppercase; }
|
||||||
|
.login-signals li { display: flex; align-items: center; gap: 7px; }
|
||||||
|
.login-signals .icon { width: 14px; color: var(--emerald); }
|
||||||
|
.login-cockpit .auth-card { align-self: center; width: auto; margin: clamp(24px, 4vw, 52px); padding: 0; border: 0; background: transparent; box-shadow: none; }
|
||||||
|
.login-cockpit .auth-brand { margin-bottom: 30px; }
|
||||||
|
.login-cockpit .auth-brand h2 { margin: 4px 0 6px; font-size: 30px; }
|
||||||
|
.login-cockpit .stack { gap: 9px; margin-top: 22px; }
|
||||||
|
.login-cockpit .stack input { margin-bottom: 9px; }
|
||||||
|
.login-cockpit .stack .button { margin-top: 12px; }
|
||||||
|
.auth-trust { display: flex; align-items: center; gap: 10px; margin-top: 30px; padding-top: 20px; border-top: 1px solid color-mix(in srgb, var(--outline) 38%, transparent); }
|
||||||
|
.auth-trust strong, .auth-trust small { display: block; }
|
||||||
|
.auth-trust strong { color: var(--text); font-size: 12px; }
|
||||||
|
.auth-trust small { margin-top: 3px; font: 10px var(--font-mono); }
|
||||||
|
.insight-hero { position: relative; display: grid; min-height: 210px; grid-template-columns: 130px minmax(0, 1fr) auto; align-items: center; gap: 28px; overflow: hidden; margin-bottom: 12px; padding: 28px; border: 1px solid color-mix(in srgb, var(--cyan) 32%, var(--outline)); background: linear-gradient(135deg, color-mix(in srgb, var(--surface-container) 92%, var(--cyan)), var(--surface-low)); box-shadow: var(--shadow-signal); }
|
||||||
|
.insight-hero::after { position: absolute; z-index: 0; right: -80px; bottom: -120px; width: 330px; height: 330px; content: ""; border: 1px solid color-mix(in srgb, var(--cyan) 10%, transparent); border-radius: 50%; }
|
||||||
|
.insight-hero > * { position: relative; z-index: 1; }
|
||||||
|
.insight-hero h2 { max-width: 800px; margin: 7px 0; font-size: clamp(23px, 2.2vw, 34px); }
|
||||||
|
.insight-hero p { margin: 0; color: var(--text-muted); }
|
||||||
|
.insight-orbit { position: relative; display: grid; width: 122px; height: 122px; place-items: center; border: 1px solid color-mix(in srgb, var(--cyan) 44%, var(--outline)); border-radius: 50%; box-shadow: inset 0 0 28px color-mix(in srgb, var(--cyan) 6%, transparent), 0 0 24px color-mix(in srgb, var(--cyan) 8%, transparent); }
|
||||||
|
.insight-orbit::before, .insight-orbit span { position: absolute; content: ""; border: 1px solid color-mix(in srgb, var(--cyan) 22%, transparent); border-radius: 50%; }
|
||||||
|
.insight-orbit::before { inset: 16px; }
|
||||||
|
.insight-orbit span:nth-child(1) { inset: 42px; }
|
||||||
|
.insight-orbit span:nth-child(2) { top: 19px; left: 76px; width: 6px; height: 6px; border: 0; background: var(--cyan); box-shadow: 0 0 10px var(--cyan); }
|
||||||
|
.insight-orbit strong { color: var(--cyan); font: 650 25px var(--font-headline); }
|
||||||
|
.skill-metrics { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); margin-bottom: 22px; border: solid color-mix(in srgb, var(--outline) 42%, transparent); border-width: 0 1px 1px; background: var(--surface-low); }
|
||||||
|
.skill-metrics article { min-height: 104px; padding: 17px; border-right: 1px solid color-mix(in srgb, var(--outline) 32%, transparent); }
|
||||||
|
.skill-metrics article:last-child { border-right: 0; }
|
||||||
|
.skill-metrics span, .skill-metrics strong, .skill-metrics small { display: block; }
|
||||||
|
.skill-metrics span { color: var(--text-faint); font: 9px var(--font-mono); letter-spacing: .08em; text-transform: uppercase; }
|
||||||
|
.skill-metrics strong { margin: 10px 0 3px; color: var(--text-strong); font: 650 27px var(--font-headline); }
|
||||||
|
.skill-metrics small { font: 10px var(--font-mono); }
|
||||||
|
.skill-metrics .metric-accent strong { color: var(--amber); }
|
||||||
|
.skills-filter-console { grid-template-columns: minmax(180px, 1fr) minmax(180px, 1fr) auto auto; }
|
||||||
|
.skills-insight-layout { display: grid; grid-template-columns: minmax(0, 1fr) 330px; align-items: start; gap: 16px; }
|
||||||
|
.skill-demand-list, .skills-method-rail { display: grid; gap: 10px; }
|
||||||
|
.skills-method-rail { position: sticky; top: calc(var(--topbar-height) + 18px); }
|
||||||
|
.skill-demand-card { display: grid; grid-template-columns: minmax(0, 1fr) auto; overflow: hidden; border: 1px solid color-mix(in srgb, var(--outline) 43%, transparent); background: var(--surface-container); }
|
||||||
|
.skill-demand-card.is-gap { border-left: 2px solid var(--amber); }
|
||||||
|
.skill-demand-main { padding: 18px; }
|
||||||
|
.skill-demand-action { display: flex; min-width: 160px; align-items: center; padding: 16px; border-left: 1px solid color-mix(in srgb, var(--outline) 34%, transparent); background: var(--surface-low); }
|
||||||
|
.skill-card-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 18px; }
|
||||||
|
.skill-card-heading h3 { margin: 4px 0 0; }
|
||||||
|
.skill-category { color: var(--cyan-dim); font: 9px var(--font-mono); letter-spacing: .09em; text-transform: uppercase; }
|
||||||
|
.demand-meter { height: 5px; overflow: hidden; margin: 15px 0 8px; background: var(--surface-highest); }
|
||||||
|
.demand-meter span { display: block; height: 100%; background: linear-gradient(90deg, var(--indigo), var(--cyan)); }
|
||||||
|
.skill-demand-stats { display: flex; flex-wrap: wrap; gap: 5px 16px; color: var(--text-faint); font: 10px var(--font-mono); }
|
||||||
|
.skill-demand-stats strong { color: var(--text); }
|
||||||
|
.learning-focus { margin: 13px 0; padding: 10px 12px; border-left: 2px solid var(--amber); background: color-mix(in srgb, var(--amber) 5%, var(--surface-high)); color: var(--text-muted); }
|
||||||
|
.learning-focus strong { display: block; margin-bottom: 4px; color: var(--amber); font: 9px var(--font-mono); letter-spacing: .08em; text-transform: uppercase; }
|
||||||
|
.skill-job-examples { display: grid; gap: 0; margin: 13px 0 0; padding: 0; list-style: none; }
|
||||||
|
.skill-job-examples li { display: flex; min-height: 36px; align-items: center; justify-content: space-between; gap: 15px; border-top: 1px solid color-mix(in srgb, var(--outline) 28%, transparent); }
|
||||||
|
.skill-job-examples a { color: var(--text); }
|
||||||
|
.skill-job-examples span { color: var(--text-faint); font: 9px var(--font-mono); }
|
||||||
|
.legend-list, .radar-steps { display: grid; gap: 10px; margin: 14px 0 0; padding: 0; list-style: none; }
|
||||||
|
.legend-list li, .radar-steps li { display: flex; align-items: flex-start; gap: 9px; }
|
||||||
|
.legend-list strong, .legend-list small, .radar-steps strong, .radar-steps span { display: block; }
|
||||||
|
.legend-list small, .radar-steps span { margin-top: 3px; }
|
||||||
|
.legend-dot { width: 8px; height: 8px; flex: 0 0 auto; margin-top: 4px; border-radius: 50%; background: var(--cyan); box-shadow: 0 0 8px color-mix(in srgb, var(--cyan) 50%, transparent); }
|
||||||
|
.legend-dot.inferred { background: var(--indigo); box-shadow: 0 0 8px color-mix(in srgb, var(--indigo) 50%, transparent); }
|
||||||
|
.radar-steps { counter-reset: radar-step; }
|
||||||
|
.radar-steps li { counter-increment: radar-step; }
|
||||||
|
.radar-steps li::before { display: grid; width: 24px; height: 24px; flex: 0 0 auto; place-items: center; content: counter(radar-step, decimal-leading-zero); border: 1px solid var(--outline); color: var(--cyan); font: 9px var(--font-mono); }
|
||||||
|
.mini-timeline { display: grid; gap: 4px; }
|
||||||
|
.mini-event { display: flex; align-items: flex-start; gap: 10px; padding: 9px 0; border-top: 1px solid color-mix(in srgb, var(--outline) 30%, transparent); }
|
||||||
|
.mini-event strong, .mini-event small { display: block; }
|
||||||
|
.mini-event strong { font-size: 12px; }
|
||||||
|
.mini-event small { margin-top: 3px; font-size: 10px; }
|
||||||
|
.active-filters { display: flex; flex-wrap: wrap; align-items: center; gap: 7px; margin: -12px 0 18px; }
|
||||||
|
.active-filters > a { margin-left: auto; color: var(--cyan-dim); font: 11px var(--font-mono); }
|
||||||
|
.explorer-shell { display: grid; grid-template-columns: minmax(430px, .78fr) minmax(500px, 1.22fr); align-items: start; gap: 16px; }
|
||||||
|
.explorer-list { display: grid; gap: 8px; }
|
||||||
|
.explorer-card { position: relative; display: grid; grid-template-columns: 64px minmax(0, 1fr) 24px; align-items: center; gap: 13px; min-height: 132px; padding: 15px; border: 1px solid color-mix(in srgb, var(--outline) 42%, transparent); background: var(--surface-container); transition: border-color .15s, background .15s, transform .15s; }
|
||||||
|
.explorer-card:hover { transform: translateX(2px); border-color: var(--outline-strong); }
|
||||||
|
.explorer-card.is-selected { border-color: color-mix(in srgb, var(--cyan) 48%, var(--outline)); background: color-mix(in srgb, var(--cyan) 5%, var(--surface-container)); box-shadow: var(--shadow-signal); }
|
||||||
|
.explorer-card-link { position: absolute; z-index: 1; inset: 0; }
|
||||||
|
.explorer-score { display: grid; width: 58px; height: 58px; place-items: center; align-content: center; border: 1px solid color-mix(in srgb, var(--cyan) 38%, var(--outline)); background: var(--surface-low); }
|
||||||
|
.explorer-score strong { color: var(--cyan); font: 650 22px/1 var(--font-headline); }
|
||||||
|
.explorer-score small { margin-top: 4px; font: 9px var(--font-mono); text-transform: uppercase; }
|
||||||
|
.explorer-card-body { min-width: 0; }
|
||||||
|
.explorer-card-body h3 { margin: 7px 0 2px; overflow: hidden; font-size: 16px; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.explorer-card-body p { margin-bottom: 7px; color: var(--text-muted); }
|
||||||
|
.card-signal { display: block; overflow: hidden; margin-top: 8px; color: var(--emerald); text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.explorer-arrow { color: var(--text-faint); }
|
||||||
|
.explorer-intelligence { position: sticky; top: calc(var(--topbar-height) + 18px); }
|
||||||
|
.intelligence-panel { overflow: hidden; border: 1px solid color-mix(in srgb, var(--cyan) 24%, var(--outline)); background: var(--surface-low); box-shadow: var(--shadow-signal); }
|
||||||
|
.intelligence-hero { position: relative; overflow: hidden; padding: 24px; border-bottom: 1px solid color-mix(in srgb, var(--outline) 42%, transparent); background: linear-gradient(140deg, color-mix(in srgb, var(--surface-container) 88%, var(--cyan)), var(--surface-container)); }
|
||||||
|
.intelligence-hero::after { position: absolute; right: -40px; bottom: -80px; width: 190px; height: 190px; content: ""; border: 1px solid color-mix(in srgb, var(--cyan) 14%, transparent); border-radius: 50%; box-shadow: inset 0 0 0 32px color-mix(in srgb, var(--cyan) 2%, transparent), inset 0 0 0 63px color-mix(in srgb, var(--cyan) 3%, transparent); }
|
||||||
|
.intelligence-hero > * { position: relative; z-index: 1; }
|
||||||
|
.intelligence-hero h2 { max-width: 620px; margin: 14px 0 4px; font-size: clamp(23px, 2vw, 32px); }
|
||||||
|
.intelligence-score { display: flex; align-items: center; gap: 13px; margin-top: 21px; }
|
||||||
|
.intelligence-score > strong { color: var(--cyan); font: 650 44px/1 var(--font-headline); }
|
||||||
|
.intelligence-score span, .intelligence-score small { display: block; }
|
||||||
|
.intelligence-score span { font-weight: 600; }
|
||||||
|
.intelligence-score small { margin-top: 3px; font: 10px var(--font-mono); }
|
||||||
|
.intel-facts { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); margin: 0; }
|
||||||
|
.intel-facts > div { min-height: 72px; padding: 13px 16px; border-right: 1px solid color-mix(in srgb, var(--outline) 28%, transparent); border-bottom: 1px solid color-mix(in srgb, var(--outline) 28%, transparent); }
|
||||||
|
.intel-facts dt { color: var(--text-faint); font: 10px var(--font-mono); letter-spacing: .06em; text-transform: uppercase; }
|
||||||
|
.intel-facts dd { margin: 5px 0 0; color: var(--text-strong); }
|
||||||
|
.intel-signals { padding: 16px 19px; border-bottom: 1px solid color-mix(in srgb, var(--outline) 30%, transparent); }
|
||||||
|
.intel-signals ul { margin: 9px 0 0; padding-left: 18px; }
|
||||||
|
.intel-signals li::marker { color: var(--emerald); }
|
||||||
|
.intel-signals.concerns li::marker { color: var(--amber); }
|
||||||
|
.intelligence-actions { display: grid; grid-template-columns: 1fr 1fr; gap: 9px; padding: 16px; }
|
||||||
|
.back-link { display: inline-flex; margin-bottom: 16px; color: var(--text-muted); font: 11px var(--font-mono); }
|
||||||
|
.detail-hero { display: flex; align-items: flex-end; justify-content: space-between; gap: 30px; min-height: 270px; }
|
||||||
|
.detail-employer { color: var(--text-muted); font-size: 18px; }
|
||||||
|
.hero-score { min-width: 170px; padding: 18px; border: 1px solid color-mix(in srgb, var(--cyan) 45%, var(--outline)); background: color-mix(in srgb, var(--surface-lowest) 82%, transparent); text-align: center; }
|
||||||
|
.hero-score strong, .hero-score span, .hero-score small { display: block; }
|
||||||
|
.hero-score strong { color: var(--cyan); font: 650 48px/1 var(--font-headline); }
|
||||||
|
.hero-score span { margin-top: 8px; color: var(--text-strong); font-weight: 650; }
|
||||||
|
.hero-score small { margin-top: 5px; font: 10px var(--font-mono); }
|
||||||
|
.hero-score.is-pending strong { color: var(--text-faint); }
|
||||||
|
.intelligence-strip { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); border: solid color-mix(in srgb, var(--outline) 42%, transparent); border-width: 0 1px 1px; background: var(--surface-low); }
|
||||||
|
.intelligence-strip > div { min-height: 92px; padding: 16px; border-right: 1px solid color-mix(in srgb, var(--outline) 32%, transparent); }
|
||||||
|
.intelligence-strip > div:last-child { border-right: 0; }
|
||||||
|
.intelligence-strip strong, .intelligence-strip small { display: block; }
|
||||||
|
.intelligence-strip strong { margin: 8px 0 3px; color: var(--text-strong); font-size: 17px; }
|
||||||
|
.score-components { display: grid; gap: 9px; margin: 18px 0; }
|
||||||
|
.score-components > div { display: grid; grid-template-columns: 150px minmax(100px, 1fr) 36px; align-items: center; gap: 10px; }
|
||||||
|
.score-components span { color: var(--text-muted); font: 11px var(--font-mono); }
|
||||||
|
.progress { height: 5px; overflow: hidden; background: var(--surface-highest); }
|
||||||
|
.progress i { display: block; height: 100%; background: linear-gradient(90deg, var(--cyan-dim), var(--emerald)); }
|
||||||
|
.signal-columns { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 14px; }
|
||||||
|
.signal-columns > div { padding: 15px; background: var(--surface-low); }
|
||||||
|
.skill-bands { display: grid; gap: 16px; margin: 16px 0; }
|
||||||
|
.tag-cloud { display: flex; flex-wrap: wrap; gap: 7px; margin-top: 8px; }
|
||||||
|
.prose { color: var(--text); line-height: 1.75; }
|
||||||
|
.prose h2, .prose h3 { margin-top: 24px; }
|
||||||
|
.source-list { display: grid; gap: 0; padding: 0; list-style: none; }
|
||||||
|
.source-list li { display: flex; min-height: 48px; align-items: center; justify-content: space-between; gap: 14px; border-top: 1px solid color-mix(in srgb, var(--outline) 30%, transparent); }
|
||||||
|
.source-list a { display: inline-flex; align-items: center; gap: 7px; color: var(--cyan-dim); }
|
||||||
|
.source-list span { color: var(--text-muted); font: 10px var(--font-mono); }
|
||||||
|
.provenance-details { margin-top: 15px; }
|
||||||
|
.provenance-details summary { padding: 11px; border: 1px solid var(--outline); background: var(--surface-low); }
|
||||||
|
.data-table-wrap { overflow-x: auto; }
|
||||||
|
.data-table { width: 100%; border-collapse: collapse; }
|
||||||
|
.data-table th, .data-table td { padding: 11px 13px; border-bottom: 1px solid color-mix(in srgb, var(--outline) 35%, transparent); text-align: left; vertical-align: top; }
|
||||||
|
.data-table th { color: var(--text-faint); font: 600 10px var(--font-mono); letter-spacing: .08em; text-transform: uppercase; }
|
||||||
|
.version-list { display: grid; }
|
||||||
|
.version-list article { display: grid; grid-template-columns: 150px 1fr; gap: 16px; padding: 11px 0; border-top: 1px solid color-mix(in srgb, var(--outline) 30%, transparent); }
|
||||||
|
.version-list time { color: var(--text-muted); font: 11px var(--font-mono); }
|
||||||
|
.view-toggle { display: inline-flex; padding: 3px; border: 1px solid var(--outline); background: var(--surface-low); }
|
||||||
|
.view-toggle button { display: inline-flex; min-height: 36px; align-items: center; gap: 7px; padding: 7px 10px; background: transparent; color: var(--text-muted); }
|
||||||
|
.view-toggle button[aria-pressed="true"] { background: color-mix(in srgb, var(--cyan) 11%, var(--surface-high)); color: var(--text-strong); }
|
||||||
|
.pipeline-summary { display: grid; grid-template-columns: repeat(3, minmax(0, 180px)); gap: 8px; margin-bottom: 18px; }
|
||||||
|
.pipeline-summary article { padding: 13px; border: 1px solid color-mix(in srgb, var(--outline) 40%, transparent); background: var(--surface-low); }
|
||||||
|
.pipeline-summary strong { display: block; margin-top: 7px; color: var(--text-strong); font: 650 24px var(--font-headline); }
|
||||||
|
.pipeline-empty { padding: 20px 8px; color: var(--text-faint); font: 11px var(--font-mono); text-align: center; }
|
||||||
|
.application-note { margin: 12px 0; padding: 9px; border-left: 2px solid var(--indigo); background: var(--surface-low); color: var(--text-muted); font-size: 12px; }
|
||||||
|
.status-form { display: grid; grid-template-columns: 1fr auto; gap: 7px; margin-top: 14px; }
|
||||||
|
.status-form select { min-height: 38px; padding-block: 7px; }
|
||||||
|
.status-form .button { min-height: 38px; }
|
||||||
|
.application-link { display: inline-flex; align-items: center; gap: 7px; margin-top: 12px; color: var(--cyan-dim); font-size: 12px; }
|
||||||
|
.application-link .icon { width: 15px; }
|
||||||
|
.application-hero { min-height: 210px; }
|
||||||
|
.application-editor-grid { display: grid; grid-template-columns: minmax(0, 1fr) 360px; align-items: start; gap: 16px; margin-top: 16px; }
|
||||||
|
.application-timeline { position: sticky; top: calc(var(--topbar-height) + 18px); }
|
||||||
|
.timeline-compact { display: grid; }
|
||||||
|
.timeline-compact article { display: grid; grid-template-columns: 34px 1fr; gap: 10px; padding: 11px 0; border-top: 1px solid color-mix(in srgb, var(--outline) 30%, transparent); }
|
||||||
|
.timeline-icon { display: grid; width: 30px; height: 30px; place-items: center; border: 1px solid var(--outline); color: var(--cyan); }
|
||||||
|
.timeline-icon .icon { width: 16px; }
|
||||||
|
.timeline-compact strong, .timeline-compact time, .timeline-compact small { display: block; }
|
||||||
|
.timeline-compact time { margin-top: 2px; color: var(--text-faint); font: 10px var(--font-mono); }
|
||||||
|
.danger-zone { display: flex; align-items: center; justify-content: space-between; gap: 20px; margin-top: 16px; border-color: color-mix(in srgb, var(--danger) 25%, var(--outline)); }
|
||||||
|
.form-section-heading { display: flex; align-items: flex-start; gap: 13px; margin-bottom: 18px; }
|
||||||
|
.form-section-heading p { margin: 0; color: var(--text-muted); }
|
||||||
|
.choice-field { min-width: 0; margin: 0; padding: 0; border: 0; }
|
||||||
|
.choice-field legend { margin-bottom: 10px; color: var(--text-strong); font-weight: 650; }
|
||||||
|
.choice-field > small { display: block; margin: 8px 0; }
|
||||||
|
.choice-field ul { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 7px; margin: 0; padding: 0; list-style: none; }
|
||||||
|
.choice-field ul li label { display: flex; min-height: 42px; align-items: center; gap: 9px; padding: 8px 10px; border: 1px solid color-mix(in srgb, var(--outline) 38%, transparent); background: var(--surface-low); color: var(--text); font: 500 13px var(--font-body); text-transform: none; }
|
||||||
|
.choice-field ul li:has(input:checked) label { border-color: color-mix(in srgb, var(--cyan) 45%, var(--outline)); background: color-mix(in srgb, var(--cyan) 7%, var(--surface-low)); }
|
||||||
|
.toggle-field { display: flex; min-height: 44px; align-items: center; align-content: center; gap: 10px; padding: 10px 12px; border: 1px solid color-mix(in srgb, var(--outline) 45%, transparent); background: var(--surface-low); }
|
||||||
|
.toggle-field > span { color: var(--text); font: 500 13px var(--font-body); letter-spacing: 0; text-transform: none; }
|
||||||
|
.toggle-field small { width: 100%; }
|
||||||
|
.config-disclosure { margin-top: 15px; border-top: 1px solid color-mix(in srgb, var(--outline) 35%, transparent); }
|
||||||
|
.config-disclosure summary { display: flex; align-items: center; justify-content: space-between; padding: 14px 0; color: var(--text-muted); }
|
||||||
|
.preference-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 20px; }
|
||||||
|
.profile-savebar { position: sticky; z-index: 15; bottom: 16px; padding: 9px; border: 1px solid var(--outline); background: color-mix(in srgb, var(--surface-lowest) 92%, transparent); backdrop-filter: blur(16px); }
|
||||||
|
.profile-preview { display: grid; min-width: 0; grid-template-columns: minmax(0, 1fr); gap: 14px; }
|
||||||
|
.profile-preview > * { min-width: 0; }
|
||||||
|
.preview-panel { border-color: color-mix(in srgb, var(--cyan) 34%, var(--outline)); box-shadow: var(--shadow-signal); }
|
||||||
|
.preview-vacancy { margin: 15px 0; padding: 16px; border: 1px solid var(--outline); background: var(--surface-lowest); }
|
||||||
|
.profile-index-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 14px; }
|
||||||
|
.profile-index-card { overflow: hidden; padding: 18px; border: 1px solid color-mix(in srgb, var(--outline) 46%, transparent); background: var(--surface-container); }
|
||||||
|
.profile-index-head { display: flex; align-items: center; justify-content: space-between; }
|
||||||
|
.profile-index-card h2 { margin-top: 16px; }
|
||||||
|
.profile-index-card .intel-facts { margin: 15px -18px; }
|
||||||
|
.source-health-list { display: grid; gap: 9px; }
|
||||||
|
.source-health-row { border: 1px solid color-mix(in srgb, var(--outline) 42%, transparent); background: var(--surface-container); }
|
||||||
|
.source-health-summary { display: grid; grid-template-columns: minmax(290px, 1.3fr) minmax(360px, 1fr) auto; align-items: center; gap: 20px; padding: 15px 17px; }
|
||||||
|
.source-health-summary h2 { margin: 0; font-size: 16px; }
|
||||||
|
.source-health-summary p { margin: 4px 0 0; color: var(--text-muted); font: 11px var(--font-mono); }
|
||||||
|
.source-health-metrics { display: grid; grid-template-columns: repeat(3, minmax(90px, 1fr)); }
|
||||||
|
.source-health-metrics > div { padding: 5px 12px; border-left: 1px solid color-mix(in srgb, var(--outline) 30%, transparent); }
|
||||||
|
.source-health-metrics span, .source-health-metrics strong { display: block; }
|
||||||
|
.source-health-metrics span { color: var(--text-faint); font: 9px var(--font-mono); text-transform: uppercase; }
|
||||||
|
.source-health-metrics strong { margin-top: 5px; font-size: 12px; }
|
||||||
|
.source-health-details { display: grid; grid-template-columns: 1.3fr 1fr; gap: 15px; padding: 17px; border-top: 1px solid color-mix(in srgb, var(--outline) 35%, transparent); background: var(--surface-low); }
|
||||||
|
.source-health-details .intel-facts { align-self: start; }
|
||||||
|
.source-detail-actions { display: flex; flex-wrap: wrap; gap: 8px; grid-column: 1 / -1; }
|
||||||
|
.inline-bulk-action { display: flex; align-items: center; gap: 8px; }
|
||||||
|
.source-management-grid { display: grid; grid-template-columns: 1.25fr .75fr; align-items: start; gap: 14px; margin-top: 30px; }
|
||||||
|
.mailbox-list { display: grid; gap: 8px; margin-bottom: 14px; }
|
||||||
|
.mailbox-row { display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 14px; border: 1px solid color-mix(in srgb, var(--outline) 38%, transparent); background: var(--surface-low); }
|
||||||
|
.mailbox-row p { margin: 4px 0; color: var(--text-muted); }
|
||||||
|
.import-stack { display: grid; gap: 13px; }
|
||||||
|
.import-result { margin-top: 18px; padding-top: 18px; border-top: 1px solid var(--outline); }
|
||||||
|
.watchlist-grid, .platform-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 9px; }
|
||||||
|
.watchlist-grid article, .platform-grid article { display: flex; align-items: center; gap: 11px; padding: 13px; border: 1px solid color-mix(in srgb, var(--outline) 36%, transparent); background: var(--surface-low); }
|
||||||
|
.watchlist-grid article > div { min-width: 0; flex: 1; }
|
||||||
|
.watchlist-grid h3, .watchlist-grid p { margin-bottom: 3px; }
|
||||||
|
.watchlist-grid p { color: var(--text-muted); }
|
||||||
|
.platform-catalog { margin-top: 14px; }
|
||||||
|
.platform-catalog > summary { display: flex; align-items: center; justify-content: space-between; list-style: none; }
|
||||||
|
.platform-catalog > summary span { display: grid; }
|
||||||
|
.platform-grid { margin-top: 18px; }
|
||||||
|
.platform-grid article { align-items: flex-start; flex-direction: column; }
|
||||||
|
.activity-filterbar { display: grid; grid-template-columns: minmax(260px, 1fr) 220px auto; align-items: end; gap: 10px; margin-bottom: 28px; padding: 14px; border: 1px solid color-mix(in srgb, var(--outline) 42%, transparent); background: var(--surface-low); }
|
||||||
|
.activity-timeline { position: relative; display: grid; gap: 16px; padding-left: 78px; }
|
||||||
|
.activity-timeline::before { position: absolute; inset: 0 auto 0 28px; width: 1px; content: ""; background: linear-gradient(var(--cyan), var(--outline) 70%, transparent); opacity: .45; }
|
||||||
|
.activity-event { position: relative; }
|
||||||
|
.activity-marker { position: absolute; top: 0; left: -78px; display: grid; width: 58px; height: 58px; place-items: center; border: 1px solid var(--outline); background: var(--surface-low); color: var(--text-muted); }
|
||||||
|
.activity-marker.is-positive { border-color: color-mix(in srgb, var(--emerald) 45%, var(--outline)); color: var(--emerald); }
|
||||||
|
.activity-marker.is-attention { border-color: color-mix(in srgb, var(--coral) 45%, var(--outline)); color: var(--coral); }
|
||||||
|
.activity-card { padding: 20px; border: 1px solid color-mix(in srgb, var(--outline) 44%, transparent); background: var(--surface-container); }
|
||||||
|
.activity-card-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 20px; }
|
||||||
|
.activity-card-head h2 { margin: 9px 0 0; }
|
||||||
|
.activity-card-head time { color: var(--text-faint); font: 11px var(--font-mono); }
|
||||||
|
.activity-object { color: var(--text-muted); }
|
||||||
|
.decision-note { margin: 14px 0; padding: 12px 14px; border-left: 2px solid var(--cyan); background: var(--surface-high); }
|
||||||
|
.decision-note p { margin: 7px 0 0; }
|
||||||
|
.employer-kpis { display: flex; gap: 8px; }
|
||||||
|
.employer-kpis > div { min-width: 130px; padding: 11px 13px; border: 1px solid color-mix(in srgb, var(--outline) 42%, transparent); background: var(--surface-low); }
|
||||||
|
.employer-kpis span, .employer-kpis strong { display: block; }
|
||||||
|
.employer-kpis span { color: var(--text-faint); font: 9px var(--font-mono); text-transform: uppercase; }
|
||||||
|
.employer-kpis strong { margin-top: 5px; color: var(--text-strong); font: 650 22px var(--font-headline); }
|
||||||
|
.employer-radar-layout { display: grid; grid-template-columns: minmax(0, 1fr) 390px; align-items: start; border: 1px solid color-mix(in srgb, var(--outline) 42%, transparent); background: var(--surface-container); }
|
||||||
|
.employer-table-panel { min-width: 0; padding: 18px; }
|
||||||
|
.employer-table td:first-child a { display: flex; align-items: center; gap: 10px; }
|
||||||
|
.employer-table td:first-child strong, .employer-table td:first-child small { display: block; }
|
||||||
|
.employer-table tr.is-selected { background: color-mix(in srgb, var(--cyan) 6%, var(--surface-high)); }
|
||||||
|
.employer-mark { display: grid; width: 34px; height: 34px; flex: 0 0 auto; place-items: center; border: 1px solid color-mix(in srgb, var(--cyan) 35%, var(--outline)); background: var(--surface-lowest); color: var(--cyan); font: 650 10px var(--font-mono); }
|
||||||
|
.employer-mark.large { width: 54px; height: 54px; margin-bottom: 14px; font-size: 15px; }
|
||||||
|
.employer-mark.hero-mark { width: 112px; height: 112px; font-size: 26px; }
|
||||||
|
.signal-number { color: var(--emerald); }
|
||||||
|
.employer-intelligence-rail { position: sticky; top: calc(var(--topbar-height) + 18px); overflow: hidden; border-left: 1px solid color-mix(in srgb, var(--outline) 42%, transparent); background: var(--surface-low); }
|
||||||
|
.employer-intel-header { padding: 22px; border-bottom: 1px solid color-mix(in srgb, var(--outline) 35%, transparent); }
|
||||||
|
.employer-intel-header h2 { font-size: 26px; }
|
||||||
|
.employer-intel-section { padding: 17px; border-top: 1px solid color-mix(in srgb, var(--outline) 35%, transparent); }
|
||||||
|
.employer-job-link, .employer-source { display: flex; min-height: 50px; align-items: center; justify-content: space-between; gap: 12px; border-top: 1px solid color-mix(in srgb, var(--outline) 28%, transparent); }
|
||||||
|
.employer-job-link strong, .employer-job-link small { display: block; }
|
||||||
|
.employer-job-link b { color: var(--cyan); font: 650 15px var(--font-mono); }
|
||||||
|
.employer-intelligence-rail > .button { margin: 17px; width: calc(100% - 34px); }
|
||||||
|
.employer-detail-hero { min-height: 230px; }
|
||||||
|
.employer-detail-grid { display: grid; grid-template-columns: minmax(0, 1fr) 340px; align-items: start; gap: 16px; }
|
||||||
|
.employer-job-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 9px; }
|
||||||
|
.employer-job-grid article { padding: 15px; border: 1px solid color-mix(in srgb, var(--outline) 38%, transparent); background: var(--surface-low); }
|
||||||
|
.employer-job-grid h3 { margin: 10px 0 4px; }
|
||||||
|
.match-history { display: grid; gap: 7px; }
|
||||||
|
.match-history > div { display: grid; grid-template-columns: 42px minmax(100px, 1fr) 40px minmax(160px, 1.3fr); align-items: center; gap: 9px; }
|
||||||
|
.match-history span { color: var(--text-faint); font: 10px var(--font-mono); }
|
||||||
|
.match-history i { position: relative; display: block; height: 5px; background: var(--surface-highest); }
|
||||||
|
.match-history i::after { position: absolute; inset: 0 auto 0 0; width: var(--match); content: ""; background: linear-gradient(90deg, var(--cyan-dim), var(--emerald)); }
|
||||||
|
.match-history strong { color: var(--cyan); font: 12px var(--font-mono); }
|
||||||
|
.automation-status { display: flex; align-items: center; justify-content: space-between; gap: 20px; padding: 19px 22px; border: 1px solid var(--outline); background: var(--surface-container); }
|
||||||
|
.automation-status.is-healthy { border-color: color-mix(in srgb, var(--emerald) 30%, var(--outline)); }
|
||||||
|
.automation-status h2 { display: flex; align-items: center; gap: 10px; margin: 8px 0 0; }
|
||||||
|
.automation-status dl { display: flex; margin: 0; }
|
||||||
|
.automation-status dl > div { min-width: 150px; padding: 0 16px; border-left: 1px solid var(--outline); }
|
||||||
|
.automation-status dt { color: var(--text-faint); font: 9px var(--font-mono); text-transform: uppercase; }
|
||||||
|
.automation-status dd { margin: 5px 0 0; }
|
||||||
|
.automation-pipeline { display: grid; grid-template-columns: repeat(8, minmax(112px, 1fr)); gap: 4px; overflow-x: auto; margin: 28px 0 16px; padding-top: 28px; }
|
||||||
|
.automation-pipeline > .section-kicker { position: absolute; margin-top: -27px; }
|
||||||
|
.automation-pipeline article { position: relative; display: grid; min-height: 130px; place-items: center; align-content: center; padding: 11px; border: 1px solid color-mix(in srgb, var(--outline) 42%, transparent); border-bottom: 2px solid var(--cyan-dim); background: var(--surface-high); text-align: center; }
|
||||||
|
.automation-pipeline article::after { position: absolute; z-index: 1; top: 50%; right: -8px; width: 12px; content: ""; border-top: 1px solid var(--cyan); }
|
||||||
|
.automation-pipeline article:last-child::after { display: none; }
|
||||||
|
.automation-pipeline .icon { color: var(--cyan); }
|
||||||
|
.automation-pipeline strong { margin-top: 8px; font: 600 10px var(--font-mono); text-transform: uppercase; }
|
||||||
|
.automation-pipeline b { margin-top: 7px; color: var(--text-strong); font: 650 20px var(--font-headline); }
|
||||||
|
.automation-pipeline small { font: 9px var(--font-mono); }
|
||||||
|
.pipeline-phase-index { position: absolute; top: 7px; left: 8px; color: var(--text-faint); font: 9px var(--font-mono); }
|
||||||
|
.automation-kpis { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 10px; margin: 17px 0 28px; }
|
||||||
|
.automation-kpis article { min-height: 120px; padding: 17px; border: 1px solid color-mix(in srgb, var(--outline) 42%, transparent); background: var(--surface-container); }
|
||||||
|
.automation-kpis strong, .automation-kpis small { display: block; }
|
||||||
|
.automation-kpis strong { margin: 15px 0 4px; color: var(--text-strong); font: 650 32px var(--font-headline); }
|
||||||
|
.automation-layout { display: grid; grid-template-columns: minmax(0, 1.5fr) minmax(300px, .5fr); align-items: start; gap: 14px; }
|
||||||
|
.automation-layout > * { min-width: 0; }
|
||||||
|
.automation-source-table, .technical-log-panel { padding: 18px; border: 1px solid color-mix(in srgb, var(--outline) 42%, transparent); background: var(--surface-container); }
|
||||||
|
.technical-log-panel { background: var(--surface-lowest); }
|
||||||
|
.live-log { display: grid; max-height: 520px; overflow-y: auto; margin-bottom: 15px; }
|
||||||
|
.live-log article { display: grid; grid-template-columns: 68px 62px 1fr; gap: 7px; padding: 11px 0; border-bottom: 1px solid color-mix(in srgb, var(--outline) 27%, transparent); font: 10px/1.5 var(--font-mono); }
|
||||||
|
.live-log time { color: var(--text-faint); }
|
||||||
|
.live-log strong { color: var(--indigo); }
|
||||||
|
.live-log article.is-success strong { color: var(--emerald); }
|
||||||
|
.live-log article.is-error strong { color: var(--coral); }
|
||||||
|
.live-log small { grid-column: 2 / -1; }
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
@media (min-width: 1850px) {
|
||||||
|
.results-feed { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||||
|
.results-feed .result-card { grid-template-columns: 1fr; }
|
||||||
|
.results-feed .result-actions { min-width: 0; flex-direction: row; justify-content: flex-start; border-top: 1px solid color-mix(in srgb, var(--outline) 34%, transparent); border-left: 0; }
|
||||||
|
.health-grid { grid-template-columns: repeat(4, minmax(0, 1fr)); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1450px) {
|
||||||
|
.filter-console { grid-template-columns: minmax(240px, 2fr) repeat(3, minmax(130px, 1fr)); }
|
||||||
|
.filter-console > :nth-last-child(-n+3) { grid-row: 2; }
|
||||||
|
.explorer-shell { grid-template-columns: minmax(390px, .9fr) minmax(430px, 1.1fr); }
|
||||||
|
.source-health-summary { grid-template-columns: minmax(260px, 1fr) minmax(300px, .9fr) auto; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1180px) {
|
||||||
|
.dashboard-grid, .results-layout, .job-detail-grid, .profile-editor-layout { grid-template-columns: 1fr; }
|
||||||
|
.dashboard-rail, .detail-sidebar, .profile-preview { position: static; grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||||
|
.stats-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||||
|
.health-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||||
|
.filter-console { grid-template-columns: repeat(3, minmax(0, 1fr)); }
|
||||||
|
.filter-console > * { grid-row: auto !important; }
|
||||||
|
.explorer-shell { grid-template-columns: 1fr; }
|
||||||
|
.explorer-intelligence { position: static; }
|
||||||
|
.application-editor-grid, .source-management-grid { grid-template-columns: 1fr; }
|
||||||
|
.application-timeline { position: static; }
|
||||||
|
.profile-index-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||||
|
.source-health-summary { grid-template-columns: 1fr auto; }
|
||||||
|
.source-health-metrics { grid-column: 1 / -1; grid-row: 2; }
|
||||||
|
.watchlist-grid, .platform-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||||
|
.employer-radar-layout, .employer-detail-grid, .automation-layout { grid-template-columns: 1fr; }
|
||||||
|
.employer-intelligence-rail { position: static; border-top: 1px solid var(--outline); border-left: 0; }
|
||||||
|
.automation-kpis { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||||
|
.skills-insight-layout { grid-template-columns: 1fr; }
|
||||||
|
.skills-method-rail { position: static; grid-template-columns: repeat(3, minmax(0, 1fr)); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 820px) {
|
||||||
|
:root { --content-gutter: 16px; --topbar-height: 64px; }
|
||||||
|
.mobile-only { display: inline-grid; }
|
||||||
|
.desktop-label { display: none !important; }
|
||||||
|
.cockpit-sidebar { width: min(296px, 86vw); transform: translateX(-102%); transition: transform .2s ease; }
|
||||||
|
.nav-open .cockpit-sidebar { transform: translateX(0); }
|
||||||
|
.cockpit-workspace { margin-left: 0; }
|
||||||
|
.cockpit-topbar { left: 0; padding: 0 14px; }
|
||||||
|
.nav-scrim { position: fixed; z-index: 45; inset: 0; display: block; visibility: hidden; background: rgba(2,7,17,.7); opacity: 0; transition: opacity .2s, visibility .2s; }
|
||||||
|
.nav-open .nav-scrim { visibility: visible; opacity: 1; }
|
||||||
|
.command-search { width: min(100%, 510px); }
|
||||||
|
.cockpit-main { padding-bottom: 92px; }
|
||||||
|
.mobile-tabbar { position: fixed; z-index: 38; inset: auto 0 0; display: grid; grid-template-columns: repeat(4, 1fr); border-top: 1px solid var(--outline); background: color-mix(in srgb, var(--surface-lowest) 94%, transparent); backdrop-filter: blur(18px); }
|
||||||
|
.mobile-tabbar a { display: grid; min-height: 66px; place-items: center; align-content: center; gap: 4px; color: var(--text-faint); font: 10px var(--font-mono); }
|
||||||
|
.mobile-tabbar a.is-active { color: var(--cyan); }
|
||||||
|
.mobile-tabbar .icon { width: 19px; height: 19px; }
|
||||||
|
.page-header, .cockpit-heading, .section-heading { align-items: flex-start; flex-direction: column; }
|
||||||
|
.page-header { margin-bottom: 20px; }
|
||||||
|
.form-grid, .source-details-grid, .import-layout, .mailbox-form-grid, .system-hero { grid-template-columns: 1fr; }
|
||||||
|
.span-2 { grid-column: auto; }
|
||||||
|
.filter-console { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||||
|
.filter-console .grow { grid-column: span 2; }
|
||||||
|
.dashboard-rail, .detail-sidebar, .profile-preview { grid-template-columns: 1fr; }
|
||||||
|
.source-alert-grid { grid-template-columns: 1fr; }
|
||||||
|
.feed-card, .result-card { grid-template-columns: 1fr; }
|
||||||
|
.feed-card-actions, .result-actions { min-width: 0; flex-direction: row; justify-content: flex-start; border-top: 1px solid color-mix(in srgb, var(--outline) 34%, transparent); border-left: 0; }
|
||||||
|
.detail-hero { align-items: flex-start; flex-direction: column; min-height: 0; }
|
||||||
|
.hero-score { min-width: 0; width: 100%; text-align: left; }
|
||||||
|
.intelligence-strip { grid-template-columns: repeat(2, 1fr); }
|
||||||
|
.preference-grid, .source-health-details { grid-template-columns: 1fr; }
|
||||||
|
.automation-status { align-items: flex-start; flex-direction: column; }
|
||||||
|
.automation-status dl { display: grid; grid-template-columns: repeat(3, 1fr); width: 100%; }
|
||||||
|
.automation-status dl > div { min-width: 0; border-top: 1px solid var(--outline); border-left: 0; padding: 11px 4px; }
|
||||||
|
.employer-job-grid { grid-template-columns: 1fr; }
|
||||||
|
.login-cockpit { min-height: 0; grid-template-columns: 1fr; }
|
||||||
|
.login-intelligence { min-height: 360px; border-right: 0; border-bottom: 1px solid var(--outline); }
|
||||||
|
.login-radar { width: 310px; }
|
||||||
|
.login-cockpit .auth-card { margin: 34px; }
|
||||||
|
.insight-hero { grid-template-columns: 96px minmax(0, 1fr); }
|
||||||
|
.insight-hero .button { grid-column: 1 / -1; width: fit-content; }
|
||||||
|
.insight-orbit { width: 92px; height: 92px; }
|
||||||
|
.skill-metrics { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||||
|
.skill-metrics article:nth-child(2) { border-right: 0; }
|
||||||
|
.skill-metrics article:nth-child(-n+2) { border-bottom: 1px solid color-mix(in srgb, var(--outline) 32%, transparent); }
|
||||||
|
.skills-method-rail { grid-template-columns: 1fr; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 560px) {
|
||||||
|
h1 { font-size: 27px; }
|
||||||
|
.cockpit-main { padding-top: calc(var(--topbar-height) + 24px); }
|
||||||
|
.command-search kbd, .system-live { display: none; }
|
||||||
|
.topbar-actions { gap: 3px; }
|
||||||
|
.stats-grid, .health-grid, .filter-console, .command-panel, .system-counts { grid-template-columns: 1fr; }
|
||||||
|
.filter-console .grow { grid-column: auto; }
|
||||||
|
.stat-card { min-height: 105px; }
|
||||||
|
.job-card-main, .result-body, .panel, .tonal-panel, .health-card, .form-panel, .form-section, .detail-section { padding: 16px; }
|
||||||
|
.job-card-head, .result-heading { align-items: flex-start; flex-direction: column; }
|
||||||
|
.score-block { min-width: 0; text-align: left; }
|
||||||
|
.source-card-head, .source-alert-card { align-items: flex-start; flex-direction: column; }
|
||||||
|
.source-actions { width: 100%; justify-content: space-between; }
|
||||||
|
.page-actions, .job-actions, .form-actions { width: 100%; }
|
||||||
|
.page-actions .button, .form-actions .button { flex: 1; }
|
||||||
|
.sticky-actions { bottom: 76px; width: 100%; overflow-x: auto; justify-content: flex-start; }
|
||||||
|
.explorer-card { grid-template-columns: 54px minmax(0, 1fr); padding: 12px; }
|
||||||
|
.explorer-score { width: 50px; height: 50px; }
|
||||||
|
.explorer-arrow { display: none; }
|
||||||
|
.intelligence-actions, .intel-facts, .signal-columns, .intelligence-strip { grid-template-columns: 1fr; }
|
||||||
|
.score-components > div { grid-template-columns: 100px minmax(70px, 1fr) 30px; }
|
||||||
|
.source-list li, .version-list article { align-items: flex-start; grid-template-columns: 1fr; flex-direction: column; padding: 11px 0; }
|
||||||
|
.pipeline-summary, .choice-field ul, .profile-index-grid, .watchlist-grid, .platform-grid { grid-template-columns: 1fr; }
|
||||||
|
.source-health-summary { grid-template-columns: 1fr; align-items: start; }
|
||||||
|
.source-health-metrics { grid-column: auto; grid-row: auto; grid-template-columns: 1fr; width: 100%; }
|
||||||
|
.source-health-metrics > div { display: flex; align-items: center; justify-content: space-between; border-top: 1px solid color-mix(in srgb, var(--outline) 30%, transparent); border-left: 0; }
|
||||||
|
.source-health-metrics strong { margin-top: 0; }
|
||||||
|
.mailbox-row, .danger-zone { align-items: flex-start; flex-direction: column; }
|
||||||
|
.activity-filterbar, .automation-kpis { grid-template-columns: 1fr; }
|
||||||
|
.activity-timeline { padding-left: 52px; }
|
||||||
|
.activity-timeline::before { left: 20px; }
|
||||||
|
.activity-marker { left: -52px; width: 42px; height: 42px; }
|
||||||
|
.activity-card-head { flex-direction: column; gap: 8px; }
|
||||||
|
.employer-kpis { width: 100%; }
|
||||||
|
.employer-kpis > div { min-width: 0; flex: 1; }
|
||||||
|
.automation-status dl { grid-template-columns: 1fr; }
|
||||||
|
.match-history > div { grid-template-columns: 38px 1fr 36px; }
|
||||||
|
.match-history a { grid-column: 1 / -1; }
|
||||||
|
.auth-shell { padding: 14px; }
|
||||||
|
.auth-card { padding: 22px 18px; }
|
||||||
|
.login-intelligence { min-height: 310px; padding: 24px; }
|
||||||
|
.login-intro h1 { font-size: 34px; }
|
||||||
|
.login-signals { display: none; }
|
||||||
|
.login-radar { top: 3%; right: -34%; width: 290px; }
|
||||||
|
.login-cockpit .auth-card { margin: 28px 20px; padding: 0; }
|
||||||
|
.insight-hero { min-height: 0; grid-template-columns: 1fr; padding: 20px; }
|
||||||
|
.insight-orbit { width: 82px; height: 82px; }
|
||||||
|
.skill-metrics { grid-template-columns: 1fr; }
|
||||||
|
.skill-metrics article { border-right: 0; border-bottom: 1px solid color-mix(in srgb, var(--outline) 32%, transparent); }
|
||||||
|
.skill-demand-card { grid-template-columns: 1fr; }
|
||||||
|
.skill-demand-action { min-width: 0; border-top: 1px solid color-mix(in srgb, var(--outline) 34%, transparent); border-left: 0; }
|
||||||
|
.skill-demand-action .button { width: 100%; }
|
||||||
|
.skill-card-heading, .skill-job-examples li { align-items: flex-start; flex-direction: column; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
*, *::before, *::after { scroll-behavior: auto !important; animation-duration: .01ms !important; animation-iteration-count: 1 !important; transition-duration: .01ms !important; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
:root {
|
||||||
|
color-scheme: dark;
|
||||||
|
--surface-lowest: #060e20;
|
||||||
|
--surface: #0b1326;
|
||||||
|
--surface-low: #131b2e;
|
||||||
|
--surface-container: #171f33;
|
||||||
|
--surface-high: #222a3d;
|
||||||
|
--surface-highest: #2d3449;
|
||||||
|
--surface-bright: #31394d;
|
||||||
|
--text: #dae2fd;
|
||||||
|
--text-strong: #e9feff;
|
||||||
|
--text-muted: #aababb;
|
||||||
|
--text-faint: #7f9095;
|
||||||
|
--outline: #3a494a;
|
||||||
|
--outline-strong: #526365;
|
||||||
|
--cyan: #00f5ff;
|
||||||
|
--cyan-dim: #00dce5;
|
||||||
|
--cyan-ink: #002b2e;
|
||||||
|
--emerald: #4edea3;
|
||||||
|
--emerald-dim: #00a572;
|
||||||
|
--indigo: #8f91ff;
|
||||||
|
--amber: #f5c451;
|
||||||
|
--coral: #ffb4ab;
|
||||||
|
--danger: #ff7b72;
|
||||||
|
--sidebar-width: 256px;
|
||||||
|
--topbar-height: 72px;
|
||||||
|
--content-gutter: clamp(20px, 2.2vw, 40px);
|
||||||
|
--radius-xs: 2px;
|
||||||
|
--radius-sm: 4px;
|
||||||
|
--radius-md: 8px;
|
||||||
|
--radius-lg: 12px;
|
||||||
|
--shadow-signal: 0 0 24px rgba(0, 245, 255, 0.08);
|
||||||
|
--font-headline: "Segoe UI Variable Display", "Aptos Display", "Segoe UI", sans-serif;
|
||||||
|
--font-body: "Segoe UI Variable Text", "Aptos", "Segoe UI", sans-serif;
|
||||||
|
--font-mono: "Cascadia Mono", "SFMono-Regular", Consolas, monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme="light"] {
|
||||||
|
color-scheme: light;
|
||||||
|
--surface-lowest: #e6eff2;
|
||||||
|
--surface: #f5f9fa;
|
||||||
|
--surface-low: #edf4f5;
|
||||||
|
--surface-container: #ffffff;
|
||||||
|
--surface-high: #e3edef;
|
||||||
|
--surface-highest: #d6e3e5;
|
||||||
|
--surface-bright: #cadadd;
|
||||||
|
--text: #12262b;
|
||||||
|
--text-strong: #07181d;
|
||||||
|
--text-muted: #475e63;
|
||||||
|
--text-faint: #64787d;
|
||||||
|
--outline: #b7c9cc;
|
||||||
|
--outline-strong: #82999d;
|
||||||
|
--cyan: #007c83;
|
||||||
|
--cyan-dim: #006b71;
|
||||||
|
--cyan-ink: #f2ffff;
|
||||||
|
--emerald: #087a55;
|
||||||
|
--emerald-dim: #d6f5e7;
|
||||||
|
--indigo: #5558bf;
|
||||||
|
--amber: #8a5b00;
|
||||||
|
--coral: #a7332d;
|
||||||
|
--danger: #a82d27;
|
||||||
|
--shadow-signal: 0 0 20px rgba(0, 124, 131, 0.1);
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
<svg width="64" height="64" viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="VacatureRadar">
|
||||||
|
<rect width="64" height="64" rx="12" fill="#007c83"/>
|
||||||
|
<g fill="none" stroke="#f2ffff" stroke-linecap="round" stroke-linejoin="round" stroke-width="4">
|
||||||
|
<circle cx="32" cy="32" r="20"/>
|
||||||
|
<circle cx="32" cy="32" r="11"/>
|
||||||
|
<path d="m32 32 15-15M32 8v5M8 32h5M32 51v5M51 32h5"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 424 B |
@@ -1,46 +1,40 @@
|
|||||||
(() => {
|
(() => {
|
||||||
|
"use strict";
|
||||||
const root = document.documentElement;
|
const root = document.documentElement;
|
||||||
let storedTheme = null;
|
|
||||||
try {
|
|
||||||
storedTheme = localStorage.getItem("vacatureradar-theme");
|
|
||||||
} catch {
|
|
||||||
// Private or embedded browser contexts may disable persistent storage.
|
|
||||||
}
|
|
||||||
if (storedTheme === "light" || storedTheme === "dark") root.dataset.theme = storedTheme;
|
|
||||||
|
|
||||||
const themeToggle = document.querySelector("[data-theme-toggle]");
|
|
||||||
themeToggle?.addEventListener("click", () => {
|
|
||||||
const theme = root.dataset.theme === "light" ? "dark" : "light";
|
|
||||||
root.dataset.theme = theme;
|
|
||||||
try {
|
|
||||||
localStorage.setItem("vacatureradar-theme", theme);
|
|
||||||
} catch {
|
|
||||||
// The selected theme still applies for the current page.
|
|
||||||
}
|
|
||||||
themeToggle.setAttribute("aria-label", theme === "light" ? "Donker thema activeren" : "Licht thema activeren");
|
|
||||||
});
|
|
||||||
|
|
||||||
const frame = document.querySelector("[data-app-frame]");
|
const frame = document.querySelector("[data-app-frame]");
|
||||||
const sidebar = document.querySelector("[data-sidebar]");
|
const sidebar = document.querySelector("[data-sidebar]");
|
||||||
const toggle = document.querySelector("[data-nav-toggle]");
|
const navToggle = document.querySelector("[data-nav-toggle]");
|
||||||
const closeButtons = document.querySelectorAll("[data-nav-close], [data-nav-scrim]");
|
|
||||||
let returnFocus = null;
|
let returnFocus = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const storedTheme = localStorage.getItem("vacatureradar-theme");
|
||||||
|
if (storedTheme === "light" || storedTheme === "dark") root.dataset.theme = storedTheme;
|
||||||
|
} catch { /* Persistent storage is optional. */ }
|
||||||
|
|
||||||
|
const themeToggle = document.querySelector("[data-theme-toggle]");
|
||||||
|
const updateThemeLabel = () => {
|
||||||
|
themeToggle?.setAttribute("aria-label", root.dataset.theme === "light" ? "Donker thema activeren" : "Licht thema activeren");
|
||||||
|
};
|
||||||
|
updateThemeLabel();
|
||||||
|
themeToggle?.addEventListener("click", () => {
|
||||||
|
const next = root.dataset.theme === "light" ? "dark" : "light";
|
||||||
|
root.dataset.theme = next;
|
||||||
|
updateThemeLabel();
|
||||||
|
try { localStorage.setItem("vacatureradar-theme", next); } catch { /* Current page still updates. */ }
|
||||||
|
});
|
||||||
|
|
||||||
const closeNavigation = () => {
|
const closeNavigation = () => {
|
||||||
frame?.classList.remove("nav-open");
|
frame?.classList.remove("nav-open");
|
||||||
toggle?.setAttribute("aria-expanded", "false");
|
navToggle?.setAttribute("aria-expanded", "false");
|
||||||
if (returnFocus instanceof HTMLElement) returnFocus.focus();
|
if (returnFocus instanceof HTMLElement) returnFocus.focus();
|
||||||
};
|
};
|
||||||
toggle?.addEventListener("click", () => {
|
navToggle?.addEventListener("click", () => {
|
||||||
returnFocus = document.activeElement;
|
returnFocus = document.activeElement;
|
||||||
frame?.classList.add("nav-open");
|
frame?.classList.add("nav-open");
|
||||||
toggle.setAttribute("aria-expanded", "true");
|
navToggle.setAttribute("aria-expanded", "true");
|
||||||
sidebar?.querySelector("a, button")?.focus();
|
sidebar?.querySelector("a, button")?.focus();
|
||||||
});
|
});
|
||||||
closeButtons.forEach((button) => button.addEventListener("click", closeNavigation));
|
document.querySelectorAll("[data-nav-close], [data-nav-scrim]").forEach((control) => control.addEventListener("click", closeNavigation));
|
||||||
document.addEventListener("keydown", (event) => {
|
|
||||||
if (event.key === "Escape") closeNavigation();
|
|
||||||
});
|
|
||||||
|
|
||||||
document.querySelectorAll("[data-disclosure]").forEach((button) => {
|
document.querySelectorAll("[data-disclosure]").forEach((button) => {
|
||||||
button.addEventListener("click", () => {
|
button.addEventListener("click", () => {
|
||||||
@@ -51,4 +45,21 @@
|
|||||||
target.hidden = expanded;
|
target.hidden = expanded;
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
document.querySelectorAll("[data-view-switch]").forEach((button) => {
|
||||||
|
button.addEventListener("click", () => {
|
||||||
|
const group = button.closest("[data-view-controls]");
|
||||||
|
const view = button.dataset.viewSwitch;
|
||||||
|
group?.querySelectorAll("[data-view-switch]").forEach((item) => item.setAttribute("aria-pressed", String(item === button)));
|
||||||
|
document.querySelectorAll("[data-view]").forEach((panel) => { panel.hidden = panel.dataset.view !== view; });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener("keydown", (event) => {
|
||||||
|
if (event.key === "Escape") closeNavigation();
|
||||||
|
if (event.key === "/" && !/INPUT|TEXTAREA|SELECT/.test(document.activeElement?.tagName || "")) {
|
||||||
|
event.preventDefault();
|
||||||
|
document.getElementById("global-search")?.focus();
|
||||||
|
}
|
||||||
|
});
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Geen toegang · VacatureRadar{% endblock %}
|
||||||
|
{% block content %}<section class="empty-state"><div class="empty-icon"><svg class="icon"><use href="#icon-alert"></use></svg></div><p class="eyebrow">403 · Toegang geweigerd</p><h1>Deze intelligence is niet voor jou beschikbaar</h1><p>Je account heeft geen toegang tot dit onderdeel. Er zijn geen gegevens gewijzigd.</p><a class="button button-primary" href="{% url 'dashboard:today' %}">Terug naar overzicht</a></section>{% endblock %}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Niet gevonden · VacatureRadar{% endblock %}
|
||||||
|
{% block content %}<section class="empty-state"><div class="empty-icon"><svg class="icon"><use href="#icon-radar"></use></svg></div><p class="eyebrow">404 · Geen radarsignaal</p><h1>Deze pagina is niet gevonden</h1><p>De link kan verouderd zijn of het object bestaat niet meer.</p><a class="button button-primary" href="{% url 'dashboard:today' %}">Terug naar overzicht</a></section>{% endblock %}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Systeemfout · VacatureRadar{% endblock %}
|
||||||
|
{% block content %}<section class="empty-state"><div class="empty-icon"><svg class="icon"><use href="#icon-system"></use></svg></div><p class="eyebrow">500 · Systeemsignaal</p><h1>De radar kon dit verzoek niet verwerken</h1><p>Probeer het later opnieuw. Er worden hier geen technische details of gevoelige gegevens getoond.</p><a class="button button-primary" href="/">Terug naar overzicht</a></section>{% endblock %}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Activiteit · VacatureRadar{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<header class="cockpit-heading"><div><p class="eyebrow">Radar audit trail</p><h1>Activiteit</h1><p>Betrouwbare gebeurtenissen uit bronruns, vacatureversies, feedback, sollicitaties, notificaties en profielrevisies.</p></div><div class="radar-status"><span class="status-beacon is-live" aria-hidden="true"></span><span>{{ event_page.paginator.count }} gebeurtenissen in deze selectie</span></div></header>
|
||||||
|
<form class="activity-filterbar" method="get"><label class="grow"><span class="field-label">Zoek in activiteit</span><span class="input-with-icon"><svg class="icon"><use href="#icon-search"></use></svg><input type="search" name="q" value="{{ request.GET.q }}" placeholder="Vacature, werkgever of gebeurtenis"></span></label><label><span class="field-label">Type</span><select name="type">{% for value,label in activity_filters %}<option value="{{ value }}" {% if current_type == value %}selected{% endif %}>{{ label }}</option>{% endfor %}</select></label><button class="button button-secondary" type="submit"><svg class="icon"><use href="#icon-filter"></use></svg>Filter</button></form>
|
||||||
|
|
||||||
|
<section class="activity-timeline" aria-label="Activiteitstijdlijn">
|
||||||
|
{% for event in event_page %}<article class="activity-event"><span class="activity-marker {% if event.tone == 'positive' %}is-positive{% elif event.tone == 'attention' %}is-attention{% endif %}"><svg class="icon"><use href="{% if event.event_type == 'source' %}#icon-sources{% elif event.event_type == 'application' %}#icon-pipeline{% elif event.event_type == 'profile' %}#icon-profile{% elif event.event_type == 'notification' %}#icon-mail{% else %}#icon-activity{% endif %}"></use></svg></span><div class="activity-card"><div class="activity-card-head"><div><span class="pill pill-{{ event.tone }}">{{ event.event_type }}</span><h2>{{ event.title }}</h2></div><time datetime="{{ event.occurred_at|date:'c' }}">{{ event.occurred_at|date:"d/m/Y H:i" }}</time></div><p class="activity-object">{{ event.object_label }}</p><div class="decision-note"><span class="field-label">Gebeurtenisdetails</span><p>{{ event.detail }}</p></div><a class="section-link" href="{{ event.url }}">Open betrokken onderdeel <svg class="icon"><use href="#icon-arrow"></use></svg></a></div></article>{% empty %}<div class="empty-state"><div class="empty-icon"><svg class="icon"><use href="#icon-activity"></use></svg></div><h2>Geen activiteit gevonden</h2><p>Deze filter bevat nog geen auditwaardige gebeurtenis.</p><a class="button button-ghost" href="{% url 'jobs:activity' %}">Alle activiteit tonen</a></div>{% endfor %}
|
||||||
|
</section>
|
||||||
|
{% if event_page.paginator.num_pages > 1 %}<nav class="pagination" aria-label="Activiteitspaginering">{% if event_page.has_previous %}<a class="button button-ghost" href="?page={{ event_page.previous_page_number }}&type={{ current_type }}&q={{ request.GET.q|urlencode }}">Vorige</a>{% endif %}<span>Pagina {{ event_page.number }} van {{ event_page.paginator.num_pages }}</span>{% if event_page.has_next %}<a class="button button-ghost" href="?page={{ event_page.next_page_number }}&type={{ current_type }}&q={{ request.GET.q|urlencode }}">Volgende</a>{% endif %}</nav>{% endif %}
|
||||||
|
{% endblock %}
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
{% block title %}Sollicitatiedossier · VacatureRadar{% endblock %}
|
{% block title %}Dossier · {{ object.job.original_title }}{% endblock %}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<header class="page-header"><div><p class="eyebrow">Sollicitatiedossier</p><h1>{{ object.job.original_title }}</h1><p>{{ object.job.employer_name }} · alleen zichtbaar voor jouw account</p></div><div class="page-actions"><a class="button button-ghost" href="{% url 'jobs:application-print' object.pk %}" target="_blank" rel="noopener">Afdrukweergave</a><a class="button button-secondary" href="{% url 'jobs:application-export' object.pk %}">Exporteren</a></div></header>
|
<a class="back-link" href="{% url 'jobs:applications' %}">← Terug naar de pipeline</a>
|
||||||
<form method="post" class="panel form-panel">{% csrf_token %}<div class="form-grid">{% for field in form %}<label class="{% if field.name == 'notes' %}span-2{% endif %}"><span>{{ field.label }}</span>{{ field }}{% for error in field.errors %}<small class="field-error">{{ error }}</small>{% endfor %}</label>{% endfor %}</div><div class="form-actions"><a class="button button-ghost" href="{% url 'jobs:applications' %}">Terug</a><button class="button button-primary" type="submit">Dossier opslaan</button></div></form>
|
<header class="detail-hero application-hero"><div><p class="eyebrow">Sollicitatiedossier · {{ object.get_status_display }}</p><h1>{{ object.job.original_title }}</h1><p class="detail-employer">{{ object.job.employer_name }}</p><div class="job-meta"><span>{{ object.job.raw_location|default:"Locatie onbekend" }}</span>{% if object.applied_at %}<span>gestart {{ object.applied_at|date:"d/m/Y" }}</span>{% endif %}{% if object.follow_up_date %}<span>opvolging {{ object.follow_up_date|date:"d/m/Y" }}</span>{% endif %}</div></div><div class="page-actions"><a class="button button-ghost" href="{% url 'jobs:application-print' object.pk %}" target="_blank" rel="noopener">Afdrukweergave</a><a class="button button-secondary" href="{% url 'jobs:application-export' object.pk %}">Exporteren</a></div></header>
|
||||||
<section class="panel panel-subtle"><h2>Privacy en verwijderen</h2><p class="muted">De export bevat de opgeslagen snapshot en tijdlijn. Verwijderen wist alleen jouw dossier; de onderliggende vacature blijft behouden.</p><form method="post" action="{% url 'jobs:application-delete' object.pk %}">{% csrf_token %}<button class="button button-danger" type="submit">Dossier verwijderen</button></form></section>
|
<div class="application-editor-grid"><form method="post" class="form-panel">{% csrf_token %}<span class="section-kicker">Dossiergegevens</span><h2>Opvolging en contact</h2>{% if form.non_field_errors %}<div class="message error">{{ form.non_field_errors }}</div>{% endif %}<div class="form-grid">{% for field in form %}<label class="{% if field.name == 'notes' %}span-2{% endif %}"><span>{{ field.label }}</span>{{ field }}{% if field.help_text %}<small>{{ field.help_text }}</small>{% endif %}{% for error in field.errors %}<small class="field-error">{{ error }}</small>{% endfor %}</label>{% endfor %}</div><div class="form-actions"><a class="button button-ghost" href="{% url 'jobs:applications' %}">Annuleren</a><button class="button button-primary" type="submit">Dossier opslaan</button></div></form><aside class="application-timeline panel"><span class="section-kicker">Audit trail</span><h2>Activiteit</h2><div class="timeline-compact">{% for event in object.timeline_events.all %}<article><span class="timeline-icon"><svg class="icon"><use href="#icon-activity"></use></svg></span><div><strong>{{ event.get_event_type_display }}</strong><time datetime="{{ event.created_at|date:'c' }}">{{ event.created_at|date:"d/m/Y H:i" }}</time>{% if event.metadata.from or event.metadata.to %}<small>{{ event.metadata.from|default:"—" }} → {{ event.metadata.to|default:"—" }}</small>{% endif %}</div></article>{% empty %}<p class="muted">Nog geen tijdlijngebeurtenis.</p>{% endfor %}</div></aside></div>
|
||||||
|
<section class="panel danger-zone"><div><span class="section-kicker">Privacy</span><h2>Dossier verwijderen</h2><p class="muted">Verwijderen wist alleen jouw dossier en tijdlijn. De onderliggende vacature blijft in de radar.</p></div><form method="post" action="{% url 'jobs:application-delete' object.pk %}">{% csrf_token %}<button class="button button-danger" type="submit">Dossier verwijderen</button></form></section>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -1,18 +1,15 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
{% block title %}Sollicitaties · VacatureRadar{% endblock %}
|
{% block title %}Sollicitatiepipeline · VacatureRadar{% endblock %}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<header class="page-header pipeline-page-header"><div><p class="eyebrow">Persoonlijke opvolging</p><h1>Sollicitatiepipeline</h1><p>Beheer je actieve trajecten, deadlines en bewaarde vacaturesnapshots.</p></div><div class="page-actions"><span class="view-indicator"><svg class="icon"><use href="#icon-pipeline"></use></svg>Kanban</span><a class="button button-primary" href="{% url 'jobs:list' %}"><svg class="icon"><use href="#icon-search"></use></svg>Vacature toevoegen</a></div></header>
|
<header class="cockpit-heading"><div><p class="eyebrow">Persoonlijke opvolging</p><h1>Sollicitatiepipeline</h1><p>Beheer je actieve trajecten, opvolging en dossierstatus. Iedere statuswijziging wordt in de tijdlijn vastgelegd.</p></div><div class="page-actions"><div class="view-toggle" data-view-controls aria-label="Weergave"><button type="button" data-view-switch="kanban" aria-pressed="true"><svg class="icon"><use href="#icon-pipeline"></use></svg>Kanban</button><button type="button" data-view-switch="list" aria-pressed="false"><svg class="icon"><use href="#icon-list"></use></svg>Lijst</button></div><a class="button button-primary" href="{% url 'jobs:list' %}"><svg class="icon"><use href="#icon-search"></use></svg>Vacature zoeken</a></div></header>
|
||||||
<section class="pipeline" aria-label="Sollicitaties per status">
|
|
||||||
{% for column in pipeline_columns %}
|
<section class="pipeline-summary" aria-label="Pipeline kerncijfers"><article><span class="field-label">Alle dossiers</span><strong>{{ applications|length }}</strong></article><article><span class="field-label">Actief</span><strong>{{ open_count }}</strong></article><article><span class="field-label">Opvolging nu</span><strong>{{ follow_up_count }}</strong></article></section>
|
||||||
<div class="pipeline-column">
|
|
||||||
<div class="pipeline-head"><h2><span class="column-dot" aria-hidden="true"></span>{{ column.label }}</h2><span class="pipeline-count">{{ column.applications|length }}</span></div>
|
{% if applications %}
|
||||||
<div class="pipeline-cards">
|
<section class="pipeline-board" data-view="kanban" aria-label="Sollicitaties per status">
|
||||||
{% for application in column.applications %}
|
{% for column in pipeline_columns %}<div class="pipeline-column"><div class="pipeline-column-header"><span>{{ column.label }}</span><span class="pill">{{ column.applications|length }}</span></div><div>{% for application in column.applications %}<article class="application-card"><div class="title-row"><span class="pill pill-{{ application.status }}">{{ application.get_status_display }}</span>{% if application.follow_up_date and application.follow_up_date <= today %}<span class="pill pill-failed">Opvolgen</span>{% endif %}</div><h3><a href="{% url 'jobs:detail' application.job.pk %}">{{ application.job.original_title }}</a></h3><p class="muted">{{ application.job.employer_name }}{% if application.job.raw_location %} · {{ application.job.raw_location }}{% endif %}</p><div class="job-meta">{% if application.applied_at %}<span><svg class="icon"><use href="#icon-mail"></use></svg>{{ application.applied_at|date:"d/m/Y" }}</span>{% endif %}{% if application.follow_up_date %}<span><svg class="icon"><use href="#icon-calendar"></use></svg>{{ application.follow_up_date|date:"d/m/Y" }}</span>{% endif %}{% if application.contact_name %}<span>{{ application.contact_name }}</span>{% endif %}</div>{% if application.notes %}<p class="application-note">{{ application.notes|truncatewords:18 }}</p>{% endif %}<form method="post" action="{% url 'jobs:application-status' application.pk %}" class="status-form">{% csrf_token %}<label><span class="sr-only">Status voor {{ application.job.original_title }}</span><select name="status">{% for value,label in status_choices %}<option value="{{ value }}" {% if value == application.status %}selected{% endif %}>{{ label }}</option>{% endfor %}</select></label><button class="button button-secondary" type="submit">Bijwerken</button></form><a class="application-link" href="{% url 'jobs:application-edit' application.pk %}">Open dossier <svg class="icon"><use href="#icon-arrow"></use></svg></a></article>{% empty %}<div class="pipeline-empty">Geen dossier in deze fase.</div>{% endfor %}</div></div>{% endfor %}
|
||||||
<article class="pipeline-card"><span class="pill pill-{{ application.status }}">{{ application.get_status_display }}</span><h3><a href="{% url 'jobs:detail' application.job.pk %}">{{ application.job.original_title }}</a></h3><p>{{ application.job.employer_name }}{% if application.job.raw_location %} · {{ application.job.raw_location }}{% endif %}</p><div class="job-meta">{% if application.applied_at %}<span>Verzonden {{ application.applied_at|date:"d/m/Y" }}</span>{% endif %}{% if application.follow_up_date %}<span>Opvolgen {{ application.follow_up_date|date:"d/m/Y" }}</span>{% endif %}</div><div class="job-actions"><a class="button button-ghost button-small" href="{% url 'jobs:application-edit' application.pk %}">Dossier <svg class="icon"><use href="#icon-arrow"></use></svg></a></div></article>
|
|
||||||
{% empty %}<div class="pipeline-empty">Geen dossiers in deze fase.</div>{% endfor %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endfor %}
|
|
||||||
</section>
|
</section>
|
||||||
{% if not applications %}<div class="empty-state compact-empty"><div class="empty-icon"><svg class="icon"><use href="#icon-pipeline"></use></svg></div><h2>Nog geen sollicitatiedossiers</h2><p>Markeer een vacature als Gesolliciteerd om automatisch een persoonlijk dossier te maken.</p><a class="button button-primary" href="{% url 'jobs:list' %}">Vacatures bekijken</a></div>{% endif %}
|
|
||||||
|
<section class="panel" data-view="list" hidden aria-label="Sollicitaties als lijst"><div class="data-table-wrap" tabindex="0"><table class="data-table"><thead><tr><th>Vacature</th><th>Status</th><th>Opvolging</th><th>Contact</th><th>Bijgewerkt</th><th><span class="sr-only">Actie</span></th></tr></thead><tbody>{% for application in applications %}<tr><td><strong>{{ application.job.original_title }}</strong><small>{{ application.job.employer_name }}</small></td><td><span class="pill pill-{{ application.status }}">{{ application.get_status_display }}</span></td><td>{{ application.follow_up_date|date:"d/m/Y"|default:"Niet gepland" }}</td><td>{{ application.contact_name|default:"Onbekend" }}</td><td>{{ application.updated_at|date:"d/m/Y H:i" }}</td><td><a class="button button-ghost" href="{% url 'jobs:application-edit' application.pk %}">Dossier</a></td></tr>{% endfor %}</tbody></table></div></section>
|
||||||
|
{% else %}<div class="empty-state"><div class="empty-icon"><svg class="icon"><use href="#icon-pipeline"></use></svg></div><h2>Nog geen sollicitatiedossiers</h2><p>Markeer een relevante vacature als gesolliciteerd of start een dossier vanuit het detail.</p><a class="button button-primary" href="{% url 'jobs:list' %}">Nieuwe matches bekijken</a></div>{% endif %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -5,89 +5,93 @@
|
|||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<meta name="color-scheme" content="dark light">
|
<meta name="color-scheme" content="dark light">
|
||||||
<meta name="theme-color" content="#07101f">
|
<meta name="theme-color" content="#060e20">
|
||||||
<title>{% block title %}{{ app_name }}{% endblock %}</title>
|
<title>{% block title %}{{ app_name }}{% endblock %}</title>
|
||||||
<link rel="icon" href="{% static 'favicon.svg' %}" type="image/svg+xml">
|
<link rel="icon" href="{% static 'favicon.svg' %}" type="image/svg+xml">
|
||||||
<link rel="stylesheet" href="{% static 'css/app.css' %}?v={{ app_version }}">
|
<link rel="stylesheet" href="{% static 'css/app.css' %}?v={{ app_version }}">
|
||||||
<script src="{% static 'js/app.js' %}?v={{ app_version }}" defer></script>
|
<script src="{% static 'js/app.js' %}?v={{ app_version }}" defer></script>
|
||||||
|
{% block extra_head %}{% endblock %}
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body class="{% block body_class %}{% endblock %}">
|
||||||
{% include "components/icon_sprite.html" %}
|
{% include "components/icon_sprite.html" %}
|
||||||
<a class="skip-link" href="#main">Naar hoofdinhoud</a>
|
<a class="skip-link" href="#main">Naar hoofdinhoud</a>
|
||||||
{% if user.is_authenticated %}
|
{% if user.is_authenticated %}
|
||||||
<div class="app-frame" data-app-frame>
|
<div class="cockpit" data-app-frame>
|
||||||
<aside class="sidebar" data-sidebar aria-label="Hoofdnavigatie">
|
<aside class="cockpit-sidebar" data-sidebar aria-label="Hoofdnavigatie">
|
||||||
<div class="sidebar-brand">
|
<div class="sidebar-heading">
|
||||||
<a class="brand" href="{% url 'dashboard:today' %}">
|
<a class="cockpit-brand" href="{% url 'dashboard:today' %}" aria-label="VacatureRadar dashboard">
|
||||||
<span class="brand-mark" aria-hidden="true"><svg class="icon"><use href="#icon-radar"></use></svg></span>
|
<span class="brand-radar" aria-hidden="true"><svg class="icon"><use href="#icon-radar"></use></svg></span>
|
||||||
<span><strong>VacatureRadar</strong><small>Intelligence cockpit</small></span>
|
<span><strong>VacatureRadar</strong><small>Intelligence cockpit</small></span>
|
||||||
</a>
|
</a>
|
||||||
<button class="icon-button sidebar-close mobile-only" type="button" data-nav-close aria-label="Menu sluiten">
|
<button class="icon-button nav-close mobile-only" type="button" data-nav-close aria-label="Menu sluiten"><svg class="icon"><use href="#icon-close"></use></svg></button>
|
||||||
<svg class="icon"><use href="#icon-close"></use></svg>
|
</div>
|
||||||
</button>
|
|
||||||
|
<nav class="cockpit-nav">
|
||||||
|
<p class="nav-caption">Intelligence</p>
|
||||||
|
<a class="nav-item {% if request.resolver_match.namespace == 'dashboard' and request.resolver_match.url_name == 'today' %}is-active{% endif %}" href="{% url 'dashboard:today' %}"><svg class="icon"><use href="#icon-dashboard"></use></svg><span>Overzicht</span></a>
|
||||||
|
<a class="nav-item {% if request.resolver_match.namespace == 'jobs' and request.resolver_match.url_name == 'list' %}is-active{% endif %}" href="{% url 'jobs:list' %}"><svg class="icon"><use href="#icon-radar"></use></svg><span>Nieuwe matches</span></a>
|
||||||
|
<a class="nav-item {% if request.resolver_match.namespace == 'jobs' and request.resolver_match.url_name == 'detail' %}is-active{% endif %}" href="{% url 'jobs:list' %}?focus=all"><svg class="icon"><use href="#icon-briefcase"></use></svg><span>Alle vacatures</span></a>
|
||||||
|
<a class="nav-item {% if request.resolver_match.namespace == 'jobs' and request.resolver_match.url_name == 'skill-insights' %}is-active{% endif %}" href="{% url 'jobs:skill-insights' %}"><svg class="icon"><use href="#icon-chart"></use></svg><span>Skillsradar</span></a>
|
||||||
|
<a class="nav-item {% if request.resolver_match.namespace == 'jobs' and request.resolver_match.url_name == 'applications' or request.resolver_match.url_name == 'application-edit' %}is-active{% endif %}" href="{% url 'jobs:applications' %}"><svg class="icon"><use href="#icon-pipeline"></use></svg><span>Sollicitaties</span></a>
|
||||||
|
<a class="nav-item {% if request.resolver_match.url_name == 'employers' or request.resolver_match.url_name == 'employer-detail' %}is-active{% endif %}" href="{% url 'jobs:employers' %}"><svg class="icon"><use href="#icon-building"></use></svg><span>Werkgevers</span></a>
|
||||||
|
|
||||||
|
<p class="nav-caption">Radarbeheer</p>
|
||||||
|
<a class="nav-item {% if request.resolver_match.namespace == 'profiles' %}is-active{% endif %}" href="{% url 'profiles:list' %}"><svg class="icon"><use href="#icon-profile"></use></svg><span>Zoekprofiel</span></a>
|
||||||
|
<a class="nav-item {% if request.resolver_match.namespace == 'sources' %}is-active{% endif %}" href="{% url 'sources:list' %}"><svg class="icon"><use href="#icon-sources"></use></svg><span>Bronnen</span></a>
|
||||||
|
<a class="nav-item {% if request.resolver_match.namespace == 'dashboard' and request.resolver_match.url_name == 'system' %}is-active{% endif %}" href="{% url 'dashboard:system' %}"><svg class="icon"><use href="#icon-system"></use></svg><span>Automatisering</span></a>
|
||||||
|
<a class="nav-item {% if request.resolver_match.url_name == 'activity' %}is-active{% endif %}" href="{% url 'jobs:activity' %}"><svg class="icon"><use href="#icon-activity"></use></svg><span>Activiteit</span></a>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div class="sidebar-radar-card">
|
||||||
|
<span class="status-beacon {% if navigation_profile %}is-live{% endif %}" aria-hidden="true"></span>
|
||||||
|
<span><strong>{% if navigation_profile %}Radar actief{% else %}Profiel nodig{% endif %}</strong><small>{% if navigation_profile %}{{ navigation_profile.name }}{% else %}Configureer je zoekprofiel{% endif %}</small></span>
|
||||||
|
</div>
|
||||||
|
<div class="sidebar-account">
|
||||||
|
<span class="avatar" aria-hidden="true">{{ user.first_name|default:user.username|slice:":1"|upper }}</span>
|
||||||
|
<span><strong>{{ user.get_full_name|default:user.username }}</strong><small>Persoonlijke radar</small></span>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<button class="nav-scrim" type="button" data-nav-scrim aria-label="Menu sluiten" tabindex="-1"></button>
|
||||||
|
<div class="cockpit-workspace">
|
||||||
|
<header class="cockpit-topbar">
|
||||||
|
<div class="topbar-start">
|
||||||
|
<button class="icon-button mobile-only" type="button" data-nav-toggle aria-label="Menu openen" aria-expanded="false"><svg class="icon"><use href="#icon-menu"></use></svg></button>
|
||||||
|
<form class="command-search" method="get" action="{% url 'jobs:list' %}" role="search">
|
||||||
|
<label class="sr-only" for="global-search">Zoek door vacatures</label>
|
||||||
|
<svg class="icon" aria-hidden="true"><use href="#icon-search"></use></svg>
|
||||||
|
<input id="global-search" type="search" name="q" value="{% if request.resolver_match.namespace == 'jobs' and request.resolver_match.url_name == 'list' %}{{ request.GET.q }}{% endif %}" placeholder="Zoek vacatures, werkgevers of skills…">
|
||||||
|
<kbd aria-hidden="true">/</kbd>
|
||||||
|
</form>
|
||||||
</div>
|
</div>
|
||||||
<nav class="nav-stack">
|
<div class="topbar-actions">
|
||||||
<p class="nav-group-label">Radar</p>
|
<a class="system-live" href="{% url 'dashboard:system' %}"><span class="status-beacon is-live" aria-hidden="true"></span><span class="desktop-label">Systeemstatus</span></a>
|
||||||
<a class="nav-link {% if request.resolver_match.namespace == 'dashboard' and request.resolver_match.url_name == 'today' %}active{% endif %}" href="{% url 'dashboard:today' %}">
|
<button class="icon-button" type="button" data-theme-toggle aria-label="Licht thema activeren" title="Thema wisselen"><svg class="icon theme-icon-dark"><use href="#icon-sun"></use></svg><svg class="icon theme-icon-light"><use href="#icon-moon"></use></svg></button>
|
||||||
<svg class="icon"><use href="#icon-dashboard"></use></svg><span>Vandaag<small>beste matches</small></span>
|
<details class="account-menu">
|
||||||
</a>
|
<summary aria-label="Gebruikersmenu openen"><span class="avatar avatar-small" aria-hidden="true">{{ user.first_name|default:user.username|slice:":1"|upper }}</span><svg class="icon desktop-label"><use href="#icon-chevron"></use></svg></summary>
|
||||||
<a class="nav-link {% if request.resolver_match.namespace == 'jobs' and request.resolver_match.url_name == 'list' %}active{% endif %}" href="{% url 'jobs:list' %}">
|
<div class="account-popover"><strong>{{ user.get_full_name|default:user.username }}</strong><small>{{ user.username }}</small>{% if navigation_profile %}<a href="{% url 'profiles:edit' navigation_profile.pk %}">Zoekprofiel aanpassen</a>{% endif %}<form action="{% url 'logout' %}" method="post">{% csrf_token %}<button type="submit"><svg class="icon"><use href="#icon-logout"></use></svg>Afmelden</button></form></div>
|
||||||
<svg class="icon"><use href="#icon-search"></use></svg><span>Vacatures<small>zoeken en filteren</small></span>
|
</details>
|
||||||
</a>
|
|
||||||
<a class="nav-link {% if request.resolver_match.namespace == 'jobs' and request.resolver_match.url_name == 'applications' or request.resolver_match.url_name == 'application-edit' %}active{% endif %}" href="{% url 'jobs:applications' %}">
|
|
||||||
<svg class="icon"><use href="#icon-pipeline"></use></svg><span>Sollicitaties<small>persoonlijke pipeline</small></span>
|
|
||||||
</a>
|
|
||||||
<p class="nav-group-label">Beheer</p>
|
|
||||||
<a class="nav-link {% if request.resolver_match.namespace == 'profiles' %}active{% endif %}" href="{% url 'profiles:list' %}">
|
|
||||||
<svg class="icon"><use href="#icon-profile"></use></svg><span>Zoekprofiel<small>regels en voorkeuren</small></span>
|
|
||||||
</a>
|
|
||||||
<a class="nav-link {% if request.resolver_match.namespace == 'sources' %}active{% endif %}" href="{% url 'sources:list' %}">
|
|
||||||
<svg class="icon"><use href="#icon-sources"></use></svg><span>Bronnen<small>import en gezondheid</small></span>
|
|
||||||
</a>
|
|
||||||
<a class="nav-link {% if request.resolver_match.namespace == 'dashboard' and request.resolver_match.url_name == 'system' %}active{% endif %}" href="{% url 'dashboard:system' %}">
|
|
||||||
<svg class="icon"><use href="#icon-system"></use></svg><span>Systeem<small>readiness en beheer</small></span>
|
|
||||||
</a>
|
|
||||||
</nav>
|
|
||||||
<div class="sidebar-user">
|
|
||||||
<span class="avatar" aria-hidden="true">{{ user.username|slice:":1"|upper }}</span>
|
|
||||||
<span><strong>{{ user.get_full_name|default:user.username }}</strong><small>Lokale gebruiker</small></span>
|
|
||||||
<span class="status-dot" title="Aangemeld"></span>
|
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</header>
|
||||||
<button class="nav-scrim" type="button" data-nav-scrim aria-label="Menu sluiten" tabindex="-1"></button>
|
|
||||||
<div class="workspace">
|
<main id="main" class="cockpit-main" tabindex="-1">
|
||||||
<header class="utilitybar">
|
{% if messages %}<div class="toast-stack" aria-live="polite" aria-atomic="true">{% for message in messages %}<div class="toast {{ message.tags }}"><svg class="icon" aria-hidden="true"><use href="{% if message.tags == 'error' %}#icon-alert{% else %}#icon-check{% endif %}"></use></svg><span>{{ message }}</span></div>{% endfor %}</div>{% endif %}
|
||||||
<div class="utilitybar-start">
|
|
||||||
<button class="icon-button mobile-only" type="button" data-nav-toggle aria-label="Menu openen" aria-expanded="false">
|
|
||||||
<svg class="icon"><use href="#icon-menu"></use></svg>
|
|
||||||
</button>
|
|
||||||
<form class="global-search" method="get" action="{% url 'jobs:list' %}" role="search">
|
|
||||||
<label class="sr-only" for="global-search">Zoek door vacatures</label>
|
|
||||||
<svg class="icon" aria-hidden="true"><use href="#icon-search"></use></svg>
|
|
||||||
<input id="global-search" type="search" name="q" value="{% if request.resolver_match.namespace == 'jobs' and request.resolver_match.url_name == 'list' %}{{ request.GET.q }}{% endif %}" placeholder="Zoek door vacatures...">
|
|
||||||
</form>
|
|
||||||
<div class="utility-title utility-radar"><span class="signal-dot" aria-hidden="true"></span><span>Radar actief</span></div>
|
|
||||||
</div>
|
|
||||||
<div class="utility-actions">
|
|
||||||
<button class="icon-button" type="button" data-theme-toggle aria-label="Thema wisselen" title="Thema wisselen">
|
|
||||||
<svg class="icon theme-icon-dark"><use href="#icon-sun"></use></svg>
|
|
||||||
<svg class="icon theme-icon-light"><use href="#icon-moon"></use></svg>
|
|
||||||
</button>
|
|
||||||
{% if navigation_profile %}<a class="button button-primary utility-cta" href="{% url 'profiles:edit' navigation_profile.pk %}"><svg class="icon"><use href="#icon-profile"></use></svg><span class="desktop-label">Zoekprofiel aanpassen</span></a>{% endif %}
|
|
||||||
<form action="{% url 'logout' %}" method="post">{% csrf_token %}<button class="utility-link" type="submit" aria-label="Afmelden"><svg class="icon"><use href="#icon-logout"></use></svg><span class="desktop-label">Afmelden</span></button></form>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
<main id="main" class="main-content" tabindex="-1">
|
|
||||||
{% else %}
|
{% else %}
|
||||||
<main id="main" class="auth-main" tabindex="-1">
|
<main id="main" class="auth-shell" tabindex="-1">
|
||||||
|
{% if messages %}<div class="toast-stack" aria-live="polite">{% for message in messages %}<div class="toast {{ message.tags }}">{{ message }}</div>{% endfor %}</div>{% endif %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if messages %}
|
{% block content %}{% endblock %}
|
||||||
<div class="messages" aria-live="polite" aria-atomic="true">
|
</main>
|
||||||
{% for message in messages %}<div class="message {{ message.tags }}">{{ message }}</div>{% endfor %}
|
{% if user.is_authenticated %}
|
||||||
</div>
|
<nav class="mobile-tabbar" aria-label="Mobiele hoofdnavigatie">
|
||||||
{% endif %}
|
<a class="{% if request.resolver_match.namespace == 'dashboard' %}is-active{% endif %}" href="{% url 'dashboard:today' %}"><svg class="icon"><use href="#icon-dashboard"></use></svg><span>Overzicht</span></a>
|
||||||
{% block content %}{% endblock %}
|
<a class="{% if request.resolver_match.namespace == 'jobs' and request.resolver_match.url_name == 'list' %}is-active{% endif %}" href="{% url 'jobs:list' %}"><svg class="icon"><use href="#icon-radar"></use></svg><span>Matches</span></a>
|
||||||
</main>
|
<a class="{% if request.resolver_match.url_name == 'applications' %}is-active{% endif %}" href="{% url 'jobs:applications' %}"><svg class="icon"><use href="#icon-pipeline"></use></svg><span>Pipeline</span></a>
|
||||||
{% if user.is_authenticated %}</div></div>{% endif %}
|
<a class="{% if request.resolver_match.namespace == 'profiles' %}is-active{% endif %}" href="{% url 'profiles:list' %}"><svg class="icon"><use href="#icon-profile"></use></svg><span>Profiel</span></a>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% block extra_js %}{% endblock %}
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -12,6 +12,9 @@
|
|||||||
<symbol id="icon-moon" viewBox="0 0 24 24"><path d="M20 15.5A8.5 8.5 0 0 1 8.5 4 8.5 8.5 0 1 0 20 15.5Z"/></symbol>
|
<symbol id="icon-moon" viewBox="0 0 24 24"><path d="M20 15.5A8.5 8.5 0 0 1 8.5 4 8.5 8.5 0 1 0 20 15.5Z"/></symbol>
|
||||||
<symbol id="icon-logout" viewBox="0 0 24 24"><path d="M10 4H4v16h6M14 8l4 4-4 4M8 12h10"/></symbol>
|
<symbol id="icon-logout" viewBox="0 0 24 24"><path d="M10 4H4v16h6M14 8l4 4-4 4M8 12h10"/></symbol>
|
||||||
<symbol id="icon-arrow" viewBox="0 0 24 24"><path d="M5 12h14M14 7l5 5-5 5"/></symbol>
|
<symbol id="icon-arrow" viewBox="0 0 24 24"><path d="M5 12h14M14 7l5 5-5 5"/></symbol>
|
||||||
|
<symbol id="icon-eye" viewBox="0 0 24 24"><path d="M2 12s3.6-7 10-7 10 7 10 7-3.6 7-10 7-10-7-10-7Z"/><circle cx="12" cy="12" r="3"/></symbol>
|
||||||
|
<symbol id="icon-eye-off" viewBox="0 0 24 24"><path d="M3 3l18 18M10.6 10.6a3 3 0 0 0 4.2 4.2M9.9 5.2A9.5 9.5 0 0 1 12 5c6.4 0 10 7 10 7a17.3 17.3 0 0 1-3.3 4.1M6.1 6.6A17.6 17.6 0 0 0 2 12s3.6 7 10 7a9.6 9.6 0 0 0 3-.5"/></symbol>
|
||||||
|
<symbol id="icon-shield" viewBox="0 0 24 24"><path d="M12 3l7 3v5c0 4.5-3 8.3-7 10-4-1.7-7-5.5-7-10V6l7-3Z"/><path d="m9 12 2 2 4-4"/></symbol>
|
||||||
<symbol id="icon-briefcase" viewBox="0 0 24 24"><rect x="3" y="7" width="18" height="13" rx="2"/><path d="M9 7V4h6v3M3 12h18"/></symbol>
|
<symbol id="icon-briefcase" viewBox="0 0 24 24"><rect x="3" y="7" width="18" height="13" rx="2"/><path d="M9 7V4h6v3M3 12h18"/></symbol>
|
||||||
<symbol id="icon-location" viewBox="0 0 24 24"><path d="M20 10c0 5-8 12-8 12S4 15 4 10a8 8 0 1 1 16 0Z"/><circle cx="12" cy="10" r="2.5"/></symbol>
|
<symbol id="icon-location" viewBox="0 0 24 24"><path d="M20 10c0 5-8 12-8 12S4 15 4 10a8 8 0 1 1 16 0Z"/><circle cx="12" cy="10" r="2.5"/></symbol>
|
||||||
<symbol id="icon-clock" viewBox="0 0 24 24"><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/></symbol>
|
<symbol id="icon-clock" viewBox="0 0 24 24"><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/></symbol>
|
||||||
@@ -21,4 +24,12 @@
|
|||||||
<symbol id="icon-external" viewBox="0 0 24 24"><path d="M14 4h6v6M20 4l-9 9"/><path d="M18 13v7H4V6h7"/></symbol>
|
<symbol id="icon-external" viewBox="0 0 24 24"><path d="M14 4h6v6M20 4l-9 9"/><path d="M18 13v7H4V6h7"/></symbol>
|
||||||
<symbol id="icon-chevron" viewBox="0 0 24 24"><path d="m7 9 5 5 5-5"/></symbol>
|
<symbol id="icon-chevron" viewBox="0 0 24 24"><path d="m7 9 5 5 5-5"/></symbol>
|
||||||
<symbol id="icon-upload" viewBox="0 0 24 24"><path d="M12 16V4M7 9l5-5 5 5M4 20h16"/></symbol>
|
<symbol id="icon-upload" viewBox="0 0 24 24"><path d="M12 16V4M7 9l5-5 5 5M4 20h16"/></symbol>
|
||||||
|
<symbol id="icon-chart" viewBox="0 0 24 24"><path d="M4 19V9M10 19V4M16 19v-7M22 19V7"/></symbol>
|
||||||
|
<symbol id="icon-activity" viewBox="0 0 24 24"><path d="M3 12h4l2-6 4 12 2-6h6"/></symbol>
|
||||||
|
<symbol id="icon-building" viewBox="0 0 24 24"><path d="M4 21V5l8-3v19M12 8h8v13M7 7h2M7 11h2M7 15h2M15 11h2M15 15h2M2 21h20"/></symbol>
|
||||||
|
<symbol id="icon-filter" viewBox="0 0 24 24"><path d="M3 5h18M6 12h12M10 19h4"/></symbol>
|
||||||
|
<symbol id="icon-refresh" viewBox="0 0 24 24"><path d="M20 7v5h-5M4 17v-5h5"/><path d="M18.5 9A7 7 0 0 0 6 6.5L4 9M5.5 15A7 7 0 0 0 18 17.5l2-2.5"/></symbol>
|
||||||
|
<symbol id="icon-list" viewBox="0 0 24 24"><path d="M9 6h12M9 12h12M9 18h12M3 6h.01M3 12h.01M3 18h.01"/></symbol>
|
||||||
|
<symbol id="icon-calendar" viewBox="0 0 24 24"><rect x="3" y="5" width="18" height="16" rx="2"/><path d="M8 3v4M16 3v4M3 10h18"/></symbol>
|
||||||
|
<symbol id="icon-mail" viewBox="0 0 24 24"><rect x="3" y="5" width="18" height="14" rx="2"/><path d="m4 7 8 6 8-6"/></symbol>
|
||||||
</svg>
|
</svg>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 2.8 KiB After Width: | Height: | Size: 4.2 KiB |
@@ -1,64 +1,45 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
{% block title %}Vandaag · VacatureRadar{% endblock %}
|
{% block title %}Vandaag · VacatureRadar{% endblock %}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<header class="page-header dashboard-header">
|
<header class="cockpit-heading dashboard-heading">
|
||||||
<div>
|
<div><p class="eyebrow">Intelligence cockpit · vandaag</p><h1>Goedemorgen, {{ user.first_name|default:user.username }}.</h1><p>{{ day_conclusion }}</p></div>
|
||||||
<p class="eyebrow">Intelligence cockpit</p>
|
<div class="radar-status" aria-label="Radarstatus"><span class="status-beacon {% if active_profile %}is-live{% endif %}" aria-hidden="true"></span><span>{% if active_profile %}{{ active_profile.name }} actief{% else %}Geen actief profiel{% endif %}</span>{% if latest_scan %}<span aria-hidden="true">·</span><span>scan {{ latest_scan.started_at|timesince }} geleden</span>{% endif %}</div>
|
||||||
<h1>Goedemorgen, {{ user.first_name|default:user.username }}</h1>
|
|
||||||
<p>Er {% if new_today == 1 %}is <strong>{{ new_today }} nieuw signaal</strong>{% else %}zijn <strong>{{ new_today }} nieuwe signalen</strong>{% endif %} die je aandacht verdienen.</p>
|
|
||||||
</div>
|
|
||||||
<div class="radar-status" aria-label="Radarstatus">
|
|
||||||
<span class="signal-dot" aria-hidden="true"></span>
|
|
||||||
<span>Persoonlijke radar actief</span>
|
|
||||||
<span aria-hidden="true">·</span>
|
|
||||||
<a href="{% url 'dashboard:system' %}">Systeem bekijken</a>
|
|
||||||
</div>
|
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<section class="stats-grid" aria-label="Kerncijfers">
|
<section class="stats-grid" aria-label="Radar kerncijfers">
|
||||||
<article class="stat-card signal"><span class="kpi-label">Nieuwe signalen</span><strong>{{ new_today }}</strong><small>vandaag gevonden</small></article>
|
<article class="stat-card signal"><span class="kpi-label">Nieuwe IT-signalen</span><strong>{{ new_today }}</strong><small>vandaag ontdekt</small></article>
|
||||||
<article class="stat-card"><span class="kpi-label">Actieve vacatures</span><strong>{{ active_jobs }}</strong><small>actueel in de radar</small></article>
|
<article class="stat-card"><span class="kpi-label">Sterke matches</span><strong>{{ strong_today }}</strong><small>vandaag · laatste profielscore</small></article>
|
||||||
<article class="stat-card"><span class="kpi-label">Open dossiers</span><strong>{{ applications_open }}</strong><small>sollicitaties in opvolging</small></article>
|
<article class="stat-card"><span class="kpi-label">Open trajecten</span><strong>{{ applications_open }}</strong><small>persoonlijke sollicitatiedossiers</small></article>
|
||||||
<article class="stat-card"><span class="kpi-label">Bronstatus</span><strong>{{ source_counts.total|default:0 }}</strong><small>{{ source_counts.unhealthy|default:0 }} vragen aandacht</small></article>
|
<article class="stat-card"><span class="kpi-label">Radardekking</span><strong>{{ source_counts.active|default:0 }}/{{ source_counts.total|default:0 }}</strong><small>bronnen actief · {{ source_counts.attention|default:0 }} aandacht</small></article>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<div class="dashboard-grid">
|
<div class="dashboard-grid">
|
||||||
<section class="dashboard-matches" aria-labelledby="best-matches-heading">
|
<section class="dashboard-matches" aria-labelledby="best-matches-heading">
|
||||||
<div class="section-heading">
|
<div class="section-heading"><div><span class="section-kicker">Persoonlijk gerangschikt</span><h2 id="best-matches-heading">Beste nieuwe matches</h2><p>De nieuwste score per vacature; datakwaliteit staat los van de inhoudelijke match.</p></div><a class="section-link" href="{% url 'jobs:list' %}">Open verkenner <svg class="icon"><use href="#icon-arrow"></use></svg></a></div>
|
||||||
<div><span class="section-kicker">Gerangschikt voor jou</span><h2 id="best-matches-heading">Beste nieuwe matches</h2><p>Matchscore en datakwaliteit blijven afzonderlijk zichtbaar.</p></div>
|
|
||||||
<a class="section-link" href="{% url 'jobs:list' %}">Alles bekijken <svg class="icon"><use href="#icon-arrow"></use></svg></a>
|
|
||||||
</div>
|
|
||||||
<div class="job-feed">
|
<div class="job-feed">
|
||||||
{% for score in score_cards %}
|
{% for score in score_cards %}
|
||||||
|
{% widthratio score.confidence 1 100 as confidence_percent %}
|
||||||
<article class="job-card feed-card {% if score.recommendation == 'strong' %}strong-match{% endif %}">
|
<article class="job-card feed-card {% if score.recommendation == 'strong' %}strong-match{% endif %}">
|
||||||
<div class="job-card-main">
|
<div class="job-card-main">
|
||||||
<div class="job-card-head">
|
<div class="job-card-head"><div><div class="title-row"><span class="pill pill-{{ score.recommendation }}">{{ score.get_recommendation_display }}</span>{% if score.job.direct_employer %}<span class="pill">Directe werkgever</span>{% elif score.job.recruiter %}<span class="pill">Recruiter</span>{% endif %}</div><h3><a href="{% url 'jobs:detail' score.job.pk %}">{{ score.job.original_title }}</a></h3><p>{{ score.job.employer_name }}</p></div><div class="score-block"><strong>{{ score.score|floatformat:0 }}%</strong><small>match · data {{ confidence_percent }}%</small></div></div>
|
||||||
<div>
|
<div class="job-meta"><span><svg class="icon"><use href="#icon-location"></use></svg>{{ score.job.raw_location|default:"Locatie onbekend" }}</span><span><svg class="icon"><use href="#icon-briefcase"></use></svg>{{ score.job.get_workplace_type_display }}</span>{% if score.job.valid_through %}<span><svg class="icon"><use href="#icon-calendar"></use></svg>sluit {{ score.job.valid_through|date:"d/m" }}</span>{% endif %}</div>
|
||||||
<div class="title-row"><span class="pill pill-{{ score.recommendation }}">{{ score.get_recommendation_display }}</span><span class="confidence-label">Datakwaliteit {{ score.confidence|floatformat:2 }}</span></div>
|
{% if score.positives %}<p class="evidence-line positive-evidence"><svg class="icon"><use href="#icon-check"></use></svg>{{ score.positives.0 }}</p>{% endif %}
|
||||||
<h3><a href="{% url 'jobs:detail' score.job.pk %}">{{ score.job.original_title }}</a></h3>
|
{% if score.concerns %}<p class="evidence-line concern-evidence"><svg class="icon"><use href="#icon-alert"></use></svg>{{ score.concerns.0 }}</p>{% endif %}
|
||||||
<p>{{ score.job.employer_name }}{% if score.job.raw_location %} · {{ score.job.raw_location }}{% endif %}</p>
|
|
||||||
</div>
|
|
||||||
<div class="score-block"><strong>{{ score.score|floatformat:0 }}%</strong><small>match</small></div>
|
|
||||||
</div>
|
|
||||||
<div class="job-meta"><span><svg class="icon"><use href="#icon-location"></use></svg>{{ score.job.raw_location|default:"Locatie onbekend" }}</span><span><svg class="icon"><use href="#icon-briefcase"></use></svg>{{ score.job.get_workplace_type_display }}</span></div>
|
|
||||||
{% if score.positives %}<ul class="signal-list">{% for item in score.positives|slice:":2" %}<li>{{ item }}</li>{% endfor %}</ul>{% endif %}
|
|
||||||
{% if score.concerns %}<p class="attention"><strong>Aandachtspunt:</strong> {{ score.concerns.0 }}</p>{% endif %}
|
|
||||||
</div>
|
|
||||||
<div class="feed-card-actions">
|
|
||||||
<a class="button button-primary" href="{% url 'jobs:detail' score.job.pk %}">Bekijk analyse <svg class="icon"><use href="#icon-arrow"></use></svg></a>
|
|
||||||
<form method="post" action="{% url 'jobs:feedback' score.job.pk %}">{% csrf_token %}<input type="hidden" name="action" value="save"><input type="hidden" name="next" value="{{ request.path }}"><button class="icon-button save-button" type="submit" aria-label="{{ score.job.original_title }} bewaren"><svg class="icon"><use href="#icon-bookmark"></use></svg></button></form>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div class="feed-card-actions"><a class="button button-primary" href="{% url 'jobs:detail' score.job.pk %}">Analyse <svg class="icon"><use href="#icon-arrow"></use></svg></a><form method="post" action="{% url 'jobs:feedback' score.job.pk %}">{% csrf_token %}<input type="hidden" name="action" value="save"><input type="hidden" name="next" value="{{ request.path }}"><button class="button button-ghost" type="submit"><svg class="icon"><use href="#icon-bookmark"></use></svg>Bewaar</button></form></div>
|
||||||
</article>
|
</article>
|
||||||
{% empty %}
|
{% empty %}
|
||||||
<div class="empty-state compact-empty"><div class="empty-icon"><svg class="icon"><use href="#icon-radar"></use></svg></div><h2>Nog geen beoordeelde vacatures</h2><p>Importeer vacaturedata of activeer een toegestane bron. Betrouwbare resultaten verschijnen hier automatisch.</p><a class="button button-secondary" href="{% url 'sources:list' %}">Bronnen beheren</a></div>
|
<div class="empty-state compact-empty"><div class="empty-icon"><svg class="icon"><use href="#icon-radar"></use></svg></div><h2>Nog geen persoonlijke matches</h2><p>Na een geldige bronrun en profielscore verschijnen relevante IT-vacatures hier automatisch.</p><div class="page-actions"><a class="button button-primary" href="{% url 'sources:list' %}">Bronnen controleren</a><a class="button button-ghost" href="{% url 'profiles:list' %}">Profiel openen</a></div></div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<aside class="dashboard-rail" aria-label="Acties en radarstatus">
|
<aside class="dashboard-rail" aria-label="Radarintelligence">
|
||||||
<section class="panel rail-panel radar-summary"><span class="section-kicker">Vandaag</span><h2>Radarstatus</h2><p class="muted">Alle cijfers komen rechtstreeks uit de huidige database.</p><div class="rail-list"><div class="rail-item"><span>Bronnen geregistreerd</span><strong>{{ source_counts.total|default:0 }}</strong></div><div class="rail-item"><span>Aandacht nodig</span><strong>{{ source_counts.unhealthy|default:0 }}</strong></div><div class="rail-item"><span>Actieve vacatures</span><strong>{{ active_jobs }}</strong></div></div></section>
|
<section class="panel rail-panel radar-summary"><span class="section-kicker">Dagconclusie</span><h2>{{ day_conclusion }}</h2><div class="rail-list"><div class="rail-item"><span>Actieve IT-vacatures</span><strong>{{ active_jobs }}</strong></div><div class="rail-item"><span>Niet-IT gefilterd</span><strong>{{ filtered_non_it_jobs }}</strong></div><div class="rail-item"><span>Duplicaten herkend</span><strong>{{ duplicate_jobs }}</strong></div></div><a class="button button-secondary button-block" href="{% url 'jobs:list' %}">Bekijk alle matches</a></section>
|
||||||
<section class="panel rail-panel"><span class="section-kicker">Actie vereist</span><h2>Open opvolging</h2><div class="rail-application-list">{% for application in priority_applications %}<a class="rail-application" href="{% url 'jobs:application-edit' application.pk %}"><span><strong>{{ application.job.original_title }}</strong><small>{{ application.job.employer_name }}</small></span>{% if application.follow_up_date %}<time datetime="{{ application.follow_up_date|date:'Y-m-d' }}">{{ application.follow_up_date|date:"d/m" }}</time>{% else %}<svg class="icon"><use href="#icon-arrow"></use></svg>{% endif %}</a>{% empty %}<p class="muted small">Geen open dossiers die opvolging vragen.</p>{% endfor %}</div><a class="button button-ghost button-block" href="{% url 'jobs:applications' %}">Pipeline openen</a></section>
|
|
||||||
<section class="panel rail-panel"><span class="section-kicker">Volgende stap</span><h2>Snelle acties</h2><div class="stack-actions"><a class="button button-secondary button-block" href="{% url 'sources:list' %}"><svg class="icon"><use href="#icon-upload"></use></svg>Handmatig importeren</a><a class="button button-ghost button-block" href="{% url 'profiles:list' %}"><svg class="icon"><use href="#icon-profile"></use></svg>Zoekprofiel controleren</a></div></section>
|
<section class="panel rail-panel"><span class="section-kicker">Opvolging</span><h2>Actie vereist</h2><div class="rail-application-list">{% for application in priority_applications %}<a class="rail-application" href="{% url 'jobs:application-edit' application.pk %}"><span><strong>{{ application.job.original_title }}</strong><small>{{ application.job.employer_name }} · {{ application.get_status_display }}</small></span>{% if application.follow_up_date %}<time datetime="{{ application.follow_up_date|date:'Y-m-d' }}">{{ application.follow_up_date|date:"d/m" }}</time>{% else %}<svg class="icon"><use href="#icon-arrow"></use></svg>{% endif %}</a>{% empty %}<p class="muted small">Geen open dossier vraagt nu opvolging.</p>{% endfor %}</div><a class="button button-ghost button-block" href="{% url 'jobs:applications' %}">Open pipeline</a></section>
|
||||||
|
|
||||||
|
<section class="panel rail-panel"><span class="section-kicker">Recente activiteit</span><h2>Radarlog</h2><div class="mini-timeline">{% for event in recent_activity %}<div class="mini-event"><span class="status-beacon {% if event.tone == 'positive' %}is-live{% endif %}" aria-hidden="true"></span><span><strong>{{ event.title }}</strong><small>{{ event.detail }} · {{ event.occurred_at|timesince }} geleden</small></span></div>{% empty %}<p class="muted small">Nog geen betrouwbare activiteit geregistreerd.</p>{% endfor %}</div><a class="button button-ghost button-block" href="{% url 'dashboard:system' %}">Automation bekijken</a></section>
|
||||||
</aside>
|
</aside>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}{{ employer.name }} intelligence · VacatureRadar{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<a class="back-link" href="{% url 'jobs:employers' %}">← Terug naar werkgeversradar</a>
|
||||||
|
<header class="detail-hero employer-detail-hero"><div><p class="eyebrow">Employer intelligence #{{ employer.pk }}</p><div class="title-row">{% if employer.is_recruiter %}<span class="pill">Recruiter</span>{% elif employer.is_direct_employer %}<span class="pill pill-success">Directe werkgever</span>{% endif %}{% if employer.employer_type %}<span class="pill">{{ employer.employer_type }}</span>{% endif %}</div><h1>{{ employer.name }}</h1><p class="detail-employer">{{ employer.domain|default:"Domein onbekend" }}</p></div><span class="employer-mark hero-mark">{{ employer.name|slice:":2"|upper }}</span></header>
|
||||||
|
<section class="intelligence-strip"><div><span class="field-label">Actieve vacatures</span><strong>{{ employer.active_vacancies }}</strong><small>alle functies</small></div><div><span class="field-label">Relevante IT</span><strong>{{ employer.relevant_vacancies }}</strong><small>deterministisch gefilterd</small></div><div><span class="field-label">Gemiddelde match</span><strong>{% if employer.average_match is not None %}{{ employer.average_match|floatformat:0 }}%{% else %}—{% endif %}</strong><small>{% if active_profile %}{{ active_profile.name }}{% else %}geen actief profiel{% endif %}</small></div><div><span class="field-label">Brondekking</span><strong>{{ employer.source_count }}</strong><small>unieke bronnen</small></div></section>
|
||||||
|
<div class="employer-detail-grid"><main><section class="detail-section"><div class="section-heading"><div><span class="section-kicker">Vacaturehistorie</span><h2>Recente vacatures</h2><p>Actief en historisch, uitsluitend uit opgeslagen bronrecords.</p></div></div><div class="employer-job-grid">{% for job in recent_jobs %}<article><div class="title-row"><span class="pill pill-{{ job.status }}">{{ job.get_status_display }}</span>{% if job.match_recommendation %}<span class="pill pill-{{ job.match_recommendation }}">{{ job.match_recommendation }}</span>{% endif %}</div><h3><a href="{% url 'jobs:detail' job.pk %}">{{ job.original_title }}</a></h3><p>{{ job.raw_location|default:"Locatie onbekend" }} · {{ job.get_workplace_type_display }}</p><div class="job-meta"><span>{{ job.first_seen|date:"d/m/Y" }}</span>{% if job.match_score is not None %}<strong>{{ job.match_score|floatformat:0 }}% match</strong>{% endif %}</div></article>{% empty %}<div class="empty-state compact-empty"><h2>Geen vacaturehistorie</h2></div>{% endfor %}</div></section>{% if match_history %}<section class="detail-section"><span class="section-kicker">Matchpatroon</span><h2>Recente profielbeoordelingen</h2><div class="match-history">{% for score in match_history %}<div><span>{{ score.created_at|date:"d/m" }}</span><i style="--match: {{ score.score }}%"></i><strong>{{ score.score|floatformat:0 }}%</strong><a href="{% url 'jobs:detail' score.job.pk %}">{{ score.job.original_title }}</a></div>{% endfor %}</div></section>{% endif %}</main><aside class="detail-sidebar"><section class="panel"><span class="section-kicker">Waargenomen context</span><h2>Werkgeversprofiel</h2><dl class="intel-facts"><div><dt>Locaties</dt><dd>{% if locations %}{{ locations|join:", " }}{% else %}Onbekend{% endif %}</dd></div><div><dt>Type</dt><dd>{{ employer.employer_type|default:"Onbekend" }}</dd></div><div><dt>Laatste signaal</dt><dd>{{ employer.latest_job_at|date:"d/m/Y"|default:"—" }}</dd></div><div><dt>Confidence</dt><dd>{% widthratio employer.confidence 1 100 %}%</dd></div></dl></section><section class="panel"><span class="section-kicker">Brongezondheid</span><h2>{{ sources|length }} gekoppelde bronnen</h2>{% for source in sources %}<a class="employer-source" href="{% url 'sources:list' %}"><span>{{ source.name }}</span><span class="pill {% if source.status == 'active' %}pill-success{% else %}pill-review{% endif %}">{{ source.get_status_display }}</span></a>{% empty %}<p class="muted">Geen gekoppelde bron opgeslagen.</p>{% endfor %}</section><section class="panel"><span class="section-kicker">Eerdere sollicitaties</span><h2>{{ applications|length }} dossier{% if applications|length != 1 %}s{% endif %}</h2>{% for application in applications %}<a class="employer-source" href="{% url 'jobs:application-edit' application.pk %}"><span>{{ application.job.original_title }}</span><span>{{ application.get_status_display }}</span></a>{% empty %}<p class="muted">Nog geen dossier bij deze werkgever.</p>{% endfor %}</section></aside></div>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Werkgevers intelligence · VacatureRadar{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<header class="cockpit-heading"><div><p class="eyebrow">Intelligent tracking</p><h1>Werkgeversradar</h1><p>Genormaliseerde werkgeversdata uit echte vacatures, bronnen, profielscores en sollicitatiedossiers.</p></div><div class="employer-kpis"><div><span>Gemonitord</span><strong>{{ employer_total }}</strong></div><div><span>Met actieve vacatures</span><strong>{{ employer_active_total }}</strong></div></div></header>
|
||||||
|
<form class="activity-filterbar" method="get"><label class="grow"><span class="field-label">Zoek werkgevers</span><span class="input-with-icon"><svg class="icon"><use href="#icon-search"></use></svg><input type="search" name="q" value="{{ request.GET.q }}" placeholder="Naam, domein of locatie"></span></label><label><span class="field-label">Kanaal</span><select name="channel"><option value="">Alle kanalen</option><option value="direct" {% if request.GET.channel == 'direct' %}selected{% endif %}>Directe werkgever</option><option value="recruiter" {% if request.GET.channel == 'recruiter' %}selected{% endif %}>Recruiter</option></select></label><label class="toggle-field"><input type="checkbox" name="active" value="1" {% if request.GET.active == '1' %}checked{% endif %}><span>Alleen actief</span></label><button class="button button-secondary" type="submit">Filter</button></form>
|
||||||
|
<div class="employer-radar-layout">
|
||||||
|
<section class="employer-table-panel" aria-labelledby="employer-list-heading"><div class="section-heading"><div><span class="section-kicker">Gevolgde werkgevers</span><h2 id="employer-list-heading">{{ page_obj.paginator.count }} werkgevers</h2></div></div><div class="data-table-wrap" tabindex="0" aria-label="Werkgevers, horizontaal scrolbaar"><table class="data-table employer-table"><thead><tr><th>Werkgever</th><th>Type</th><th>Actief</th><th>Relevant IT</th><th>Bronnen</th><th>Gem. match</th><th>Laatste signaal</th></tr></thead><tbody>{% for item in employers %}<tr class="{% if selected_employer.pk == item.pk %}is-selected{% endif %}"><td><a href="?selected={{ item.pk }}&q={{ request.GET.q|urlencode }}&channel={{ request.GET.channel }}&active={{ request.GET.active }}"><span class="employer-mark">{{ item.name|slice:":2"|upper }}</span><span><strong>{{ item.name }}</strong><small>{{ item.domain|default:"Geen domein" }}</small></span></a></td><td>{% if item.is_recruiter %}<span class="pill">Recruiter</span>{% elif item.is_direct_employer %}<span class="pill pill-success">Direct</span>{% else %}<span class="pill">Onbekend</span>{% endif %}</td><td>{{ item.active_vacancies }}</td><td><strong class="signal-number">{{ item.relevant_vacancies }}</strong></td><td>{{ item.source_count }}</td><td>{% if item.average_match is not None %}{{ item.average_match|floatformat:0 }}%{% else %}—{% endif %}</td><td>{{ item.latest_job_at|date:"d/m/Y"|default:"—" }}</td></tr>{% empty %}<tr><td colspan="7">Geen werkgevers gevonden.</td></tr>{% endfor %}</tbody></table></div>{% if is_paginated %}<nav class="pagination" aria-label="Werkgeverspaginering">{% if page_obj.has_previous %}<a class="button button-ghost" href="?page={{ page_obj.previous_page_number }}&q={{ request.GET.q|urlencode }}&channel={{ request.GET.channel }}&active={{ request.GET.active }}">Vorige</a>{% endif %}<span>{{ page_obj.number }}/{{ page_obj.paginator.num_pages }}</span>{% if page_obj.has_next %}<a class="button button-ghost" href="?page={{ page_obj.next_page_number }}&q={{ request.GET.q|urlencode }}&channel={{ request.GET.channel }}&active={{ request.GET.active }}">Volgende</a>{% endif %}</nav>{% endif %}</section>
|
||||||
|
|
||||||
|
<aside class="employer-intelligence-rail" aria-label="Geselecteerde werkgever">{% if selected_employer %}<div class="employer-intel-header"><span class="section-kicker">Employer report #{{ selected_employer.pk }}</span><span class="employer-mark large">{{ selected_employer.name|slice:":2"|upper }}</span><h2>{{ selected_employer.name }}</h2><p>{{ selected_employer.domain|default:"Domein onbekend" }}</p></div><dl class="intel-facts"><div><dt>Actieve vacatures</dt><dd>{{ selected_employer.active_vacancies }}</dd></div><div><dt>Relevante IT</dt><dd>{{ selected_employer.relevant_vacancies }}</dd></div><div><dt>Eerdere dossiers</dt><dd>{{ selected_employer.previous_applications }}</dd></div><div><dt>Bronnen</dt><dd>{{ selected_employer.source_count }}</dd></div><div><dt>Gem. match</dt><dd>{% if selected_employer.average_match is not None %}{{ selected_employer.average_match|floatformat:0 }}%{% else %}Nog niet gemeten{% endif %}</dd></div><div><dt>Kanaal</dt><dd>{% if selected_employer.is_recruiter %}Recruiter{% elif selected_employer.is_direct_employer %}Direct{% else %}Onbekend{% endif %}</dd></div></dl><section class="employer-intel-section"><span class="field-label">Recente vacatures</span>{% for job in selected_detail.recent_jobs|slice:":4" %}<a class="employer-job-link" href="{% url 'jobs:detail' job.pk %}"><span><strong>{{ job.original_title }}</strong><small>{{ job.first_seen|date:"d/m/Y" }}</small></span>{% if job.match_score is not None %}<b>{{ job.match_score|floatformat:0 }}%</b>{% endif %}</a>{% empty %}<p class="muted">Nog geen vacaturehistorie.</p>{% endfor %}</section><section class="employer-intel-section"><span class="field-label">Waargenomen locaties</span><p>{% if selected_detail.locations %}{{ selected_detail.locations|join:", " }}{% else %}Onbekend{% endif %}</p></section><a class="button button-primary button-block" href="{% url 'jobs:employer-detail' selected_employer.pk %}">Volledig werkgeversrapport</a>{% else %}<div class="empty-state"><h2>Kies een werkgever</h2></div>{% endif %}</aside>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -1,24 +1,38 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
{% block title %}{{ job.original_title }} · VacatureRadar{% endblock %}
|
{% block title %}{{ job.original_title }} · VacatureRadar{% endblock %}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<a class="back-link" href="{% url 'jobs:list' %}">← Terug naar vacatures</a>
|
<a class="back-link" href="{% url 'jobs:list' %}">← Terug naar de vacatureverkenner</a>
|
||||||
<div class="detail-layout">
|
<div class="job-detail-grid">
|
||||||
<div class="detail-main">
|
<div class="detail-main">
|
||||||
<header class="job-hero">
|
<header class="detail-hero">
|
||||||
<div><p class="eyebrow">{{ job.get_status_display }}{% if job.date_posted %} · gepubliceerd {{ job.date_posted|date:"d/m/Y" }}{% endif %}</p><h1>{{ job.original_title }}</h1><p class="hero-subtitle">{{ job.employer_name }}{% if job.raw_location %} · {{ job.raw_location }}{% endif %}</p><div class="job-meta"><span><svg class="icon"><use href="#icon-briefcase"></use></svg>{{ job.get_workplace_type_display }}</span>{% for type in job.employment_types %}<span>{{ type }}</span>{% endfor %}{% if job.hours_text %}<span>{{ job.hours_text }}</span>{% endif %}{% if job.valid_through %}<span><svg class="icon"><use href="#icon-clock"></use></svg>sluit {{ job.valid_through|date:"d/m/Y" }}</span>{% endif %}</div></div>
|
<div class="detail-hero-copy"><p class="eyebrow">Vacature intelligence · {{ job.get_status_display }}</p><div class="title-row">{% if score.evidence.it_relevance.relevant %}<span class="pill pill-it">IT-focus bevestigd</span>{% endif %}{% if job.direct_employer %}<span class="pill pill-success">Directe werkgever</span>{% elif job.recruiter %}<span class="pill">Recruiter</span>{% endif %}{% if duplicate_count %}<span class="pill">{{ duplicate_count }} duplicaat{% if duplicate_count != 1 %}signalen{% endif %}</span>{% endif %}</div><h1>{{ job.original_title }}</h1><p class="detail-employer">{{ job.employer_name }}</p><div class="job-meta"><span><svg class="icon"><use href="#icon-location"></use></svg>{{ job.raw_location|default:"Locatie onbekend" }}</span><span><svg class="icon"><use href="#icon-briefcase"></use></svg>{{ job.get_workplace_type_display }}</span>{% for type in job.employment_types %}<span>{{ type }}</span>{% endfor %}{% if job.hours_text %}<span>{{ job.hours_text }}</span>{% endif %}</div></div>
|
||||||
{% if score %}<div class="hero-score"><strong>{{ score.score|floatformat:0 }}%</strong><span>{{ score.get_recommendation_display }}</span><small>confidence {{ score.confidence|floatformat:2 }}</small></div>{% endif %}
|
{% if score %}{% widthratio score.confidence 1 100 as score_confidence_percent %}<div class="hero-score"><strong>{{ score.score|floatformat:0 }}%</strong><span>{{ score.get_recommendation_display }}</span><small>Datakwaliteit {{ score_confidence_percent }}%</small></div>{% else %}<div class="hero-score is-pending"><strong>—</strong><span>Nog niet gescoord</span><small>Brondata blijft inspecteerbaar</small></div>{% endif %}
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<section class="intelligence-strip" aria-label="Vacaturekerngegevens">
|
<section class="intelligence-strip" aria-label="Vacaturekerngegevens">
|
||||||
<div><span class="field-label">Matchkwaliteit</span>{% if score %}<strong>{{ score.score|floatformat:0 }}%</strong><small>{{ score.get_recommendation_display }}</small>{% else %}<strong>—</strong><small>Nog niet beoordeeld</small>{% endif %}</div>
|
<div><span class="field-label">Publicatie</span><strong>{{ job.date_posted|date:"d/m/Y"|default:"Onbekend" }}</strong><small>Eerst gezien {{ job.first_seen|date:"d/m/Y" }}</small></div>
|
||||||
<div><span class="field-label">Datakwaliteit</span><strong>{{ job.extraction_confidence|floatformat:2 }}</strong><small>Extractieconfidence</small></div>
|
<div><span class="field-label">Deadline</span><strong>{{ job.valid_through|date:"d/m/Y"|default:"Niet vermeld" }}</strong><small>{% if job.valid_through %}Controleer altijd de bron{% else %}Geen einddatum in bron{% endif %}</small></div>
|
||||||
<div><span class="field-label">Werkmodel</span><strong>{{ job.get_workplace_type_display }}</strong><small>{{ job.hours_text|default:"Uren onbekend" }}</small></div>
|
<div><span class="field-label">Brondekking</span><strong>{{ source_count }}</strong><small>{{ version_count }} inhoudsversie{% if version_count != 1 %}s{% endif %}</small></div>
|
||||||
|
{% widthratio job.extraction_confidence 1 100 as extraction_confidence_percent %}<div><span class="field-label">Extractie</span><strong>{{ extraction_confidence_percent }}%</strong><small>Datakwaliteit bronvelden</small></div>
|
||||||
</section>
|
</section>
|
||||||
{% if score %}
|
|
||||||
<section class="panel"><div class="section-heading"><div><span class="section-kicker">Deterministische analyse</span><h2>Waarom deze score?</h2><p>De inhoudelijke match en betrouwbaarheid worden afzonderlijk beoordeeld.</p></div><span class="pill pill-{{ score.recommendation }}">{{ score.get_recommendation_display }}</span></div><div class="score-components">{% for name,value in score.components.items %}<div><span>{{ name|capfirst }}</span><div class="progress" aria-label="{{ name }}: {{ value|floatformat:1 }} procent"><i style="width: {{ value }}%"></i></div><strong>{{ value|floatformat:1 }}</strong></div>{% endfor %}</div><div class="two-columns"><div><h3>Waarom passend</h3><ul class="compact-list positive">{% for item in score.positives %}<li>{{ item }}</li>{% empty %}<li>Nog onvoldoende positieve signalen.</li>{% endfor %}</ul></div><div><h3>Aandachtspunten</h3><ul class="compact-list caution">{% for item in score.concerns %}<li>{{ item }}</li>{% empty %}<li>Geen specifieke aandachtspunten gedetecteerd.</li>{% endfor %}</ul></div></div>{% if score.hard_exclusions %}<div class="message warning"><strong>Harde uitsluitingen:</strong> {{ score.hard_exclusions|join:", " }}</div>{% endif %}</section>
|
{% if score %}<section class="detail-section score-analysis"><div class="section-heading"><div><span class="section-kicker">Beslisuitleg</span><h2>Waarom deze match?</h2><p>Alle componenten zijn deterministisch. Ervaring en senioriteitslabels tellen niet mee.</p></div><span class="pill pill-{{ score.recommendation }}">{{ score.get_recommendation_display }}</span></div><div class="score-components">{% for name,value in score.components.items %}<div><span>{{ name|capfirst }}</span><div class="progress" role="img" aria-label="{{ name }}: {{ value|floatformat:1 }} procent"><i style="width: {{ value }}%"></i></div><strong>{{ value|floatformat:0 }}</strong></div>{% endfor %}</div><div class="signal-columns"><div><h3>Positieve signalen</h3><ul class="compact-list positive">{% for item in score.positives %}<li>{{ item }}</li>{% empty %}<li>Nog onvoldoende positieve signalen.</li>{% endfor %}</ul></div><div><h3>Aandachtspunten</h3><ul class="compact-list caution">{% for item in score.concerns %}<li>{{ item }}</li>{% empty %}<li>Geen specifiek aandachtspunt gevonden.</li>{% endfor %}</ul></div></div>{% if score.hard_exclusions %}<div class="message error"><strong>Harde uitsluiting:</strong> {{ score.hard_exclusions|join:", " }}</div>{% endif %}</section>{% endif %}
|
||||||
{% else %}<section class="panel panel-subtle"><h2>Nog geen persoonlijke score</h2><p class="muted">Deze vacature heeft nog geen score voor je actieve zoekprofiel. De broninformatie en vacaturetekst blijven wel beschikbaar.</p></section>{% endif %}
|
|
||||||
<section class="panel"><span class="section-kicker">Vacaturetekst</span><h2>Over de functie</h2>{% if job.description_html_sanitized %}<div class="prose">{{ job.description_html_sanitized|safe }}</div>{% elif job.description_text %}<div class="prose"><p>{{ job.description_text|linebreaksbr }}</p></div>{% else %}<p class="muted">Geen vacatureomschrijving beschikbaar.</p>{% endif %}</section>
|
{% if job.skills_required or job.skills_preferred %}<section class="detail-section"><span class="section-kicker">Technologie & capability</span><h2>Gevraagde skills</h2><div class="skill-bands">{% if job.skills_required %}<div><span class="field-label">Expliciet vereist</span><div class="tag-cloud">{% for skill in job.skills_required %}<span class="pill pill-it">{{ skill }}</span>{% endfor %}</div></div>{% endif %}{% if job.skills_preferred %}<div><span class="field-label">Voorkeur in bron</span><div class="tag-cloud">{% for skill in job.skills_preferred %}<span class="pill">{{ skill }}</span>{% endfor %}</div></div>{% endif %}</div><a class="section-link" href="{% url 'jobs:skill-insights' %}">Vergelijk met de actuele skillsvraag <svg class="icon"><use href="#icon-arrow"></use></svg></a></section>{% endif %}
|
||||||
<section class="panel"><span class="section-kicker">Transparantie</span><h2>Bron en historie</h2><dl class="definition-grid"><div><dt>Eerst gezien</dt><dd>{{ job.first_seen|date:"d/m/Y H:i" }}</dd></div><div><dt>Laatst gezien</dt><dd>{{ job.last_seen|date:"d/m/Y H:i" }}</dd></div><div><dt>Laatst gewijzigd</dt><dd>{{ job.last_changed|date:"d/m/Y H:i" }}</dd></div><div><dt>Extractieconfidence</dt><dd>{{ job.extraction_confidence|floatformat:2 }}</dd></div></dl><h3>Ook gevonden via</h3><ul class="source-list">{% for alias in job.source_aliases.all %}<li><a href="{{ alias.url }}" target="_blank" rel="noopener noreferrer nofollow">{{ alias.source.name|default:"Onbekende bron" }} <span class="sr-only">(opent in nieuw tabblad)</span></a><span>{{ alias.extraction_method }} · confidence {{ alias.extraction_confidence|floatformat:2 }}</span></li>{% empty %}<li>Geen bronalias beschikbaar.</li>{% endfor %}</ul></section>
|
|
||||||
|
<section class="detail-section"><span class="section-kicker">Vacaturetekst</span><h2>Over de functie</h2>{% if job.description_html_sanitized %}<div class="prose">{{ job.description_html_sanitized|safe }}</div>{% elif job.description_text %}<div class="prose"><p>{{ job.description_text|linebreaksbr }}</p></div>{% else %}<p class="muted">Geen vacatureomschrijving beschikbaar.</p>{% endif %}{% if job.requirements %}<h3>Vereisten uit de bron</h3><ul class="compact-list">{% for requirement in job.requirements %}<li>{{ requirement }}</li>{% endfor %}</ul>{% endif %}{% if job.benefits %}<h3>Aanbod uit de bron</h3><ul class="compact-list">{% for benefit in job.benefits %}<li>{{ benefit }}</li>{% endfor %}</ul>{% endif %}</section>
|
||||||
|
|
||||||
|
<section class="detail-section"><div class="section-heading"><div><span class="section-kicker">Provenance</span><h2>Bronnen en betrouwbaarheid</h2><p>Ieder extern veld blijft herleidbaar tot de opgeslagen bron.</p></div><span class="pill">{{ source_count }} bron{% if source_count != 1 %}nen{% endif %}</span></div><ul class="source-list">{% for alias in job.source_aliases.all %}{% widthratio alias.extraction_confidence 1 100 as alias_confidence_percent %}<li><a href="{{ alias.url }}" target="_blank" rel="noopener noreferrer nofollow"><svg class="icon"><use href="#icon-external"></use></svg>{{ alias.source.name|default:"Onbekende bron" }}<span class="sr-only"> (opent in nieuw tabblad)</span></a><span>{{ alias.extraction_method|default:"Onbekende extractor" }} · data {{ alias_confidence_percent }}%</span></li>{% empty %}<li>Geen afzonderlijke bronalias opgeslagen.</li>{% endfor %}</ul>{% if provenance_rows %}<details class="provenance-details"><summary>Veldbewijs bekijken</summary><div class="data-table-wrap" tabindex="0" aria-label="Veldbewijs, horizontaal scrolbaar"><table class="data-table"><thead><tr><th>Veld</th><th>Methode</th><th>Confidence</th><th>Bewijs</th></tr></thead><tbody>{% for row in provenance_rows %}{% widthratio row.confidence 1 100 as row_confidence %}<tr><td>{{ row.field_name }}</td><td>{{ row.extraction_method }}</td><td>{{ row_confidence }}%</td><td>{{ row.evidence_excerpt|default:"Geen excerpt opgeslagen" }}</td></tr>{% endfor %}</tbody></table></div></details>{% endif %}</section>
|
||||||
|
|
||||||
|
<section class="detail-section"><span class="section-kicker">Wijzigingshistorie</span><h2>{{ version_count }} opgeslagen inhoudsversie{% if version_count != 1 %}s{% endif %}</h2><div class="version-list">{% for version in latest_versions %}<article><time datetime="{{ version.created_at|date:'c' }}">{{ version.created_at|date:"d/m/Y H:i" }}</time><span>{% if version.changed_fields %}Gewijzigd: {{ version.changed_fields|join:", " }}{% else %}Bronmoment vastgelegd{% endif %}</span></article>{% empty %}<p class="muted">Nog geen afzonderlijke wijzigingsversie opgeslagen.</p>{% endfor %}</div></section>
|
||||||
</div>
|
</div>
|
||||||
<aside class="detail-sidebar"><div class="sticky-card"><span class="section-kicker">Persoonlijke actie</span><h2>Volgende stap</h2><p>VacatureRadar verstuurt nooit automatisch een sollicitatie.</p><div class="stack-actions"><form method="post" action="{% url 'jobs:feedback' job.pk %}">{% csrf_token %}<input type="hidden" name="action" value="interesting"><button class="button button-primary button-block" type="submit">Interessant</button></form><form method="post" action="{% url 'jobs:feedback' job.pk %}">{% csrf_token %}<input type="hidden" name="action" value="save"><button class="button button-secondary button-block" type="submit"><svg class="icon"><use href="#icon-bookmark"></use></svg>Bewaren</button></form><form method="post" action="{% url 'jobs:feedback' job.pk %}">{% csrf_token %}<input type="hidden" name="action" value="applied"><button class="button button-secondary button-block" type="submit">Als gesolliciteerd markeren</button></form><form method="post" action="{% url 'jobs:feedback' job.pk %}" class="hide-form">{% csrf_token %}<input type="hidden" name="action" value="hide"><label>Reden (optioneel)<select name="reason"><option value="">Geen reden</option><option>Te ver</option><option>Te veel support</option><option>Recruiter/consultancy</option><option>Onrealistische senioriteit</option><option>Niet mijn technologie</option></select></label><button class="button button-ghost button-block" type="submit">Verbergen</button></form></div>{% if job.canonical_url %}<a class="button button-link button-block" href="{{ job.canonical_url }}" target="_blank" rel="noopener noreferrer nofollow">Originele vacature <svg class="icon"><use href="#icon-external"></use></svg><span class="sr-only">(opent in nieuw tabblad)</span></a>{% endif %}{% if application %}<hr><p class="muted">Dossierstatus: <strong>{{ application.get_status_display }}</strong></p><a class="button button-ghost button-block" href="{% url 'jobs:application-edit' application.pk %}">Dossier bewerken</a>{% endif %}</div></aside>
|
|
||||||
|
<aside class="detail-sidebar" aria-label="Vacature intelligence zijpaneel">
|
||||||
|
<section class="panel intelligence-summary"><span class="section-kicker">Intelligence</span><h2>Besliscontext</h2><dl class="intel-facts"><div><dt>Werkgever</dt><dd>{{ job.employer_name }}</dd></div><div><dt>Kanaal</dt><dd>{% if job.direct_employer %}Direct{% elif job.recruiter %}Recruiter{% else %}Onbekend{% endif %}</dd></div><div><dt>Werkmodel</dt><dd>{{ job.get_workplace_type_display }}</dd></div><div><dt>Locatie</dt><dd>{{ job.raw_location|default:"Onbekend" }}</dd></div>{% if job.compensation %}<div><dt>Vergoeding</dt><dd>{{ job.compensation }}</dd></div>{% endif %}<div><dt>Laatst gezien</dt><dd>{{ job.last_seen|date:"d/m/Y H:i" }}</dd></div></dl></section>
|
||||||
|
<section class="panel"><span class="section-kicker">Jouw status</span><h2>{% if application %}{{ application.get_status_display }}{% elif latest_feedback %}{{ latest_feedback.get_action_display }}{% else %}Nog geen actie{% endif %}</h2><p class="muted">VacatureRadar bereidt je dossier voor, maar verstuurt nooit automatisch.</p>{% if application %}<a class="button button-secondary button-block" href="{% url 'jobs:application-edit' application.pk %}">Dossier openen</a>{% endif %}</section>
|
||||||
|
<section class="panel"><span class="section-kicker">Broncontrole</span><h2>Transparant signaal</h2><dl class="intel-facts"><div><dt>Eerst gezien</dt><dd>{{ job.first_seen|date:"d/m/Y H:i" }}</dd></div><div><dt>Laatst gewijzigd</dt><dd>{{ job.last_changed|date:"d/m/Y H:i" }}</dd></div><div><dt>Status</dt><dd>{{ job.get_status_display }}</dd></div></dl>{% if job.canonical_url %}<a class="button button-ghost button-block" href="{{ job.canonical_url }}" target="_blank" rel="noopener noreferrer nofollow">Open originele vacature <svg class="icon"><use href="#icon-external"></use></svg><span class="sr-only"> (opent in nieuw tabblad)</span></a>{% endif %}</section>
|
||||||
|
</aside>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="sticky-actions" aria-label="Vacatureacties"><form method="post" action="{% url 'jobs:feedback' job.pk %}">{% csrf_token %}<input type="hidden" name="action" value="interesting"><button class="button button-primary" type="submit"><svg class="icon"><use href="#icon-check"></use></svg>Interessant</button></form><form method="post" action="{% url 'jobs:feedback' job.pk %}">{% csrf_token %}<input type="hidden" name="action" value="save"><button class="button button-secondary" type="submit"><svg class="icon"><use href="#icon-bookmark"></use></svg>Bewaren</button></form><form method="post" action="{% url 'jobs:feedback' job.pk %}">{% csrf_token %}<input type="hidden" name="action" value="applied"><button class="button button-ghost" type="submit">Dossier starten</button></form><form method="post" action="{% url 'jobs:feedback' job.pk %}">{% csrf_token %}<input type="hidden" name="action" value="hide"><input type="hidden" name="reason" value="Niet relevant"><button class="button button-ghost" type="submit">Verbergen</button></form></div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -1,36 +1,49 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
{% block title %}Vacatures · VacatureRadar{% endblock %}
|
{% block title %}Nieuwe matches · VacatureRadar{% endblock %}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<header class="page-header">
|
<header class="cockpit-heading"><div><p class="eyebrow">Vacature explorer</p><h1>Nieuwe matches</h1><p>Alleen aantoonbaar relevante IT-kansen staan standaard in de radar. Kies een resultaat voor directe intelligence.</p></div><div class="radar-status"><span class="status-beacon is-live" aria-hidden="true"></span><span>{% if current_focus == 'all' %}Volledig archief{% else %}IT-focus actief{% endif %}</span><span aria-hidden="true">·</span><strong>{{ page_obj.paginator.count }} resultaten</strong></div></header>
|
||||||
<div><p class="eyebrow">Nieuwe matches & archief</p><h1>Vacature-intelligentie</h1><p>Doorzoek actuele en gearchiveerde vacatures met score, herkomst en status in één scanbare feed.</p></div>
|
|
||||||
</header>
|
<form method="get" class="filter-console" aria-label="Vacaturefilters">
|
||||||
<form method="get" class="filter-console">
|
|
||||||
<label class="grow"><span class="field-label">Zoeken</span><span class="input-with-icon"><svg class="icon"><use href="#icon-search"></use></svg><input type="search" name="q" value="{{ request.GET.q }}" placeholder="Titel, werkgever, locatie of skill"></span></label>
|
<label class="grow"><span class="field-label">Zoeken</span><span class="input-with-icon"><svg class="icon"><use href="#icon-search"></use></svg><input type="search" name="q" value="{{ request.GET.q }}" placeholder="Titel, werkgever, locatie of skill"></span></label>
|
||||||
<label><span class="field-label">Status</span><select name="status"><option value="">Alle statussen</option>{% for value,label in view.model.Status.choices %}<option value="{{ value }}" {% if request.GET.status|default:'active' == value %}selected{% endif %}>{{ label }}</option>{% endfor %}</select></label>
|
<label><span class="field-label">Focus</span><select name="focus"><option value="it" {% if current_focus != 'all' %}selected{% endif %}>Alleen IT</option><option value="all" {% if current_focus == 'all' %}selected{% endif %}>Volledig archief</option></select></label>
|
||||||
|
<label><span class="field-label">Match</span><select name="match"><option value="radar" {% if current_match == 'radar' %}selected{% endif %}>Radarselectie</option><option value="strong" {% if current_match == 'strong' %}selected{% endif %}>Sterk</option><option value="possible" {% if current_match == 'possible' %}selected{% endif %}>Mogelijk</option><option value="weak" {% if current_match == 'weak' %}selected{% endif %}>Laag</option><option value="hidden" {% if current_match == 'hidden' %}selected{% endif %}>Verborgen</option><option value="all" {% if current_match == 'all' %}selected{% endif %}>Alle scores</option></select></label>
|
||||||
|
<label><span class="field-label">Kanaal</span><select name="channel"><option value="">Alle kanalen</option><option value="direct" {% if current_channel == 'direct' %}selected{% endif %}>Directe werkgever</option><option value="recruiter" {% if current_channel == 'recruiter' %}selected{% endif %}>Recruiter</option></select></label>
|
||||||
<label><span class="field-label">Werkmodel</span><select name="workplace"><option value="">Elk werkmodel</option>{% for value,label in view.model.Workplace.choices %}<option value="{{ value }}" {% if request.GET.workplace == value %}selected{% endif %}>{{ label }}</option>{% endfor %}</select></label>
|
<label><span class="field-label">Werkmodel</span><select name="workplace"><option value="">Elk werkmodel</option>{% for value,label in view.model.Workplace.choices %}<option value="{{ value }}" {% if request.GET.workplace == value %}selected{% endif %}>{{ label }}</option>{% endfor %}</select></label>
|
||||||
<button class="button button-primary" type="submit"><svg class="icon"><use href="#icon-search"></use></svg>Filters toepassen</button>
|
<label><span class="field-label">Sortering</span><select name="sort"><option value="match" {% if current_sort == 'match' %}selected{% endif %}>Beste match</option><option value="newest" {% if current_sort == 'newest' %}selected{% endif %}>Nieuwste</option><option value="closing" {% if current_sort == 'closing' %}selected{% endif %}>Deadline</option></select></label>
|
||||||
|
<input type="hidden" name="status" value="{{ request.GET.status|default:'active' }}">
|
||||||
|
<button class="button button-primary" type="submit"><svg class="icon"><use href="#icon-filter"></use></svg>Filter</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<div class="results-toolbar"><div><span class="section-kicker">Resultaten</span><h2>{{ page_obj.paginator.count }} vacatures gevonden</h2></div><span class="view-indicator"><svg class="icon"><use href="#icon-pipeline"></use></svg>Lijstweergave</span></div>
|
<div class="active-filters" aria-label="Actieve filters"><span class="pill pill-it">{% if current_focus == 'all' %}Archief{% else %}IT-focus{% endif %}</span><span class="pill">{{ current_match|default:"radar" }}</span>{% if request.GET.workplace %}<span class="pill">{{ request.GET.workplace }}</span>{% endif %}{% if current_channel %}<span class="pill">{{ current_channel }}</span>{% endif %}{% if request.GET.q %}<span class="pill">“{{ request.GET.q }}”</span>{% endif %}<a href="{% url 'jobs:list' %}">Filters wissen</a></div>
|
||||||
<div class="results-feed">
|
|
||||||
{% for job in jobs %}
|
<div class="explorer-shell">
|
||||||
<article class="result-card {% if job.match_recommendation == 'strong' %}strong-match{% endif %}">
|
<section class="explorer-results" aria-labelledby="results-heading">
|
||||||
<div class="result-accent" aria-hidden="true"></div>
|
<div class="results-toolbar"><div><span class="section-kicker">Gerangschikte signalen</span><h2 id="results-heading">{{ page_obj.paginator.count }} vacatures gevonden</h2></div><span class="view-indicator"><svg class="icon"><use href="#icon-list"></use></svg>Pagina {{ page_obj.number }}</span></div>
|
||||||
<div class="result-body">
|
<div class="explorer-list">
|
||||||
<div class="result-heading">
|
{% for job in jobs %}
|
||||||
<div>
|
{% widthratio job.match_confidence 1 100 as confidence_percent %}
|
||||||
<div class="title-row"><span class="pill pill-{{ job.status }}">{{ job.get_status_display }}</span>{% if job.match_recommendation %}<span class="pill pill-{{ job.match_recommendation }}">{% if job.match_recommendation == 'strong' %}Sterke match{% elif job.match_recommendation == 'possible' %}Mogelijke match{% elif job.match_recommendation == 'weak' %}Lage match{% else %}Verborgen{% endif %}</span>{% endif %}</div>
|
<article class="explorer-card {% if selected_job.pk == job.pk %}is-selected{% endif %} {% if job.match_recommendation == 'strong' %}strong-match{% endif %}">
|
||||||
<h2><a href="{% url 'jobs:detail' job.pk %}">{{ job.original_title }}</a></h2>
|
<a class="explorer-card-link" href="?selected={{ job.pk }}&q={{ request.GET.q|urlencode }}&focus={{ current_focus }}&match={{ current_match }}&channel={{ current_channel }}&workplace={{ request.GET.workplace }}&status={{ request.GET.status|default:'active' }}&sort={{ current_sort }}&page={{ page_obj.number }}" aria-label="Toon intelligence voor {{ job.original_title }}"></a>
|
||||||
<p>{{ job.employer_name }}</p>
|
<div class="explorer-score">{% if job.match_score is not None %}<strong>{{ job.match_score|floatformat:0 }}</strong><small>match</small>{% else %}<strong>—</strong><small>wacht</small>{% endif %}</div>
|
||||||
</div>
|
<div class="explorer-card-body"><div class="title-row">{% if job.match_recommendation %}<span class="pill pill-{{ job.match_recommendation }}">{% if job.match_recommendation == 'strong' %}Sterk{% elif job.match_recommendation == 'possible' %}Mogelijk{% elif job.match_recommendation == 'weak' %}Laag{% else %}Verborgen{% endif %}</span>{% endif %}{% if job.direct_employer %}<span class="pill">Direct</span>{% elif job.recruiter %}<span class="pill">Recruiter</span>{% endif %}</div><h3>{{ job.original_title }}</h3><p>{{ job.employer_name }}</p><div class="job-meta"><span><svg class="icon"><use href="#icon-location"></use></svg>{{ job.raw_location|default:"Onbekend" }}</span><span>{{ job.get_workplace_type_display }}</span>{% if job.valid_through %}<span>sluit {{ job.valid_through|date:"d/m" }}</span>{% endif %}</div>{% if job.match_positives.0 %}<small class="card-signal">{{ job.match_positives.0 }}</small>{% endif %}</div>
|
||||||
{% if job.match_score is not None %}<div class="score-block"><strong>{{ job.match_score|floatformat:0 }}%</strong><small>confidence {{ job.match_confidence|floatformat:2 }}</small></div>{% endif %}
|
<svg class="icon explorer-arrow" aria-hidden="true"><use href="#icon-arrow"></use></svg>
|
||||||
</div>
|
</article>
|
||||||
<div class="job-meta"><span><svg class="icon"><use href="#icon-location"></use></svg>{{ job.raw_location|default:"Locatie onbekend" }}</span><span><svg class="icon"><use href="#icon-briefcase"></use></svg>{{ job.get_workplace_type_display }}</span><span><svg class="icon"><use href="#icon-clock"></use></svg>gevonden {{ job.first_seen|date:"d/m/Y" }}</span>{% if job.direct_employer %}<span>Directe werkgever</span>{% endif %}</div>
|
{% empty %}<div class="empty-state compact-empty"><div class="empty-icon"><svg class="icon"><use href="#icon-search"></use></svg></div><h2>Geen passende vacatures</h2><p>Pas de filters aan of inspecteer bewust het volledige archief.</p><a class="button button-secondary" href="?focus=all&match=all&status=active&sort=match">Open archief</a></div>{% endfor %}
|
||||||
{% if job.skills_required %}<p class="result-summary"><strong>Herkenbare skills:</strong> {{ job.skills_required|slice:":4"|join:", " }}</p>{% elif job.description_text %}<p class="result-summary">{{ job.description_text|truncatewords:24 }}</p>{% endif %}
|
|
||||||
</div>
|
</div>
|
||||||
<div class="result-actions"><a class="button button-primary" href="{% url 'jobs:detail' job.pk %}">Bekijk analyse <svg class="icon"><use href="#icon-arrow"></use></svg></a></div>
|
{% if is_paginated %}<nav class="pagination" aria-label="Paginering">{% if page_obj.has_previous %}<a class="button button-ghost" href="?page={{ page_obj.previous_page_number }}&q={{ request.GET.q|urlencode }}&status={{ request.GET.status }}&workplace={{ request.GET.workplace }}&channel={{ current_channel }}&focus={{ current_focus }}&match={{ current_match }}&sort={{ current_sort }}">Vorige</a>{% endif %}<span>Pagina {{ page_obj.number }} van {{ page_obj.paginator.num_pages }}</span>{% if page_obj.has_next %}<a class="button button-ghost" href="?page={{ page_obj.next_page_number }}&q={{ request.GET.q|urlencode }}&status={{ request.GET.status }}&workplace={{ request.GET.workplace }}&channel={{ current_channel }}&focus={{ current_focus }}&match={{ current_match }}&sort={{ current_sort }}">Volgende</a>{% endif %}</nav>{% endif %}
|
||||||
</article>
|
</section>
|
||||||
{% empty %}<div class="empty-state compact-empty"><div class="empty-icon"><svg class="icon"><use href="#icon-search"></use></svg></div><h2>Geen vacatures gevonden</h2><p>Pas de zoekterm of filters aan. Gearchiveerde resultaten verschijnen wanneer je de statusfilter leegmaakt.</p></div>{% endfor %}
|
|
||||||
|
<aside class="explorer-intelligence" aria-label="Geselecteerde vacature">
|
||||||
|
{% if selected_job %}
|
||||||
|
{% widthratio selected_job.match_confidence 1 100 as selected_confidence %}
|
||||||
|
<div class="intelligence-panel">
|
||||||
|
<div class="intelligence-hero"><span class="section-kicker">Vacature intelligence</span><div class="title-row"><span class="pill pill-{{ selected_job.match_recommendation }}">{{ selected_job.match_recommendation|default:"Niet gescoord" }}</span><span class="pill">{{ selected_job.get_status_display }}</span></div><h2>{{ selected_job.original_title }}</h2><p>{{ selected_job.employer_name }}</p><div class="intelligence-score"><strong>{% if selected_job.match_score is not None %}{{ selected_job.match_score|floatformat:0 }}%{% else %}—{% endif %}</strong><span>persoonlijke match<small>{% if selected_job.match_confidence %}Datakwaliteit {{ selected_confidence }}%{% else %}Nog niet beoordeeld{% endif %}</small></span></div></div>
|
||||||
|
<dl class="intel-facts"><div><dt>Locatie</dt><dd>{{ selected_job.raw_location|default:"Onbekend" }}</dd></div><div><dt>Werkmodel</dt><dd>{{ selected_job.get_workplace_type_display }}</dd></div><div><dt>Gepubliceerd</dt><dd>{{ selected_job.date_posted|date:"d/m/Y"|default:"Onbekend" }}</dd></div><div><dt>Deadline</dt><dd>{{ selected_job.valid_through|date:"d/m/Y"|default:"Niet vermeld" }}</dd></div><div><dt>Bronnen</dt><dd>{{ selected_intelligence.source_count }}</dd></div><div><dt>Duplicaten</dt><dd>{{ selected_intelligence.duplicate_count }}</dd></div></dl>
|
||||||
|
{% if selected_job.match_positives %}<div class="intel-signals"><span class="field-label">Positieve signalen</span><ul>{% for item in selected_job.match_positives|slice:":3" %}<li>{{ item }}</li>{% endfor %}</ul></div>{% endif %}
|
||||||
|
{% if selected_job.match_concerns %}<div class="intel-signals concerns"><span class="field-label">Aandachtspunten</span><ul>{% for item in selected_job.match_concerns|slice:":2" %}<li>{{ item }}</li>{% endfor %}</ul></div>{% endif %}
|
||||||
|
<div class="intelligence-actions"><a class="button button-primary button-block" href="{% url 'jobs:detail' selected_job.pk %}">Volledige analyse <svg class="icon"><use href="#icon-arrow"></use></svg></a><form method="post" action="{% url 'jobs:feedback' selected_job.pk %}">{% csrf_token %}<input type="hidden" name="action" value="save"><input type="hidden" name="next" value="{{ request.get_full_path }}"><button class="button button-ghost button-block" type="submit"><svg class="icon"><use href="#icon-bookmark"></use></svg>Bewaren</button></form></div>
|
||||||
|
</div>
|
||||||
|
{% else %}<div class="empty-state"><h2>Kies een vacature</h2><p>De intelligence verschijnt hier zonder je filters te verliezen.</p></div>{% endif %}
|
||||||
|
</aside>
|
||||||
</div>
|
</div>
|
||||||
{% if is_paginated %}<nav class="pagination" aria-label="Paginering">{% if page_obj.has_previous %}<a class="button button-ghost" href="?page={{ page_obj.previous_page_number }}&q={{ request.GET.q }}&status={{ request.GET.status }}&workplace={{ request.GET.workplace }}">Vorige</a>{% endif %}<span>Pagina {{ page_obj.number }} van {{ page_obj.paginator.num_pages }}</span>{% if page_obj.has_next %}<a class="button button-ghost" href="?page={{ page_obj.next_page_number }}&q={{ request.GET.q }}&status={{ request.GET.status }}&workplace={{ request.GET.workplace }}">Volgende</a>{% endif %}</nav>{% endif %}
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Skillsradar · VacatureRadar{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<header class="page-header skills-page-header">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">Marktvraag · persoonlijk leerkompas</p>
|
||||||
|
<h1>Skillsradar</h1>
|
||||||
|
<p>Zie wat in je actuele IT-radar terugkomt, wat je profiel al dekt en welke technologieën een gerichte bijscholing waard kunnen zijn.</p>
|
||||||
|
</div>
|
||||||
|
{% if active_profile %}<div class="radar-status" aria-label="Analysebasis"><span class="signal-dot" aria-hidden="true"></span><span>{{ active_profile.name }}</span><span aria-hidden="true">·</span><strong>profiel v{{ active_profile.version }}</strong></div>{% endif %}
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{% if report %}
|
||||||
|
<section class="insight-hero" aria-labelledby="insight-summary-title">
|
||||||
|
<div class="insight-orbit" aria-hidden="true"><span></span><span></span><span></span><strong>{{ report.coverage_percent }}%</strong></div>
|
||||||
|
<div>
|
||||||
|
<span class="section-kicker">Jouw dekking van de zichtbare vraag</span>
|
||||||
|
<h2 id="insight-summary-title">{{ report.covered_count }} van {{ report.signal_count }} gevraagde skills staan al in je profiel</h2>
|
||||||
|
<p>Gebaseerd op {{ report.total_jobs }} actieve, IT-relevante en niet-uitgesloten vacature{{ report.total_jobs|pluralize:"s" }}. Ervaringsjaren tellen nergens mee in deze analyse of je matchscore.</p>
|
||||||
|
</div>
|
||||||
|
<a class="button button-secondary" href="{% url 'profiles:edit' active_profile.pk %}">Skills in profiel beheren</a>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="skill-metrics" aria-label="Samenvatting skillsvraag">
|
||||||
|
<article><span>Vacatures onderzocht</span><strong>{{ report.total_jobs }}</strong><small>huidige profielversie</small></article>
|
||||||
|
<article><span>Met herkenbaar signaal</span><strong>{{ report.jobs_with_signals }}</strong><small>titel, skillveld of tekst</small></article>
|
||||||
|
<article><span>Unieke skills gevraagd</span><strong>{{ report.signal_count }}</strong><small>deterministisch herkend</small></article>
|
||||||
|
<article class="metric-accent"><span>Mogelijke leerkansen</span><strong>{{ report.gap_count }}</strong><small>gevraagd, niet aangevinkt</small></article>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<form method="get" class="filter-console skills-filter-console">
|
||||||
|
<label><span class="field-label">Skilldomein</span><select name="category"><option value="all">Alle domeinen</option>{% for value,label in report.categories %}<option value="{{ value }}" {% if report.selected_category == value %}selected{% endif %}>{{ label }}</option>{% endfor %}</select></label>
|
||||||
|
<label><span class="field-label">Profieldekking</span><select name="coverage"><option value="all" {% if report.selected_coverage == 'all' %}selected{% endif %}>Alles</option><option value="gap" {% if report.selected_coverage == 'gap' %}selected{% endif %}>Nog niet in profiel</option><option value="covered" {% if report.selected_coverage == 'covered' %}selected{% endif %}>Al in profiel</option></select></label>
|
||||||
|
<button class="button button-primary" type="submit">Analyse filteren</button>
|
||||||
|
{% if report.selected_category != 'all' or report.selected_coverage != 'all' %}<a class="button button-ghost" href="{% url 'jobs:skill-insights' %}">Filters wissen</a>{% endif %}
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div class="skills-insight-layout">
|
||||||
|
<section aria-labelledby="demand-heading">
|
||||||
|
<div class="results-toolbar"><div><span class="section-kicker">Vraagindicatoren</span><h2 id="demand-heading">{{ report.items|length }} skills in deze selectie</h2></div><span class="view-indicator">gesorteerd op vacaturevolume</span></div>
|
||||||
|
<div class="skill-demand-list">
|
||||||
|
{% for item in report.items %}
|
||||||
|
<article class="skill-demand-card {% if not item.covered %}is-gap{% endif %}">
|
||||||
|
<div class="skill-demand-main">
|
||||||
|
<div class="skill-card-heading">
|
||||||
|
<div><span class="skill-category">{{ item.category }}</span><h3>{{ item.label }}</h3></div>
|
||||||
|
<span class="pill {% if item.covered %}pill-success{% else %}pill-review{% endif %}">{% if item.covered %}In je profiel{% else %}Leerkans{% endif %}</span>
|
||||||
|
</div>
|
||||||
|
<div class="demand-meter" aria-label="Komt voor in {{ item.share_percent }} procent van de onderzochte vacatures"><span style="width: {{ item.share_percent }}%"></span></div>
|
||||||
|
<div class="skill-demand-stats"><strong>{{ item.vacancy_count }} vacature{{ item.vacancy_count|pluralize:"s" }}</strong><span>{{ item.share_percent }}% van je radar</span><span>{{ item.explicit_count }} in skillveld · {{ item.inferred_count }} via titel/tekst</span></div>
|
||||||
|
{% if not item.covered %}<p class="learning-focus"><strong>Mogelijke volgende stap</strong>{{ item.learning_focus }}</p>{% endif %}
|
||||||
|
{% if item.examples %}<ul class="skill-job-examples">{% for example in item.examples %}<li><a href="{% url 'jobs:detail' example.id %}">{{ example.title }}</a><span>{{ example.employer }}</span></li>{% endfor %}</ul>{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="skill-demand-action"><a class="button button-ghost" href="{% url 'jobs:list' %}?q={{ item.search_term|urlencode }}&status=active&focus=it&match=radar&sort=match">Vacatures bekijken</a></div>
|
||||||
|
</article>
|
||||||
|
{% empty %}
|
||||||
|
<div class="empty-state compact-empty"><div class="empty-icon"><svg class="icon"><use href="#icon-radar"></use></svg></div><h2>Geen skills binnen deze filters</h2><p>Wis de filters of kies een ander skilldomein. De onderliggende vacatures blijven ongewijzigd.</p><a class="button button-secondary" href="{% url 'jobs:skill-insights' %}">Alle signalen tonen</a></div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<aside class="skills-method-rail" aria-label="Uitleg bij de skillsanalyse">
|
||||||
|
<section class="panel rail-panel"><span class="section-kicker">Wat betekent dit?</span><h2>Vraag, geen afwijzingsregel</h2><p class="muted">Een leerkans is een skill die in minstens één zichtbare vacature wordt genoemd maar nog niet in je profiel staat. Dit is nooit een blokkade en zegt niet dat je zonder die skill kansloos bent.</p></section>
|
||||||
|
<section class="panel rail-panel"><span class="section-kicker">Bewijskwaliteit</span><h2>Feit versus tekstsignaal</h2><ul class="legend-list"><li><span class="legend-dot exact"></span><div><strong>Skillveld</strong><small>Door de bron als vereiste of voorkeur aangeleverd.</small></div></li><li><span class="legend-dot inferred"></span><div><strong>Titel/tekst</strong><small>Deterministisch aangetroffen; mogelijk context en geen harde eis.</small></div></li></ul></section>
|
||||||
|
<section class="panel rail-panel"><span class="section-kicker">Selectiegrens</span><h2>Alleen jouw radar</h2><ol class="radar-steps"><li><strong>Actief</strong><span>Geen verlopen of verwijderde vacatures.</span></li><li><strong>IT-relevant</strong><span>Dezelfde deterministische IT-grens als de vacaturelijst.</span></li><li><strong>Niet uitgesloten</strong><span>Alleen scores van je huidige profielversie.</span></li></ol></section>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="empty-state"><div class="empty-icon"><svg class="icon"><use href="#icon-profile"></use></svg></div><h2>Geen actief zoekprofiel</h2><p>Activeer eerst een profiel. De skillsradar blijft persoonlijk en mengt nooit gegevens van andere gebruikers.</p><a class="button button-primary" href="{% url 'profiles:list' %}">Profielen bekijken</a></div>
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
@@ -1,21 +1,22 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
{% block title %}{{ object.name }} bewerken · VacatureRadar{% endblock %}
|
{% block title %}Configureer je radar · VacatureRadar{% endblock %}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<header class="page-header"><div><p class="eyebrow">Zoekprofiel · versie {{ object.version }}</p><h1>Configureer je radar</h1><p>Verfijn {{ object.name }} zodat alleen relevante, uitlegbare kansen bovenaan verschijnen.</p></div><span class="pill {% if object.is_active %}pill-active{% endif %}">{% if object.is_active %}Actief profiel{% else %}Inactief profiel{% endif %}</span></header>
|
<header class="cockpit-heading"><div><p class="eyebrow">Zoekprofiel · versie {{ object.version }}</p><h1>Configureer je radar</h1><p>Stuur rollen, technologiesignalen en harde grenzen met echte selecties. Ervaringsduur blijft uitsluitend informatieve context.</p></div><span class="pill {% if object.is_active %}pill-success{% endif %}">{% if object.is_active %}Live profiel{% else %}Gepauzeerd{% endif %}</span></header>
|
||||||
<div class="editor-layout">
|
<div class="profile-editor-layout">
|
||||||
<form id="profile-form" method="post" class="panel form-panel profile-editor">{% csrf_token %}
|
<form id="profile-form" method="post" class="radar-configurator">{% csrf_token %}
|
||||||
{% if form.non_field_errors %}<div class="message error">{{ form.non_field_errors }}</div>{% endif %}
|
{% if form.non_field_errors %}<div class="message error">{{ form.non_field_errors }}</div>{% endif %}
|
||||||
<div class="form-grid">
|
<section class="form-section" id="basis"><div class="form-section-heading"><span class="source-icon"><svg class="icon"><use href="#icon-profile"></use></svg></span><div><span class="section-kicker">01 · Radaridentiteit</span><h2>Profielbasis</h2><p>Je postcode volstaat; gemeente en coördinaten worden uit de lokale gelicenseerde dataset afgeleid.</p></div></div><div class="form-grid"><label><span>{{ form.name.label }}</span>{{ form.name }}{% for error in form.name.errors %}<small class="field-error">{{ error }}</small>{% endfor %}</label><label class="toggle-field">{{ form.is_active }}<span>{{ form.is_active.label }}</span></label><label><span>{{ form.home_postal_code.label }}</span>{{ form.home_postal_code }}<small>{{ form.home_postal_code.help_text }}</small>{% for error in form.home_postal_code.errors %}<small class="field-error">{{ error }}</small>{% endfor %}</label><label><span>{{ form.max_distance_km.label }}</span>{{ form.max_distance_km }}{% for error in form.max_distance_km.errors %}<small class="field-error">{{ error }}</small>{% endfor %}</label><label class="span-2"><span>{{ form.experience_years.label }}</span>{{ form.experience_years }}<small>{{ form.experience_years.help_text }}</small></label></div></section>
|
||||||
{% for field in form %}
|
|
||||||
{% if field.name == 'name' %}<div class="form-section"><span class="form-section-icon"><svg class="icon"><use href="#icon-profile"></use></svg></span><div><h2>Profielbasis</h2><p>Je postcode volstaat; technische locatievelden worden op de achtergrond afgeleid.</p></div></div>{% elif field.name == 'desired_titles' %}<div class="form-section"><span class="form-section-icon"><svg class="icon"><use href="#icon-briefcase"></use></svg></span><div><h2>Gewenste rollen & technologie</h2><p>Kies herkenbare opties. Uitsluitingen blijven altijd doorslaggevend.</p></div></div>{% elif field.name == 'allowed_employment_types' %}<div class="form-section"><span class="form-section-icon"><svg class="icon"><use href="#icon-location"></use></svg></span><div><h2>Werkvorm, regio & reistijd</h2><p>Selecteer contractvormen, werkmodellen en regio's zonder technische notatie.</p></div></div>{% elif field.name == 'recommendation_threshold' %}<div class="form-section"><span class="form-section-icon"><svg class="icon"><use href="#icon-radar"></use></svg></span><div><h2>Scoring & automatisering</h2><p>Een topmatch ligt altijd boven de gewone aanbevelingsdrempel.</p></div></div>{% endif %}
|
<section class="form-section" id="rollen"><div class="form-section-heading"><span class="source-icon"><svg class="icon"><use href="#icon-briefcase"></use></svg></span><div><span class="section-kicker">02 · Functierichting</span><h2>Gewenste en uitgesloten rollen</h2><p>Selecteer alle relevante richtingen; een uitgesloten titel blijft een harde grens.</p></div></div><fieldset class="choice-field"><legend>{{ form.desired_titles.label }}</legend>{{ form.desired_titles }}<small>{{ form.desired_titles.help_text }}</small>{% for error in form.desired_titles.errors %}<small class="field-error">{{ error }}</small>{% endfor %}</fieldset><details class="config-disclosure"><summary>Uitgesloten functietitels <svg class="icon"><use href="#icon-chevron"></use></svg></summary><fieldset class="choice-field"><legend class="sr-only">{{ form.excluded_titles.label }}</legend>{{ form.excluded_titles }}<small>{{ form.excluded_titles.help_text }}</small>{% for error in form.excluded_titles.errors %}<small class="field-error">{{ error }}</small>{% endfor %}</fieldset></details></section>
|
||||||
{% if field.field.widget.allow_multiple_selected %}<fieldset class="choice-field span-2"><legend>{{ field.label }}</legend>{{ field }}{% if field.help_text %}<small>{{ field.help_text }}</small>{% endif %}{% for error in field.errors %}<small class="field-error">{{ error }}</small>{% endfor %}</fieldset>{% else %}<label><span>{{ field.label }}</span>{{ field }}{% if field.help_text %}<small>{{ field.help_text }}</small>{% endif %}{% for error in field.errors %}<small class="field-error">{{ error }}</small>{% endfor %}</label>{% endif %}
|
|
||||||
{% endfor %}
|
<section class="form-section" id="technologie"><div class="form-section-heading"><span class="source-icon"><svg class="icon"><use href="#icon-chart"></use></svg></span><div><span class="section-kicker">03 · Technologieën & capability</span><h2>Technologiesignalen</h2><p>Een skill telt alleen wanneer de vacaturetekst een expliciet signaal bevat. Meer gekozen cv-skills verwateren de score niet onbeperkt.</p></div></div><fieldset class="choice-field"><legend>{{ form.desired_skills.label }}</legend>{{ form.desired_skills }}<small>{{ form.desired_skills.help_text }}</small>{% for error in form.desired_skills.errors %}<small class="field-error">{{ error }}</small>{% endfor %}</fieldset><details class="config-disclosure"><summary>Harde skilluitsluitingen <svg class="icon"><use href="#icon-chevron"></use></svg></summary><fieldset class="choice-field"><legend class="sr-only">{{ form.excluded_skills.label }}</legend>{{ form.excluded_skills }}{% for error in form.excluded_skills.errors %}<small class="field-error">{{ error }}</small>{% endfor %}</fieldset></details></section>
|
||||||
</div>
|
|
||||||
<div class="form-actions"><a class="button button-ghost" href="{% url 'profiles:list' %}">Annuleren</a><button class="button button-primary" type="submit">Instellingen opslaan</button></div>
|
<section class="form-section" id="werkvorm"><div class="form-section-heading"><span class="source-icon"><svg class="icon"><use href="#icon-location"></use></svg></span><div><span class="section-kicker">04 · Regio & organisatie</span><h2>Werkvorm en bereik</h2><p>Contract, werkmodel en regio worden als expliciete selecties opgeslagen.</p></div></div><div class="preference-grid"><fieldset class="choice-field"><legend>{{ form.allowed_employment_types.label }}</legend>{{ form.allowed_employment_types }}</fieldset><fieldset class="choice-field"><legend>{{ form.preferred_workplace.label }}</legend>{{ form.preferred_workplace }}</fieldset><fieldset class="choice-field"><legend>{{ form.preferred_regions.label }}</legend>{{ form.preferred_regions }}</fieldset><fieldset class="choice-field"><legend>{{ form.excluded_regions.label }}</legend>{{ form.excluded_regions }}{% for error in form.excluded_regions.errors %}<small class="field-error">{{ error }}</small>{% endfor %}</fieldset></div></section>
|
||||||
|
|
||||||
|
<section class="form-section" id="automatisering"><div class="form-section-heading"><span class="source-icon"><svg class="icon"><use href="#icon-system"></use></svg></span><div><span class="section-kicker">05 · Rangschikking</span><h2>Drempels & samenvatting</h2><p>De topmatchdrempel ligt boven de aanbevelingsdrempel. Harde regels winnen altijd van de score.</p></div></div><div class="form-grid"><label><span>{{ form.recommendation_threshold.label }}</span>{{ form.recommendation_threshold }}{% for error in form.recommendation_threshold.errors %}<small class="field-error">{{ error }}</small>{% endfor %}</label><label><span>{{ form.top_match_threshold.label }}</span>{{ form.top_match_threshold }}{% for error in form.top_match_threshold.errors %}<small class="field-error">{{ error }}</small>{% endfor %}</label><label><span>{{ form.digest_time.label }}</span>{{ form.digest_time }}</label><label class="toggle-field">{{ form.learning_enabled }}<span>{{ form.learning_enabled.label }}</span><small>{{ form.learning_enabled.help_text }}</small></label></div></section>
|
||||||
|
<div class="form-actions profile-savebar"><a class="button button-ghost" href="{% url 'profiles:list' %}">Annuleren</a><button class="button button-primary" type="submit">Instellingen opslaan</button></div>
|
||||||
</form>
|
</form>
|
||||||
<aside class="editor-rail" aria-label="Profielsamenvatting">
|
|
||||||
<section class="panel radar-preview"><div class="title-row"><span class="section-kicker">Radarprofiel</span><span class="pill {% if object.is_active %}pill-active{% endif %}">{% if object.is_active %}Live{% else %}Pauze{% endif %}</span></div><h2>{{ object.name }}</h2><p class="muted">De huidige opgeslagen configuratie.</p><div class="preview-score"><strong>{{ object.top_match_threshold }}%</strong><span>Topmatch vanaf</span></div><div class="rail-list"><div class="rail-item"><span>Thuislocatie</span><strong>{% if object.home_postal_code %}{{ object.home_postal_code }}{% if object.home_municipality %} · {{ object.home_municipality }}{% endif %}{% else %}Niet ingesteld{% endif %}</strong></div><div class="rail-item"><span>Gewenste rollen</span><strong>{{ object.desired_titles|length }}</strong></div><div class="rail-item"><span>Gewenste skills</span><strong>{{ object.desired_skills|length }}</strong></div><div class="rail-item"><span>Maximumafstand</span><strong>{{ object.max_distance_km }} km</strong></div><div class="rail-item"><span>Gecontroleerd leren</span><strong>{% if object.learning_enabled %}Aan{% else %}Uit{% endif %}</strong></div></div></section>
|
<aside class="profile-preview" aria-label="Radar preview"><section class="panel preview-panel"><div class="title-row"><span class="section-kicker">Radar preview</span><span class="pill {% if object.is_active %}pill-success{% endif %}">{% if object.is_active %}Live{% else %}Pauze{% endif %}</span></div>{% if preview_score %}{% widthratio preview_score.confidence 1 100 as preview_confidence %}<div class="preview-vacancy"><span class="field-label">Laatste echte beoordeling</span><h2>{{ preview_score.job.original_title }}</h2><p>{{ preview_score.job.employer_name }}</p><div class="intelligence-score"><strong>{{ preview_score.score|floatformat:0 }}%</strong><span>{{ preview_score.get_recommendation_display }}<small>Datakwaliteit {{ preview_confidence }}%</small></span></div>{% if preview_score.positives %}<ul class="compact-list positive">{% for item in preview_score.positives|slice:":3" %}<li>{{ item }}</li>{% endfor %}</ul>{% endif %}<a class="button button-ghost button-block" href="{% url 'jobs:detail' preview_score.job.pk %}">Bekijk onderbouwing</a></div>{% else %}<div class="empty-state compact-empty"><h2>Nog geen echte preview</h2><p>Na de volgende profielscore verschijnt hier een recente vacature, zonder demo-data.</p></div>{% endif %}<div class="rail-list"><div class="rail-item"><span>Postcode</span><strong>{{ object.home_postal_code|default:"—" }}</strong></div><div class="rail-item"><span>Rollen</span><strong>{{ object.desired_titles|length }}</strong></div><div class="rail-item"><span>Skills</span><strong>{{ object.desired_skills|length }}</strong></div><div class="rail-item"><span>Radius</span><strong>{{ object.max_distance_km }} km</strong></div></div><button class="button button-primary button-block" type="submit" form="profile-form">Radar opslaan</button></section><section class="panel"><span class="section-kicker">Revisies</span><h2>Configuratiehistorie</h2><div class="version-list">{% for revision in recent_revisions %}<article><time>{{ revision.created_at|date:"d/m/Y H:i" }}</time><span>Versie {{ revision.version }} · {{ revision.reason|default:"profielwijziging" }}</span></article>{% empty %}<p class="muted">Nog geen profielrevisie opgeslagen.</p>{% endfor %}</div></section></aside>
|
||||||
<section class="panel rail-panel"><span class="section-kicker">Veilige grenzen</span><h2>Deterministisch eerst</h2><p class="muted">Harde uitsluitingen en bronbeleid veranderen niet door AI of feedback.</p><button class="button button-primary button-block" type="submit" form="profile-form">Instellingen opslaan</button></section>
|
|
||||||
</aside>
|
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -1,6 +1,3 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
{% block title %}Zoekprofielen · VacatureRadar{% endblock %}
|
{% block title %}Zoekprofiel · VacatureRadar{% endblock %}
|
||||||
{% block content %}
|
{% block content %}<header class="cockpit-heading"><div><p class="eyebrow">Persoonlijke radar</p><h1>Zoekprofiel</h1><p>Je harde grenzen blijven deterministisch; ervaringsduur is alleen context en nooit een scorefactor.</p></div></header><div class="profile-index-grid">{% for profile in profiles %}<article class="profile-index-card {% if profile.is_active %}strong-match{% endif %}"><div class="profile-index-head"><span class="source-icon"><svg class="icon"><use href="#icon-profile"></use></svg></span><span class="pill {% if profile.is_active %}pill-success{% endif %}">{% if profile.is_active %}Live{% else %}Pauze{% endif %}</span></div><h2>{{ profile.name }}</h2><p class="muted">{{ profile.desired_titles|slice:":3"|join:", "|default:"Nog geen rollen geselecteerd" }}</p><dl class="intel-facts"><div><dt>Postcode</dt><dd>{{ profile.home_postal_code|default:"—" }}</dd></div><div><dt>Radius</dt><dd>{{ profile.max_distance_km }} km</dd></div><div><dt>Skills</dt><dd>{{ profile.desired_skills|length }}</dd></div><div><dt>Versie</dt><dd>{{ profile.version }}</dd></div></dl><div class="page-actions"><a class="button button-primary" href="{% url 'profiles:edit' profile.pk %}">Radar configureren</a>{% if not profile.is_active %}<form method="post" action="{% url 'profiles:activate' profile.pk %}">{% csrf_token %}<button class="button button-ghost" type="submit">Activeren</button></form>{% endif %}</div></article>{% empty %}<div class="empty-state"><h2>Geen profiel beschikbaar</h2><p>Initialiseer het persoonlijke profiel via de bootstrapopdracht.</p></div>{% endfor %}</div>{% endblock %}
|
||||||
<header class="page-header"><div><p class="eyebrow">Persoonlijke regels</p><h1>Zoekprofiel</h1><p>Harde grenzen veranderen nooit automatisch; zachte voorkeuren kunnen alleen gecontroleerd leren.</p></div></header>
|
|
||||||
<div class="card-list">{% for profile in profiles %}<article class="panel profile-card {% if profile.is_active %}strong-match{% endif %}"><div><div class="title-row"><span class="pill {% if profile.is_active %}pill-active{% endif %}">{% if profile.is_active %}Actief{% else %}Inactief{% endif %}</span><h2>{{ profile.name }}</h2></div><p class="muted">{{ profile.desired_titles|join:", "|default:"Nog geen functietitels" }}</p><div class="job-meta"><span><svg class="icon"><use href="#icon-location"></use></svg>{% if profile.home_postal_code %}{{ profile.home_postal_code }}{% if profile.home_municipality %} · {{ profile.home_municipality }}{% endif %}{% else %}geen thuispostcode{% endif %}</span><span>max. {{ profile.max_distance_km }} km</span><span><svg class="icon"><use href="#icon-clock"></use></svg>digest {{ profile.digest_time|time:"H:i" }}</span><span>versie {{ profile.version }}</span><span>leren {% if profile.learning_enabled %}aan{% else %}uit{% endif %}</span></div></div><div class="job-actions"><a class="button button-primary" href="{% url 'profiles:edit' profile.pk %}">Profiel bewerken</a>{% if not profile.is_active %}<form method="post" action="{% url 'profiles:activate' profile.pk %}">{% csrf_token %}<button class="button button-ghost" type="submit">Activeren</button></form>{% endif %}</div></article>{% empty %}<div class="empty-state"><div class="empty-icon"><svg class="icon"><use href="#icon-profile"></use></svg></div><h2>Geen profiel beschikbaar</h2><p>Voer de bootstrapopdracht uit om het standaardprofiel aan te maken.</p></div>{% endfor %}</div>
|
|
||||||
{% endblock %}
|
|
||||||
|
|||||||