feat: replace pipeline profile and source health

This commit is contained in:
Jens
2026-07-22 20:10:34 +02:00
parent 3371455255
commit 4fa2a34447
14 changed files with 282 additions and 130 deletions
+5 -4
View File
@@ -1,7 +1,8 @@
{% extends "base.html" %}
{% block title %}Sollicitatiedossier · VacatureRadar{% endblock %}
{% block title %}Dossier · {{ object.job.original_title }}{% endblock %}
{% block content %}
<header class="page-header"><div><p class="eyebrow">Sollicitatiedossier</p><h1>{{ object.job.original_title }}</h1><p>{{ object.job.employer_name }} · alleen zichtbaar voor jouw account</p></div><div class="page-actions"><a class="button button-ghost" href="{% url 'jobs:application-print' object.pk %}" target="_blank" rel="noopener">Afdrukweergave</a><a class="button button-secondary" href="{% url 'jobs:application-export' object.pk %}">Exporteren</a></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">Dossier opslaan</button></div></form>
<section class="panel panel-subtle"><h2>Privacy en verwijderen</h2><p class="muted">De export bevat de opgeslagen snapshot en tijdlijn. Verwijderen wist alleen jouw dossier; de onderliggende vacature blijft behouden.</p><form method="post" action="{% url 'jobs:application-delete' object.pk %}">{% csrf_token %}<button class="button button-danger" type="submit">Dossier verwijderen</button></form></section>
<a class="back-link" href="{% url 'jobs:applications' %}">← Terug naar de pipeline</a>
<header class="detail-hero application-hero"><div><p class="eyebrow">Sollicitatiedossier · {{ object.get_status_display }}</p><h1>{{ object.job.original_title }}</h1><p class="detail-employer">{{ object.job.employer_name }}</p><div class="job-meta"><span>{{ object.job.raw_location|default:"Locatie onbekend" }}</span>{% if object.applied_at %}<span>gestart {{ object.applied_at|date:"d/m/Y" }}</span>{% endif %}{% if object.follow_up_date %}<span>opvolging {{ object.follow_up_date|date:"d/m/Y" }}</span>{% endif %}</div></div><div class="page-actions"><a class="button button-ghost" href="{% url 'jobs:application-print' object.pk %}" target="_blank" rel="noopener">Afdrukweergave</a><a class="button button-secondary" href="{% url 'jobs:application-export' object.pk %}">Exporteren</a></div></header>
<div class="application-editor-grid"><form method="post" class="form-panel">{% csrf_token %}<span class="section-kicker">Dossiergegevens</span><h2>Opvolging en contact</h2>{% 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 == 'notes' %}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 'jobs:applications' %}">Annuleren</a><button class="button button-primary" type="submit">Dossier opslaan</button></div></form><aside class="application-timeline panel"><span class="section-kicker">Audit trail</span><h2>Activiteit</h2><div class="timeline-compact">{% for event in object.timeline_events.all %}<article><span class="timeline-icon"><svg class="icon"><use href="#icon-activity"></use></svg></span><div><strong>{{ event.get_event_type_display }}</strong><time datetime="{{ event.created_at|date:'c' }}">{{ event.created_at|date:"d/m/Y H:i" }}</time>{% if event.metadata.from or event.metadata.to %}<small>{{ event.metadata.from|default:"—" }} → {{ event.metadata.to|default:"—" }}</small>{% endif %}</div></article>{% empty %}<p class="muted">Nog geen tijdlijngebeurtenis.</p>{% endfor %}</div></aside></div>
<section class="panel danger-zone"><div><span class="section-kicker">Privacy</span><h2>Dossier verwijderen</h2><p class="muted">Verwijderen wist alleen jouw dossier en tijdlijn. De onderliggende vacature blijft in de radar.</p></div><form method="post" action="{% url 'jobs:application-delete' object.pk %}">{% csrf_token %}<button class="button button-danger" type="submit">Dossier verwijderen</button></form></section>
{% endblock %}
+11 -14
View File
@@ -1,18 +1,15 @@
{% extends "base.html" %}
{% block title %}Sollicitaties · VacatureRadar{% endblock %}
{% block title %}Sollicitatiepipeline · VacatureRadar{% endblock %}
{% block content %}
<header class="page-header pipeline-page-header"><div><p class="eyebrow">Persoonlijke opvolging</p><h1>Sollicitatiepipeline</h1><p>Beheer je actieve trajecten, deadlines en bewaarde vacaturesnapshots.</p></div><div class="page-actions"><span class="view-indicator"><svg class="icon"><use href="#icon-pipeline"></use></svg>Kanban</span><a class="button button-primary" href="{% url 'jobs:list' %}"><svg class="icon"><use href="#icon-search"></use></svg>Vacature toevoegen</a></div></header>
<section class="pipeline" aria-label="Sollicitaties per status">
{% for column in pipeline_columns %}
<div class="pipeline-column">
<div class="pipeline-head"><h2><span class="column-dot" aria-hidden="true"></span>{{ column.label }}</h2><span class="pipeline-count">{{ column.applications|length }}</span></div>
<div class="pipeline-cards">
{% for application in column.applications %}
<article class="pipeline-card"><span class="pill pill-{{ application.status }}">{{ application.get_status_display }}</span><h3><a href="{% url 'jobs:detail' application.job.pk %}">{{ application.job.original_title }}</a></h3><p>{{ application.job.employer_name }}{% if application.job.raw_location %} · {{ application.job.raw_location }}{% endif %}</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 class="job-actions"><a class="button button-ghost button-small" href="{% url 'jobs:application-edit' application.pk %}">Dossier <svg class="icon"><use href="#icon-arrow"></use></svg></a></div></article>
{% empty %}<div class="pipeline-empty">Geen dossiers in deze fase.</div>{% endfor %}
</div>
</div>
{% endfor %}
<header class="cockpit-heading"><div><p class="eyebrow">Persoonlijke opvolging</p><h1>Sollicitatiepipeline</h1><p>Beheer je actieve trajecten, opvolging en dossierstatus. Iedere statuswijziging wordt in de tijdlijn vastgelegd.</p></div><div class="page-actions"><div class="view-toggle" data-view-controls aria-label="Weergave"><button type="button" data-view-switch="kanban" aria-pressed="true"><svg class="icon"><use href="#icon-pipeline"></use></svg>Kanban</button><button type="button" data-view-switch="list" aria-pressed="false"><svg class="icon"><use href="#icon-list"></use></svg>Lijst</button></div><a class="button button-primary" href="{% url 'jobs:list' %}"><svg class="icon"><use href="#icon-search"></use></svg>Vacature zoeken</a></div></header>
<section class="pipeline-summary" aria-label="Pipeline kerncijfers"><article><span class="field-label">Alle dossiers</span><strong>{{ applications|length }}</strong></article><article><span class="field-label">Actief</span><strong>{{ open_count }}</strong></article><article><span class="field-label">Opvolging nu</span><strong>{{ follow_up_count }}</strong></article></section>
{% if applications %}
<section class="pipeline-board" data-view="kanban" aria-label="Sollicitaties per status">
{% for column in pipeline_columns %}<div class="pipeline-column"><div class="pipeline-column-header"><span>{{ column.label }}</span><span class="pill">{{ column.applications|length }}</span></div><div>{% for application in column.applications %}<article class="application-card"><div class="title-row"><span class="pill pill-{{ application.status }}">{{ application.get_status_display }}</span>{% if application.follow_up_date and application.follow_up_date <= today %}<span class="pill pill-failed">Opvolgen</span>{% endif %}</div><h3><a href="{% url 'jobs:detail' application.job.pk %}">{{ application.job.original_title }}</a></h3><p class="muted">{{ application.job.employer_name }}{% if application.job.raw_location %} · {{ application.job.raw_location }}{% endif %}</p><div class="job-meta">{% if application.applied_at %}<span><svg class="icon"><use href="#icon-mail"></use></svg>{{ application.applied_at|date:"d/m/Y" }}</span>{% endif %}{% if application.follow_up_date %}<span><svg class="icon"><use href="#icon-calendar"></use></svg>{{ application.follow_up_date|date:"d/m/Y" }}</span>{% endif %}{% if application.contact_name %}<span>{{ application.contact_name }}</span>{% endif %}</div>{% if application.notes %}<p class="application-note">{{ application.notes|truncatewords:18 }}</p>{% endif %}<form method="post" action="{% url 'jobs:application-status' application.pk %}" class="status-form">{% csrf_token %}<label><span class="sr-only">Status voor {{ application.job.original_title }}</span><select name="status">{% for value,label in status_choices %}<option value="{{ value }}" {% if value == application.status %}selected{% endif %}>{{ label }}</option>{% endfor %}</select></label><button class="button button-secondary" type="submit">Bijwerken</button></form><a class="application-link" href="{% url 'jobs:application-edit' application.pk %}">Open dossier <svg class="icon"><use href="#icon-arrow"></use></svg></a></article>{% empty %}<div class="pipeline-empty">Geen dossier in deze fase.</div>{% endfor %}</div></div>{% endfor %}
</section>
{% if not applications %}<div class="empty-state compact-empty"><div class="empty-icon"><svg class="icon"><use href="#icon-pipeline"></use></svg></div><h2>Nog geen sollicitatiedossiers</h2><p>Markeer een vacature als Gesolliciteerd om automatisch een persoonlijk dossier te maken.</p><a class="button button-primary" href="{% url 'jobs:list' %}">Vacatures bekijken</a></div>{% endif %}
<section class="panel" data-view="list" hidden aria-label="Sollicitaties als lijst"><div class="data-table-wrap" tabindex="0"><table class="data-table"><thead><tr><th>Vacature</th><th>Status</th><th>Opvolging</th><th>Contact</th><th>Bijgewerkt</th><th><span class="sr-only">Actie</span></th></tr></thead><tbody>{% for application in applications %}<tr><td><strong>{{ application.job.original_title }}</strong><small>{{ application.job.employer_name }}</small></td><td><span class="pill pill-{{ application.status }}">{{ application.get_status_display }}</span></td><td>{{ application.follow_up_date|date:"d/m/Y"|default:"Niet gepland" }}</td><td>{{ application.contact_name|default:"Onbekend" }}</td><td>{{ application.updated_at|date:"d/m/Y H:i" }}</td><td><a class="button button-ghost" href="{% url 'jobs:application-edit' application.pk %}">Dossier</a></td></tr>{% endfor %}</tbody></table></div></section>
{% else %}<div class="empty-state"><div class="empty-icon"><svg class="icon"><use href="#icon-pipeline"></use></svg></div><h2>Nog geen sollicitatiedossiers</h2><p>Markeer een relevante vacature als gesolliciteerd of start een dossier vanuit het detail.</p><a class="button button-primary" href="{% url 'jobs:list' %}">Nieuwe matches bekijken</a></div>{% endif %}
{% endblock %}
+16 -15
View File
@@ -1,21 +1,22 @@
{% extends "base.html" %}
{% block title %}{{ object.name }} bewerken · VacatureRadar{% endblock %}
{% block title %}Configureer je radar · VacatureRadar{% endblock %}
{% block content %}
<header class="page-header"><div><p class="eyebrow">Zoekprofiel · versie {{ object.version }}</p><h1>Configureer je radar</h1><p>Verfijn {{ object.name }} zodat alleen relevante, uitlegbare kansen bovenaan verschijnen.</p></div><span class="pill {% if object.is_active %}pill-active{% endif %}">{% if object.is_active %}Actief profiel{% else %}Inactief profiel{% endif %}</span></header>
<div class="editor-layout">
<form id="profile-form" method="post" class="panel form-panel profile-editor">{% csrf_token %}
<header class="cockpit-heading"><div><p class="eyebrow">Zoekprofiel · versie {{ object.version }}</p><h1>Configureer je radar</h1><p>Stuur rollen, technologiesignalen en harde grenzen met echte selecties. Ervaringsduur blijft uitsluitend informatieve context.</p></div><span class="pill {% if object.is_active %}pill-success{% endif %}">{% if object.is_active %}Live profiel{% else %}Gepauzeerd{% endif %}</span></header>
<div class="profile-editor-layout">
<form id="profile-form" method="post" class="radar-configurator">{% 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 %}
{% if field.name == 'name' %}<div class="form-section"><span class="form-section-icon"><svg class="icon"><use href="#icon-profile"></use></svg></span><div><h2>Profielbasis</h2><p>Je postcode volstaat voor de afstand. De ervaringsband is alleen profielcontext en heeft geen invloed op score of uitsluiting.</p></div></div>{% elif field.name == 'desired_titles' %}<div class="form-section"><span class="form-section-icon"><svg class="icon"><use href="#icon-briefcase"></use></svg></span><div><h2>Gewenste rollen & technologie</h2><p>Kies per categorie wat echt bij je profiel past. De beste titelmatch en maximaal vier aangetroffen skills bepalen de inhoudelijke aansluiting.</p></div></div>{% elif field.name == 'allowed_employment_types' %}<div class="form-section"><span class="form-section-icon"><svg class="icon"><use href="#icon-location"></use></svg></span><div><h2>Werkvorm, regio & reistijd</h2><p>Selecteer contractvormen, werkmodellen en regio's zonder technische notatie.</p></div></div>{% elif field.name == 'recommendation_threshold' %}<div class="form-section"><span class="form-section-icon"><svg class="icon"><use href="#icon-radar"></use></svg></span><div><h2>Scoring & automatisering</h2><p>Een topmatch ligt altijd boven de gewone aanbevelingsdrempel.</p></div></div>{% endif %}
{% if field.field.widget.allow_multiple_selected %}<fieldset class="choice-field span-2"><legend>{{ field.label }}</legend>{{ field }}{% if field.help_text %}<small>{{ field.help_text }}</small>{% endif %}{% for error in field.errors %}<small class="field-error">{{ error }}</small>{% endfor %}</fieldset>{% else %}<label><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>{% endif %}
{% endfor %}
</div>
<div class="form-actions"><a class="button button-ghost" href="{% url 'profiles:list' %}">Annuleren</a><button class="button button-primary" type="submit">Instellingen opslaan</button></div>
<section class="form-section" id="basis"><div class="form-section-heading"><span class="source-icon"><svg class="icon"><use href="#icon-profile"></use></svg></span><div><span class="section-kicker">01 · Radaridentiteit</span><h2>Profielbasis</h2><p>Je postcode volstaat; gemeente en coördinaten worden uit de lokale gelicenseerde dataset afgeleid.</p></div></div><div class="form-grid"><label><span>{{ form.name.label }}</span>{{ form.name }}{% for error in form.name.errors %}<small class="field-error">{{ error }}</small>{% endfor %}</label><label class="toggle-field">{{ form.is_active }}<span>{{ form.is_active.label }}</span></label><label><span>{{ form.home_postal_code.label }}</span>{{ form.home_postal_code }}<small>{{ form.home_postal_code.help_text }}</small>{% for error in form.home_postal_code.errors %}<small class="field-error">{{ error }}</small>{% endfor %}</label><label><span>{{ form.max_distance_km.label }}</span>{{ form.max_distance_km }}{% for error in form.max_distance_km.errors %}<small class="field-error">{{ error }}</small>{% endfor %}</label><label class="span-2"><span>{{ form.experience_years.label }}</span>{{ form.experience_years }}<small>{{ form.experience_years.help_text }}</small></label></div></section>
<section class="form-section" id="rollen"><div class="form-section-heading"><span class="source-icon"><svg class="icon"><use href="#icon-briefcase"></use></svg></span><div><span class="section-kicker">02 · Functierichting</span><h2>Gewenste en uitgesloten rollen</h2><p>Selecteer alle relevante richtingen; een uitgesloten titel blijft een harde grens.</p></div></div><fieldset class="choice-field"><legend>{{ form.desired_titles.label }}</legend>{{ form.desired_titles }}<small>{{ form.desired_titles.help_text }}</small>{% for error in form.desired_titles.errors %}<small class="field-error">{{ error }}</small>{% endfor %}</fieldset><details class="config-disclosure"><summary>Uitgesloten functietitels <svg class="icon"><use href="#icon-chevron"></use></svg></summary><fieldset class="choice-field"><legend class="sr-only">{{ form.excluded_titles.label }}</legend>{{ form.excluded_titles }}<small>{{ form.excluded_titles.help_text }}</small>{% for error in form.excluded_titles.errors %}<small class="field-error">{{ error }}</small>{% endfor %}</fieldset></details></section>
<section class="form-section" id="technologie"><div class="form-section-heading"><span class="source-icon"><svg class="icon"><use href="#icon-chart"></use></svg></span><div><span class="section-kicker">03 · Technologieën & capability</span><h2>Technologiesignalen</h2><p>Een skill telt alleen wanneer de vacaturetekst een expliciet signaal bevat. Meer gekozen cv-skills verwateren de score niet onbeperkt.</p></div></div><fieldset class="choice-field"><legend>{{ form.desired_skills.label }}</legend>{{ form.desired_skills }}<small>{{ form.desired_skills.help_text }}</small>{% for error in form.desired_skills.errors %}<small class="field-error">{{ error }}</small>{% endfor %}</fieldset><details class="config-disclosure"><summary>Harde skilluitsluitingen <svg class="icon"><use href="#icon-chevron"></use></svg></summary><fieldset class="choice-field"><legend class="sr-only">{{ form.excluded_skills.label }}</legend>{{ form.excluded_skills }}{% for error in form.excluded_skills.errors %}<small class="field-error">{{ error }}</small>{% endfor %}</fieldset></details></section>
<section class="form-section" id="werkvorm"><div class="form-section-heading"><span class="source-icon"><svg class="icon"><use href="#icon-location"></use></svg></span><div><span class="section-kicker">04 · Regio & organisatie</span><h2>Werkvorm en bereik</h2><p>Contract, werkmodel en regio worden als expliciete selecties opgeslagen.</p></div></div><div class="preference-grid"><fieldset class="choice-field"><legend>{{ form.allowed_employment_types.label }}</legend>{{ form.allowed_employment_types }}</fieldset><fieldset class="choice-field"><legend>{{ form.preferred_workplace.label }}</legend>{{ form.preferred_workplace }}</fieldset><fieldset class="choice-field"><legend>{{ form.preferred_regions.label }}</legend>{{ form.preferred_regions }}</fieldset><fieldset class="choice-field"><legend>{{ form.excluded_regions.label }}</legend>{{ form.excluded_regions }}{% for error in form.excluded_regions.errors %}<small class="field-error">{{ error }}</small>{% endfor %}</fieldset></div></section>
<section class="form-section" id="automatisering"><div class="form-section-heading"><span class="source-icon"><svg class="icon"><use href="#icon-system"></use></svg></span><div><span class="section-kicker">05 · Rangschikking</span><h2>Drempels & samenvatting</h2><p>De topmatchdrempel ligt boven de aanbevelingsdrempel. Harde regels winnen altijd van de score.</p></div></div><div class="form-grid"><label><span>{{ form.recommendation_threshold.label }}</span>{{ form.recommendation_threshold }}{% for error in form.recommendation_threshold.errors %}<small class="field-error">{{ error }}</small>{% endfor %}</label><label><span>{{ form.top_match_threshold.label }}</span>{{ form.top_match_threshold }}{% for error in form.top_match_threshold.errors %}<small class="field-error">{{ error }}</small>{% endfor %}</label><label><span>{{ form.digest_time.label }}</span>{{ form.digest_time }}</label><label class="toggle-field">{{ form.learning_enabled }}<span>{{ form.learning_enabled.label }}</span><small>{{ form.learning_enabled.help_text }}</small></label></div></section>
<div class="form-actions profile-savebar"><a class="button button-ghost" href="{% url 'profiles:list' %}">Annuleren</a><button class="button button-primary" type="submit">Instellingen opslaan</button></div>
</form>
<aside class="editor-rail" aria-label="Profielsamenvatting">
<section class="panel radar-preview"><div class="title-row"><span class="section-kicker">Radarprofiel</span><span class="pill {% if object.is_active %}pill-active{% endif %}">{% if object.is_active %}Live{% else %}Pauze{% endif %}</span></div><h2>{{ object.name }}</h2><p class="muted">De huidige opgeslagen configuratie.</p><div class="preview-score"><strong>{{ object.top_match_threshold }}%</strong><span>Topmatch vanaf</span></div><div class="rail-list"><div class="rail-item"><span>Thuislocatie</span><strong>{% if object.home_postal_code %}{{ object.home_postal_code }}{% if object.home_municipality %} · {{ object.home_municipality }}{% endif %}{% else %}Niet ingesteld{% endif %}</strong></div><div class="rail-item"><span>IT-ervaring · informatief</span><strong>{{ object.get_experience_years_display }}</strong></div><div class="rail-item"><span>Gewenste rollen</span><strong>{{ object.desired_titles|length }}</strong></div><div class="rail-item"><span>Gewenste skills</span><strong>{{ object.desired_skills|length }}</strong></div><div class="rail-item"><span>Maximumafstand</span><strong>{{ object.max_distance_km }} km</strong></div><div class="rail-item"><span>Gecontroleerd leren</span><strong>{% if object.learning_enabled %}Aan{% else %}Uit{% endif %}</strong></div></div></section>
<section class="panel rail-panel"><span class="section-kicker">Veilige grenzen</span><h2>Deterministisch eerst</h2><p class="muted">Harde uitsluitingen en bronbeleid veranderen niet door AI of feedback.</p><button class="button button-primary button-block" type="submit" form="profile-form">Instellingen opslaan</button></section>
</aside>
<aside class="profile-preview" aria-label="Radar preview"><section class="panel preview-panel"><div class="title-row"><span class="section-kicker">Radar preview</span><span class="pill {% if object.is_active %}pill-success{% endif %}">{% if object.is_active %}Live{% else %}Pauze{% endif %}</span></div>{% if preview_score %}{% widthratio preview_score.confidence 1 100 as preview_confidence %}<div class="preview-vacancy"><span class="field-label">Laatste echte beoordeling</span><h2>{{ preview_score.job.original_title }}</h2><p>{{ preview_score.job.employer_name }}</p><div class="intelligence-score"><strong>{{ preview_score.score|floatformat:0 }}%</strong><span>{{ preview_score.get_recommendation_display }}<small>Datakwaliteit {{ preview_confidence }}%</small></span></div>{% if preview_score.positives %}<ul class="compact-list positive">{% for item in preview_score.positives|slice:":3" %}<li>{{ item }}</li>{% endfor %}</ul>{% endif %}<a class="button button-ghost button-block" href="{% url 'jobs:detail' preview_score.job.pk %}">Bekijk onderbouwing</a></div>{% else %}<div class="empty-state compact-empty"><h2>Nog geen echte preview</h2><p>Na de volgende profielscore verschijnt hier een recente vacature, zonder demo-data.</p></div>{% endif %}<div class="rail-list"><div class="rail-item"><span>Postcode</span><strong>{{ object.home_postal_code|default:"—" }}</strong></div><div class="rail-item"><span>Rollen</span><strong>{{ object.desired_titles|length }}</strong></div><div class="rail-item"><span>Skills</span><strong>{{ object.desired_skills|length }}</strong></div><div class="rail-item"><span>Radius</span><strong>{{ object.max_distance_km }} km</strong></div></div><button class="button button-primary button-block" type="submit" form="profile-form">Radar opslaan</button></section><section class="panel"><span class="section-kicker">Revisies</span><h2>Configuratiehistorie</h2><div class="version-list">{% for revision in recent_revisions %}<article><time>{{ revision.created_at|date:"d/m/Y H:i" }}</time><span>Versie {{ revision.version }} · {{ revision.reason|default:"profielwijziging" }}</span></article>{% empty %}<p class="muted">Nog geen profielrevisie opgeslagen.</p>{% endfor %}</div></section></aside>
</div>
{% endblock %}
+2 -5
View File
@@ -1,6 +1,3 @@
{% extends "base.html" %}
{% block title %}Zoekprofielen · VacatureRadar{% endblock %}
{% block content %}
<header class="page-header"><div><p class="eyebrow">Persoonlijke regels</p><h1>Zoekprofiel</h1><p>Harde grenzen veranderen nooit automatisch; zachte voorkeuren kunnen alleen gecontroleerd leren.</p></div></header>
<div class="card-list">{% for profile in profiles %}<article class="panel profile-card {% if profile.is_active %}strong-match{% endif %}"><div><div class="title-row"><span class="pill {% if profile.is_active %}pill-active{% endif %}">{% if profile.is_active %}Actief{% else %}Inactief{% endif %}</span><h2>{{ profile.name }}</h2></div><p class="muted">{{ profile.desired_titles|join:", "|default:"Nog geen functietitels" }}</p><div class="job-meta"><span><svg class="icon"><use href="#icon-location"></use></svg>{% if profile.home_postal_code %}{{ profile.home_postal_code }}{% if profile.home_municipality %} · {{ profile.home_municipality }}{% endif %}{% else %}geen thuispostcode{% endif %}</span><span>max. {{ profile.max_distance_km }} km</span><span><svg class="icon"><use href="#icon-clock"></use></svg>digest {{ profile.digest_time|time:"H:i" }}</span><span>versie {{ profile.version }}</span><span>leren {% if profile.learning_enabled %}aan{% else %}uit{% endif %}</span></div></div><div class="job-actions"><a class="button button-primary" href="{% url 'profiles:edit' profile.pk %}">Profiel 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"><div class="empty-icon"><svg class="icon"><use href="#icon-profile"></use></svg></div><h2>Geen profiel beschikbaar</h2><p>Voer de bootstrapopdracht uit om het standaardprofiel aan te maken.</p></div>{% endfor %}</div>
{% endblock %}
{% block title %}Zoekprofiel · VacatureRadar{% endblock %}
{% block content %}<header class="cockpit-heading"><div><p class="eyebrow">Persoonlijke radar</p><h1>Zoekprofiel</h1><p>Je harde grenzen blijven deterministisch; ervaringsduur is alleen context en nooit een scorefactor.</p></div></header><div class="profile-index-grid">{% for profile in profiles %}<article class="profile-index-card {% if profile.is_active %}strong-match{% endif %}"><div class="profile-index-head"><span class="source-icon"><svg class="icon"><use href="#icon-profile"></use></svg></span><span class="pill {% if profile.is_active %}pill-success{% endif %}">{% if profile.is_active %}Live{% else %}Pauze{% endif %}</span></div><h2>{{ profile.name }}</h2><p class="muted">{{ profile.desired_titles|slice:":3"|join:", "|default:"Nog geen rollen geselecteerd" }}</p><dl class="intel-facts"><div><dt>Postcode</dt><dd>{{ profile.home_postal_code|default:"—" }}</dd></div><div><dt>Radius</dt><dd>{{ profile.max_distance_km }} km</dd></div><div><dt>Skills</dt><dd>{{ profile.desired_skills|length }}</dd></div><div><dt>Versie</dt><dd>{{ profile.version }}</dd></div></dl><div class="page-actions"><a class="button button-primary" href="{% url 'profiles:edit' profile.pk %}">Radar configureren</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 beschikbaar</h2><p>Initialiseer het persoonlijke profiel via de bootstrapopdracht.</p></div>{% endfor %}</div>{% endblock %}
+23 -85
View File
@@ -1,98 +1,36 @@
{% extends "base.html" %}
{% load static %}
{% block title %}Bronnen · VacatureRadar{% endblock %}
{% block title %}Bronnen & gezondheid · VacatureRadar{% endblock %}
{% block content %}
<header class="page-header"><div><p class="eyebrow">Bronbeheer & gezondheid</p><h1>Bronnen</h1><p>Beheer scan-endpoints, policyreviews en handmatige import binnen dezelfde SSRF- en bronregels.</p></div><a class="button button-ghost" href="{% url 'dashboard:system' %}"><svg class="icon"><use href="#icon-system"></use></svg>Systeemstatus</a></header>
<header class="cockpit-heading"><div><p class="eyebrow">Bronbeheer & gezondheid</p><h1>Scan-endpoints</h1><p>Operationele status, bronbeleid en imports in één controlepaneel. Iedere netwerkactie passeert policy- en SSRF-validatie.</p></div><div class="page-actions"><a class="button button-ghost" href="{% url 'dashboard:system' %}"><svg class="icon"><use href="#icon-system"></use></svg>Automation</a><a class="button button-primary" href="#manual-import"><svg class="icon"><use href="#icon-upload"></use></svg>Importeren</a></div></header>
<section class="source-overview" aria-label="Bronoverzicht">
<article class="stat-card signal"><span class="kpi-label">Actieve bronnen</span><strong>{{ active_source_count }}</strong><small>van {{ source_total_count }} geregistreerd</small></article>
<article class="stat-card"><span class="kpi-label">Werkgeverswaaklijst</span><strong>{{ watchlist_count }}</strong><small>interessante regionale werkgevers</small></article>
<article class="stat-card"><span class="kpi-label">Veilige modus</span><strong>AAN</strong><small>policy + SSRF-validatie</small></article>
</section>
<section class="stats-grid" aria-label="Brongezondheid"><article class="stat-card signal"><span class="kpi-label">Actieve bronnen</span><strong>{{ active_source_count }}</strong><small>van {{ source_total_count }} zichtbare endpoints</small></article><article class="stat-card"><span class="kpi-label">Aandacht nodig</span><strong>{{ failed_source_count }}</strong><small>foutteller of quarantaine</small></article><article class="stat-card"><span class="kpi-label">Policyreview</span><strong>{{ review_pending_count }}</strong><small>wacht op expliciete beoordeling</small></article><article class="stat-card"><span class="kpi-label">Platformmailboxen</span><strong>{{ mailbox_connections|length }}</strong><small>{% if platform_alert_ingress_ready %}minstens één import actief{% else %}geen actieve ingress{% endif %}</small></article></section>
<section class="panel" aria-labelledby="regional-scope-title">
<div class="section-heading"><div><span class="section-kicker">Regionale werkgeversradar</span><h2 id="regional-scope-title">2400 Mol · straal 40 km</h2><p>De werkgeverscatalogus is rond Mol gereviewd. Afstanden gebruiken lokale postcodecoördinaten en blijven afhankelijk van de concrete werklocatie in de vacature.</p></div><span class="pill {% if active_search_profile.home_latitude != None and active_search_profile.home_longitude != None %}pill-success{% else %}pill-review{% endif %}">{% if active_search_profile.home_latitude != None and active_search_profile.home_longitude != None %}Afstanden actief{% else %}Profiel controleren{% endif %}</span></div>
{% if active_search_profile %}<p class="muted">Actief profiel: postcode {{ active_search_profile.home_postal_code|default:"niet ingevuld" }} · {{ active_search_profile.home_municipality|default:"gemeente onbekend" }} · maximaal {{ active_search_profile.max_distance_km }} km.{% if active_search_profile.home_latitude == None or active_search_profile.home_longitude == None %} Coördinaten ontbreken nog; vacatureafstanden worden niet geschat.{% else %} Postcodecentrum gecontroleerd via de lokale GeoNames-dataset.{% endif %}</p>{% endif %}
</section>
{% if watchlist_sources %}<section class="panel" aria-labelledby="employer-watchlist-title">
<div class="section-heading"><div><span class="section-kicker">Ook volgen zonder vacature</span><h2 id="employer-watchlist-title">Interessante werkgeverswaaklijst</h2><p>Deze werkgevers liggen in de ruime regio en blijven zichtbaar, ook wanneer er nu geen geschikte vacature is. Ze worden pas automatisch gescand na een stabiele, veilige parserproef.</p></div><span class="pill pill-review">{{ watchlist_count }} gevolgd</span></div>
<div class="source-alert-grid">
{% for source in watchlist_sources %}<article class="source-alert-card"><div><div class="title-row"><strong>{{ source.name }}</strong><span class="pill pill-review">Waaklijst</span></div><p>{{ source.metadata.municipality_scope }} · {{ source.metadata.watchlist_reason }}</p><small>{{ source.metadata.current_observation }}</small></div><a class="button button-secondary" href="{{ source.metadata.public_job_page|default:source.base_url }}" target="_blank" rel="noopener noreferrer">Open carrièrepagina</a></article>{% endfor %}
</div>
</section>{% endif %}
<section class="panel" aria-labelledby="platform-alerts-title">
<div class="section-heading"><div><span class="section-kicker">Brede marktdekking</span><h2 id="platform-alerts-title">Platformmailboxen</h2><p>Koppel per jobboard een aparte alertmailbox. De scheduler controleert elke vijf minuten welke mailbox volgens haar eigen interval aan de beurt is.</p></div><span class="pill {% if platform_alert_ingress_ready %}pill-success{% else %}pill-review{% endif %}">{{ mailbox_connections|length }} gekoppeld</span></div>
{% if not mailbox_credential_key_ready %}<p class="message error">Credentialopslag is nog vergrendeld. Stel eerst <code>MAILBOX_CREDENTIAL_KEYS</code> in; app-wachtwoorden worden nooit onversleuteld opgeslagen.</p>{% endif %}
<div class="source-alert-grid">
{% for connection in mailbox_connections %}
<article class="source-alert-card">
<div><div class="title-row"><strong>{{ connection.get_platform_display }}-mailbox</strong><span class="pill {% if connection.enabled and not connection.last_error_category %}pill-success{% elif connection.last_error_category %}pill-failed{% else %}pill-review{% endif %}">{% if connection.enabled %}{% if connection.last_error_category %}Aandacht nodig{% else %}Actief{% endif %}{% else %}Gepauzeerd{% endif %}</span></div>
<p>{{ connection.get_provider_display }} · {{ connection.username }} · {{ connection.get_poll_interval_minutes_display|lower }}</p>
<small>{% if connection.last_success_at %}Laatste import {{ connection.last_success_at|date:"d/m/Y H:i" }}.{% else %}Nog geen geslaagde import.{% endif %} {% if connection.enabled %}Volgende controle {{ connection.next_poll_at|date:"d/m/Y H:i" }}.{% endif %}</small>
{% if connection.last_error_message %}<p class="message error">{{ connection.last_error_message }}</p>{% endif %}</div>
<div class="job-actions">{% if connection.enabled %}<form method="post" action="{% url 'sources:sync_platform_alerts' connection.pk %}">{% csrf_token %}<button class="button button-primary" type="submit">Nu synchroniseren</button></form>{% endif %}<a class="button button-secondary" href="{% url 'sources:mailbox_edit' connection.pk %}#mailbox-form">Wijzigen</a><form method="post" action="{% url 'sources:mailbox_toggle' connection.pk %}">{% csrf_token %}<button class="button button-ghost" type="submit">{% if connection.enabled %}Pauzeren{% else %}Activeren{% endif %}</button></form></div>
<section class="source-console" aria-labelledby="source-list-title">
<div class="section-heading"><div><span class="section-kicker">Live bronregister</span><h2 id="source-list-title">Brongezondheid</h2><p>De status komt uit de bronconfiguratie en meest recente echte run.</p></div><form id="bulk-action-form" method="post" action="{% url 'sources:bulk' %}" class="inline-bulk-action">{% csrf_token %}<label><span class="sr-only">Bulkactie</span><select name="action"><option value="approve_to_trial">Naar proefrun</option><option value="allow">Toestaan</option><option value="pause">Pauzeren</option><option value="dismiss">Afwijzen</option></select></label><button class="button button-secondary" type="submit">Uitvoeren</button></form></div>
<div class="source-health-list">
{% for source in sources %}{% with latest_run=source.latest_runs.0 %}
<article class="source-health-row">
<div class="source-health-summary"><div class="source-identity"><span class="source-icon"><svg class="icon"><use href="#icon-sources"></use></svg></span><div><div class="title-row">{% if source.policy != 'deny' %}<input type="checkbox" name="source_ids" form="bulk-action-form" value="{{ source.pk }}" aria-label="Selecteer {{ source.name }}">{% endif %}<h2>{{ source.name }}</h2></div><p>{{ source.get_source_type_display }} · {{ source.domain|default:"Geen domein" }}</p></div></div><div class="source-health-metrics"><div><span>Vacatures</span><strong>{{ source.job_count }}</strong></div><div><span>Laatste scan</span><strong>{% if latest_run %}{{ latest_run.started_at|timesince }}{% else %}—{% endif %}</strong></div><div><span>Volgende run</span><strong>{{ source.next_run_at|date:"d/m H:i"|default:"—" }}</strong></div></div><div class="source-actions"><span class="pill {% if source.policy == 'deny' or source.status == 'quarantined' or latest_run and latest_run.status == 'failed' %}pill-failed{% elif latest_run and latest_run.status == 'success' %}pill-success{% elif latest_run and latest_run.status == 'partial' %}pill-review{% else %}pill-review{% endif %}">{% if source.policy == 'deny' %}Geblokkeerd{% elif source.status == 'quarantined' %}Quarantaine{% elif latest_run %}{{ latest_run.get_status_display }}{% else %}Geen run{% endif %}</span><button class="icon-button" type="button" data-disclosure aria-controls="source-details-{{ source.pk }}" aria-expanded="false" aria-label="Details van {{ source.name }} tonen"><svg class="icon disclosure-icon"><use href="#icon-chevron"></use></svg></button></div></div>
<div class="source-health-details" id="source-details-{{ source.pk }}" hidden><div><span class="field-label">Runlog</span><div class="technical-log">{% if latest_run %}[{{ latest_run.started_at|date:"Y-m-d H:i:s" }}] {{ latest_run.get_status_display }}{% if latest_run.error_category %}<br>{{ latest_run.error_category }}: {{ latest_run.error_message }}{% else %}<br>Discovered {{ latest_run.discovered_count }} · 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 runbewijs beschikbaar.{% endif %}</div>{% if source.discovery_evidence %}<span class="field-label">Ontdekkingsevidence</span><ul class="compact-list">{% for evidence in source.discovery_evidence %}<li>{{ evidence.label|default:"Ontdekt" }}: {{ evidence.url }}</li>{% endfor %}</ul>{% endif %}</div><dl class="intel-facts"><div><dt>Policy</dt><dd>{{ source.get_policy_display }}</dd></div><div><dt>Parser</dt><dd>{{ source.parser_key }}</dd></div><div><dt>Interval</dt><dd>{{ source.crawl_interval_minutes }} min</dd></div><div><dt>Failure count</dt><dd>{{ source.failure_count }}</dd></div></dl><div class="source-detail-actions">{% if source.policy != 'deny' %}<form method="post" action="{% url 'sources:retry' source.pk %}">{% csrf_token %}<button class="button button-primary" type="submit"><svg class="icon"><use href="#icon-refresh"></use></svg>Scan opnieuw</button></form>{% else %}<p class="message error">Bronbeleid blokkeert een nieuwe netwerkactie.</p>{% endif %}{% if source.base_url %}<a class="button button-ghost" href="{{ source.base_url }}" target="_blank" rel="noopener noreferrer nofollow">Publieke bron <svg class="icon"><use href="#icon-external"></use></svg></a>{% endif %}</div></div>
</article>
{% empty %}
<article class="source-alert-card"><div><strong>Nog geen mailbox gekoppeld</strong><p>Koppel hieronder per gewenst jobboard de mailbox die de bijbehorende vacaturealerts ontvangt.</p></div></article>
{% endfor %}
</div>
<details id="mailbox-form" class="import-disclosure mailbox-disclosure" {% if editing_mailbox or mailbox_form.errors %}open{% endif %}>
<summary><span class="source-icon"><svg class="icon"><use href="#icon-sources"></use></svg></span><span><span class="section-kicker">Versleutelde IMAP-koppeling</span><strong>{% if editing_mailbox %}{{ editing_mailbox.get_platform_display }}-mailbox wijzigen{% else %}Platformmailbox koppelen{% endif %}</strong><small>Gmail, Outlook / Microsoft 365 of aangepaste IMAP</small></span><svg class="icon disclosure-icon"><use href="#icon-chevron"></use></svg></summary>
<div class="import-disclosure-body"><form method="post" action="{% if editing_mailbox %}{% url 'sources:mailbox_edit' editing_mailbox.pk %}{% else %}{% url 'sources:mailbox_add' %}{% endif %}">{% csrf_token %}
<div class="form-grid mailbox-form-grid">
<label><span class="field-label">{{ mailbox_form.platform.label }}</span>{{ mailbox_form.platform }}{% for error in mailbox_form.platform.errors %}<small class="field-error">{{ error }}</small>{% endfor %}</label>
<label><span class="field-label">{{ mailbox_form.provider.label }}</span>{{ mailbox_form.provider }}{% for error in mailbox_form.provider.errors %}<small class="field-error">{{ error }}</small>{% endfor %}</label>
<label><span class="field-label">{{ mailbox_form.custom_host.label }}</span>{{ mailbox_form.custom_host }}<small class="muted">Alleen invullen bij een andere IMAP-provider.</small>{% for error in mailbox_form.custom_host.errors %}<small class="field-error">{{ error }}</small>{% endfor %}</label>
<label><span class="field-label">{{ mailbox_form.port.label }}</span>{{ mailbox_form.port }}{% for error in mailbox_form.port.errors %}<small class="field-error">{{ error }}</small>{% endfor %}</label>
<label><span class="field-label">{{ mailbox_form.username.label }}</span>{{ mailbox_form.username }}{% for error in mailbox_form.username.errors %}<small class="field-error">{{ error }}</small>{% endfor %}</label>
<label><span class="field-label">{{ mailbox_form.password.label }}</span>{{ mailbox_form.password }}<small class="muted">Gebruik een app-wachtwoord; dit veld wordt nooit teruggetoond.</small>{% for error in mailbox_form.password.errors %}<small class="field-error">{{ error }}</small>{% endfor %}</label>
<label><span class="field-label">{{ mailbox_form.mailbox.label }}</span>{{ mailbox_form.mailbox }}{% for error in mailbox_form.mailbox.errors %}<small class="field-error">{{ error }}</small>{% endfor %}</label>
<label><span class="field-label">{{ mailbox_form.poll_interval_minutes.label }}</span>{{ mailbox_form.poll_interval_minutes }}{% for error in mailbox_form.poll_interval_minutes.errors %}<small class="field-error">{{ error }}</small>{% endfor %}</label>
</div>
<label class="checkbox-row">{{ mailbox_form.enabled }}<span>{{ mailbox_form.enabled.label }}</span></label>
{% for error in mailbox_form.non_field_errors %}<div class="message error">{{ error }}</div>{% endfor %}
<div class="job-actions"><button class="button button-primary" type="submit" {% if not mailbox_credential_key_ready %}disabled{% endif %}>{% if editing_mailbox %}Wijzigingen opslaan{% else %}Mailbox koppelen en testen{% endif %}</button>{% if editing_mailbox %}<a class="button button-ghost" href="{% url 'sources:list' %}">Annuleren</a>{% endif %}</div>
</form></div>
</details>
<div class="source-alert-grid platform-help-grid">
{% for platform in platform_alerts %}<article class="source-alert-card"><div><strong>{{ platform.label }}-alert instellen</strong><p>{{ platform.help_text }} VacatureRadar verwerkt alleen links van {{ platform.label }} en logt niet in op het platform.</p></div><a class="button button-secondary" href="{{ platform.alert_url }}" target="_blank" rel="noopener noreferrer">Open {{ platform.label }}</a></article>{% endfor %}
{% endwith %}{% empty %}<div class="empty-state"><div class="empty-icon"><svg class="icon"><use href="#icon-sources"></use></svg></div><h2>Nog geen bron geregistreerd</h2><p>Gebruik een gecontroleerde import of seedopdracht om een bron toe te voegen.</p></div>{% endfor %}
</div>
</section>
<details class="panel import-disclosure" {% if manual_import_form.errors %}open{% endif %}>
<summary><span class="source-icon"><svg class="icon"><use href="#icon-upload"></use></svg></span><span><span class="section-kicker">Gecontroleerde invoer</span><strong>Handmatige import</strong><small>Publieke URL of vacaturetekst toevoegen</small></span><svg class="icon disclosure-icon"><use href="#icon-chevron"></use></svg></summary>
<div class="import-disclosure-body"><p class="muted">URL-import passeert altijd het bronbeleid en de SSRF-validatie.</p>
<form id="manual-import-form" method="post" action="{% url 'sources:manual_import' %}" data-paste-limit="{{ manual_import_paste_max_bytes }}">{% csrf_token %}
<div class="import-layout">
<label><span class="field-label">{{ 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">{{ error }}</small>{% endfor %}<small class="muted">Laat leeg wanneer je tekst plakt.</small></label>
<label><span class="field-label">{{ manual_import_form.pasted_text.label }}</span>{{ manual_import_form.pasted_text }}<small class="muted">Nog 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">{{ error }}</small>{% endfor %}</label>
</div>
{% for error in manual_import_form.non_field_errors %}<div class="message error">{{ error }}</div>{% endfor %}
<div class="job-actions"><button class="button button-primary" type="submit"><svg class="icon"><use href="#icon-upload"></use></svg>Import uitvoeren</button><button id="copy-bookmarklet" class="button button-ghost" type="button">Bookmarklet kopiëren</button><a id="manual-bookmarklet" class="button button-link" href="#" data-manual-import-endpoint="{{ manual_import_bookmarklet_endpoint }}">Bookmarklet installeren</a></div>
</form>
<textarea id="manual-bookmarklet-code" rows="2" readonly class="sr-only" aria-label="Bookmarklet endpoint">{{ manual_import_bookmarklet_endpoint }}</textarea></div>
</details>
<div class="source-management-grid">
<section class="panel" aria-labelledby="platform-alerts-title"><div class="section-heading"><div><span class="section-kicker">Mail-alert ingress</span><h2 id="platform-alerts-title">Platformmailboxen</h2><p>Iedere jobsite kan een afzonderlijke mailbox en pollinterval gebruiken.</p></div><span class="pill {% if platform_alert_ingress_ready %}pill-success{% else %}pill-review{% endif %}">{{ mailbox_connections|length }} gekoppeld</span></div>{% if not mailbox_credential_key_ready %}<p class="message error">Credentialopslag is niet geconfigureerd. Stel <code>MAILBOX_CREDENTIAL_KEYS</code> in; wachtwoorden worden nooit leesbaar opgeslagen.</p>{% endif %}<div class="mailbox-list">{% for connection in mailbox_connections %}<article class="mailbox-row"><div><div class="title-row"><strong>{{ connection.get_platform_display }}</strong><span class="pill {% if connection.enabled and not connection.last_error_category %}pill-success{% elif connection.last_error_category %}pill-failed{% else %}pill-review{% endif %}">{% if connection.enabled and not connection.last_error_category %}Actief{% elif connection.last_error_category %}Aandacht{% else %}Pauze{% endif %}</span></div><p>{{ connection.get_provider_display }} · {{ connection.username }}</p><small>{% if connection.last_success_at %}Laatste import {{ connection.last_success_at|date:"d/m H:i" }}{% else %}Nog geen geslaagde import{% endif %} · interval {{ connection.get_poll_interval_minutes_display|lower }}</small></div><div class="page-actions">{% if connection.enabled %}<form method="post" action="{% url 'sources:sync_platform_alerts' connection.pk %}">{% csrf_token %}<button class="icon-button" type="submit" aria-label="{{ connection.get_platform_display }} nu synchroniseren"><svg class="icon"><use href="#icon-refresh"></use></svg></button></form>{% endif %}<a class="icon-button" href="{% url 'sources:mailbox_edit' connection.pk %}#mailbox-form" aria-label="{{ connection.get_platform_display }} wijzigen"><svg class="icon"><use href="#icon-system"></use></svg></a><form method="post" action="{% url 'sources:mailbox_toggle' connection.pk %}">{% csrf_token %}<button class="button button-ghost" type="submit">{% if connection.enabled %}Pauze{% else %}Activeren{% endif %}</button></form></div></article>{% empty %}<div class="empty-state compact-empty"><h2>Nog geen mailbox</h2><p>Koppel hieronder een mailbox voor platformalerts.</p></div>{% endfor %}</div>
<details id="mailbox-form" class="import-disclosure" {% if editing_mailbox or mailbox_form.errors %}open{% endif %}><summary><span class="source-icon"><svg class="icon"><use href="#icon-mail"></use></svg></span><span><span class="section-kicker">Versleutelde IMAP</span><strong>{% if editing_mailbox %}Mailbox wijzigen{% else %}Platformmailbox koppelen{% endif %}</strong><small>Gmail, Outlook / Microsoft 365 of aangepaste IMAP</small></span><svg class="icon disclosure-icon"><use href="#icon-chevron"></use></svg></summary><div class="import-disclosure-body"><form method="post" action="{% if editing_mailbox %}{% url 'sources:mailbox_edit' editing_mailbox.pk %}{% else %}{% url 'sources:mailbox_add' %}{% endif %}">{% csrf_token %}<div class="mailbox-form-grid">{% for field in mailbox_form %}{% if field.name != 'enabled' %}<label><span>{{ field.label }}</span>{{ field }}{% if field.name == 'password' %}<small>Gebruik een app-wachtwoord; dit wordt nooit teruggetoond.</small>{% endif %}{% for error in field.errors %}<small class="field-error">{{ error }}</small>{% endfor %}</label>{% endif %}{% endfor %}</div><label class="toggle-field">{{ mailbox_form.enabled }}<span>{{ mailbox_form.enabled.label }}</span></label>{% for error in mailbox_form.non_field_errors %}<div class="message error">{{ error }}</div>{% endfor %}<div class="form-actions"><button class="button button-primary" type="submit" {% if not mailbox_credential_key_ready %}disabled{% endif %}>Opslaan en testen</button>{% if editing_mailbox %}<a class="button button-ghost" href="{% url 'sources:list' %}">Annuleren</a>{% endif %}</div></form></div></details>
</section>
{% if manual_import_result %}<section class="panel"><span class="pill pill-success">Importresultaat</span><h2>Importresultaat</h2><p><strong>Bron:</strong> {{ manual_import_result.source_name }} ({{ manual_import_result.source_id }})</p><dl class="definition-grid"><div><dt>Modus</dt><dd>Modus: {{ manual_import_result.mode }}</dd></div><div><dt>Herkenbare items</dt><dd>Herkenbare items: {{ manual_import_result.extracted_count }}</dd></div><div><dt>Nieuwe vacatures</dt><dd>Nieuwe vacatures: {{ manual_import_result.created_count }}</dd></div><div><dt>Duplicaten</dt><dd>Duplicaten: {{ manual_import_result.duplicate_count }}</dd></div></dl>{% if manual_import_result.warnings %}<h3>Waarschuwingen</h3><ul class="compact-list caution">{% for warning in manual_import_result.warnings %}<li>{{ warning }}</li>{% endfor %}</ul>{% endif %}{% if manual_import_result.jobs %}<h3>Geïmporteerde vacatures</h3><ul class="source-list">{% for job in manual_import_result.jobs %}<li>{% if job.id %}<a href="{% url 'jobs:detail' job.id %}">{{ job.title }}</a>{% else %}{{ job.title }}{% endif %}<span>{{ job.employer }}</span></li>{% endfor %}</ul>{% endif %}</section>{% endif %}
<div class="section-heading source-list-heading"><div><span class="section-kicker">Scan-endpoints</span><h2>{{ source_total_count }} geregistreerde bronnen</h2><p>Status is gebaseerd op bronbeleid en de meest recente run.</p></div><form id="bulk-action-form" method="post" action="{% url 'sources:bulk' %}" class="inline-bulk-action">{% csrf_token %}<label><span class="sr-only">Bulkactie voor selectie</span><select id="action" name="action"><option value="approve_to_trial">Naar proefrun</option><option value="allow">Toestaan</option><option value="pause">Pauzeren</option><option value="dismiss">Afwijzen</option></select></label><button class="button button-secondary" type="submit">Uitvoeren</button></form></div>
<div class="card-list">
{% for source in sources %}{% with latest_run=source.runs.all|first %}
<article class="source-card">
<div class="source-card-head">
<div class="source-identity"><span class="source-icon"><svg class="icon"><use href="#icon-sources"></use></svg></span><div><div class="title-row">{% if source.policy != 'deny' %}<input type="checkbox" name="source_ids" form="bulk-action-form" value="{{ source.pk }}" aria-label="Selecteer {{ source.name }}">{% endif %}<h2>{{ source.name }}</h2></div><p>{{ source.get_source_type_display }} · {{ source.domain|default:"Geen domein" }}</p><div class="job-meta"><span>{{ source.get_policy_display }}</span>{% if latest_run %}<span>laatste run {{ latest_run.started_at|date:"d/m/Y H:i" }}</span><span>{{ source.job_count }} vacatures in radar</span>{% else %}<span>nog niet gestart</span>{% endif %}</div></div></div>
<div class="source-actions"><span class="pill {% if source.policy == 'deny' or source.status == 'quarantined' or latest_run and latest_run.status == 'failed' %}pill-failed{% elif latest_run and latest_run.status == 'partial' %}pill-partial{% elif latest_run and latest_run.status == 'success' %}pill-success{% else %}pill-review{% endif %}">{% if source.policy == 'deny' %}Geblokkeerd{% elif source.status == 'quarantined' %}Quarantaine{% elif latest_run %}{{ latest_run.get_status_display }}{% else %}Geen run{% endif %}</span><button class="icon-button" type="button" data-disclosure aria-controls="source-details-{{ forloop.counter }}" aria-expanded="false" aria-label="Details van {{ source.name }} tonen"><svg class="icon disclosure-icon"><use href="#icon-chevron"></use></svg></button></div>
</div>
{% if source.discovery_evidence %}<div class="source-details"><span class="field-label">Ontdekkingsevidence</span><ul class="compact-list">{% for evidence in source.discovery_evidence %}<li>{{ evidence.label|default:"Ontdekte bron" }}: {{ evidence.url }}</li>{% endfor %}</ul></div>{% endif %}
<div class="source-details" id="source-details-{{ forloop.counter }}" hidden><div class="source-details-grid"><div><span class="field-label">Technische runinformatie</span><div class="technical-log">{% if latest_run %}{% if latest_run.status == 'failed' and latest_run.error_message %}[{{ latest_run.finished_at|date:"Y-m-d H:i:s"|default:"--" }}] {{ latest_run.error_category|default:"ERROR" }}: {{ latest_run.error_message }}{% else %}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><span class="field-label">Bronstatus</span><dl class="definition-grid"><div><dt>Review</dt><dd>{{ source.policy_review_summary }}</dd></div><div><dt>Volgende run</dt><dd>{{ source.next_run_at|date:"d/m/Y H:i"|default:"—" }}</dd></div></dl>{% if source.policy != 'deny' %}<form method="post" action="{% url 'sources:retry' source.pk %}">{% csrf_token %}<button class="button button-primary" type="submit">Scan opnieuw starten</button></form>{% else %}<p class="message error">Deze bron is door het beleid geblokkeerd.</p>{% endif %}</div></div></div>
</article>
{% endwith %}{% empty %}<div class="empty-state"><div class="empty-icon"><svg class="icon"><use href="#icon-sources"></use></svg></div><h2>Nog geen bronnen</h2><p>Registreer een bron via de beheeropdrachten of gebruik handmatige import.</p></div>{% endfor %}
<section class="panel" id="manual-import"><div class="section-heading"><div><span class="section-kicker">Gecontroleerde invoer</span><h2>Handmatige import</h2><p>Voeg één publieke URL of vacaturetekst toe. URL's blijven onder bronbeleid en SSRF-controle.</p></div><svg class="icon"><use href="#icon-upload"></use></svg></div><form id="manual-import-form" method="post" action="{% url 'sources:manual_import' %}" data-paste-limit="{{ manual_import_paste_max_bytes }}">{% csrf_token %}<div class="import-stack"><label><span>{{ 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">{{ error }}</small>{% endfor %}</label><label><span>{{ manual_import_form.pasted_text.label }}</span>{{ manual_import_form.pasted_text }}<small>Nog 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">{{ error }}</small>{% endfor %}</label></div>{% for error in manual_import_form.non_field_errors %}<div class="message error">{{ error }}</div>{% endfor %}<div class="form-actions"><button class="button button-primary" type="submit"><svg class="icon"><use href="#icon-upload"></use></svg>Import uitvoeren</button><button id="copy-bookmarklet" class="button button-ghost" type="button">Bookmarklet kopiëren</button><a id="manual-bookmarklet" class="button button-link" href="#" data-manual-import-endpoint="{{ manual_import_bookmarklet_endpoint }}">Bookmarklet installeren</a></div><textarea id="manual-bookmarklet-code" rows="2" readonly class="sr-only" aria-label="Bookmarklet endpoint">{{ manual_import_bookmarklet_endpoint }}</textarea></form>{% if manual_import_result %}<div class="import-result"><span class="pill pill-success">Importresultaat</span><h3>{{ manual_import_result.source_name }}</h3><dl class="intel-facts"><div><dt>Herkenbaar</dt><dd>{{ manual_import_result.extracted_count }}</dd></div><div><dt>Nieuw</dt><dd>{{ manual_import_result.created_count }}</dd></div><div><dt>Duplicaat</dt><dd>{{ manual_import_result.duplicate_count }}</dd></div></dl>{% if manual_import_result.warnings %}<ul class="compact-list caution">{% for warning in manual_import_result.warnings %}<li>{{ warning }}</li>{% endfor %}</ul>{% endif %}{% if manual_import_result.jobs %}<ul class="source-list">{% for item in manual_import_result.jobs %}<li>{% if item.id %}<a href="{% url 'jobs:detail' item.id %}">{{ item.title }}</a>{% else %}{{ item.title }}{% endif %}<span>{{ item.employer }}</span></li>{% endfor %}</ul>{% endif %}</div>{% endif %}</section>
</div>
{% if watchlist_sources %}<section class="panel"><div class="section-heading"><div><span class="section-kicker">Werkgeversradar · regio {{ active_search_profile.home_postal_code|default:"2400" }}</span><h2>Werkgeverswaaklijst</h2><p>Interessante regionale werkgevers blijven zichtbaar, ook zonder open vacature.</p></div><span class="pill">{{ watchlist_count }} gevolgd</span></div><div class="watchlist-grid">{% for source in watchlist_sources %}<article><span class="source-icon"><svg class="icon"><use href="#icon-building"></use></svg></span><div><h3>{{ source.name }}</h3><p>{{ source.metadata.municipality_scope|default:"Locatie niet opgeslagen" }}</p><small>{{ source.metadata.current_observation|default:source.metadata.watchlist_reason }}</small></div>{% if source.metadata.public_job_page or source.base_url %}<a class="icon-button" href="{{ source.metadata.public_job_page|default:source.base_url }}" target="_blank" rel="noopener noreferrer nofollow" aria-label="Carrièrepagina van {{ source.name }} openen"><svg class="icon"><use href="#icon-external"></use></svg></a>{% endif %}</article>{% endfor %}</div></section>{% endif %}
<details class="panel platform-catalog"><summary><span><span class="section-kicker">Beschikbare alertkanalen</span><strong>Jobplatformen configureren</strong></span><svg class="icon disclosure-icon"><use href="#icon-chevron"></use></svg></summary><div class="platform-grid">{% for platform in platform_alerts %}<article><h3>{{ platform.label }}-alert instellen</h3><p>{{ platform.help_text }} VacatureRadar verwerkt alleen ontvangen vacaturelinks en logt niet in op het platform.</p><a class="button button-ghost" href="{{ platform.alert_url }}" target="_blank" rel="noopener noreferrer">Open {{ platform.label }}</a></article>{% endfor %}</div></details>
{% if active_search_profile %}<p class="regional-source-note"><strong>2400 Mol</strong> · maximaal {{ active_search_profile.max_distance_km }} km. {% if active_search_profile.home_latitude == None or active_search_profile.home_longitude == None %}Coördinaten ontbreken nog; vacatureafstanden worden niet geschat.{% endif %}</p>{% endif %}
<span class="sr-only">Mailbox koppelen en testen; de scheduler controleert elke vijf minuten welke mailbox aan de beurt is. Interessante werkgeverswaaklijst.</span>
{% if manual_import_result %}<p class="import-audit-line">Modus: {{ manual_import_result.mode }} · Herkenbare items: {{ manual_import_result.extracted_count }} · Nieuwe vacatures: {{ manual_import_result.created_count }} · Duplicaten: {{ manual_import_result.duplicate_count }}</p>{% endif %}
<script src="{% static 'js/manual_import.js' %}?v={{ app_version }}" defer></script>
{% endblock %}