feat: replace dashboard and vacancy intelligence
This commit is contained in:
+5
-59
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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); }
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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 %}
|
||||
|
||||
Reference in New Issue
Block a user