=feat-stitch-intelligence-cockpit
@@ -2,6 +2,25 @@
|
||||
|
||||
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
|
||||
|
||||
### Toegevoegd
|
||||
|
||||
@@ -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.
|
||||
|
||||
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`.
|
||||
|
||||
@@ -8,5 +8,5 @@ from django.http import HttpRequest
|
||||
def navigation_context(request: HttpRequest) -> dict[str, Any]:
|
||||
return {
|
||||
"app_name": "VacatureRadar",
|
||||
"app_version": "0.1.0",
|
||||
"app_version": "0.2.1",
|
||||
}
|
||||
|
||||
@@ -108,6 +108,20 @@ class ApplicationListView(LoginRequiredMixin, ListView):
|
||||
"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):
|
||||
model = Application
|
||||
|
||||
@@ -941,6 +941,42 @@ tasks:
|
||||
completed_at: '2026-07-21'
|
||||
note: Release script, smoke-run, backup/restore and artifact bundle for VR-117 toegevoegd; release artefacten
|
||||
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
|
||||
title: Concrete live-bronnen inventariseren en juridisch/operationeel reviewen
|
||||
status: blocked-external
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# Projectstatus
|
||||
|
||||
- Laatst bijgewerkt: 2026-07-21
|
||||
- Repositoryversie: 0.1.0 MVP-basis
|
||||
- Repositoryversie: 0.2.1 Stitch Intelligence Cockpit
|
||||
- Uitvoeringsmodus: autonome backlog
|
||||
- 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;
|
||||
- normalisatie, sanitization, werkgeverresolutie, dedupe, aliassen, provenance en versies;
|
||||
- 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;
|
||||
- Celeryworker/scheduler, healthchecks, Docker/Unraidconfiguratie en back-up-/herstelscripts;
|
||||
- offline unit-, integratie- en securitytests.
|
||||
@@ -43,6 +43,13 @@ Release-audit op 2026-07-21:
|
||||
|
||||
## 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.
|
||||
- Productiereleasecontrole toegevoegd met `scripts/release_verify.sh`, inclusief smoke, backup/restore-smoke en artifactbundel.
|
||||
- Changelog, traceability en Unraid-runbook samengekoppeld aan het releasepad.
|
||||
@@ -156,7 +163,7 @@ Deze blokkeren onafhankelijke code niet:
|
||||
|
||||
## 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
|
||||
|
||||
|
||||
@@ -62,3 +62,12 @@ Per zichtbare taak:
|
||||
4. screenreader smoke test voor dashboard, vacaturedetail en profiel;
|
||||
5. contrastcontrole;
|
||||
6. test met lange/lege/unknown content.
|
||||
|
||||
## VR-118 browservalidatie
|
||||
|
||||
- Dashboard gecontroleerd op 1440×900, 1280×800, 1024×768, 768×1024, 390×844 en 360×800.
|
||||
- Op alle zes viewports: exact één `h1`, geen documentbrede horizontale overflow en de juiste desktop/mobile navigatiestatus.
|
||||
- Mobiel menu opent met `aria-expanded=true`, sluit met Escape/sluitactie en is buiten de focus-/accessibilityflow wanneer gesloten.
|
||||
- Themawisselaar blijft bruikbaar wanneer browseropslag niet beschikbaar is; de UI geeft dan geen consolefout.
|
||||
- Dashboard, vacaturelijst/detail, sollicitatiepipeline, profielen/editor, bronnen en systeem zijn met echte seeded data en zonder externe assets gevalideerd.
|
||||
- Screenshots staan onder `docs/design/validation/` en zijn via `.dockerignore` uitgesloten van de productiecontainer.
|
||||
|
||||
@@ -1,37 +1,44 @@
|
||||
# Design system
|
||||
|
||||
> Geactualiseerd voor VR-118. De volledige Stitch-mapping en bewuste afwijkingen staan in
|
||||
> `docs/design/STITCH_INTEGRATION_PLAN.md`.
|
||||
|
||||
## 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
|
||||
|
||||
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
|
||||
--surface-page
|
||||
--surface-card
|
||||
--surface-raised
|
||||
--text-primary
|
||||
--text-secondary
|
||||
--text-muted
|
||||
--border-subtle
|
||||
--accent
|
||||
--accent-strong
|
||||
--success
|
||||
--bg
|
||||
--bg-deep
|
||||
--surface
|
||||
--surface-low
|
||||
--surface-high
|
||||
--surface-bright
|
||||
--text
|
||||
--text-soft
|
||||
--muted
|
||||
--border
|
||||
--border-strong
|
||||
--primary
|
||||
--primary-strong
|
||||
--positive
|
||||
--warning
|
||||
--danger
|
||||
--focus-ring
|
||||
--shadow-card
|
||||
--signal-glow
|
||||
--radius-sm
|
||||
--radius-md
|
||||
--radius-lg
|
||||
--space-1 ... --space-8
|
||||
--radius
|
||||
```
|
||||
|
||||
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
|
||||
|
||||
@@ -45,10 +52,11 @@ Donkere modus volgt `prefers-color-scheme` en behoudt semantische contrastrollen
|
||||
## 4. Spacing en layout
|
||||
|
||||
- 4/8px-gebaseerde schaal;
|
||||
- contentcontainer ongeveer 1180–1280px;
|
||||
- contentcontainer maximaal 1600px zodat de cockpit op brede schermen bruikbaar blijft;
|
||||
- kaarten met consistente padding 20–24px desktop, 16px mobiel;
|
||||
- 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.
|
||||
|
||||
## 5. Componenten
|
||||
|
||||
|
After Width: | Height: | Size: 69 KiB |
|
After Width: | Height: | Size: 60 KiB |
|
After Width: | Height: | Size: 79 KiB |
|
After Width: | Height: | Size: 98 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 61 KiB |
|
After Width: | Height: | Size: 73 KiB |
|
After Width: | Height: | Size: 99 KiB |
|
After Width: | Height: | Size: 79 KiB |
|
After Width: | Height: | Size: 67 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 61 KiB |
|
After Width: | Height: | Size: 56 KiB |
|
After Width: | Height: | Size: 67 KiB |
|
After Width: | Height: | Size: 72 KiB |
@@ -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-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-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
|
||||
|
||||
|
||||
@@ -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-045 | I | optionele Ollamaservice/fallback | `test_ai.py` | VR-109 |
|
||||
| PR-046 | I | geen tools + guardrails | AI/securitytests | VR-109/115 evaluaties |
|
||||
| PR-050 | I | dashboard view/template | viewtests | VR-114 e2e/a11y |
|
||||
| PR-051 | I | detailview/template/provenance | viewtests | VR-114 |
|
||||
| PR-050 | I | dashboard view/template + Stitch-cockpitshell | viewtests, VR-118 browsermatrix | VR-114/118 e2e/a11y |
|
||||
| PR-051 | I | detailview/template/provenance + intelligence-layout | viewtests, VR-118 screenshots | VR-114/118 |
|
||||
| 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-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-006 | I | offline pytest + coveragegate | VR-114 verbreedt e2e |
|
||||
| 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-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-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-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.
|
||||
|
||||
|
||||
|
||||
@@ -1,303 +1,382 @@
|
||||
:root {
|
||||
--bg: #f5f7fb;
|
||||
--surface: #ffffff;
|
||||
--surface-2: #eef2f8;
|
||||
--text: #142033;
|
||||
--muted: #667085;
|
||||
--border: #dce3ee;
|
||||
--primary: #465bd8;
|
||||
--primary-strong: #3446b9;
|
||||
--primary-soft: #edf0ff;
|
||||
--positive: #087f5b;
|
||||
--positive-soft: #e7f7f1;
|
||||
--warning: #9a6700;
|
||||
--warning-soft: #fff5d6;
|
||||
--danger: #b42318;
|
||||
--danger-soft: #ffebe9;
|
||||
--shadow: 0 16px 40px rgba(23, 36, 64, .08);
|
||||
--radius: 16px;
|
||||
--radius-sm: 10px;
|
||||
--sidebar: 248px;
|
||||
:root,
|
||||
:root[data-theme="dark"] {
|
||||
color-scheme: dark;
|
||||
--bg: #081120;
|
||||
--bg-deep: #050c18;
|
||||
--surface: #111b2e;
|
||||
--surface-low: #0c1627;
|
||||
--surface-high: #19253a;
|
||||
--surface-bright: #243149;
|
||||
--text: #e7efff;
|
||||
--text-soft: #c7d3e7;
|
||||
--muted: #8fa1b8;
|
||||
--border: #2b3a50;
|
||||
--border-strong: #42536b;
|
||||
--primary: #16e5ee;
|
||||
--primary-strong: #7cf8ff;
|
||||
--primary-ink: #002f32;
|
||||
--primary-soft: rgba(22, 229, 238, .1);
|
||||
--positive: #4edea3;
|
||||
--positive-soft: rgba(78, 222, 163, .11);
|
||||
--warning: #ffca69;
|
||||
--warning-soft: rgba(255, 202, 105, .11);
|
||||
--danger: #ff8f87;
|
||||
--danger-soft: rgba(255, 143, 135, .11);
|
||||
--indigo: #9ea5ff;
|
||||
--shadow: 0 18px 55px rgba(0, 0, 0, .24);
|
||||
--signal-glow: 0 0 0 1px rgba(22, 229, 238, .12), 0 0 28px rgba(22, 229, 238, .07);
|
||||
--radius: 9px;
|
||||
--radius-sm: 5px;
|
||||
--sidebar: 272px;
|
||||
--utility: 66px;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--bg: #0f1520;
|
||||
--surface: #171f2d;
|
||||
--surface-2: #202a3a;
|
||||
--text: #edf2fa;
|
||||
--muted: #a8b2c4;
|
||||
--border: #303b4e;
|
||||
--primary: #91a0ff;
|
||||
--primary-strong: #aab5ff;
|
||||
--primary-soft: #252f55;
|
||||
--positive: #5bd3aa;
|
||||
--positive-soft: #153b31;
|
||||
--warning: #ffd166;
|
||||
--warning-soft: #443716;
|
||||
--danger: #ff8c84;
|
||||
--danger-soft: #4b2424;
|
||||
--shadow: 0 18px 42px rgba(0, 0, 0, .28);
|
||||
}
|
||||
:root[data-theme="light"] {
|
||||
color-scheme: light;
|
||||
--bg: #f3f6fa;
|
||||
--bg-deep: #e9eef5;
|
||||
--surface: #ffffff;
|
||||
--surface-low: #f7f9fc;
|
||||
--surface-high: #edf2f7;
|
||||
--surface-bright: #e2e9f2;
|
||||
--text: #142033;
|
||||
--text-soft: #2c3c52;
|
||||
--muted: #617187;
|
||||
--border: #d7e0eb;
|
||||
--border-strong: #aebdce;
|
||||
--primary: #007f87;
|
||||
--primary-strong: #005e64;
|
||||
--primary-ink: #f4ffff;
|
||||
--primary-soft: rgba(0, 127, 135, .09);
|
||||
--positive: #087f5b;
|
||||
--positive-soft: rgba(8, 127, 91, .1);
|
||||
--warning: #8b5b00;
|
||||
--warning-soft: rgba(183, 119, 0, .11);
|
||||
--danger: #b42318;
|
||||
--danger-soft: rgba(180, 35, 24, .09);
|
||||
--indigo: #4e56c8;
|
||||
--shadow: 0 16px 44px rgba(27, 43, 65, .08);
|
||||
--signal-glow: 0 0 0 1px rgba(0, 127, 135, .12), 0 12px 34px rgba(0, 70, 75, .08);
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
html { min-height: 100%; scroll-behavior: smooth; }
|
||||
body {
|
||||
min-width: 320px;
|
||||
min-height: 100vh;
|
||||
margin: 0;
|
||||
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);
|
||||
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; }
|
||||
button, input, select, textarea { font: inherit; }
|
||||
button { cursor: pointer; }
|
||||
code, pre { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
|
||||
pre { overflow: auto; border-radius: var(--radius-sm); background: var(--surface-2); padding: 18px; }
|
||||
|
||||
.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);
|
||||
code, pre, .eyebrow, .pill, .meta-label, .nav-group-label, .job-meta, .utility-title, .kpi-label {
|
||||
font-family: ui-monospace, SFMono-Regular, Consolas, "Liberation Mono", monospace;
|
||||
}
|
||||
.brand-wrap, .brand, .topbar-actions { display: flex; align-items: center; gap: 12px; }
|
||||
.brand { color: var(--text); }
|
||||
.brand:hover { text-decoration: none; }
|
||||
.brand span:last-child { display: grid; line-height: 1.2; }
|
||||
.brand small { color: var(--muted); font-size: 12px; }
|
||||
.brand-mark {
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
display: inline-grid;
|
||||
place-items: center;
|
||||
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); }
|
||||
pre { overflow: auto; margin: 0; border-radius: var(--radius-sm); background: var(--bg-deep); padding: 18px; border: 1px solid var(--border); }
|
||||
svg { fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; }
|
||||
.icon { width: 20px; height: 20px; flex: 0 0 auto; }
|
||||
.icon-sprite { position: absolute; width: 0; height: 0; overflow: hidden; }
|
||||
.skip-link { position: fixed; left: 12px; top: -90px; z-index: 1000; padding: 11px 15px; background: var(--primary); color: var(--primary-ink); border-radius: var(--radius-sm); font-weight: 800; }
|
||||
.skip-link:focus { top: 12px; }
|
||||
.sr-only { position: absolute !important; width: 1px !important; height: 1px !important; padding: 0 !important; margin: -1px !important; overflow: hidden !important; clip: rect(0, 0, 0, 0) !important; white-space: nowrap !important; border: 0 !important; }
|
||||
.mobile-only { display: none !important; }
|
||||
|
||||
.app-frame { min-height: 100vh; }
|
||||
.sidebar {
|
||||
position: sticky;
|
||||
top: 70px;
|
||||
height: calc(100vh - 70px);
|
||||
position: fixed;
|
||||
inset: 0 auto 0 0;
|
||||
z-index: 60;
|
||||
width: var(--sidebar);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
padding: 24px 14px;
|
||||
background: var(--surface);
|
||||
padding: 18px 14px;
|
||||
background: color-mix(in srgb, var(--bg-deep) 96%, transparent);
|
||||
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; }
|
||||
.nav-link small { color: var(--muted); font-weight: 400; }
|
||||
.nav-link:hover { background: var(--surface-2); text-decoration: none; }
|
||||
.nav-link.active { background: var(--primary-soft); color: var(--primary-strong); }
|
||||
.nav-link.active small { color: var(--primary); }
|
||||
.sidebar-foot { margin-top: auto; display: flex; align-items: center; gap: 7px; padding: 12px; color: var(--muted); font-size: 12px; }
|
||||
.sidebar-foot small { margin-left: auto; }
|
||||
.status-dot { width: 8px; height: 8px; border-radius: 99px; background: var(--positive); box-shadow: 0 0 0 4px var(--positive-soft); }
|
||||
.main-content { width: 100%; max-width: 1480px; padding: 34px clamp(20px, 4vw, 56px) 64px; }
|
||||
.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); }
|
||||
.sidebar-brand { display: flex; align-items: center; justify-content: space-between; padding: 2px 6px 22px; }
|
||||
.brand { min-width: 0; display: flex; align-items: center; gap: 12px; color: var(--text); }
|
||||
.brand:hover { text-decoration: none; }
|
||||
.brand > span:last-child { min-width: 0; display: grid; line-height: 1.2; }
|
||||
.brand strong { font-size: 18px; letter-spacing: -.025em; }
|
||||
.brand small { margin-top: 3px; color: var(--muted); font: 10px/1.2 ui-monospace, SFMono-Regular, Consolas, monospace; letter-spacing: .1em; text-transform: uppercase; }
|
||||
.brand-mark { width: 42px; height: 42px; display: inline-grid; place-items: center; border-radius: 7px; background: var(--primary); color: var(--primary-ink); box-shadow: 0 0 24px color-mix(in srgb, var(--primary) 25%, transparent); }
|
||||
.brand-mark .icon { width: 25px; height: 25px; stroke-width: 1.6; }
|
||||
.nav-stack { min-height: 0; overflow-y: auto; display: grid; align-content: start; gap: 4px; padding: 4px 0; }
|
||||
.nav-group-label { margin: 18px 13px 5px; color: var(--muted); font-size: 10px; text-transform: uppercase; letter-spacing: .13em; }
|
||||
.nav-group-label:first-child { margin-top: 3px; }
|
||||
.nav-link { position: relative; display: grid; grid-template-columns: 24px minmax(0, 1fr); align-items: center; gap: 12px; min-height: 54px; padding: 8px 12px; border: 1px solid transparent; border-radius: 7px; color: var(--muted); }
|
||||
.nav-link > span { min-width: 0; display: grid; color: var(--text-soft); font-weight: 700; line-height: 1.25; }
|
||||
.nav-link small { color: var(--muted); font-size: 11px; font-weight: 400; }
|
||||
.nav-link:hover { background: var(--surface); border-color: var(--border); text-decoration: none; color: var(--primary); }
|
||||
.nav-link.active { background: linear-gradient(90deg, var(--primary-soft), transparent); border-color: color-mix(in srgb, var(--primary) 25%, var(--border)); color: var(--primary); box-shadow: inset 3px 0 var(--primary); }
|
||||
.nav-link.active > span, .nav-link.active small { color: var(--primary-strong); }
|
||||
.sidebar-user { margin-top: auto; min-width: 0; display: grid; grid-template-columns: 38px minmax(0, 1fr) 10px; align-items: center; gap: 10px; padding: 15px 8px 2px; border-top: 1px solid var(--border); }
|
||||
.sidebar-user > span:nth-child(2) { min-width: 0; display: grid; line-height: 1.25; }
|
||||
.sidebar-user strong, .sidebar-user small { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.sidebar-user strong { font-size: 13px; }
|
||||
.sidebar-user small { color: var(--muted); font-size: 11px; }
|
||||
.avatar { width: 38px; height: 38px; display: grid; place-items: center; border: 1px solid var(--border-strong); border-radius: 50%; background: var(--surface-high); color: var(--primary-strong); font-weight: 800; }
|
||||
.status-dot, .signal-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--positive); box-shadow: 0 0 0 4px var(--positive-soft); }
|
||||
.workspace { min-height: 100vh; margin-left: var(--sidebar); }
|
||||
.utilitybar { position: sticky; top: 0; z-index: 40; height: var(--utility); display: flex; align-items: center; justify-content: space-between; gap: 20px; padding: 0 clamp(18px, 3vw, 38px); background: color-mix(in srgb, var(--bg) 86%, transparent); border-bottom: 1px solid var(--border); backdrop-filter: blur(16px); }
|
||||
.utilitybar-start, .utility-actions, .utility-title, .utility-link { display: flex; align-items: center; }
|
||||
.utility-title { gap: 10px; color: var(--muted); font-size: 11px; text-transform: uppercase; letter-spacing: .08em; }
|
||||
.utility-actions { gap: 8px; }
|
||||
.utility-actions form { margin: 0; }
|
||||
.utility-link, .icon-button { min-height: 42px; border: 1px solid transparent; border-radius: 6px; background: transparent; color: var(--muted); }
|
||||
.utility-link { gap: 8px; padding: 8px 10px; font-weight: 650; }
|
||||
button.utility-link { width: auto; }
|
||||
.utility-link:hover, .icon-button:hover { border-color: var(--border); background: var(--surface-high); color: var(--text); text-decoration: none; }
|
||||
.icon-button { width: 42px; display: inline-grid; place-items: center; padding: 0; }
|
||||
.theme-icon-light { display: none; }
|
||||
:root[data-theme="light"] .theme-icon-dark { display: none; }
|
||||
:root[data-theme="light"] .theme-icon-light { display: block; }
|
||||
.nav-scrim { display: none; }
|
||||
.main-content { width: 100%; max-width: 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 h1, .job-hero h1 { margin: 0; font-size: clamp(30px, 4vw, 44px); line-height: 1.08; letter-spacing: -.035em; }
|
||||
.page-header p { margin: 8px 0 0; color: var(--muted); max-width: 780px; }
|
||||
.eyebrow { margin: 0 0 8px !important; color: var(--primary) !important; text-transform: uppercase; letter-spacing: .12em; font-size: 12px; font-weight: 800; }
|
||||
.section-heading { display: flex; align-items: flex-end; justify-content: space-between; gap: 20px; margin: 34px 0 18px; }
|
||||
.section-heading h2 { margin: 0; font-size: 23px; letter-spacing: -.02em; }
|
||||
.page-header { display: flex; align-items: flex-end; justify-content: space-between; gap: 26px; margin-bottom: 28px; }
|
||||
.page-header h1, .job-hero h1 { margin: 0; font-size: clamp(30px, 3.7vw, 48px); line-height: 1.06; letter-spacing: -.042em; }
|
||||
.page-header p { max-width: 760px; margin: 9px 0 0; color: var(--muted); }
|
||||
.eyebrow { margin: 0 0 9px !important; color: var(--primary) !important; font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: .13em; }
|
||||
.page-actions { display: flex; flex-wrap: wrap; gap: 9px; }
|
||||
.section-heading { display: flex; align-items: end; justify-content: space-between; gap: 20px; margin: 34px 0 17px; }
|
||||
.section-heading h2 { margin: 0; font-size: 23px; letter-spacing: -.025em; }
|
||||
.section-heading p { margin: 5px 0 0; color: var(--muted); }
|
||||
.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 {
|
||||
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 { min-height: 42px; display: inline-flex; align-items: center; justify-content: center; gap: 8px; padding: 8px 14px; border: 1px solid transparent; border-radius: 6px; background: transparent; color: var(--text); font-weight: 750; line-height: 1.2; text-decoration: none; }
|
||||
.button:hover { text-decoration: none; transform: translateY(-1px); }
|
||||
.button:focus-visible, 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-primary { background: var(--primary); color: white; box-shadow: 0 7px 16px rgba(70,91,216,.2); }
|
||||
.button:focus-visible, .icon-button:focus-visible, .utility-link:focus-visible, input:focus-visible, select:focus-visible, textarea:focus-visible, summary:focus-visible, a:focus-visible { outline: 3px solid color-mix(in srgb, var(--primary) 40%, transparent); outline-offset: 3px; }
|
||||
.button-primary { background: var(--primary); color: var(--primary-ink); box-shadow: 0 8px 22px color-mix(in srgb, var(--primary) 16%, transparent); }
|
||||
.button-primary:hover { background: var(--primary-strong); }
|
||||
.button-secondary { background: var(--surface); border-color: var(--border); }
|
||||
.button-secondary:hover, .button-ghost:hover { background: var(--surface-2); }
|
||||
.button-ghost { color: var(--muted); }
|
||||
.button-link { color: var(--primary); background: var(--primary-soft); }
|
||||
.button-secondary { background: var(--surface-high); border-color: var(--border); }
|
||||
.button-secondary:hover, .button-ghost:hover { background: var(--surface-bright); border-color: var(--border-strong); }
|
||||
.button-ghost { border-color: var(--border); color: var(--text-soft); }
|
||||
.button-link { color: var(--primary-strong); background: var(--primary-soft); border-color: color-mix(in srgb, var(--primary) 20%, var(--border)); }
|
||||
.button-danger { color: var(--danger); border-color: color-mix(in srgb, var(--danger) 30%, var(--border)); }
|
||||
.button-block { width: 100%; }
|
||||
.button-small { min-height: 32px; padding: 5px 9px; font-size: 13px; }
|
||||
.icon-button { width: 40px; height: 40px; border: 1px solid var(--border); border-radius: 10px; background: var(--surface); color: var(--text); }
|
||||
.button-small { min-height: 34px; padding: 5px 10px; font-size: 13px; }
|
||||
|
||||
.stats-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 14px; }
|
||||
.stat-card, .panel, .job-card, .auth-card {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
.stat-card { min-height: 130px; display: grid; align-content: center; padding: 22px; }
|
||||
.stat-card span, .stat-card small { color: var(--muted); }
|
||||
.stat-card strong { margin: 2px 0; font-size: 34px; line-height: 1; letter-spacing: -.04em; }
|
||||
.panel { padding: clamp(20px, 3vw, 30px); margin-bottom: 20px; }
|
||||
.panel > h2:first-child { margin-top: 0; }
|
||||
.stats-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 12px; }
|
||||
.stat-card, .panel, .job-card, .auth-card, .filter-bar, .table-wrap, .source-card { background: color-mix(in srgb, var(--surface) 95%, transparent); border: 1px solid var(--border); border-radius: var(--radius); box-shadow: var(--shadow); }
|
||||
.stat-card { position: relative; min-height: 126px; display: grid; align-content: center; gap: 2px; padding: 20px; overflow: hidden; }
|
||||
.stat-card::after { content: ""; position: absolute; right: -38px; bottom: -48px; width: 110px; height: 110px; border: 1px solid color-mix(in srgb, var(--primary) 12%, transparent); border-radius: 50%; }
|
||||
.stat-card span, .stat-card small { position: relative; z-index: 1; color: var(--muted); }
|
||||
.stat-card strong { position: relative; z-index: 1; margin: 2px 0; font-size: 34px; line-height: 1; letter-spacing: -.045em; }
|
||||
.stat-card.signal { border-color: color-mix(in srgb, var(--primary) 35%, var(--border)); box-shadow: var(--signal-glow); }
|
||||
.kpi-label { font-size: 10px; text-transform: uppercase; letter-spacing: .09em; }
|
||||
.panel { padding: clamp(19px, 2.5vw, 30px); margin-bottom: 18px; }
|
||||
.panel > h2:first-child, .panel > h3:first-child { margin-top: 0; }
|
||||
.panel-subtle { background: var(--surface-low); box-shadow: none; }
|
||||
|
||||
.job-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 18px; }
|
||||
.job-card { padding: 22px; display: flex; flex-direction: column; gap: 16px; }
|
||||
.job-card-head { display: grid; grid-template-columns: auto 1fr; gap: 15px; align-items: flex-start; }
|
||||
.job-card h3 { margin: 7px 0 2px; font-size: 21px; line-height: 1.25; }
|
||||
.job-card h3 a { color: var(--text); }
|
||||
.dashboard-grid { display: grid; grid-template-columns: minmax(0, 1fr) 320px; gap: 20px; align-items: start; }
|
||||
.dashboard-rail { position: sticky; top: calc(var(--utility) + 20px); }
|
||||
.radar-summary { position: relative; overflow: hidden; background: radial-gradient(circle at 88% 10%, var(--primary-soft), transparent 35%), var(--surface); }
|
||||
.radar-summary::after { content: ""; position: absolute; right: -50px; top: -55px; width: 190px; height: 190px; border: 1px solid color-mix(in srgb, var(--primary) 18%, transparent); border-radius: 50%; box-shadow: inset 0 0 0 38px color-mix(in srgb, var(--primary) 2%, transparent), inset 0 0 0 74px color-mix(in srgb, var(--primary) 3%, transparent); pointer-events: none; }
|
||||
.radar-summary > * { position: relative; z-index: 1; }
|
||||
.rail-list { display: grid; gap: 10px; margin: 16px 0 0; }
|
||||
.rail-item { display: flex; align-items: center; justify-content: space-between; gap: 14px; padding: 12px 0; border-top: 1px solid var(--border); }
|
||||
.rail-item:first-child { border-top: 0; }
|
||||
.rail-item span { color: var(--muted); }
|
||||
|
||||
.job-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 15px; }
|
||||
.job-card { position: relative; display: flex; flex-direction: column; gap: 15px; padding: 20px; transition: border-color .16s ease, transform .16s ease, box-shadow .16s ease; }
|
||||
.job-card:hover { border-color: var(--border-strong); transform: translateY(-2px); }
|
||||
.job-card.strong-match { border-color: color-mix(in srgb, var(--primary) 38%, var(--border)); box-shadow: var(--signal-glow); background: linear-gradient(145deg, var(--primary-soft), transparent 45%), var(--surface); }
|
||||
.job-card-head { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 15px; align-items: start; }
|
||||
.job-card h2, .job-card h3 { margin: 6px 0 3px; font-size: 20px; line-height: 1.24; letter-spacing: -.02em; }
|
||||
.job-card h2 a, .job-card h3 a { color: var(--text); }
|
||||
.job-card p { margin: 0; color: var(--muted); }
|
||||
.score-ring {
|
||||
width: 58px;
|
||||
height: 58px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: 50%;
|
||||
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); }
|
||||
.score-block { min-width: 72px; display: grid; justify-items: end; }
|
||||
.score-block strong { color: var(--primary); font-size: 27px; line-height: 1; letter-spacing: -.04em; }
|
||||
.score-block small { margin-top: 5px; color: var(--muted); font: 9px ui-monospace, SFMono-Regular, Consolas, monospace; text-transform: uppercase; letter-spacing: .06em; }
|
||||
.job-meta { display: flex; flex-wrap: wrap; gap: 7px; color: var(--muted); font-size: 11px; }
|
||||
.job-meta span { display: inline-flex; align-items: center; gap: 5px; padding: 5px 7px; border-radius: 4px; background: var(--surface-high); border: 1px solid color-mix(in srgb, var(--border) 70%, transparent); }
|
||||
.job-meta .icon { width: 13px; height: 13px; }
|
||||
.job-actions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: auto; }
|
||||
.pill { display: inline-flex; align-items: center; width: fit-content; padding: 4px 8px; border-radius: 999px; background: var(--surface-2); color: var(--muted); font-size: 12px; font-weight: 750; }
|
||||
.pill-strong, .pill-allow { background: var(--positive-soft); color: var(--positive); }
|
||||
.pill-possible, .pill-review { background: var(--warning-soft); color: var(--warning); }
|
||||
.pill-hidden, .pill-deny { background: var(--danger-soft); color: var(--danger); }
|
||||
.pill { display: inline-flex; align-items: center; gap: 6px; width: fit-content; padding: 4px 8px; border: 1px solid var(--border); border-radius: 999px; background: var(--surface-high); color: var(--muted); font-size: 10px; font-weight: 750; text-transform: uppercase; letter-spacing: .04em; }
|
||||
.pill::before { content: ""; width: 6px; height: 6px; border-radius: 50%; background: currentColor; }
|
||||
.pill-strong, .pill-allow, .pill-active, .pill-success, .pill-offer { background: var(--positive-soft); border-color: color-mix(in srgb, var(--positive) 24%, var(--border)); color: var(--positive); }
|
||||
.pill-possible, .pill-review, .pill-uncertain, .pill-partial, .pill-interview { background: var(--warning-soft); border-color: color-mix(in srgb, var(--warning) 24%, var(--border)); color: var(--warning); }
|
||||
.pill-hidden, .pill-deny, .pill-failed, .pill-quarantined, .pill-rejected { background: var(--danger-soft); border-color: color-mix(in srgb, var(--danger) 24%, var(--border)); color: var(--danger); }
|
||||
.pill-new, .pill-applied, .pill-trial { background: var(--primary-soft); border-color: color-mix(in srgb, var(--primary) 24%, var(--border)); color: var(--primary-strong); }
|
||||
.compact-list { margin: 0; padding-left: 20px; }
|
||||
.compact-list li + li { margin-top: 5px; }
|
||||
.compact-list li + li { margin-top: 6px; }
|
||||
.compact-list.positive li::marker { color: var(--positive); }
|
||||
.compact-list.caution li::marker { color: var(--warning); }
|
||||
.attention { padding: 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; }
|
||||
input, select, textarea {
|
||||
width: 100%;
|
||||
min-height: 42px;
|
||||
padding: 9px 11px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 9px;
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
}
|
||||
.field-label { color: var(--muted); font: 10px ui-monospace, SFMono-Regular, Consolas, monospace; text-transform: uppercase; letter-spacing: .07em; }
|
||||
input, select, textarea { width: 100%; min-height: 43px; padding: 9px 11px; border: 1px solid var(--border); border-radius: 5px; background: var(--surface-low); color: var(--text); }
|
||||
input::placeholder, textarea::placeholder { color: color-mix(in srgb, var(--muted) 78%, transparent); }
|
||||
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 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 td { padding: 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 th { text-align: left; padding: 12px 15px; background: var(--surface-low); color: var(--muted); font: 10px ui-monospace, SFMono-Regular, Consolas, monospace; text-transform: uppercase; letter-spacing: .07em; }
|
||||
.data-table td { padding: 14px 15px; border-top: 1px solid var(--border); vertical-align: middle; }
|
||||
.data-table tbody tr:hover { background: var(--surface-low); }
|
||||
.data-table td small, .table-title + small { display: block; margin-top: 3px; color: var(--muted); }
|
||||
.table-title { color: var(--text); font-weight: 760; }
|
||||
.pagination { display: flex; justify-content: center; 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; }
|
||||
.back-link { display: inline-block; 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); }
|
||||
.hero-subtitle { margin: 10px 0 14px; color: var(--muted); 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 strong { font-size: 36px; line-height: 1; }
|
||||
.hero-score span, .hero-score small { color: var(--muted); font-size: 12px; }
|
||||
.sticky-card { position: sticky; top: 92px; padding: 22px; background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); box-shadow: var(--shadow); }
|
||||
.sticky-card h2 { margin-top: 0; }
|
||||
.detail-layout { display: grid; grid-template-columns: minmax(0, 1fr) 320px; gap: 20px; align-items: start; }
|
||||
.back-link { display: inline-flex; align-items: center; gap: 6px; margin-bottom: 15px; color: var(--muted); }
|
||||
.job-hero { position: relative; overflow: hidden; display: flex; justify-content: space-between; gap: 28px; padding: clamp(24px, 4vw, 40px); margin-bottom: 18px; border: 1px solid color-mix(in srgb, var(--primary) 30%, var(--border)); border-radius: var(--radius); background: radial-gradient(circle at 88% 25%, var(--primary-soft), transparent 32%), linear-gradient(145deg, var(--surface), var(--surface-low)); box-shadow: var(--signal-glow); }
|
||||
.hero-subtitle { margin: 11px 0 15px; color: var(--text-soft); font-size: 17px; }
|
||||
.hero-score { min-width: 136px; display: grid; justify-items: end; align-content: start; }
|
||||
.hero-score strong { color: var(--primary); font-size: 54px; line-height: .95; letter-spacing: -.06em; }
|
||||
.hero-score span { margin-top: 8px; color: var(--primary-strong); font: 10px ui-monospace, SFMono-Regular, Consolas, monospace; text-transform: uppercase; letter-spacing: .08em; }
|
||||
.hero-score small { margin-top: 3px; color: var(--muted); }
|
||||
.sticky-card { position: sticky; top: calc(var(--utility) + 20px); padding: 20px; background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); box-shadow: var(--shadow); }
|
||||
.sticky-card h2 { margin: 0 0 4px; }
|
||||
.sticky-card > p { margin-top: 0; color: var(--muted); }
|
||||
.stack-actions { display: grid; gap: 9px; }
|
||||
.hide-form { display: grid; gap: 8px; }
|
||||
.hide-form { display: grid; gap: 8px; padding-top: 9px; border-top: 1px solid var(--border); }
|
||||
.hide-form label { color: var(--muted); font-size: 12px; }
|
||||
.score-components { display: grid; gap: 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 strong { text-align: right; }
|
||||
.progress { height: 9px; overflow: hidden; border-radius: 999px; background: var(--surface-2); }
|
||||
.progress i { display: block; height: 100%; min-width: 2px; border-radius: inherit; background: var(--primary); }
|
||||
.two-columns { display: grid; grid-template-columns: repeat(2, 1fr); gap: 22px; }
|
||||
.prose { max-width: 82ch; }
|
||||
.prose h2, .prose h3, .prose h4 { margin-top: 1.5em; }
|
||||
.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: 12px; }
|
||||
.definition-grid div { padding: 12px; border-radius: 10px; background: var(--surface-2); }
|
||||
.definition-grid dt { color: var(--muted); font-size: 12px; }
|
||||
.definition-grid dd { margin: 3px 0 0; font-weight: 700; }
|
||||
.progress { height: 7px; overflow: hidden; border-radius: 999px; background: var(--surface-high); }
|
||||
.progress i { display: block; height: 100%; min-width: 2px; border-radius: inherit; background: linear-gradient(90deg, var(--primary), var(--indigo)); }
|
||||
.two-columns { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 22px; }
|
||||
.prose { max-width: 82ch; color: var(--text-soft); }
|
||||
.prose h2, .prose h3, .prose h4 { margin-top: 1.5em; color: var(--text); }
|
||||
.definition-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; }
|
||||
.definition-grid div { padding: 12px; border: 1px solid var(--border); border-radius: 5px; background: var(--surface-low); }
|
||||
.definition-grid dt { color: var(--muted); font: 10px ui-monospace, SFMono-Regular, Consolas, monospace; text-transform: uppercase; }
|
||||
.definition-grid dd { margin: 4px 0 0; font-weight: 700; }
|
||||
.source-list { list-style: none; padding: 0; }
|
||||
.source-list li { display: flex; justify-content: space-between; gap: 16px; padding: 11px 0; border-top: 1px solid var(--border); }
|
||||
.source-list span { color: var(--muted); font-size: 12px; }
|
||||
|
||||
.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 h2, .application-card h2 { margin: 5px 0; }
|
||||
.form-panel { max-width: 980px; }
|
||||
.form-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 18px; }
|
||||
.form-grid label { display: grid; gap: 6px; color: var(--muted); font-size: 13px; font-weight: 650; }
|
||||
.form-panel { max-width: 1100px; }
|
||||
.form-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 17px; }
|
||||
.form-grid label { display: grid; align-content: start; gap: 6px; color: var(--muted); font-size: 13px; font-weight: 650; }
|
||||
.form-grid label > input, .form-grid label > select, .form-grid label > textarea { color: var(--text); font-weight: 400; }
|
||||
.form-section { grid-column: 1 / -1; margin: 12px 0 -2px; padding-top: 18px; border-top: 1px solid var(--border); }
|
||||
.form-section h2 { margin: 0; font-size: 17px; }
|
||||
.form-section p { margin: 4px 0 0; color: var(--muted); font-size: 12px; }
|
||||
.span-2 { grid-column: 1 / -1; }
|
||||
.field-error { color: var(--danger); }
|
||||
.form-actions { 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; }
|
||||
.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.success { background: var(--positive-soft); color: var(--positive); }
|
||||
.message.warning { background: var(--warning-soft); color: var(--warning); }
|
||||
.message.error { background: var(--danger-soft); 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); }
|
||||
.message { padding: 12px 14px; border: 1px solid color-mix(in srgb, var(--primary) 24%, var(--border)); border-left: 3px solid var(--primary); border-radius: 5px; background: var(--primary-soft); color: var(--primary-strong); }
|
||||
.message.success { background: var(--positive-soft); border-color: color-mix(in srgb, var(--positive) 25%, var(--border)); border-left-color: var(--positive); color: var(--positive); }
|
||||
.message.warning { background: var(--warning-soft); border-color: color-mix(in srgb, var(--warning) 25%, var(--border)); border-left-color: var(--warning); color: var(--warning); }
|
||||
.message.error { background: var(--danger-soft); border-color: color-mix(in srgb, var(--danger) 25%, var(--border)); border-left-color: var(--danger); color: var(--danger); }
|
||||
.empty-state { grid-column: 1 / -1; padding: 48px 24px; text-align: center; border: 1px dashed var(--border-strong); border-radius: var(--radius); background: var(--surface-low); }
|
||||
.empty-state h2 { margin-bottom: 6px; }
|
||||
.empty-state p { color: var(--muted); max-width: 600px; margin: 0 auto 16px; }
|
||||
.empty-icon { font-size: 40px; color: var(--primary); }
|
||||
.auth-card { width: min(460px, 100%); padding: 32px; }
|
||||
.empty-state p { max-width: 600px; margin: 0 auto 16px; color: var(--muted); }
|
||||
.empty-icon { width: 54px; height: 54px; display: grid; place-items: center; margin: 0 auto 14px; border: 1px solid var(--border); border-radius: 50%; color: var(--primary); background: var(--primary-soft); }
|
||||
.empty-icon .icon { width: 27px; height: 27px; }
|
||||
.muted { color: var(--muted); }
|
||||
.small { font-size: 12px; }
|
||||
.auth-main { min-height: 100vh; display: grid; place-items: center; padding: 24px; background: radial-gradient(circle at 18% 14%, var(--primary-soft), transparent 32%), radial-gradient(circle at 83% 85%, rgba(104, 112, 255, .08), transparent 28%), var(--bg); }
|
||||
.auth-card { width: min(450px, 100%); padding: 30px; }
|
||||
.auth-brand { display: flex; align-items: center; gap: 14px; margin-bottom: 24px; }
|
||||
.auth-card h1 { margin: 0; }
|
||||
.auth-card p { margin: 4px 0; color: var(--muted); }
|
||||
.stack { display: grid; gap: 16px; }
|
||||
.stack label { display: grid; gap: 6px; font-weight: 650; }
|
||||
.muted { color: var(--muted); }
|
||||
.small { font-size: 12px; }
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.stats-grid { grid-template-columns: repeat(2, 1fr); }
|
||||
.job-grid { grid-template-columns: 1fr; }
|
||||
@media (max-width: 1180px) {
|
||||
.stats-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.dashboard-grid { grid-template-columns: 1fr; }
|
||||
.dashboard-rail { position: static; display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 15px; }
|
||||
.detail-layout { grid-template-columns: 1fr; }
|
||||
.sticky-card { position: static; }
|
||||
.source-overview { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.mobile-only { display: inline-grid; place-items: center; }
|
||||
.topbar { padding: 10px 14px; }
|
||||
.brand small, .topbar-actions .button:first-child { display: none; }
|
||||
.app-shell { grid-template-columns: 1fr; }
|
||||
.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); }
|
||||
.sidebar.open { transform: translateX(0); }
|
||||
.main-content { padding: 24px 15px 50px; }
|
||||
.page-header, .profile-card, .application-card, .job-hero { flex-direction: column; }
|
||||
.page-header .button { width: 100%; }
|
||||
.stats-grid, .two-columns, .form-grid, .definition-grid { grid-template-columns: 1fr; }
|
||||
.filter-bar { flex-direction: column; }
|
||||
.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: 900px) {
|
||||
.mobile-only { display: inline-grid !important; }
|
||||
.sidebar { visibility: hidden; transform: translateX(-105%); transition: transform .18s ease, visibility 0s linear .18s; }
|
||||
.workspace { margin-left: 0; }
|
||||
.nav-open .sidebar { visibility: visible; transform: translateX(0); transition-delay: 0s; }
|
||||
.nav-scrim { position: fixed; inset: 0; z-index: 50; width: 100%; height: 100%; border: 0; background: rgba(1, 8, 17, .62); backdrop-filter: blur(2px); }
|
||||
.nav-open .nav-scrim { display: block; }
|
||||
.utilitybar { padding: 0 16px; }
|
||||
.main-content { padding-top: 26px; }
|
||||
.job-grid { grid-template-columns: 1fr; }
|
||||
.dashboard-rail { grid-template-columns: 1fr 1fr; }
|
||||
.pipeline { grid-template-columns: repeat(6, 245px); }
|
||||
}
|
||||
|
||||
@media (max-width: 680px) {
|
||||
.desktop-label { display: none; }
|
||||
.utility-link { width: 42px; justify-content: center; padding: 0; border: 1px solid transparent; }
|
||||
.main-content { padding: 22px 14px 54px; }
|
||||
.page-header, .profile-card, .application-card, .job-hero { align-items: stretch; flex-direction: column; }
|
||||
.page-header .button, .page-actions { width: 100%; }
|
||||
.page-actions .button { flex: 1; }
|
||||
.stats-grid, .two-columns, .form-grid, .definition-grid, .source-overview, .source-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-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) {
|
||||
|
||||
@@ -1,23 +1,54 @@
|
||||
(() => {
|
||||
const toggle = document.querySelector('[data-nav-toggle]');
|
||||
const sidebar = document.querySelector('[data-sidebar]');
|
||||
if (!toggle || !sidebar) return;
|
||||
const root = document.documentElement;
|
||||
let storedTheme = null;
|
||||
try {
|
||||
storedTheme = localStorage.getItem("vacatureradar-theme");
|
||||
} catch {
|
||||
// Private or embedded browser contexts may disable persistent storage.
|
||||
}
|
||||
if (storedTheme === "light" || storedTheme === "dark") root.dataset.theme = storedTheme;
|
||||
|
||||
const close = () => {
|
||||
sidebar.classList.remove('open');
|
||||
toggle.setAttribute('aria-expanded', 'false');
|
||||
};
|
||||
|
||||
toggle.addEventListener('click', () => {
|
||||
const open = sidebar.classList.toggle('open');
|
||||
toggle.setAttribute('aria-expanded', String(open));
|
||||
});
|
||||
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();
|
||||
const themeToggle = document.querySelector("[data-theme-toggle]");
|
||||
themeToggle?.addEventListener("click", () => {
|
||||
const theme = root.dataset.theme === "light" ? "dark" : "light";
|
||||
root.dataset.theme = theme;
|
||||
try {
|
||||
localStorage.setItem("vacatureradar-theme", theme);
|
||||
} catch {
|
||||
// The selected theme still applies for the current page.
|
||||
}
|
||||
themeToggle.setAttribute("aria-label", theme === "light" ? "Donker thema activeren" : "Licht thema activeren");
|
||||
});
|
||||
|
||||
const frame = document.querySelector("[data-app-frame]");
|
||||
const 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;
|
||||
});
|
||||
});
|
||||
})();
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
{% extends "base.html" %}
|
||||
{% 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 %}
|
||||
|
||||
@@ -1,3 +1,18 @@
|
||||
{% extends "base.html" %}
|
||||
{% 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 %}
|
||||
|
||||
@@ -1,50 +1,87 @@
|
||||
{% load static %}
|
||||
<!doctype html>
|
||||
<html lang="nl">
|
||||
<html lang="nl" data-theme="dark">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<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>
|
||||
<link rel="stylesheet" href="{% static 'css/app.css' %}">
|
||||
<script src="{% static 'js/app.js' %}" defer></script>
|
||||
<link rel="stylesheet" href="{% static 'css/app.css' %}?v={{ app_version }}">
|
||||
<script src="{% static 'js/app.js' %}?v={{ app_version }}" defer></script>
|
||||
</head>
|
||||
<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 %}
|
||||
<header class="topbar">
|
||||
<div class="brand-wrap">
|
||||
<button class="icon-button mobile-only" type="button" data-nav-toggle aria-label="Menu openen" aria-expanded="false">☰</button>
|
||||
<a class="brand" href="{% url 'dashboard:today' %}">
|
||||
<span class="brand-mark" aria-hidden="true">VR</span>
|
||||
<span><strong>VacatureRadar</strong><small>persoonlijke vacature-assistent</small></span>
|
||||
</a>
|
||||
</div>
|
||||
<div class="topbar-actions">
|
||||
<a class="button button-ghost" href="{% url 'dashboard:system' %}">Systeem</a>
|
||||
<form action="{% url 'logout' %}" method="post">{% csrf_token %}<button class="button button-ghost" type="submit">Afmelden</button></form>
|
||||
</div>
|
||||
</header>
|
||||
<div class="app-shell">
|
||||
<nav class="sidebar" data-sidebar aria-label="Hoofdnavigatie">
|
||||
<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 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.url_name == 'applications' %}active{% endif %}" href="{% url 'jobs:applications' %}"><span>Sollicitaties</span><small>opvolging</small></a>
|
||||
<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 class="nav-link {% if request.resolver_match.namespace == 'sources' %}active{% endif %}" href="{% url 'sources:list' %}"><span>Brongezondheid</span><small>geavanceerd</small></a>
|
||||
<div class="sidebar-foot"><span class="status-dot"></span> strict mode <small>v{{ app_version }}</small></div>
|
||||
</nav>
|
||||
<main id="main" class="main-content" tabindex="-1">
|
||||
<div class="app-frame" data-app-frame>
|
||||
<aside class="sidebar" data-sidebar aria-label="Hoofdnavigatie">
|
||||
<div class="sidebar-brand">
|
||||
<a class="brand" href="{% url 'dashboard:today' %}">
|
||||
<span class="brand-mark" aria-hidden="true"><svg class="icon"><use href="#icon-radar"></use></svg></span>
|
||||
<span><strong>VacatureRadar</strong><small>Intelligence cockpit</small></span>
|
||||
</a>
|
||||
<button class="icon-button sidebar-close mobile-only" type="button" data-nav-close aria-label="Menu sluiten">
|
||||
<svg class="icon"><use href="#icon-close"></use></svg>
|
||||
</button>
|
||||
</div>
|
||||
<nav class="nav-stack">
|
||||
<p class="nav-group-label">Radar</p>
|
||||
<a class="nav-link {% if request.resolver_match.namespace == 'dashboard' and request.resolver_match.url_name == 'today' %}active{% endif %}" href="{% url 'dashboard:today' %}">
|
||||
<svg class="icon"><use href="#icon-dashboard"></use></svg><span>Vandaag<small>beste matches</small></span>
|
||||
</a>
|
||||
<a class="nav-link {% if request.resolver_match.namespace == 'jobs' and request.resolver_match.url_name == 'list' %}active{% endif %}" href="{% url 'jobs:list' %}">
|
||||
<svg class="icon"><use href="#icon-search"></use></svg><span>Vacatures<small>zoeken en filteren</small></span>
|
||||
</a>
|
||||
<a class="nav-link {% if request.resolver_match.namespace == 'jobs' and request.resolver_match.url_name == 'applications' or request.resolver_match.url_name == 'application-edit' %}active{% endif %}" href="{% url 'jobs:applications' %}">
|
||||
<svg class="icon"><use href="#icon-pipeline"></use></svg><span>Sollicitaties<small>persoonlijke pipeline</small></span>
|
||||
</a>
|
||||
<p class="nav-group-label">Beheer</p>
|
||||
<a class="nav-link {% if request.resolver_match.namespace == 'profiles' %}active{% endif %}" href="{% url 'profiles:list' %}">
|
||||
<svg class="icon"><use href="#icon-profile"></use></svg><span>Zoekprofiel<small>regels en voorkeuren</small></span>
|
||||
</a>
|
||||
<a class="nav-link {% if request.resolver_match.namespace == 'sources' %}active{% endif %}" href="{% url 'sources:list' %}">
|
||||
<svg class="icon"><use href="#icon-sources"></use></svg><span>Bronnen<small>import en gezondheid</small></span>
|
||||
</a>
|
||||
<a class="nav-link {% if request.resolver_match.namespace == 'dashboard' and request.resolver_match.url_name == 'system' %}active{% endif %}" href="{% url 'dashboard:system' %}">
|
||||
<svg class="icon"><use href="#icon-system"></use></svg><span>Systeem<small>readiness en beheer</small></span>
|
||||
</a>
|
||||
</nav>
|
||||
<div class="sidebar-user">
|
||||
<span class="avatar" aria-hidden="true">{{ user.username|slice:":1"|upper }}</span>
|
||||
<span><strong>{{ user.get_full_name|default:user.username }}</strong><small>Lokale gebruiker</small></span>
|
||||
<span class="status-dot" title="Aangemeld"></span>
|
||||
</div>
|
||||
</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 %}
|
||||
<main id="main" class="auth-main" tabindex="-1">
|
||||
{% endif %}
|
||||
{% 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 %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
{% if user.is_authenticated %}</div>{% endif %}
|
||||
{% if user.is_authenticated %}</div></div>{% endif %}
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -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 |
@@ -2,31 +2,43 @@
|
||||
{% block title %}Vandaag · VacatureRadar{% endblock %}
|
||||
{% block content %}
|
||||
<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>
|
||||
<a class="button button-secondary" href="{% url 'jobs:list' %}">Alle vacatures</a>
|
||||
<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>
|
||||
<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>
|
||||
<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"><span>Actief</span><strong>{{ active_jobs }}</strong><small>actuele vacatures</small></article>
|
||||
<article class="stat-card"><span>Sollicitaties</span><strong>{{ applications_open }}</strong><small>open dossiers</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 signal"><span class="kpi-label">Nieuwe signalen</span><strong>{{ new_today }}</strong><small>vandaag gevonden</small></article>
|
||||
<article class="stat-card"><span class="kpi-label">Actieve vacatures</span><strong>{{ active_jobs }}</strong><small>actueel in de radar</small></article>
|
||||
<article class="stat-card"><span class="kpi-label">Open dossiers</span><strong>{{ applications_open }}</strong><small>sollicitaties in opvolging</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 class="section-heading"><div><h2>Beste matches</h2><p>Gerangschikt op persoonlijke matchscore; confidence blijft afzonderlijk zichtbaar.</p></div></section>
|
||||
<div class="job-grid">
|
||||
{% for score in score_cards %}
|
||||
<article class="job-card">
|
||||
<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-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>
|
||||
{% 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">Bewaren</button></form>
|
||||
<a class="button button-ghost" href="{% url 'jobs:detail' score.job.pk %}">Details</a>
|
||||
|
||||
<div class="dashboard-grid">
|
||||
<section>
|
||||
<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-grid">
|
||||
{% for score in score_cards %}
|
||||
<article class="job-card {% if score.recommendation == 'strong' %}strong-match{% endif %}">
|
||||
<div class="job-card-head">
|
||||
<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 class="score-block"><strong>{{ score.score|floatformat:0 }}%</strong><small>confidence {{ score.confidence|floatformat:2 }}</small></div>
|
||||
</div>
|
||||
<div class="job-meta"><span><svg class="icon"><use href="#icon-location"></use></svg>{{ score.job.raw_location|default:"Locatie onbekend" }}</span><span><svg class="icon"><use href="#icon-briefcase"></use></svg>{{ score.job.get_workplace_type_display }}</span></div>
|
||||
{% if score.positives %}<ul class="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>
|
||||
</article>
|
||||
{% empty %}
|
||||
<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>
|
||||
{% endfor %}
|
||||
</section>
|
||||
<aside class="dashboard-rail" aria-label="Radarstatus">
|
||||
<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>
|
||||
<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>
|
||||
{% endblock %}
|
||||
|
||||
@@ -1,14 +1,19 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}{{ job.original_title }} · VacatureRadar{% endblock %}
|
||||
{% block content %}
|
||||
<a class="back-link" href="{% url 'jobs:list' %}">← Terug naar vacatures</a>
|
||||
<div class="detail-layout">
|
||||
<div class="detail-main">
|
||||
<a class="back-link" href="{% url 'jobs:list' %}">← Terug naar vacatures</a>
|
||||
<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>
|
||||
{% 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 %}
|
||||
<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>
|
||||
<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>
|
||||
<header class="job-hero">
|
||||
<div><p class="eyebrow">{{ job.get_status_display }}{% if job.date_posted %} · gepubliceerd {{ job.date_posted|date:"d/m/Y" }}{% endif %}</p><h1>{{ job.original_title }}</h1><p class="hero-subtitle">{{ job.employer_name }}{% if job.raw_location %} · {{ job.raw_location }}{% endif %}</p><div class="job-meta"><span><svg class="icon"><use href="#icon-briefcase"></use></svg>{{ job.get_workplace_type_display }}</span>{% for type in job.employment_types %}<span>{{ type }}</span>{% endfor %}{% if job.hours_text %}<span>{{ job.hours_text }}</span>{% endif %}{% if job.valid_through %}<span><svg class="icon"><use href="#icon-clock"></use></svg>sluit {{ job.valid_through|date:"d/m/Y" }}</span>{% endif %}</div></div>
|
||||
{% 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 %}
|
||||
</header>
|
||||
{% 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>
|
||||
<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>
|
||||
{% endblock %}
|
||||
|
||||
@@ -1,16 +1,23 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Vacatures · VacatureRadar{% endblock %}
|
||||
{% 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">
|
||||
<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><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="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>
|
||||
<button class="button button-secondary" type="submit">Filter</button>
|
||||
<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="field-label">Status</span><select name="status"><option value="">Alle statussen</option>{% for value,label in view.model.Status.choices %}<option value="{{ value }}" {% if request.GET.status|default:'active' == value %}selected{% endif %}>{{ label }}</option>{% endfor %}</select></label>
|
||||
<label><span class="field-label">Werkmodel</span><select name="workplace"><option value="">Elk werkmodel</option>{% for value,label in view.model.Workplace.choices %}<option value="{{ value }}" {% if request.GET.workplace == value %}selected{% endif %}>{{ label }}</option>{% endfor %}</select></label>
|
||||
<button class="button button-primary" type="submit"><svg class="icon"><use href="#icon-search"></use></svg>Filter</button>
|
||||
</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>
|
||||
{% 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>
|
||||
{% empty %}<tr><td colspan="5">Geen vacatures gevonden voor deze filters.</td></tr>{% endfor %}
|
||||
</tbody></table></div>
|
||||
{% 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="section-heading"><div><span class="section-kicker">Resultaten</span><h2>{{ page_obj.paginator.count|default:jobs|length }} vacatures</h2></div></div>
|
||||
<div class="job-grid">
|
||||
{% for job in jobs %}
|
||||
<article class="job-card">
|
||||
<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 %}
|
||||
|
||||
@@ -1,3 +1,11 @@
|
||||
{% extends "base.html" %}
|
||||
{% 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 %}
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
{% extends "base.html" %}
|
||||
{% 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 %}
|
||||
|
||||
@@ -2,13 +2,9 @@
|
||||
{% block title %}Aanmelden · VacatureRadar{% endblock %}
|
||||
{% block content %}
|
||||
<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 %}
|
||||
<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>
|
||||
<p class="muted small">De applicatie is ontworpen voor lokaal gebruik via LAN of VPN.</p>
|
||||
<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>
|
||||
<p class="muted small">Beveiligde lokale toegang via je eigen netwerk, VPN of reverse proxy.</p>
|
||||
</section>
|
||||
{% endblock %}
|
||||
|
||||
@@ -1,499 +1,44 @@
|
||||
{% extends "base.html" %}
|
||||
{% load static %}
|
||||
<!doctype html>
|
||||
<html class="dark" lang="nl">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta content="width=device-width, initial-scale=1.0" name="viewport" />
|
||||
<title>VacatureRadar - Bronnen & Source Health</title>
|
||||
<script src="https://cdn.tailwindcss.com?plugins=forms,container-queries"></script>
|
||||
<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" />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&display=swap" rel="stylesheet" />
|
||||
<script id="tailwind-config">
|
||||
tailwind.config = {
|
||||
darkMode: "class",
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
"on-secondary-fixed-variant": "#005236",
|
||||
"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>
|
||||
{% block title %}Bronnen · VacatureRadar{% endblock %}
|
||||
{% block content %}
|
||||
<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>
|
||||
|
||||
<section class="source-overview" aria-label="Bronoverzicht">
|
||||
<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>
|
||||
<article class="stat-card"><span class="kpi-label">Policyreview</span><strong>{{ review_pending_count }}</strong><small>bronnen te beoordelen</small></article>
|
||||
<article class="stat-card"><span class="kpi-label">Veilige modus</span><strong>AAN</strong><small>policy + SSRF-validatie</small></article>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<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>
|
||||
<form id="manual-import-form" method="post" action="{% url 'sources:manual_import' %}" data-paste-limit="{{ manual_import_paste_max_bytes }}">{% csrf_token %}
|
||||
<div class="import-layout">
|
||||
<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>
|
||||
<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>
|
||||
</div>
|
||||
</header>
|
||||
<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="px-6 pb-6">
|
||||
<h1 class="font-headline-md text-headline-md text-primary font-bold">VacatureRadar</h1>
|
||||
<p class="font-label-mono text-label-mono text-on-surface-variant opacity-60">Intelligent Discovery</p>
|
||||
{% for error in manual_import_form.non_field_errors %}<div class="message error">{{ error }}</div>{% endfor %}
|
||||
<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>
|
||||
</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="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>
|
||||
<nav class="flex flex-col px-3">
|
||||
<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">dashboard</span>
|
||||
<span class="font-label-mono text-label-mono">Overview</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">radar</span>
|
||||
<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>
|
||||
{% 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 %}
|
||||
<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>
|
||||
</article>
|
||||
{% 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 %}
|
||||
</div>
|
||||
<script src="{% static 'js/manual_import.js' %}?v={{ app_version }}" defer></script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
{% extends "base.html" %}
|
||||
{% 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/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")
|
||||
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:
|
||||
_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"))
|
||||
assert response.status_code == 200
|
||||
_assert_basic_html_accessibility(response.content, page_name="applications")
|
||||
assert "Sollicitatiepipeline" in response.content.decode("utf-8")
|
||||
|
||||
response = client.get(reverse("sources:list"))
|
||||
assert response.status_code == 200
|
||||
_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:
|
||||
page.wait_for_load_state("networkidle")
|
||||
|
||||
@@ -4,7 +4,7 @@ import pytest
|
||||
from django.urls import reverse
|
||||
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.notifications.tasks import build_due_digests
|
||||
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("sources:list")).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.save(update_fields=["policy"])
|
||||
|
||||