Initial deploy setup
deploy / deploy (push) Canceled after 0s

This commit is contained in:
Jens
2026-07-21 14:00:00 +02:00
commit b8091e59bd
285 changed files with 27854 additions and 0 deletions
+3
View File
@@ -0,0 +1,3 @@
{% extends "base.html" %}
{% block title %}Sollicitatiedossier · VacatureRadar{% endblock %}
{% block content %}<header class="page-header"><div><p class="eyebrow">Sollicitatiedossier</p><h1>{{ object.job.original_title }}</h1><p>{{ object.job.employer_name }}</p></div></header><form method="post" class="panel form-panel">{% csrf_token %}<div class="form-grid">{% for field in form %}<label class="{% if field.name == 'notes' %}span-2{% endif %}"><span>{{ field.label }}</span>{{ field }}{% for error in field.errors %}<small class="field-error">{{ error }}</small>{% endfor %}</label>{% endfor %}</div><div class="form-actions"><a class="button button-ghost" href="{% url 'jobs:applications' %}">Terug</a><button class="button button-primary" type="submit">Opslaan</button></div></form>{% endblock %}
+3
View File
@@ -0,0 +1,3 @@
{% extends "base.html" %}
{% block title %}Sollicitaties · VacatureRadar{% endblock %}
{% block content %}<header class="page-header"><div><p class="eyebrow">Persoonlijke opvolging</p><h1>Sollicitaties</h1><p>Vacaturesnapshots blijven bewaard wanneer de bron offline gaat.</p></div></header><div class="card-list">{% for application in applications %}<article class="panel application-card"><div><span class="pill">{{ application.get_status_display }}</span><h2><a href="{% url 'jobs:detail' application.job.pk %}">{{ application.job.original_title }}</a></h2><p>{{ application.job.employer_name }} · {{ application.job.raw_location }}</p><div class="job-meta">{% if application.applied_at %}<span>Verzonden {{ application.applied_at|date:"d/m/Y" }}</span>{% endif %}{% if application.follow_up_date %}<span>Opvolgen {{ application.follow_up_date|date:"d/m/Y" }}</span>{% endif %}</div></div><a class="button button-secondary" href="{% url 'jobs:application-edit' application.pk %}">Dossier</a></article>{% empty %}<div class="empty-state"><h2>Nog geen sollicitatiedossiers</h2><p>Markeer een vacature als Gesolliciteerd om automatisch een dossier te maken.</p></div>{% endfor %}</div>{% endblock %}
+50
View File
@@ -0,0 +1,50 @@
{% load static %}
<!doctype html>
<html lang="nl">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light dark">
<title>{% block title %}{{ app_name }}{% endblock %}</title>
<link rel="stylesheet" href="{% static 'css/app.css' %}">
<script src="{% static 'js/app.js' %}" defer></script>
</head>
<body>
<a class="skip-link" href="#main">Naar inhoud</a>
{% if user.is_authenticated %}
<header class="topbar">
<div class="brand-wrap">
<button class="icon-button mobile-only" type="button" data-nav-toggle aria-label="Menu openen" aria-expanded="false"></button>
<a class="brand" href="{% url 'dashboard:today' %}">
<span class="brand-mark" aria-hidden="true">VR</span>
<span><strong>VacatureRadar</strong><small>persoonlijke vacature-assistent</small></span>
</a>
</div>
<div class="topbar-actions">
<a class="button button-ghost" href="{% url 'dashboard:system' %}">Systeem</a>
<form action="{% url 'logout' %}" method="post">{% csrf_token %}<button class="button button-ghost" type="submit">Afmelden</button></form>
</div>
</header>
<div class="app-shell">
<nav class="sidebar" data-sidebar aria-label="Hoofdnavigatie">
<a class="nav-link {% if request.resolver_match.namespace == 'dashboard' and request.resolver_match.url_name == 'today' %}active{% endif %}" href="{% url 'dashboard:today' %}"><span>Vandaag</span><small>beste matches</small></a>
<a class="nav-link {% if request.resolver_match.namespace == 'jobs' and request.resolver_match.url_name == 'list' %}active{% endif %}" href="{% url 'jobs:list' %}"><span>Vacatures</span><small>zoeken en filteren</small></a>
<a class="nav-link {% if request.resolver_match.url_name == 'applications' %}active{% endif %}" href="{% url 'jobs:applications' %}"><span>Sollicitaties</span><small>opvolging</small></a>
<a class="nav-link {% if request.resolver_match.namespace == 'profiles' %}active{% endif %}" href="{% url 'profiles:list' %}"><span>Profiel</span><small>regels en voorkeuren</small></a>
<a class="nav-link {% if request.resolver_match.namespace == 'sources' %}active{% endif %}" href="{% url 'sources:list' %}"><span>Brongezondheid</span><small>geavanceerd</small></a>
<div class="sidebar-foot"><span class="status-dot"></span> strict mode <small>v{{ app_version }}</small></div>
</nav>
<main id="main" class="main-content" tabindex="-1">
{% else %}
<main id="main" class="auth-main" tabindex="-1">
{% endif %}
{% if messages %}
<div class="messages" aria-live="polite">
{% for message in messages %}<div class="message {{ message.tags }}">{{ message }}</div>{% endfor %}
</div>
{% endif %}
{% block content %}{% endblock %}
</main>
{% if user.is_authenticated %}</div>{% endif %}
</body>
</html>
+32
View File
@@ -0,0 +1,32 @@
{% extends "base.html" %}
{% block title %}Vandaag · VacatureRadar{% endblock %}
{% block content %}
<header class="page-header">
<div><p class="eyebrow">Dagelijkse selectie</p><h1>Vandaag</h1><p>Alleen nieuwe en relevante vacatures die aandacht verdienen.</p></div>
<a class="button button-secondary" href="{% url 'jobs:list' %}">Alle vacatures</a>
</header>
<section class="stats-grid" aria-label="Kerncijfers">
<article class="stat-card"><span>Nieuwe vacatures</span><strong>{{ new_today }}</strong><small>vandaag gevonden</small></article>
<article class="stat-card"><span>Actief</span><strong>{{ active_jobs }}</strong><small>actuele vacatures</small></article>
<article class="stat-card"><span>Sollicitaties</span><strong>{{ applications_open }}</strong><small>open dossiers</small></article>
<article class="stat-card"><span>Bronnen</span><strong>{{ source_counts.total|default:0 }}</strong><small>{{ source_counts.unhealthy|default:0 }} vragen aandacht</small></article>
</section>
<section class="section-heading"><div><h2>Beste matches</h2><p>Gerangschikt op persoonlijke matchscore; confidence blijft afzonderlijk zichtbaar.</p></div></section>
<div class="job-grid">
{% for score in score_cards %}
<article class="job-card">
<div class="job-card-head"><span class="score-ring">{{ score.score|floatformat:0 }}</span><div><span class="pill pill-{{ score.recommendation }}">{{ score.get_recommendation_display }}</span><h3><a href="{% url 'jobs:detail' score.job.pk %}">{{ score.job.original_title }}</a></h3><p>{{ score.job.employer_name }}</p></div></div>
<div class="job-meta"><span>{{ score.job.raw_location|default:"Locatie onbekend" }}</span><span>{{ score.job.get_workplace_type_display }}</span><span>confidence {{ score.confidence|floatformat:2 }}</span></div>
{% if score.positives %}<ul class="compact-list positive">{% for item in score.positives|slice:":2" %}<li>{{ item }}</li>{% endfor %}</ul>{% endif %}
{% if score.concerns %}<p class="attention"><strong>Aandachtspunt:</strong> {{ score.concerns.0 }}</p>{% endif %}
<div class="job-actions">
<form method="post" action="{% url 'jobs:feedback' score.job.pk %}">{% csrf_token %}<input type="hidden" name="action" value="interesting"><input type="hidden" name="next" value="{{ request.path }}"><button class="button button-primary" type="submit">Interessant</button></form>
<form method="post" action="{% url 'jobs:feedback' score.job.pk %}">{% csrf_token %}<input type="hidden" name="action" value="save"><input type="hidden" name="next" value="{{ request.path }}"><button class="button button-secondary" type="submit">Bewaren</button></form>
<a class="button button-ghost" href="{% url 'jobs:detail' score.job.pk %}">Details</a>
</div>
</article>
{% empty %}
<div class="empty-state"><div class="empty-icon"></div><h2>Nog geen beoordeelde vacatures</h2><p>Importeer de meegeleverde fixture of activeer een toegestane bron. De eerste resultaten verschijnen hier automatisch.</p><code>make demo</code></div>
{% endfor %}
</div>
{% endblock %}
+1
View File
@@ -0,0 +1 @@
<!doctype html><html lang="nl"><body style="font-family:Arial,sans-serif;background:#f4f7fb;color:#152033;margin:0;padding:24px"><div style="max-width:720px;margin:auto;background:#fff;border-radius:18px;padding:28px"><p style="color:#4a62d8;font-weight:bold;text-transform:uppercase;letter-spacing:.08em">VacatureRadar</p><h1 style="margin:0 0 8px">Dagelijkse selectie</h1><p>{{ digest.strong_count }} sterke en {{ digest.possible_count }} mogelijke matches.</p>{% for score in digest.scores %}<div style="border:1px solid #dde3ee;border-radius:14px;padding:18px;margin:16px 0"><p style="margin:0;color:#4a62d8;font-weight:bold">{{ score.score|floatformat:0 }}/100</p><h2 style="margin:6px 0">{{ score.title }}</h2><p>{{ score.employer }} · {{ score.location }}</p>{% if score.positives %}<p><strong>Waarom:</strong> {{ score.positives|join:"; " }}</p>{% endif %}{% if score.concerns %}<p><strong>Aandachtspunt:</strong> {{ score.concerns.0 }}</p>{% endif %}<p><a href="{{ score.url }}">Open originele vacature</a></p></div>{% empty %}<p>Geen nieuwe aanbevelingen in deze digest.</p>{% endfor %}</div></body></html>
+14
View File
@@ -0,0 +1,14 @@
VacatureRadar — dagelijkse selectie
{{ digest.strong_count }} sterke matches
{{ digest.possible_count }} mogelijke matches
{% for score in digest.scores %}{{ score.title }} — {{ score.employer }}
Score {{ score.score|floatformat:0 }}/100 · {{ score.location }}
{% if score.positives %}Waarom: {{ score.positives|join:"; " }}{% endif %}
{% if score.concerns %}Aandachtspunt: {{ score.concerns.0 }}{% endif %}
{{ score.url }}
{% empty %}Geen nieuwe aanbevelingen in deze digest.
{% endfor %}
Open VacatureRadar voor feedback en sollicitatieopvolging.
+1
View File
@@ -0,0 +1 @@
<!doctype html><html lang="nl"><body style="font-family:Arial,sans-serif;background:#f4f7fb;color:#152033;margin:0;padding:24px"><div style="max-width:720px;margin:auto;background:#fff;border-radius:18px;padding:28px"><p style="color:#4a62d8;font-weight:bold;text-transform:uppercase;letter-spacing:.08em">VacatureRadar</p><h1 style="margin:0 0 8px">Herinnering</h1><p style="margin:0 0 16px;color:#0f1f3d">{{ reminder.job_title|default:"Vacature" }}</p><p style="margin:0 0 8px"><strong>Werkgever:</strong> {{ reminder.employer|default:"Onbekend" }}</p>{% if reminder.reminder_type == "closing" %}<p>Deze vacature sluit op: {{ reminder.valid_through }}</p>{% elif reminder.reminder_type == "follow_up" %}<p>Opvolgingsdatum: {{ reminder.follow_up_date }}</p><p>Status dossier: {{ reminder.job_status }}</p>{% elif reminder.reminder_type == "top_match" %}<p>Topmatch met score {{ reminder.score|floatformat:0 }}/100</p><p>Confidence: {{ reminder.confidence|floatformat:2 }}</p>{% endif %}<p><a href="{{ reminder.link }}">Open vacature</a></p></div></body></html>
+18
View File
@@ -0,0 +1,18 @@
VacatureRadar — {{ reminder.reminder_type|default:"herinnering" }}
{{ reminder.job_title|default:"Vacature" }}
{{ reminder.employer|default:"Onbekende werkgever" }}
{% if reminder.reminder_type == "closing" %}
Deze vacature sluit op: {{ reminder.valid_through }}.
{% elif reminder.reminder_type == "follow_up" %}
Opvolgingsdatum: {{ reminder.follow_up_date }}.
Status dossier: {{ reminder.job_status }}.
{% elif reminder.reminder_type == "top_match" %}
Topmatch gevonden met score {{ reminder.score|floatformat:0 }}/100
En confidence {{ reminder.confidence|floatformat:2 }}.
{% endif %}
Bron: {{ reminder.link }}
Open vacature in VacatureRadar om dossierstatus bij te werken.
+14
View File
@@ -0,0 +1,14 @@
{% extends "base.html" %}
{% block title %}{{ job.original_title }} · VacatureRadar{% endblock %}
{% block content %}
<div class="detail-layout">
<div class="detail-main">
<a class="back-link" href="{% url 'jobs:list' %}">← Terug naar vacatures</a>
<header class="job-hero"><div><p class="eyebrow">{{ job.get_status_display }}</p><h1>{{ job.original_title }}</h1><p class="hero-subtitle">{{ job.employer_name }} · {{ job.raw_location|default:"Locatie onbekend" }}</p><div class="job-meta"><span>{{ job.get_workplace_type_display }}</span>{% for type in job.employment_types %}<span>{{ type }}</span>{% endfor %}{% if job.valid_through %}<span>Sluit {{ job.valid_through|date:"d/m/Y" }}</span>{% endif %}</div></div>{% if score %}<div class="hero-score"><strong>{{ score.score|floatformat:0 }}</strong><span>matchscore</span><small>confidence {{ score.confidence|floatformat:2 }}</small></div>{% endif %}</header>
{% if score %}<section class="panel"><div class="section-heading"><div><h2>Waarom deze score?</h2><p>De inhoudelijke score en de betrouwbaarheid worden apart behandeld.</p></div><span class="pill pill-{{ score.recommendation }}">{{ score.get_recommendation_display }}</span></div><div class="score-components">{% for name,value in score.components.items %}<div><span>{{ name|capfirst }}</span><div class="progress"><i style="width: {{ value }}%"></i></div><strong>{{ value|floatformat:1 }}</strong></div>{% endfor %}</div><div class="two-columns"><div><h3>Waarom passend</h3><ul class="compact-list positive">{% for item in score.positives %}<li>{{ item }}</li>{% empty %}<li>Nog onvoldoende positieve signalen.</li>{% endfor %}</ul></div><div><h3>Aandachtspunten</h3><ul class="compact-list caution">{% for item in score.concerns %}<li>{{ item }}</li>{% empty %}<li>Geen specifieke aandachtspunten gedetecteerd.</li>{% endfor %}</ul></div></div>{% if score.hard_exclusions %}<div class="message warning"><strong>Harde uitsluitingen:</strong> {{ score.hard_exclusions|join:", " }}</div>{% endif %}</section>{% endif %}
<section class="panel"><h2>Vacatureomschrijving</h2>{% if job.description_html_sanitized %}<div class="prose">{{ job.description_html_sanitized|safe }}</div>{% else %}<div class="prose"><p>{{ job.description_text|linebreaksbr }}</p></div>{% endif %}</section>
<section class="panel"><h2>Bron en historie</h2><dl class="definition-grid"><div><dt>Eerst gezien</dt><dd>{{ job.first_seen|date:"d/m/Y H:i" }}</dd></div><div><dt>Laatst gezien</dt><dd>{{ job.last_seen|date:"d/m/Y H:i" }}</dd></div><div><dt>Laatst gewijzigd</dt><dd>{{ job.last_changed|date:"d/m/Y H:i" }}</dd></div><div><dt>Extractieconfidence</dt><dd>{{ job.extraction_confidence|floatformat:2 }}</dd></div></dl><h3>Ook gevonden via</h3><ul class="source-list">{% for alias in job.source_aliases.all %}<li><a href="{{ alias.url }}" target="_blank" rel="noopener noreferrer nofollow">{{ alias.source.name|default:"Onbekende bron" }}</a><span>{{ alias.extraction_method }} · confidence {{ alias.extraction_confidence|floatformat:2 }}</span></li>{% empty %}<li>Geen bronalias beschikbaar.</li>{% endfor %}</ul></section>
</div>
<aside class="detail-sidebar"><div class="sticky-card"><h2>Volgende stap</h2><div class="stack-actions"><form method="post" action="{% url 'jobs:feedback' job.pk %}">{% csrf_token %}<input type="hidden" name="action" value="interesting"><button class="button button-primary button-block" type="submit">Interessant</button></form><form method="post" action="{% url 'jobs:feedback' job.pk %}">{% csrf_token %}<input type="hidden" name="action" value="save"><button class="button button-secondary button-block" type="submit">Bewaren</button></form><form method="post" action="{% url 'jobs:feedback' job.pk %}">{% csrf_token %}<input type="hidden" name="action" value="applied"><button class="button button-secondary button-block" type="submit">Gesolliciteerd</button></form><form method="post" action="{% url 'jobs:feedback' job.pk %}" class="hide-form">{% csrf_token %}<input type="hidden" name="action" value="hide"><label>Reden (optioneel)<select name="reason"><option value="">Geen reden</option><option>Te ver</option><option>Te veel support</option><option>Recruiter/consultancy</option><option>Onrealistische senioriteit</option><option>Niet mijn technologie</option></select></label><button class="button button-ghost button-block" type="submit">Verbergen</button></form></div>{% if job.canonical_url %}<a class="button button-link button-block" href="{{ job.canonical_url }}" target="_blank" rel="noopener noreferrer nofollow">Open originele vacature ↗</a>{% endif %}{% if application %}<hr><p class="muted">Dossierstatus: <strong>{{ application.get_status_display }}</strong></p><a href="{% url 'jobs:application-edit' application.pk %}">Dossier bewerken</a>{% endif %}</div></aside>
</div>
{% endblock %}
+16
View File
@@ -0,0 +1,16 @@
{% extends "base.html" %}
{% block title %}Vacatures · VacatureRadar{% endblock %}
{% block content %}
<header class="page-header"><div><p class="eyebrow">Alle resultaten</p><h1>Vacatures</h1><p>Doorzoek actieve en gearchiveerde vacatureclusters.</p></div></header>
<form method="get" class="filter-bar">
<label class="grow"><span class="sr-only">Zoeken</span><input type="search" name="q" value="{{ request.GET.q }}" placeholder="Titel, werkgever, locatie of skill"></label>
<label><span class="sr-only">Status</span><select name="status"><option value="">Alle statussen</option>{% for value,label in view.model.Status.choices %}<option value="{{ value }}" {% if request.GET.status|default:'active' == value %}selected{% endif %}>{{ label }}</option>{% endfor %}</select></label>
<label><span class="sr-only">Werkmodel</span><select name="workplace"><option value="">Elk werkmodel</option>{% for value,label in view.model.Workplace.choices %}<option value="{{ value }}" {% if request.GET.workplace == value %}selected{% endif %}>{{ label }}</option>{% endfor %}</select></label>
<button class="button button-secondary" type="submit">Filter</button>
</form>
<div class="table-wrap"><table class="data-table"><thead><tr><th>Vacature</th><th>Locatie</th><th>Werkmodel</th><th>Status</th><th>Gevonden</th></tr></thead><tbody>
{% for job in jobs %}<tr><td><a class="table-title" href="{% url 'jobs:detail' job.pk %}">{{ job.original_title }}</a><small>{{ job.employer_name }}</small></td><td>{{ job.raw_location|default:"—" }}</td><td>{{ job.get_workplace_type_display }}</td><td><span class="pill">{{ job.get_status_display }}</span></td><td>{{ job.first_seen|date:"d/m/Y" }}</td></tr>
{% empty %}<tr><td colspan="5">Geen vacatures gevonden voor deze filters.</td></tr>{% endfor %}
</tbody></table></div>
{% if is_paginated %}<nav class="pagination" aria-label="Paginering">{% if page_obj.has_previous %}<a href="?page={{ page_obj.previous_page_number }}&q={{ request.GET.q }}">Vorige</a>{% endif %}<span>Pagina {{ page_obj.number }} van {{ page_obj.paginator.num_pages }}</span>{% if page_obj.has_next %}<a href="?page={{ page_obj.next_page_number }}&q={{ request.GET.q }}">Volgende</a>{% endif %}</nav>{% endif %}
{% endblock %}
+3
View File
@@ -0,0 +1,3 @@
{% extends "base.html" %}
{% block title %}{{ object.name }} bewerken · VacatureRadar{% endblock %}
{% block content %}<header class="page-header"><div><p class="eyebrow">Zoekprofiel versie {{ object.version }}</p><h1>{{ object.name }} bewerken</h1><p>Wijzig alleen persoonlijke keuzes; bronlogica blijft automatisch.</p></div></header><form method="post" class="panel form-panel">{% csrf_token %}{% if form.non_field_errors %}<div class="message error">{{ form.non_field_errors }}</div>{% endif %}<div class="form-grid">{% for field in form %}<label class="{% if field.name|slice:'-5:' == '_text' %}span-2{% endif %}"><span>{{ field.label }}</span>{{ field }}{% if field.help_text %}<small>{{ field.help_text }}</small>{% endif %}{% for error in field.errors %}<small class="field-error">{{ error }}</small>{% endfor %}</label>{% endfor %}</div><div class="form-actions"><a class="button button-ghost" href="{% url 'profiles:list' %}">Annuleren</a><button class="button button-primary" type="submit">Profiel opslaan</button></div></form>{% endblock %}
+3
View File
@@ -0,0 +1,3 @@
{% extends "base.html" %}
{% block title %}Zoekprofielen · VacatureRadar{% endblock %}
{% block content %}<header class="page-header"><div><p class="eyebrow">Persoonlijke regels</p><h1>Zoekprofielen</h1><p>Harde grenzen veranderen nooit automatisch; zachte gewichten kunnen gecontroleerd leren.</p></div></header><div class="card-list">{% for profile in profiles %}<article class="panel profile-card"><div><div class="title-row"><h2>{{ profile.name }}</h2>{% if profile.is_active %}<span class="pill pill-strong">Actief</span>{% endif %}</div><p>{{ profile.desired_titles|join:", "|default:"Nog geen functietitels" }}</p><div class="job-meta"><span>max. {{ profile.max_distance_km }} km</span><span>digest {{ profile.digest_time|time:"H:i" }}</span><span>versie {{ profile.version }}</span></div></div><div class="job-actions"><a class="button button-secondary" href="{% url 'profiles:edit' profile.pk %}">Bewerken</a>{% if not profile.is_active %}<form method="post" action="{% url 'profiles:activate' profile.pk %}">{% csrf_token %}<button class="button button-ghost" type="submit">Activeren</button></form>{% endif %}</div></article>{% empty %}<div class="empty-state"><h2>Geen profiel</h2><p>Voer de bootstrapopdracht uit om het standaardprofiel aan te maken.</p></div>{% endfor %}</div>{% endblock %}
+14
View File
@@ -0,0 +1,14 @@
{% extends "base.html" %}
{% block title %}Aanmelden · VacatureRadar{% endblock %}
{% block content %}
<section class="auth-card">
<div class="auth-brand"><span class="brand-mark">VR</span><div><h1>Welkom terug</h1><p>Open je persoonlijke vacatureselectie.</p></div></div>
{% if form.errors %}<div class="message error">Gebruikersnaam of wachtwoord klopt niet.</div>{% endif %}
<form method="post" class="stack">{% csrf_token %}
<label>Gebruikersnaam {{ form.username }}</label>
<label>Wachtwoord {{ form.password }}</label>
<button class="button button-primary button-block" type="submit">Aanmelden</button>
</form>
<p class="muted small">De applicatie is ontworpen voor lokaal gebruik via LAN of VPN.</p>
</section>
{% endblock %}
+489
View File
@@ -0,0 +1,489 @@
{% load static %}
<!doctype html>
<html class="dark" lang="nl">
<head>
<meta charset="utf-8" />
<meta content="width=device-width, initial-scale=1.0" name="viewport" />
<title>VacatureRadar - Bronnen & Source Health</title>
<script src="https://cdn.tailwindcss.com?plugins=forms,container-queries"></script>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&family=Geist:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet" />
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&display=swap" rel="stylesheet" />
<script id="tailwind-config">
tailwind.config = {
darkMode: "class",
theme: {
extend: {
colors: {
"on-secondary-fixed-variant": "#005236",
"secondary-fixed": "#6ffbbe",
"on-error-container": "#ffdad6",
"surface": "#0b1326",
"surface-dim": "#0b1326",
"on-primary": "#003739",
"on-surface": "#dae2fd",
"inverse-surface": "#dae2fd",
"background": "#0b1326",
"tertiary-fixed": "#e1e0ff",
"surface-container": "#171f33",
"primary-container": "#00f5ff",
"surface-container-lowest": "#060e20",
"on-tertiary-fixed-variant": "#2f2ebe",
"on-tertiary": "#1000a9",
"on-secondary": "#003824",
"surface-container-highest": "#2d3449",
"on-primary-fixed-variant": "#004f53",
"on-secondary-container": "#00311f",
"on-error": "#690005",
"tertiary": "#fcf8ff",
"on-secondary-fixed": "#002113",
"on-primary-fixed": "#002021",
"surface-variant": "#2d3449",
"inverse-primary": "#00696e",
"surface-bright": "#31394d",
"secondary": "#4edea3",
"primary-fixed": "#63f7ff",
"outline": "#849495",
"surface-container-high": "#222a3d",
"primary": "#e9feff",
"secondary-fixed-dim": "#4edea3",
"on-background": "#dae2fd",
"on-surface-variant": "#b9caca",
"error": "#ffb4ab",
"secondary-container": "#00a572",
"tertiary-fixed-dim": "#c0c1ff",
"on-tertiary-container": "#4c4ed9",
"surface-container-low": "#131b2e",
"outline-variant": "#3a494a",
"inverse-on-surface": "#283044",
"on-primary-container": "#006c71",
"primary-fixed-dim": "#00dce5",
"error-container": "#93000a",
"tertiary-container": "#dbdaff",
"surface-tint": "#00dce5",
"on-tertiary-fixed": "#07006c"
},
borderRadius: {
DEFAULT: "0.125rem",
lg: "0.25rem",
xl: "0.5rem",
full: "0.75rem"
},
spacing: {
"stack-md": "16px",
"container-max": "1280px",
"gutter": "24px",
"stack-sm": "8px",
"margin-mobile": "16px",
"stack-lg": "32px"
},
fontFamily: {
"headline-lg": ["Geist"],
"headline-md": ["Geist"],
"body-md": ["Inter"],
"body-sm": ["Inter"],
"label-mono": ["JetBrains Mono"],
"metadata-caps": ["JetBrains Mono"]
}
}
}
}
</script>
<style>
body {
background-color: #0b1326;
color: #dae2fd;
}
.material-symbols-outlined {
font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;
}
.signal-glow-primary {
box-shadow: 0 0 15px rgba(0, 245, 255, 0.1);
border: 1px solid rgba(0, 245, 255, 0.2);
}
.glass-card {
background: rgba(23, 31, 51, 0.6);
backdrop-filter: blur(12px);
border: 1px solid rgba(58, 73, 74, 0.5);
}
.technical-log {
scrollbar-width: thin;
scrollbar-color: #3a494a #171f33;
}
@keyframes pulse-cyan {
0% { opacity: 1; transform: scale(1); }
50% { opacity: 0.5; transform: scale(1.2); }
100% { opacity: 1; transform: scale(1); }
}
.status-pulse {
animation: pulse-cyan 2s infinite ease-in-out;
}
</style>
</head>
<body class="font-body-md text-body-md overflow-x-hidden">
<header class="fixed top-0 w-full z-50 bg-surface/80 backdrop-blur-xl border-b border-outline-variant/30 shadow-sm hidden md:block">
<div class="flex justify-between items-center px-gutter py-stack-sm w-full max-w-container-max mx-auto">
<div class="flex items-center gap-8">
<span class="font-headline-md text-headline-md font-bold tracking-tight text-primary">VacatureRadar</span>
<nav class="flex gap-6">
<a class="text-on-surface-variant hover:bg-surface-container-highest/50 px-3 py-1 rounded transition-colors font-medium" href="#">Overzicht</a>
<a class="text-primary font-bold px-3 py-1 rounded" href="#">Bronnen</a>
<a class="text-on-surface-variant hover:bg-surface-container-highest/50 px-3 py-1 rounded transition-colors font-medium" href="#">Instellingen</a>
</nav>
</div>
<div class="flex items-center gap-4">
<span class="material-symbols-outlined text-on-surface-variant cursor-pointer hover:text-primary transition-colors">notifications</span>
<span class="material-symbols-outlined text-on-surface-variant cursor-pointer hover:text-primary transition-colors">account_circle</span>
</div>
</div>
</header>
<aside class="h-screen w-64 fixed left-0 top-0 bg-surface-container border-r border-outline-variant/20 hidden md:flex flex-col py-stack-lg gap-stack-md z-40">
<div class="px-6 pb-6">
<h1 class="font-headline-md text-headline-md text-primary font-bold">VacatureRadar</h1>
<p class="font-label-mono text-label-mono text-on-surface-variant opacity-60">Intelligent Discovery</p>
</div>
<nav class="flex flex-col px-3">
<a class="flex items-center gap-3 px-4 py-3 rounded text-on-surface-variant hover:text-primary hover:bg-surface-container-high transition-all" href="#">
<span class="material-symbols-outlined">dashboard</span>
<span class="font-label-mono text-label-mono">Overview</span>
</a>
<a class="flex items-center gap-3 px-4 py-3 rounded text-on-surface-variant hover:text-primary hover:bg-surface-container-high transition-all" href="#">
<span class="material-symbols-outlined">radar</span>
<span class="font-label-mono text-label-mono">Nieuwe matches</span>
</a>
<a class="flex items-center gap-3 px-4 py-3 rounded text-primary bg-primary-container/10 border-r-2 border-primary-container" href="#">
<span class="material-symbols-outlined">hub</span>
<span class="font-label-mono text-label-mono">Bronnen</span>
</a>
<a class="flex items-center gap-3 px-4 py-3 rounded text-on-surface-variant hover:text-primary hover:bg-surface-container-high transition-all" href="#">
<span class="material-symbols-outlined">precision_manufacturing</span>
<span class="font-label-mono text-label-mono">Automatisering</span>
</a>
<a class="flex items-center gap-3 px-4 py-3 rounded text-on-surface-variant hover:text-primary hover:bg-surface-container-high transition-all" href="#">
<span class="material-symbols-outlined">settings</span>
<span class="font-label-mono text-label-mono">Instellingen</span>
</a>
</nav>
<div class="px-6 py-4 mt-auto border-t border-outline-variant/30">
<div class="flex items-center gap-3">
<span class="material-symbols-outlined">group</span>
<div>
<p class="font-body-sm font-medium">{{ user.get_short_name|default:user.username|default:"Gast" }}</p>
<p class="text-[10px] font-label-mono text-on-surface-variant">gebruiker</p>
</div>
</div>
</div>
</aside>
<main class="md:ml-64 pt-6 md:pt-24 min-h-screen px-4 md:px-gutter max-w-container-max mx-auto mb-24 md:mb-12">
{% if messages %}
<div class="mb-stack-md" aria-live="polite">
{% for message in messages %}
<div class="px-4 py-2 rounded mb-2 border border-outline-variant/30 bg-surface-container-highest">{{ message }}</div>
{% endfor %}
</div>
{% endif %}
<div class="flex flex-col md:flex-row md:items-end justify-between gap-6 mb-stack-lg">
<div>
<h2 class="font-headline-lg text-headline-lg text-primary tracking-tight">Bronbeheer</h2>
<p class="text-on-surface-variant font-body-md mt-1">Beheer en monitor de actieve scan-endpoints van jouw radarprofiel.</p>
</div>
<div class="flex gap-3">
<div class="glass-card px-4 py-2 rounded-xl flex flex-col">
<span class="text-label-mono text-[10px] text-on-surface-variant uppercase tracking-tighter">Actieve Bronnen</span>
<span class="font-headline-md text-secondary">{{ active_source_count }}</span>
</div>
<div class="glass-card px-4 py-2 rounded-xl flex flex-col">
<span class="text-label-mono text-[10px] text-on-surface-variant uppercase tracking-tighter">Systeemstatus</span>
<span class="font-headline-md text-primary flex items-center gap-2">
Gezond
<span class="w-2 h-2 rounded-full bg-secondary status-pulse"></span>
</span>
</div>
</div>
</div>
<section class="glass-card rounded-xl p-6 mb-stack-lg">
<h3 class="font-headline-md text-primary mb-4">Handmatige import</h3>
<p class="mb-4">Importeer een URL of plak vacaturetekst met dezelfde policy- en SSRF-controle als de backend.</p>
<form id="manual-import-form" method="post" action="{% url 'sources:manual_import' %}" class="grid grid-cols-1 md:grid-cols-2 gap-4" data-paste-limit="{{ manual_import_paste_max_bytes }}">
{% csrf_token %}
<label>
<span class="text-label-mono text-label-mono text-on-surface-variant mb-1 block">{{ manual_import_form.source_url.label }}</span>
{{ manual_import_form.source_url }}
{% for error in manual_import_form.source_url.errors %}
<small class="field-error block text-error">{{ error }}</small>
{% endfor %}
<small class="text-on-surface-variant">Vooraf ingevuld via bookmarklet; laat dit veld leeg wanneer je tekst plakt.</small>
</label>
<label>
<span class="text-label-mono text-label-mono text-on-surface-variant mb-1 block">{{ manual_import_form.pasted_text.label }}</span>
{{ manual_import_form.pasted_text }}
<small class="text-on-surface-variant">Maximaal <span id="manual-import-byte-count">{{ manual_import_paste_max_bytes }}</span> bytes.</small>
{% for error in manual_import_form.pasted_text.errors %}
<small class="field-error block text-error">{{ error }}</small>
{% endfor %}
</label>
<div class="md:col-span-2 flex flex-wrap gap-2">
<button class="bg-primary text-on-primary px-4 py-2 rounded" type="submit">Import uitvoeren</button>
<button id="copy-bookmarklet" class="border border-outline-variant px-4 py-2 rounded" type="button">Bookmarklet kopieren</button>
</div>
<a id="manual-bookmarklet" href="#" class="text-sm text-primary" data-manual-import-endpoint="{{ manual_import_bookmarklet_endpoint }}">
Bookmarklet: sleep naar bladwijzers
</a>
{% for error in manual_import_form.non_field_errors %}
<small class="field-error block text-error">{{ error }}</small>
{% endfor %}
</form>
<textarea id="manual-bookmarklet-code" rows="2" readonly class="sr-only">{{ manual_import_bookmarklet_endpoint }}</textarea>
</section>
{% if manual_import_result %}
<section class="glass-card rounded-xl p-6 mb-stack-lg">
<h3 class="font-headline-md text-primary mb-4">Importresultaat</h3>
<p class="mb-2"><strong>Bron:</strong> {{ manual_import_result.source_name }} ({{ manual_import_result.source_id }})</p>
<ul class="space-y-1 font-label-mono text-on-surface-variant text-xs mb-4">
<li>Modus: {{ manual_import_result.mode }}</li>
<li>URL: {{ manual_import_result.source_url }}</li>
<li>Herkenbare items: {{ manual_import_result.extracted_count }}</li>
<li>Nieuwe vacatures: {{ manual_import_result.created_count }}</li>
<li>Reeds bestaande vacatures: {{ manual_import_result.duplicate_count }}</li>
</ul>
{% if manual_import_result.warnings %}
<h4 class="font-label-mono text-label-mono uppercase text-xs mb-2">Waarschuwingen</h4>
<ul class="list-disc list-inside text-on-surface-variant text-xs mb-4">
{% for warning in manual_import_result.warnings %}
<li>{{ warning }}</li>
{% endfor %}
</ul>
{% endif %}
{% if manual_import_result.jobs %}
<h4 class="font-label-mono text-label-mono uppercase text-xs mb-2">Geïmporteerde vacatures</h4>
<ul class="space-y-1">
{% for job in manual_import_result.jobs %}
<li>
{% if job.id %}
<a class="text-primary" href="{% url 'jobs:detail' job.id %}">{{ job.title }}</a> — {{ job.employer }}
{% else %}
{{ job.title }} — {{ job.employer }}
{% endif %}
</li>
{% endfor %}
</ul>
{% endif %}
</section>
{% endif %}
<form
id="bulk-action-form"
method="post"
action="{% url 'sources:bulk' %}"
class="glass-card rounded-xl p-4 mb-stack-lg flex flex-wrap gap-3 items-center"
>
{% csrf_token %}
<label for="action" class="text-label-mono text-[11px] uppercase tracking-[0.04em] text-on-surface-variant">Bulkactie</label>
<select
id="action"
name="action"
class="bg-surface-container-low border border-outline-variant rounded-lg px-3 py-2 text-sm text-on-surface"
>
<option value="approve_to_trial">Kandidaten naar proefrun</option>
<option value="allow">Toestaan</option>
<option value="pause">Pauzeren</option>
<option value="dismiss">Afwijzen</option>
</select>
<button
class="bg-primary text-on-primary px-4 py-2 rounded-lg font-semibold border border-primary/30 shadow-sm"
type="submit"
>
Uitvoeren
</button>
</form>
<div class="grid grid-cols-1 gap-6 mb-stack-lg">
{% for source in sources %}
{% with latest_run=source.runs.all|first %}
<section class="glass-card rounded-xl overflow-hidden
{% if source.status == 'active' and source.policy != 'deny' and latest_run and latest_run.status == 'success' %}signal-glow-primary{% elif source.status == 'quarantined' or source.policy == 'deny' %}border-error/30{% elif latest_run and latest_run.status == 'failed' %}border-error/30{% elif latest_run and latest_run.status == 'partial' %}border-orange-500/20{% else %}border-surface-container-high{% endif %}
">
<div class="p-6 flex flex-col md:flex-row md:items-center justify-between gap-4">
<div class="flex items-start gap-4">
<div class="w-12 h-12 rounded-lg bg-surface-container-highest flex items-center justify-center border border-outline-variant/30">
<span class="material-symbols-outlined text-primary">
{% if source.source_type == 'email' %}mail{% elif source.source_type == 'rss' or source.source_type == 'sitemap' %}rss_feed{% elif source.source_type == 'ats' %}hub{% else %}web{% endif %}
</span>
</div>
<div>
<div class="flex items-center gap-2">
{% if source.policy != "deny" %}
<input type="checkbox" name="source_ids" form="bulk-action-form" value="{{ source.pk }}" aria-label="Selecteer {{ source.name }}">
{% endif %}
<h3 class="font-headline-md text-primary">{{ source.name }}</h3>
</div>
<div class="flex flex-wrap items-center gap-3 mt-1">
<span class="font-label-mono text-label-mono text-on-surface-variant">{{ source.get_source_type_display }}</span>
<span class="w-1 h-1 rounded-full bg-outline-variant"></span>
<span class="font-label-mono text-label-mono text-on-surface-variant">{{ source.domain|default:'Geen domein' }}</span>
{% if latest_run %}
<span class="font-label-mono text-label-mono text-on-surface-variant">Laatste scan: {{ latest_run.started_at|date:'d/m/Y H:i' }}</span>
{% else %}
<span class="font-label-mono text-label-mono text-on-surface-variant">Nog niet gestart</span>
{% endif %}
</div>
</div>
</div>
<div class="flex items-center gap-6">
<div class="text-right">
<span class="block text-[10px] font-label-mono text-on-surface-variant uppercase">Vacatures gevonden</span>
<span class="text-headline-md text-primary">{% if latest_run %}{{ latest_run.extracted_count }}{% else %}0{% endif %}</span>
</div>
<div class="px-3 py-1 rounded-full flex items-center gap-2
{% if source.policy == 'deny' or (latest_run and latest_run.status == 'failed') %}bg-error/10 border border-error/20{% elif source.status == 'quarantined' %}bg-error/10 border border-error/20{% elif latest_run and latest_run.status == 'partial' %}bg-orange-500/10 border border-orange-500/20{% else %}bg-secondary/10 border border-secondary/20{% endif %}">
<span class="w-2 h-2 rounded-full
{% if source.policy == 'deny' or (latest_run and latest_run.status == 'failed') %}bg-error{% elif latest_run and latest_run.status == 'partial' %}bg-orange-500{% else %}bg-secondary{% endif %}
"></span>
<span class="text-label-mono font-medium
{% if source.policy == 'deny' or (latest_run and latest_run.status == 'failed') %}text-error{% elif latest_run and latest_run.status == 'partial' %}text-orange-400{% else %}text-secondary{% endif %}">
{% if source.policy == "deny" %}
Geblokkeerd
{% elif latest_run and latest_run.status == 'failed' %}
Mislukt
{% elif latest_run and latest_run.status == 'partial' %}
Beperkt
{% elif source.status == 'quarantined' %}
Quarantaine
{% else %}
Gezond
{% endif %}
</span>
</div>
<button class="text-on-surface-variant hover:text-primary transition-colors" type="button" onclick="toggleDetails('details-{{ forloop.counter }}')">
<span class="material-symbols-outlined" id="icon-details-{{ forloop.counter }}">expand_more</span>
</button>
</div>
</div>
<div class="hidden border-t border-outline-variant/20 bg-surface-container-low/50" id="details-{{ forloop.counter }}">
<div class="p-6 grid grid-cols-1 md:grid-cols-2 gap-8">
<div>
<h4 class="text-label-mono text-[11px] text-on-surface-variant uppercase mb-3">Technische log</h4>
<div class="bg-surface-container-lowest p-4 rounded border border-outline-variant/30 font-label-mono text-xs leading-relaxed text-on-surface-variant/80 technical-log max-h-40 overflow-y-auto">
{% if latest_run %}
{% if latest_run.status == 'failed' and latest_run.error_message %}
[{% if latest_run.finished_at %}{{ latest_run.finished_at|date:"Y-m-d H:i:s" }}{% else %}--{% endif %}] ERROR: {{ latest_run.error_message }}
{% else %}
[{% if latest_run.finished_at %}{{ latest_run.finished_at|date:"Y-m-d H:i:s" }}{% else %}--{% endif %}] RUN {{ latest_run.get_status_display }}<br />
Extracted: {{ latest_run.extracted_count }} Created: {{ latest_run.created_count }} Updated: {{ latest_run.updated_count }} Duplicate: {{ latest_run.duplicate_count }}
{% if latest_run.http_status %}<br />HTTP {{ latest_run.http_status }}{% endif %}
{% endif %}
{% else %}
Nog geen bronrun beschikbaar.
{% endif %}
</div>
</div>
<div class="space-y-4">
<div>
<h4 class="text-label-mono text-[11px] text-on-surface-variant uppercase mb-2">Bronstatus</h4>
<p class="font-label-mono text-xs text-on-surface-variant/80">Policy: {{ source.get_policy_display }}</p>
<p class="font-label-mono text-xs text-on-surface-variant/80">Review: {{ source.policy_review_summary }}</p>
<p class="font-label-mono text-xs text-on-surface-variant/80">Volgende run: {{ source.next_run_at|date:'d/m/Y H:i'|default:'-' }}</p>
</div>
<div class="flex gap-2">
{% if source.policy != 'deny' %}
<form method="post" action="{% url 'sources:retry' source.pk %}">
{% csrf_token %}
<button class="bg-primary-container text-on-primary-container px-4 py-2 rounded font-bold text-sm" type="submit">Herstart scan</button>
</form>
{% else %}
<button class="bg-error text-surface px-4 py-2 rounded font-bold text-sm" type="button">Geblokkeerd</button>
{% endif %}
</div>
</div>
</div>
</div>
</section>
{% endwith %}
{% empty %}
<section class="glass-card rounded-xl p-6">
<p>Nog geen bronnen geregistreerd.</p>
</section>
{% endfor %}
</div>
<section class="mt-12 mb-24 md:mb-12">
<h3 class="font-headline-md text-primary mb-6">Bronstatus op overzicht</h3>
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
<div class="glass-card p-6 rounded-xl relative overflow-hidden h-64">
<h4 class="text-label-mono text-[11px] text-on-surface-variant uppercase mb-4">Bronnen geregistreerd</h4>
<div class="relative z-10 flex flex-col justify-end h-full">
<span class="text-headline-md text-primary">{{ source_total_count }}</span>
<span class="text-xs text-on-surface-variant font-label-mono">Totaal in beheer</span>
</div>
</div>
<div class="glass-card p-6 rounded-xl relative overflow-hidden h-64">
<h4 class="text-label-mono text-[11px] text-on-surface-variant uppercase mb-4">Te reviewen</h4>
<div class="relative z-10 flex flex-col justify-end h-full">
<span class="text-headline-md text-primary">{{ review_pending_count }}</span>
<span class="text-xs text-on-surface-variant font-label-mono">Policy review</span>
</div>
</div>
<div class="glass-card p-6 rounded-xl relative overflow-hidden h-64">
<h4 class="text-label-mono text-[11px] text-on-surface-variant uppercase mb-4">Systeemlogboek</h4>
<p class="text-xs text-on-surface-variant">Brongezondheid en technische events zijn zichtbaar op source-level kaarten.</p>
</div>
</div>
</section>
</main>
<nav class="md:hidden fixed bottom-0 left-0 w-full bg-surface-container border-t border-outline-variant/10 py-3 px-6 flex justify-between items-center z-50">
<button class="flex flex-col items-center gap-1 text-on-surface-variant">
<span class="material-symbols-outlined">dashboard</span>
<span class="text-[10px] font-label-mono">Overzicht</span>
</button>
<button class="flex flex-col items-center gap-1 text-primary">
<span class="material-symbols-outlined" style="font-variation-settings: 'FILL' 1;">hub</span>
<span class="text-[10px] font-label-mono">Bronnen</span>
</button>
<button class="flex flex-col items-center gap-1 text-on-surface-variant">
<span class="material-symbols-outlined">radar</span>
<span class="text-[10px] font-label-mono">Radar</span>
</button>
<button class="flex flex-col items-center gap-1 text-on-surface-variant">
<span class="material-symbols-outlined">settings</span>
<span class="text-[10px] font-label-mono">Instellingen</span>
</button>
</nav>
<script>
function toggleDetails(id) {
const el = document.getElementById(id);
const icon = document.getElementById('icon-' + id);
if (!el || !icon) {
return;
}
const isHidden = el.classList.contains('hidden');
document.querySelectorAll('[id^=\"details-\"]').forEach(item => {
if (item.id !== id) {
item.classList.add('hidden');
const otherIcon = document.getElementById('icon-' + item.id);
if (otherIcon) {
otherIcon.style.transform = 'rotate(0deg)';
}
}
});
if (isHidden) {
el.classList.remove('hidden');
icon.style.transform = 'rotate(180deg)';
icon.style.transition = 'transform 0.3s ease';
} else {
el.classList.add('hidden');
icon.style.transform = 'rotate(0deg)';
}
}
</script>
<script src="{% static 'js/manual_import.js' %}" defer></script>
</body>
</html>
+6
View File
@@ -0,0 +1,6 @@
{% extends "base.html" %}
{% block title %}Systeemstatus · VacatureRadar{% endblock %}
{% block content %}<header class="page-header"><div><p class="eyebrow">Beheer</p><h1>Systeemstatus</h1><p>Lokale healthchecks en aantallen; secrets worden nooit getoond.</p></div></header><section class="stats-grid"><article class="stat-card"><span>Readiness</span><strong>{% if health.ok %}OK{% else %}FOUT{% endif %}</strong><small>{{ health.checks.database }}</small></article>{% for row in source_summary %}<article class="stat-card"><span>Bronnen {{ row.status }}</span><strong>{{ row.total }}</strong></article>{% endfor %}{% for row in job_summary %}<article class="stat-card"><span>Vacatures {{ row.status }}</span><strong>{{ row.total }}</strong></article>{% endfor %}</section><section class="panel"><h2>Veilige beheeropdrachten</h2><pre><code>make verify
./scripts/backup.sh
./scripts/restore.sh /pad/naar/backup.sql.gz
uv run python manage.py seed_sources</code></pre></section>{% endblock %}