This commit is contained in:
@@ -2,6 +2,22 @@
|
||||
|
||||
Alle betekenisvolle wijzigingen worden hier bijgehouden. Het project volgt voorlopig een pre-1.0 semantische versieaanpak.
|
||||
|
||||
## 0.2.11 — 2026-07-22
|
||||
|
||||
### Gewijzigd
|
||||
|
||||
- De standaardradar toont alleen titelmatig aantoonbare IT-vacatures en laat verborgen/hard uitgesloten matches weg; het volledige bron- en scorearchief blijft expliciet bereikbaar.
|
||||
- Vacatures worden standaard op de laatste persoonlijke matchscore gerangschikt en tonen scorebewijs, datakwaliteit, aandachtspunt en directe acties in de lijst.
|
||||
- Vandaag gebruikt uitsluitend de laatste gebruikersgescopeerde score en telt alleen persoonlijke open dossiers.
|
||||
- Dashboard, vacaturelijst en detail volgen de Stitch-panelhiërarchie met context-rails, rijkere evidencekaarten en een adaptieve contentbreedte tot 2240 px.
|
||||
- Op 1920 en 2560 px krijgen resultaatfeeds twee kolommen; op 320 px stapelen filters, sectiekoppen en acties zonder horizontale overflow.
|
||||
- Feedbackterugkeer is tegen externe redirects afgeschermd en `/favicon.ico` gebruikt het lokale SVG-favicon.
|
||||
|
||||
### Verificatie
|
||||
|
||||
- 225 tests geslaagd, 2 optionele Playwrightvarianten overgeslagen en 84,53% branch-aware codedekking; Ruff, Django, migraties, taakledger en repositoryvalidatie zijn groen.
|
||||
- De geïntegreerde browsermatrix controleerde zes kernroutes en zes breedtes van 320 tot 2560 px, dark/light, mobiele navigatie en de radar-/archiefflow zonder horizontale paginaoverflow.
|
||||
|
||||
## 0.2.10 — 2026-07-22
|
||||
|
||||
### Gewijzigd
|
||||
|
||||
@@ -10,7 +10,7 @@ from apps.profiles.models import SearchProfile
|
||||
def navigation_context(request: HttpRequest) -> dict[str, Any]:
|
||||
context = {
|
||||
"app_name": "VacatureRadar",
|
||||
"app_version": "0.2.2",
|
||||
"app_version": "0.2.11",
|
||||
}
|
||||
if request.user.is_authenticated:
|
||||
context["navigation_profile"] = (
|
||||
|
||||
+29
-9
@@ -4,13 +4,15 @@ from django.conf import settings
|
||||
from django.contrib import messages
|
||||
from django.contrib.auth.mixins import LoginRequiredMixin
|
||||
from django.contrib.auth.views import LoginView
|
||||
from django.db.models import Count, Q
|
||||
from django.db.models import Count, OuterRef, Q, Subquery
|
||||
from django.http import JsonResponse
|
||||
from django.utils import timezone
|
||||
from django.views.generic import TemplateView
|
||||
|
||||
from apps.core.rate_limit import clear_rate_limit, is_rate_limited, register_rate_limit_failure
|
||||
from apps.jobs.models import Application, JobPosting, ScoreRun
|
||||
from apps.jobs.services.relevance import it_relevance_query
|
||||
from apps.profiles.models import SearchProfile
|
||||
from apps.sources.models import Source
|
||||
from apps.sources.services.health import collect_source_health
|
||||
|
||||
@@ -83,9 +85,24 @@ class TodayView(LoginRequiredMixin, TemplateView):
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super().get_context_data(**kwargs)
|
||||
profile = SearchProfile.objects.filter(user=self.request.user, is_active=True).first()
|
||||
latest_score_id = (
|
||||
ScoreRun.objects.filter(job_id=OuterRef("job_id"), profile=profile)
|
||||
.order_by("-created_at")
|
||||
.values("pk")[:1]
|
||||
)
|
||||
latest_scores = (
|
||||
ScoreRun.objects.select_related("job", "job__employer", "profile")
|
||||
.filter(job__status=JobPosting.Status.ACTIVE, hard_exclusions=[])
|
||||
.filter(
|
||||
profile=profile,
|
||||
job__status=JobPosting.Status.ACTIVE,
|
||||
hard_exclusions=[],
|
||||
recommendation__in=[
|
||||
ScoreRun.Recommendation.STRONG,
|
||||
ScoreRun.Recommendation.POSSIBLE,
|
||||
],
|
||||
)
|
||||
.filter(it_relevance_query("job__"), pk=Subquery(latest_score_id))
|
||||
.order_by("-score", "-created_at")
|
||||
)
|
||||
# Eén score per vacature, zonder PostgreSQL-specifieke DISTINCT ON.
|
||||
@@ -99,6 +116,10 @@ class TodayView(LoginRequiredMixin, TemplateView):
|
||||
cards.append(score)
|
||||
if len(cards) >= 20:
|
||||
break
|
||||
active_it_jobs = JobPosting.objects.filter(
|
||||
Q(status=JobPosting.Status.ACTIVE) & it_relevance_query()
|
||||
)
|
||||
all_active_jobs = JobPosting.objects.filter(status=JobPosting.Status.ACTIVE)
|
||||
context.update(
|
||||
{
|
||||
"score_cards": cards,
|
||||
@@ -106,13 +127,12 @@ class TodayView(LoginRequiredMixin, TemplateView):
|
||||
.exclude(status__in=[Application.Status.REJECTED, Application.Status.WITHDRAWN])
|
||||
.select_related("job", "job__employer")
|
||||
.order_by("follow_up_date", "-updated_at")[:3],
|
||||
"active_jobs": JobPosting.objects.filter(status=JobPosting.Status.ACTIVE).count(),
|
||||
"new_today": JobPosting.objects.filter(
|
||||
first_seen__date=timezone.localdate()
|
||||
).count(),
|
||||
"applications_open": Application.objects.exclude(
|
||||
status__in=[Application.Status.REJECTED, Application.Status.WITHDRAWN]
|
||||
).count(),
|
||||
"active_jobs": active_it_jobs.count(),
|
||||
"filtered_non_it_jobs": all_active_jobs.exclude(it_relevance_query()).count(),
|
||||
"new_today": active_it_jobs.filter(first_seen__date=timezone.localdate()).count(),
|
||||
"applications_open": Application.objects.filter(user=self.request.user)
|
||||
.exclude(status__in=[Application.Status.REJECTED, Application.Status.WITHDRAWN])
|
||||
.count(),
|
||||
"source_counts": Source.objects.exclude(
|
||||
domain="jobs.example.org", status=Source.Status.DISABLED
|
||||
).aggregate(
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
|
||||
from django.db.models import Q
|
||||
|
||||
from apps.profiles.models import SearchProfile
|
||||
|
||||
from .normalization import normalize_token
|
||||
|
||||
# Deliberately title-led: employer pages regularly mention digital systems in otherwise
|
||||
# non-IT vacancies. A description hit alone must therefore never make a vacancy relevant.
|
||||
IT_TITLE_TERMS = (
|
||||
"it",
|
||||
"ict",
|
||||
"informatica",
|
||||
"software",
|
||||
"development engineer",
|
||||
"devops",
|
||||
"secops",
|
||||
"cloud",
|
||||
"data engineer",
|
||||
"data scientist",
|
||||
"data steward",
|
||||
"database",
|
||||
"dba",
|
||||
"business intelligence",
|
||||
"bi analyst",
|
||||
"system engineer",
|
||||
"systems engineer",
|
||||
"systeembeheer",
|
||||
"infrastructure engineer",
|
||||
"infrastructuur engineer",
|
||||
"network engineer",
|
||||
"network architect",
|
||||
"netwerkbeheer",
|
||||
"netwerk engineer",
|
||||
"cybersecurity",
|
||||
"cyber security",
|
||||
"security engineer",
|
||||
"security architect",
|
||||
"security analyst",
|
||||
"information security",
|
||||
"informatiebeveiliging",
|
||||
"workplace engineer",
|
||||
"modern workplace",
|
||||
"digital workplace",
|
||||
"service desk",
|
||||
"servicedesk",
|
||||
"helpdesk",
|
||||
"support engineer",
|
||||
"support specialist",
|
||||
"application lead",
|
||||
"application engineer",
|
||||
"application manager",
|
||||
"application specialist",
|
||||
"applicatiebeheer",
|
||||
"platform engineer",
|
||||
"platform expert",
|
||||
"solution architect",
|
||||
"solutions architect",
|
||||
"technical architect",
|
||||
"technisch architect",
|
||||
"functional analyst",
|
||||
"functioneel analist",
|
||||
"business analyst",
|
||||
"product owner",
|
||||
"web developer",
|
||||
"mobile developer",
|
||||
"low-code developer",
|
||||
"low code developer",
|
||||
"api developer",
|
||||
"idm developer",
|
||||
"power platform",
|
||||
"forgerock",
|
||||
"embedded",
|
||||
"c#",
|
||||
"front end",
|
||||
"frontend",
|
||||
"back end",
|
||||
"backend",
|
||||
"full stack",
|
||||
"fullstack",
|
||||
"php",
|
||||
".net",
|
||||
"java",
|
||||
"python",
|
||||
"powershell",
|
||||
"azure",
|
||||
"aws",
|
||||
"microsoft 365",
|
||||
"linux",
|
||||
"windows server",
|
||||
"kubernetes",
|
||||
"docker",
|
||||
"terraform",
|
||||
"erp",
|
||||
"sap",
|
||||
"machine learning",
|
||||
"ai engineer",
|
||||
"artificial intelligence",
|
||||
"image processing",
|
||||
"computer vision",
|
||||
"qa engineer",
|
||||
"test automation",
|
||||
)
|
||||
|
||||
# These phrases can contain IT vocabulary while describing commercial, recruitment,
|
||||
# or educational-design work. They therefore override positive title signals.
|
||||
NON_IT_TITLE_TERMS = (
|
||||
"business developer",
|
||||
"business development",
|
||||
"it recruitment",
|
||||
"it recruiter",
|
||||
"learning designer",
|
||||
)
|
||||
|
||||
IT_PROFILE_TERMS = (*IT_TITLE_TERMS, "networking", "security", "active directory", "intune")
|
||||
|
||||
|
||||
def _term_pattern(term: str) -> str:
|
||||
escaped = re.escape(term).replace(r"\ ", r"[\s/_-]+")
|
||||
return rf"(?<!\w){escaped}(?!\w)"
|
||||
|
||||
|
||||
IT_TITLE_PATTERN = "(?:" + "|".join(_term_pattern(term) for term in IT_TITLE_TERMS) + ")"
|
||||
NON_IT_TITLE_PATTERN = "(?:" + "|".join(_term_pattern(term) for term in NON_IT_TITLE_TERMS) + ")"
|
||||
_IT_TITLE_RE = re.compile(IT_TITLE_PATTERN, re.IGNORECASE)
|
||||
_NON_IT_TITLE_RE = re.compile(NON_IT_TITLE_PATTERN, re.IGNORECASE)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ItRelevanceAssessment:
|
||||
relevant: bool
|
||||
signals: tuple[str, ...]
|
||||
reason: str
|
||||
|
||||
|
||||
def assess_it_relevance(title: str) -> ItRelevanceAssessment:
|
||||
normalized_title = normalize_token(title)
|
||||
blocked_signal = _NON_IT_TITLE_RE.search(normalized_title)
|
||||
if blocked_signal:
|
||||
return ItRelevanceAssessment(
|
||||
relevant=False,
|
||||
signals=(),
|
||||
reason=f"Niet-technische titelcontext: {blocked_signal.group(0)}.",
|
||||
)
|
||||
signals = tuple(
|
||||
term
|
||||
for term in IT_TITLE_TERMS
|
||||
if re.search(_term_pattern(normalize_token(term)), normalized_title, re.IGNORECASE)
|
||||
)
|
||||
if signals:
|
||||
return ItRelevanceAssessment(
|
||||
relevant=True,
|
||||
signals=signals[:4],
|
||||
reason="IT-signaal in functietitel: " + ", ".join(signals[:4]),
|
||||
)
|
||||
return ItRelevanceAssessment(
|
||||
relevant=False,
|
||||
signals=(),
|
||||
reason="Geen aantoonbaar IT-signaal in de functietitel.",
|
||||
)
|
||||
|
||||
|
||||
def profile_requires_it_focus(profile: SearchProfile) -> bool:
|
||||
configured = " ".join([profile.name, *profile.desired_titles, *profile.desired_skills])
|
||||
normalized = normalize_token(configured)
|
||||
return any(
|
||||
re.search(_term_pattern(normalize_token(term)), normalized, re.IGNORECASE)
|
||||
for term in IT_PROFILE_TERMS
|
||||
)
|
||||
|
||||
|
||||
def it_relevance_query(prefix: str = "") -> Q:
|
||||
"""Return the database equivalent of the conservative title-led classifier."""
|
||||
return Q(**{f"{prefix}original_title__iregex": IT_TITLE_PATTERN}) & ~Q(
|
||||
**{f"{prefix}original_title__iregex": NON_IT_TITLE_PATTERN}
|
||||
)
|
||||
@@ -14,6 +14,7 @@ from apps.jobs.services.geocoding import resolve_cached_location
|
||||
from apps.profiles.models import SearchProfile
|
||||
|
||||
from .normalization import normalize_token
|
||||
from .relevance import assess_it_relevance, profile_requires_it_focus
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -227,6 +228,9 @@ def _hard_exclusions(
|
||||
conflicts = sorted(excluded_skills.intersection(explicit_job_skills))
|
||||
if conflicts:
|
||||
reasons.append("Uitgesloten verplichte skill: " + ", ".join(conflicts))
|
||||
it_relevance = assess_it_relevance(job.original_title)
|
||||
if profile_requires_it_focus(profile) and not it_relevance.relevant:
|
||||
reasons.append(it_relevance.reason)
|
||||
return reasons, distance.distance_confidence
|
||||
|
||||
|
||||
@@ -292,6 +296,7 @@ def _analyze_with_ai(job: JobPosting, profile: SearchProfile) -> tuple[AiAnalysi
|
||||
def calculate_score(job: JobPosting, profile: SearchProfile) -> ScoreResult:
|
||||
distance = _distance(job, profile)
|
||||
exclusions, distance_confidence = _hard_exclusions(job, profile, distance)
|
||||
it_relevance = assess_it_relevance(job.original_title)
|
||||
title_fit = _title_fit(job, profile)
|
||||
skill_fit, present_skills, missing_skills = _skill_fit(job, profile)
|
||||
features = job.analysis_features or {}
|
||||
@@ -381,6 +386,8 @@ def calculate_score(job: JobPosting, profile: SearchProfile) -> ScoreResult:
|
||||
concerns: list[str] = []
|
||||
if title_fit >= 0.75:
|
||||
positives.append("Functietitel sluit goed aan op het zoekprofiel.")
|
||||
if it_relevance.relevant:
|
||||
positives.append("IT-focus bevestigd via de functietitel.")
|
||||
if present_skills:
|
||||
positives.append("Herkenbare skills: " + ", ".join(present_skills[:6]))
|
||||
if job.direct_employer and not job.recruiter:
|
||||
@@ -435,6 +442,11 @@ def calculate_score(job: JobPosting, profile: SearchProfile) -> ScoreResult:
|
||||
concerns=concerns,
|
||||
hard_exclusions=exclusions,
|
||||
evidence={
|
||||
"it_relevance": {
|
||||
"relevant": it_relevance.relevant,
|
||||
"signals": list(it_relevance.signals),
|
||||
"reason": it_relevance.reason,
|
||||
},
|
||||
"distance_km": distance.exact_distance_km,
|
||||
"distance_has_data": distance.has_distance_data,
|
||||
"distance_exact": distance.exact_distance_km is not None,
|
||||
|
||||
+90
-3
@@ -3,10 +3,20 @@ from __future__ import annotations
|
||||
from django.contrib import messages
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.contrib.auth.mixins import LoginRequiredMixin
|
||||
from django.db.models import CharField, DecimalField, OuterRef, Q, Subquery
|
||||
from django.db.models import (
|
||||
CharField,
|
||||
DateTimeField,
|
||||
DecimalField,
|
||||
F,
|
||||
JSONField,
|
||||
OuterRef,
|
||||
Q,
|
||||
Subquery,
|
||||
)
|
||||
from django.http import Http404, HttpResponse
|
||||
from django.shortcuts import get_object_or_404, redirect
|
||||
from django.urls import reverse
|
||||
from django.utils.http import url_has_allowed_host_and_scheme
|
||||
from django.views.decorators.http import require_POST
|
||||
from django.views.generic import DetailView, ListView, UpdateView
|
||||
|
||||
@@ -21,6 +31,7 @@ from .services.applications import (
|
||||
track_application_changes,
|
||||
)
|
||||
from .services.feedback import record_feedback
|
||||
from .services.relevance import it_relevance_query
|
||||
|
||||
|
||||
class JobListView(LoginRequiredMixin, ListView):
|
||||
@@ -29,6 +40,15 @@ class JobListView(LoginRequiredMixin, ListView):
|
||||
context_object_name = "jobs"
|
||||
paginate_by = 30
|
||||
|
||||
VALID_RECOMMENDATIONS = {
|
||||
ScoreRun.Recommendation.STRONG,
|
||||
ScoreRun.Recommendation.POSSIBLE,
|
||||
ScoreRun.Recommendation.WEAK,
|
||||
ScoreRun.Recommendation.HIDDEN,
|
||||
}
|
||||
VALID_MATCH_FILTERS = {*VALID_RECOMMENDATIONS, "radar", "all"}
|
||||
VALID_SORTS = {"match", "newest", "closing"}
|
||||
|
||||
def get_queryset(self):
|
||||
queryset = JobPosting.objects.select_related("employer").all()
|
||||
profile = SearchProfile.objects.filter(user=self.request.user, is_active=True).first()
|
||||
@@ -48,10 +68,27 @@ class JobListView(LoginRequiredMixin, ListView):
|
||||
match_recommendation=Subquery(
|
||||
latest_score.values("recommendation")[:1], output_field=CharField()
|
||||
),
|
||||
match_positives=Subquery(
|
||||
latest_score.values("positives")[:1], output_field=JSONField()
|
||||
),
|
||||
match_concerns=Subquery(
|
||||
latest_score.values("concerns")[:1], output_field=JSONField()
|
||||
),
|
||||
match_hard_exclusions=Subquery(
|
||||
latest_score.values("hard_exclusions")[:1], output_field=JSONField()
|
||||
),
|
||||
match_scored_at=Subquery(
|
||||
latest_score.values("created_at")[:1], output_field=DateTimeField()
|
||||
),
|
||||
)
|
||||
query = self.request.GET.get("q", "").strip()
|
||||
status = self.request.GET.get("status", "active").strip()
|
||||
workplace = self.request.GET.get("workplace", "").strip()
|
||||
focus = self.request.GET.get("focus", "it").strip()
|
||||
recommendation = self.request.GET.get("match", "radar").strip()
|
||||
sort = self.request.GET.get("sort", "match").strip()
|
||||
if recommendation not in self.VALID_MATCH_FILTERS:
|
||||
recommendation = "radar"
|
||||
if query:
|
||||
queryset = queryset.filter(
|
||||
Q(original_title__icontains=query)
|
||||
@@ -63,7 +100,51 @@ class JobListView(LoginRequiredMixin, ListView):
|
||||
queryset = queryset.filter(status=status)
|
||||
if workplace:
|
||||
queryset = queryset.filter(workplace_type=workplace)
|
||||
return queryset.order_by("-first_seen")
|
||||
if focus != "all":
|
||||
queryset = queryset.filter(it_relevance_query())
|
||||
if profile and recommendation == "radar":
|
||||
queryset = queryset.filter(
|
||||
Q(
|
||||
match_recommendation__in=[
|
||||
ScoreRun.Recommendation.STRONG,
|
||||
ScoreRun.Recommendation.POSSIBLE,
|
||||
ScoreRun.Recommendation.WEAK,
|
||||
]
|
||||
)
|
||||
| Q(match_recommendation__isnull=True)
|
||||
)
|
||||
elif profile and recommendation in self.VALID_RECOMMENDATIONS:
|
||||
queryset = queryset.filter(match_recommendation=recommendation)
|
||||
if sort not in self.VALID_SORTS:
|
||||
sort = "match"
|
||||
if sort == "newest":
|
||||
return queryset.order_by("-first_seen", F("match_score").desc(nulls_last=True))
|
||||
if sort == "closing":
|
||||
return queryset.order_by(F("valid_through").asc(nulls_last=True), "-first_seen")
|
||||
return queryset.order_by(F("match_score").desc(nulls_last=True), "-first_seen")
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super().get_context_data(**kwargs)
|
||||
active_jobs = JobPosting.objects.filter(status=JobPosting.Status.ACTIVE)
|
||||
it_jobs = active_jobs.filter(it_relevance_query())
|
||||
context.update(
|
||||
{
|
||||
"active_profile": SearchProfile.objects.filter(
|
||||
user=self.request.user, is_active=True
|
||||
).first(),
|
||||
"active_job_count": active_jobs.count(),
|
||||
"it_job_count": it_jobs.count(),
|
||||
"filtered_non_it_count": active_jobs.exclude(it_relevance_query()).count(),
|
||||
"current_focus": self.request.GET.get("focus", "it"),
|
||||
"current_match": (
|
||||
self.request.GET.get("match", "radar")
|
||||
if self.request.GET.get("match", "radar") in self.VALID_MATCH_FILTERS
|
||||
else "radar"
|
||||
),
|
||||
"current_sort": self.request.GET.get("sort", "match"),
|
||||
}
|
||||
)
|
||||
return context
|
||||
|
||||
|
||||
class JobDetailView(LoginRequiredMixin, DetailView):
|
||||
@@ -113,7 +194,13 @@ def job_feedback(request, pk):
|
||||
)
|
||||
messages.success(request, "Actie opgeslagen.")
|
||||
next_url = request.POST.get("next")
|
||||
return redirect(next_url or reverse("jobs:detail", kwargs={"pk": pk}))
|
||||
if next_url and url_has_allowed_host_and_scheme(
|
||||
next_url,
|
||||
allowed_hosts={request.get_host()},
|
||||
require_https=request.is_secure(),
|
||||
):
|
||||
return redirect(next_url)
|
||||
return redirect(reverse("jobs:detail", kwargs={"pk": pk}))
|
||||
|
||||
|
||||
class ApplicationListView(LoginRequiredMixin, ListView):
|
||||
|
||||
@@ -4,11 +4,18 @@ from django.conf import settings
|
||||
from django.conf.urls.static import static
|
||||
from django.contrib import admin
|
||||
from django.contrib.auth import views as auth_views
|
||||
from django.templatetags.static import static as static_url
|
||||
from django.urls import include, path
|
||||
from django.views.generic import RedirectView
|
||||
|
||||
from apps.core.views import SecurityAwareLoginView
|
||||
|
||||
urlpatterns = [
|
||||
path(
|
||||
"favicon.ico",
|
||||
RedirectView.as_view(url=static_url("favicon.svg"), permanent=True),
|
||||
name="favicon",
|
||||
),
|
||||
path("admin/", admin.site.urls),
|
||||
path(
|
||||
"login/",
|
||||
|
||||
@@ -1444,6 +1444,50 @@ tasks:
|
||||
note: GeoNames België postcodedataset 2026-04-03 onder CC BY 4.0 gereviewd, checksums vastgelegd, 2781 unieke
|
||||
rijen gevalideerd en 5562 lookuprecords geïmporteerd; 2400 Mol opgelost naar 51.1919, 5.1166 en actieve profielscores
|
||||
herberekend.
|
||||
- id: VR-206
|
||||
title: Premium Stitch-cockpit en deterministische IT-focus
|
||||
status: done
|
||||
priority: P0
|
||||
requirement_ids:
|
||||
- PR-040
|
||||
- PR-050
|
||||
- PR-051
|
||||
- NFR-004
|
||||
- NFR-008
|
||||
depends_on:
|
||||
- VR-119
|
||||
- VR-120
|
||||
- VR-205
|
||||
summary: Audit de volledige gebruikersflow in code en browser, voorkom dat niet-IT-vacatures de dagelijkse radar
|
||||
vervuilen en benut de Stitch-compositie veel dominanter op mobiel, desktop en widescreen.
|
||||
acceptance_criteria:
|
||||
- Een deterministische en uitlegbare IT-relevantieregel heeft voorrang op scoring en AI; niet-IT-vacatures zijn
|
||||
standaard afwezig uit Vandaag en de vacaturefeed maar blijven via een expliciete archiefkeuze inspecteerbaar.
|
||||
- De vacaturefeed gebruikt echte score-evidence, bruikbare relevantie-/match-/sorteerfilters en toont standaard
|
||||
de meest relevante IT-kansen in plaats van louter de nieuwste bronrecords.
|
||||
- Dashboard, vacaturelijst en vacaturedetail nemen de bruikbare Stitch-panelhiërarchie, informatiedichtheid en
|
||||
actiepatronen over zonder fictieve metrics, quick-apply of externe frontendafhankelijkheden.
|
||||
- De shell en kernlayouts schalen inhoudelijk tussen 320 en 2560 px; widescreen voegt bruikbare kolommen/panelen
|
||||
toe en laat de inhoud niet op een smalle 1280px-strook staan.
|
||||
- Focus, headings, labels, lege toestanden, dark/light contrast, mobiele navigatie en reduced motion blijven toegankelijk;
|
||||
kernroutes hebben geen horizontale paginaoverflow of browserconsolefouten.
|
||||
verification:
|
||||
- uv run pytest tests/unit/test_relevance.py tests/unit/test_scoring.py tests/integration/test_views.py tests/e2e/test_vr114_browser_and_accessibility.py
|
||||
- ./scripts/codex_verify.sh
|
||||
- Browsermatrix op 2560x1440, 1920x1080, 1440x900, 1024x768, 390x844 en 320x800
|
||||
primary_paths:
|
||||
- apps/jobs/services/relevance.py
|
||||
- apps/jobs/services/scoring.py
|
||||
- apps/jobs/views.py
|
||||
- templates/dashboard/today.html
|
||||
- templates/jobs/list.html
|
||||
- templates/jobs/detail.html
|
||||
- static/css/app.css
|
||||
- docs/design/STITCH_INTEGRATION_PLAN.md
|
||||
result:
|
||||
completed_at: '2026-07-22'
|
||||
note: 'Volledige code- en browseraudit: deterministische IT-focus, laatste gebruikersscore, premium Stitch-feed/detail
|
||||
en adaptieve 320-2560px-layout; 225 tests groen en browsermatrix geslaagd.'
|
||||
- id: VR-301
|
||||
title: Multi-tenant SaaS, teams en billing
|
||||
status: deferred
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# Projectstatus
|
||||
|
||||
- Laatst bijgewerkt: 2026-07-22
|
||||
- Repositoryversie: 0.2.10 Kempen-werkgeversradar en GeoNames
|
||||
- Repositoryversie: 0.2.11 premium IT-radar en ultrawide Stitch-cockpit
|
||||
- Uitvoeringsmodus: autonome backlog
|
||||
- Eerste uitvoerbare taak: controleer met `python scripts/backlog.py next`
|
||||
|
||||
@@ -131,6 +131,15 @@ VR-201/VR-205-herverificatie op 2026-07-22:
|
||||
- de fetcher valideert het werkelijk verbonden publieke peer-IP, zodat legitieme CDN-rotatie van DAF en Xplore Group veilig wordt ondersteund;
|
||||
- taakledger bevat 44 taken: 38 `done`, 3 `blocked-external`, 3 `deferred` en 0 `ready`.
|
||||
|
||||
VR-206-herverificatie op 2026-07-22:
|
||||
|
||||
- 225 geslaagde tests en 2 optioneel overgeslagen pytest-Playwrightvarianten; HTML/a11y-fallback geslaagd;
|
||||
- 84,53% branch-aware codedekking; Ruff, Django system check, migratiecontrole, taakledger en repositoryvalidatie geslaagd via `scripts/codex_verify.sh`;
|
||||
- de standaardradar gebruikt een deterministische titelgestuurde IT-grens en laat verborgen/hard uitgesloten matches weg, met expliciete archiefopties voor volledige inspectie;
|
||||
- de lokale auditinventaris telt 336 actieve records: 108 aantoonbare IT-titels, 228 gefilterde niet-IT-records en 102 kaarten in de persoonlijke radarselectie;
|
||||
- de Stitch-cockpit benut maximaal 2240 px met tweekoloms matchfeeds op ultrawide; de zes kernroutes zijn handmatig gecontroleerd op 320, 390, 1024, 1440, 1920 en 2560 px zonder horizontale paginaoverflow;
|
||||
- dark/light, gelabelde velden, mobiele drawer, score-/confidencecopy, archieffilters en inline bewaren zijn in de geïntegreerde browser gecontroleerd.
|
||||
|
||||
## Deploymentstatus
|
||||
|
||||
- `main` is via de bestaande Gitea-SSH-sleutel naar `NuklearRabbit/VacatureRadar` gepusht.
|
||||
@@ -141,6 +150,13 @@ VR-201/VR-205-herverificatie op 2026-07-22:
|
||||
|
||||
## Laatste uitgevoerde backlogtaak
|
||||
|
||||
- 2026-07-22: `VR-206` voltooid.
|
||||
- Niet-IT-vacatures en commercieel/recruitmentmisleidende IT-titels zijn standaard uit Vandaag en de vacaturefeed verwijderd zonder bronhistoriek te wissen.
|
||||
- Matchsortering, radar-/archieffilters, score-evidence en gebruikersgescopeerde dashboardscores maken de dagelijkse selectie beslisbaar.
|
||||
- De Stitch-dominante shell, feeds, details en context-rails schalen van 320 tot 2560 px en gebruiken op ultrawide twee resultaatkolommen.
|
||||
- Een open redirect in feedbackterugkeer, 320 px bronoverflow en de ontbrekende faviconfallback zijn als auditbevindingen mee opgelost.
|
||||
- Volgende taak: geen uitvoerbare `ready` taak; `VR-202`, `VR-203` en `VR-204` blijven `blocked-external`.
|
||||
|
||||
- 2026-07-22: `VR-201` en `VR-205` voltooid.
|
||||
- De officiële GeoNames-postcodedataset lost postcode 2400 op naar Mol en levert indicatieve postcodecentrumafstanden; licentie, versie, reviewdatum en twee SHA-256-checksums zijn vastgelegd.
|
||||
- Gemeente Mol, Ziekenhuis Geel, lokaal bestuur Geel, Stad Turnhout, Group Renotec, Ravago, Sanofi Geel en DAF Trucks Westerlo zijn na fixtures, negatieve routechecks en twee livecycli actief.
|
||||
|
||||
@@ -52,11 +52,12 @@ mag in een afgeschermde browsercontext uitvallen zonder de bediening te breken.
|
||||
## 4. Spacing en layout
|
||||
|
||||
- 4/8px-gebaseerde schaal;
|
||||
- contentcontainer maximaal 1600px zodat de cockpit op brede schermen bruikbaar blijft;
|
||||
- contentcontainer maximaal 2240px; vanaf 1850px gebruikt de cockpit extra resultaatkolommen en
|
||||
contextpanelen in plaats van alleen langere tekstregels;
|
||||
- kaarten met consistente padding 20–24px desktop, 16px mobiel;
|
||||
- minimaal 44×44px touch targets voor primaire interactie;
|
||||
- vaste zijbalk van 272px boven 900px, toegankelijke uitschuifnavigatie daaronder;
|
||||
- grids breken onder 1180px, 900px en 680px logisch af;
|
||||
- grids breken rond 1850px, 1350px, 1180px, 900px en 680px logisch af;
|
||||
- vaste/sticky elementen mogen content niet bedekken.
|
||||
|
||||
## 5. Componenten
|
||||
|
||||
@@ -19,6 +19,17 @@ Na productfeedback is de integratie aangescherpt: Stitch bepaalt nu niet alleen
|
||||
|
||||
De bedoelde “circa 90% Stitch” slaat hiermee op overgenomen visuele structuur en interactiehiërarchie, niet op het kopiëren van fictieve content, externe assets, auto-apply of routes waarvoor het domeinmodel geen betrouwbare data bezit.
|
||||
|
||||
## VR-206: premium radar en ultrawide compositie
|
||||
|
||||
De volledige audit maakt de Stitch-compositie ook functioneel dominant. De vacaturefeed start met
|
||||
een brede commandofilter, toont beslisbare score-evidence en krijgt op breed desktop een vaste
|
||||
radarcontext. Vanaf 1850 px worden matchfeeds tweekoloms; de shell benut maximaal 2240 px in plaats
|
||||
van de vroegere 1280 px. Op mobiel worden filters, sectiekoppen en acties lineair gestapeld.
|
||||
|
||||
De visuele hiërarchie is gekoppeld aan een deterministische IT-grens: niet-IT en hard uitgesloten
|
||||
matches zijn standaard afwezig. Het volledige archief en verborgen scores blijven expliciet
|
||||
inspecteerbaar, zodat premium rust niet ten koste gaat van auditbaarheid.
|
||||
|
||||
## Visuele richting
|
||||
|
||||
- Donkere cockpit als standaard: diep marineblauw, tonale panelen, fijne slate-randen, signaalcyaan voor primaire interactie, emerald voor aantoonbaar gezonde status en koraal/amber voor fouten of aandacht.
|
||||
@@ -58,7 +69,7 @@ De bedoelde “circa 90% Stitch” slaat hiermee op overgenomen visuele structuu
|
||||
|
||||
## Responsive en toegankelijkheid
|
||||
|
||||
- Validatiebreedtes: 1440×900, 1280×800, 1024×768, 768×1024, 390×844 en 360×800.
|
||||
- Validatiebreedtes: 2560×1440, 1920×1080, 1440×900, 1024×768, 390×844 en 320×800.
|
||||
- Eén `h1` per pagina, semantische landmarks, zichtbare focus, toetsenbordbediening, 44px touchdoelen waar praktisch, tekstlabels naast statuskleur en een robuuste skiplink.
|
||||
- Tabellen schakelen op smalle schermen naar kaartachtige rijen of blijven gecontroleerd horizontaal scrolbaar wanneer kolomvergelijking essentieel is.
|
||||
- Animatie is subtiel en volledig uitgeschakeld bij `prefers-reduced-motion`.
|
||||
|
||||
@@ -56,6 +56,8 @@ Regels:
|
||||
Filters:
|
||||
|
||||
- vrije tekst;
|
||||
- relevantiefocus: standaard aantoonbare IT-titels, met expliciet volledig bronarchief;
|
||||
- radarselectie: standaard geen harde uitsluitingen of verborgen score, met aparte inspectiefilters;
|
||||
- status;
|
||||
- recommendation/scoreband;
|
||||
- werkvorm;
|
||||
@@ -65,6 +67,10 @@ Filters:
|
||||
|
||||
Desktop gebruikt een compacte filterbalk; mobiel een disclosurepanel. Filters blijven in querystring zodat bookmarks en back navigation werken.
|
||||
|
||||
De standaardvolgorde is persoonlijke matchscore, niet importtijd. Niet-IT-inhoud wordt niet
|
||||
verwijderd: ze blijft uitsluitend via `Volledig archief` bereikbaar. Een beschrijvingshit alleen
|
||||
mag nooit een niet-IT-titel promoveren.
|
||||
|
||||
Tabel of kaarten kiezen op breedte. Geen horizontale scroll voor kernvelden.
|
||||
|
||||
## 5. Vacaturedetail
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
# VR-206 volledige UX-, logica- en browseraudit
|
||||
|
||||
Datum: 2026-07-22
|
||||
|
||||
## Uitgangssituatie
|
||||
|
||||
De productiecontrole toonde 242 actieve vacatures. De standaardlijst sorteerde hoofdzakelijk op
|
||||
`first_seen` en liet elke actieve vacature zien. Daardoor stonden onder meer facilitaire techniek,
|
||||
logopedie, vroedkunde en niet-IT-onderzoek tussen de persoonlijke IT-resultaten. Op 2560 px bleef
|
||||
de inhoud bovendien op ongeveer 1280 px begrensd, waardoor de Stitch-cockpit een brede lege zone
|
||||
overliet.
|
||||
|
||||
## Bevindingen en oplossing
|
||||
|
||||
| Prioriteit | Bevinding | Oplossing |
|
||||
| --- | --- | --- |
|
||||
| P0 | Niet-IT-vacatures bereikten Vandaag en de standaardfeed. | Titelgestuurde, deterministische IT-classificatie vóór score/AI; niet-IT blijft alleen in het expliciete volledige archief. |
|
||||
| P0 | Vandaag gebruikte niet overal de laatste gebruikersgescopeerde score en open dossiercijfers waren niet volledig gebruikersgescopeerd. | Laatste score per vacature/profiel via subquery; profiel, scores en dossiers zijn aan de aangemelde gebruiker gebonden. |
|
||||
| P1 | Verborgen/hard uitgesloten IT-vacatures stonden hoog door hun ruwe score. | Nieuwe standaardkeuze `Radarselectie` laat verborgen resultaten weg; `Verborgen` en `Alle IT-scores` blijven bewuste inspectiekeuzes. |
|
||||
| P1 | De lijst sorteerde op bronrecency in plaats van persoonlijke besliswaarde. | Standaard matchscore aflopend, met alternatieven voor nieuwste en sluitingsdatum. |
|
||||
| P1 | Stitch werd visueel gebruikt, maar ultrawide bleef een smalle strook. | Adaptieve contentbreedte tot 2240 px, tweekoloms feeds vanaf 1850 px en context-/actierails op breed desktop. |
|
||||
| P1 | Vacaturekaarten boden weinig scorebewijs. | Score, datakwaliteit, eerste pluspunt, eerste aandachtspunt, status, werkmodel en directe actie staan in de feed. |
|
||||
| P2 | Confidencewaarden wisselden tussen breuken en percentages. | Alle dagelijkse score- en extractiekwaliteit wordt als percentage getoond. |
|
||||
| P2 | Inline feedback accepteerde een willekeurige `next`-URL. | Terugkeer alleen naar dezelfde host, met negatieve regressietest. |
|
||||
| P2 | De bronpagina liep op 320 px uit door statuschips naast lange sectiekoppen. | Sectiekoppen stapelen onder 680 px; de 320 px matrix heeft geen horizontale overflow. |
|
||||
| P2 | Sommige browsers vroegen `/favicon.ico` en kregen een 404. | Lokale permanente fallback naar het bestaande SVG-favicon. |
|
||||
|
||||
## Relevantieregel
|
||||
|
||||
De classifier is bewust conservatief en titelgestuurd. Een beschrijving die toevallig “digitale
|
||||
systemen” vermeldt, maakt een zorg- of facilitaire functie niet relevant. Duidelijke IT-families
|
||||
zoals infrastructuur, netwerk, cloud, security, software, data, applicatiebeheer en workplace
|
||||
worden toegelaten. Commerciële of recruitmentcontext die IT-woorden bevat, wordt expliciet
|
||||
afgewezen. Dezelfde regexcontracten worden in Python en in de databasequery gebruikt.
|
||||
|
||||
Op de lokale auditdataset met 336 actieve records bleven 108 titelmatig aantoonbare IT-vacatures
|
||||
over en werden 228 niet-IT-records uit de standaardfocus gehouden. Na uitsluiting van verborgen
|
||||
matches bevatte de persoonlijke radarselectie 102 kaarten. Het volledige archief blijft 336
|
||||
records bevatten: er wordt geen bronhistoriek gewist.
|
||||
|
||||
## Stitch-vertaling
|
||||
|
||||
- Dashboard: KPI-strip, beslisbare matchfeed en vaste radar-/actierail.
|
||||
- Vacaturelijst: brede commandofilter, compacte evidencekaarten, scorehiërarchie en radarcontext.
|
||||
- Detail: scorehero, intelligence-strip, bronhistorie en menselijke actierail.
|
||||
- Shell: vaste desktopnavigatie, compacte utilitybar, lokale iconen en mobiele drawer.
|
||||
- Ultrawide: twee inhoudskolommen plus context; geen kunstmatig uitgerekte tekstregels of fictieve grafieken.
|
||||
|
||||
Alle cijfers en signalen komen uit de database. Quick apply, automatische sollicitatie,
|
||||
platformloginbots, fictieve trendgrafieken en externe frontendassets blijven bewust afwezig.
|
||||
|
||||
## Browsermatrix
|
||||
|
||||
Handmatig gecontroleerd in de geïntegreerde browser op 320×800, 390×844, 1024×768, 1440×900,
|
||||
1920×1080 en 2560×1440. De zes kernroutes (`/`, `/jobs/`, `/jobs/applications/`, `/profiles/`,
|
||||
`/sources/`, `/system/`) hebben één `h1`, gelabelde formuliervelden en geen horizontale
|
||||
paginaoverflow. De vacaturefeed schakelt van één kolom naar twee kolommen op ultrawide en gebruikt
|
||||
daar 2240 px van de beschikbare ruimte. Mobiele navigatie opent en sluit met correcte
|
||||
`aria-expanded`-status.
|
||||
|
||||
## Resterend operationeel punt
|
||||
|
||||
Na deployment moet de bestaande productie-inventaris opnieuw gescoord worden zodat opgeslagen
|
||||
score-evidence eveneens het nieuwe `it_relevance`-blok bevat. De viewfilter werkt onmiddellijk en
|
||||
onafhankelijk daarvan; het herscoren verbetert de uitleg en blijvende `hidden`-status.
|
||||
@@ -80,6 +80,7 @@ Django/Celery
|
||||
| T-38 | Cross-user mailboxbeheer, dubbele polls of IMAP naar private host | credentialmisbruik, duplicaten, toegang tot interne diensten | alle views scopen op gebruiker en vereisen CSRF; unieke user+platformkoppeling; transactionele lease; hostvorm en publieke DNS/IP-validatie vóór TLS-IMAP | `tests/integration/test_mailbox_connections.py`, `tests/unit/test_mailbox_credentials.py`, `apps/sources/services/imap_import.py`, `VR-123` |
|
||||
| T-39 | Mol-/Kempenbron vermengt een andere werkgever, campus of vestiging | foutieve afstand, phishing of buitenregionale resultaten | exacte HTTPS-host/pad/GUID-routes; same-host details; expliciete plaatsfilters voor regionale en gedeelde lijsten; postcodecentra blijven als benadering herkenbaar; waaklijstbronnen worden niet gescand | `tests/unit/test_regional_adapters.py`, `tests/integration/test_tasks_and_more_views.py`, `VR-126/201` |
|
||||
| T-40 | Verouderde lokale CA-store of ongeldige serverketen lokt een TLS-bypass uit | onderschepte vacature- of mailboxdata en credentialdiefstal | expliciet gepinde `certifi`-CA-bundel voor HTTP, robots en IMAP; hostnaamcontrole en `CERT_REQUIRED`; nooit `verify=False`; dependency- en negatieve TLS-tests bij update | `tests/unit/test_tls.py`, `tests/security/test_robots.py`, `apps/sources/services/tls.py`, `VR-201` |
|
||||
| T-41 | Gemodificeerde `next`-parameter na vacaturefeedback | open redirect naar phishingdomein na een geldige gebruikersactie | terugkeer-URL moet dezelfde host en het actuele HTTPS-beleid respecteren; anders veilige detailfallback | `tests/integration/test_tasks_and_more_views.py`, `VR-206` |
|
||||
|
||||
## Misbruikscenario's
|
||||
|
||||
|
||||
@@ -32,14 +32,14 @@ Legenda: **I** = geïmplementeerd en getest in de huidige MVP; **P** = gedeeltel
|
||||
| PR-031 | I | conservatieve fuzzy dedupe | `tests/unit/test_dedupe_lifecycle_feedback.py`, `scripts/benchmark.py`, `tests/benchmark/test_benchmark.py` | VR-116 benchmark |
|
||||
| PR-032 | I | direct-sourcepreferentie/alias | pipeline- en dedupetests | VR-108 resolver |
|
||||
| PR-033 | I | canonical constraints/transacties | replaytests | — |
|
||||
| PR-040 | I | features/scoring | scoringtests | — |
|
||||
| PR-040 | I | features/scoring + deterministische IT-relevantie vóór AI | `tests/unit/test_scoring.py`, `tests/unit/test_relevance.py`, viewtests | VR-206 |
|
||||
| PR-041 | I | scorecomponenten | `tests/unit/test_features_distance.py`, `tests/unit/test_scoring.py` | VR-105 afgerond |
|
||||
| PR-042 | I | unknown/confidencegedrag | `tests/unit/test_features_distance.py`, `tests/unit/test_scoring.py` | VR-105 |
|
||||
| PR-043 | I | `ScoreRun` | pipeline/scoringtests | — |
|
||||
| PR-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 + Stitch-dominante cockpitshell/feed/rail | viewtests, VR-119 browsermatrix | VR-114/118/119 e2e/a11y |
|
||||
| PR-050 | I | dashboard/list views + Stitch-dominante cockpitshell/feed/rail en ultrawide compositie | viewtests, VR-119/206 browsermatrix | VR-114/118/119/206 e2e/a11y |
|
||||
| PR-051 | I | detailview/template/provenance + scorehero/intelligence-strip | viewtests, VR-119 screenshots | VR-114/118/119 |
|
||||
| PR-052 | I | feedbackendpoint/UI | feedback/viewtests | — |
|
||||
| PR-053 | I | applicationmodel/serviceflow | `tests/integration/test_dedupe_lifecycle_feedback.py`, `tests/integration/test_applications.py` | VR-113 uitgebreid dossier |
|
||||
@@ -98,5 +98,5 @@ Iedere backlogtaak die een requirement wijzigt, werkt in dezelfde commit deze ma
|
||||
- VR-126 herankert profiel en broncatalogus op postcode 2400 Mol en 40 km. De host-, pad- en locatiefilters voor SCK CEN, Cipal Schaubroeck, VanRoey, NTX en Thomas More worden gedekt door regionale adaptertests, source-viewtests, live bronruns en de bijgewerkte threat T-39.
|
||||
- VR-201 activeert Gemeente Mol en zeven bijkomende Kempen-werkgevers na menselijke voorwaardenreview, fixtures, fail-closed locatie-/routefilters en twee livecycli. Twaalf interessante werkgevers zonder stabiele actieve ingang staan afzonderlijk en niet-scanbaar op de UI-waaklijst. Bewijs staat in de livecatalogus, seeddata, `KempenEmployerAdapter`, bronruns en adapter-/seed-/viewtests.
|
||||
- VR-205 laadt de officiële Belgische GeoNames-postcodedataset 2026-04-03 onder CC BY 4.0, documenteert bron- en afgeleide checksums en lost 2400 Mol op naar het benaderende postcodecentrum. Bewijs staat in `config-data/geodata/README.local.md`, het importlog, geocodingtests en de opnieuw berekende profiel-vacaturescores.
|
||||
|
||||
- VR-206 voegt een titelgestuurde IT-relevantiegrens vóór scoring/AI toe, maakt de persoonlijke radarselectie de standaard en benut Stitch-panelen dynamisch tot 2560 px. Bewijs staat in `apps/jobs/services/relevance.py`, de score-/viewtests en `docs/design/validation/VR-206_AUDIT.md`.
|
||||
|
||||
|
||||
+95
-3
@@ -7,6 +7,7 @@
|
||||
--surface-low: #131b2e;
|
||||
--surface-high: #222a3d;
|
||||
--surface-bright: #2d3449;
|
||||
--surface-soft: #111a2d;
|
||||
--text: #dae2fd;
|
||||
--text-soft: #b9caca;
|
||||
--muted: #849495;
|
||||
@@ -16,6 +17,7 @@
|
||||
--primary-strong: #63f7ff;
|
||||
--primary-ink: #003739;
|
||||
--primary-soft: rgba(0, 245, 255, .1);
|
||||
--accent: var(--primary);
|
||||
--positive: #4edea3;
|
||||
--positive-soft: rgba(78, 222, 163, .11);
|
||||
--warning: #ffca69;
|
||||
@@ -39,6 +41,7 @@
|
||||
--surface-low: #f7f9fc;
|
||||
--surface-high: #edf2f7;
|
||||
--surface-bright: #e2e9f2;
|
||||
--surface-soft: #f6f9fc;
|
||||
--text: #142033;
|
||||
--text-soft: #2c3c52;
|
||||
--muted: #617187;
|
||||
@@ -62,7 +65,7 @@
|
||||
* { box-sizing: border-box; }
|
||||
html { min-height: 100%; scroll-behavior: smooth; }
|
||||
body {
|
||||
min-width: 320px;
|
||||
min-width: 0;
|
||||
min-height: 100vh;
|
||||
margin: 0;
|
||||
overflow-x: hidden;
|
||||
@@ -72,7 +75,7 @@ body {
|
||||
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;
|
||||
font: 16px/1.55 Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
a { color: var(--primary-strong); text-decoration: none; }
|
||||
@@ -143,7 +146,7 @@ button.utility-link { width: auto; }
|
||||
:root[data-theme="light"] .theme-icon-dark { display: none; }
|
||||
:root[data-theme="light"] .theme-icon-light { display: block; }
|
||||
.nav-scrim { display: none; }
|
||||
.main-content { width: 100%; max-width: 1280px; margin: 0 auto; padding: 32px 24px 72px; }
|
||||
.main-content { width: 100%; max-width: 2240px; margin: 0 auto; padding: clamp(28px, 2.2vw, 48px) clamp(22px, 2.4vw, 54px) 80px; }
|
||||
|
||||
.page-header { display: flex; align-items: flex-end; justify-content: space-between; gap: 26px; margin-bottom: 28px; }
|
||||
.page-header h1, .job-hero h1 { margin: 0; font-size: clamp(30px, 3.2vw, 42px); line-height: 1.08; letter-spacing: -.035em; }
|
||||
@@ -589,3 +592,92 @@ textarea { resize: vertical; }
|
||||
.result-body { padding: 17px; }
|
||||
.result-actions { padding: 0 17px 17px; }
|
||||
}
|
||||
|
||||
/* VR-206: premium, title-led IT radar and genuinely adaptive widescreen composition. */
|
||||
.jobs-page-header { align-items: center; }
|
||||
.jobs-page-header .radar-status strong { color: var(--primary-strong); }
|
||||
.premium-filter-console {
|
||||
grid-template-columns: minmax(220px, 1fr) repeat(5, minmax(112px, 145px)) auto;
|
||||
gap: 12px;
|
||||
padding: 18px;
|
||||
background:
|
||||
linear-gradient(135deg, color-mix(in srgb, var(--primary) 4%, transparent), transparent 45%),
|
||||
var(--surface);
|
||||
box-shadow: 0 16px 42px rgba(0, 0, 0, .1);
|
||||
}
|
||||
.premium-filter-console select,
|
||||
.premium-filter-console input { background: color-mix(in srgb, var(--surface-low) 94%, transparent); }
|
||||
.results-layout { display: grid; grid-template-columns: minmax(0, 1fr); gap: 28px; align-items: start; }
|
||||
.results-column { min-width: 0; }
|
||||
.jobs-insight-rail { display: none; }
|
||||
.result-card {
|
||||
background:
|
||||
linear-gradient(135deg, color-mix(in srgb, var(--primary) 2%, transparent), transparent 48%),
|
||||
var(--surface);
|
||||
box-shadow: 0 12px 32px rgba(0, 0, 0, .09);
|
||||
}
|
||||
.result-card:hover { box-shadow: 0 18px 42px rgba(0, 0, 0, .14); }
|
||||
.result-heading h2 { font-size: clamp(20px, 1.3vw, 25px); letter-spacing: -.025em; }
|
||||
.result-heading > div:first-child { min-width: 0; }
|
||||
.score-block strong { font-size: 32px; }
|
||||
.score-block small { max-width: 110px; text-align: right; line-height: 1.35; }
|
||||
.score-pending strong { color: var(--muted); }
|
||||
.pill-it { color: var(--primary-strong); border-color: color-mix(in srgb, var(--primary) 30%, var(--border)); background: var(--primary-soft); }
|
||||
.evidence-line { display: flex; align-items: flex-start; gap: 9px; margin: 0; padding: 10px 12px; border-radius: var(--radius-sm); font-size: 13px; line-height: 1.45; }
|
||||
.evidence-line .icon { width: 17px; height: 17px; margin-top: 1px; }
|
||||
.positive-evidence { border-left: 2px solid var(--positive); background: var(--positive-soft); color: var(--text-soft); }
|
||||
.positive-evidence .icon { color: var(--positive); }
|
||||
.concern-evidence { border-left: 2px solid var(--warning); background: var(--warning-soft); color: var(--text-soft); }
|
||||
.concern-evidence > span:first-child { color: var(--warning); font-weight: 900; }
|
||||
.result-actions { align-content: center; gap: 9px; }
|
||||
.result-actions form { margin: 0; }
|
||||
.result-actions .button { width: 100%; }
|
||||
.profile-signal-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 9px; margin: 16px 0; }
|
||||
.profile-signal-grid > div { display: grid; gap: 3px; padding: 11px; border: 1px solid var(--border); border-radius: var(--radius-sm); background: var(--surface-low); }
|
||||
.profile-signal-grid span { color: var(--muted); font: 9px ui-monospace, SFMono-Regular, Consolas, monospace; text-transform: uppercase; letter-spacing: .08em; }
|
||||
.profile-signal-grid strong { font-size: 18px; }
|
||||
.radar-steps { display: grid; gap: 0; margin: 14px 0 0; padding: 0; list-style: none; counter-reset: radar-step; }
|
||||
.radar-steps li { position: relative; display: grid; gap: 2px; padding: 12px 0 12px 34px; border-top: 1px solid var(--border); counter-increment: radar-step; }
|
||||
.radar-steps li::before { content: counter(radar-step); position: absolute; left: 0; top: 12px; width: 22px; height: 22px; display: grid; place-items: center; border: 1px solid color-mix(in srgb, var(--primary) 30%, var(--border)); border-radius: 50%; color: var(--primary-strong); font: 10px ui-monospace, SFMono-Regular, Consolas, monospace; }
|
||||
.radar-steps span { color: var(--muted); font-size: 12px; }
|
||||
.job-hero .title-row { margin-bottom: 12px; }
|
||||
.detail-main .panel { box-shadow: 0 12px 38px rgba(0, 0, 0, .08); }
|
||||
|
||||
@media (min-width: 1350px) {
|
||||
.results-layout { grid-template-columns: minmax(0, 1fr) 320px; }
|
||||
.jobs-insight-rail { position: sticky; top: calc(var(--utility) + 20px); display: block; }
|
||||
}
|
||||
|
||||
@media (min-width: 1850px) {
|
||||
.utilitybar { padding-inline: clamp(30px, 2.5vw, 60px); }
|
||||
.global-search { width: min(620px, 42vw); }
|
||||
.dashboard-grid { grid-template-columns: minmax(0, 1fr) 360px; gap: 34px; }
|
||||
.dashboard-grid .job-feed { grid-template-columns: repeat(2, minmax(0, 1fr)); align-items: start; }
|
||||
.dashboard-grid .section-heading { grid-column: 1 / -1; }
|
||||
.results-layout { grid-template-columns: minmax(0, 1fr) 360px; gap: 34px; }
|
||||
.results-feed { grid-template-columns: repeat(2, minmax(0, 1fr)); align-items: start; }
|
||||
.result-card { height: 100%; grid-template-columns: 4px minmax(0, 1fr); }
|
||||
.result-actions { grid-column: 2; grid-template-columns: minmax(0, 1fr) auto; padding: 0 22px 20px; border: 0; background: transparent; }
|
||||
.result-actions .button-ghost { width: auto; }
|
||||
.detail-layout { grid-template-columns: minmax(0, 1fr) 380px; gap: 34px; }
|
||||
.health-grid { grid-template-columns: repeat(4, minmax(0, 1fr)); }
|
||||
.editor-layout { grid-template-columns: minmax(0, 1fr) 360px; gap: 34px; }
|
||||
}
|
||||
|
||||
@media (max-width: 1180px) {
|
||||
.premium-filter-console { grid-template-columns: minmax(0, 1fr) repeat(2, minmax(145px, .35fr)); }
|
||||
.premium-filter-console .button { grid-column: 1 / -1; }
|
||||
}
|
||||
|
||||
@media (max-width: 680px) {
|
||||
body { font-size: 15px; }
|
||||
.section-heading { align-items: flex-start; flex-direction: column; gap: 10px; }
|
||||
.section-heading > .pill { align-self: flex-start; }
|
||||
.jobs-page-header { align-items: stretch; }
|
||||
.premium-filter-console { grid-template-columns: 1fr; padding: 14px; }
|
||||
.premium-filter-console .button { grid-column: auto; }
|
||||
.result-actions { gap: 8px; }
|
||||
.result-actions form { width: 100%; }
|
||||
.result-heading h2 { font-size: 20px; }
|
||||
.score-block small { max-width: none; text-align: left; }
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
|
||||
<section class="stats-grid" aria-label="Kerncijfers">
|
||||
<article class="stat-card signal"><span class="kpi-label">Nieuwe signalen</span><strong>{{ new_today }}</strong><small>vandaag gevonden</small></article>
|
||||
<article class="stat-card"><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">Actieve IT-vacatures</span><strong>{{ active_jobs }}</strong><small>niet-IT blijft uit beeld</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>
|
||||
@@ -34,7 +34,7 @@
|
||||
<div class="job-card-main">
|
||||
<div class="job-card-head">
|
||||
<div>
|
||||
<div class="title-row"><span class="pill pill-{{ score.recommendation }}">{{ score.get_recommendation_display }}</span><span class="confidence-label">Datakwaliteit {{ score.confidence|floatformat:2 }}</span></div>
|
||||
{% widthratio score.confidence 1 100 as confidence_percent %}<div class="title-row"><span class="pill pill-{{ score.recommendation }}">{{ score.get_recommendation_display }}</span><span class="confidence-label">Datakwaliteit {{ confidence_percent }}%</span></div>
|
||||
<h3><a href="{% url 'jobs:detail' score.job.pk %}">{{ score.job.original_title }}</a></h3>
|
||||
<p>{{ score.job.employer_name }}{% if score.job.raw_location %} · {{ score.job.raw_location }}{% endif %}</p>
|
||||
</div>
|
||||
@@ -56,7 +56,7 @@
|
||||
</section>
|
||||
|
||||
<aside class="dashboard-rail" aria-label="Acties en radarstatus">
|
||||
<section class="panel rail-panel radar-summary"><span class="section-kicker">Vandaag</span><h2>Radarstatus</h2><p class="muted">Alle cijfers komen rechtstreeks uit de huidige database.</p><div class="rail-list"><div class="rail-item"><span>Bronnen geregistreerd</span><strong>{{ source_counts.total|default:0 }}</strong></div><div class="rail-item"><span>Aandacht nodig</span><strong>{{ source_counts.unhealthy|default:0 }}</strong></div><div class="rail-item"><span>Actieve vacatures</span><strong>{{ active_jobs }}</strong></div></div></section>
|
||||
<section class="panel rail-panel radar-summary"><span class="section-kicker">Vandaag</span><h2>Radarstatus</h2><p class="muted">Alle cijfers komen rechtstreeks uit de huidige database.</p><div class="rail-list"><div class="rail-item"><span>Bronnen geregistreerd</span><strong>{{ source_counts.total|default:0 }}</strong></div><div class="rail-item"><span>Aandacht nodig</span><strong>{{ source_counts.unhealthy|default:0 }}</strong></div><div class="rail-item"><span>Actieve IT-vacatures</span><strong>{{ active_jobs }}</strong></div><div class="rail-item"><span>Niet-IT gefilterd</span><strong>{{ filtered_non_it_jobs }}</strong></div></div></section>
|
||||
<section class="panel rail-panel"><span class="section-kicker">Actie vereist</span><h2>Open opvolging</h2><div class="rail-application-list">{% for application in priority_applications %}<a class="rail-application" href="{% url 'jobs:application-edit' application.pk %}"><span><strong>{{ application.job.original_title }}</strong><small>{{ application.job.employer_name }}</small></span>{% if application.follow_up_date %}<time datetime="{{ application.follow_up_date|date:'Y-m-d' }}">{{ application.follow_up_date|date:"d/m" }}</time>{% else %}<svg class="icon"><use href="#icon-arrow"></use></svg>{% endif %}</a>{% empty %}<p class="muted small">Geen open dossiers die opvolging vragen.</p>{% endfor %}</div><a class="button button-ghost button-block" href="{% url 'jobs:applications' %}">Pipeline openen</a></section>
|
||||
<section class="panel rail-panel"><span class="section-kicker">Volgende stap</span><h2>Snelle acties</h2><div class="stack-actions"><a class="button button-secondary button-block" href="{% url 'sources:list' %}"><svg class="icon"><use href="#icon-upload"></use></svg>Handmatig importeren</a><a class="button button-ghost button-block" href="{% url 'profiles:list' %}"><svg class="icon"><use href="#icon-profile"></use></svg>Zoekprofiel controleren</a></div></section>
|
||||
</aside>
|
||||
|
||||
@@ -5,19 +5,19 @@
|
||||
<div class="detail-layout">
|
||||
<div class="detail-main">
|
||||
<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 %}
|
||||
<div><p class="eyebrow">{{ job.get_status_display }}{% if job.date_posted %} · gepubliceerd {{ job.date_posted|date:"d/m/Y" }}{% endif %}</p><div class="title-row">{% if score.evidence.it_relevance.relevant %}<span class="pill pill-it">IT-focus bevestigd</span>{% endif %}{% if job.direct_employer %}<span class="pill pill-active">Directe werkgever</span>{% endif %}</div><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 %}{% widthratio score.confidence 1 100 as score_confidence_percent %}<div class="hero-score"><strong>{{ score.score|floatformat:0 }}%</strong><span>{{ score.get_recommendation_display }}</span><small>Datakwaliteit {{ score_confidence_percent }}%</small></div>{% endif %}
|
||||
</header>
|
||||
<section class="intelligence-strip" aria-label="Vacaturekerngegevens">
|
||||
<div><span class="field-label">Matchkwaliteit</span>{% if score %}<strong>{{ score.score|floatformat:0 }}%</strong><small>{{ score.get_recommendation_display }}</small>{% else %}<strong>—</strong><small>Nog niet beoordeeld</small>{% endif %}</div>
|
||||
<div><span class="field-label">Datakwaliteit</span><strong>{{ job.extraction_confidence|floatformat:2 }}</strong><small>Extractieconfidence</small></div>
|
||||
{% widthratio job.extraction_confidence 1 100 as extraction_confidence_percent %}<div><span class="field-label">Datakwaliteit</span><strong>{{ extraction_confidence_percent }}%</strong><small>Extractiezekerheid</small></div>
|
||||
<div><span class="field-label">Werkmodel</span><strong>{{ job.get_workplace_type_display }}</strong><small>{{ job.hours_text|default:"Uren onbekend" }}</small></div>
|
||||
</section>
|
||||
{% if score %}
|
||||
<section class="panel"><div class="section-heading"><div><span class="section-kicker">Deterministische analyse</span><h2>Waarom deze score?</h2><p>De inhoudelijke match en betrouwbaarheid worden afzonderlijk beoordeeld.</p></div><span class="pill pill-{{ score.recommendation }}">{{ score.get_recommendation_display }}</span></div><div class="score-components">{% for name,value in score.components.items %}<div><span>{{ name|capfirst }}</span><div class="progress" aria-label="{{ name }}: {{ value|floatformat:1 }} procent"><i style="width: {{ value }}%"></i></div><strong>{{ value|floatformat:1 }}</strong></div>{% endfor %}</div><div class="two-columns"><div><h3>Waarom passend</h3><ul class="compact-list positive">{% for item in score.positives %}<li>{{ item }}</li>{% empty %}<li>Nog onvoldoende positieve signalen.</li>{% endfor %}</ul></div><div><h3>Aandachtspunten</h3><ul class="compact-list caution">{% for item in score.concerns %}<li>{{ item }}</li>{% empty %}<li>Geen specifieke aandachtspunten gedetecteerd.</li>{% endfor %}</ul></div></div>{% if score.hard_exclusions %}<div class="message warning"><strong>Harde uitsluitingen:</strong> {{ score.hard_exclusions|join:", " }}</div>{% endif %}</section>
|
||||
{% else %}<section class="panel panel-subtle"><h2>Nog geen persoonlijke score</h2><p class="muted">Deze vacature heeft nog geen score voor je actieve zoekprofiel. De broninformatie en vacaturetekst blijven wel beschikbaar.</p></section>{% endif %}
|
||||
<section class="panel"><span class="section-kicker">Vacaturetekst</span><h2>Over de functie</h2>{% if job.description_html_sanitized %}<div class="prose">{{ job.description_html_sanitized|safe }}</div>{% elif job.description_text %}<div class="prose"><p>{{ job.description_text|linebreaksbr }}</p></div>{% else %}<p class="muted">Geen vacatureomschrijving beschikbaar.</p>{% endif %}</section>
|
||||
<section class="panel"><span class="section-kicker">Transparantie</span><h2>Bron en historie</h2><dl class="definition-grid"><div><dt>Eerst gezien</dt><dd>{{ job.first_seen|date:"d/m/Y H:i" }}</dd></div><div><dt>Laatst gezien</dt><dd>{{ job.last_seen|date:"d/m/Y H:i" }}</dd></div><div><dt>Laatst gewijzigd</dt><dd>{{ job.last_changed|date:"d/m/Y H:i" }}</dd></div><div><dt>Extractieconfidence</dt><dd>{{ job.extraction_confidence|floatformat:2 }}</dd></div></dl><h3>Ook gevonden via</h3><ul class="source-list">{% for alias in job.source_aliases.all %}<li><a href="{{ alias.url }}" target="_blank" rel="noopener noreferrer nofollow">{{ alias.source.name|default:"Onbekende bron" }} <span class="sr-only">(opent in nieuw tabblad)</span></a><span>{{ alias.extraction_method }} · confidence {{ alias.extraction_confidence|floatformat:2 }}</span></li>{% empty %}<li>Geen bronalias beschikbaar.</li>{% endfor %}</ul></section>
|
||||
<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>Extractiezekerheid</dt><dd>{{ extraction_confidence_percent }}%</dd></div></dl><h3>Ook gevonden via</h3><ul class="source-list">{% for alias in job.source_aliases.all %}{% widthratio alias.extraction_confidence 1 100 as alias_confidence_percent %}<li><a href="{{ alias.url }}" target="_blank" rel="noopener noreferrer nofollow">{{ alias.source.name|default:"Onbekende bron" }} <span class="sr-only">(opent in nieuw tabblad)</span></a><span>{{ alias.extraction_method }} · datakwaliteit {{ alias_confidence_percent }}%</span></li>{% empty %}<li>Geen bronalias beschikbaar.</li>{% endfor %}</ul></section>
|
||||
</div>
|
||||
<aside class="detail-sidebar"><div class="sticky-card"><span class="section-kicker">Persoonlijke actie</span><h2>Volgende stap</h2><p>VacatureRadar verstuurt nooit automatisch een sollicitatie.</p><div class="stack-actions"><form method="post" action="{% url 'jobs:feedback' job.pk %}">{% csrf_token %}<input type="hidden" name="action" value="interesting"><button class="button button-primary button-block" type="submit">Interessant</button></form><form method="post" action="{% url 'jobs:feedback' job.pk %}">{% csrf_token %}<input type="hidden" name="action" value="save"><button class="button button-secondary button-block" type="submit"><svg class="icon"><use href="#icon-bookmark"></use></svg>Bewaren</button></form><form method="post" action="{% url 'jobs:feedback' job.pk %}">{% csrf_token %}<input type="hidden" name="action" value="applied"><button class="button button-secondary button-block" type="submit">Als gesolliciteerd markeren</button></form><form method="post" action="{% url 'jobs:feedback' job.pk %}" class="hide-form">{% csrf_token %}<input type="hidden" name="action" value="hide"><label>Reden (optioneel)<select name="reason"><option value="">Geen reden</option><option>Te ver</option><option>Te veel support</option><option>Recruiter/consultancy</option><option>Onrealistische senioriteit</option><option>Niet mijn technologie</option></select></label><button class="button button-ghost button-block" type="submit">Verbergen</button></form></div>{% if job.canonical_url %}<a class="button button-link button-block" href="{{ job.canonical_url }}" target="_blank" rel="noopener noreferrer nofollow">Originele vacature <svg class="icon"><use href="#icon-external"></use></svg><span class="sr-only">(opent in nieuw tabblad)</span></a>{% endif %}{% if application %}<hr><p class="muted">Dossierstatus: <strong>{{ application.get_status_display }}</strong></p><a class="button button-ghost button-block" href="{% url 'jobs:application-edit' application.pk %}">Dossier bewerken</a>{% endif %}</div></aside>
|
||||
</div>
|
||||
|
||||
+55
-23
@@ -1,36 +1,68 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Vacatures · VacatureRadar{% endblock %}
|
||||
{% block content %}
|
||||
<header class="page-header">
|
||||
<div><p class="eyebrow">Nieuwe matches & archief</p><h1>Vacature-intelligentie</h1><p>Doorzoek actuele en gearchiveerde vacatures met score, herkomst en status in één scanbare feed.</p></div>
|
||||
<header class="page-header jobs-page-header">
|
||||
<div>
|
||||
<p class="eyebrow">Nieuwe matches & archief</p>
|
||||
<h1>Vacature-intelligentie</h1>
|
||||
<p>Een rustige IT-radar: eerst de aantoonbaar relevante functies, gerangschikt op persoonlijke match.</p>
|
||||
</div>
|
||||
<div class="radar-status" aria-label="Actieve relevantiefilter">
|
||||
<span class="signal-dot" aria-hidden="true"></span>
|
||||
<span>{% if current_focus == 'all' %}Volledig bronarchief{% else %}IT-focus actief{% endif %}</span>
|
||||
<span aria-hidden="true">·</span>
|
||||
<strong>{{ it_job_count }} IT-signalen</strong>
|
||||
</div>
|
||||
</header>
|
||||
<form method="get" class="filter-console">
|
||||
|
||||
<form method="get" class="filter-console premium-filter-console">
|
||||
<label class="grow"><span class="field-label">Zoeken</span><span class="input-with-icon"><svg class="icon"><use href="#icon-search"></use></svg><input type="search" name="q" value="{{ request.GET.q }}" placeholder="Titel, werkgever, locatie of skill"></span></label>
|
||||
<label><span class="field-label">Relevantie</span><select name="focus"><option value="it" {% if current_focus != 'all' %}selected{% endif %}>Alleen IT</option><option value="all" {% if current_focus == 'all' %}selected{% endif %}>Volledig archief</option></select></label>
|
||||
<label><span class="field-label">Match</span><select name="match"><option value="radar" {% if current_match == 'radar' %}selected{% endif %}>Radarselectie</option><option value="strong" {% if current_match == 'strong' %}selected{% endif %}>Sterke match</option><option value="possible" {% if current_match == 'possible' %}selected{% endif %}>Mogelijke match</option><option value="weak" {% if current_match == 'weak' %}selected{% endif %}>Lage match</option><option value="hidden" {% if current_match == 'hidden' %}selected{% endif %}>Verborgen</option><option value="all" {% if current_match == 'all' %}selected{% endif %}>Alle IT-scores</option></select></label>
|
||||
<label><span class="field-label">Status</span><select name="status"><option value="">Alle statussen</option>{% for value,label in view.model.Status.choices %}<option value="{{ value }}" {% if request.GET.status|default:'active' == value %}selected{% endif %}>{{ label }}</option>{% endfor %}</select></label>
|
||||
<label><span class="field-label">Werkmodel</span><select name="workplace"><option value="">Elk werkmodel</option>{% for value,label in view.model.Workplace.choices %}<option value="{{ value }}" {% if request.GET.workplace == value %}selected{% endif %}>{{ label }}</option>{% endfor %}</select></label>
|
||||
<label><span class="field-label">Sortering</span><select name="sort"><option value="match" {% if current_sort == 'match' %}selected{% endif %}>Beste match</option><option value="newest" {% if current_sort == 'newest' %}selected{% endif %}>Nieuwste eerst</option><option value="closing" {% if current_sort == 'closing' %}selected{% endif %}>Sluitingsdatum</option></select></label>
|
||||
<button class="button button-primary" type="submit"><svg class="icon"><use href="#icon-search"></use></svg>Filters toepassen</button>
|
||||
</form>
|
||||
|
||||
<div class="results-toolbar"><div><span class="section-kicker">Resultaten</span><h2>{{ page_obj.paginator.count }} vacatures gevonden</h2></div><span class="view-indicator"><svg class="icon"><use href="#icon-pipeline"></use></svg>Lijstweergave</span></div>
|
||||
<div class="results-feed">
|
||||
{% for job in jobs %}
|
||||
<article class="result-card {% if job.match_recommendation == 'strong' %}strong-match{% endif %}">
|
||||
<div class="result-accent" aria-hidden="true"></div>
|
||||
<div class="result-body">
|
||||
<div class="result-heading">
|
||||
<div>
|
||||
<div class="title-row"><span class="pill pill-{{ job.status }}">{{ job.get_status_display }}</span>{% if job.match_recommendation %}<span class="pill pill-{{ job.match_recommendation }}">{% if job.match_recommendation == 'strong' %}Sterke match{% elif job.match_recommendation == 'possible' %}Mogelijke match{% elif job.match_recommendation == 'weak' %}Lage match{% else %}Verborgen{% endif %}</span>{% endif %}</div>
|
||||
<h2><a href="{% url 'jobs:detail' job.pk %}">{{ job.original_title }}</a></h2>
|
||||
<p>{{ job.employer_name }}</p>
|
||||
</div>
|
||||
{% if job.match_score is not None %}<div class="score-block"><strong>{{ job.match_score|floatformat:0 }}%</strong><small>confidence {{ job.match_confidence|floatformat:2 }}</small></div>{% endif %}
|
||||
</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>{% if job.direct_employer %}<span>Directe werkgever</span>{% endif %}</div>
|
||||
{% if job.skills_required %}<p class="result-summary"><strong>Herkenbare skills:</strong> {{ job.skills_required|slice:":4"|join:", " }}</p>{% elif job.description_text %}<p class="result-summary">{{ job.description_text|truncatewords:24 }}</p>{% endif %}
|
||||
<div class="results-layout">
|
||||
<section class="results-column" aria-labelledby="results-heading">
|
||||
<div class="results-toolbar">
|
||||
<div><span class="section-kicker">Gerangschikte resultaten</span><h2 id="results-heading">{{ page_obj.paginator.count }} vacatures gevonden</h2></div>
|
||||
<span class="view-indicator"><svg class="icon"><use href="#icon-radar"></use></svg>{% if current_sort == 'newest' %}Nieuwste eerst{% elif current_sort == 'closing' %}Deadline eerst{% else %}Matchvolgorde{% endif %}</span>
|
||||
</div>
|
||||
<div class="result-actions"><a class="button button-primary" href="{% url 'jobs:detail' job.pk %}">Bekijk analyse <svg class="icon"><use href="#icon-arrow"></use></svg></a></div>
|
||||
</article>
|
||||
{% empty %}<div class="empty-state compact-empty"><div class="empty-icon"><svg class="icon"><use href="#icon-search"></use></svg></div><h2>Geen vacatures gevonden</h2><p>Pas de zoekterm of filters aan. Gearchiveerde resultaten verschijnen wanneer je de statusfilter leegmaakt.</p></div>{% endfor %}
|
||||
<div class="results-feed">
|
||||
{% for job in jobs %}
|
||||
{% widthratio job.match_confidence 1 100 as confidence_percent %}
|
||||
<article class="result-card {% if job.match_recommendation == 'strong' %}strong-match{% endif %}">
|
||||
<div class="result-accent" aria-hidden="true"></div>
|
||||
<div class="result-body">
|
||||
<div class="result-heading">
|
||||
<div>
|
||||
<div class="title-row"><span class="pill pill-{{ job.status }}">{{ job.get_status_display }}</span>{% if job.match_recommendation %}<span class="pill pill-{{ job.match_recommendation }}">{% if job.match_recommendation == 'strong' %}Sterke match{% elif job.match_recommendation == 'possible' %}Mogelijke match{% elif job.match_recommendation == 'weak' %}Lage match{% else %}Verborgen{% endif %}</span>{% endif %}{% if current_focus != 'all' %}<span class="pill pill-it">IT-focus</span>{% endif %}</div>
|
||||
<h2><a href="{% url 'jobs:detail' job.pk %}">{{ job.original_title }}</a></h2>
|
||||
<p>{{ job.employer_name }}</p>
|
||||
</div>
|
||||
{% if job.match_score is not None %}<div class="score-block"><strong>{{ job.match_score|floatformat:0 }}%</strong><small>Datakwaliteit {{ confidence_percent }}%</small></div>{% else %}<div class="score-block score-pending"><strong>—</strong><small>Nog niet gescoord</small></div>{% endif %}
|
||||
</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>{{ job.first_seen|date:"d/m/Y" }}</span>{% if job.direct_employer %}<span>Directe werkgever</span>{% endif %}</div>
|
||||
{% if job.match_positives.0 %}<p class="evidence-line positive-evidence"><svg class="icon"><use href="#icon-radar"></use></svg><span>{{ job.match_positives.0 }}</span></p>{% elif job.skills_required %}<p class="result-summary"><strong>Herkenbare skills:</strong> {{ job.skills_required|slice:":4"|join:", " }}</p>{% elif job.description_text %}<p class="result-summary">{{ job.description_text|truncatewords:24 }}</p>{% endif %}
|
||||
{% if job.match_concerns.0 %}<p class="evidence-line concern-evidence"><span aria-hidden="true">!</span><span>{{ job.match_concerns.0 }}</span></p>{% endif %}
|
||||
</div>
|
||||
<div class="result-actions">
|
||||
<a class="button button-primary" href="{% url 'jobs:detail' job.pk %}">Bekijk analyse <svg class="icon"><use href="#icon-arrow"></use></svg></a>
|
||||
<form method="post" action="{% url 'jobs:feedback' job.pk %}">{% csrf_token %}<input type="hidden" name="action" value="save"><input type="hidden" name="next" value="{{ request.get_full_path }}"><button class="button button-ghost" type="submit"><svg class="icon"><use href="#icon-bookmark"></use></svg>Bewaren</button></form>
|
||||
</div>
|
||||
</article>
|
||||
{% empty %}<div class="empty-state compact-empty"><div class="empty-icon"><svg class="icon"><use href="#icon-search"></use></svg></div><h2>Geen passende vacatures gevonden</h2><p>Pas de filters aan of open het volledige bronarchief. Niet-IT-vacatures en harde uitsluitingen blijven standaard bewust uit je radar.</p><a class="button button-secondary" href="?focus=all&match=all&status=active&sort=match">Volledig archief bekijken</a></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 }}&focus={{ current_focus }}&match={{ current_match }}&sort={{ current_sort }}">Vorige</a>{% endif %}<span>Pagina {{ page_obj.number }} van {{ page_obj.paginator.num_pages }}</span>{% if page_obj.has_next %}<a class="button button-ghost" href="?page={{ page_obj.next_page_number }}&q={{ request.GET.q }}&status={{ request.GET.status }}&workplace={{ request.GET.workplace }}&focus={{ current_focus }}&match={{ current_match }}&sort={{ current_sort }}">Volgende</a>{% endif %}</nav>{% endif %}
|
||||
</section>
|
||||
|
||||
<aside class="jobs-insight-rail" aria-label="Radarcontext">
|
||||
<section class="panel rail-panel radar-summary"><span class="section-kicker">Radarbereik</span><h2>IT-focus</h2><p class="muted">Titels krijgen eerst een deterministische sectorcontrole. AI kan deze grens niet wijzigen.</p><div class="rail-list"><div class="rail-item"><span>Actieve IT-vacatures</span><strong>{{ it_job_count }}</strong></div><div class="rail-item"><span>Niet-IT uit beeld</span><strong>{{ filtered_non_it_count }}</strong></div><div class="rail-item"><span>Volledig actief archief</span><strong>{{ active_job_count }}</strong></div></div><a class="button button-ghost button-block" href="?focus={% if current_focus == 'all' %}it{% else %}all{% endif %}&match={% if current_focus == 'all' %}radar{% else %}all{% endif %}&status=active&sort=match">{% if current_focus == 'all' %}Terug naar IT-focus{% else %}Volledig archief openen{% endif %}</a></section>
|
||||
<section class="panel rail-panel"><span class="section-kicker">Actief zoekprofiel</span><h2>{{ active_profile.name|default:"Geen profiel actief" }}</h2>{% if active_profile %}<div class="profile-signal-grid"><div><span>Rollen</span><strong>{{ active_profile.desired_titles|length }}</strong></div><div><span>Skills</span><strong>{{ active_profile.desired_skills|length }}</strong></div><div><span>Radius</span><strong>{{ active_profile.max_distance_km }} km</strong></div><div><span>Vanaf</span><strong>{{ active_profile.recommendation_threshold }}%</strong></div></div><a class="button button-secondary button-block" href="{% url 'profiles:edit' active_profile.pk %}">Profiel verfijnen</a>{% else %}<p class="muted">Activeer een zoekprofiel om persoonlijke scores te berekenen.</p>{% endif %}</section>
|
||||
<section class="panel rail-panel"><span class="section-kicker">Hoe rangschikking werkt</span><h2>Deterministisch eerst</h2><ol class="radar-steps"><li><strong>IT-titel</strong><span>Sectorfilter op functietitel.</span></li><li><strong>Harde regels</strong><span>Afstand en uitsluitingen winnen altijd.</span></li><li><strong>Matchscore</strong><span>Rollen, skills en voorkeuren bepalen de volgorde.</span></li></ol></section>
|
||||
</aside>
|
||||
</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 %}
|
||||
|
||||
@@ -159,6 +159,15 @@ def test_job_profile_source_and_application_views(client, user, profile, job, so
|
||||
assert response.status_code == 302
|
||||
assert Feedback.objects.filter(user=user, job=job).exists()
|
||||
|
||||
unsafe_redirect = client.post(
|
||||
reverse("jobs:feedback", kwargs={"pk": job.pk}),
|
||||
{
|
||||
"action": Feedback.Action.INTERESTING,
|
||||
"next": "https://malicious.example/steal-session",
|
||||
},
|
||||
)
|
||||
assert unsafe_redirect.url == reverse("jobs:detail", kwargs={"pk": job.pk})
|
||||
|
||||
assert client.get(reverse("profiles:list")).status_code == 200
|
||||
assert client.get(reverse("profiles:edit", kwargs={"pk": profile.pk})).status_code == 200
|
||||
assert client.get(reverse("sources:list")).status_code == 200
|
||||
|
||||
@@ -2,6 +2,9 @@ import pytest
|
||||
from django.core.cache import cache
|
||||
from django.test import override_settings
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
|
||||
from apps.jobs.models import JobPosting, ScoreRun
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@@ -12,6 +15,14 @@ def test_today_requires_login(client):
|
||||
assert reverse("login") in response.url
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.django_db
|
||||
def test_browser_favicon_fallback_uses_local_svg(client):
|
||||
response = client.get("/favicon.ico")
|
||||
assert response.status_code == 301
|
||||
assert response.url.endswith("/static/favicon.svg")
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.django_db
|
||||
def test_today_loads_for_user(client, user, profile):
|
||||
@@ -21,6 +32,130 @@ def test_today_loads_for_user(client, user, profile):
|
||||
assert "Vandaag" in response.content.decode()
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.django_db
|
||||
def test_job_list_defaults_to_ranked_it_focus_but_keeps_explicit_archive(
|
||||
client, user, profile, job
|
||||
):
|
||||
non_it = JobPosting.objects.create(
|
||||
employer=job.employer,
|
||||
original_title="Spontane sollicitatie - logopedist",
|
||||
normalized_title="spontane sollicitatie logopedist",
|
||||
canonical_url="https://jobs.example.org/vacatures/logopedist",
|
||||
canonical_key="9" * 64,
|
||||
content_hash="8" * 64,
|
||||
description_text="Zorgfunctie met toegang tot digitale patiëntendossiers.",
|
||||
status=JobPosting.Status.ACTIVE,
|
||||
)
|
||||
misleading_it_title = JobPosting.objects.create(
|
||||
employer=job.employer,
|
||||
original_title="IT Recruitment Specialist",
|
||||
normalized_title="it recruitment specialist",
|
||||
canonical_url="https://jobs.example.org/vacatures/it-recruitment",
|
||||
canonical_key="3" * 64,
|
||||
content_hash="2" * 64,
|
||||
status=JobPosting.Status.ACTIVE,
|
||||
)
|
||||
ScoreRun.objects.create(
|
||||
job=job,
|
||||
profile=profile,
|
||||
profile_version=profile.version,
|
||||
score=60,
|
||||
confidence=0.9,
|
||||
recommendation=ScoreRun.Recommendation.WEAK,
|
||||
positives=["IT-focus bevestigd via de functietitel."],
|
||||
)
|
||||
ScoreRun.objects.create(
|
||||
job=non_it,
|
||||
profile=profile,
|
||||
profile_version=profile.version,
|
||||
score=99,
|
||||
confidence=0.9,
|
||||
recommendation=ScoreRun.Recommendation.POSSIBLE,
|
||||
)
|
||||
hidden_it = JobPosting.objects.create(
|
||||
employer=job.employer,
|
||||
original_title="Cloud Engineer buiten de zoekradius",
|
||||
normalized_title="cloud engineer buiten de zoekradius",
|
||||
canonical_url="https://jobs.example.org/vacatures/cloud-ver",
|
||||
canonical_key="5" * 64,
|
||||
content_hash="4" * 64,
|
||||
status=JobPosting.Status.ACTIVE,
|
||||
)
|
||||
ScoreRun.objects.create(
|
||||
job=hidden_it,
|
||||
profile=profile,
|
||||
profile_version=profile.version,
|
||||
score=98,
|
||||
confidence=0.9,
|
||||
recommendation=ScoreRun.Recommendation.HIDDEN,
|
||||
hard_exclusions=["Buiten de zoekradius."],
|
||||
)
|
||||
client.force_login(user)
|
||||
|
||||
focused = client.get(reverse("jobs:list"))
|
||||
focused_body = focused.content.decode()
|
||||
assert job.original_title in focused_body
|
||||
assert non_it.original_title not in focused_body
|
||||
assert misleading_it_title.original_title not in focused_body
|
||||
assert hidden_it.original_title not in focused_body
|
||||
assert focused.context["it_job_count"] == 2
|
||||
assert focused.context["filtered_non_it_count"] == 2
|
||||
|
||||
archive = client.get(reverse("jobs:list"), {"focus": "all", "match": "all"})
|
||||
archive_body = archive.content.decode()
|
||||
assert non_it.original_title in archive_body
|
||||
assert misleading_it_title.original_title in archive_body
|
||||
assert hidden_it.original_title in archive_body
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.django_db
|
||||
def test_today_uses_latest_user_score_and_excludes_non_it(client, user, profile, job):
|
||||
non_it = JobPosting.objects.create(
|
||||
employer=job.employer,
|
||||
original_title="Technieker Facilitaire Diensten",
|
||||
normalized_title="technieker facilitaire diensten",
|
||||
canonical_url="https://jobs.example.org/vacatures/facilitair",
|
||||
canonical_key="7" * 64,
|
||||
content_hash="6" * 64,
|
||||
status=JobPosting.Status.ACTIVE,
|
||||
first_seen=timezone.now(),
|
||||
)
|
||||
ScoreRun.objects.create(
|
||||
job=job,
|
||||
profile=profile,
|
||||
profile_version=profile.version,
|
||||
score=70,
|
||||
confidence=0.9,
|
||||
recommendation=ScoreRun.Recommendation.POSSIBLE,
|
||||
)
|
||||
ScoreRun.objects.create(
|
||||
job=job,
|
||||
profile=profile,
|
||||
profile_version=profile.version,
|
||||
score=66,
|
||||
confidence=0.9,
|
||||
recommendation=ScoreRun.Recommendation.POSSIBLE,
|
||||
)
|
||||
ScoreRun.objects.create(
|
||||
job=non_it,
|
||||
profile=profile,
|
||||
profile_version=profile.version,
|
||||
score=95,
|
||||
confidence=0.9,
|
||||
recommendation=ScoreRun.Recommendation.STRONG,
|
||||
)
|
||||
client.force_login(user)
|
||||
|
||||
response = client.get(reverse("dashboard:today"))
|
||||
|
||||
cards = list(response.context["score_cards"])
|
||||
assert [card.job_id for card in cards] == [job.id]
|
||||
assert cards[0].score == 66
|
||||
assert response.context["filtered_non_it_jobs"] == 1
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.django_db
|
||||
@override_settings(
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
from apps.jobs.services.relevance import assess_it_relevance, profile_requires_it_focus
|
||||
|
||||
|
||||
def test_title_led_it_relevance_accepts_clear_it_roles():
|
||||
for title in (
|
||||
"Modern Workplace Engineer",
|
||||
"Product owner ICT (100%)",
|
||||
"Functioneel Analist",
|
||||
"Senior .Net Developer",
|
||||
"Senior Mobile Developer",
|
||||
"Deeltijds docent informatica, networking & security",
|
||||
):
|
||||
assessment = assess_it_relevance(title)
|
||||
assert assessment.relevant, title
|
||||
assert assessment.signals
|
||||
|
||||
|
||||
def test_title_led_it_relevance_rejects_non_it_and_ambiguous_roles():
|
||||
for title in (
|
||||
"Technieker Facilitaire Diensten",
|
||||
"Spontane sollicitatie - logopedist",
|
||||
"Doctoraatsbursaal trombo-inflammatie bij hemodialyse",
|
||||
"Stafmedewerker Onderwijs faculteit Rechten",
|
||||
"Project Manager",
|
||||
"Business Developer",
|
||||
"IT Recruitment Specialist",
|
||||
):
|
||||
assessment = assess_it_relevance(title)
|
||||
assert not assessment.relevant, title
|
||||
assert assessment.reason
|
||||
|
||||
|
||||
def test_active_it_profile_requires_deterministic_it_focus(profile):
|
||||
assert profile_requires_it_focus(profile)
|
||||
|
||||
profile.name = "Algemeen profiel"
|
||||
profile.desired_titles = ["verpleegkundige"]
|
||||
profile.desired_skills = []
|
||||
assert not profile_requires_it_focus(profile)
|
||||
@@ -63,6 +63,18 @@ def test_hard_title_exclusion_wins(matching_job, profile):
|
||||
assert any("sales" in reason.lower() for reason in result.hard_exclusions)
|
||||
|
||||
|
||||
def test_non_it_vacancy_is_hidden_for_it_profile(matching_job, profile):
|
||||
matching_job.original_title = "Spontane sollicitatie - logopedist"
|
||||
matching_job.normalized_title = "spontane sollicitatie logopedist"
|
||||
matching_job.description_text = "Werk met het digitale patiëntendossier in ons ziekenhuis."
|
||||
|
||||
result = calculate_score(matching_job, profile)
|
||||
|
||||
assert result.recommendation == ScoreRun.Recommendation.HIDDEN
|
||||
assert any("Geen aantoonbaar IT-signaal" in reason for reason in result.hard_exclusions)
|
||||
assert result.evidence["it_relevance"]["relevant"] is False
|
||||
|
||||
|
||||
def test_distance_boundary_inclusief(matching_job, profile, monkeypatch):
|
||||
profile.max_distance_km = 50
|
||||
profile.save(update_fields=["max_distance_km"])
|
||||
|
||||
Reference in New Issue
Block a user