feat: replace dashboard and vacancy intelligence

This commit is contained in:
Jens
2026-07-22 19:56:15 +02:00
parent 09af98193f
commit 3371455255
9 changed files with 388 additions and 216 deletions
+5 -59
View File
@@ -4,14 +4,13 @@ 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, OuterRef, Q, Subquery
from django.db.models import Count
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.jobs.models import JobPosting
from apps.jobs.services.cockpit import build_dashboard_cockpit
from apps.profiles.models import SearchProfile
from apps.sources.models import Source
from apps.sources.services.health import collect_source_health
@@ -86,61 +85,8 @@ 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(
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.
seen: set[str] = set()
cards = []
for score in latest_scores[:250]:
key = str(score.job_id)
if key in seen:
continue
seen.add(key)
cards.append(score)
if len(cards) >= 20:
break
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,
"priority_applications": Application.objects.filter(user=self.request.user)
.exclude(status__in=[Application.Status.REJECTED, Application.Status.WITHDRAWN])
.select_related("job", "job__employer")
.order_by("follow_up_date", "-updated_at")[:3],
"active_jobs": 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(
total=Count("id"),
unhealthy=Count("id", filter=~Q(status=Source.Status.ACTIVE)),
),
}
)
context.update(build_dashboard_cockpit(self.request.user, profile))
context["active_profile"] = profile
return context
+177
View File
@@ -0,0 +1,177 @@
from __future__ import annotations
from typing import Any
from django.contrib.auth import get_user_model
from django.db.models import (
CharField,
Count,
DateTimeField,
DecimalField,
F,
JSONField,
OuterRef,
Q,
QuerySet,
Subquery,
)
from django.utils import timezone
from apps.profiles.models import SearchProfile
from apps.sources.models import Source, SourceRun
from ..models import Application, ApplicationTimelineEvent, JobPosting, ScoreRun
from .relevance import it_relevance_query
User = get_user_model()
def annotate_latest_profile_score(
queryset: QuerySet[JobPosting], profile: SearchProfile | None
) -> QuerySet[JobPosting]:
"""Annotate jobs with one deterministic latest score for the active profile."""
if profile is None:
return queryset
latest = ScoreRun.objects.filter(job=OuterRef("pk"), profile=profile).order_by("-created_at")
return queryset.annotate(
match_score=Subquery(
latest.values("score")[:1],
output_field=DecimalField(max_digits=5, decimal_places=2),
),
match_confidence=Subquery(
latest.values("confidence")[:1],
output_field=DecimalField(max_digits=4, decimal_places=3),
),
match_recommendation=Subquery(
latest.values("recommendation")[:1], output_field=CharField()
),
match_positives=Subquery(latest.values("positives")[:1], output_field=JSONField()),
match_concerns=Subquery(latest.values("concerns")[:1], output_field=JSONField()),
match_hard_exclusions=Subquery(
latest.values("hard_exclusions")[:1], output_field=JSONField()
),
match_scored_at=Subquery(latest.values("created_at")[:1], output_field=DateTimeField()),
)
def _recent_dashboard_activity(user: User) -> list[dict[str, Any]]:
activity: list[dict[str, Any]] = []
for run in SourceRun.objects.select_related("source").order_by("-started_at")[:4]:
activity.append(
{
"occurred_at": run.started_at,
"kind": "source",
"title": f"Bronrun {run.get_status_display().lower()}",
"detail": run.source.name,
"tone": "positive" if run.status == SourceRun.Status.SUCCESS else "attention",
}
)
for event in (
ApplicationTimelineEvent.objects.filter(user=user)
.select_related("application__job")
.order_by("-created_at")[:4]
):
activity.append(
{
"occurred_at": event.created_at,
"kind": "application",
"title": event.get_event_type_display(),
"detail": event.application.job.original_title,
"tone": "neutral",
}
)
return sorted(activity, key=lambda item: item["occurred_at"], reverse=True)[:5]
def build_dashboard_cockpit(user: User, profile: SearchProfile | None) -> dict[str, Any]:
"""Build truthful dashboard metrics and bounded read lists."""
active_jobs = JobPosting.objects.filter(status=JobPosting.Status.ACTIVE)
active_it_jobs = active_jobs.filter(it_relevance_query())
today = timezone.localdate()
latest_scores = ScoreRun.objects.none()
if profile is not None:
latest_score_id = (
ScoreRun.objects.filter(job_id=OuterRef("job_id"), profile=profile)
.order_by("-created_at")
.values("pk")[:1]
)
latest_scores = (
ScoreRun.objects.select_related("job", "job__employer")
.filter(
profile=profile,
pk=Subquery(latest_score_id),
job__status=JobPosting.Status.ACTIVE,
recommendation__in=[
ScoreRun.Recommendation.STRONG,
ScoreRun.Recommendation.POSSIBLE,
],
hard_exclusions=[],
)
.filter(it_relevance_query("job__"))
)
sources = Source.objects.exclude(domain="jobs.example.org", status=Source.Status.DISABLED)
source_counts = sources.aggregate(
total=Count("id"),
active=Count("id", filter=Q(status=Source.Status.ACTIVE)),
attention=Count("id", filter=~Q(status=Source.Status.ACTIVE)),
)
open_applications = Application.objects.filter(user=user).exclude(
status__in=[Application.Status.REJECTED, Application.Status.WITHDRAWN]
)
new_today = active_it_jobs.filter(first_seen__date=today).count()
strong_today = latest_scores.filter(
recommendation=ScoreRun.Recommendation.STRONG,
job__first_seen__date=today,
).count()
filtered_count = active_jobs.exclude(it_relevance_query()).count()
duplicate_count = JobPosting.objects.filter(status=JobPosting.Status.DUPLICATE).count()
if strong_today:
day_conclusion = f"{strong_today} sterke match(es) verdienen vandaag eerst je aandacht."
elif new_today:
day_conclusion = f"De radar vond vandaag {new_today} nieuwe relevante vacature(s)."
else:
day_conclusion = "Geen nieuw sterk signaal; je bestaande opvolging blijft prioritair."
return {
"score_cards": list(latest_scores.order_by("-score", "-created_at")[:8]),
"priority_applications": list(
open_applications.select_related("job", "job__employer").order_by(
F("follow_up_date").asc(nulls_last=True), "-updated_at"
)[:4]
),
"active_jobs": active_it_jobs.count(),
"filtered_non_it_jobs": filtered_count,
"duplicate_jobs": duplicate_count,
"new_today": new_today,
"strong_today": strong_today,
"applications_open": open_applications.count(),
"source_counts": source_counts,
"recent_activity": _recent_dashboard_activity(user),
"day_conclusion": day_conclusion,
"latest_scan": SourceRun.objects.order_by("-started_at").first(),
}
def build_job_intelligence(
*, job: JobPosting, profile: SearchProfile | None, user: User
) -> dict[str, Any]:
"""Return the bounded, user-scoped read model for explorer and detail."""
score = (
ScoreRun.objects.filter(job=job, profile=profile).order_by("-created_at").first()
if profile
else None
)
return {
"score": score,
"application": Application.objects.filter(user=user, job=job).first(),
"duplicate_count": job.duplicates.count(),
"source_count": job.source_aliases.count(),
"version_count": job.versions.count(),
"latest_versions": list(job.versions.all()[:5]),
"provenance_rows": list(job.provenance.select_related("source_alias__source")[:12]),
}
+26 -49
View File
@@ -3,16 +3,7 @@ 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,
DateTimeField,
DecimalField,
F,
JSONField,
OuterRef,
Q,
Subquery,
)
from django.db.models import Count, F, Q
from django.http import Http404, HttpResponse
from django.shortcuts import get_object_or_404, redirect
from django.urls import reverse
@@ -30,6 +21,7 @@ from .services.applications import (
delete_application_dossier,
track_application_changes,
)
from .services.cockpit import annotate_latest_profile_score, build_job_intelligence
from .services.feedback import record_feedback
from .services.relevance import it_relevance_query
from .services.skill_demand import build_skill_demand_report
@@ -70,40 +62,15 @@ class JobListView(LoginRequiredMixin, ListView):
VALID_SORTS = {"match", "newest", "closing"}
def get_queryset(self):
queryset = JobPosting.objects.select_related("employer").all()
queryset = JobPosting.objects.select_related("employer").annotate(
duplicate_count=Count("duplicates", distinct=True)
)
profile = SearchProfile.objects.filter(user=self.request.user, is_active=True).first()
if profile:
latest_score = ScoreRun.objects.filter(job=OuterRef("pk"), profile=profile).order_by(
"-created_at"
)
queryset = queryset.annotate(
match_score=Subquery(
latest_score.values("score")[:1],
output_field=DecimalField(max_digits=5, decimal_places=2),
),
match_confidence=Subquery(
latest_score.values("confidence")[:1],
output_field=DecimalField(max_digits=4, decimal_places=3),
),
match_recommendation=Subquery(
latest_score.values("recommendation")[:1], output_field=CharField()
),
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()
),
)
queryset = annotate_latest_profile_score(queryset, profile)
query = self.request.GET.get("q", "").strip()
status = self.request.GET.get("status", "active").strip()
workplace = self.request.GET.get("workplace", "").strip()
channel = self.request.GET.get("channel", "").strip()
focus = self.request.GET.get("focus", "it").strip()
recommendation = self.request.GET.get("match", "radar").strip()
sort = self.request.GET.get("sort", "match").strip()
@@ -120,6 +87,10 @@ class JobListView(LoginRequiredMixin, ListView):
queryset = queryset.filter(status=status)
if workplace:
queryset = queryset.filter(workplace_type=workplace)
if channel == "direct":
queryset = queryset.filter(direct_employer=True, recruiter=False)
elif channel == "recruiter":
queryset = queryset.filter(recruiter=True)
if focus != "all":
queryset = queryset.filter(it_relevance_query())
if profile and recommendation == "radar":
@@ -162,8 +133,21 @@ class JobListView(LoginRequiredMixin, ListView):
else "radar"
),
"current_sort": self.request.GET.get("sort", "match"),
"current_channel": self.request.GET.get("channel", ""),
}
)
jobs = list(context["jobs"])
selected_id = self.request.GET.get("selected", "")
selected_job = next((job for job in jobs if str(job.pk) == selected_id), None)
if selected_job is None and jobs:
selected_job = jobs[0]
context["selected_job"] = selected_job
if selected_job is not None:
context["selected_intelligence"] = build_job_intelligence(
job=selected_job,
profile=context["active_profile"],
user=self.request.user,
)
return context
@@ -180,16 +164,9 @@ class JobDetailView(LoginRequiredMixin, DetailView):
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
profile = SearchProfile.objects.filter(user=self.request.user, is_active=True).first()
context["score"] = (
ScoreRun.objects.filter(job=self.object, profile=profile)
.order_by("-created_at")
.first()
if profile
else None
context.update(
build_job_intelligence(job=self.object, profile=profile, user=self.request.user)
)
context["application"] = Application.objects.filter(
user=self.request.user, job=self.object
).first()
context["latest_feedback"] = Feedback.objects.filter(
user=self.request.user, job=self.object
).first()
+6 -1
View File
@@ -1611,7 +1611,7 @@ tasks:
met gesplitst design system, mobile navigatie en generieke foutstates; relevante tests groen.
- id: VR-210
title: Stitch dashboard en vacature-intelligence
status: ready
status: done
priority: P0
requirement_ids:
- PR-040
@@ -1638,6 +1638,11 @@ tasks:
- templates/dashboard/today.html
- templates/jobs/list.html
- templates/jobs/detail.html
result:
completed_at: '2026-07-22'
note: Dashboard, vacatureverkenner en detail volledig naar Stitch-composities vervangen met echte cockpitaggregaties,
laatste profielscore, master/detailselectie, provenance, versies, duplicaten en veilige acties; relevante
scoring/relevance/viewtests groen.
- id: VR-211
title: Stitch pipeline, radarprofiel en brongezondheid
status: ready
+79
View File
@@ -66,3 +66,82 @@
.command-panel { display: grid; grid-template-columns: repeat(2, 1fr); gap: 12px; }
.command-panel pre { margin: 6px 0 0; padding: 10px; background: var(--surface-lowest); color: var(--emerald); }
.auth-card .stack label { color: var(--text); font-size: 13px; }
.mini-timeline { display: grid; gap: 4px; }
.mini-event { display: flex; align-items: flex-start; gap: 10px; padding: 9px 0; border-top: 1px solid color-mix(in srgb, var(--outline) 30%, transparent); }
.mini-event strong, .mini-event small { display: block; }
.mini-event strong { font-size: 12px; }
.mini-event small { margin-top: 3px; font-size: 10px; }
.active-filters { display: flex; flex-wrap: wrap; align-items: center; gap: 7px; margin: -12px 0 18px; }
.active-filters > a { margin-left: auto; color: var(--cyan-dim); font: 11px var(--font-mono); }
.explorer-shell { display: grid; grid-template-columns: minmax(430px, .78fr) minmax(500px, 1.22fr); align-items: start; gap: 16px; }
.explorer-list { display: grid; gap: 8px; }
.explorer-card { position: relative; display: grid; grid-template-columns: 64px minmax(0, 1fr) 24px; align-items: center; gap: 13px; min-height: 132px; padding: 15px; border: 1px solid color-mix(in srgb, var(--outline) 42%, transparent); background: var(--surface-container); transition: border-color .15s, background .15s, transform .15s; }
.explorer-card:hover { transform: translateX(2px); border-color: var(--outline-strong); }
.explorer-card.is-selected { border-color: color-mix(in srgb, var(--cyan) 48%, var(--outline)); background: color-mix(in srgb, var(--cyan) 5%, var(--surface-container)); box-shadow: var(--shadow-signal); }
.explorer-card-link { position: absolute; z-index: 1; inset: 0; }
.explorer-score { display: grid; width: 58px; height: 58px; place-items: center; align-content: center; border: 1px solid color-mix(in srgb, var(--cyan) 38%, var(--outline)); background: var(--surface-low); }
.explorer-score strong { color: var(--cyan); font: 650 22px/1 var(--font-headline); }
.explorer-score small { margin-top: 4px; font: 9px var(--font-mono); text-transform: uppercase; }
.explorer-card-body { min-width: 0; }
.explorer-card-body h3 { margin: 7px 0 2px; overflow: hidden; font-size: 16px; text-overflow: ellipsis; white-space: nowrap; }
.explorer-card-body p { margin-bottom: 7px; color: var(--text-muted); }
.card-signal { display: block; overflow: hidden; margin-top: 8px; color: var(--emerald); text-overflow: ellipsis; white-space: nowrap; }
.explorer-arrow { color: var(--text-faint); }
.explorer-intelligence { position: sticky; top: calc(var(--topbar-height) + 18px); }
.intelligence-panel { overflow: hidden; border: 1px solid color-mix(in srgb, var(--cyan) 24%, var(--outline)); background: var(--surface-low); box-shadow: var(--shadow-signal); }
.intelligence-hero { position: relative; overflow: hidden; padding: 24px; border-bottom: 1px solid color-mix(in srgb, var(--outline) 42%, transparent); background: linear-gradient(140deg, color-mix(in srgb, var(--surface-container) 88%, var(--cyan)), var(--surface-container)); }
.intelligence-hero::after { position: absolute; right: -40px; bottom: -80px; width: 190px; height: 190px; content: ""; border: 1px solid color-mix(in srgb, var(--cyan) 14%, transparent); border-radius: 50%; box-shadow: inset 0 0 0 32px color-mix(in srgb, var(--cyan) 2%, transparent), inset 0 0 0 63px color-mix(in srgb, var(--cyan) 3%, transparent); }
.intelligence-hero > * { position: relative; z-index: 1; }
.intelligence-hero h2 { max-width: 620px; margin: 14px 0 4px; font-size: clamp(23px, 2vw, 32px); }
.intelligence-score { display: flex; align-items: center; gap: 13px; margin-top: 21px; }
.intelligence-score > strong { color: var(--cyan); font: 650 44px/1 var(--font-headline); }
.intelligence-score span, .intelligence-score small { display: block; }
.intelligence-score span { font-weight: 600; }
.intelligence-score small { margin-top: 3px; font: 10px var(--font-mono); }
.intel-facts { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); margin: 0; }
.intel-facts > div { min-height: 72px; padding: 13px 16px; border-right: 1px solid color-mix(in srgb, var(--outline) 28%, transparent); border-bottom: 1px solid color-mix(in srgb, var(--outline) 28%, transparent); }
.intel-facts dt { color: var(--text-faint); font: 10px var(--font-mono); letter-spacing: .06em; text-transform: uppercase; }
.intel-facts dd { margin: 5px 0 0; color: var(--text-strong); }
.intel-signals { padding: 16px 19px; border-bottom: 1px solid color-mix(in srgb, var(--outline) 30%, transparent); }
.intel-signals ul { margin: 9px 0 0; padding-left: 18px; }
.intel-signals li::marker { color: var(--emerald); }
.intel-signals.concerns li::marker { color: var(--amber); }
.intelligence-actions { display: grid; grid-template-columns: 1fr 1fr; gap: 9px; padding: 16px; }
.back-link { display: inline-flex; margin-bottom: 16px; color: var(--text-muted); font: 11px var(--font-mono); }
.detail-hero { display: flex; align-items: flex-end; justify-content: space-between; gap: 30px; min-height: 270px; }
.detail-employer { color: var(--text-muted); font-size: 18px; }
.hero-score { min-width: 170px; padding: 18px; border: 1px solid color-mix(in srgb, var(--cyan) 45%, var(--outline)); background: color-mix(in srgb, var(--surface-lowest) 82%, transparent); text-align: center; }
.hero-score strong, .hero-score span, .hero-score small { display: block; }
.hero-score strong { color: var(--cyan); font: 650 48px/1 var(--font-headline); }
.hero-score span { margin-top: 8px; color: var(--text-strong); font-weight: 650; }
.hero-score small { margin-top: 5px; font: 10px var(--font-mono); }
.hero-score.is-pending strong { color: var(--text-faint); }
.intelligence-strip { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); border: solid color-mix(in srgb, var(--outline) 42%, transparent); border-width: 0 1px 1px; background: var(--surface-low); }
.intelligence-strip > div { min-height: 92px; padding: 16px; border-right: 1px solid color-mix(in srgb, var(--outline) 32%, transparent); }
.intelligence-strip > div:last-child { border-right: 0; }
.intelligence-strip strong, .intelligence-strip small { display: block; }
.intelligence-strip strong { margin: 8px 0 3px; color: var(--text-strong); font-size: 17px; }
.score-components { display: grid; gap: 9px; margin: 18px 0; }
.score-components > div { display: grid; grid-template-columns: 150px minmax(100px, 1fr) 36px; align-items: center; gap: 10px; }
.score-components span { color: var(--text-muted); font: 11px var(--font-mono); }
.progress { height: 5px; overflow: hidden; background: var(--surface-highest); }
.progress i { display: block; height: 100%; background: linear-gradient(90deg, var(--cyan-dim), var(--emerald)); }
.signal-columns { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 14px; }
.signal-columns > div { padding: 15px; background: var(--surface-low); }
.skill-bands { display: grid; gap: 16px; margin: 16px 0; }
.tag-cloud { display: flex; flex-wrap: wrap; gap: 7px; margin-top: 8px; }
.prose { color: var(--text); line-height: 1.75; }
.prose h2, .prose h3 { margin-top: 24px; }
.source-list { display: grid; gap: 0; padding: 0; list-style: none; }
.source-list li { display: flex; min-height: 48px; align-items: center; justify-content: space-between; gap: 14px; border-top: 1px solid color-mix(in srgb, var(--outline) 30%, transparent); }
.source-list a { display: inline-flex; align-items: center; gap: 7px; color: var(--cyan-dim); }
.source-list span { color: var(--text-muted); font: 10px var(--font-mono); }
.provenance-details { margin-top: 15px; }
.provenance-details summary { padding: 11px; border: 1px solid var(--outline); background: var(--surface-low); }
.data-table-wrap { overflow-x: auto; }
.data-table { width: 100%; border-collapse: collapse; }
.data-table th, .data-table td { padding: 11px 13px; border-bottom: 1px solid color-mix(in srgb, var(--outline) 35%, transparent); text-align: left; vertical-align: top; }
.data-table th { color: var(--text-faint); font: 600 10px var(--font-mono); letter-spacing: .08em; text-transform: uppercase; }
.version-list { display: grid; }
.version-list article { display: grid; grid-template-columns: 150px 1fr; gap: 16px; padding: 11px 0; border-top: 1px solid color-mix(in srgb, var(--outline) 30%, transparent); }
.version-list time { color: var(--text-muted); font: 11px var(--font-mono); }
+12
View File
@@ -8,6 +8,7 @@
@media (max-width: 1450px) {
.filter-console { grid-template-columns: minmax(240px, 2fr) repeat(3, minmax(130px, 1fr)); }
.filter-console > :nth-last-child(-n+3) { grid-row: 2; }
.explorer-shell { grid-template-columns: minmax(390px, .9fr) minmax(430px, 1.1fr); }
}
@media (max-width: 1180px) {
@@ -17,6 +18,8 @@
.health-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.filter-console { grid-template-columns: repeat(3, minmax(0, 1fr)); }
.filter-console > * { grid-row: auto !important; }
.explorer-shell { grid-template-columns: 1fr; }
.explorer-intelligence { position: static; }
}
@media (max-width: 820px) {
@@ -45,6 +48,9 @@
.source-alert-grid { grid-template-columns: 1fr; }
.feed-card, .result-card { grid-template-columns: 1fr; }
.feed-card-actions, .result-actions { min-width: 0; flex-direction: row; justify-content: flex-start; border-top: 1px solid color-mix(in srgb, var(--outline) 34%, transparent); border-left: 0; }
.detail-hero { align-items: flex-start; flex-direction: column; min-height: 0; }
.hero-score { min-width: 0; width: 100%; text-align: left; }
.intelligence-strip { grid-template-columns: repeat(2, 1fr); }
}
@media (max-width: 560px) {
@@ -63,6 +69,12 @@
.page-actions, .job-actions, .form-actions { width: 100%; }
.page-actions .button, .form-actions .button { flex: 1; }
.sticky-actions { bottom: 76px; width: 100%; overflow-x: auto; justify-content: flex-start; }
.explorer-card { grid-template-columns: 54px minmax(0, 1fr); padding: 12px; }
.explorer-score { width: 50px; height: 50px; }
.explorer-arrow { display: none; }
.intelligence-actions, .intel-facts, .signal-columns, .intelligence-strip { grid-template-columns: 1fr; }
.score-components > div { grid-template-columns: 100px minmax(70px, 1fr) 30px; }
.source-list li, .version-list article { align-items: flex-start; grid-template-columns: 1fr; flex-direction: column; padding: 11px 0; }
.auth-shell { padding: 14px; }
.auth-card { padding: 22px 18px; }
}
+22 -41
View File
@@ -1,64 +1,45 @@
{% extends "base.html" %}
{% block title %}Vandaag · VacatureRadar{% endblock %}
{% block content %}
<header class="page-header dashboard-header">
<div>
<p class="eyebrow">Intelligence cockpit</p>
<h1>Goedemorgen, {{ user.first_name|default:user.username }}</h1>
<p>Er {% if new_today == 1 %}is <strong>{{ new_today }} nieuw signaal</strong>{% else %}zijn <strong>{{ new_today }} nieuwe signalen</strong>{% endif %} die je aandacht verdienen.</p>
</div>
<div class="radar-status" aria-label="Radarstatus">
<span class="signal-dot" aria-hidden="true"></span>
<span>Persoonlijke radar actief</span>
<span aria-hidden="true">·</span>
<a href="{% url 'dashboard:system' %}">Systeem bekijken</a>
</div>
<header class="cockpit-heading dashboard-heading">
<div><p class="eyebrow">Intelligence cockpit · vandaag</p><h1>Goedemorgen, {{ user.first_name|default:user.username }}.</h1><p>{{ day_conclusion }}</p></div>
<div class="radar-status" aria-label="Radarstatus"><span class="status-beacon {% if active_profile %}is-live{% endif %}" aria-hidden="true"></span><span>{% if active_profile %}{{ active_profile.name }} actief{% else %}Geen actief profiel{% endif %}</span>{% if latest_scan %}<span aria-hidden="true">·</span><span>scan {{ latest_scan.started_at|timesince }} geleden</span>{% endif %}</div>
</header>
<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 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 class="stats-grid" aria-label="Radar kerncijfers">
<article class="stat-card signal"><span class="kpi-label">Nieuwe IT-signalen</span><strong>{{ new_today }}</strong><small>vandaag ontdekt</small></article>
<article class="stat-card"><span class="kpi-label">Sterke matches</span><strong>{{ strong_today }}</strong><small>vandaag · laatste profielscore</small></article>
<article class="stat-card"><span class="kpi-label">Open trajecten</span><strong>{{ applications_open }}</strong><small>persoonlijke sollicitatiedossiers</small></article>
<article class="stat-card"><span class="kpi-label">Radardekking</span><strong>{{ source_counts.active|default:0 }}/{{ source_counts.total|default:0 }}</strong><small>bronnen actief · {{ source_counts.attention|default:0 }} aandacht</small></article>
</section>
<div class="dashboard-grid">
<section class="dashboard-matches" aria-labelledby="best-matches-heading">
<div class="section-heading">
<div><span class="section-kicker">Gerangschikt voor jou</span><h2 id="best-matches-heading">Beste nieuwe matches</h2><p>Matchscore en datakwaliteit blijven afzonderlijk zichtbaar.</p></div>
<a class="section-link" href="{% url 'jobs:list' %}">Alles bekijken <svg class="icon"><use href="#icon-arrow"></use></svg></a>
</div>
<div class="section-heading"><div><span class="section-kicker">Persoonlijk gerangschikt</span><h2 id="best-matches-heading">Beste nieuwe matches</h2><p>De nieuwste score per vacature; datakwaliteit staat los van de inhoudelijke match.</p></div><a class="section-link" href="{% url 'jobs:list' %}">Open verkenner <svg class="icon"><use href="#icon-arrow"></use></svg></a></div>
<div class="job-feed">
{% for score in score_cards %}
{% widthratio score.confidence 1 100 as confidence_percent %}
<article class="job-card feed-card {% if score.recommendation == 'strong' %}strong-match{% endif %}">
<div class="job-card-main">
<div class="job-card-head">
<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>
<div class="score-block"><strong>{{ score.score|floatformat:0 }}%</strong><small>match</small></div>
</div>
<div class="job-meta"><span><svg class="icon"><use href="#icon-location"></use></svg>{{ score.job.raw_location|default:"Locatie onbekend" }}</span><span><svg class="icon"><use href="#icon-briefcase"></use></svg>{{ score.job.get_workplace_type_display }}</span></div>
{% if score.positives %}<ul class="signal-list">{% for item in score.positives|slice:":2" %}<li>{{ item }}</li>{% endfor %}</ul>{% endif %}
{% if score.concerns %}<p class="attention"><strong>Aandachtspunt:</strong> {{ score.concerns.0 }}</p>{% endif %}
</div>
<div class="feed-card-actions">
<a class="button button-primary" href="{% url 'jobs:detail' score.job.pk %}">Bekijk analyse <svg class="icon"><use href="#icon-arrow"></use></svg></a>
<form method="post" action="{% url 'jobs:feedback' score.job.pk %}">{% csrf_token %}<input type="hidden" name="action" value="save"><input type="hidden" name="next" value="{{ request.path }}"><button class="icon-button save-button" type="submit" aria-label="{{ score.job.original_title }} bewaren"><svg class="icon"><use href="#icon-bookmark"></use></svg></button></form>
<div class="job-card-head"><div><div class="title-row"><span class="pill pill-{{ score.recommendation }}">{{ score.get_recommendation_display }}</span>{% if score.job.direct_employer %}<span class="pill">Directe werkgever</span>{% elif score.job.recruiter %}<span class="pill">Recruiter</span>{% endif %}</div><h3><a href="{% url 'jobs:detail' score.job.pk %}">{{ score.job.original_title }}</a></h3><p>{{ score.job.employer_name }}</p></div><div class="score-block"><strong>{{ score.score|floatformat:0 }}%</strong><small>match · data {{ confidence_percent }}%</small></div></div>
<div class="job-meta"><span><svg class="icon"><use href="#icon-location"></use></svg>{{ score.job.raw_location|default:"Locatie onbekend" }}</span><span><svg class="icon"><use href="#icon-briefcase"></use></svg>{{ score.job.get_workplace_type_display }}</span>{% if score.job.valid_through %}<span><svg class="icon"><use href="#icon-calendar"></use></svg>sluit {{ score.job.valid_through|date:"d/m" }}</span>{% endif %}</div>
{% if score.positives %}<p class="evidence-line positive-evidence"><svg class="icon"><use href="#icon-check"></use></svg>{{ score.positives.0 }}</p>{% endif %}
{% if score.concerns %}<p class="evidence-line concern-evidence"><svg class="icon"><use href="#icon-alert"></use></svg>{{ score.concerns.0 }}</p>{% endif %}
</div>
<div class="feed-card-actions"><a class="button button-primary" href="{% url 'jobs:detail' score.job.pk %}">Analyse <svg class="icon"><use href="#icon-arrow"></use></svg></a><form method="post" action="{% url 'jobs:feedback' score.job.pk %}">{% csrf_token %}<input type="hidden" name="action" value="save"><input type="hidden" name="next" value="{{ request.path }}"><button class="button button-ghost" type="submit"><svg class="icon"><use href="#icon-bookmark"></use></svg>Bewaar</button></form></div>
</article>
{% empty %}
<div class="empty-state compact-empty"><div class="empty-icon"><svg class="icon"><use href="#icon-radar"></use></svg></div><h2>Nog geen beoordeelde vacatures</h2><p>Importeer vacaturedata of activeer een toegestane bron. Betrouwbare resultaten verschijnen hier automatisch.</p><a class="button button-secondary" href="{% url 'sources:list' %}">Bronnen beheren</a></div>
<div class="empty-state compact-empty"><div class="empty-icon"><svg class="icon"><use href="#icon-radar"></use></svg></div><h2>Nog geen persoonlijke matches</h2><p>Na een geldige bronrun en profielscore verschijnen relevante IT-vacatures hier automatisch.</p><div class="page-actions"><a class="button button-primary" href="{% url 'sources:list' %}">Bronnen controleren</a><a class="button button-ghost" href="{% url 'profiles:list' %}">Profiel openen</a></div></div>
{% endfor %}
</div>
</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 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 class="dashboard-rail" aria-label="Radarintelligence">
<section class="panel rail-panel radar-summary"><span class="section-kicker">Dagconclusie</span><h2>{{ day_conclusion }}</h2><div class="rail-list"><div class="rail-item"><span>Actieve IT-vacatures</span><strong>{{ active_jobs }}</strong></div><div class="rail-item"><span>Niet-IT gefilterd</span><strong>{{ filtered_non_it_jobs }}</strong></div><div class="rail-item"><span>Duplicaten herkend</span><strong>{{ duplicate_jobs }}</strong></div></div><a class="button button-secondary button-block" href="{% url 'jobs:list' %}">Bekijk alle matches</a></section>
<section class="panel rail-panel"><span class="section-kicker">Opvolging</span><h2>Actie vereist</h2><div class="rail-application-list">{% for application in priority_applications %}<a class="rail-application" href="{% url 'jobs:application-edit' application.pk %}"><span><strong>{{ application.job.original_title }}</strong><small>{{ application.job.employer_name }} · {{ application.get_status_display }}</small></span>{% if application.follow_up_date %}<time datetime="{{ application.follow_up_date|date:'Y-m-d' }}">{{ application.follow_up_date|date:"d/m" }}</time>{% else %}<svg class="icon"><use href="#icon-arrow"></use></svg>{% endif %}</a>{% empty %}<p class="muted small">Geen open dossier vraagt nu opvolging.</p>{% endfor %}</div><a class="button button-ghost button-block" href="{% url 'jobs:applications' %}">Open pipeline</a></section>
<section class="panel rail-panel"><span class="section-kicker">Recente activiteit</span><h2>Radarlog</h2><div class="mini-timeline">{% for event in recent_activity %}<div class="mini-event"><span class="status-beacon {% if event.tone == 'positive' %}is-live{% endif %}" aria-hidden="true"></span><span><strong>{{ event.title }}</strong><small>{{ event.detail }} · {{ event.occurred_at|timesince }} geleden</small></span></div>{% empty %}<p class="muted small">Nog geen betrouwbare activiteit geregistreerd.</p>{% endfor %}</div><a class="button button-ghost button-block" href="{% url 'dashboard:system' %}">Automation bekijken</a></section>
</aside>
</div>
{% endblock %}
+28 -14
View File
@@ -1,24 +1,38 @@
{% 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">
<a class="back-link" href="{% url 'jobs:list' %}">← Terug naar de vacatureverkenner</a>
<div class="job-detail-grid">
<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><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 class="detail-hero">
<div class="detail-hero-copy"><p class="eyebrow">Vacature intelligence · {{ job.get_status_display }}</p><div class="title-row">{% if score.evidence.it_relevance.relevant %}<span class="pill pill-it">IT-focus bevestigd</span>{% endif %}{% if job.direct_employer %}<span class="pill pill-success">Directe werkgever</span>{% elif job.recruiter %}<span class="pill">Recruiter</span>{% endif %}{% if duplicate_count %}<span class="pill">{{ duplicate_count }} duplicaat{% if duplicate_count != 1 %}signalen{% endif %}</span>{% endif %}</div><h1>{{ job.original_title }}</h1><p class="detail-employer">{{ job.employer_name }}</p><div class="job-meta"><span><svg class="icon"><use href="#icon-location"></use></svg>{{ job.raw_location|default:"Locatie onbekend" }}</span><span><svg class="icon"><use href="#icon-briefcase"></use></svg>{{ job.get_workplace_type_display }}</span>{% for type in job.employment_types %}<span>{{ type }}</span>{% endfor %}{% if job.hours_text %}<span>{{ job.hours_text }}</span>{% endif %}</div></div>
{% if score %}{% widthratio score.confidence 1 100 as score_confidence_percent %}<div class="hero-score"><strong>{{ score.score|floatformat:0 }}%</strong><span>{{ score.get_recommendation_display }}</span><small>Datakwaliteit {{ score_confidence_percent }}%</small></div>{% else %}<div class="hero-score is-pending"><strong></strong><span>Nog niet gescoord</span><small>Brondata blijft inspecteerbaar</small></div>{% endif %}
</header>
<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>
{% 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>
<div><span class="field-label">Publicatie</span><strong>{{ job.date_posted|date:"d/m/Y"|default:"Onbekend" }}</strong><small>Eerst gezien {{ job.first_seen|date:"d/m/Y" }}</small></div>
<div><span class="field-label">Deadline</span><strong>{{ job.valid_through|date:"d/m/Y"|default:"Niet vermeld" }}</strong><small>{% if job.valid_through %}Controleer altijd de bron{% else %}Geen einddatum in bron{% endif %}</small></div>
<div><span class="field-label">Brondekking</span><strong>{{ source_count }}</strong><small>{{ version_count }} inhoudsversie{% if version_count != 1 %}s{% endif %}</small></div>
{% widthratio job.extraction_confidence 1 100 as extraction_confidence_percent %}<div><span class="field-label">Extractie</span><strong>{{ extraction_confidence_percent }}%</strong><small>Datakwaliteit bronvelden</small></div>
</section>
{% 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>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>
{% if score %}<section class="detail-section score-analysis"><div class="section-heading"><div><span class="section-kicker">Beslisuitleg</span><h2>Waarom deze match?</h2><p>Alle componenten zijn deterministisch. Ervaring en senioriteitslabels tellen niet mee.</p></div><span class="pill pill-{{ score.recommendation }}">{{ score.get_recommendation_display }}</span></div><div class="score-components">{% for name,value in score.components.items %}<div><span>{{ name|capfirst }}</span><div class="progress" role="img" aria-label="{{ name }}: {{ value|floatformat:1 }} procent"><i style="width: {{ value }}%"></i></div><strong>{{ value|floatformat:0 }}</strong></div>{% endfor %}</div><div class="signal-columns"><div><h3>Positieve signalen</h3><ul class="compact-list positive">{% for item in score.positives %}<li>{{ item }}</li>{% empty %}<li>Nog onvoldoende positieve signalen.</li>{% endfor %}</ul></div><div><h3>Aandachtspunten</h3><ul class="compact-list caution">{% for item in score.concerns %}<li>{{ item }}</li>{% empty %}<li>Geen specifiek aandachtspunt gevonden.</li>{% endfor %}</ul></div></div>{% if score.hard_exclusions %}<div class="message error"><strong>Harde uitsluiting:</strong> {{ score.hard_exclusions|join:", " }}</div>{% endif %}</section>{% endif %}
{% if job.skills_required or job.skills_preferred %}<section class="detail-section"><span class="section-kicker">Technologie & capability</span><h2>Gevraagde skills</h2><div class="skill-bands">{% if job.skills_required %}<div><span class="field-label">Expliciet vereist</span><div class="tag-cloud">{% for skill in job.skills_required %}<span class="pill pill-it">{{ skill }}</span>{% endfor %}</div></div>{% endif %}{% if job.skills_preferred %}<div><span class="field-label">Voorkeur in bron</span><div class="tag-cloud">{% for skill in job.skills_preferred %}<span class="pill">{{ skill }}</span>{% endfor %}</div></div>{% endif %}</div><a class="section-link" href="{% url 'jobs:skill-insights' %}">Vergelijk met de actuele skillsvraag <svg class="icon"><use href="#icon-arrow"></use></svg></a></section>{% endif %}
<section class="detail-section"><span class="section-kicker">Vacaturetekst</span><h2>Over de functie</h2>{% if job.description_html_sanitized %}<div class="prose">{{ job.description_html_sanitized|safe }}</div>{% elif job.description_text %}<div class="prose"><p>{{ job.description_text|linebreaksbr }}</p></div>{% else %}<p class="muted">Geen vacatureomschrijving beschikbaar.</p>{% endif %}{% if job.requirements %}<h3>Vereisten uit de bron</h3><ul class="compact-list">{% for requirement in job.requirements %}<li>{{ requirement }}</li>{% endfor %}</ul>{% endif %}{% if job.benefits %}<h3>Aanbod uit de bron</h3><ul class="compact-list">{% for benefit in job.benefits %}<li>{{ benefit }}</li>{% endfor %}</ul>{% endif %}</section>
<section class="detail-section"><div class="section-heading"><div><span class="section-kicker">Provenance</span><h2>Bronnen en betrouwbaarheid</h2><p>Ieder extern veld blijft herleidbaar tot de opgeslagen bron.</p></div><span class="pill">{{ source_count }} bron{% if source_count != 1 %}nen{% endif %}</span></div><ul class="source-list">{% for alias in job.source_aliases.all %}{% widthratio alias.extraction_confidence 1 100 as alias_confidence_percent %}<li><a href="{{ alias.url }}" target="_blank" rel="noopener noreferrer nofollow"><svg class="icon"><use href="#icon-external"></use></svg>{{ alias.source.name|default:"Onbekende bron" }}<span class="sr-only"> (opent in nieuw tabblad)</span></a><span>{{ alias.extraction_method|default:"Onbekende extractor" }} · data {{ alias_confidence_percent }}%</span></li>{% empty %}<li>Geen afzonderlijke bronalias opgeslagen.</li>{% endfor %}</ul>{% if provenance_rows %}<details class="provenance-details"><summary>Veldbewijs bekijken</summary><div class="data-table-wrap" tabindex="0" aria-label="Veldbewijs, horizontaal scrolbaar"><table class="data-table"><thead><tr><th>Veld</th><th>Methode</th><th>Confidence</th><th>Bewijs</th></tr></thead><tbody>{% for row in provenance_rows %}{% widthratio row.confidence 1 100 as row_confidence %}<tr><td>{{ row.field_name }}</td><td>{{ row.extraction_method }}</td><td>{{ row_confidence }}%</td><td>{{ row.evidence_excerpt|default:"Geen excerpt opgeslagen" }}</td></tr>{% endfor %}</tbody></table></div></details>{% endif %}</section>
<section class="detail-section"><span class="section-kicker">Wijzigingshistorie</span><h2>{{ version_count }} opgeslagen inhoudsversie{% if version_count != 1 %}s{% endif %}</h2><div class="version-list">{% for version in latest_versions %}<article><time datetime="{{ version.created_at|date:'c' }}">{{ version.created_at|date:"d/m/Y H:i" }}</time><span>{% if version.changed_fields %}Gewijzigd: {{ version.changed_fields|join:", " }}{% else %}Bronmoment vastgelegd{% endif %}</span></article>{% empty %}<p class="muted">Nog geen afzonderlijke wijzigingsversie opgeslagen.</p>{% endfor %}</div></section>
</div>
<aside class="detail-sidebar"><div class="sticky-card"><span class="section-kicker">Persoonlijke actie</span><h2>Volgende stap</h2><p>VacatureRadar verstuurt nooit automatisch een sollicitatie.</p><div class="stack-actions"><form method="post" action="{% url 'jobs:feedback' job.pk %}">{% csrf_token %}<input type="hidden" name="action" value="interesting"><button class="button button-primary button-block" type="submit">Interessant</button></form><form method="post" action="{% url 'jobs:feedback' job.pk %}">{% csrf_token %}<input type="hidden" name="action" value="save"><button class="button button-secondary button-block" type="submit"><svg class="icon"><use href="#icon-bookmark"></use></svg>Bewaren</button></form><form method="post" action="{% url 'jobs:feedback' job.pk %}">{% csrf_token %}<input type="hidden" name="action" value="applied"><button class="button button-secondary button-block" type="submit">Als gesolliciteerd markeren</button></form><form method="post" action="{% url 'jobs:feedback' job.pk %}" class="hide-form">{% csrf_token %}<input type="hidden" name="action" value="hide"><label>Reden (optioneel)<select name="reason"><option value="">Geen reden</option><option>Te ver</option><option>Te veel support</option><option>Recruiter/consultancy</option><option>Onrealistische senioriteit</option><option>Niet mijn technologie</option></select></label><button class="button button-ghost button-block" type="submit">Verbergen</button></form></div>{% if job.canonical_url %}<a class="button button-link button-block" href="{{ job.canonical_url }}" target="_blank" rel="noopener noreferrer nofollow">Originele vacature <svg class="icon"><use href="#icon-external"></use></svg><span class="sr-only">(opent in nieuw tabblad)</span></a>{% endif %}{% if application %}<hr><p class="muted">Dossierstatus: <strong>{{ application.get_status_display }}</strong></p><a class="button button-ghost button-block" href="{% url 'jobs:application-edit' application.pk %}">Dossier bewerken</a>{% endif %}</div></aside>
<aside class="detail-sidebar" aria-label="Vacature intelligence zijpaneel">
<section class="panel intelligence-summary"><span class="section-kicker">Intelligence</span><h2>Besliscontext</h2><dl class="intel-facts"><div><dt>Werkgever</dt><dd>{{ job.employer_name }}</dd></div><div><dt>Kanaal</dt><dd>{% if job.direct_employer %}Direct{% elif job.recruiter %}Recruiter{% else %}Onbekend{% endif %}</dd></div><div><dt>Werkmodel</dt><dd>{{ job.get_workplace_type_display }}</dd></div><div><dt>Locatie</dt><dd>{{ job.raw_location|default:"Onbekend" }}</dd></div>{% if job.compensation %}<div><dt>Vergoeding</dt><dd>{{ job.compensation }}</dd></div>{% endif %}<div><dt>Laatst gezien</dt><dd>{{ job.last_seen|date:"d/m/Y H:i" }}</dd></div></dl></section>
<section class="panel"><span class="section-kicker">Jouw status</span><h2>{% if application %}{{ application.get_status_display }}{% elif latest_feedback %}{{ latest_feedback.get_action_display }}{% else %}Nog geen actie{% endif %}</h2><p class="muted">VacatureRadar bereidt je dossier voor, maar verstuurt nooit automatisch.</p>{% if application %}<a class="button button-secondary button-block" href="{% url 'jobs:application-edit' application.pk %}">Dossier openen</a>{% endif %}</section>
<section class="panel"><span class="section-kicker">Broncontrole</span><h2>Transparant signaal</h2><dl class="intel-facts"><div><dt>Eerst gezien</dt><dd>{{ job.first_seen|date:"d/m/Y H:i" }}</dd></div><div><dt>Laatst gewijzigd</dt><dd>{{ job.last_changed|date:"d/m/Y H:i" }}</dd></div><div><dt>Status</dt><dd>{{ job.get_status_display }}</dd></div></dl>{% if job.canonical_url %}<a class="button button-ghost button-block" href="{{ job.canonical_url }}" target="_blank" rel="noopener noreferrer nofollow">Open originele vacature <svg class="icon"><use href="#icon-external"></use></svg><span class="sr-only"> (opent in nieuw tabblad)</span></a>{% endif %}</section>
</aside>
</div>
<div class="sticky-actions" aria-label="Vacatureacties"><form method="post" action="{% url 'jobs:feedback' job.pk %}">{% csrf_token %}<input type="hidden" name="action" value="interesting"><button class="button button-primary" type="submit"><svg class="icon"><use href="#icon-check"></use></svg>Interessant</button></form><form method="post" action="{% url 'jobs:feedback' job.pk %}">{% csrf_token %}<input type="hidden" name="action" value="save"><button class="button button-secondary" type="submit"><svg class="icon"><use href="#icon-bookmark"></use></svg>Bewaren</button></form><form method="post" action="{% url 'jobs:feedback' job.pk %}">{% csrf_token %}<input type="hidden" name="action" value="applied"><button class="button button-ghost" type="submit">Dossier starten</button></form><form method="post" action="{% url 'jobs:feedback' job.pk %}">{% csrf_token %}<input type="hidden" name="action" value="hide"><input type="hidden" name="reason" value="Niet relevant"><button class="button button-ghost" type="submit">Verbergen</button></form></div>
{% endblock %}
+33 -52
View File
@@ -1,68 +1,49 @@
{% extends "base.html" %}
{% block title %}Vacatures · VacatureRadar{% endblock %}
{% block title %}Nieuwe matches · VacatureRadar{% endblock %}
{% block content %}
<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>
<header class="cockpit-heading"><div><p class="eyebrow">Vacature explorer</p><h1>Nieuwe matches</h1><p>Alleen aantoonbaar relevante IT-kansen staan standaard in de radar. Kies een resultaat voor directe intelligence.</p></div><div class="radar-status"><span class="status-beacon is-live" aria-hidden="true"></span><span>{% if current_focus == 'all' %}Volledig archief{% else %}IT-focus actief{% endif %}</span><span aria-hidden="true">·</span><strong>{{ page_obj.paginator.count }} resultaten</strong></div></header>
<form method="get" class="filter-console premium-filter-console">
<form method="get" class="filter-console" aria-label="Vacaturefilters">
<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">Focus</span><select name="focus"><option value="it" {% if current_focus != 'all' %}selected{% endif %}>Alleen IT</option><option value="all" {% if current_focus == 'all' %}selected{% endif %}>Volledig archief</option></select></label>
<label><span class="field-label">Match</span><select name="match"><option value="radar" {% if current_match == 'radar' %}selected{% endif %}>Radarselectie</option><option value="strong" {% if current_match == 'strong' %}selected{% endif %}>Sterk</option><option value="possible" {% if current_match == 'possible' %}selected{% endif %}>Mogelijk</option><option value="weak" {% if current_match == 'weak' %}selected{% endif %}>Laag</option><option value="hidden" {% if current_match == 'hidden' %}selected{% endif %}>Verborgen</option><option value="all" {% if current_match == 'all' %}selected{% endif %}>Alle scores</option></select></label>
<label><span class="field-label">Kanaal</span><select name="channel"><option value="">Alle kanalen</option><option value="direct" {% if current_channel == 'direct' %}selected{% endif %}>Directe werkgever</option><option value="recruiter" {% if current_channel == 'recruiter' %}selected{% endif %}>Recruiter</option></select></label>
<label><span class="field-label">Werkmodel</span><select name="workplace"><option value="">Elk werkmodel</option>{% for value,label in view.model.Workplace.choices %}<option value="{{ value }}" {% if request.GET.workplace == value %}selected{% endif %}>{{ label }}</option>{% endfor %}</select></label>
<label><span class="field-label">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>
<label><span class="field-label">Sortering</span><select name="sort"><option value="match" {% if current_sort == 'match' %}selected{% endif %}>Beste match</option><option value="newest" {% if current_sort == 'newest' %}selected{% endif %}>Nieuwste</option><option value="closing" {% if current_sort == 'closing' %}selected{% endif %}>Deadline</option></select></label>
<input type="hidden" name="status" value="{{ request.GET.status|default:'active' }}">
<button class="button button-primary" type="submit"><svg class="icon"><use href="#icon-filter"></use></svg>Filter</button>
</form>
<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="results-feed">
<div class="active-filters" aria-label="Actieve filters"><span class="pill pill-it">{% if current_focus == 'all' %}Archief{% else %}IT-focus{% endif %}</span><span class="pill">{{ current_match|default:"radar" }}</span>{% if request.GET.workplace %}<span class="pill">{{ request.GET.workplace }}</span>{% endif %}{% if current_channel %}<span class="pill">{{ current_channel }}</span>{% endif %}{% if request.GET.q %}<span class="pill">“{{ request.GET.q }}”</span>{% endif %}<a href="{% url 'jobs:list' %}">Filters wissen</a></div>
<div class="explorer-shell">
<section class="explorer-results" aria-labelledby="results-heading">
<div class="results-toolbar"><div><span class="section-kicker">Gerangschikte signalen</span><h2 id="results-heading">{{ page_obj.paginator.count }} vacatures gevonden</h2></div><span class="view-indicator"><svg class="icon"><use href="#icon-list"></use></svg>Pagina {{ page_obj.number }}</span></div>
<div class="explorer-list">
{% 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 class="explorer-card {% if selected_job.pk == job.pk %}is-selected{% endif %} {% if job.match_recommendation == 'strong' %}strong-match{% endif %}">
<a class="explorer-card-link" href="?selected={{ job.pk }}&q={{ request.GET.q|urlencode }}&focus={{ current_focus }}&match={{ current_match }}&channel={{ current_channel }}&workplace={{ request.GET.workplace }}&status={{ request.GET.status|default:'active' }}&sort={{ current_sort }}&page={{ page_obj.number }}" aria-label="Toon intelligence voor {{ job.original_title }}"></a>
<div class="explorer-score">{% if job.match_score is not None %}<strong>{{ job.match_score|floatformat:0 }}</strong><small>match</small>{% else %}<strong></strong><small>wacht</small>{% endif %}</div>
<div class="explorer-card-body"><div class="title-row">{% if job.match_recommendation %}<span class="pill pill-{{ job.match_recommendation }}">{% if job.match_recommendation == 'strong' %}Sterk{% elif job.match_recommendation == 'possible' %}Mogelijk{% elif job.match_recommendation == 'weak' %}Laag{% else %}Verborgen{% endif %}</span>{% endif %}{% if job.direct_employer %}<span class="pill">Direct</span>{% elif job.recruiter %}<span class="pill">Recruiter</span>{% endif %}</div><h3>{{ job.original_title }}</h3><p>{{ job.employer_name }}</p><div class="job-meta"><span><svg class="icon"><use href="#icon-location"></use></svg>{{ job.raw_location|default:"Onbekend" }}</span><span>{{ job.get_workplace_type_display }}</span>{% if job.valid_through %}<span>sluit {{ job.valid_through|date:"d/m" }}</span>{% endif %}</div>{% if job.match_positives.0 %}<small class="card-signal">{{ job.match_positives.0 }}</small>{% endif %}</div>
<svg class="icon explorer-arrow" aria-hidden="true"><use href="#icon-arrow"></use></svg>
</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 %}
{% empty %}<div class="empty-state compact-empty"><div class="empty-icon"><svg class="icon"><use href="#icon-search"></use></svg></div><h2>Geen passende vacatures</h2><p>Pas de filters aan of inspecteer bewust het volledige archief.</p><a class="button button-secondary" href="?focus=all&match=all&status=active&sort=match">Open archief</a></div>{% endfor %}
</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 %}
{% if is_paginated %}<nav class="pagination" aria-label="Paginering">{% if page_obj.has_previous %}<a class="button button-ghost" href="?page={{ page_obj.previous_page_number }}&q={{ request.GET.q|urlencode }}&status={{ request.GET.status }}&workplace={{ request.GET.workplace }}&channel={{ current_channel }}&focus={{ current_focus }}&match={{ current_match }}&sort={{ current_sort }}">Vorige</a>{% endif %}<span>Pagina {{ page_obj.number }} van {{ page_obj.paginator.num_pages }}</span>{% if page_obj.has_next %}<a class="button button-ghost" href="?page={{ page_obj.next_page_number }}&q={{ request.GET.q|urlencode }}&status={{ request.GET.status }}&workplace={{ request.GET.workplace }}&channel={{ current_channel }}&focus={{ current_focus }}&match={{ current_match }}&sort={{ current_sort }}">Volgende</a>{% endif %}</nav>{% endif %}
</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><div class="stack-actions"><a class="button button-secondary button-block" href="{% url 'jobs:skill-insights' %}">Bekijk vraag & leerkansen</a><a class="button button-ghost button-block" href="{% url 'profiles:edit' active_profile.pk %}">Profiel verfijnen</a></div>{% 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 class="explorer-intelligence" aria-label="Geselecteerde vacature">
{% if selected_job %}
{% widthratio selected_job.match_confidence 1 100 as selected_confidence %}
<div class="intelligence-panel">
<div class="intelligence-hero"><span class="section-kicker">Vacature intelligence</span><div class="title-row"><span class="pill pill-{{ selected_job.match_recommendation }}">{{ selected_job.match_recommendation|default:"Niet gescoord" }}</span><span class="pill">{{ selected_job.get_status_display }}</span></div><h2>{{ selected_job.original_title }}</h2><p>{{ selected_job.employer_name }}</p><div class="intelligence-score"><strong>{% if selected_job.match_score is not None %}{{ selected_job.match_score|floatformat:0 }}%{% else %}—{% endif %}</strong><span>persoonlijke match<small>{% if selected_job.match_confidence %}Datakwaliteit {{ selected_confidence }}%{% else %}Nog niet beoordeeld{% endif %}</small></span></div></div>
<dl class="intel-facts"><div><dt>Locatie</dt><dd>{{ selected_job.raw_location|default:"Onbekend" }}</dd></div><div><dt>Werkmodel</dt><dd>{{ selected_job.get_workplace_type_display }}</dd></div><div><dt>Gepubliceerd</dt><dd>{{ selected_job.date_posted|date:"d/m/Y"|default:"Onbekend" }}</dd></div><div><dt>Deadline</dt><dd>{{ selected_job.valid_through|date:"d/m/Y"|default:"Niet vermeld" }}</dd></div><div><dt>Bronnen</dt><dd>{{ selected_intelligence.source_count }}</dd></div><div><dt>Duplicaten</dt><dd>{{ selected_intelligence.duplicate_count }}</dd></div></dl>
{% if selected_job.match_positives %}<div class="intel-signals"><span class="field-label">Positieve signalen</span><ul>{% for item in selected_job.match_positives|slice:":3" %}<li>{{ item }}</li>{% endfor %}</ul></div>{% endif %}
{% if selected_job.match_concerns %}<div class="intel-signals concerns"><span class="field-label">Aandachtspunten</span><ul>{% for item in selected_job.match_concerns|slice:":2" %}<li>{{ item }}</li>{% endfor %}</ul></div>{% endif %}
<div class="intelligence-actions"><a class="button button-primary button-block" href="{% url 'jobs:detail' selected_job.pk %}">Volledige analyse <svg class="icon"><use href="#icon-arrow"></use></svg></a><form method="post" action="{% url 'jobs:feedback' selected_job.pk %}">{% csrf_token %}<input type="hidden" name="action" value="save"><input type="hidden" name="next" value="{{ request.get_full_path }}"><button class="button button-ghost button-block" type="submit"><svg class="icon"><use href="#icon-bookmark"></use></svg>Bewaren</button></form></div>
</div>
{% else %}<div class="empty-state"><h2>Kies een vacature</h2><p>De intelligence verschijnt hier zonder je filters te verliezen.</p></div>{% endif %}
</aside>
</div>
{% endblock %}