From afe18e5b9674201a7171a9dd5b25e6d05475c7e6 Mon Sep 17 00:00:00 2001 From: Jens Date: Wed, 22 Jul 2026 20:17:20 +0200 Subject: [PATCH] feat: add automation activity and employer intelligence --- apps/core/services/__init__.py | 1 + apps/core/services/automation.py | 112 +++++++++++++ apps/core/views.py | 8 +- apps/jobs/services/activity.py | 159 +++++++++++++++++++ apps/jobs/services/employer_intelligence.py | 86 ++++++++++ apps/jobs/urls.py | 6 + apps/jobs/views.py | 94 ++++++++++- docs/ai/BACKLOG.yaml | 7 +- static/css/pages.css | 78 +++++++++ static/css/responsive.css | 17 ++ templates/activity/list.html | 11 ++ templates/base.html | 2 + templates/employers/detail.html | 8 + templates/employers/list.html | 11 ++ templates/system/status.html | 17 +- tests/integration/test_intelligence_views.py | 80 ++++++++++ 16 files changed, 679 insertions(+), 18 deletions(-) create mode 100644 apps/core/services/__init__.py create mode 100644 apps/core/services/automation.py create mode 100644 apps/jobs/services/activity.py create mode 100644 apps/jobs/services/employer_intelligence.py create mode 100644 templates/activity/list.html create mode 100644 templates/employers/detail.html create mode 100644 templates/employers/list.html create mode 100644 tests/integration/test_intelligence_views.py diff --git a/apps/core/services/__init__.py b/apps/core/services/__init__.py new file mode 100644 index 0000000..e855851 --- /dev/null +++ b/apps/core/services/__init__.py @@ -0,0 +1 @@ +"""Read services for the application cockpit.""" diff --git a/apps/core/services/automation.py b/apps/core/services/automation.py new file mode 100644 index 0000000..f802688 --- /dev/null +++ b/apps/core/services/automation.py @@ -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", + } diff --git a/apps/core/views.py b/apps/core/views.py index ddf4789..53eac19 100644 --- a/apps/core/views.py +++ b/apps/core/views.py @@ -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 diff --git a/apps/jobs/services/activity.py b/apps/jobs/services/activity.py new file mode 100644 index 0000000..9800e22 --- /dev/null +++ b/apps/jobs/services/activity.py @@ -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) diff --git a/apps/jobs/services/employer_intelligence.py b/apps/jobs/services/employer_intelligence.py new file mode 100644 index 0000000..724b155 --- /dev/null +++ b/apps/jobs/services/employer_intelligence.py @@ -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 [], + } diff --git a/apps/jobs/urls.py b/apps/jobs/urls.py index 0027a7b..9482084 100644 --- a/apps/jobs/urls.py +++ b/apps/jobs/urls.py @@ -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//", EmployerDetailView.as_view(), name="employer-detail"), path("applications/", ApplicationListView.as_view(), name="applications"), path("applications//", ApplicationUpdateView.as_view(), name="application-edit"), path("applications//status/", application_status, name="application-status"), diff --git a/apps/jobs/views.py b/apps/jobs/views.py index ec05128..32fcc20 100644 --- a/apps/jobs/views.py +++ b/apps/jobs/views.py @@ -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" diff --git a/docs/ai/BACKLOG.yaml b/docs/ai/BACKLOG.yaml index f941700..b7b95b4 100644 --- a/docs/ai/BACKLOG.yaml +++ b/docs/ai/BACKLOG.yaml @@ -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 diff --git a/static/css/pages.css b/static/css/pages.css index f13e941..df3e4ef 100644 --- a/static/css/pages.css +++ b/static/css/pages.css @@ -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; } diff --git a/static/css/responsive.css b/static/css/responsive.css index 13a8e4b..e089e27 100644 --- a/static/css/responsive.css +++ b/static/css/responsive.css @@ -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; } } diff --git a/templates/activity/list.html b/templates/activity/list.html new file mode 100644 index 0000000..fabc91e --- /dev/null +++ b/templates/activity/list.html @@ -0,0 +1,11 @@ +{% extends "base.html" %} +{% block title %}Activiteit · VacatureRadar{% endblock %} +{% block content %} +

Radar audit trail

Activiteit

Betrouwbare gebeurtenissen uit bronruns, vacatureversies, feedback, sollicitaties, notificaties en profielrevisies.

{{ event_page.paginator.count }} gebeurtenissen in deze selectie
+
+ +
+{% for event in event_page %}
{{ event.event_type }}

{{ event.title }}

{{ event.object_label }}

Gebeurtenisdetails

{{ event.detail }}

Open betrokken onderdeel
{% empty %}

Geen activiteit gevonden

Deze filter bevat nog geen auditwaardige gebeurtenis.

Alle activiteit tonen
{% endfor %} +
+{% if event_page.paginator.num_pages > 1 %}{% endif %} +{% endblock %} diff --git a/templates/base.html b/templates/base.html index f91a8e9..2e9c874 100644 --- a/templates/base.html +++ b/templates/base.html @@ -33,11 +33,13 @@ Alle vacatures Skillsradar Sollicitaties + Werkgevers Zoekprofiel Bronnen Automatisering + Activiteit