=feat-stitch-intelligence-cockpit

This commit is contained in:
Jens
2026-07-21 22:52:28 +02:00
parent 4505e47d36
commit 479f61fc6a
42 changed files with 754 additions and 857 deletions
+19
View File
@@ -2,6 +2,25 @@
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.2.1 — 2026-07-21
### Toegevoegd
- Productieklare vertaling van de negen Stitch Intelligence Cockpit-schermen naar de bestaande Django-kern.
- Gedeelde responsive shell, lokale SVG-iconsprite, donkere en lichte thematokens en toegankelijke mobiele navigatie.
- Cockpitdashboard, vacature-intelligentiekaarten/detail, sollicitatiepipeline, gegroepeerde profieleditor, bronkaarten en systeemstatus.
- Integratieplan, permanente Stitch-referentie en browservalidatiescreenshots voor zes doelviewports.
### Gewijzigd
- Alle kernroutes gebruiken nu één CSP-veilige lokale CSS/JS-laag zonder externe productie-assets.
- Bronbeheer is teruggebracht onder de gedeelde shell terwijl import, bulkacties, retry, policy en SSRF-beveiliging intact blijven.
- Browseropslag voor themavoorkeur is progressief: opslagbeperkingen veroorzaken geen console- of bedieningsfout.
### Niet overgenomen
- Quick/direct apply, fictieve workerstatistieken, onbewezen werkgeversinformatie en kunstmatige activiteitenfeeds.
## 0.1.1 — 2026-07-21 ## 0.1.1 — 2026-07-21
### Toegevoegd ### Toegevoegd
+16
View File
@@ -76,3 +76,19 @@ Codex krijgt vaste productbeslissingen via `AGENTS.md`, `CODEX_START_HERE.md`, a
Docker was in de assemblageomgeving niet beschikbaar. Daarom zijn de Dockerfile en Composebestanden statisch gevalideerd, maar een echte imagebuild, PostgreSQL/Redis-container-smoke, Unraid-installatie en restore-drill worden niet als uitgevoerd geclaimd. `VR-115` en `VR-117` bevatten de exacte acceptatiecriteria en commando's voor een Docker-capabele omgeving. Docker was in de assemblageomgeving niet beschikbaar. Daarom zijn de Dockerfile en Composebestanden statisch gevalideerd, maar een echte imagebuild, PostgreSQL/Redis-container-smoke, Unraid-installatie en restore-drill worden niet als uitgevoerd geclaimd. `VR-115` en `VR-117` bevatten de exacte acceptatiecriteria en commando's voor een Docker-capabele omgeving.
Live bronvoorwaarden, mailboxcredentials, SMTP, domein/TLS, registry en een gelicenseerde Belgische geodatabron vereisen persoonlijke/externe configuratie en staan apart als `blocked-external`. Live bronvoorwaarden, mailboxcredentials, SMTP, domein/TLS, registry en een gelicenseerde Belgische geodatabron vereisen persoonlijke/externe configuratie en staan apart als `blocked-external`.
## VR-118 — Stitch Intelligence Cockpit (2026-07-21)
Aanvullend op de bestaande releasebasis is de volledige interface opnieuw geïntegreerd en gecontroleerd.
Uitgevoerde browsermatrix:
- 1440×900, 1280×800, 1024×768, 768×1024, 390×844 en 360×800;
- exact één `h1` en geen documentbrede horizontale overflow op ieder formaat;
- dashboard, vacaturelijst/detail, sollicitatiepipeline, profiel/editor, bronnen en systeem;
- dark en light, mobiele open/sluitnavigatie, bron-disclosure en vacature → gesolliciteerd → pipeline;
- geen externe productie-scripts of stylesheets en geen nieuwe consolefouten na de storagefallbackfix.
Screenshots: `docs/design/validation/`. Deze map en de originele Stitch-referentie zijn via `.dockerignore` uitgesloten van de productiecontainer.
De volledige afrondingsgate en actuele aantallen worden onderaan dit rapport toegevoegd na `scripts/codex_verify.sh`.
+1 -1
View File
@@ -8,5 +8,5 @@ from django.http import HttpRequest
def navigation_context(request: HttpRequest) -> dict[str, Any]: def navigation_context(request: HttpRequest) -> dict[str, Any]:
return { return {
"app_name": "VacatureRadar", "app_name": "VacatureRadar",
"app_version": "0.1.0", "app_version": "0.2.1",
} }
+14
View File
@@ -108,6 +108,20 @@ class ApplicationListView(LoginRequiredMixin, ListView):
"job", "job__employer" "job", "job__employer"
) )
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
applications = list(context["applications"])
context["applications"] = applications
context["pipeline_columns"] = [
{
"status": status,
"label": label,
"applications": [item for item in applications if item.status == status],
}
for status, label in Application.Status.choices
]
return context
class ApplicationUpdateView(LoginRequiredMixin, UpdateView): class ApplicationUpdateView(LoginRequiredMixin, UpdateView):
model = Application model = Application
+36
View File
@@ -941,6 +941,42 @@ tasks:
completed_at: '2026-07-21' completed_at: '2026-07-21'
note: Release script, smoke-run, backup/restore and artifact bundle for VR-117 toegevoegd; release artefacten note: Release script, smoke-run, backup/restore and artifact bundle for VR-117 toegevoegd; release artefacten
en Unraid-runbook geactualiseerd; traceability en projectstate bijgewerkt. en Unraid-runbook geactualiseerd; traceability en projectstate bijgewerkt.
- id: VR-118
title: Stitch Intelligence Cockpit production-grade integreren
status: done
priority: P1
requirement_ids:
- NFR-006
- NFR-008
- AC-002
- AC-003
- AC-051
depends_on:
- VR-107
- VR-114
summary: Vertaal de negen aangeleverde Stitch-schermen naar de bestaande server-rendered Django-interface zonder
domeinlogica, securitygrenzen of gebruikersscope te vervangen en zonder fictieve productdata te introduceren.
acceptance_criteria:
- Een consistente responsive cockpit-shell en dark/light tokens bedienen alle kernroutes.
- Dashboard, vacatures, detail, pipeline, profiel, bronnen, systeem en login gebruiken echte backenddata en acties.
- Externe auto-apply, nepmetrics, onbewezen werkgeverinzichten en onbetrouwbare activiteitenfeeds zijn niet overgenomen.
- Zes doelviewports hebben één h1, geen horizontale overflow en werkende mobiele navigatie.
- Stitch-bronnen, integratieplan, screenshots en verificatierapport zijn traceerbaar maar niet onderdeel van de productiecontainer.
verification:
- uv run pytest tests/e2e tests/integration/test_views.py tests/integration/test_tasks_and_more_views.py
- ./scripts/codex_verify.sh
- Browsercontrole op 1440x900, 1280x800, 1024x768, 768x1024, 390x844 en 360x800
primary_paths:
- templates/
- static/css/app.css
- static/js/app.js
- docs/design/STITCH_INTEGRATION_PLAN.md
- docs/design/stitch-reference/
- docs/design/validation/
result:
completed_at: '2026-07-21'
note: Stitch-visuele taal geïntegreerd in CSP-veilige Django-templates; dark/light, responsive shell, pipeline,
broninteracties en browservalidatie toegevoegd zonder functionele scope-uitbreiding of fictieve data.
- id: VR-201 - id: VR-201
title: Concrete live-bronnen inventariseren en juridisch/operationeel reviewen title: Concrete live-bronnen inventariseren en juridisch/operationeel reviewen
status: blocked-external status: blocked-external
+10 -3
View File
@@ -1,7 +1,7 @@
# Projectstatus # Projectstatus
- Laatst bijgewerkt: 2026-07-21 - Laatst bijgewerkt: 2026-07-21
- Repositoryversie: 0.1.0 MVP-basis - Repositoryversie: 0.2.1 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,7 +14,7 @@ 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;
- dashboard, vacaturelijst/detail, profiel-, bron- en sollicitatiepagina's; - responsive dark/light cockpitdashboard, vacaturelijst/detail, sollicitatiepipeline, profiel-, bron- en systeemschermen;
- 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.
@@ -43,6 +43,13 @@ Release-audit op 2026-07-21:
## Laatste uitgevoerde backlogtaak ## Laatste uitgevoerde backlogtaak
- 2026-07-21: `VR-118` voltooid.
- Alle kernroutes delen nu één responsive Intelligence Cockpit-shell met lokale SVG-iconen en CSP-veilige assets.
- Stitch-dark en een volwaardige light variant zijn gekoppeld aan echte Django-data; externe quick apply, nepmetrics en onbewezen intelligence zijn bewust geweerd.
- Browserflow valideerde vacature → sollicitatiedossier → pipeline, bron-disclosures, themawissel en viewports 1440×900, 1280×800, 1024×768, 768×1024, 390×844 en 360×800 zonder horizontale overflow.
- Stitch-bronnen, integratieplan en screenshots staan onder `docs/design/` en zijn uit de productiecontainer gesloten.
- Volgende taak: `VR-201` (`blocked-external`).
- 2026-07-21: `VR-117` voltooid. - 2026-07-21: `VR-117` voltooid.
- Productiereleasecontrole toegevoegd met `scripts/release_verify.sh`, inclusief smoke, backup/restore-smoke en artifactbundel. - Productiereleasecontrole toegevoegd met `scripts/release_verify.sh`, inclusief smoke, backup/restore-smoke en artifactbundel.
- Changelog, traceability en Unraid-runbook samengekoppeld aan het releasepad. - Changelog, traceability en Unraid-runbook samengekoppeld aan het releasepad.
@@ -156,7 +163,7 @@ Deze blokkeren onafhankelijke code niet:
## Bekende verificatiebeperking van de aangeleverde basis ## Bekende verificatiebeperking van de aangeleverde basis
De Pythonapplicatie, Dockerimage en PostgreSQL/Redis/Unraid-stack zijn geverifieerd. De Playwright-browservarianten zijn lokaal niet uitgevoerd; hun HTML/a11y-fallback is wel geslaagd. Een publieke HTTPS-smoke blijft afhankelijk van domein-, DNS- en reverse-proxyconfiguratie. De Pythonapplicatie, Dockerimage en PostgreSQL/Redis/Unraid-stack zijn geverifieerd. De optionele pytest-Playwrightvarianten vallen lokaal terug op de HTML/a11y-probe; VR-118 is aanvullend handmatig met de geïntegreerde browser op zes viewports uitgevoerd. Een publieke HTTPS-smoke blijft afhankelijk van domein-, DNS- en reverse-proxyconfiguratie.
## Hervatten ## Hervatten
+9
View File
@@ -62,3 +62,12 @@ Per zichtbare taak:
4. screenreader smoke test voor dashboard, vacaturedetail en profiel; 4. screenreader smoke test voor dashboard, vacaturedetail en profiel;
5. contrastcontrole; 5. contrastcontrole;
6. test met lange/lege/unknown content. 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.
+28 -20
View File
@@ -1,37 +1,44 @@
# Design system # Design system
> Geactualiseerd voor VR-118. De volledige Stitch-mapping en bewuste afwijkingen staan in
> `docs/design/STITCH_INTEGRATION_PLAN.md`.
## 1. Merkgevoel ## 1. Merkgevoel
Rustig, betrouwbaar, helder en licht technisch. De interface moet focus geven, niet gamificeren. Geen confetti, eindeloze badges of agressieve notificatiekleuren. Rustig, betrouwbaar, helder en technisch precies. De interface voelt als een professionele intelligence-cockpit:
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 ## 2. Visuele tokens
De implementatie staat in `static/css/app.css`. Gebruik semantische custom properties in plaats van losse kleuren in componenten. De implementatie staat in `static/css/app.css`. Gebruik semantische custom properties in plaats van losse kleuren in componenten.
Aanbevolen tokenrollen: Geïmplementeerde tokenrollen omvatten onder meer:
```css ```css
--surface-page --bg
--surface-card --bg-deep
--surface-raised --surface
--text-primary --surface-low
--text-secondary --surface-high
--text-muted --surface-bright
--border-subtle --text
--accent --text-soft
--accent-strong --muted
--success --border
--border-strong
--primary
--primary-strong
--positive
--warning --warning
--danger --danger
--focus-ring --signal-glow
--shadow-card
--radius-sm --radius-sm
--radius-md --radius
--radius-lg
--space-1 ... --space-8
``` ```
Donkere modus volgt `prefers-color-scheme` en behoudt semantische contrastrollen. Donkere modus is de productstandaard. De gebruiker kan lokaal wisselen via `data-theme`; opslag is progressief en
mag in een afgeschermde browsercontext uitvallen zonder de bediening te breken.
## 3. Typografie ## 3. Typografie
@@ -45,10 +52,11 @@ Donkere modus volgt `prefers-color-scheme` en behoudt semantische contrastrollen
## 4. Spacing en layout ## 4. Spacing en layout
- 4/8px-gebaseerde schaal; - 4/8px-gebaseerde schaal;
- contentcontainer ongeveer 11801280px; - contentcontainer maximaal 1600px zodat de cockpit op brede schermen bruikbaar blijft;
- kaarten met consistente padding 2024px desktop, 16px mobiel; - kaarten met consistente padding 2024px desktop, 16px mobiel;
- minimaal 44×44px touch targets voor primaire interactie; - minimaal 44×44px touch targets voor primaire interactie;
- grids breken onder 900px en 640px logisch af; - vaste zijbalk van 272px boven 900px, toegankelijke uitschuifnavigatie daaronder;
- grids breken onder 1180px, 900px en 680px logisch af;
- vaste/sticky elementen mogen content niet bedekken. - vaste/sticky elementen mogen content niet bedekken.
## 5. Componenten ## 5. Componenten
Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

