feat: add automation activity and employer intelligence
This commit is contained in:
@@ -0,0 +1 @@
|
||||
"""Read services for the application cockpit."""
|
||||
@@ -0,0 +1,112 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import timedelta
|
||||
from typing import Any
|
||||
|
||||
from django.conf import settings
|
||||
from django.db.models import Count, Q, Sum
|
||||
from django.utils import timezone
|
||||
|
||||
from apps.jobs.models import JobPosting, ScoreRun
|
||||
from apps.jobs.services.relevance import it_relevance_query
|
||||
from apps.notifications.models import DigestOutbox, ReminderOutbox
|
||||
from apps.profiles.models import SearchProfile
|
||||
from apps.sources.models import Source, SourceRun
|
||||
|
||||
from ..health import readiness
|
||||
|
||||
|
||||
def build_automation_cockpit() -> dict[str, Any]:
|
||||
"""Build an honest operational read model without probing unsafe hidden endpoints."""
|
||||
|
||||
since = timezone.now() - timedelta(hours=24)
|
||||
recent_runs = SourceRun.objects.filter(started_at__gte=since)
|
||||
run_metrics = recent_runs.aggregate(
|
||||
discovered=Sum("discovered_count"),
|
||||
extracted=Sum("extracted_count"),
|
||||
created=Sum("created_count"),
|
||||
updated=Sum("updated_count"),
|
||||
duplicates=Sum("duplicate_count"),
|
||||
failures=Count("id", filter=Q(status=SourceRun.Status.FAILED)),
|
||||
)
|
||||
notification_counts = {
|
||||
"pending": DigestOutbox.objects.filter(status=DigestOutbox.Status.PENDING).count()
|
||||
+ ReminderOutbox.objects.filter(status=ReminderOutbox.Status.PENDING).count(),
|
||||
"failed": DigestOutbox.objects.filter(status=DigestOutbox.Status.FAILED).count()
|
||||
+ ReminderOutbox.objects.filter(status=ReminderOutbox.Status.FAILED).count(),
|
||||
"sent_24h": DigestOutbox.objects.filter(
|
||||
status=DigestOutbox.Status.SENT, sent_at__gte=since
|
||||
).count()
|
||||
+ ReminderOutbox.objects.filter(
|
||||
status=ReminderOutbox.Status.SENT, sent_at__gte=since
|
||||
).count(),
|
||||
}
|
||||
source_total = Source.objects.count()
|
||||
source_active = Source.objects.filter(status=Source.Status.ACTIVE).count()
|
||||
active_jobs = JobPosting.objects.filter(status=JobPosting.Status.ACTIVE)
|
||||
phase_rows = [
|
||||
{"key": "discover", "label": "Ontdekken", "value": source_total, "unit": "bronnen"},
|
||||
{"key": "fetch", "label": "Ophalen", "value": recent_runs.count(), "unit": "runs / 24u"},
|
||||
{
|
||||
"key": "extract",
|
||||
"label": "Extraheren",
|
||||
"value": run_metrics["extracted"] or 0,
|
||||
"unit": "items",
|
||||
},
|
||||
{
|
||||
"key": "normalize",
|
||||
"label": "Normaliseren",
|
||||
"value": (run_metrics["created"] or 0) + (run_metrics["updated"] or 0),
|
||||
"unit": "records",
|
||||
},
|
||||
{
|
||||
"key": "dedupe",
|
||||
"label": "Dedupliceren",
|
||||
"value": run_metrics["duplicates"] or 0,
|
||||
"unit": "herkend",
|
||||
},
|
||||
{
|
||||
"key": "filter",
|
||||
"label": "IT-filter",
|
||||
"value": active_jobs.exclude(it_relevance_query()).count(),
|
||||
"unit": "uit beeld",
|
||||
},
|
||||
{
|
||||
"key": "analyze",
|
||||
"label": "Analyseren",
|
||||
"value": ScoreRun.objects.filter(created_at__gte=since).count(),
|
||||
"unit": "scores / 24u",
|
||||
},
|
||||
{
|
||||
"key": "notify",
|
||||
"label": "Notificeren",
|
||||
"value": notification_counts["sent_24h"],
|
||||
"unit": "verzonden / 24u",
|
||||
},
|
||||
]
|
||||
return {
|
||||
"health": readiness(),
|
||||
"source_total": source_total,
|
||||
"source_active": source_active,
|
||||
"run_metrics": run_metrics,
|
||||
"run_count": recent_runs.count(),
|
||||
"active_jobs": active_jobs.filter(it_relevance_query()).count(),
|
||||
"review_jobs": ScoreRun.objects.filter(
|
||||
recommendation__in=[ScoreRun.Recommendation.STRONG, ScoreRun.Recommendation.POSSIBLE],
|
||||
job__status=JobPosting.Status.ACTIVE,
|
||||
).count(),
|
||||
"notification_counts": notification_counts,
|
||||
"phase_rows": phase_rows,
|
||||
"recent_runs": list(
|
||||
SourceRun.objects.select_related("source").order_by("-started_at")[:10]
|
||||
),
|
||||
"source_rows": Source.objects.annotate(run_count=Count("runs")).order_by("name")[:12],
|
||||
"queue_state": "Eager/lokaal"
|
||||
if settings.CELERY_TASK_ALWAYS_EAGER
|
||||
else "Geconfigureerd, worker niet gemeten",
|
||||
"ai_state": "Geconfigureerd"
|
||||
if settings.OLLAMA_ENABLED and settings.OLLAMA_MODEL
|
||||
else "Niet geconfigureerd",
|
||||
"ai_profiles": SearchProfile.objects.filter(ai_scoring_enabled=True).count(),
|
||||
"browser_state": "Geen aparte browserextractor geconfigureerd",
|
||||
}
|
||||
+2
-6
@@ -4,18 +4,16 @@ 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
|
||||
from django.http import JsonResponse
|
||||
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 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
|
||||
|
||||
from .health import readiness
|
||||
from .services.automation import build_automation_cockpit
|
||||
|
||||
|
||||
class SecurityAwareLoginView(LoginView):
|
||||
@@ -95,8 +93,6 @@ class SystemStatusView(LoginRequiredMixin, TemplateView):
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super().get_context_data(**kwargs)
|
||||
context["health"] = readiness()
|
||||
context["source_summary"] = Source.objects.values("status").annotate(total=Count("id"))
|
||||
context["job_summary"] = JobPosting.objects.values("status").annotate(total=Count("id"))
|
||||
context.update(build_automation_cockpit())
|
||||
context["source_health"] = collect_source_health()
|
||||
return context
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
|
||||
from django.core.paginator import Page, Paginator
|
||||
from django.urls import reverse
|
||||
|
||||
from apps.notifications.models import DigestOutbox, ReminderOutbox
|
||||
from apps.profiles.models import ProfileRevision
|
||||
from apps.sources.models import SourceRun
|
||||
|
||||
from ..models import ApplicationTimelineEvent, Feedback, JobVersion
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ActivityEvent:
|
||||
occurred_at: datetime
|
||||
event_type: str
|
||||
title: str
|
||||
detail: str
|
||||
tone: str
|
||||
url: str
|
||||
object_label: str
|
||||
|
||||
|
||||
ACTIVITY_FILTERS = (
|
||||
("all", "Alle activiteit"),
|
||||
("source", "Bronruns"),
|
||||
("job", "Vacatures"),
|
||||
("application", "Sollicitaties"),
|
||||
("feedback", "Feedback"),
|
||||
("notification", "Notificaties"),
|
||||
("profile", "Profiel"),
|
||||
)
|
||||
|
||||
|
||||
def _source_events() -> list[ActivityEvent]:
|
||||
events = []
|
||||
for run in SourceRun.objects.select_related("source").order_by("-started_at")[:100]:
|
||||
detail = (
|
||||
f"{run.extracted_count} geëxtraheerd · {run.created_count} nieuw · "
|
||||
f"{run.duplicate_count} duplicaat"
|
||||
)
|
||||
if run.error_category:
|
||||
detail = f"{run.error_category}: {run.error_message or 'geen foutdetail'}"
|
||||
events.append(
|
||||
ActivityEvent(
|
||||
run.started_at,
|
||||
"source",
|
||||
f"Bronrun {run.get_status_display().lower()}",
|
||||
detail,
|
||||
"positive" if run.status == SourceRun.Status.SUCCESS else "attention",
|
||||
reverse("sources:list") + f"#source-details-{run.source_id}",
|
||||
run.source.name,
|
||||
)
|
||||
)
|
||||
return events
|
||||
|
||||
|
||||
def _job_events() -> list[ActivityEvent]:
|
||||
return [
|
||||
ActivityEvent(
|
||||
version.created_at,
|
||||
"job",
|
||||
"Vacature-inhoud gewijzigd" if version.changed_fields else "Vacatureversie opgeslagen",
|
||||
", ".join(version.changed_fields)
|
||||
if version.changed_fields
|
||||
else "Bronmoment vastgelegd",
|
||||
"neutral",
|
||||
reverse("jobs:detail", kwargs={"pk": version.job_id}),
|
||||
version.job.original_title,
|
||||
)
|
||||
for version in JobVersion.objects.select_related("job").order_by("-created_at")[:100]
|
||||
]
|
||||
|
||||
|
||||
def build_activity_page(
|
||||
*, user, event_type: str = "all", query: str = "", page: int | str = 1, per_page: int = 20
|
||||
) -> Page:
|
||||
"""Combine bounded trustworthy events into a user-safe read-only timeline."""
|
||||
|
||||
events: list[ActivityEvent] = []
|
||||
if event_type in {"all", "source"}:
|
||||
events.extend(_source_events())
|
||||
if event_type in {"all", "job"}:
|
||||
events.extend(_job_events())
|
||||
if event_type in {"all", "feedback"}:
|
||||
for item in (
|
||||
Feedback.objects.filter(user=user).select_related("job").order_by("-created_at")[:100]
|
||||
):
|
||||
events.append(
|
||||
ActivityEvent(
|
||||
item.created_at,
|
||||
"feedback",
|
||||
item.get_action_display(),
|
||||
item.reason or "Gebruikersactie opgeslagen",
|
||||
"neutral",
|
||||
reverse("jobs:detail", kwargs={"pk": item.job_id}),
|
||||
item.job.original_title,
|
||||
)
|
||||
)
|
||||
if event_type in {"all", "application"}:
|
||||
for item in (
|
||||
ApplicationTimelineEvent.objects.filter(user=user)
|
||||
.select_related("application__job")
|
||||
.order_by("-created_at")[:100]
|
||||
):
|
||||
events.append(
|
||||
ActivityEvent(
|
||||
item.created_at,
|
||||
"application",
|
||||
item.get_event_type_display(),
|
||||
"Sollicitatiedossier bijgewerkt",
|
||||
"positive" if item.event_type == item.EventType.STATUS_CHANGED else "neutral",
|
||||
reverse("jobs:application-edit", kwargs={"pk": item.application_id}),
|
||||
item.application.job.original_title,
|
||||
)
|
||||
)
|
||||
if event_type in {"all", "notification"}:
|
||||
for model, label in ((DigestOutbox, "Samenvatting"), (ReminderOutbox, "Herinnering")):
|
||||
for item in model.objects.filter(profile__user=user).order_by("-created_at")[:60]:
|
||||
events.append(
|
||||
ActivityEvent(
|
||||
item.created_at,
|
||||
"notification",
|
||||
f"{label} {item.get_status_display().lower()}",
|
||||
item.subject,
|
||||
"positive" if item.status == item.Status.SENT else "attention",
|
||||
reverse("dashboard:system"),
|
||||
label,
|
||||
)
|
||||
)
|
||||
if event_type in {"all", "profile"}:
|
||||
for item in (
|
||||
ProfileRevision.objects.filter(profile__user=user)
|
||||
.select_related("profile")
|
||||
.order_by("-created_at")[:60]
|
||||
):
|
||||
events.append(
|
||||
ActivityEvent(
|
||||
item.created_at,
|
||||
"profile",
|
||||
f"Zoekprofiel versie {item.version}",
|
||||
item.reason or "Profielconfiguratie opgeslagen",
|
||||
"neutral",
|
||||
reverse("profiles:edit", kwargs={"pk": item.profile_id}),
|
||||
item.profile.name,
|
||||
)
|
||||
)
|
||||
normalized_query = query.strip().casefold()
|
||||
if normalized_query:
|
||||
events = [
|
||||
item
|
||||
for item in events
|
||||
if normalized_query in f"{item.title} {item.detail} {item.object_label}".casefold()
|
||||
]
|
||||
events.sort(key=lambda item: item.occurred_at, reverse=True)
|
||||
return Paginator(events, per_page).get_page(page)
|
||||
@@ -0,0 +1,86 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from django.db.models import Avg, Count, Max, Q, QuerySet
|
||||
|
||||
from apps.profiles.models import SearchProfile
|
||||
from apps.sources.models import Source
|
||||
|
||||
from ..models import Application, Employer, JobPosting, ScoreRun
|
||||
from .cockpit import annotate_latest_profile_score
|
||||
from .relevance import it_relevance_query
|
||||
|
||||
|
||||
def employer_index_queryset(*, user, profile: SearchProfile | None) -> QuerySet[Employer]:
|
||||
score_filter = Q(jobs__scores__profile=profile) if profile else Q(pk__isnull=True)
|
||||
return Employer.objects.annotate(
|
||||
active_vacancies=Count(
|
||||
"jobs", filter=Q(jobs__status=JobPosting.Status.ACTIVE), distinct=True
|
||||
),
|
||||
relevant_vacancies=Count(
|
||||
"jobs",
|
||||
filter=Q(jobs__status=JobPosting.Status.ACTIVE) & it_relevance_query("jobs__"),
|
||||
distinct=True,
|
||||
),
|
||||
previous_applications=Count(
|
||||
"jobs__applications",
|
||||
filter=Q(jobs__applications__user=user),
|
||||
distinct=True,
|
||||
),
|
||||
source_count=Count("jobs__source_aliases__source", distinct=True),
|
||||
latest_job_at=Max("jobs__first_seen"),
|
||||
average_match=Avg("jobs__scores__score", filter=score_filter),
|
||||
)
|
||||
|
||||
|
||||
def filter_employers(
|
||||
queryset: QuerySet[Employer], *, query: str = "", channel: str = "", active_only: bool = False
|
||||
) -> QuerySet[Employer]:
|
||||
if query.strip():
|
||||
queryset = queryset.filter(
|
||||
Q(name__icontains=query.strip())
|
||||
| Q(domain__icontains=query.strip())
|
||||
| Q(jobs__raw_location__icontains=query.strip())
|
||||
)
|
||||
if channel == "direct":
|
||||
queryset = queryset.filter(is_direct_employer=True, is_recruiter=False)
|
||||
elif channel == "recruiter":
|
||||
queryset = queryset.filter(is_recruiter=True)
|
||||
if active_only:
|
||||
queryset = queryset.filter(active_vacancies__gt=0)
|
||||
return queryset.order_by("-relevant_vacancies", "-active_vacancies", "name").distinct()
|
||||
|
||||
|
||||
def build_employer_detail(
|
||||
*, employer: Employer, user, profile: SearchProfile | None
|
||||
) -> dict[str, Any]:
|
||||
jobs = annotate_latest_profile_score(
|
||||
employer.jobs.select_related("employer").all(), profile
|
||||
).order_by("-first_seen")
|
||||
sources = Source.objects.filter(job_aliases__job__employer=employer).distinct().order_by("name")
|
||||
locations = list(
|
||||
employer.jobs.exclude(raw_location="")
|
||||
.order_by("raw_location")
|
||||
.values_list("raw_location", flat=True)
|
||||
.distinct()[:8]
|
||||
)
|
||||
return {
|
||||
"employer": employer,
|
||||
"recent_jobs": list(jobs[:12]),
|
||||
"active_jobs": list(jobs.filter(status=JobPosting.Status.ACTIVE)[:8]),
|
||||
"applications": list(
|
||||
Application.objects.filter(user=user, job__employer=employer)
|
||||
.select_related("job")
|
||||
.order_by("-updated_at")[:8]
|
||||
),
|
||||
"sources": list(sources[:10]),
|
||||
"locations": locations,
|
||||
"match_history": list(
|
||||
ScoreRun.objects.filter(profile=profile, job__employer=employer)
|
||||
.select_related("job")
|
||||
.order_by("-created_at")[:20]
|
||||
)
|
||||
if profile
|
||||
else [],
|
||||
}
|
||||
@@ -1,8 +1,11 @@
|
||||
from django.urls import path
|
||||
|
||||
from .views import (
|
||||
ActivityListView,
|
||||
ApplicationListView,
|
||||
ApplicationUpdateView,
|
||||
EmployerDetailView,
|
||||
EmployerListView,
|
||||
JobDetailView,
|
||||
JobListView,
|
||||
SkillInsightsView,
|
||||
@@ -16,6 +19,9 @@ from .views import (
|
||||
urlpatterns = [
|
||||
path("", JobListView.as_view(), name="list"),
|
||||
path("skills/", SkillInsightsView.as_view(), name="skill-insights"),
|
||||
path("activity/", ActivityListView.as_view(), name="activity"),
|
||||
path("employers/", EmployerListView.as_view(), name="employers"),
|
||||
path("employers/<int:pk>/", EmployerDetailView.as_view(), name="employer-detail"),
|
||||
path("applications/", ApplicationListView.as_view(), name="applications"),
|
||||
path("applications/<int:pk>/", ApplicationUpdateView.as_view(), name="application-edit"),
|
||||
path("applications/<int:pk>/status/", application_status, name="application-status"),
|
||||
|
||||
+93
-1
@@ -15,7 +15,8 @@ from django.views.generic import DetailView, ListView, TemplateView, UpdateView
|
||||
from apps.profiles.models import SearchProfile
|
||||
|
||||
from .forms import ApplicationForm
|
||||
from .models import Application, Feedback, JobPosting, ScoreRun
|
||||
from .models import Application, Employer, Feedback, JobPosting, ScoreRun
|
||||
from .services.activity import ACTIVITY_FILTERS, build_activity_page
|
||||
from .services.applications import (
|
||||
build_application_export,
|
||||
build_print_html,
|
||||
@@ -24,6 +25,11 @@ from .services.applications import (
|
||||
track_application_changes,
|
||||
)
|
||||
from .services.cockpit import annotate_latest_profile_score, build_job_intelligence
|
||||
from .services.employer_intelligence import (
|
||||
build_employer_detail,
|
||||
employer_index_queryset,
|
||||
filter_employers,
|
||||
)
|
||||
from .services.feedback import record_feedback
|
||||
from .services.relevance import it_relevance_query
|
||||
from .services.skill_demand import build_skill_demand_report
|
||||
@@ -48,6 +54,92 @@ class SkillInsightsView(LoginRequiredMixin, TemplateView):
|
||||
return context
|
||||
|
||||
|
||||
class ActivityListView(LoginRequiredMixin, TemplateView):
|
||||
template_name = "activity/list.html"
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super().get_context_data(**kwargs)
|
||||
event_type = self.request.GET.get("type", "all")
|
||||
valid_types = {value for value, _label in ACTIVITY_FILTERS}
|
||||
if event_type not in valid_types:
|
||||
event_type = "all"
|
||||
context.update(
|
||||
{
|
||||
"event_page": build_activity_page(
|
||||
user=self.request.user,
|
||||
event_type=event_type,
|
||||
query=self.request.GET.get("q", ""),
|
||||
page=self.request.GET.get("page", 1),
|
||||
),
|
||||
"activity_filters": ACTIVITY_FILTERS,
|
||||
"current_type": event_type,
|
||||
}
|
||||
)
|
||||
return context
|
||||
|
||||
|
||||
class EmployerListView(LoginRequiredMixin, ListView):
|
||||
model = Employer
|
||||
template_name = "employers/list.html"
|
||||
context_object_name = "employers"
|
||||
paginate_by = 40
|
||||
|
||||
def get_queryset(self):
|
||||
self.profile = SearchProfile.objects.filter(user=self.request.user, is_active=True).first()
|
||||
return filter_employers(
|
||||
employer_index_queryset(user=self.request.user, profile=self.profile),
|
||||
query=self.request.GET.get("q", ""),
|
||||
channel=self.request.GET.get("channel", ""),
|
||||
active_only=self.request.GET.get("active") == "1",
|
||||
)
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super().get_context_data(**kwargs)
|
||||
employers = list(context["employers"])
|
||||
selected_id = self.request.GET.get("selected", "")
|
||||
selected = next((item for item in employers if str(item.pk) == selected_id), None)
|
||||
if selected is None and employers:
|
||||
selected = employers[0]
|
||||
context.update(
|
||||
{
|
||||
"selected_employer": selected,
|
||||
"selected_detail": build_employer_detail(
|
||||
employer=selected, user=self.request.user, profile=self.profile
|
||||
)
|
||||
if selected
|
||||
else None,
|
||||
"active_profile": self.profile,
|
||||
"employer_total": Employer.objects.count(),
|
||||
"employer_active_total": Employer.objects.filter(
|
||||
jobs__status=JobPosting.Status.ACTIVE
|
||||
)
|
||||
.distinct()
|
||||
.count(),
|
||||
}
|
||||
)
|
||||
return context
|
||||
|
||||
|
||||
class EmployerDetailView(LoginRequiredMixin, DetailView):
|
||||
model = Employer
|
||||
template_name = "employers/detail.html"
|
||||
context_object_name = "employer"
|
||||
|
||||
def get_queryset(self):
|
||||
self.profile = SearchProfile.objects.filter(user=self.request.user, is_active=True).first()
|
||||
return employer_index_queryset(user=self.request.user, profile=self.profile)
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super().get_context_data(**kwargs)
|
||||
context.update(
|
||||
build_employer_detail(
|
||||
employer=self.object, user=self.request.user, profile=self.profile
|
||||
)
|
||||
)
|
||||
context["active_profile"] = self.profile
|
||||
return context
|
||||
|
||||
|
||||
class JobListView(LoginRequiredMixin, ListView):
|
||||
model = JobPosting
|
||||
template_name = "jobs/list.html"
|
||||
|
||||
@@ -1680,7 +1680,7 @@ tasks:
|
||||
Relevante tests groen.
|
||||
- id: VR-212
|
||||
title: Stitch automation, activiteit en werkgeversintelligence
|
||||
status: ready
|
||||
status: done
|
||||
priority: P0
|
||||
requirement_ids:
|
||||
- PR-040
|
||||
@@ -1710,6 +1710,11 @@ tasks:
|
||||
- templates/activity
|
||||
- templates/employers
|
||||
- templates/system/status.html
|
||||
result:
|
||||
completed_at: '2026-07-22'
|
||||
note: Echte automationcockpit met 24u-pipeline en eerlijke runtimeconfiguratie; gepagineerde/filterbare activiteitreadservice;
|
||||
werkgeversindex/detail met vacature-, score-, bron- en dossieraggregaties. Nieuwe routes, templates en scope-tests
|
||||
groen.
|
||||
- id: VR-213
|
||||
title: Stitch responsive validatie en legacyverwijdering
|
||||
status: ready
|
||||
|
||||
@@ -221,3 +221,81 @@
|
||||
.platform-catalog > summary span { display: grid; }
|
||||
.platform-grid { margin-top: 18px; }
|
||||
.platform-grid article { align-items: flex-start; flex-direction: column; }
|
||||
.activity-filterbar { display: grid; grid-template-columns: minmax(260px, 1fr) 220px auto; align-items: end; gap: 10px; margin-bottom: 28px; padding: 14px; border: 1px solid color-mix(in srgb, var(--outline) 42%, transparent); background: var(--surface-low); }
|
||||
.activity-timeline { position: relative; display: grid; gap: 16px; padding-left: 78px; }
|
||||
.activity-timeline::before { position: absolute; inset: 0 auto 0 28px; width: 1px; content: ""; background: linear-gradient(var(--cyan), var(--outline) 70%, transparent); opacity: .45; }
|
||||
.activity-event { position: relative; }
|
||||
.activity-marker { position: absolute; top: 0; left: -78px; display: grid; width: 58px; height: 58px; place-items: center; border: 1px solid var(--outline); background: var(--surface-low); color: var(--text-muted); }
|
||||
.activity-marker.is-positive { border-color: color-mix(in srgb, var(--emerald) 45%, var(--outline)); color: var(--emerald); }
|
||||
.activity-marker.is-attention { border-color: color-mix(in srgb, var(--coral) 45%, var(--outline)); color: var(--coral); }
|
||||
.activity-card { padding: 20px; border: 1px solid color-mix(in srgb, var(--outline) 44%, transparent); background: var(--surface-container); }
|
||||
.activity-card-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 20px; }
|
||||
.activity-card-head h2 { margin: 9px 0 0; }
|
||||
.activity-card-head time { color: var(--text-faint); font: 11px var(--font-mono); }
|
||||
.activity-object { color: var(--text-muted); }
|
||||
.decision-note { margin: 14px 0; padding: 12px 14px; border-left: 2px solid var(--cyan); background: var(--surface-high); }
|
||||
.decision-note p { margin: 7px 0 0; }
|
||||
.employer-kpis { display: flex; gap: 8px; }
|
||||
.employer-kpis > div { min-width: 130px; padding: 11px 13px; border: 1px solid color-mix(in srgb, var(--outline) 42%, transparent); background: var(--surface-low); }
|
||||
.employer-kpis span, .employer-kpis strong { display: block; }
|
||||
.employer-kpis span { color: var(--text-faint); font: 9px var(--font-mono); text-transform: uppercase; }
|
||||
.employer-kpis strong { margin-top: 5px; color: var(--text-strong); font: 650 22px var(--font-headline); }
|
||||
.employer-radar-layout { display: grid; grid-template-columns: minmax(0, 1fr) 390px; align-items: start; border: 1px solid color-mix(in srgb, var(--outline) 42%, transparent); background: var(--surface-container); }
|
||||
.employer-table-panel { min-width: 0; padding: 18px; }
|
||||
.employer-table td:first-child a { display: flex; align-items: center; gap: 10px; }
|
||||
.employer-table td:first-child strong, .employer-table td:first-child small { display: block; }
|
||||
.employer-table tr.is-selected { background: color-mix(in srgb, var(--cyan) 6%, var(--surface-high)); }
|
||||
.employer-mark { display: grid; width: 34px; height: 34px; flex: 0 0 auto; place-items: center; border: 1px solid color-mix(in srgb, var(--cyan) 35%, var(--outline)); background: var(--surface-lowest); color: var(--cyan); font: 650 10px var(--font-mono); }
|
||||
.employer-mark.large { width: 54px; height: 54px; margin-bottom: 14px; font-size: 15px; }
|
||||
.employer-mark.hero-mark { width: 112px; height: 112px; font-size: 26px; }
|
||||
.signal-number { color: var(--emerald); }
|
||||
.employer-intelligence-rail { position: sticky; top: calc(var(--topbar-height) + 18px); overflow: hidden; border-left: 1px solid color-mix(in srgb, var(--outline) 42%, transparent); background: var(--surface-low); }
|
||||
.employer-intel-header { padding: 22px; border-bottom: 1px solid color-mix(in srgb, var(--outline) 35%, transparent); }
|
||||
.employer-intel-header h2 { font-size: 26px; }
|
||||
.employer-intel-section { padding: 17px; border-top: 1px solid color-mix(in srgb, var(--outline) 35%, transparent); }
|
||||
.employer-job-link, .employer-source { display: flex; min-height: 50px; align-items: center; justify-content: space-between; gap: 12px; border-top: 1px solid color-mix(in srgb, var(--outline) 28%, transparent); }
|
||||
.employer-job-link strong, .employer-job-link small { display: block; }
|
||||
.employer-job-link b { color: var(--cyan); font: 650 15px var(--font-mono); }
|
||||
.employer-intelligence-rail > .button { margin: 17px; width: calc(100% - 34px); }
|
||||
.employer-detail-hero { min-height: 230px; }
|
||||
.employer-detail-grid { display: grid; grid-template-columns: minmax(0, 1fr) 340px; align-items: start; gap: 16px; }
|
||||
.employer-job-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 9px; }
|
||||
.employer-job-grid article { padding: 15px; border: 1px solid color-mix(in srgb, var(--outline) 38%, transparent); background: var(--surface-low); }
|
||||
.employer-job-grid h3 { margin: 10px 0 4px; }
|
||||
.match-history { display: grid; gap: 7px; }
|
||||
.match-history > div { display: grid; grid-template-columns: 42px minmax(100px, 1fr) 40px minmax(160px, 1.3fr); align-items: center; gap: 9px; }
|
||||
.match-history span { color: var(--text-faint); font: 10px var(--font-mono); }
|
||||
.match-history i { position: relative; display: block; height: 5px; background: var(--surface-highest); }
|
||||
.match-history i::after { position: absolute; inset: 0 auto 0 0; width: var(--match); content: ""; background: linear-gradient(90deg, var(--cyan-dim), var(--emerald)); }
|
||||
.match-history strong { color: var(--cyan); font: 12px var(--font-mono); }
|
||||
.automation-status { display: flex; align-items: center; justify-content: space-between; gap: 20px; padding: 19px 22px; border: 1px solid var(--outline); background: var(--surface-container); }
|
||||
.automation-status.is-healthy { border-color: color-mix(in srgb, var(--emerald) 30%, var(--outline)); }
|
||||
.automation-status h2 { display: flex; align-items: center; gap: 10px; margin: 8px 0 0; }
|
||||
.automation-status dl { display: flex; margin: 0; }
|
||||
.automation-status dl > div { min-width: 150px; padding: 0 16px; border-left: 1px solid var(--outline); }
|
||||
.automation-status dt { color: var(--text-faint); font: 9px var(--font-mono); text-transform: uppercase; }
|
||||
.automation-status dd { margin: 5px 0 0; }
|
||||
.automation-pipeline { display: grid; grid-template-columns: repeat(8, minmax(112px, 1fr)); gap: 4px; overflow-x: auto; margin: 28px 0 16px; padding-top: 28px; }
|
||||
.automation-pipeline > .section-kicker { position: absolute; margin-top: -27px; }
|
||||
.automation-pipeline article { position: relative; display: grid; min-height: 130px; place-items: center; align-content: center; padding: 11px; border: 1px solid color-mix(in srgb, var(--outline) 42%, transparent); border-bottom: 2px solid var(--cyan-dim); background: var(--surface-high); text-align: center; }
|
||||
.automation-pipeline article::after { position: absolute; z-index: 1; top: 50%; right: -8px; width: 12px; content: ""; border-top: 1px solid var(--cyan); }
|
||||
.automation-pipeline article:last-child::after { display: none; }
|
||||
.automation-pipeline .icon { color: var(--cyan); }
|
||||
.automation-pipeline strong { margin-top: 8px; font: 600 10px var(--font-mono); text-transform: uppercase; }
|
||||
.automation-pipeline b { margin-top: 7px; color: var(--text-strong); font: 650 20px var(--font-headline); }
|
||||
.automation-pipeline small { font: 9px var(--font-mono); }
|
||||
.pipeline-phase-index { position: absolute; top: 7px; left: 8px; color: var(--text-faint); font: 9px var(--font-mono); }
|
||||
.automation-kpis { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 10px; margin: 17px 0 28px; }
|
||||
.automation-kpis article { min-height: 120px; padding: 17px; border: 1px solid color-mix(in srgb, var(--outline) 42%, transparent); background: var(--surface-container); }
|
||||
.automation-kpis strong, .automation-kpis small { display: block; }
|
||||
.automation-kpis strong { margin: 15px 0 4px; color: var(--text-strong); font: 650 32px var(--font-headline); }
|
||||
.automation-layout { display: grid; grid-template-columns: minmax(0, 1.5fr) minmax(300px, .5fr); align-items: start; gap: 14px; }
|
||||
.automation-source-table, .technical-log-panel { padding: 18px; border: 1px solid color-mix(in srgb, var(--outline) 42%, transparent); background: var(--surface-container); }
|
||||
.technical-log-panel { background: var(--surface-lowest); }
|
||||
.live-log { display: grid; max-height: 520px; overflow-y: auto; margin-bottom: 15px; }
|
||||
.live-log article { display: grid; grid-template-columns: 68px 62px 1fr; gap: 7px; padding: 11px 0; border-bottom: 1px solid color-mix(in srgb, var(--outline) 27%, transparent); font: 10px/1.5 var(--font-mono); }
|
||||
.live-log time { color: var(--text-faint); }
|
||||
.live-log strong { color: var(--indigo); }
|
||||
.live-log article.is-success strong { color: var(--emerald); }
|
||||
.live-log article.is-error strong { color: var(--coral); }
|
||||
.live-log small { grid-column: 2 / -1; }
|
||||
|
||||
@@ -27,6 +27,9 @@
|
||||
.source-health-summary { grid-template-columns: 1fr auto; }
|
||||
.source-health-metrics { grid-column: 1 / -1; grid-row: 2; }
|
||||
.watchlist-grid, .platform-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.employer-radar-layout, .employer-detail-grid, .automation-layout { grid-template-columns: 1fr; }
|
||||
.employer-intelligence-rail { position: static; border-top: 1px solid var(--outline); border-left: 0; }
|
||||
.automation-kpis { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
}
|
||||
|
||||
@media (max-width: 820px) {
|
||||
@@ -59,6 +62,10 @@
|
||||
.hero-score { min-width: 0; width: 100%; text-align: left; }
|
||||
.intelligence-strip { grid-template-columns: repeat(2, 1fr); }
|
||||
.preference-grid, .source-health-details { grid-template-columns: 1fr; }
|
||||
.automation-status { align-items: flex-start; flex-direction: column; }
|
||||
.automation-status dl { display: grid; grid-template-columns: repeat(3, 1fr); width: 100%; }
|
||||
.automation-status dl > div { min-width: 0; border-top: 1px solid var(--outline); border-left: 0; padding: 11px 4px; }
|
||||
.employer-job-grid { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
@media (max-width: 560px) {
|
||||
@@ -89,6 +96,16 @@
|
||||
.source-health-metrics > div { display: flex; align-items: center; justify-content: space-between; border-top: 1px solid color-mix(in srgb, var(--outline) 30%, transparent); border-left: 0; }
|
||||
.source-health-metrics strong { margin-top: 0; }
|
||||
.mailbox-row, .danger-zone { align-items: flex-start; flex-direction: column; }
|
||||
.activity-filterbar, .automation-kpis { grid-template-columns: 1fr; }
|
||||
.activity-timeline { padding-left: 52px; }
|
||||
.activity-timeline::before { left: 20px; }
|
||||
.activity-marker { left: -52px; width: 42px; height: 42px; }
|
||||
.activity-card-head { flex-direction: column; gap: 8px; }
|
||||
.employer-kpis { width: 100%; }
|
||||
.employer-kpis > div { min-width: 0; flex: 1; }
|
||||
.automation-status dl { grid-template-columns: 1fr; }
|
||||
.match-history > div { grid-template-columns: 38px 1fr 36px; }
|
||||
.match-history a { grid-column: 1 / -1; }
|
||||
.auth-shell { padding: 14px; }
|
||||
.auth-card { padding: 22px 18px; }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Activiteit · VacatureRadar{% endblock %}
|
||||
{% block content %}
|
||||
<header class="cockpit-heading"><div><p class="eyebrow">Radar audit trail</p><h1>Activiteit</h1><p>Betrouwbare gebeurtenissen uit bronruns, vacatureversies, feedback, sollicitaties, notificaties en profielrevisies.</p></div><div class="radar-status"><span class="status-beacon is-live" aria-hidden="true"></span><span>{{ event_page.paginator.count }} gebeurtenissen in deze selectie</span></div></header>
|
||||
<form class="activity-filterbar" method="get"><label class="grow"><span class="field-label">Zoek in activiteit</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="Vacature, werkgever of gebeurtenis"></span></label><label><span class="field-label">Type</span><select name="type">{% for value,label in activity_filters %}<option value="{{ value }}" {% if current_type == value %}selected{% endif %}>{{ label }}</option>{% endfor %}</select></label><button class="button button-secondary" type="submit"><svg class="icon"><use href="#icon-filter"></use></svg>Filter</button></form>
|
||||
|
||||
<section class="activity-timeline" aria-label="Activiteitstijdlijn">
|
||||
{% for event in event_page %}<article class="activity-event"><span class="activity-marker {% if event.tone == 'positive' %}is-positive{% elif event.tone == 'attention' %}is-attention{% endif %}"><svg class="icon"><use href="{% if event.event_type == 'source' %}#icon-sources{% elif event.event_type == 'application' %}#icon-pipeline{% elif event.event_type == 'profile' %}#icon-profile{% elif event.event_type == 'notification' %}#icon-mail{% else %}#icon-activity{% endif %}"></use></svg></span><div class="activity-card"><div class="activity-card-head"><div><span class="pill pill-{{ event.tone }}">{{ event.event_type }}</span><h2>{{ event.title }}</h2></div><time datetime="{{ event.occurred_at|date:'c' }}">{{ event.occurred_at|date:"d/m/Y H:i" }}</time></div><p class="activity-object">{{ event.object_label }}</p><div class="decision-note"><span class="field-label">Gebeurtenisdetails</span><p>{{ event.detail }}</p></div><a class="section-link" href="{{ event.url }}">Open betrokken onderdeel <svg class="icon"><use href="#icon-arrow"></use></svg></a></div></article>{% empty %}<div class="empty-state"><div class="empty-icon"><svg class="icon"><use href="#icon-activity"></use></svg></div><h2>Geen activiteit gevonden</h2><p>Deze filter bevat nog geen auditwaardige gebeurtenis.</p><a class="button button-ghost" href="{% url 'jobs:activity' %}">Alle activiteit tonen</a></div>{% endfor %}
|
||||
</section>
|
||||
{% if event_page.paginator.num_pages > 1 %}<nav class="pagination" aria-label="Activiteitspaginering">{% if event_page.has_previous %}<a class="button button-ghost" href="?page={{ event_page.previous_page_number }}&type={{ current_type }}&q={{ request.GET.q|urlencode }}">Vorige</a>{% endif %}<span>Pagina {{ event_page.number }} van {{ event_page.paginator.num_pages }}</span>{% if event_page.has_next %}<a class="button button-ghost" href="?page={{ event_page.next_page_number }}&type={{ current_type }}&q={{ request.GET.q|urlencode }}">Volgende</a>{% endif %}</nav>{% endif %}
|
||||
{% endblock %}
|
||||
@@ -33,11 +33,13 @@
|
||||
<a class="nav-item {% if request.resolver_match.namespace == 'jobs' and request.resolver_match.url_name == 'detail' %}is-active{% endif %}" href="{% url 'jobs:list' %}?focus=all"><svg class="icon"><use href="#icon-briefcase"></use></svg><span>Alle vacatures</span></a>
|
||||
<a class="nav-item {% if request.resolver_match.namespace == 'jobs' and request.resolver_match.url_name == 'skill-insights' %}is-active{% endif %}" href="{% url 'jobs:skill-insights' %}"><svg class="icon"><use href="#icon-chart"></use></svg><span>Skillsradar</span></a>
|
||||
<a class="nav-item {% if request.resolver_match.namespace == 'jobs' and request.resolver_match.url_name == 'applications' or request.resolver_match.url_name == 'application-edit' %}is-active{% endif %}" href="{% url 'jobs:applications' %}"><svg class="icon"><use href="#icon-pipeline"></use></svg><span>Sollicitaties</span></a>
|
||||
<a class="nav-item {% if request.resolver_match.url_name == 'employers' or request.resolver_match.url_name == 'employer-detail' %}is-active{% endif %}" href="{% url 'jobs:employers' %}"><svg class="icon"><use href="#icon-building"></use></svg><span>Werkgevers</span></a>
|
||||
|
||||
<p class="nav-caption">Radarbeheer</p>
|
||||
<a class="nav-item {% if request.resolver_match.namespace == 'profiles' %}is-active{% endif %}" href="{% url 'profiles:list' %}"><svg class="icon"><use href="#icon-profile"></use></svg><span>Zoekprofiel</span></a>
|
||||
<a class="nav-item {% if request.resolver_match.namespace == 'sources' %}is-active{% endif %}" href="{% url 'sources:list' %}"><svg class="icon"><use href="#icon-sources"></use></svg><span>Bronnen</span></a>
|
||||
<a class="nav-item {% if request.resolver_match.namespace == 'dashboard' and request.resolver_match.url_name == 'system' %}is-active{% endif %}" href="{% url 'dashboard:system' %}"><svg class="icon"><use href="#icon-system"></use></svg><span>Automatisering</span></a>
|
||||
<a class="nav-item {% if request.resolver_match.url_name == 'activity' %}is-active{% endif %}" href="{% url 'jobs:activity' %}"><svg class="icon"><use href="#icon-activity"></use></svg><span>Activiteit</span></a>
|
||||
</nav>
|
||||
|
||||
<div class="sidebar-radar-card">
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}{{ employer.name }} intelligence · VacatureRadar{% endblock %}
|
||||
{% block content %}
|
||||
<a class="back-link" href="{% url 'jobs:employers' %}">← Terug naar werkgeversradar</a>
|
||||
<header class="detail-hero employer-detail-hero"><div><p class="eyebrow">Employer intelligence #{{ employer.pk }}</p><div class="title-row">{% if employer.is_recruiter %}<span class="pill">Recruiter</span>{% elif employer.is_direct_employer %}<span class="pill pill-success">Directe werkgever</span>{% endif %}{% if employer.employer_type %}<span class="pill">{{ employer.employer_type }}</span>{% endif %}</div><h1>{{ employer.name }}</h1><p class="detail-employer">{{ employer.domain|default:"Domein onbekend" }}</p></div><span class="employer-mark hero-mark">{{ employer.name|slice:":2"|upper }}</span></header>
|
||||
<section class="intelligence-strip"><div><span class="field-label">Actieve vacatures</span><strong>{{ employer.active_vacancies }}</strong><small>alle functies</small></div><div><span class="field-label">Relevante IT</span><strong>{{ employer.relevant_vacancies }}</strong><small>deterministisch gefilterd</small></div><div><span class="field-label">Gemiddelde match</span><strong>{% if employer.average_match is not None %}{{ employer.average_match|floatformat:0 }}%{% else %}—{% endif %}</strong><small>{% if active_profile %}{{ active_profile.name }}{% else %}geen actief profiel{% endif %}</small></div><div><span class="field-label">Brondekking</span><strong>{{ employer.source_count }}</strong><small>unieke bronnen</small></div></section>
|
||||
<div class="employer-detail-grid"><main><section class="detail-section"><div class="section-heading"><div><span class="section-kicker">Vacaturehistorie</span><h2>Recente vacatures</h2><p>Actief en historisch, uitsluitend uit opgeslagen bronrecords.</p></div></div><div class="employer-job-grid">{% for job in recent_jobs %}<article><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 }}">{{ job.match_recommendation }}</span>{% endif %}</div><h3><a href="{% url 'jobs:detail' job.pk %}">{{ job.original_title }}</a></h3><p>{{ job.raw_location|default:"Locatie onbekend" }} · {{ job.get_workplace_type_display }}</p><div class="job-meta"><span>{{ job.first_seen|date:"d/m/Y" }}</span>{% if job.match_score is not None %}<strong>{{ job.match_score|floatformat:0 }}% match</strong>{% endif %}</div></article>{% empty %}<div class="empty-state compact-empty"><h2>Geen vacaturehistorie</h2></div>{% endfor %}</div></section>{% if match_history %}<section class="detail-section"><span class="section-kicker">Matchpatroon</span><h2>Recente profielbeoordelingen</h2><div class="match-history">{% for score in match_history %}<div><span>{{ score.created_at|date:"d/m" }}</span><i style="--match: {{ score.score }}%"></i><strong>{{ score.score|floatformat:0 }}%</strong><a href="{% url 'jobs:detail' score.job.pk %}">{{ score.job.original_title }}</a></div>{% endfor %}</div></section>{% endif %}</main><aside class="detail-sidebar"><section class="panel"><span class="section-kicker">Waargenomen context</span><h2>Werkgeversprofiel</h2><dl class="intel-facts"><div><dt>Locaties</dt><dd>{% if locations %}{{ locations|join:", " }}{% else %}Onbekend{% endif %}</dd></div><div><dt>Type</dt><dd>{{ employer.employer_type|default:"Onbekend" }}</dd></div><div><dt>Laatste signaal</dt><dd>{{ employer.latest_job_at|date:"d/m/Y"|default:"—" }}</dd></div><div><dt>Confidence</dt><dd>{% widthratio employer.confidence 1 100 %}%</dd></div></dl></section><section class="panel"><span class="section-kicker">Brongezondheid</span><h2>{{ sources|length }} gekoppelde bronnen</h2>{% for source in sources %}<a class="employer-source" href="{% url 'sources:list' %}"><span>{{ source.name }}</span><span class="pill {% if source.status == 'active' %}pill-success{% else %}pill-review{% endif %}">{{ source.get_status_display }}</span></a>{% empty %}<p class="muted">Geen gekoppelde bron opgeslagen.</p>{% endfor %}</section><section class="panel"><span class="section-kicker">Eerdere sollicitaties</span><h2>{{ applications|length }} dossier{% if applications|length != 1 %}s{% endif %}</h2>{% for application in applications %}<a class="employer-source" href="{% url 'jobs:application-edit' application.pk %}"><span>{{ application.job.original_title }}</span><span>{{ application.get_status_display }}</span></a>{% empty %}<p class="muted">Nog geen dossier bij deze werkgever.</p>{% endfor %}</section></aside></div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,11 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Werkgevers intelligence · VacatureRadar{% endblock %}
|
||||
{% block content %}
|
||||
<header class="cockpit-heading"><div><p class="eyebrow">Intelligent tracking</p><h1>Werkgeversradar</h1><p>Genormaliseerde werkgeversdata uit echte vacatures, bronnen, profielscores en sollicitatiedossiers.</p></div><div class="employer-kpis"><div><span>Gemonitord</span><strong>{{ employer_total }}</strong></div><div><span>Met actieve vacatures</span><strong>{{ employer_active_total }}</strong></div></div></header>
|
||||
<form class="activity-filterbar" method="get"><label class="grow"><span class="field-label">Zoek werkgevers</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="Naam, domein of locatie"></span></label><label><span class="field-label">Kanaal</span><select name="channel"><option value="">Alle kanalen</option><option value="direct" {% if request.GET.channel == 'direct' %}selected{% endif %}>Directe werkgever</option><option value="recruiter" {% if request.GET.channel == 'recruiter' %}selected{% endif %}>Recruiter</option></select></label><label class="toggle-field"><input type="checkbox" name="active" value="1" {% if request.GET.active == '1' %}checked{% endif %}><span>Alleen actief</span></label><button class="button button-secondary" type="submit">Filter</button></form>
|
||||
<div class="employer-radar-layout">
|
||||
<section class="employer-table-panel" aria-labelledby="employer-list-heading"><div class="section-heading"><div><span class="section-kicker">Gevolgde werkgevers</span><h2 id="employer-list-heading">{{ page_obj.paginator.count }} werkgevers</h2></div></div><div class="data-table-wrap" tabindex="0" aria-label="Werkgevers, horizontaal scrolbaar"><table class="data-table employer-table"><thead><tr><th>Werkgever</th><th>Type</th><th>Actief</th><th>Relevant IT</th><th>Bronnen</th><th>Gem. match</th><th>Laatste signaal</th></tr></thead><tbody>{% for item in employers %}<tr class="{% if selected_employer.pk == item.pk %}is-selected{% endif %}"><td><a href="?selected={{ item.pk }}&q={{ request.GET.q|urlencode }}&channel={{ request.GET.channel }}&active={{ request.GET.active }}"><span class="employer-mark">{{ item.name|slice:":2"|upper }}</span><span><strong>{{ item.name }}</strong><small>{{ item.domain|default:"Geen domein" }}</small></span></a></td><td>{% if item.is_recruiter %}<span class="pill">Recruiter</span>{% elif item.is_direct_employer %}<span class="pill pill-success">Direct</span>{% else %}<span class="pill">Onbekend</span>{% endif %}</td><td>{{ item.active_vacancies }}</td><td><strong class="signal-number">{{ item.relevant_vacancies }}</strong></td><td>{{ item.source_count }}</td><td>{% if item.average_match is not None %}{{ item.average_match|floatformat:0 }}%{% else %}—{% endif %}</td><td>{{ item.latest_job_at|date:"d/m/Y"|default:"—" }}</td></tr>{% empty %}<tr><td colspan="7">Geen werkgevers gevonden.</td></tr>{% endfor %}</tbody></table></div>{% if is_paginated %}<nav class="pagination" aria-label="Werkgeverspaginering">{% if page_obj.has_previous %}<a class="button button-ghost" href="?page={{ page_obj.previous_page_number }}&q={{ request.GET.q|urlencode }}&channel={{ request.GET.channel }}&active={{ request.GET.active }}">Vorige</a>{% endif %}<span>{{ page_obj.number }}/{{ 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 }}&channel={{ request.GET.channel }}&active={{ request.GET.active }}">Volgende</a>{% endif %}</nav>{% endif %}</section>
|
||||
|
||||
<aside class="employer-intelligence-rail" aria-label="Geselecteerde werkgever">{% if selected_employer %}<div class="employer-intel-header"><span class="section-kicker">Employer report #{{ selected_employer.pk }}</span><span class="employer-mark large">{{ selected_employer.name|slice:":2"|upper }}</span><h2>{{ selected_employer.name }}</h2><p>{{ selected_employer.domain|default:"Domein onbekend" }}</p></div><dl class="intel-facts"><div><dt>Actieve vacatures</dt><dd>{{ selected_employer.active_vacancies }}</dd></div><div><dt>Relevante IT</dt><dd>{{ selected_employer.relevant_vacancies }}</dd></div><div><dt>Eerdere dossiers</dt><dd>{{ selected_employer.previous_applications }}</dd></div><div><dt>Bronnen</dt><dd>{{ selected_employer.source_count }}</dd></div><div><dt>Gem. match</dt><dd>{% if selected_employer.average_match is not None %}{{ selected_employer.average_match|floatformat:0 }}%{% else %}Nog niet gemeten{% endif %}</dd></div><div><dt>Kanaal</dt><dd>{% if selected_employer.is_recruiter %}Recruiter{% elif selected_employer.is_direct_employer %}Direct{% else %}Onbekend{% endif %}</dd></div></dl><section class="employer-intel-section"><span class="field-label">Recente vacatures</span>{% for job in selected_detail.recent_jobs|slice:":4" %}<a class="employer-job-link" href="{% url 'jobs:detail' job.pk %}"><span><strong>{{ job.original_title }}</strong><small>{{ job.first_seen|date:"d/m/Y" }}</small></span>{% if job.match_score is not None %}<b>{{ job.match_score|floatformat:0 }}%</b>{% endif %}</a>{% empty %}<p class="muted">Nog geen vacaturehistorie.</p>{% endfor %}</section><section class="employer-intel-section"><span class="field-label">Waargenomen locaties</span><p>{% if selected_detail.locations %}{{ selected_detail.locations|join:", " }}{% else %}Onbekend{% endif %}</p></section><a class="button button-primary button-block" href="{% url 'jobs:employer-detail' selected_employer.pk %}">Volledig werkgeversrapport</a>{% else %}<div class="empty-state"><h2>Kies een werkgever</h2></div>{% endif %}</aside>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -1,15 +1,12 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Systeemstatus · VacatureRadar{% endblock %}
|
||||
{% block title %}Automation center · VacatureRadar{% endblock %}
|
||||
{% block content %}
|
||||
<header class="page-header"><div><p class="eyebrow">Automatisering centrum</p><h1>Radarbesturing</h1><p>Readiness, bronruns en veilige beheeracties op basis van actuele systeemdata.</p></div><span class="pill {% if health.ok %}pill-success{% else %}pill-failed{% endif %}">{% if health.ok %}Operationeel{% else %}Aandacht nodig{% endif %}</span></header>
|
||||
<section class="system-hero">
|
||||
<article class="system-readiness {% if health.ok %}is-healthy{% endif %}"><span class="source-icon"><svg class="icon"><use href="#icon-system"></use></svg></span><div><span class="field-label">Systeemreadiness</span><h2>{% if health.ok %}Radar is operationeel{% else %}Controle vereist{% endif %}</h2><p>Databasecontrole: {{ health.checks.database }}</p></div><span class="signal-dot" aria-hidden="true"></span></article>
|
||||
<div class="system-counts">{% for row in source_summary %}<article><span class="field-label">Bronnen · {{ row.status }}</span><strong>{{ row.total }}</strong></article>{% endfor %}{% for row in job_summary %}<article><span class="field-label">Vacatures · {{ row.status }}</span><strong>{{ row.total }}</strong></article>{% endfor %}</div>
|
||||
</section>
|
||||
<header class="cockpit-heading"><div><p class="eyebrow">Automation center</p><h1>Radarbesturing</h1><p>Echte 24-uursverwerking, bronruns, notificaties en configuratiestatus. Niet-gemeten runtime-informatie blijft expliciet onbekend.</p></div><div class="page-actions"><a class="button button-ghost" href="{% url 'jobs:activity' %}"><svg class="icon"><use href="#icon-activity"></use></svg>Activiteitenlog</a><a class="button button-primary" href="{% url 'sources:list' %}"><svg class="icon"><use href="#icon-sources"></use></svg>Bronnen beheren</a></div></header>
|
||||
<section class="automation-status {% if health.ok %}is-healthy{% endif %}"><div><span class="field-label">Systeemstatus</span><h2><span class="status-beacon {% if health.ok %}is-live{% endif %}" aria-hidden="true"></span>{% if health.ok %}Database operationeel{% else %}Databasecontrole vereist{% endif %}</h2></div><dl><div><dt>Queue</dt><dd>{{ queue_state }}</dd></div><div><dt>AI</dt><dd>{{ ai_state }}{% if ai_profiles %} · {{ ai_profiles }} profiel{% endif %}</dd></div><div><dt>Browserextractie</dt><dd>{{ browser_state }}</dd></div></dl></section>
|
||||
|
||||
<div class="section-heading"><div><span class="section-kicker">Brontelemetrie</span><h2>Recente rungezondheid</h2><p>Meetwaarden uit maximaal de laatste 30 runs per bron.</p></div><a class="section-link" href="{% url 'sources:list' %}">Bronnen beheren <svg class="icon"><use href="#icon-arrow"></use></svg></a></div>
|
||||
<div class="health-grid">{% for row in source_health %}<article class="health-card"><div class="health-card-head"><div><span class="field-label">{{ row.source_type }}</span><h3>{{ row.source_name }}</h3></div><span class="pill {% if row.success_ratio >= 0.8 %}pill-success{% elif row.monitored_runs == 0 %}pill-review{% else %}pill-failed{% endif %}">{% if row.monitored_runs == 0 %}Geen runs{% else %}Ratio {{ row.success_ratio|floatformat:2 }}{% endif %}</span></div><dl><div><dt>Runs</dt><dd>{{ row.monitored_runs }}</dd></div><div><dt>Gevonden</dt><dd>{{ row.extracted_count }}</dd></div><div><dt>Bijgewerkt</dt><dd>{{ row.updated_count }}</dd></div><div><dt>Latency</dt><dd>{% if row.avg_latency_ms is not None %}{{ row.avg_latency_ms|floatformat:0 }} ms{% else %}—{% endif %}</dd></div></dl>{% if row.last_health_reason %}<p class="attention">{{ row.last_health_reason }}</p>{% endif %}</article>{% empty %}<div class="empty-state compact-empty"><h2>Nog geen brontelemetrie</h2><p>Bronruns verschijnen hier zodra een toegestane bron is uitgevoerd.</p></div>{% endfor %}</div>
|
||||
<section class="automation-pipeline" aria-labelledby="pipeline-title"><span class="section-kicker">Verwerkingspipeline · laatste 24 uur</span><h2 id="pipeline-title" class="sr-only">Verwerkingspipeline</h2>{% for phase in phase_rows %}<article><span class="pipeline-phase-index">0{{ forloop.counter }}</span><svg class="icon"><use href="{% if phase.key == 'discover' %}#icon-radar{% elif phase.key == 'fetch' %}#icon-refresh{% elif phase.key == 'notify' %}#icon-mail{% else %}#icon-system{% endif %}"></use></svg><strong>{{ phase.label }}</strong><b>{{ phase.value }}</b><small>{{ phase.unit }}</small></article>{% endfor %}</section>
|
||||
|
||||
<div class="section-heading"><div><span class="section-kicker">Operaties</span><h2>Veilige beheeropdrachten</h2><p>Uitsluitend uitvoeren vanaf een vertrouwde shell op de server.</p></div></div>
|
||||
<section class="panel command-panel"><div><span class="field-label">Verificatie</span><pre><code>make verify</code></pre></div><div><span class="field-label">Back-up</span><pre><code>./scripts/backup.sh</code></pre></div><div><span class="field-label">Herstel</span><pre><code>./scripts/restore.sh /pad/naar/backup.sql.gz</code></pre></div><div><span class="field-label">Bronnen initialiseren</span><pre><code>uv run python manage.py seed_sources</code></pre></div></section>
|
||||
<section class="automation-kpis"><article><span class="field-label">Bronruns / 24u</span><strong>{{ run_count }}</strong><small>{{ run_metrics.failures|default:0 }} mislukt</small></article><article><span class="field-label">Vacatures gevonden</span><strong>{{ run_metrics.discovered|default:0 }}</strong><small>{{ run_metrics.extracted|default:0 }} geëxtraheerd</small></article><article><span class="field-label">IT-vacatures actief</span><strong>{{ active_jobs }}</strong><small>{{ review_jobs }} beoordeelde kansen</small></article><article><span class="field-label">Notificatie-outbox</span><strong>{{ notification_counts.pending }}</strong><small>{{ notification_counts.failed }} mislukt · {{ notification_counts.sent_24h }} verzonden</small></article></section>
|
||||
|
||||
<div class="automation-layout"><section class="automation-source-table"><div class="section-heading"><div><span class="section-kicker">Brongezondheid</span><h2>{{ source_active }}/{{ source_total }} actief</h2></div><a class="section-link" href="{% url 'sources:list' %}">Alle bronnen <svg class="icon"><use href="#icon-arrow"></use></svg></a></div><div class="data-table-wrap" tabindex="0"><table class="data-table"><thead><tr><th>Bron</th><th>Status</th><th>Runs</th><th>Succesratio</th><th>Extractie</th><th>Latency</th></tr></thead><tbody>{% for row in source_health %}<tr><td><strong>{{ row.source_name }}</strong><small>{{ row.source_type }}</small></td><td><span class="pill {% if row.success_ratio >= 0.8 %}pill-success{% elif row.monitored_runs == 0 %}pill-review{% else %}pill-failed{% endif %}">{% if row.monitored_runs == 0 %}Geen meting{% else %}{{ row.source_status }}{% endif %}</span></td><td>{{ row.monitored_runs }}</td><td>{% widthratio row.success_ratio 1 100 %}%</td><td>{{ row.extracted_count }}</td><td>{% if row.avg_latency_ms is not None %}{{ row.avg_latency_ms|floatformat:0 }} ms{% else %}—{% endif %}</td></tr>{% empty %}<tr><td colspan="6">Nog geen brontelemetrie.</td></tr>{% endfor %}</tbody></table></div></section><aside class="technical-log-panel"><div class="section-heading"><div><span class="section-kicker">Technische logs</span><h2>Recente bronruns</h2></div></div><div class="live-log">{% for run in recent_runs %}<article class="{% if run.status == 'failed' %}is-error{% elif run.status == 'success' %}is-success{% endif %}"><time>[{{ run.started_at|date:"H:i:s" }}]</time><strong>{{ run.get_status_display|upper }}</strong><span>{{ run.source.name }}</span><small>{% if run.error_category %}{{ run.error_category }} · {{ run.error_message }}{% else %}{{ run.extracted_count }} geëxtraheerd · {{ run.duplicate_count }} duplicaat{% endif %}</small></article>{% empty %}<p class="muted">Nog geen echte bronrun geregistreerd.</p>{% endfor %}</div><a class="button button-ghost button-block" href="{% url 'jobs:activity' %}?type=source">Volledig logboek</a></aside></div>
|
||||
{% endblock %}
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.urls import reverse
|
||||
|
||||
from apps.jobs.models import Feedback
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.django_db
|
||||
@pytest.mark.parametrize(
|
||||
"route_name",
|
||||
["jobs:activity", "jobs:employers", "dashboard:system"],
|
||||
)
|
||||
def test_intelligence_routes_require_login(client, route_name):
|
||||
response = client.get(reverse(route_name))
|
||||
assert response.status_code == 302
|
||||
assert reverse("login") in response.url
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.django_db
|
||||
def test_activity_feed_filters_and_scopes_user_events(client, user, job):
|
||||
other = get_user_model().objects.create_user(username="activity-other")
|
||||
Feedback.objects.create(
|
||||
user=other,
|
||||
job=job,
|
||||
action=Feedback.Action.HIDE,
|
||||
reason="private-other-marker",
|
||||
)
|
||||
Feedback.objects.create(
|
||||
user=user,
|
||||
job=job,
|
||||
action=Feedback.Action.INTERESTING,
|
||||
reason="own-event-marker",
|
||||
)
|
||||
client.force_login(user)
|
||||
|
||||
response = client.get(reverse("jobs:activity"), {"type": "feedback"})
|
||||
|
||||
assert response.status_code == 200
|
||||
body = response.content.decode("utf-8")
|
||||
assert "own-event-marker" in body
|
||||
assert "private-other-marker" not in body
|
||||
assert response.context["event_page"].paginator.count == 1
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.django_db
|
||||
def test_employer_index_and_detail_use_real_aggregates(client, user, profile, job, source):
|
||||
client.force_login(user)
|
||||
|
||||
response = client.get(reverse("jobs:employers"), {"q": job.employer.name})
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.context["selected_employer"].pk == job.employer_id
|
||||
assert response.context["selected_employer"].active_vacancies == 1
|
||||
assert response.context["selected_employer"].relevant_vacancies == 1
|
||||
assert job.original_title in response.content.decode("utf-8")
|
||||
|
||||
detail = client.get(reverse("jobs:employer-detail", kwargs={"pk": job.employer_id}))
|
||||
assert detail.status_code == 200
|
||||
assert detail.context["employer"].pk == job.employer_id
|
||||
assert [item.pk for item in detail.context["recent_jobs"]] == [job.pk]
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.django_db
|
||||
def test_automation_center_labels_unmeasured_runtime_honestly(client, user):
|
||||
client.force_login(user)
|
||||
|
||||
response = client.get(reverse("dashboard:system"))
|
||||
|
||||
assert response.status_code == 200
|
||||
body = response.content.decode("utf-8")
|
||||
assert "Automation center" in body
|
||||
assert "worker niet gemeten" in body or "Eager/lokaal" in body
|
||||
assert "Niet geconfigureerd" in body
|
||||
assert len(response.context["phase_rows"]) == 8
|
||||
Reference in New Issue
Block a user