+1
View File
@@ -72,6 +72,7 @@ Django/Celery
| T-24 | Source terms wijzigen | ongewenste voortgezette crawling | reviewdatum, source health en policy expiry gepland | `VR-102`, `VR-112` | | T-24 | Source terms wijzigen | ongewenste voortgezette crawling | reviewdatum, source health en policy expiry gepland | `VR-102`, `VR-112` |
| T-31 | Sollicitatiedossier-export bevat niet-gewenste payload | data-lek of onbedoeld dossieroverdragen | export bevat alleen het gekozen dossier, ZIP-inhoud is beperkt tot snapshot/tijdlijn/print-HTML, bestandsnaam is geslugified zonder padseparators | `tests/integration/test_applications.py` | | T-31 | Sollicitatiedossier-export bevat niet-gewenste payload | data-lek of onbedoeld dossieroverdragen | export bevat alleen het gekozen dossier, ZIP-inhoud is beperkt tot snapshot/tijdlijn/print-HTML, bestandsnaam is geslugified zonder padseparators | `tests/integration/test_applications.py` |
| T-32 | XML external entity/entity-expansion via sitemap | lokale data-uitlezing of parseruitputting | sitemap-XML wordt met `defusedxml` verwerkt; fetcherlimieten blijven vóór parsing gelden | `tests/unit/test_discovery_rss.py`, Ruff securitycheck | | T-32 | XML external entity/entity-expansion via sitemap | lokale data-uitlezing of parseruitputting | sitemap-XML wordt met `defusedxml` verwerkt; fetcherlimieten blijven vóór parsing gelden | `tests/unit/test_discovery_rss.py`, Ruff securitycheck |
| T-33 | Externe UI-CDN of niet-vertrouwde Stitch-interactie | supply-chain-XSS, tracking of acties buiten Django-beleid | productie gebruikt alleen lokale CSS/JS en inline SVG-symbolen; Stitch-code is documentatiereferentie, alle mutaties blijven Django POST+CSRF; themavoorkeur bevat geen persoonsgegevens en opslagfouten worden afgevangen | `tests/e2e/test_vr114_browser_and_accessibility.py`, browserconsole- en assetcontrole VR-118 |
## Misbruikscenario's ## Misbruikscenario's
+4 -3
View File
@@ -39,8 +39,8 @@ Legenda: **I** = geïmplementeerd en getest in de huidige MVP; **P** = gedeeltel
| 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 | viewtests | VR-114 e2e/a11y | | PR-050 | I | dashboard view/template + Stitch-cockpitshell | viewtests, VR-118 browsermatrix | VR-114/118 e2e/a11y |
| PR-051 | I | detailview/template/provenance | viewtests | VR-114 | | PR-051 | I | detailview/template/provenance + intelligence-layout | viewtests, VR-118 screenshots | VR-114/118 |
| 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 |
| PR-054 | I | `notifications/models.py`, outbox/services/tasks | `tests/integration/test_email_import_notifications.py`, `tests/integration/test_reminders.py` | VR-111 | | PR-054 | I | `notifications/models.py`, outbox/services/tasks | `tests/integration/test_email_import_notifications.py`, `tests/integration/test_reminders.py` | VR-111 |
@@ -63,7 +63,7 @@ Legenda: **I** = geïmplementeerd en getest in de huidige MVP; **P** = gedeeltel
| 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 templates/designspec | `tests/e2e/test_vr114_browser_and_accessibility.py` | | NFR-008 | I | semantische templates/designspec, zes viewports, dark/light en toetsenbordmenu | `tests/e2e/test_vr114_browser_and_accessibility.py`, `docs/design/validation/`, VR-118 |
| 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 |
@@ -87,5 +87,6 @@ Iedere backlogtaak die een requirement wijzigt, werkt in dezelfde commit deze ma
- VR-102 is gerealiseerd met auditbare policyreviews (`SourcePolicyReview`), robotscache met TTL/size/SSRF-controles en fail-closed policy-gating op verlopen of conflicterende reviews. - VR-102 is gerealiseerd met auditbare policyreviews (`SourcePolicyReview`), robotscache met TTL/size/SSRF-controles en fail-closed policy-gating op verlopen of conflicterende reviews.
- VR-107 is gerealiseerd met `apps/sources/views.py`, `apps/sources/services/manual_import.py`, `templates/sources/list.html`, `static/js/manual_import.js` en bijhorende unit/integration-tests. - VR-107 is gerealiseerd met `apps/sources/views.py`, `apps/sources/services/manual_import.py`, `templates/sources/list.html`, `static/js/manual_import.js` en bijhorende unit/integration-tests.
- VR-115 is afgerond met productie-hardening voor `config/settings.py`, DNS-rebindmitigatie in `apps/sources/services/fetcher.py`, rate limiting op login en handmatige import, CI-secret- en kwetsbaarheidsscans, non-root/read-only containerhardening en bijbehorende security/unit/integration-tests. - VR-115 is afgerond met productie-hardening voor `config/settings.py`, DNS-rebindmitigatie in `apps/sources/services/fetcher.py`, rate limiting op login en handmatige import, CI-secret- en kwetsbaarheidsscans, non-root/read-only containerhardening en bijbehorende security/unit/integration-tests.
- VR-118 vertaalt de aangeleverde Stitch Intelligence Cockpit naar de bestaande server-rendered routes. Bewijs staat in `docs/design/STITCH_INTEGRATION_PLAN.md`, `docs/design/validation/`, de view/a11ytests en de gedeelde lokale shell/assets.
+312 -233
View File
@@ -1,303 +1,382 @@
:root { :root,
--bg: #f5f7fb; :root[data-theme="dark"] {
--surface: #ffffff; color-scheme: dark;
--surface-2: #eef2f8; --bg: #081120;
--text: #142033; --bg-deep: #050c18;
--muted: #667085; --surface: #111b2e;
--border: #dce3ee; --surface-low: #0c1627;
--primary: #465bd8; --surface-high: #19253a;
--primary-strong: #3446b9; --surface-bright: #243149;
--primary-soft: #edf0ff; --text: #e7efff;
--positive: #087f5b; --text-soft: #c7d3e7;
--positive-soft: #e7f7f1; --muted: #8fa1b8;
--warning: #9a6700; --border: #2b3a50;
--warning-soft: #fff5d6; --border-strong: #42536b;
--danger: #b42318; --primary: #16e5ee;
--danger-soft: #ffebe9; --primary-strong: #7cf8ff;
--shadow: 0 16px 40px rgba(23, 36, 64, .08); --primary-ink: #002f32;
--radius: 16px; --primary-soft: rgba(22, 229, 238, .1);
--radius-sm: 10px; --positive: #4edea3;
--sidebar: 248px; --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: 9px;
--radius-sm: 5px;
--sidebar: 272px;
--utility: 66px;
} }
@media (prefers-color-scheme: dark) { :root[data-theme="light"] {
:root { color-scheme: light;
--bg: #0f1520; --bg: #f3f6fa;
--surface: #171f2d; --bg-deep: #e9eef5;
--surface-2: #202a3a; --surface: #ffffff;
--text: #edf2fa; --surface-low: #f7f9fc;
--muted: #a8b2c4; --surface-high: #edf2f7;
--border: #303b4e; --surface-bright: #e2e9f2;
--primary: #91a0ff; --text: #142033;
--primary-strong: #aab5ff; --text-soft: #2c3c52;
--primary-soft: #252f55; --muted: #617187;
--positive: #5bd3aa; --border: #d7e0eb;
--positive-soft: #153b31; --border-strong: #aebdce;
--warning: #ffd166; --primary: #007f87;
--warning-soft: #443716; --primary-strong: #005e64;
--danger: #ff8c84; --primary-ink: #f4ffff;
--danger-soft: #4b2424; --primary-soft: rgba(0, 127, 135, .09);
--shadow: 0 18px 42px rgba(0, 0, 0, .28); --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; } * { box-sizing: border-box; }
html { min-height: 100%; scroll-behavior: smooth; } html { min-height: 100%; scroll-behavior: smooth; }
body { body {
min-width: 320px;
min-height: 100vh; min-height: 100vh;
margin: 0; margin: 0;
background: var(--bg); 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); color: var(--text);
font: 15px/1.55 Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; 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); text-decoration: none; } a { color: var(--primary-strong); text-decoration: none; }
a:hover { text-decoration: underline; } a:hover { text-decoration: underline; }
button, input, select, textarea { font: inherit; } button, input, select, textarea { font: inherit; }
button { cursor: pointer; } button { cursor: pointer; }
code, pre { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; } code, pre, .eyebrow, .pill, .meta-label, .nav-group-label, .job-meta, .utility-title, .kpi-label {
pre { overflow: auto; border-radius: var(--radius-sm); background: var(--surface-2); padding: 18px; } font-family: ui-monospace, SFMono-Regular, Consolas, "Liberation Mono", monospace;
.skip-link { position: fixed; left: 10px; top: -80px; z-index: 1000; padding: 10px 14px; background: var(--surface); color: var(--text); border-radius: 8px; box-shadow: var(--shadow); }
.skip-link:focus { top: 10px; }
.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; }
.mobile-only { display: none; }
.topbar {
position: sticky;
top: 0;
z-index: 30;
min-height: 70px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 20px;
padding: 10px 24px;
border-bottom: 1px solid var(--border);
background: color-mix(in srgb, var(--surface) 92%, transparent);
backdrop-filter: blur(16px);
} }
.brand-wrap, .brand, .topbar-actions { display: flex; align-items: center; gap: 12px; } pre { overflow: auto; margin: 0; border-radius: var(--radius-sm); background: var(--bg-deep); padding: 18px; border: 1px solid var(--border); }
.brand { color: var(--text); } svg { fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; }
.brand:hover { text-decoration: none; } .icon { width: 20px; height: 20px; flex: 0 0 auto; }
.brand span:last-child { display: grid; line-height: 1.2; } .icon-sprite { position: absolute; width: 0; height: 0; overflow: hidden; }
.brand small { color: var(--muted); font-size: 12px; } .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; }
.brand-mark { .skip-link:focus { top: 12px; }
width: 42px; .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; }
height: 42px; .mobile-only { display: none !important; }
display: inline-grid;
place-items: center; .app-frame { min-height: 100vh; }
border-radius: 12px;
background: linear-gradient(145deg, var(--primary), #7b4fe8);
color: white;
font-weight: 800;
letter-spacing: -.04em;
box-shadow: 0 8px 18px rgba(70, 91, 216, .25);
}
.app-shell { display: grid; grid-template-columns: var(--sidebar) minmax(0, 1fr); min-height: calc(100vh - 70px); }
.sidebar { .sidebar {
position: sticky; position: fixed;
top: 70px; inset: 0 auto 0 0;
height: calc(100vh - 70px); z-index: 60;
width: var(--sidebar);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 6px; padding: 18px 14px;
padding: 24px 14px; background: color-mix(in srgb, var(--bg-deep) 96%, transparent);
background: var(--surface);
border-right: 1px solid var(--border); border-right: 1px solid var(--border);
overflow-y: auto; box-shadow: 12px 0 42px rgba(0, 0, 0, .08);
} }
.nav-link { display: grid; gap: 2px; padding: 11px 14px; border-radius: 11px; color: var(--text); font-weight: 650; } .sidebar-brand { display: flex; align-items: center; justify-content: space-between; padding: 2px 6px 22px; }
.nav-link small { color: var(--muted); font-weight: 400; } .brand { min-width: 0; display: flex; align-items: center; gap: 12px; color: var(--text); }
.nav-link:hover { background: var(--surface-2); text-decoration: none; } .brand:hover { text-decoration: none; }
.nav-link.active { background: var(--primary-soft); color: var(--primary-strong); } .brand > span:last-child { min-width: 0; display: grid; line-height: 1.2; }
.nav-link.active small { color: var(--primary); } .brand strong { font-size: 18px; letter-spacing: -.025em; }
.sidebar-foot { margin-top: auto; display: flex; align-items: center; gap: 7px; padding: 12px; color: var(--muted); font-size: 12px; } .brand small { margin-top: 3px; color: var(--muted); font: 10px/1.2 ui-monospace, SFMono-Regular, Consolas, monospace; letter-spacing: .1em; text-transform: uppercase; }
.sidebar-foot small { margin-left: auto; } .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); }
.status-dot { width: 8px; height: 8px; border-radius: 99px; background: var(--positive); box-shadow: 0 0 0 4px var(--positive-soft); } .brand-mark .icon { width: 25px; height: 25px; stroke-width: 1.6; }
.main-content { width: 100%; max-width: 1480px; padding: 34px clamp(20px, 4vw, 56px) 64px; } .nav-stack { min-height: 0; overflow-y: auto; display: grid; align-content: start; gap: 4px; padding: 4px 0; }
.auth-main { min-height: 100vh; display: grid; place-items: center; padding: 24px; background: radial-gradient(circle at 20% 10%, var(--primary-soft), transparent 36%), var(--bg); } .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: 1600px; margin: 0 auto; padding: 34px clamp(18px, 3.2vw, 52px) 72px; }
.page-header { display: flex; align-items: flex-start; justify-content: space-between; gap: 24px; margin-bottom: 28px; } .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, 4vw, 44px); line-height: 1.08; letter-spacing: -.035em; } .page-header h1, .job-hero h1 { margin: 0; font-size: clamp(30px, 3.7vw, 48px); line-height: 1.06; letter-spacing: -.042em; }
.page-header p { margin: 8px 0 0; color: var(--muted); max-width: 780px; } .page-header p { max-width: 760px; margin: 9px 0 0; color: var(--muted); }
.eyebrow { margin: 0 0 8px !important; color: var(--primary) !important; text-transform: uppercase; letter-spacing: .12em; font-size: 12px; font-weight: 800; } .eyebrow { margin: 0 0 9px !important; color: var(--primary) !important; font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: .13em; }
.section-heading { display: flex; align-items: flex-end; justify-content: space-between; gap: 20px; margin: 34px 0 18px; } .page-actions { display: flex; flex-wrap: wrap; gap: 9px; }
.section-heading h2 { margin: 0; font-size: 23px; letter-spacing: -.02em; } .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-heading p { margin: 5px 0 0; color: var(--muted); }
.title-row { display: flex; align-items: center; gap: 10px; } .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 { .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; }
min-height: 40px;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 8px 14px;
border: 1px solid transparent;
border-radius: 10px;
background: transparent;
color: var(--text);
font-weight: 700;
line-height: 1.2;
text-decoration: none;
}
.button:hover { text-decoration: none; transform: translateY(-1px); } .button:hover { text-decoration: none; transform: translateY(-1px); }
.button:focus-visible, input:focus-visible, select:focus-visible, textarea:focus-visible, a:focus-visible { outline: 3px solid color-mix(in srgb, var(--primary) 38%, transparent); outline-offset: 2px; } .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: white; box-shadow: 0 7px 16px rgba(70,91,216,.2); } .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-primary:hover { background: var(--primary-strong); }
.button-secondary { background: var(--surface); border-color: var(--border); } .button-secondary { background: var(--surface-high); border-color: var(--border); }
.button-secondary:hover, .button-ghost:hover { background: var(--surface-2); } .button-secondary:hover, .button-ghost:hover { background: var(--surface-bright); border-color: var(--border-strong); }
.button-ghost { color: var(--muted); } .button-ghost { border-color: var(--border); color: var(--text-soft); }
.button-link { color: var(--primary); background: var(--primary-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-block { width: 100%; }
.button-small { min-height: 32px; padding: 5px 9px; font-size: 13px; } .button-small { min-height: 34px; padding: 5px 10px; font-size: 13px; }
.icon-button { width: 40px; height: 40px; border: 1px solid var(--border); border-radius: 10px; background: var(--surface); color: var(--text); }
.stats-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 14px; } .stats-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 12px; }
.stat-card, .panel, .job-card, .auth-card { .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); }
background: var(--surface); .stat-card { position: relative; min-height: 126px; display: grid; align-content: center; gap: 2px; padding: 20px; overflow: hidden; }
border: 1px solid var(--border); .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%; }
border-radius: var(--radius); .stat-card span, .stat-card small { position: relative; z-index: 1; color: var(--muted); }
box-shadow: var(--shadow); .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); }
.stat-card { min-height: 130px; display: grid; align-content: center; padding: 22px; } .kpi-label { font-size: 10px; text-transform: uppercase; letter-spacing: .09em; }
.stat-card span, .stat-card small { color: var(--muted); } .panel { padding: clamp(19px, 2.5vw, 30px); margin-bottom: 18px; }
.stat-card strong { margin: 2px 0; font-size: 34px; line-height: 1; letter-spacing: -.04em; } .panel > h2:first-child, .panel > h3:first-child { margin-top: 0; }
.panel { padding: clamp(20px, 3vw, 30px); margin-bottom: 20px; } .panel-subtle { background: var(--surface-low); box-shadow: none; }
.panel > h2:first-child { margin-top: 0; }
.job-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 18px; } .dashboard-grid { display: grid; grid-template-columns: minmax(0, 1fr) 320px; gap: 20px; align-items: start; }
.job-card { padding: 22px; display: flex; flex-direction: column; gap: 16px; } .dashboard-rail { position: sticky; top: calc(var(--utility) + 20px); }
.job-card-head { display: grid; grid-template-columns: auto 1fr; gap: 15px; align-items: flex-start; } .radar-summary { position: relative; overflow: hidden; background: radial-gradient(circle at 88% 10%, var(--primary-soft), transparent 35%), var(--surface); }
.job-card h3 { margin: 7px 0 2px; font-size: 21px; line-height: 1.25; } .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; }
.job-card h3 a { color: var(--text); } .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); } .job-card p { margin: 0; color: var(--muted); }
.score-ring { .score-block { min-width: 72px; display: grid; justify-items: end; }
width: 58px; .score-block strong { color: var(--primary); font-size: 27px; line-height: 1; letter-spacing: -.04em; }
height: 58px; .score-block small { margin-top: 5px; color: var(--muted); font: 9px ui-monospace, SFMono-Regular, Consolas, monospace; text-transform: uppercase; letter-spacing: .06em; }
display: grid; .job-meta { display: flex; flex-wrap: wrap; gap: 7px; color: var(--muted); font-size: 11px; }
place-items: center; .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); }
border-radius: 50%; .job-meta .icon { width: 13px; height: 13px; }
border: 6px solid var(--primary-soft);
box-shadow: inset 0 0 0 2px var(--primary);
color: var(--primary-strong);
font-size: 18px;
font-weight: 850;
}
.job-meta { display: flex; flex-wrap: wrap; gap: 8px; color: var(--muted); font-size: 13px; }
.job-meta span { padding: 5px 8px; border-radius: 7px; background: var(--surface-2); }
.job-actions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: auto; } .job-actions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: auto; }
.pill { display: inline-flex; align-items: center; width: fit-content; padding: 4px 8px; border-radius: 999px; background: var(--surface-2); color: var(--muted); font-size: 12px; font-weight: 750; } .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-strong, .pill-allow { background: var(--positive-soft); color: var(--positive); } .pill::before { content: ""; width: 6px; height: 6px; border-radius: 50%; background: currentColor; }
.pill-possible, .pill-review { background: var(--warning-soft); color: var(--warning); } .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-hidden, .pill-deny { background: var(--danger-soft); color: var(--danger); } .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 { margin: 0; padding-left: 20px; }
.compact-list li + li { margin-top: 5px; } .compact-list li + li { margin-top: 6px; }
.compact-list.positive li::marker { color: var(--positive); } .compact-list.positive li::marker { color: var(--positive); }
.compact-list.caution li::marker { color: var(--warning); } .compact-list.caution li::marker { color: var(--warning); }
.attention { padding: 11px 13px; border-radius: 9px; background: var(--warning-soft); color: var(--warning) !important; font-size: 13px; } .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; gap: 10px; padding: 14px; margin-bottom: 18px; background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); } .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; } .filter-bar .grow { flex: 1; }
input, select, textarea { .field-label { color: var(--muted); font: 10px ui-monospace, SFMono-Regular, Consolas, monospace; text-transform: uppercase; letter-spacing: .07em; }
width: 100%; 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); }
min-height: 42px; input::placeholder, textarea::placeholder { color: color-mix(in srgb, var(--muted) 78%, transparent); }
padding: 9px 11px;
border: 1px solid var(--border);
border-radius: 9px;
background: var(--surface);
color: var(--text);
}
textarea { resize: vertical; } textarea { resize: vertical; }
.table-wrap { overflow-x: auto; border: 1px solid var(--border); border-radius: var(--radius); background: var(--surface); box-shadow: var(--shadow); } .table-wrap { overflow-x: auto; }
.data-table { width: 100%; border-collapse: collapse; min-width: 760px; } .data-table { width: 100%; border-collapse: collapse; min-width: 760px; }
.data-table th { text-align: left; padding: 12px 15px; background: var(--surface-2); color: var(--muted); font-size: 12px; text-transform: uppercase; letter-spacing: .06em; } .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: 15px; border-top: 1px solid var(--border); vertical-align: middle; } .data-table td { padding: 14px 15px; border-top: 1px solid var(--border); vertical-align: middle; }
.data-table td small, .table-title + small { display: block; color: var(--muted); margin-top: 3px; } .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; } .table-title { color: var(--text); font-weight: 760; }
.pagination { display: flex; justify-content: center; gap: 18px; padding: 20px; } .pagination { display: flex; justify-content: center; align-items: center; gap: 18px; padding: 22px; }
.detail-layout { display: grid; grid-template-columns: minmax(0, 1fr) 310px; gap: 22px; align-items: start; } .detail-layout { display: grid; grid-template-columns: minmax(0, 1fr) 320px; gap: 20px; align-items: start; }
.back-link { display: inline-block; margin-bottom: 15px; color: var(--muted); } .back-link { display: inline-flex; align-items: center; gap: 6px; margin-bottom: 15px; color: var(--muted); }
.job-hero { display: flex; justify-content: space-between; gap: 24px; padding: clamp(22px, 4vw, 38px); margin-bottom: 20px; border-radius: var(--radius); background: linear-gradient(140deg, var(--surface), var(--primary-soft)); border: 1px solid var(--border); } .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: 10px 0 14px; color: var(--muted); font-size: 17px; } .hero-subtitle { margin: 11px 0 15px; color: var(--text-soft); font-size: 17px; }
.hero-score { min-width: 132px; height: 132px; display: grid; place-items: center; align-content: center; border-radius: 50%; background: var(--surface); border: 8px solid var(--primary); box-shadow: var(--shadow); } .hero-score { min-width: 136px; display: grid; justify-items: end; align-content: start; }
.hero-score strong { font-size: 36px; line-height: 1; } .hero-score strong { color: var(--primary); font-size: 54px; line-height: .95; letter-spacing: -.06em; }
.hero-score span, .hero-score small { color: var(--muted); font-size: 12px; } .hero-score span { margin-top: 8px; color: var(--primary-strong); font: 10px ui-monospace, SFMono-Regular, Consolas, monospace; text-transform: uppercase; letter-spacing: .08em; }
.sticky-card { position: sticky; top: 92px; padding: 22px; background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); box-shadow: var(--shadow); } .hero-score small { margin-top: 3px; color: var(--muted); }
.sticky-card h2 { margin-top: 0; } .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; } .stack-actions { display: grid; gap: 9px; }
.hide-form { display: grid; gap: 8px; } .hide-form { display: grid; gap: 8px; padding-top: 9px; border-top: 1px solid var(--border); }
.hide-form label { color: var(--muted); font-size: 12px; } .hide-form label { color: var(--muted); font-size: 12px; }
.score-components { display: grid; gap: 12px; margin: 18px 0 24px; } .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 > div { display: grid; grid-template-columns: 120px minmax(120px, 1fr) 52px; gap: 12px; align-items: center; }
.score-components strong { text-align: right; } .score-components strong { text-align: right; }
.progress { height: 9px; overflow: hidden; border-radius: 999px; background: var(--surface-2); } .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: var(--primary); } .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, 1fr); gap: 22px; } .two-columns { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 22px; }
.prose { max-width: 82ch; } .prose { max-width: 82ch; color: var(--text-soft); }
.prose h2, .prose h3, .prose h4 { margin-top: 1.5em; } .prose h2, .prose h3, .prose h4 { margin-top: 1.5em; color: var(--text); }
.prose p, .prose li { color: color-mix(in srgb, var(--text) 88%, var(--muted)); } .definition-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; }
.definition-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; } .definition-grid div { padding: 12px; border: 1px solid var(--border); border-radius: 5px; background: var(--surface-low); }
.definition-grid div { padding: 12px; border-radius: 10px; background: var(--surface-2); } .definition-grid dt { color: var(--muted); font: 10px ui-monospace, SFMono-Regular, Consolas, monospace; text-transform: uppercase; }
.definition-grid dt { color: var(--muted); font-size: 12px; } .definition-grid dd { margin: 4px 0 0; font-weight: 700; }
.definition-grid dd { margin: 3px 0 0; font-weight: 700; }
.source-list { list-style: none; padding: 0; } .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 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; } .source-list span { color: var(--muted); font-size: 12px; }
.card-list { display: grid; gap: 14px; } .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, .application-card { display: flex; justify-content: space-between; align-items: center; gap: 24px; margin: 0; }
.profile-card h2, .application-card h2 { margin: 5px 0; } .profile-card h2, .application-card h2 { margin: 5px 0; }
.form-panel { max-width: 980px; } .form-panel { max-width: 1100px; }
.form-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 18px; } .form-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 17px; }
.form-grid label { display: grid; gap: 6px; color: var(--muted); font-size: 13px; font-weight: 650; } .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; } .form-grid label > input, .form-grid label > select, .form-grid label > textarea { color: var(--text); font-weight: 400; }
.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; } .span-2 { grid-column: 1 / -1; }
.field-error { color: var(--danger); } .field-error { color: var(--danger); }
.form-actions { display: flex; justify-content: flex-end; gap: 10px; margin-top: 24px; } .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-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; } .messages { display: grid; gap: 8px; margin-bottom: 18px; }
.message { padding: 12px 14px; border-radius: 10px; background: var(--primary-soft); color: var(--primary-strong); border: 1px solid color-mix(in srgb, var(--primary) 25%, var(--border)); } .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); color: var(--positive); } .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); color: var(--warning); } .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); color: var(--danger); } .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: 54px 24px; text-align: center; border: 1px dashed var(--border); border-radius: var(--radius); background: var(--surface); } .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 h2 { margin-bottom: 6px; }
.empty-state p { color: var(--muted); max-width: 600px; margin: 0 auto 16px; } .empty-state p { max-width: 600px; margin: 0 auto 16px; color: var(--muted); }
.empty-icon { font-size: 40px; color: var(--primary); } .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); }
.auth-card { width: min(460px, 100%); padding: 32px; } .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-brand { display: flex; align-items: center; gap: 14px; margin-bottom: 24px; }
.auth-card h1 { margin: 0; } .auth-card h1 { margin: 0; }
.auth-card p { margin: 4px 0; color: var(--muted); } .auth-card p { margin: 4px 0; color: var(--muted); }
.stack { display: grid; gap: 16px; } .stack { display: grid; gap: 16px; }
.stack label { display: grid; gap: 6px; font-weight: 650; } .stack label { display: grid; gap: 6px; font-weight: 650; }
.muted { color: var(--muted); }
.small { font-size: 12px; }
@media (max-width: 1100px) { @media (max-width: 1180px) {
.stats-grid { grid-template-columns: repeat(2, 1fr); } .stats-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.job-grid { grid-template-columns: 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; } .detail-layout { grid-template-columns: 1fr; }
.sticky-card { position: static; } .sticky-card { position: static; }
.source-overview { grid-template-columns: repeat(2, minmax(0, 1fr)); }
} }
@media (max-width: 760px) { @media (max-width: 900px) {
.mobile-only { display: inline-grid; place-items: center; } .mobile-only { display: inline-grid !important; }
.topbar { padding: 10px 14px; } .sidebar { visibility: hidden; transform: translateX(-105%); transition: transform .18s ease, visibility 0s linear .18s; }
.brand small, .topbar-actions .button:first-child { display: none; } .workspace { margin-left: 0; }
.app-shell { grid-template-columns: 1fr; } .nav-open .sidebar { visibility: visible; transform: translateX(0); transition-delay: 0s; }
.sidebar { position: fixed; inset: 70px auto 0 0; z-index: 40; width: min(86vw, 300px); height: calc(100vh - 70px); transform: translateX(-105%); transition: transform .2s ease; box-shadow: var(--shadow); } .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); }
.sidebar.open { transform: translateX(0); } .nav-open .nav-scrim { display: block; }
.main-content { padding: 24px 15px 50px; } .utilitybar { padding: 0 16px; }
.page-header, .profile-card, .application-card, .job-hero { flex-direction: column; } .main-content { padding-top: 26px; }
.page-header .button { width: 100%; } .job-grid { grid-template-columns: 1fr; }
.stats-grid, .two-columns, .form-grid, .definition-grid { grid-template-columns: 1fr; } .dashboard-rail { grid-template-columns: 1fr 1fr; }
.filter-bar { flex-direction: column; } .pipeline { grid-template-columns: repeat(6, 245px); }
.span-2 { grid-column: auto; } }
.hero-score { width: 105px; height: 105px; min-width: 105px; }
.score-components > div { grid-template-columns: 94px 1fr 40px; } @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-details-grid, .import-layout, .dashboard-rail { grid-template-columns: 1fr; }
.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-list li { flex-direction: column; gap: 3px; }
.source-card-head { flex-direction: column; }
.source-actions { width: 100%; justify-content: space-between; }
.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) { @media (prefers-reduced-motion: reduce) {
+49 -18
View File
@@ -1,23 +1,54 @@
(() => { (() => {
const toggle = document.querySelector('[data-nav-toggle]'); const root = document.documentElement;
const sidebar = document.querySelector('[data-sidebar]'); let storedTheme = null;
if (!toggle || !sidebar) return; 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 close = () => { const themeToggle = document.querySelector("[data-theme-toggle]");
sidebar.classList.remove('open'); themeToggle?.addEventListener("click", () => {
toggle.setAttribute('aria-expanded', 'false'); const theme = root.dataset.theme === "light" ? "dark" : "light";
}; root.dataset.theme = theme;
try {
toggle.addEventListener('click', () => { localStorage.setItem("vacatureradar-theme", theme);
const open = sidebar.classList.toggle('open'); } catch {
toggle.setAttribute('aria-expanded', String(open)); // The selected theme still applies for the current page.
});
document.addEventListener('keydown', (event) => {
if (event.key === 'Escape') close();
});
document.addEventListener('click', (event) => {
if (sidebar.classList.contains('open') && !sidebar.contains(event.target) && !toggle.contains(event.target)) {
close();
} }
themeToggle.setAttribute("aria-label", theme === "light" ? "Donker thema activeren" : "Licht thema activeren");
});
const frame = document.querySelector("[data-app-frame]");
const sidebar = document.querySelector("[data-sidebar]");
const toggle = document.querySelector("[data-nav-toggle]");
const closeButtons = document.querySelectorAll("[data-nav-close], [data-nav-scrim]");
let returnFocus = null;
const closeNavigation = () => {
frame?.classList.remove("nav-open");
toggle?.setAttribute("aria-expanded", "false");
if (returnFocus instanceof HTMLElement) returnFocus.focus();
};
toggle?.addEventListener("click", () => {
returnFocus = document.activeElement;
frame?.classList.add("nav-open");
toggle.setAttribute("aria-expanded", "true");
sidebar?.querySelector("a, button")?.focus();
});
closeButtons.forEach((button) => button.addEventListener("click", closeNavigation));
document.addEventListener("keydown", (event) => {
if (event.key === "Escape") closeNavigation();
});
document.querySelectorAll("[data-disclosure]").forEach((button) => {
button.addEventListener("click", () => {
const target = document.getElementById(button.getAttribute("aria-controls"));
if (!target) return;
const expanded = button.getAttribute("aria-expanded") === "true";
button.setAttribute("aria-expanded", String(!expanded));
target.hidden = expanded;
});
}); });
})(); })();
+5 -1
View File
@@ -1,3 +1,7 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}Sollicitatiedossier · VacatureRadar{% endblock %} {% block title %}Sollicitatiedossier · VacatureRadar{% endblock %}
{% block content %}<header class="page-header"><div><p class="eyebrow">Sollicitatiedossier</p><h1>{{ object.job.original_title }}</h1><p>{{ object.job.employer_name }}</p></div></header><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">Opslaan</button></div></form>{% endblock %} {% 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>
<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>
<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>
{% endblock %}
+16 -1
View File
@@ -1,3 +1,18 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}Sollicitaties · VacatureRadar{% endblock %} {% block title %}Sollicitaties · VacatureRadar{% endblock %}
{% block content %}<header class="page-header"><div><p class="eyebrow">Persoonlijke opvolging</p><h1>Sollicitaties</h1><p>Vacaturesnapshots blijven bewaard wanneer de bron offline gaat.</p></div></header><div class="card-list">{% for application in applications %}<article class="panel application-card"><div><span class="pill">{{ application.get_status_display }}</span><h2><a href="{% url 'jobs:detail' application.job.pk %}">{{ application.job.original_title }}</a></h2><p>{{ application.job.employer_name }} · {{ application.job.raw_location }}</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><a class="button button-secondary" href="{% url 'jobs:application-edit' application.pk %}">Dossier</a></article>{% empty %}<div class="empty-state"><h2>Nog geen sollicitatiedossiers</h2><p>Markeer een vacature als Gesolliciteerd om automatisch een dossier te maken.</p></div>{% endfor %}</div>{% endblock %} {% block content %}
<header class="page-header"><div><p class="eyebrow">Persoonlijke opvolging</p><h1>Sollicitatiepipeline</h1><p>Vacaturesnapshots blijven beschikbaar wanneer de oorspronkelijke bron offline gaat.</p></div></header>
<section class="pipeline" aria-label="Sollicitaties per status">
{% for column in pipeline_columns %}
<div class="pipeline-column">
<div class="pipeline-head"><h2>{{ column.label }}</h2><span class="pipeline-count">{{ column.applications|length }}</span></div>
<div class="pipeline-cards">
{% for application in column.applications %}
<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</a></div></article>
{% empty %}<div class="pipeline-empty">Geen dossiers in deze fase.</div>{% endfor %}
</div>
</div>
{% endfor %}
</section>
{% if not applications %}<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 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 %}
{% endblock %}
+67 -30
View File
@@ -1,50 +1,87 @@
{% load static %} {% load static %}
<!doctype html> <!doctype html>
<html lang="nl"> <html lang="nl" data-theme="dark">
<head> <head>
<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="light dark"> <meta name="color-scheme" content="dark light">
<meta name="theme-color" content="#07101f">
<title>{% block title %}{{ app_name }}{% endblock %}</title> <title>{% block title %}{{ app_name }}{% endblock %}</title>
<link rel="stylesheet" href="{% static 'css/app.css' %}"> <link rel="stylesheet" href="{% static 'css/app.css' %}?v={{ app_version }}">
<script src="{% static 'js/app.js' %}" defer></script> <script src="{% static 'js/app.js' %}?v={{ app_version }}" defer></script>
</head> </head>
<body> <body>
<a class="skip-link" href="#main">Naar inhoud</a> {% include "components/icon_sprite.html" %}
<a class="skip-link" href="#main">Naar hoofdinhoud</a>
{% if user.is_authenticated %} {% if user.is_authenticated %}
<header class="topbar"> <div class="app-frame" data-app-frame>
<div class="brand-wrap"> <aside class="sidebar" data-sidebar aria-label="Hoofdnavigatie">
<button class="icon-button mobile-only" type="button" data-nav-toggle aria-label="Menu openen" aria-expanded="false"></button> <div class="sidebar-brand">
<a class="brand" href="{% url 'dashboard:today' %}"> <a class="brand" href="{% url 'dashboard:today' %}">
<span class="brand-mark" aria-hidden="true">VR</span> <span class="brand-mark" aria-hidden="true"><svg class="icon"><use href="#icon-radar"></use></svg></span>
<span><strong>VacatureRadar</strong><small>persoonlijke vacature-assistent</small></span> <span><strong>VacatureRadar</strong><small>Intelligence cockpit</small></span>
</a> </a>
</div> <button class="icon-button sidebar-close mobile-only" type="button" data-nav-close aria-label="Menu sluiten">
<div class="topbar-actions"> <svg class="icon"><use href="#icon-close"></use></svg>
<a class="button button-ghost" href="{% url 'dashboard:system' %}">Systeem</a> </button>
<form action="{% url 'logout' %}" method="post">{% csrf_token %}<button class="button button-ghost" type="submit">Afmelden</button></form> </div>
</div> <nav class="nav-stack">
</header> <p class="nav-group-label">Radar</p>
<div class="app-shell"> <a class="nav-link {% if request.resolver_match.namespace == 'dashboard' and request.resolver_match.url_name == 'today' %}active{% endif %}" href="{% url 'dashboard:today' %}">
<nav class="sidebar" data-sidebar aria-label="Hoofdnavigatie"> <svg class="icon"><use href="#icon-dashboard"></use></svg><span>Vandaag<small>beste matches</small></span>
<a class="nav-link {% if request.resolver_match.namespace == 'dashboard' and request.resolver_match.url_name == 'today' %}active{% endif %}" href="{% url 'dashboard:today' %}"><span>Vandaag</span><small>beste matches</small></a> </a>
<a class="nav-link {% if request.resolver_match.namespace == 'jobs' and request.resolver_match.url_name == 'list' %}active{% endif %}" href="{% url 'jobs:list' %}"><span>Vacatures</span><small>zoeken en filteren</small></a> <a class="nav-link {% if request.resolver_match.namespace == 'jobs' and request.resolver_match.url_name == 'list' %}active{% endif %}" href="{% url 'jobs:list' %}">
<a class="nav-link {% if request.resolver_match.url_name == 'applications' %}active{% endif %}" href="{% url 'jobs:applications' %}"><span>Sollicitaties</span><small>opvolging</small></a> <svg class="icon"><use href="#icon-search"></use></svg><span>Vacatures<small>zoeken en filteren</small></span>
<a class="nav-link {% if request.resolver_match.namespace == 'profiles' %}active{% endif %}" href="{% url 'profiles:list' %}"><span>Profiel</span><small>regels en voorkeuren</small></a> </a>
<a class="nav-link {% if request.resolver_match.namespace == 'sources' %}active{% endif %}" href="{% url 'sources:list' %}"><span>Brongezondheid</span><small>geavanceerd</small></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' %}">
<div class="sidebar-foot"><span class="status-dot"></span> strict mode <small>v{{ app_version }}</small></div> <svg class="icon"><use href="#icon-pipeline"></use></svg><span>Sollicitaties<small>persoonlijke pipeline</small></span>
</nav> </a>
<main id="main" class="main-content" tabindex="-1"> <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>
</aside>
<button class="nav-scrim" type="button" data-nav-scrim aria-label="Menu sluiten" tabindex="-1"></button>
<div class="workspace">
<header class="utilitybar">
<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>
<div class="utility-title"><span class="signal-dot" aria-hidden="true"></span><span>Persoonlijke radar</span></div>
</div>
<div class="utility-actions">
<a class="utility-link" href="{% url 'jobs:list' %}" aria-label="Vacatures zoeken"><svg class="icon"><use href="#icon-search"></use></svg><span class="desktop-label">Zoeken</span></a>
<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>
<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-main" tabindex="-1">
{% endif %} {% endif %}
{% if messages %} {% if messages %}
<div class="messages" aria-live="polite"> <div class="messages" aria-live="polite" aria-atomic="true">
{% for message in messages %}<div class="message {{ message.tags }}">{{ message }}</div>{% endfor %} {% for message in messages %}<div class="message {{ message.tags }}">{{ message }}</div>{% endfor %}
</div> </div>
{% endif %} {% endif %}
{% block content %}{% endblock %} {% block content %}{% endblock %}
</main> </main>
{% if user.is_authenticated %}</div>{% endif %} {% if user.is_authenticated %}</div></div>{% endif %}
</body> </body>
</html> </html>
+24
View File
@@ -0,0 +1,24 @@
<svg class="icon-sprite" aria-hidden="true" focusable="false">
<symbol id="icon-radar" viewBox="0 0 24 24"><circle cx="12" cy="12" r="9"/><circle cx="12" cy="12" r="5"/><path d="M12 12 19 5M12 3v2M3 12h2M12 19v2M19 12h2"/></symbol>
<symbol id="icon-dashboard" viewBox="0 0 24 24"><rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="7" rx="1"/><rect x="3" y="14" width="7" height="7" rx="1"/><rect x="14" y="14" width="7" height="7" rx="1"/></symbol>
<symbol id="icon-search" viewBox="0 0 24 24"><circle cx="10.5" cy="10.5" r="6.5"/><path d="m15.5 15.5 5 5"/></symbol>
<symbol id="icon-pipeline" viewBox="0 0 24 24"><path d="M5 5h14M5 12h9M5 19h5"/><circle cx="19" cy="12" r="2"/><circle cx="15" cy="19" r="2"/></symbol>
<symbol id="icon-profile" viewBox="0 0 24 24"><circle cx="12" cy="8" r="4"/><path d="M4 21c.8-4.5 3.5-7 8-7s7.2 2.5 8 7"/></symbol>
<symbol id="icon-sources" viewBox="0 0 24 24"><circle cx="5" cy="12" r="2"/><circle cx="19" cy="5" r="2"/><circle cx="19" cy="19" r="2"/><path d="m7 11 10-5M7 13l10 5"/></symbol>
<symbol id="icon-system" viewBox="0 0 24 24"><path d="M4 19v-7M10 19V5M16 19v-9M22 19V2"/></symbol>
<symbol id="icon-menu" viewBox="0 0 24 24"><path d="M4 7h16M4 12h16M4 17h16"/></symbol>
<symbol id="icon-close" viewBox="0 0 24 24"><path d="m5 5 14 14M19 5 5 19"/></symbol>
<symbol id="icon-sun" viewBox="0 0 24 24"><circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4"/></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-arrow" viewBox="0 0 24 24"><path d="M5 12h14M14 7l5 5-5 5"/></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-clock" viewBox="0 0 24 24"><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/></symbol>
<symbol id="icon-check" viewBox="0 0 24 24"><path d="m5 12 4 4L19 6"/></symbol>
<symbol id="icon-alert" viewBox="0 0 24 24"><path d="M12 3 2.5 20h19L12 3Z"/><path d="M12 9v5M12 17h.01"/></symbol>
<symbol id="icon-bookmark" viewBox="0 0 24 24"><path d="M6 3h12v18l-6-4-6 4V3Z"/></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-upload" viewBox="0 0 24 24"><path d="M12 16V4M7 9l5-5 5 5M4 20h16"/></symbol>
</svg>

After

Width:  |  Height:  |  Size: 2.8 KiB

+34 -22
View File
@@ -2,31 +2,43 @@
{% block title %}Vandaag · VacatureRadar{% endblock %} {% block title %}Vandaag · VacatureRadar{% endblock %}
{% block content %} {% block content %}
<header class="page-header"> <header class="page-header">
<div><p class="eyebrow">Dagelijkse selectie</p><h1>Vandaag</h1><p>Alleen nieuwe en relevante vacatures die aandacht verdienen.</p></div> <div><p class="eyebrow">Intelligence cockpit</p><h1>Goedemorgen, {{ user.first_name|default:user.username }}</h1><p>Je persoonlijke radar brengt alleen actuele signalen en concrete volgende stappen samen.</p></div>
<a class="button button-secondary" href="{% url 'jobs:list' %}">Alle vacatures</a> <div class="page-actions"><a class="button button-primary" href="{% url 'jobs:list' %}"><svg class="icon"><use href="#icon-search"></use></svg>Vacatures verkennen</a></div>
</header> </header>
<section class="stats-grid" aria-label="Kerncijfers"> <section class="stats-grid" aria-label="Kerncijfers">
<article class="stat-card"><span>Nieuwe vacatures</span><strong>{{ new_today }}</strong><small>vandaag gevonden</small></article> <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"><span>Actief</span><strong>{{ active_jobs }}</strong><small>actuele vacatures</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>Sollicitaties</span><strong>{{ applications_open }}</strong><small>open dossiers</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>Bronnen</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">Bronstatus</span><strong>{{ source_counts.total|default:0 }}</strong><small>{{ source_counts.unhealthy|default:0 }} vragen aandacht</small></article>
</section> </section>
<section class="section-heading"><div><h2>Beste matches</h2><p>Gerangschikt op persoonlijke matchscore; confidence blijft afzonderlijk zichtbaar.</p></div></section>
<div class="job-grid"> <div class="dashboard-grid">
{% for score in score_cards %} <section>
<article class="job-card"> <div class="section-heading"><div><span class="section-kicker">Gerangschikt voor jou</span><h2>Beste matches</h2><p>Matchscore en dataconfidence blijven bewust afzonderlijk zichtbaar.</p></div><a href="{% url 'jobs:list' %}">Alles bekijken →</a></div>
<div class="job-card-head"><span class="score-ring">{{ score.score|floatformat:0 }}</span><div><span class="pill pill-{{ score.recommendation }}">{{ score.get_recommendation_display }}</span><h3><a href="{% url 'jobs:detail' score.job.pk %}">{{ score.job.original_title }}</a></h3><p>{{ score.job.employer_name }}</p></div></div> <div class="job-grid">
<div class="job-meta"><span>{{ score.job.raw_location|default:"Locatie onbekend" }}</span><span>{{ score.job.get_workplace_type_display }}</span><span>confidence {{ score.confidence|floatformat:2 }}</span></div> {% for score in score_cards %}
{% if score.positives %}<ul class="compact-list positive">{% for item in score.positives|slice:":2" %}<li>{{ item }}</li>{% endfor %}</ul>{% endif %} <article class="job-card {% if score.recommendation == 'strong' %}strong-match{% endif %}">
{% if score.concerns %}<p class="attention"><strong>Aandachtspunt:</strong> {{ score.concerns.0 }}</p>{% endif %} <div class="job-card-head">
<div class="job-actions"> <div><span class="pill pill-{{ score.recommendation }}">{{ score.get_recommendation_display }}</span><h3><a href="{% url 'jobs:detail' score.job.pk %}">{{ score.job.original_title }}</a></h3><p>{{ score.job.employer_name }}</p></div>
<form method="post" action="{% url 'jobs:feedback' score.job.pk %}">{% csrf_token %}<input type="hidden" name="action" value="interesting"><input type="hidden" name="next" value="{{ request.path }}"><button class="button button-primary" type="submit">Interessant</button></form> <div class="score-block"><strong>{{ score.score|floatformat:0 }}%</strong><small>confidence {{ score.confidence|floatformat:2 }}</small></div>
<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-secondary" type="submit">Bewaren</button></form> </div>
<a class="button button-ghost" href="{% url 'jobs:detail' score.job.pk %}">Details</a> <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="compact-list positive">{% 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 class="job-actions">
<form method="post" action="{% url 'jobs:feedback' score.job.pk %}">{% csrf_token %}<input type="hidden" name="action" value="interesting"><input type="hidden" name="next" value="{{ request.path }}"><button class="button button-primary" type="submit">Interessant</button></form>
<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-secondary" type="submit"><svg class="icon"><use href="#icon-bookmark"></use></svg>Bewaren</button></form>
<a class="button button-ghost" href="{% url 'jobs:detail' score.job.pk %}">Analyse <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-radar"></use></svg></div><h2>Nog geen beoordeelde vacatures</h2><p>Importeer vacaturedata of activeer een toegestane bron. De eerste betrouwbare resultaten verschijnen hier automatisch.</p><a class="button button-secondary" href="{% url 'sources:list' %}">Bronnen beheren</a></div>
{% endfor %}
</div> </div>
</article> </section>
{% empty %} <aside class="dashboard-rail" aria-label="Radarstatus">
<div class="empty-state"><div class="empty-icon"></div><h2>Nog geen beoordeelde vacatures</h2><p>Importeer de meegeleverde fixture of activeer een toegestane bron. De eerste resultaten verschijnen hier automatisch.</p><code>make demo</code></div> <section class="panel radar-summary"><span class="section-kicker">Vandaag</span><h2>Radarstatus</h2><p class="muted">Alle cijfers komen rechtstreeks uit de huidige database. Geen gesimuleerde trends of workerdata.</p><div class="rail-list"><div class="rail-item"><span>Bronnen actief</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></section>
{% endfor %} <section class="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><a class="button button-ghost button-block" href="{% url 'jobs:applications' %}"><svg class="icon"><use href="#icon-pipeline"></use></svg>Pipeline openen</a></div></section>
</aside>
</div> </div>
{% endblock %} {% endblock %}
+11 -6
View File
@@ -1,14 +1,19 @@
{% 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>
<div class="detail-layout"> <div class="detail-layout">
<div class="detail-main"> <div class="detail-main">
<a class="back-link" href="{% url 'jobs:list' %}">← Terug naar vacatures</a> <header class="job-hero">
<header class="job-hero"><div><p class="eyebrow">{{ job.get_status_display }}</p><h1>{{ job.original_title }}</h1><p class="hero-subtitle">{{ job.employer_name }} · {{ job.raw_location|default:"Locatie onbekend" }}</p><div class="job-meta"><span>{{ job.get_workplace_type_display }}</span>{% for type in job.employment_types %}<span>{{ type }}</span>{% endfor %}{% if job.valid_through %}<span>Sluit {{ job.valid_through|date:"d/m/Y" }}</span>{% endif %}</div></div>{% if score %}<div class="hero-score"><strong>{{ score.score|floatformat:0 }}</strong><span>matchscore</span><small>confidence {{ score.confidence|floatformat:2 }}</small></div>{% endif %}</header> <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>
{% if score %}<section class="panel"><div class="section-heading"><div><h2>Waarom deze score?</h2><p>De inhoudelijke score en de betrouwbaarheid worden apart behandeld.</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"><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>{% endif %} {% 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 %}
<section class="panel"><h2>Vacatureomschrijving</h2>{% if job.description_html_sanitized %}<div class="prose">{{ job.description_html_sanitized|safe }}</div>{% else %}<div class="prose"><p>{{ job.description_text|linebreaksbr }}</p></div>{% endif %}</section> </header>
<section class="panel"><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" }}</a><span>{{ alias.extraction_method }} · confidence {{ alias.extraction_confidence|floatformat:2 }}</span></li>{% empty %}<li>Geen bronalias beschikbaar.</li>{% endfor %}</ul></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>
{% 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>
<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>
</div> </div>
<aside class="detail-sidebar"><div class="sticky-card"><h2>Volgende stap</h2><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">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">Gesolliciteerd</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">Open originele vacature </a>{% endif %}{% if application %}<hr><p class="muted">Dossierstatus: <strong>{{ application.get_status_display }}</strong></p><a href="{% url 'jobs:application-edit' application.pk %}">Dossier bewerken</a>{% endif %}</div></aside> <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>
</div> </div>
{% endblock %} {% endblock %}
+17 -10
View File
@@ -1,16 +1,23 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}Vacatures · VacatureRadar{% endblock %} {% block title %}Vacatures · VacatureRadar{% endblock %}
{% block content %} {% block content %}
<header class="page-header"><div><p class="eyebrow">Alle resultaten</p><h1>Vacatures</h1><p>Doorzoek actieve en gearchiveerde vacatureclusters.</p></div></header> <header class="page-header"><div><p class="eyebrow">Nieuwe matches & archief</p><h1>Vacature-intelligentie</h1><p>Doorzoek actuele en gearchiveerde vacatures zonder de herkomst of status uit het oog te verliezen.</p></div></header>
<form method="get" class="filter-bar"> <form method="get" class="filter-bar">
<label class="grow"><span class="sr-only">Zoeken</span><input type="search" name="q" value="{{ request.GET.q }}" placeholder="Titel, werkgever, locatie of skill"></label> <label class="grow"><span class="field-label">Zoeken</span><input type="search" name="q" value="{{ request.GET.q }}" placeholder="Titel, werkgever, locatie of skill"></label>
<label><span class="sr-only">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">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="sr-only">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-secondary" type="submit">Filter</button> <button class="button button-primary" type="submit"><svg class="icon"><use href="#icon-search"></use></svg>Filter</button>
</form> </form>
<div class="table-wrap"><table class="data-table"><thead><tr><th>Vacature</th><th>Locatie</th><th>Werkmodel</th><th>Status</th><th>Gevonden</th></tr></thead><tbody> <div class="section-heading"><div><span class="section-kicker">Resultaten</span><h2>{{ page_obj.paginator.count|default:jobs|length }} vacatures</h2></div></div>
{% for job in jobs %}<tr><td><a class="table-title" href="{% url 'jobs:detail' job.pk %}">{{ job.original_title }}</a><small>{{ job.employer_name }}</small></td><td>{{ job.raw_location|default:"—" }}</td><td>{{ job.get_workplace_type_display }}</td><td><span class="pill">{{ job.get_status_display }}</span></td><td>{{ job.first_seen|date:"d/m/Y" }}</td></tr> <div class="job-grid">
{% empty %}<tr><td colspan="5">Geen vacatures gevonden voor deze filters.</td></tr>{% endfor %} {% for job in jobs %}
</tbody></table></div> <article class="job-card">
{% if is_paginated %}<nav class="pagination" aria-label="Paginering">{% if page_obj.has_previous %}<a href="?page={{ page_obj.previous_page_number }}&q={{ request.GET.q }}">Vorige</a>{% endif %}<span>Pagina {{ page_obj.number }} van {{ page_obj.paginator.num_pages }}</span>{% if page_obj.has_next %}<a href="?page={{ page_obj.next_page_number }}&q={{ request.GET.q }}">Volgende</a>{% endif %}</nav>{% endif %} <div class="job-card-head"><div><span class="pill pill-{{ job.status }}">{{ job.get_status_display }}</span><h2><a href="{% url 'jobs:detail' job.pk %}">{{ job.original_title }}</a></h2><p>{{ job.employer_name }}</p></div></div>
<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></div>
{% if job.skills_required %}<p class="muted small">Vereiste skills: {{ job.skills_required|slice:":3"|join:", " }}</p>{% endif %}
<div class="job-actions"><a class="button button-secondary" href="{% url 'jobs:detail' job.pk %}">Bekijk analyse <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-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 %}
</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 %}
+9 -1
View File
@@ -1,3 +1,11 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}{{ object.name }} bewerken · VacatureRadar{% endblock %} {% block title %}{{ object.name }} bewerken · VacatureRadar{% endblock %}
{% block content %}<header class="page-header"><div><p class="eyebrow">Zoekprofiel versie {{ object.version }}</p><h1>{{ object.name }} bewerken</h1><p>Wijzig alleen persoonlijke keuzes; bronlogica blijft automatisch.</p></div></header><form method="post" class="panel form-panel">{% csrf_token %}{% 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|slice:'-5:' == '_text' %}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 'profiles:list' %}">Annuleren</a><button class="button button-primary" type="submit">Profiel opslaan</button></div></form>{% endblock %} {% block content %}
<header class="page-header"><div><p class="eyebrow">Zoekprofiel · versie {{ object.version }}</p><h1>{{ object.name }} bewerken</h1><p>Je voorkeuren sturen ranking en uitsluiting; bronbeleid en veiligheidsregels blijven automatisch bewaakt.</p></div><span class="pill {% if object.is_active %}pill-active{% endif %}">{% if object.is_active %}Actief profiel{% else %}Inactief profiel{% endif %}</span></header>
<form method="post" class="panel form-panel">{% csrf_token %}{% if form.non_field_errors %}<div class="message error">{{ form.non_field_errors }}</div>{% endif %}<div class="form-grid">
{% for field in form %}
{% if field.name == 'name' %}<div class="form-section"><h2>Profielbasis</h2><p>Naam, status en thuislocatie voor afstandsbepaling.</p></div>{% elif field.name == 'desired_titles_text' %}<div class="form-section"><h2>Functies en vaardigheden</h2><p>Eén titel of skill per regel; uitsluitingen hebben voorrang op voorkeuren.</p></div>{% elif field.name == 'allowed_employment_types' %}<div class="form-section"><h2>Werkvorm en regio</h2><p>Lijstvelden gebruiken JSON-notatie, bijvoorbeeld ["hybrid", "on_site"].</p></div>{% elif field.name == 'recommendation_threshold' %}<div class="form-section"><h2>Scoring en automatisering</h2><p>Topmatch moet minstens gelijk zijn aan de aanbevelingsdrempel.</p></div>{% endif %}
<label class="{% if field.name|slice:'-5:' == '_text' %}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 'profiles:list' %}">Annuleren</a><button class="button button-primary" type="submit">Profiel opslaan</button></div></form>
{% endblock %}
+4 -1
View File
@@ -1,3 +1,6 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}Zoekprofielen · VacatureRadar{% endblock %} {% block title %}Zoekprofielen · VacatureRadar{% endblock %}
{% block content %}<header class="page-header"><div><p class="eyebrow">Persoonlijke regels</p><h1>Zoekprofielen</h1><p>Harde grenzen veranderen nooit automatisch; zachte gewichten kunnen gecontroleerd leren.</p></div></header><div class="card-list">{% for profile in profiles %}<article class="panel profile-card"><div><div class="title-row"><h2>{{ profile.name }}</h2>{% if profile.is_active %}<span class="pill pill-strong">Actief</span>{% endif %}</div><p>{{ profile.desired_titles|join:", "|default:"Nog geen functietitels" }}</p><div class="job-meta"><span>max. {{ profile.max_distance_km }} km</span><span>digest {{ profile.digest_time|time:"H:i" }}</span><span>versie {{ profile.version }}</span></div></div><div class="job-actions"><a class="button button-secondary" href="{% url 'profiles:edit' profile.pk %}">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"><h2>Geen profiel</h2><p>Voer de bootstrapopdracht uit om het standaardprofiel aan te maken.</p></div>{% endfor %}</div>{% endblock %} {% block content %}
<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>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 %}
+3 -7
View File
@@ -2,13 +2,9 @@
{% block title %}Aanmelden · VacatureRadar{% endblock %} {% block title %}Aanmelden · VacatureRadar{% endblock %}
{% block content %} {% block content %}
<section class="auth-card"> <section class="auth-card">
<div class="auth-brand"><span class="brand-mark">VR</span><div><h1>Welkom terug</h1><p>Open je persoonlijke vacatureselectie.</p></div></div> <div class="auth-brand"><span class="brand-mark"><svg class="icon"><use href="#icon-radar"></use></svg></span><div><p class="eyebrow">Intelligence cockpit</p><h1>Welkom terug</h1><p>Open je persoonlijke vacatureselectie.</p></div></div>
{% if form.errors %}<div class="message error">Gebruikersnaam of wachtwoord klopt niet.</div>{% endif %} {% if form.errors %}<div class="message error">Gebruikersnaam of wachtwoord klopt niet.</div>{% endif %}
<form method="post" class="stack">{% csrf_token %} <form method="post" class="stack">{% csrf_token %}<label>Gebruikersnaam {{ form.username }}</label><label>Wachtwoord {{ form.password }}</label><button class="button button-primary button-block" type="submit">Aanmelden</button></form>
<label>Gebruikersnaam {{ form.username }}</label> <p class="muted small">Beveiligde lokale toegang via je eigen netwerk, VPN of reverse proxy.</p>
<label>Wachtwoord {{ form.password }}</label>
<button class="button button-primary button-block" type="submit">Aanmelden</button>
</form>
<p class="muted small">De applicatie is ontworpen voor lokaal gebruik via LAN of VPN.</p>
</section> </section>
{% endblock %} {% endblock %}
+41 -496
View File
@@ -1,499 +1,44 @@
{% extends "base.html" %}
{% load static %} {% load static %}
<!doctype html> {% block title %}Bronnen · VacatureRadar{% endblock %}
<html class="dark" lang="nl"> {% block content %}
<head> <header class="page-header"><div><p class="eyebrow">Bronbeheer & gezondheid</p><h1>Bronnen</h1><p>Beheer scan-endpoints, policyreviews en handmatige import binnen dezelfde SSRF- en bronregels.</p></div><a class="button button-ghost" href="{% url 'dashboard:system' %}"><svg class="icon"><use href="#icon-system"></use></svg>Systeemstatus</a></header>
<meta charset="utf-8" />
<meta content="width=device-width, initial-scale=1.0" name="viewport" /> <section class="source-overview" aria-label="Bronoverzicht">
<title>VacatureRadar - Bronnen & Source Health</title> <article class="stat-card signal"><span class="kpi-label">Actieve bronnen</span><strong>{{ active_source_count }}</strong><small>van {{ source_total_count }} geregistreerd</small></article>
<script src="https://cdn.tailwindcss.com?plugins=forms,container-queries"></script> <article class="stat-card"><span class="kpi-label">Policyreview</span><strong>{{ review_pending_count }}</strong><small>bronnen te beoordelen</small></article>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&family=Geist:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet" /> <article class="stat-card"><span class="kpi-label">Veilige modus</span><strong>AAN</strong><small>policy + SSRF-validatie</small></article>
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&display=swap" rel="stylesheet" /> </section>
<script id="tailwind-config">
tailwind.config = { <section class="panel">
darkMode: "class", <div class="section-heading"><div><span class="section-kicker">Gecontroleerde invoer</span><h2>Handmatige import</h2><p>Gebruik een publieke URL of plak vacaturetekst. URL-import passeert altijd het bronbeleid.</p></div></div>
theme: { <form id="manual-import-form" method="post" action="{% url 'sources:manual_import' %}" data-paste-limit="{{ manual_import_paste_max_bytes }}">{% csrf_token %}
extend: { <div class="import-layout">
colors: { <label><span class="field-label">{{ manual_import_form.source_url.label }}</span>{{ manual_import_form.source_url }}{% for error in manual_import_form.source_url.errors %}<small class="field-error">{{ error }}</small>{% endfor %}<small class="muted">Laat leeg wanneer je tekst plakt.</small></label>
"on-secondary-fixed-variant": "#005236", <label><span class="field-label">{{ manual_import_form.pasted_text.label }}</span>{{ manual_import_form.pasted_text }}<small class="muted">Nog maximaal <span id="manual-import-byte-count">{{ manual_import_paste_max_bytes }}</span> bytes.</small>{% for error in manual_import_form.pasted_text.errors %}<small class="field-error">{{ error }}</small>{% endfor %}</label>
"secondary-fixed": "#6ffbbe",
"on-error-container": "#ffdad6",
"surface": "#0b1326",
"surface-dim": "#0b1326",
"on-primary": "#003739",
"on-surface": "#dae2fd",
"inverse-surface": "#dae2fd",
"background": "#0b1326",
"tertiary-fixed": "#e1e0ff",
"surface-container": "#171f33",
"primary-container": "#00f5ff",
"surface-container-lowest": "#060e20",
"on-tertiary-fixed-variant": "#2f2ebe",
"on-tertiary": "#1000a9",
"on-secondary": "#003824",
"surface-container-highest": "#2d3449",
"on-primary-fixed-variant": "#004f53",
"on-secondary-container": "#00311f",
"on-error": "#690005",
"tertiary": "#fcf8ff",
"on-secondary-fixed": "#002113",
"on-primary-fixed": "#002021",
"surface-variant": "#2d3449",
"inverse-primary": "#00696e",
"surface-bright": "#31394d",
"secondary": "#4edea3",
"primary-fixed": "#63f7ff",
"outline": "#849495",
"surface-container-high": "#222a3d",
"primary": "#e9feff",
"secondary-fixed-dim": "#4edea3",
"on-background": "#dae2fd",
"on-surface-variant": "#b9caca",
"error": "#ffb4ab",
"secondary-container": "#00a572",
"tertiary-fixed-dim": "#c0c1ff",
"on-tertiary-container": "#4c4ed9",
"surface-container-low": "#131b2e",
"outline-variant": "#3a494a",
"inverse-on-surface": "#283044",
"on-primary-container": "#006c71",
"primary-fixed-dim": "#00dce5",
"error-container": "#93000a",
"tertiary-container": "#dbdaff",
"surface-tint": "#00dce5",
"on-tertiary-fixed": "#07006c"
},
borderRadius: {
DEFAULT: "0.125rem",
lg: "0.25rem",
xl: "0.5rem",
full: "0.75rem"
},
spacing: {
"stack-md": "16px",
"container-max": "1280px",
"gutter": "24px",
"stack-sm": "8px",
"margin-mobile": "16px",
"stack-lg": "32px"
},
fontFamily: {
"headline-lg": ["Geist"],
"headline-md": ["Geist"],
"body-md": ["Inter"],
"body-sm": ["Inter"],
"label-mono": ["JetBrains Mono"],
"metadata-caps": ["JetBrains Mono"]
}
}
}
}
</script>
<style>
body {
background-color: #0b1326;
color: #dae2fd;
}
.material-symbols-outlined {
font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;
}
.signal-glow-primary {
box-shadow: 0 0 15px rgba(0, 245, 255, 0.1);
border: 1px solid rgba(0, 245, 255, 0.2);
}
.glass-card {
background: rgba(23, 31, 51, 0.6);
backdrop-filter: blur(12px);
border: 1px solid rgba(58, 73, 74, 0.5);
}
.technical-log {
scrollbar-width: thin;
scrollbar-color: #3a494a #171f33;
}
@keyframes pulse-cyan {
0% { opacity: 1; transform: scale(1); }
50% { opacity: 0.5; transform: scale(1.2); }
100% { opacity: 1; transform: scale(1); }
}
.status-pulse {
animation: pulse-cyan 2s infinite ease-in-out;
}
</style>
</head>
<body class="font-body-md text-body-md overflow-x-hidden">
<header class="fixed top-0 w-full z-50 bg-surface/80 backdrop-blur-xl border-b border-outline-variant/30 shadow-sm hidden md:block">
<div class="flex justify-between items-center px-gutter py-stack-sm w-full max-w-container-max mx-auto">
<div class="flex items-center gap-8">
<span class="font-headline-md text-headline-md font-bold tracking-tight text-primary">VacatureRadar</span>
<nav class="flex gap-6">
<a class="text-on-surface-variant hover:bg-surface-container-highest/50 px-3 py-1 rounded transition-colors font-medium" href="#">Overzicht</a>
<a class="text-primary font-bold px-3 py-1 rounded" href="#">Bronnen</a>
<a class="text-on-surface-variant hover:bg-surface-container-highest/50 px-3 py-1 rounded transition-colors font-medium" href="#">Instellingen</a>
</nav>
</div>
<div class="flex items-center gap-4">
<span class="material-symbols-outlined text-on-surface-variant cursor-pointer hover:text-primary transition-colors">notifications</span>
<span class="material-symbols-outlined text-on-surface-variant cursor-pointer hover:text-primary transition-colors">account_circle</span>
</div>
</div> </div>
</header> {% for error in manual_import_form.non_field_errors %}<div class="message error">{{ error }}</div>{% endfor %}
<aside class="h-screen w-64 fixed left-0 top-0 bg-surface-container border-r border-outline-variant/20 hidden md:flex flex-col py-stack-lg gap-stack-md z-40"> <div class="job-actions"><button class="button button-primary" type="submit"><svg class="icon"><use href="#icon-upload"></use></svg>Import uitvoeren</button><button id="copy-bookmarklet" class="button button-ghost" type="button">Bookmarklet kopiëren</button><a id="manual-bookmarklet" class="button button-link" href="#" data-manual-import-endpoint="{{ manual_import_bookmarklet_endpoint }}">Bookmarklet installeren</a></div>
<div class="px-6 pb-6"> </form>
<h1 class="font-headline-md text-headline-md text-primary font-bold">VacatureRadar</h1> <textarea id="manual-bookmarklet-code" rows="2" readonly class="sr-only" aria-label="Bookmarklet endpoint">{{ manual_import_bookmarklet_endpoint }}</textarea>
<p class="font-label-mono text-label-mono text-on-surface-variant opacity-60">Intelligent Discovery</p> </section>
{% if manual_import_result %}<section class="panel"><span class="pill pill-success">Importresultaat</span><h2>Importresultaat</h2><p><strong>Bron:</strong> {{ manual_import_result.source_name }} ({{ manual_import_result.source_id }})</p><dl class="definition-grid"><div><dt>Modus</dt><dd>Modus: {{ manual_import_result.mode }}</dd></div><div><dt>Herkenbare items</dt><dd>Herkenbare items: {{ manual_import_result.extracted_count }}</dd></div><div><dt>Nieuwe vacatures</dt><dd>Nieuwe vacatures: {{ manual_import_result.created_count }}</dd></div><div><dt>Duplicaten</dt><dd>Duplicaten: {{ manual_import_result.duplicate_count }}</dd></div></dl>{% if manual_import_result.warnings %}<h3>Waarschuwingen</h3><ul class="compact-list caution">{% for warning in manual_import_result.warnings %}<li>{{ warning }}</li>{% endfor %}</ul>{% endif %}{% if manual_import_result.jobs %}<h3>Geïmporteerde vacatures</h3><ul class="source-list">{% for job in manual_import_result.jobs %}<li>{% if job.id %}<a href="{% url 'jobs:detail' job.id %}">{{ job.title }}</a>{% else %}{{ job.title }}{% endif %}<span>{{ job.employer }}</span></li>{% endfor %}</ul>{% endif %}</section>{% endif %}
<form id="bulk-action-form" method="post" action="{% url 'sources:bulk' %}" class="filter-bar">{% csrf_token %}<label><span class="field-label">Bulkactie voor selectie</span><select id="action" name="action"><option value="approve_to_trial">Kandidaten naar proefrun</option><option value="allow">Toestaan</option><option value="pause">Pauzeren</option><option value="dismiss">Afwijzen</option></select></label><button class="button button-secondary" type="submit">Uitvoeren</button></form>
<div class="section-heading"><div><span class="section-kicker">Endpoints</span><h2>{{ source_total_count }} geregistreerde bronnen</h2><p>Status is gebaseerd op bronbeleid en de meest recente run, indien beschikbaar.</p></div></div>
<div class="card-list">
{% for source in sources %}{% with latest_run=source.runs.all|first %}
<article class="source-card">
<div class="source-card-head">
<div class="source-identity"><span class="source-icon"><svg class="icon"><use href="#icon-sources"></use></svg></span><div><div class="title-row">{% if source.policy != 'deny' %}<input type="checkbox" name="source_ids" form="bulk-action-form" value="{{ source.pk }}" aria-label="Selecteer {{ source.name }}">{% endif %}<h2>{{ source.name }}</h2></div><p>{{ source.get_source_type_display }} · {{ source.domain|default:"Geen domein" }}</p><div class="job-meta"><span>{{ source.get_policy_display }}</span>{% if latest_run %}<span>laatste run {{ latest_run.started_at|date:"d/m/Y H:i" }}</span><span>{{ latest_run.extracted_count }} gevonden</span>{% else %}<span>nog niet gestart</span>{% endif %}</div></div></div>
<div class="source-actions"><span class="pill {% if source.policy == 'deny' or source.status == 'quarantined' or latest_run and latest_run.status == 'failed' %}pill-failed{% elif latest_run and latest_run.status == 'partial' %}pill-partial{% elif latest_run and latest_run.status == 'success' %}pill-success{% else %}pill-review{% endif %}">{% if source.policy == 'deny' %}Geblokkeerd{% elif source.status == 'quarantined' %}Quarantaine{% elif latest_run %}{{ latest_run.get_status_display }}{% else %}Geen run{% endif %}</span><button class="icon-button" type="button" data-disclosure aria-controls="source-details-{{ forloop.counter }}" aria-expanded="false" aria-label="Details van {{ source.name }} tonen"><svg class="icon disclosure-icon"><use href="#icon-chevron"></use></svg></button></div>
</div> </div>
<nav class="flex flex-col px-3"> {% if source.discovery_evidence %}<div class="source-details"><span class="field-label">Ontdekkingsevidence</span><ul class="compact-list">{% for evidence in source.discovery_evidence %}<li>{{ evidence.label|default:"Ontdekte bron" }}: {{ evidence.url }}</li>{% endfor %}</ul></div>{% endif %}
<a class="flex items-center gap-3 px-4 py-3 rounded text-on-surface-variant hover:text-primary hover:bg-surface-container-high transition-all" href="#"> <div class="source-details" id="source-details-{{ forloop.counter }}" hidden><div class="source-details-grid"><div><span class="field-label">Technische runinformatie</span><div class="technical-log">{% if latest_run %}{% if latest_run.status == 'failed' and latest_run.error_message %}[{{ latest_run.finished_at|date:"Y-m-d H:i:s"|default:"--" }}] {{ latest_run.error_category|default:"ERROR" }}: {{ latest_run.error_message }}{% else %}RUN {{ latest_run.get_status_display }}<br>Extracted: {{ latest_run.extracted_count }} · Created: {{ latest_run.created_count }} · Updated: {{ latest_run.updated_count }} · Duplicate: {{ latest_run.duplicate_count }}{% if latest_run.http_status %}<br>HTTP {{ latest_run.http_status }}{% endif %}{% endif %}{% else %}Nog geen bronrun beschikbaar.{% endif %}</div></div><div><span class="field-label">Bronstatus</span><dl class="definition-grid"><div><dt>Review</dt><dd>{{ source.policy_review_summary }}</dd></div><div><dt>Volgende run</dt><dd>{{ source.next_run_at|date:"d/m/Y H:i"|default:"—" }}</dd></div></dl>{% if source.policy != 'deny' %}<form method="post" action="{% url 'sources:retry' source.pk %}">{% csrf_token %}<button class="button button-primary" type="submit">Scan opnieuw starten</button></form>{% else %}<p class="message error">Deze bron is door het beleid geblokkeerd.</p>{% endif %}</div></div></div>
<span class="material-symbols-outlined">dashboard</span> </article>
<span class="font-label-mono text-label-mono">Overview</span> {% endwith %}{% empty %}<div class="empty-state"><div class="empty-icon"><svg class="icon"><use href="#icon-sources"></use></svg></div><h2>Nog geen bronnen</h2><p>Registreer een bron via de beheeropdrachten of gebruik handmatige import.</p></div>{% endfor %}
</a> </div>
<a class="flex items-center gap-3 px-4 py-3 rounded text-on-surface-variant hover:text-primary hover:bg-surface-container-high transition-all" href="#"> <script src="{% static 'js/manual_import.js' %}?v={{ app_version }}" defer></script>
<span class="material-symbols-outlined">radar</span> {% endblock %}
<span class="font-label-mono text-label-mono">Nieuwe matches</span>
</a>
<a class="flex items-center gap-3 px-4 py-3 rounded text-primary bg-primary-container/10 border-r-2 border-primary-container" href="#">
<span class="material-symbols-outlined">hub</span>
<span class="font-label-mono text-label-mono">Bronnen</span>
</a>
<a class="flex items-center gap-3 px-4 py-3 rounded text-on-surface-variant hover:text-primary hover:bg-surface-container-high transition-all" href="#">
<span class="material-symbols-outlined">precision_manufacturing</span>
<span class="font-label-mono text-label-mono">Automatisering</span>
</a>
<a class="flex items-center gap-3 px-4 py-3 rounded text-on-surface-variant hover:text-primary hover:bg-surface-container-high transition-all" href="#">
<span class="material-symbols-outlined">settings</span>
<span class="font-label-mono text-label-mono">Instellingen</span>
</a>
</nav>
<div class="px-6 py-4 mt-auto border-t border-outline-variant/30">
<div class="flex items-center gap-3">
<span class="material-symbols-outlined">group</span>
<div>
<p class="font-body-sm font-medium">{{ user.get_short_name|default:user.username|default:"Gast" }}</p>
<p class="text-[10px] font-label-mono text-on-surface-variant">gebruiker</p>
</div>
</div>
</div>
</aside>
<main class="md:ml-64 pt-6 md:pt-24 min-h-screen px-4 md:px-gutter max-w-container-max mx-auto mb-24 md:mb-12">
{% if messages %}
<div class="mb-stack-md" aria-live="polite">
{% for message in messages %}
<div class="px-4 py-2 rounded mb-2 border border-outline-variant/30 bg-surface-container-highest">{{ message }}</div>
{% endfor %}
</div>
{% endif %}
<div class="flex flex-col md:flex-row md:items-end justify-between gap-6 mb-stack-lg">
<div>
<h2 class="font-headline-lg text-headline-lg text-primary tracking-tight">Bronbeheer</h2>
<p class="text-on-surface-variant font-body-md mt-1">Beheer en monitor de actieve scan-endpoints van jouw radarprofiel.</p>
</div>
<div class="flex gap-3">
<div class="glass-card px-4 py-2 rounded-xl flex flex-col">
<span class="text-label-mono text-[10px] text-on-surface-variant uppercase tracking-tighter">Actieve Bronnen</span>
<span class="font-headline-md text-secondary">{{ active_source_count }}</span>
</div>
<div class="glass-card px-4 py-2 rounded-xl flex flex-col">
<span class="text-label-mono text-[10px] text-on-surface-variant uppercase tracking-tighter">Systeemstatus</span>
<span class="font-headline-md text-primary flex items-center gap-2">
Gezond
<span class="w-2 h-2 rounded-full bg-secondary status-pulse"></span>
</span>
</div>
</div>
</div>
<section class="glass-card rounded-xl p-6 mb-stack-lg">
<h3 class="font-headline-md text-primary mb-4">Handmatige import</h3>
<p class="mb-4">Importeer een URL of plak vacaturetekst met dezelfde policy- en SSRF-controle als de backend.</p>
<form id="manual-import-form" method="post" action="{% url 'sources:manual_import' %}" class="grid grid-cols-1 md:grid-cols-2 gap-4" data-paste-limit="{{ manual_import_paste_max_bytes }}">
{% csrf_token %}
<label>
<span class="text-label-mono text-label-mono text-on-surface-variant mb-1 block">{{ manual_import_form.source_url.label }}</span>
{{ manual_import_form.source_url }}
{% for error in manual_import_form.source_url.errors %}
<small class="field-error block text-error">{{ error }}</small>
{% endfor %}
<small class="text-on-surface-variant">Vooraf ingevuld via bookmarklet; laat dit veld leeg wanneer je tekst plakt.</small>
</label>
<label>
<span class="text-label-mono text-label-mono text-on-surface-variant mb-1 block">{{ manual_import_form.pasted_text.label }}</span>
{{ manual_import_form.pasted_text }}
<small class="text-on-surface-variant">Maximaal <span id="manual-import-byte-count">{{ manual_import_paste_max_bytes }}</span> bytes.</small>
{% for error in manual_import_form.pasted_text.errors %}
<small class="field-error block text-error">{{ error }}</small>
{% endfor %}
</label>
<div class="md:col-span-2 flex flex-wrap gap-2">
<button class="bg-primary text-on-primary px-4 py-2 rounded" type="submit">Import uitvoeren</button>
<button id="copy-bookmarklet" class="border border-outline-variant px-4 py-2 rounded" type="button">Bookmarklet kopieren</button>
</div>
<a id="manual-bookmarklet" href="#" class="text-sm text-primary" data-manual-import-endpoint="{{ manual_import_bookmarklet_endpoint }}">
Bookmarklet: sleep naar bladwijzers
</a>
{% for error in manual_import_form.non_field_errors %}
<small class="field-error block text-error">{{ error }}</small>
{% endfor %}
</form>
<textarea id="manual-bookmarklet-code" rows="2" readonly class="sr-only" aria-label="Bookmarklet endpoint">{{ manual_import_bookmarklet_endpoint }}</textarea>
</section>
{% if manual_import_result %}
<section class="glass-card rounded-xl p-6 mb-stack-lg">
<h3 class="font-headline-md text-primary mb-4">Importresultaat</h3>
<p class="mb-2"><strong>Bron:</strong> {{ manual_import_result.source_name }} ({{ manual_import_result.source_id }})</p>
<ul class="space-y-1 font-label-mono text-on-surface-variant text-xs mb-4">
<li>Modus: {{ manual_import_result.mode }}</li>
<li>URL: {{ manual_import_result.source_url }}</li>
<li>Herkenbare items: {{ manual_import_result.extracted_count }}</li>
<li>Nieuwe vacatures: {{ manual_import_result.created_count }}</li>
<li>Reeds bestaande vacatures: {{ manual_import_result.duplicate_count }}</li>
</ul>
{% if manual_import_result.warnings %}
<h4 class="font-label-mono text-label-mono uppercase text-xs mb-2">Waarschuwingen</h4>
<ul class="list-disc list-inside text-on-surface-variant text-xs mb-4">
{% for warning in manual_import_result.warnings %}
<li>{{ warning }}</li>
{% endfor %}
</ul>
{% endif %}
{% if manual_import_result.jobs %}
<h4 class="font-label-mono text-label-mono uppercase text-xs mb-2">Geïmporteerde vacatures</h4>
<ul class="space-y-1">
{% for job in manual_import_result.jobs %}
<li>
{% if job.id %}
<a class="text-primary" href="{% url 'jobs:detail' job.id %}">{{ job.title }}</a> — {{ job.employer }}
{% else %}
{{ job.title }} — {{ job.employer }}
{% endif %}
</li>
{% endfor %}
</ul>
{% endif %}
</section>
{% endif %}
<form
id="bulk-action-form"
method="post"
action="{% url 'sources:bulk' %}"
class="glass-card rounded-xl p-4 mb-stack-lg flex flex-wrap gap-3 items-center"
>
{% csrf_token %}
<label for="action" class="text-label-mono text-[11px] uppercase tracking-[0.04em] text-on-surface-variant">Bulkactie</label>
<select
id="action"
name="action"
class="bg-surface-container-low border border-outline-variant rounded-lg px-3 py-2 text-sm text-on-surface"
>
<option value="approve_to_trial">Kandidaten naar proefrun</option>
<option value="allow">Toestaan</option>
<option value="pause">Pauzeren</option>
<option value="dismiss">Afwijzen</option>
</select>
<button
class="bg-primary text-on-primary px-4 py-2 rounded-lg font-semibold border border-primary/30 shadow-sm"
type="submit"
>
Uitvoeren
</button>
</form>
<div class="grid grid-cols-1 gap-6 mb-stack-lg">
{% for source in sources %}
{% with latest_run=source.runs.all|first %}
<section class="glass-card rounded-xl overflow-hidden
{% if source.status == 'active' and source.policy != 'deny' and latest_run and latest_run.status == 'success' %}signal-glow-primary{% elif source.status == 'quarantined' or source.policy == 'deny' %}border-error/30{% elif latest_run and latest_run.status == 'failed' %}border-error/30{% elif latest_run and latest_run.status == 'partial' %}border-orange-500/20{% else %}border-surface-container-high{% endif %}
">
<div class="p-6 flex flex-col md:flex-row md:items-center justify-between gap-4">
<div class="flex items-start gap-4">
<div class="w-12 h-12 rounded-lg bg-surface-container-highest flex items-center justify-center border border-outline-variant/30">
<span class="material-symbols-outlined text-primary">
{% if source.source_type == 'email' %}mail{% elif source.source_type == 'rss' or source.source_type == 'sitemap' %}rss_feed{% elif source.source_type == 'ats' %}hub{% else %}web{% endif %}
</span>
</div>
<div>
<div class="flex items-center gap-2">
{% if source.policy != "deny" %}
<input type="checkbox" name="source_ids" form="bulk-action-form" value="{{ source.pk }}" aria-label="Selecteer {{ source.name }}">
{% endif %}
<h3 class="font-headline-md text-primary">{{ source.name }}</h3>
</div>
<div class="flex flex-wrap items-center gap-3 mt-1">
<span class="font-label-mono text-label-mono text-on-surface-variant">{{ source.get_source_type_display }}</span>
<span class="w-1 h-1 rounded-full bg-outline-variant"></span>
<span class="font-label-mono text-label-mono text-on-surface-variant">{{ source.domain|default:'Geen domein' }}</span>
{% if latest_run %}
<span class="font-label-mono text-label-mono text-on-surface-variant">Laatste scan: {{ latest_run.started_at|date:'d/m/Y H:i' }}</span>
{% else %}
<span class="font-label-mono text-label-mono text-on-surface-variant">Nog niet gestart</span>
{% endif %}
</div>
</div>
</div>
<div class="flex items-center gap-6">
<div class="text-right">
<span class="block text-[10px] font-label-mono text-on-surface-variant uppercase">Vacatures gevonden</span>
<span class="text-headline-md text-primary">{% if latest_run %}{{ latest_run.extracted_count }}{% else %}0{% endif %}</span>
</div>
<div class="px-3 py-1 rounded-full flex items-center gap-2
{% if source.policy == 'deny' or latest_run and latest_run.status == 'failed' %}bg-error/10 border border-error/20{% elif source.status == 'quarantined' %}bg-error/10 border border-error/20{% elif latest_run and latest_run.status == 'partial' %}bg-orange-500/10 border border-orange-500/20{% else %}bg-secondary/10 border border-secondary/20{% endif %}">
<span class="w-2 h-2 rounded-full
{% if source.policy == 'deny' or latest_run and latest_run.status == 'failed' %}bg-error{% elif latest_run and latest_run.status == 'partial' %}bg-orange-500{% else %}bg-secondary{% endif %}
"></span>
<span class="text-label-mono font-medium
{% if source.policy == 'deny' or latest_run and latest_run.status == 'failed' %}text-error{% elif latest_run and latest_run.status == 'partial' %}text-orange-400{% else %}text-secondary{% endif %}">
{% if source.policy == "deny" %}
Geblokkeerd
{% elif latest_run and latest_run.status == 'failed' %}
Mislukt
{% elif latest_run and latest_run.status == 'partial' %}
Beperkt
{% elif source.status == 'quarantined' %}
Quarantaine
{% else %}
Gezond
{% endif %}
</span>
</div>
<button class="text-on-surface-variant hover:text-primary transition-colors" type="button" onclick="toggleDetails('details-{{ forloop.counter }}')">
<span class="material-symbols-outlined" id="icon-details-{{ forloop.counter }}">expand_more</span>
</button>
</div>
</div>
{% if source.discovery_evidence %}
<div class="px-6 pb-4 text-xs text-on-surface-variant">
<p class="font-label-mono uppercase mb-2">Ontdekkingsevidence</p>
<ul>
{% for evidence in source.discovery_evidence %}
<li>{{ evidence.label|default:"Ontdekte bron" }}: {{ evidence.url }}</li>
{% endfor %}
</ul>
</div>
{% endif %}
<div class="hidden border-t border-outline-variant/20 bg-surface-container-low/50" id="details-{{ forloop.counter }}">
<div class="p-6 grid grid-cols-1 md:grid-cols-2 gap-8">
<div>
<h4 class="text-label-mono text-[11px] text-on-surface-variant uppercase mb-3">Technische log</h4>
<div class="bg-surface-container-lowest p-4 rounded border border-outline-variant/30 font-label-mono text-xs leading-relaxed text-on-surface-variant/80 technical-log max-h-40 overflow-y-auto">
{% if latest_run %}
{% if latest_run.status == 'failed' and latest_run.error_message %}
[{% if latest_run.finished_at %}{{ latest_run.finished_at|date:"Y-m-d H:i:s" }}{% else %}--{% endif %}] ERROR: {{ latest_run.error_message }}
{% else %}
[{% if latest_run.finished_at %}{{ latest_run.finished_at|date:"Y-m-d H:i:s" }}{% else %}--{% endif %}] RUN {{ latest_run.get_status_display }}<br />
Extracted: {{ latest_run.extracted_count }} Created: {{ latest_run.created_count }} Updated: {{ latest_run.updated_count }} Duplicate: {{ latest_run.duplicate_count }}
{% if latest_run.http_status %}<br />HTTP {{ latest_run.http_status }}{% endif %}
{% endif %}
{% else %}
Nog geen bronrun beschikbaar.
{% endif %}
</div>
</div>
<div class="space-y-4">
<div>
<h4 class="text-label-mono text-[11px] text-on-surface-variant uppercase mb-2">Bronstatus</h4>
<p class="font-label-mono text-xs text-on-surface-variant/80">Policy: {{ source.get_policy_display }}</p>
<p class="font-label-mono text-xs text-on-surface-variant/80">Review: {{ source.policy_review_summary }}</p>
<p class="font-label-mono text-xs text-on-surface-variant/80">Volgende run: {{ source.next_run_at|date:'d/m/Y H:i'|default:'-' }}</p>
</div>
<div class="flex gap-2">
{% if source.policy != 'deny' %}
<form method="post" action="{% url 'sources:retry' source.pk %}">
{% csrf_token %}
<button class="bg-primary-container text-on-primary-container px-4 py-2 rounded font-bold text-sm" type="submit">Herstart scan</button>
</form>
{% else %}
<button class="bg-error text-surface px-4 py-2 rounded font-bold text-sm" type="button">Geblokkeerd</button>
{% endif %}
</div>
</div>
</div>
</div>
</section>
{% endwith %}
{% empty %}
<section class="glass-card rounded-xl p-6">
<p>Nog geen bronnen geregistreerd.</p>
</section>
{% endfor %}
</div>
<section class="mt-12 mb-24 md:mb-12">
<h3 class="font-headline-md text-primary mb-6">Bronstatus op overzicht</h3>
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
<div class="glass-card p-6 rounded-xl relative overflow-hidden h-64">
<h4 class="text-label-mono text-[11px] text-on-surface-variant uppercase mb-4">Bronnen geregistreerd</h4>
<div class="relative z-10 flex flex-col justify-end h-full">
<span class="text-headline-md text-primary">{{ source_total_count }}</span>
<span class="text-xs text-on-surface-variant font-label-mono">Totaal in beheer</span>
</div>
</div>
<div class="glass-card p-6 rounded-xl relative overflow-hidden h-64">
<h4 class="text-label-mono text-[11px] text-on-surface-variant uppercase mb-4">Te reviewen</h4>
<div class="relative z-10 flex flex-col justify-end h-full">
<span class="text-headline-md text-primary">{{ review_pending_count }}</span>
<span class="text-xs text-on-surface-variant font-label-mono">Policy review</span>
</div>
</div>
<div class="glass-card p-6 rounded-xl relative overflow-hidden h-64">
<h4 class="text-label-mono text-[11px] text-on-surface-variant uppercase mb-4">Systeemlogboek</h4>
<p class="text-xs text-on-surface-variant">Brongezondheid en technische events zijn zichtbaar op source-level kaarten.</p>
</div>
</div>
</section>
</main>
<nav class="md:hidden fixed bottom-0 left-0 w-full bg-surface-container border-t border-outline-variant/10 py-3 px-6 flex justify-between items-center z-50">
<button class="flex flex-col items-center gap-1 text-on-surface-variant">
<span class="material-symbols-outlined">dashboard</span>
<span class="text-[10px] font-label-mono">Overzicht</span>
</button>
<button class="flex flex-col items-center gap-1 text-primary">
<span class="material-symbols-outlined" style="font-variation-settings: 'FILL' 1;">hub</span>
<span class="text-[10px] font-label-mono">Bronnen</span>
</button>
<button class="flex flex-col items-center gap-1 text-on-surface-variant">
<span class="material-symbols-outlined">radar</span>
<span class="text-[10px] font-label-mono">Radar</span>
</button>
<button class="flex flex-col items-center gap-1 text-on-surface-variant">
<span class="material-symbols-outlined">settings</span>
<span class="text-[10px] font-label-mono">Instellingen</span>
</button>
</nav>
<script>
function toggleDetails(id) {
const el = document.getElementById(id);
const icon = document.getElementById('icon-' + id);
if (!el || !icon) {
return;
}
const isHidden = el.classList.contains('hidden');
document.querySelectorAll('[id^=\"details-\"]').forEach(item => {
if (item.id !== id) {
item.classList.add('hidden');
const otherIcon = document.getElementById('icon-' + item.id);
if (otherIcon) {
otherIcon.style.transform = 'rotate(0deg)';
}
}
});
if (isHidden) {
el.classList.remove('hidden');
icon.style.transform = 'rotate(180deg)';
icon.style.transition = 'transform 0.3s ease';
} else {
el.classList.add('hidden');
icon.style.transform = 'rotate(0deg)';
}
}
</script>
<script src="{% static 'js/manual_import.js' %}" defer></script>
</body>
</html>
+7 -2
View File
@@ -1,6 +1,11 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}Systeemstatus · VacatureRadar{% endblock %} {% block title %}Systeemstatus · VacatureRadar{% endblock %}
{% block content %}<header class="page-header"><div><p class="eyebrow">Beheer</p><h1>Systeemstatus</h1><p>Lokale healthchecks en aantallen; secrets worden nooit getoond.</p></div></header><section class="stats-grid"><article class="stat-card"><span>Readiness</span><strong>{% if health.ok %}OK{% else %}FOUT{% endif %}</strong><small>{{ health.checks.database }}</small></article>{% for row in source_summary %}<article class="stat-card"><span>Bronnen {{ row.status }}</span><strong>{{ row.total }}</strong></article>{% endfor %}{% for row in job_summary %}<article class="stat-card"><span>Vacatures {{ row.status }}</span><strong>{{ row.total }}</strong></article>{% endfor %}</section><section class="panel"><h2>Veilige beheeropdrachten</h2><pre><code>make verify {% block content %}
<header class="page-header"><div><p class="eyebrow">Automatisering centrum</p><h1>Systeemstatus</h1><p>Readiness en echte database-aantallen. Secrets, fictieve uptime en niet-gemeten latency worden nooit getoond.</p></div><span class="pill {% if health.ok %}pill-success{% else %}pill-failed{% endif %}">{% if health.ok %}Operationeel{% else %}Aandacht nodig{% endif %}</span></header>
<section class="stats-grid"><article class="stat-card signal"><span class="kpi-label">Readiness</span><strong>{% if health.ok %}OK{% else %}FOUT{% endif %}</strong><small>database: {{ health.checks.database }}</small></article>{% for row in source_summary %}<article class="stat-card"><span class="kpi-label">Bronnen · {{ row.status }}</span><strong>{{ row.total }}</strong><small>geregistreerde bronnen</small></article>{% endfor %}{% for row in job_summary %}<article class="stat-card"><span class="kpi-label">Vacatures · {{ row.status }}</span><strong>{{ row.total }}</strong><small>vacatures in database</small></article>{% endfor %}</section>
<div class="section-heading"><div><span class="section-kicker">Operaties</span><h2>Veilige beheeropdrachten</h2><p>Voer deze uitsluitend uit vanaf een vertrouwde shell op de server.</p></div></div>
<section class="panel"><pre><code>make verify
./scripts/backup.sh ./scripts/backup.sh
./scripts/restore.sh /pad/naar/backup.sql.gz ./scripts/restore.sh /pad/naar/backup.sql.gz
uv run python manage.py seed_sources</code></pre></section>{% endblock %} uv run python manage.py seed_sources</code></pre></section>
{% endblock %}
@@ -66,6 +66,12 @@ def _assert_basic_html_accessibility(payload: bytes | str, *, page_name: str) ->
label = button.get_text(" ", strip=True) or button.get("aria-label") label = button.get_text(" ", strip=True) or button.get("aria-label")
assert label, f"{page_name}: knop zonder tekst of aria-label" assert label, f"{page_name}: knop zonder tekst of aria-label"
for element in soup.select("script[src], link[rel='stylesheet'][href]"):
asset_url = element.get("src") or element.get("href") or ""
assert not asset_url.startswith(("http://", "https://")), (
f"{page_name}: externe productie-asset gevonden: {asset_url}"
)
def _run_html_accessibility_probe(client, user, profile, job) -> None: def _run_html_accessibility_probe(client, user, profile, job) -> None:
_seed_dashboard_score(profile=profile, job=job) _seed_dashboard_score(profile=profile, job=job)
@@ -99,11 +105,16 @@ def _run_html_accessibility_probe(client, user, profile, job) -> None:
response = client.get(reverse("jobs:applications")) response = client.get(reverse("jobs:applications"))
assert response.status_code == 200 assert response.status_code == 200
_assert_basic_html_accessibility(response.content, page_name="applications") _assert_basic_html_accessibility(response.content, page_name="applications")
assert "Sollicitatiepipeline" in response.content.decode("utf-8")
response = client.get(reverse("sources:list")) response = client.get(reverse("sources:list"))
assert response.status_code == 200 assert response.status_code == 200
_assert_basic_html_accessibility(response.content, page_name="sources") _assert_basic_html_accessibility(response.content, page_name="sources")
response = client.get(reverse("dashboard:system"))
assert response.status_code == 200
_assert_basic_html_accessibility(response.content, page_name="system")
def _assert_playwright_accessibility(page, *, expected_h1: str) -> None: def _assert_playwright_accessibility(page, *, expected_h1: str) -> None:
page.wait_for_load_state("networkidle") page.wait_for_load_state("networkidle")
@@ -4,7 +4,7 @@ import pytest
from django.urls import reverse from django.urls import reverse
from django.utils import timezone from django.utils import timezone
from apps.jobs.models import Feedback, ScoreRun from apps.jobs.models import Application, Feedback, ScoreRun
from apps.jobs.tasks import rescore_active_jobs, update_job_lifecycle from apps.jobs.tasks import rescore_active_jobs, update_job_lifecycle
from apps.notifications.tasks import build_due_digests from apps.notifications.tasks import build_due_digests
from apps.sources.models import RawDocument, Source, SourceLease, SourcePolicyReview, SourceRun from apps.sources.models import RawDocument, Source, SourceLease, SourcePolicyReview, SourceRun
@@ -163,7 +163,11 @@ def test_job_profile_source_and_application_views(client, user, profile, job, so
assert client.get(reverse("profiles:edit", kwargs={"pk": profile.pk})).status_code == 200 assert client.get(reverse("profiles:edit", kwargs={"pk": profile.pk})).status_code == 200
assert client.get(reverse("sources:list")).status_code == 200 assert client.get(reverse("sources:list")).status_code == 200
assert client.get(reverse("dashboard:system")).status_code == 200 assert client.get(reverse("dashboard:system")).status_code == 200
assert client.get(reverse("jobs:applications")).status_code == 200 applications_response = client.get(reverse("jobs:applications"))
assert applications_response.status_code == 200
assert [column["status"] for column in applications_response.context["pipeline_columns"]] == [
status for status, _label in Application.Status.choices
]
source.policy = Source.Policy.DENY source.policy = Source.Policy.DENY
source.save(update_fields=["policy"]) source.save(update_fields=["policy"])