Compare commits

..

4 Commits

Author SHA1 Message Date
Flatlogic Bot
6baa5f4e3d Knb 2026-06-09 23:18:34 +00:00
Flatlogic Bot
af768b6ac1 Vv 2026-06-09 23:17:01 +00:00
Flatlogic Bot
ef6dd13037 D 2026-06-09 23:15:53 +00:00
Flatlogic Bot
41cce00390 A 2026-06-09 23:09:44 +00:00
31 changed files with 1742 additions and 185 deletions

Binary file not shown.

View File

@ -1,3 +1,18 @@
from django.contrib import admin from django.contrib import admin
# Register your models here. from .models import JobPosting, JobSource
@admin.register(JobSource)
class JobSourceAdmin(admin.ModelAdmin):
list_display = ("name", "family", "status", "url", "last_checked_at", "created_at")
list_filter = ("family", "status")
search_fields = ("name", "url", "owner")
@admin.register(JobPosting)
class JobPostingAdmin(admin.ModelAdmin):
list_display = ("title", "company", "location", "contract_type", "source", "published_at", "is_active")
list_filter = ("contract_type", "is_active", "source__family", "published_at")
search_fields = ("title", "company", "location", "description")
autocomplete_fields = ("source",)

69
core/forms.py Normal file
View File

@ -0,0 +1,69 @@
from django import forms
from .models import JobPosting, JobSource
class StyledModelForm(forms.ModelForm):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
for field in self.fields.values():
widget = field.widget
if isinstance(widget, forms.CheckboxInput):
widget.attrs.setdefault("class", "form-check-input")
elif isinstance(widget, forms.Select):
widget.attrs.setdefault("class", "form-select")
else:
widget.attrs.setdefault("class", "form-control")
class JobSourceForm(StyledModelForm):
class Meta:
model = JobSource
fields = ["name", "family", "url", "status", "owner", "notes"]
widgets = {
"notes": forms.Textarea(attrs={"rows": 4}),
}
help_texts = {
"url": "Paste the public job board, agency, or careers page URL.",
"owner": "Optional teammate responsible for this connector.",
}
def clean_name(self):
return self.cleaned_data["name"].strip()
class JobPostingForm(StyledModelForm):
class Meta:
model = JobPosting
fields = [
"source",
"title",
"company",
"location",
"contract_type",
"remote",
"salary",
"apply_url",
"published_at",
"description",
"is_active",
]
widgets = {
"published_at": forms.DateInput(attrs={"type": "date"}),
"description": forms.Textarea(attrs={"rows": 6}),
}
help_texts = {
"source": "Choose the connector/source that found this offer.",
"description": "Paste a concise cleaned description; the pipeline view will evolve from here.",
}
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields["source"].queryset = JobSource.objects.order_by("family", "name")
self.fields["source"].empty_label = "Select a source"
def clean_title(self):
return self.cleaned_data["title"].strip()
def clean_company(self):
return self.cleaned_data["company"].strip()

View File

@ -0,0 +1 @@

Binary file not shown.

View File

@ -0,0 +1 @@

View File

@ -0,0 +1,212 @@
from datetime import timedelta
from django.core.management.base import BaseCommand
from django.utils import timezone
from core.models import JobPosting, JobSource
class Command(BaseCommand):
help = "Seed realistic demo sources and job postings for local MVP reviews."
def add_arguments(self, parser):
parser.add_argument(
"--reset",
action="store_true",
help="Delete existing JobSource and JobPosting records before seeding.",
)
def handle(self, *args, **options):
if options["reset"]:
JobPosting.objects.all().delete()
JobSource.objects.all().delete()
self.stdout.write(self.style.WARNING("Existing sources and jobs deleted."))
today = timezone.localdate()
now = timezone.now()
sources_payload = [
{
"name": "France Travail Dijon",
"family": JobSource.Family.PORTAL,
"url": "https://candidat.francetravail.fr/offres/recherche?lieux=21D",
"status": JobSource.Status.ACTIVE,
"owner": "Ops Team",
"notes": "Primary public feed for Dijon area listings.",
"last_checked_at": now - timedelta(minutes=22),
},
{
"name": "Apec Bourgogne-Franche-Comté",
"family": JobSource.Family.PORTAL,
"url": "https://www.apec.fr/candidat/recherche-emploi.html",
"status": JobSource.Status.ACTIVE,
"owner": "Ops Team",
"notes": "Executive and white-collar roles.",
"last_checked_at": now - timedelta(hours=2),
},
{
"name": "Adecco Dijon Tertiaire",
"family": JobSource.Family.AGENCY,
"url": "https://www.adecco.fr/offres-emploi/?k=dijon",
"status": JobSource.Status.PAUSED,
"owner": "Data Partner",
"notes": "Paused while waiting on extraction rule update.",
"last_checked_at": now - timedelta(days=2),
},
{
"name": "Urgo Group Careers",
"family": JobSource.Family.COMPANY,
"url": "https://careers.urgo-group.com/",
"status": JobSource.Status.ACTIVE,
"owner": "Ops Team",
"notes": "Direct company careers feed.",
"last_checked_at": now - timedelta(minutes=55),
},
{
"name": "SEB Selongey Careers",
"family": JobSource.Family.COMPANY,
"url": "https://www.groupe-seb.com/fr/carrieres",
"status": JobSource.Status.ERROR,
"owner": "Connector Squad",
"notes": "Blocked by anti-bot response, requires parser fallback.",
"last_checked_at": now - timedelta(days=5),
},
]
source_map = {}
for payload in sources_payload:
source, _ = JobSource.objects.update_or_create(url=payload["url"], defaults=payload)
source_map[source.name] = source
jobs_payload = [
{
"source": "France Travail Dijon",
"title": "Développeur Python Django (H/F)",
"company": "Noveo Digital",
"location": "Dijon",
"contract_type": JobPosting.ContractType.CDI,
"remote": True,
"salary": "38k€45k€",
"apply_url": "https://example.com/jobs/python-django-dijon",
"published_at": today - timedelta(days=1),
"description": "Concevoir des APIs Django et maintenir un back-office métier orienté data.",
"is_active": True,
"duplicate_score": 4.10,
},
{
"source": "France Travail Dijon",
"title": "Intégrateur Front-end React",
"company": "Pixel Nordic",
"location": "Dijon",
"contract_type": JobPosting.ContractType.CDD,
"remote": False,
"salary": "34k€",
"apply_url": "https://example.com/jobs/react-integrator",
"published_at": today - timedelta(days=2),
"description": "Intégrer des interfaces performantes et accessibles au sein d'une équipe produit.",
"is_active": True,
"duplicate_score": 1.90,
},
{
"source": "Apec Bourgogne-Franche-Comté",
"title": "Product Owner CMS",
"company": "Cobalt Studio",
"location": "Dijon",
"contract_type": JobPosting.ContractType.CDI,
"remote": True,
"salary": "45k€52k€",
"apply_url": "https://example.com/jobs/product-owner-cms",
"published_at": today - timedelta(days=3),
"description": "Piloter la roadmap d'une plateforme CMS B2B et animer les rituels produit.",
"is_active": True,
"duplicate_score": 0.50,
},
{
"source": "Adecco Dijon Tertiaire",
"title": "Technicien support applicatif",
"company": "Helix Services",
"location": "Chenôve",
"contract_type": JobPosting.ContractType.INTERIM,
"remote": False,
"salary": "13,50€/h",
"apply_url": "https://example.com/jobs/support-applicatif",
"published_at": today - timedelta(days=4),
"description": "Support N1/N2 sur une suite SaaS et suivi d'incidents applicatifs.",
"is_active": True,
"duplicate_score": 6.75,
},
{
"source": "Urgo Group Careers",
"title": "Data Analyst RH",
"company": "Urgo Group",
"location": "Chenôve",
"contract_type": JobPosting.ContractType.CDI,
"remote": True,
"salary": "40k€",
"apply_url": "https://example.com/jobs/data-analyst-rh",
"published_at": today - timedelta(days=5),
"description": "Structurer les tableaux de bord RH et fiabiliser les flux de reporting.",
"is_active": True,
"duplicate_score": 0.30,
},
{
"source": "SEB Selongey Careers",
"title": "Ingénieur QA Automatisation",
"company": "Groupe SEB",
"location": "Selongey",
"contract_type": JobPosting.ContractType.CDI,
"remote": False,
"salary": "42k€48k€",
"apply_url": "https://example.com/jobs/qa-automation",
"published_at": today - timedelta(days=6),
"description": "Automatiser les scénarios de validation et renforcer la non-régression continue.",
"is_active": False,
"duplicate_score": 3.20,
},
{
"source": "Apec Bourgogne-Franche-Comté",
"title": "Chef de projet digital",
"company": "Mutualité Bourgogne",
"location": "Dijon",
"contract_type": JobPosting.ContractType.CDD,
"remote": False,
"salary": "39k€",
"apply_url": "https://example.com/jobs/chef-projet-digital",
"published_at": today - timedelta(days=7),
"description": "Coordonner la refonte d'outils internes et piloter les prestataires externes.",
"is_active": True,
"duplicate_score": 2.40,
},
{
"source": "France Travail Dijon",
"title": "Développeur Full Stack Junior",
"company": "BFC Cloud",
"location": "Dijon",
"contract_type": JobPosting.ContractType.APPRENTICESHIP,
"remote": True,
"salary": "Selon grille alternance",
"apply_url": "https://example.com/jobs/fullstack-junior",
"published_at": today - timedelta(days=8),
"description": "Participer au développement d'un CMS métier avec Django et Vue.",
"is_active": True,
"duplicate_score": 5.60,
},
]
seeded_jobs = 0
for payload in jobs_payload:
source = source_map[payload.pop("source")]
_, created = JobPosting.objects.update_or_create(
source=source,
title=payload["title"],
company=payload["company"],
defaults=payload,
)
if created:
seeded_jobs += 1
self.stdout.write(
self.style.SUCCESS(
f"Demo seed complete: {len(source_map)} sources available, {JobPosting.objects.count()} total job postings ({seeded_jobs} new)."
)
)

View File

@ -0,0 +1,59 @@
# Generated by Django 5.2.7 on 2026-06-09 22:57
import django.db.models.deletion
import django.utils.timezone
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
]
operations = [
migrations.CreateModel(
name='JobSource',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=160)),
('family', models.CharField(choices=[('portal', 'National portal'), ('agency', 'Interim agency'), ('company', 'Company careers')], max_length=24)),
('url', models.URLField(unique=True)),
('status', models.CharField(choices=[('planned', 'Planned'), ('active', 'Active'), ('paused', 'Paused'), ('error', 'Needs attention')], default='planned', max_length=24)),
('owner', models.CharField(blank=True, max_length=120)),
('notes', models.TextField(blank=True)),
('last_checked_at', models.DateTimeField(blank=True, null=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
],
options={
'ordering': ['family', 'name'],
'indexes': [models.Index(fields=['family', 'status'], name='core_jobsou_family_d61233_idx'), models.Index(fields=['name'], name='core_jobsou_name_a1bfb5_idx')],
},
),
migrations.CreateModel(
name='JobPosting',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('title', models.CharField(max_length=220)),
('company', models.CharField(max_length=180)),
('location', models.CharField(default='Dijon, Bourgogne-Franche-Comté', max_length=160)),
('contract_type', models.CharField(choices=[('cdi', 'CDI'), ('cdd', 'CDD'), ('interim', 'Interim'), ('apprenticeship', 'Apprenticeship'), ('freelance', 'Freelance'), ('other', 'Other')], default='cdi', max_length=24)),
('remote', models.BooleanField(default=False)),
('salary', models.CharField(blank=True, max_length=120)),
('apply_url', models.URLField(blank=True)),
('published_at', models.DateField(default=django.utils.timezone.localdate)),
('description', models.TextField()),
('is_active', models.BooleanField(default=True)),
('duplicate_score', models.DecimalField(decimal_places=2, default=0, max_digits=5)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('source', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='jobs', to='core.jobsource')),
],
options={
'ordering': ['-published_at', '-created_at'],
'indexes': [models.Index(fields=['is_active', 'published_at'], name='core_jobpos_is_acti_ee6ffc_idx'), models.Index(fields=['company', 'title'], name='core_jobpos_company_49594a_idx'), models.Index(fields=['contract_type'], name='core_jobpos_contrac_b2fa07_idx')],
},
),
]

View File

@ -1,3 +1,78 @@
from django.db import models from django.db import models
from django.urls import reverse
from django.utils import timezone
# Create your models here.
class JobSource(models.Model):
class Family(models.TextChoices):
PORTAL = "portal", "National portal"
AGENCY = "agency", "Interim agency"
COMPANY = "company", "Company careers"
class Status(models.TextChoices):
PLANNED = "planned", "Planned"
ACTIVE = "active", "Active"
PAUSED = "paused", "Paused"
ERROR = "error", "Needs attention"
name = models.CharField(max_length=160)
family = models.CharField(max_length=24, choices=Family.choices)
url = models.URLField(unique=True)
status = models.CharField(max_length=24, choices=Status.choices, default=Status.PLANNED)
owner = models.CharField(max_length=120, blank=True)
notes = models.TextField(blank=True)
last_checked_at = models.DateTimeField(null=True, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
ordering = ["family", "name"]
indexes = [
models.Index(fields=["family", "status"]),
models.Index(fields=["name"]),
]
def __str__(self):
return self.name
def get_absolute_url(self):
return reverse("source_success", args=[self.pk])
class JobPosting(models.Model):
class ContractType(models.TextChoices):
CDI = "cdi", "CDI"
CDD = "cdd", "CDD"
INTERIM = "interim", "Interim"
APPRENTICESHIP = "apprenticeship", "Apprenticeship"
FREELANCE = "freelance", "Freelance"
OTHER = "other", "Other"
source = models.ForeignKey(JobSource, on_delete=models.PROTECT, related_name="jobs")
title = models.CharField(max_length=220)
company = models.CharField(max_length=180)
location = models.CharField(max_length=160, default="Dijon, Bourgogne-Franche-Comté")
contract_type = models.CharField(max_length=24, choices=ContractType.choices, default=ContractType.CDI)
remote = models.BooleanField(default=False)
salary = models.CharField(max_length=120, blank=True)
apply_url = models.URLField(blank=True)
published_at = models.DateField(default=timezone.localdate)
description = models.TextField()
is_active = models.BooleanField(default=True)
duplicate_score = models.DecimalField(max_digits=5, decimal_places=2, default=0)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
ordering = ["-published_at", "-created_at"]
indexes = [
models.Index(fields=["is_active", "published_at"]),
models.Index(fields=["company", "title"]),
models.Index(fields=["contract_type"]),
]
def __str__(self):
return f"{self.title}{self.company}"
def get_absolute_url(self):
return reverse("job_detail", args=[self.pk])

View File

@ -3,23 +3,68 @@
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<title>{% block title %}Knowledge Base{% endblock %}</title> <meta name="viewport" content="width=device-width, initial-scale=1">
{% if project_description %} <title>{% block title %}{{ page_title|default:"Dijon Job Aggregator" }}{% endblock %}</title>
<meta name="description" content="{{ project_description }}"> <meta name="description" content="{% block meta_description %}{{ meta_description|default:project_description|default:'Pilot dashboard for aggregating job offers around Dijon.' }}{% endblock %}">
<meta property="og:description" content="{{ project_description }}">
<meta property="twitter:description" content="{{ project_description }}">
{% endif %}
{% if project_image_url %} {% if project_image_url %}
<meta property="og:image" content="{{ project_image_url }}"> <meta property="og:image" content="{{ project_image_url }}">
<meta property="twitter:image" content="{{ project_image_url }}"> <meta property="twitter:image" content="{{ project_image_url }}">
{% endif %} {% endif %}
{% load static %} {% load static %}
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=Space+Grotesk:wght@600;700&display=swap" rel="stylesheet">
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
<link rel="stylesheet" href="{% static 'css/custom.css' %}?v={{ deployment_timestamp }}"> <link rel="stylesheet" href="{% static 'css/custom.css' %}?v={{ deployment_timestamp }}">
{% block head %}{% endblock %} {% block head %}{% endblock %}
</head> </head>
<body> <body>
{% block content %}{% endblock %} <a class="skip-link" href="#main-content">Skip to content</a>
<nav class="navbar navbar-expand-lg app-nav sticky-top" aria-label="Main navigation">
<div class="container">
<a class="navbar-brand brand-lockup" href="{% url 'home' %}">
<span class="brand-mark">DJA</span>
<span>Dijon Job Aggregator</span>
</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#mainNav" aria-controls="mainNav" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="mainNav">
<ul class="navbar-nav ms-auto align-items-lg-center gap-lg-2">
<li class="nav-item"><a class="nav-link" href="{% url 'job_list' %}">Jobs</a></li>
<li class="nav-item"><a class="nav-link" href="{% url 'ops_dashboard' %}">Ops</a></li>
<li class="nav-item"><a class="nav-link" href="{% url 'source_create' %}">Add source</a></li>
<li class="nav-item"><a class="nav-link" href="{% url 'job_create' %}">Add offer</a></li>
<li class="nav-item"><a class="btn btn-sm btn-outline-dark rounded-pill px-3" href="/admin/">Admin</a></li>
</ul>
</div>
</div>
</nav>
{% if messages %}
<div class="container message-stack" aria-live="polite">
{% for message in messages %}
<div class="alert alert-{{ message.tags|default:'info' }} alert-dismissible fade show shadow-sm" role="alert">
{{ message }}
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
{% endfor %}
</div>
{% endif %}
<main id="main-content">
{% block content %}{% endblock %}
</main>
<footer class="app-footer">
<div class="container d-flex flex-column flex-md-row justify-content-between gap-3">
<span>Built for a progressive Dijon scraping platform pilot.</span>
<span class="text-muted">Next: connectors, dedupe, metrics, and scheduler.</span>
</div>
</footer>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js" defer></script>
</body> </body>
</html> </html>

View File

@ -1,145 +1,113 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}{{ project_name }}{% endblock %} {% block title %}{{ page_title }}{% endblock %}
{% block meta_description %}{{ meta_description }}{% endblock %}
{% block head %}
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;700&display=swap" rel="stylesheet">
<style>
:root {
--bg-color-start: #6a11cb;
--bg-color-end: #2575fc;
--text-color: #ffffff;
--card-bg-color: rgba(255, 255, 255, 0.01);
--card-border-color: rgba(255, 255, 255, 0.1);
}
* {
box-sizing: border-box;
}
body {
margin: 0;
font-family: 'Inter', sans-serif;
background: linear-gradient(45deg, var(--bg-color-start), var(--bg-color-end));
color: var(--text-color);
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
text-align: center;
overflow: hidden;
position: relative;
}
body::before {
content: '';
position: absolute;
inset: 0;
background-image: url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' width='100' height='100' viewBox='0 0 100 100'><path d='M-10 10L110 10M10 -10L10 110' stroke-width='1' stroke='rgba(255,255,255,0.05)'/></svg>");
animation: bg-pan 20s linear infinite;
z-index: -1;
}
@keyframes bg-pan {
0% {
background-position: 0% 0%;
}
100% {
background-position: 100% 100%;
}
}
main {
padding: 2rem;
}
.card {
background: var(--card-bg-color);
border: 1px solid var(--card-border-color);
border-radius: 16px;
padding: 2.5rem 2rem;
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
box-shadow: 0 12px 36px rgba(0, 0, 0, 0.25);
}
h1 {
font-size: clamp(2.2rem, 3vw + 1.2rem, 3.2rem);
font-weight: 700;
margin: 0 0 1.2rem;
letter-spacing: -0.02em;
}
p {
margin: 0.5rem 0;
font-size: 1.1rem;
opacity: 0.92;
}
.loader {
margin: 1.5rem auto;
width: 56px;
height: 56px;
border: 4px solid rgba(255, 255, 255, 0.25);
border-top-color: #fff;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.runtime code {
background: rgba(0, 0, 0, 0.25);
padding: 0.15rem 0.45rem;
border-radius: 4px;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
border: 0;
}
footer {
position: absolute;
bottom: 1rem;
width: 100%;
text-align: center;
font-size: 0.85rem;
opacity: 0.75;
}
</style>
{% endblock %}
{% block content %} {% block content %}
<main> <section class="hero-section position-relative overflow-hidden">
<div class="card"> <div class="shape shape-one"></div>
<h1>Analyzing your requirements and generating your app…</h1> <div class="shape shape-two"></div>
<div class="loader" role="status" aria-live="polite" aria-label="Applying initial changes"> <div class="container position-relative">
<span class="sr-only">Loading…</span> <div class="row align-items-center g-5 py-5">
<div class="col-lg-7">
<span class="eyebrow">Pilot MVP · Dijon employment intelligence</span>
<h1 class="display-title mt-3">Aggregate, normalize, and review Dijon job offers from day one.</h1>
<p class="lead hero-copy mt-4">A polished first slice for the future distributed scraping platform: register sources, capture offers, search the active queue, and inspect normalized job details.</p>
<form class="hero-search mt-4" action="{% url 'job_list' %}" method="get" role="search">
<label class="visually-hidden" for="hero-q">Search jobs</label>
<input id="hero-q" class="form-control form-control-lg" type="search" name="q" placeholder="Search roles, companies, locations…">
<button class="btn btn-accent btn-lg" type="submit">Search jobs</button>
</form>
<div class="d-flex flex-wrap gap-3 mt-4">
<a class="btn btn-dark btn-lg rounded-pill px-4" href="{% url 'source_create' %}">Register source</a>
<a class="btn btn-glass btn-lg rounded-pill px-4" href="{% url 'job_create' %}">Add offer</a>
</div>
</div>
<div class="col-lg-5">
<div class="command-card glass-card">
<div class="d-flex justify-content-between align-items-start mb-4">
<div>
<p class="card-kicker mb-1">System snapshot</p>
<h2 class="h4 mb-0">Connector runway</h2>
</div>
<span class="status-pill">Live MVP</span>
</div>
<div class="metric-grid">
<div class="metric-tile"><span>{{ source_count }}</span><small>Sources</small></div>
<div class="metric-tile"><span>{{ active_jobs }}</span><small>Active offers</small></div>
<div class="metric-tile"><span>{{ total_jobs }}</span><small>Total captured</small></div>
</div>
<div class="pipeline-list mt-4">
<div><span class="dot dot-green"></span> Source registry</div>
<div><span class="dot dot-orange"></span> Manual intake queue</div>
<div><span class="dot dot-blue"></span> Search + detail review</div>
</div>
</div>
</div>
</div> </div>
<p class="hint">AppWizzy AI is collecting your requirements and applying the first changes.</p>
<p class="hint">This page will refresh automatically as the plan is implemented.</p>
<p class="runtime">
Runtime: Django <code>{{ django_version }}</code> · Python <code>{{ python_version }}</code>
— UTC <code>{{ current_time|date:"Y-m-d H:i:s" }}</code>
</p>
</div> </div>
</main> </section>
<footer>
Page updated: {{ current_time|date:"Y-m-d H:i:s" }} (UTC) <section class="section-pad">
</footer> <div class="container">
{% endblock %} <div class="row g-4 align-items-stretch">
<div class="col-lg-4">
<article class="feature-card h-100">
<span class="feature-icon">01</span>
<h2 class="h4">Register sources</h2>
<p>Add national portals, interim agencies, and company careers pages with family/status metadata.</p>
<a href="{% url 'source_create' %}" class="stretched-link">Create a source</a>
</article>
</div>
<div class="col-lg-4">
<article class="feature-card h-100">
<span class="feature-icon">02</span>
<h2 class="h4">Capture offers</h2>
<p>Store a cleaned offer linked to its source, including contract, salary, remote flag, and apply URL.</p>
<a href="{% url 'job_create' %}" class="stretched-link">Add an offer</a>
</article>
</div>
<div class="col-lg-4">
<article class="feature-card h-100">
<span class="feature-icon">03</span>
<h2 class="h4">Review the queue</h2>
<p>Search active offers and open a detailed review page designed for future dedupe and metrics.</p>
<a href="{% url 'job_list' %}" class="stretched-link">Browse jobs</a>
</article>
</div>
</div>
</div>
</section>
<section class="section-pad pt-0">
<div class="container">
<div class="section-heading d-flex flex-column flex-md-row justify-content-between gap-3 align-items-md-end">
<div>
<span class="eyebrow">Latest normalized offers</span>
<h2>Review queue</h2>
</div>
<a class="btn btn-outline-dark rounded-pill" href="{% url 'job_list' %}">View all offers</a>
</div>
{% if latest_jobs %}
<div class="row g-4 mt-2">
{% for job in latest_jobs %}
<div class="col-md-6 col-xl-4">
{% include "core/partials/job_card.html" with job=job %}
</div>
{% endfor %}
</div>
{% else %}
<div class="empty-state mt-4">
<div class="empty-orb"></div>
<h3>No offers captured yet</h3>
<p>Start by registering a source, then add the first normalized offer to make the queue useful.</p>
<div class="d-flex justify-content-center gap-3 flex-wrap">
<a class="btn btn-dark rounded-pill px-4" href="{% url 'source_create' %}">Register source</a>
<a class="btn btn-accent rounded-pill px-4" href="{% url 'job_create' %}">Add offer</a>
</div>
</div>
{% endif %}
</div>
</section>
{% endblock %}

View File

@ -0,0 +1,58 @@
{% extends "base.html" %}
{% block title %}{{ page_title }}{% endblock %}
{% block meta_description %}{{ meta_description }}{% endblock %}
{% block content %}
<section class="detail-hero">
<div class="container">
<a class="back-link" href="{% url 'job_list' %}">← Back to jobs</a>
<div class="row g-4 align-items-start mt-2">
<div class="col-lg-8">
<span class="badge-soft">{{ job.get_contract_type_display }}</span>
<h1>{{ job.title }}</h1>
<p class="lead">{{ job.company }} · {{ job.location }}</p>
</div>
<div class="col-lg-4">
<div class="detail-side glass-card">
<p class="card-kicker">Source</p>
<h2 class="h5">{{ job.source.name }}</h2>
<p class="small text-muted mb-3">{{ job.source.get_family_display }} · {{ job.source.get_status_display }}</p>
{% if job.apply_url %}<a class="btn btn-accent w-100 rounded-pill" href="{{ job.apply_url }}" target="_blank" rel="noopener">Open apply URL</a>{% endif %}
</div>
</div>
</div>
</div>
</section>
<section class="section-pad pt-0">
<div class="container">
<div class="row g-4">
<div class="col-lg-8">
<article class="content-panel">
<h2>Description</h2>
<p>{{ job.description|linebreaksbr }}</p>
</article>
</div>
<div class="col-lg-4">
<aside class="content-panel">
<h2 class="h5">Pipeline facts</h2>
<dl class="detail-list compact">
<div><dt>Published</dt><dd>{{ job.published_at|date:"M d, Y" }}</dd></div>
<div><dt>Remote</dt><dd>{{ job.remote|yesno:"Yes,No" }}</dd></div>
<div><dt>Salary</dt><dd>{{ job.salary|default:"Not specified" }}</dd></div>
<div><dt>Duplicate score</dt><dd>{{ job.duplicate_score }}</dd></div>
</dl>
</aside>
</div>
</div>
{% if related_jobs %}
<div class="section-heading mt-5"><h2>More from {{ job.company }}</h2></div>
<div class="row g-4">
{% for related in related_jobs %}
<div class="col-md-6 col-xl-4">{% include "core/partials/job_card.html" with job=related %}</div>
{% endfor %}
</div>
{% endif %}
</div>
</section>
{% endblock %}

View File

@ -0,0 +1,51 @@
{% extends "base.html" %}
{% block title %}{{ page_title }}{% endblock %}
{% block meta_description %}{{ meta_description }}{% endblock %}
{% block content %}
<section class="form-hero">
<div class="container">
<div class="row justify-content-center">
<div class="col-lg-9">
<span class="eyebrow">Offer intake</span>
<h1>Add a normalized job offer</h1>
<p class="lead">This manual intake mirrors the future scraper output: one source, one cleaned offer, ready for search and review.</p>
</div>
</div>
</div>
</section>
<section class="section-pad pt-0">
<div class="container">
<div class="row justify-content-center">
<div class="col-lg-10">
<form class="app-form glass-card" method="post" novalidate>
{% csrf_token %}
{% include "core/partials/form_errors.html" with form=form %}
<div class="row g-4">
{% for field in form %}
<div class="col-md-{% if field.name == 'description' %}12{% elif field.name == 'remote' or field.name == 'is_active' %}6 check-column{% else %}6{% endif %}">
{% if field.field.widget.input_type == 'checkbox' %}
<div class="form-check form-switch mt-4">
{{ field }}
<label class="form-check-label" for="{{ field.id_for_label }}">{{ field.label }}</label>
</div>
{% else %}
<label class="form-label" for="{{ field.id_for_label }}">{{ field.label }}</label>
{{ field }}
{% endif %}
{% if field.help_text %}<div class="form-text">{{ field.help_text }}</div>{% endif %}
{% for error in field.errors %}<div class="invalid-feedback d-block">{{ error }}</div>{% endfor %}
</div>
{% endfor %}
</div>
<div class="d-flex flex-wrap gap-3 mt-4">
<button class="btn btn-accent btn-lg rounded-pill px-4" type="submit">Save offer</button>
<a class="btn btn-outline-dark btn-lg rounded-pill px-4" href="{% url 'job_list' %}">Cancel</a>
</div>
</form>
</div>
</div>
</div>
</section>
{% endblock %}

View File

@ -0,0 +1,97 @@
{% extends "base.html" %}
{% block title %}{{ page_title }}{% endblock %}
{% block meta_description %}{{ meta_description }}{% endblock %}
{% block content %}
<section class="list-hero">
<div class="container">
<div class="d-flex flex-column flex-lg-row justify-content-between gap-4 align-items-lg-end">
<div>
<span class="eyebrow">Searchable review queue</span>
<h1>Active job offers</h1>
<p class="lead">Filter normalized offers by keyword, source family, or contract type.</p>
</div>
<a class="btn btn-accent rounded-pill px-4" href="{% url 'job_create' %}">Add offer</a>
</div>
<form class="filter-bar glass-card mt-4" method="get" role="search">
<div class="row g-3 align-items-end">
<div class="col-lg-5">
<label class="form-label" for="q">Keyword</label>
<input class="form-control" id="q" type="search" name="q" value="{{ query }}" placeholder="Title, company, location…">
</div>
<div class="col-lg-3">
<label class="form-label" for="contract">Contract</label>
<select class="form-select" id="contract" name="contract">
<option value="">All contracts</option>
{% for value,label in contract_choices %}<option value="{{ value }}" {% if contract == value %}selected{% endif %}>{{ label }}</option>{% endfor %}
</select>
</div>
<div class="col-lg-3">
<label class="form-label" for="family">Source family</label>
<select class="form-select" id="family" name="family">
<option value="">All families</option>
{% for value,label in family_choices %}<option value="{{ value }}" {% if family == value %}selected{% endif %}>{{ label }}</option>{% endfor %}
</select>
</div>
<div class="col-lg-3">
<label class="form-label" for="source_status">Source status</label>
<select class="form-select" id="source_status" name="source_status">
<option value="">Any status</option>
{% for value,label in source_status_choices %}<option value="{{ value }}" {% if source_status == value %}selected{% endif %}>{{ label }}</option>{% endfor %}
</select>
</div>
<div class="col-lg-3">
<label class="form-label" for="sort">Sort</label>
<select class="form-select" id="sort" name="sort">
<option value="newest" {% if sort == "newest" %}selected{% endif %}>Newest</option>
<option value="oldest" {% if sort == "oldest" %}selected{% endif %}>Oldest</option>
<option value="company_az" {% if sort == "company_az" %}selected{% endif %}>Company A-Z</option>
<option value="company_za" {% if sort == "company_za" %}selected{% endif %}>Company Z-A</option>
</select>
</div>
<div class="col-lg-1 d-grid">
<button class="btn btn-dark" type="submit">Go</button>
</div>
<div class="col-lg-12">
<a class="small text-decoration-none" href="{% url 'job_list' %}">Clear filters</a>
</div>
</div>
</form>
</div>
</section>
<section class="section-pad pt-4">
<div class="container">
<p class="results-label">{{ result_count }} result{{ result_count|pluralize }} found</p>
{% if jobs %}
<div class="row g-4">
{% for job in jobs %}
<div class="col-md-6 col-xl-4">
{% include "core/partials/job_card.html" with job=job %}
</div>
{% endfor %}
</div>
{% if jobs.has_other_pages %}
<nav class="mt-4" aria-label="Jobs pagination">
<ul class="pagination">
{% if jobs.has_previous %}
<li class="page-item"><a class="page-link" href="?{% if current_filters_qs %}{{ current_filters_qs }}&{% endif %}page={{ jobs.previous_page_number }}">Previous</a></li>
{% endif %}
<li class="page-item disabled"><span class="page-link">Page {{ jobs.number }} of {{ jobs.paginator.num_pages }}</span></li>
{% if jobs.has_next %}
<li class="page-item"><a class="page-link" href="?{% if current_filters_qs %}{{ current_filters_qs }}&{% endif %}page={{ jobs.next_page_number }}">Next</a></li>
{% endif %}
</ul>
</nav>
{% endif %}
{% else %}
<div class="empty-state">
<div class="empty-orb"></div>
<h2>No matching offers</h2>
<p>Adjust filters or add the first offer from a registered source.</p>
<a class="btn btn-accent rounded-pill px-4" href="{% url 'job_create' %}">Add offer</a>
</div>
{% endif %}
</div>
</section>
{% endblock %}

View File

@ -0,0 +1,21 @@
{% extends "base.html" %}
{% block title %}{{ page_title }}{% endblock %}
{% block meta_description %}{{ meta_description }}{% endblock %}
{% block content %}
<section class="success-wrap">
<div class="container">
<div class="success-card glass-card mx-auto">
<span class="success-mark"></span>
<h1>{{ job.title }} added</h1>
<p class="lead">The offer from <strong>{{ job.company }}</strong> is now searchable and linked to <strong>{{ job.source.name }}</strong>.</p>
<div class="d-flex flex-wrap justify-content-center gap-3 mt-4">
<a class="btn btn-accent rounded-pill px-4" href="{% url 'job_detail' job.pk %}">Open detail</a>
<a class="btn btn-outline-dark rounded-pill px-4" href="{% url 'job_list' %}">Browse queue</a>
<a class="btn btn-outline-dark rounded-pill px-4" href="{% url 'job_create' %}">Add another</a>
</div>
</div>
</div>
</section>
{% endblock %}

View File

@ -0,0 +1,157 @@
{% extends "base.html" %}
{% block title %}{{ page_title }}{% endblock %}
{% block meta_description %}{{ meta_description }}{% endblock %}
{% block content %}
<section class="ops-hero">
<div class="container">
<div class="row align-items-end g-4">
<div class="col-lg-8">
<span class="eyebrow">Operator cockpit</span>
<h1>Connector readiness and intake queue</h1>
<p class="lead">Track source status, latest offers, and coverage by connector family before automated scraping is introduced.</p>
</div>
<div class="col-lg-4 text-lg-end">
<a class="btn btn-accent btn-lg rounded-pill px-4" href="{% url 'source_create' %}">Register source</a>
</div>
</div>
</div>
</section>
<section class="section-pad pt-0">
<div class="container">
<form class="filter-bar glass-card mb-4" method="get" role="search">
<div class="row g-3 align-items-end">
<div class="col-lg-5">
<label class="form-label" for="q">Search source</label>
<input class="form-control" id="q" type="search" name="q" value="{{ query }}" placeholder="Name, owner, or URL…">
</div>
<div class="col-lg-3">
<label class="form-label" for="family">Family</label>
<select class="form-select" id="family" name="family">
<option value="">All families</option>
{% for value,label in family_choices %}<option value="{{ value }}" {% if family == value %}selected{% endif %}>{{ label }}</option>{% endfor %}
</select>
</div>
<div class="col-lg-3">
<label class="form-label" for="status">Status</label>
<select class="form-select" id="status" name="status">
<option value="">All statuses</option>
{% for value,label in status_choices %}<option value="{{ value }}" {% if status == value %}selected{% endif %}>{{ label }}</option>{% endfor %}
</select>
</div>
<div class="col-lg-3">
<label class="form-label" for="sort">Sort</label>
<select class="form-select" id="sort" name="sort">
<option value="family_name" {% if sort == "family_name" %}selected{% endif %}>Family + name</option>
<option value="name_az" {% if sort == "name_az" %}selected{% endif %}>Name A-Z</option>
<option value="name_za" {% if sort == "name_za" %}selected{% endif %}>Name Z-A</option>
<option value="status" {% if sort == "status" %}selected{% endif %}>Status</option>
<option value="offers_high" {% if sort == "offers_high" %}selected{% endif %}>Offers high-low</option>
<option value="offers_low" {% if sort == "offers_low" %}selected{% endif %}>Offers low-high</option>
</select>
</div>
<div class="col-lg-1 d-grid">
<button class="btn btn-dark" type="submit">Go</button>
</div>
<div class="col-lg-12">
<a class="small text-decoration-none" href="{% url 'ops_dashboard' %}">Clear filters</a>
</div>
</div>
</form>
<div class="ops-grid mb-4">
<article class="metric-tile ops-tile"><span>{{ total_sources }}</span><small>Total sources</small></article>
<article class="metric-tile ops-tile"><span>{{ active_jobs }}</span><small>Active offers</small></article>
<article class="metric-tile ops-tile"><span>{{ needs_attention }}</span><small>Need attention</small></article>
<article class="metric-tile ops-tile"><span>{{ stale_sources }}</span><small>Never checked</small></article>
</div>
<div class="row g-4">
<div class="col-lg-8">
<div class="glass-card ops-panel">
<div class="d-flex justify-content-between align-items-start gap-3 mb-3">
<div>
<p class="card-kicker mb-1">Source registry</p>
<h2 class="h4 mb-0">Connector queue</h2>
</div>
<span class="status-pill">{{ sources|length }} tracked</span>
</div>
{% if sources %}
<div class="table-responsive">
<table class="table align-middle ops-table">
<thead><tr><th>Source</th><th>Family</th><th>Status</th><th>Offers</th><th>Last checked</th></tr></thead>
<tbody>
{% for source in sources %}
<tr>
<td><strong>{{ source.name }}</strong><br><a href="{{ source.url }}" target="_blank" rel="noopener">Open source</a></td>
<td>{{ source.get_family_display }}</td>
<td><span class="status-chip status-{{ source.status }}">{{ source.get_status_display }}</span></td>
<td>{{ source.job_total }}</td>
<td>{% if source.last_checked_at %}{{ source.last_checked_at|date:"M d, H:i" }}{% else %}<span class="text-muted">Not checked</span>{% endif %}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% if sources.has_other_pages %}
<nav class="mt-3" aria-label="Sources pagination">
<ul class="pagination mb-0">
{% if sources.has_previous %}
<li class="page-item"><a class="page-link" href="?{% if current_filters_qs %}{{ current_filters_qs }}&{% endif %}page={{ sources.previous_page_number }}">Previous</a></li>
{% endif %}
<li class="page-item disabled"><span class="page-link">Page {{ sources.number }} of {{ sources.paginator.num_pages }}</span></li>
{% if sources.has_next %}
<li class="page-item"><a class="page-link" href="?{% if current_filters_qs %}{{ current_filters_qs }}&{% endif %}page={{ sources.next_page_number }}">Next</a></li>
{% endif %}
</ul>
</nav>
{% endif %}
{% else %}
<div class="empty-state">No sources yet. Register the first portal, agency, or company careers page.</div>
{% endif %}
</div>
</div>
<div class="col-lg-4">
<div class="glass-card ops-panel mb-4">
<p class="card-kicker mb-1">Coverage</p>
<h2 class="h5">By family</h2>
{% for row in family_breakdown %}
<div class="breakdown-row"><span>{{ row.family|title }}</span><strong>{{ row.total }} sources · {{ row.jobs }} offers</strong></div>
{% empty %}<p class="text-muted mb-0">No family data yet.</p>{% endfor %}
</div>
<div class="glass-card ops-panel">
<p class="card-kicker mb-1">Health</p>
<h2 class="h5">By status</h2>
{% for row in status_breakdown %}
<div class="breakdown-row"><span>{{ row.status|title }}</span><strong>{{ row.total }}</strong></div>
{% empty %}<p class="text-muted mb-0">No status data yet.</p>{% endfor %}
</div>
</div>
</div>
<div class="glass-card ops-panel mt-4">
<div class="d-flex justify-content-between align-items-start gap-3 mb-3">
<div><p class="card-kicker mb-1">Review queue</p><h2 class="h4 mb-0">Latest normalized offers</h2></div>
<a class="btn btn-outline-dark rounded-pill px-3" href="{% url 'job_list' %}">View all</a>
</div>
<div class="row g-3">
{% for job in recent_jobs %}
<div class="col-md-6 col-xl-3">
<article class="queue-card">
<span class="source-chip">{{ job.source.get_family_display }}</span>
<h3 class="h6 mt-3"><a href="{% url 'job_detail' job.pk %}">{{ job.title }}</a></h3>
<p class="mb-1">{{ job.company }}</p>
<small class="text-muted">{{ job.location }} · {{ job.created_at|date:"M d" }}</small>
</article>
</div>
{% empty %}
<div class="col-12"><div class="empty-state">No offers in the queue yet.</div></div>
{% endfor %}
</div>
</div>
</div>
</section>
{% endblock %}

View File

@ -0,0 +1,5 @@
{% if form.non_field_errors %}
<div class="alert alert-danger" role="alert">
{% for error in form.non_field_errors %}<div>{{ error }}</div>{% endfor %}
</div>
{% endif %}

View File

@ -0,0 +1,11 @@
<article class="job-card h-100">
<div class="d-flex justify-content-between gap-3 align-items-start">
<span class="badge-soft">{{ job.get_contract_type_display }}</span>
{% if job.remote %}<span class="remote-pill">Remote</span>{% endif %}
</div>
<h3 class="h5 mt-3 mb-2"><a href="{% url 'job_detail' job.pk %}">{{ job.title }}</a></h3>
<p class="company mb-2">{{ job.company }}</p>
<p class="text-muted small mb-3">{{ job.location }} · {{ job.published_at|date:"M d, Y" }}</p>
<p class="job-excerpt">{{ job.description|truncatechars:120 }}</p>
<div class="source-chip mt-auto">{{ job.source.get_family_display }} · {{ job.source.name }}</div>
</article>

View File

@ -0,0 +1,44 @@
{% extends "base.html" %}
{% block title %}{{ page_title }}{% endblock %}
{% block meta_description %}{{ meta_description }}{% endblock %}
{% block content %}
<section class="form-hero">
<div class="container">
<div class="row justify-content-center">
<div class="col-lg-9">
<span class="eyebrow">Connector registry</span>
<h1>Register a new job source</h1>
<p class="lead">Capture the minimum metadata needed to make a future connector discoverable, monitorable, and attachable to offers.</p>
</div>
</div>
</div>
</section>
<section class="section-pad pt-0">
<div class="container">
<div class="row justify-content-center">
<div class="col-lg-9">
<form class="app-form glass-card" method="post" novalidate>
{% csrf_token %}
{% include "core/partials/form_errors.html" with form=form %}
<div class="row g-4">
{% for field in form %}
<div class="col-md-{% if field.name == 'notes' %}12{% else %}6{% endif %}">
<label class="form-label" for="{{ field.id_for_label }}">{{ field.label }}</label>
{{ field }}
{% if field.help_text %}<div class="form-text">{{ field.help_text }}</div>{% endif %}
{% for error in field.errors %}<div class="invalid-feedback d-block">{{ error }}</div>{% endfor %}
</div>
{% endfor %}
</div>
<div class="d-flex flex-wrap gap-3 mt-4">
<button class="btn btn-accent btn-lg rounded-pill px-4" type="submit">Save source</button>
<a class="btn btn-outline-dark btn-lg rounded-pill px-4" href="{% url 'home' %}">Cancel</a>
</div>
</form>
</div>
</div>
</div>
</section>
{% endblock %}

View File

@ -0,0 +1,24 @@
{% extends "base.html" %}
{% block title %}{{ page_title }}{% endblock %}
{% block meta_description %}{{ meta_description }}{% endblock %}
{% block content %}
<section class="success-wrap">
<div class="container">
<div class="success-card glass-card mx-auto">
<span class="success-mark"></span>
<h1>{{ source.name }} is ready for intake</h1>
<p class="lead">The source is registered as <strong>{{ source.get_family_display }}</strong> with status <strong>{{ source.get_status_display }}</strong>.</p>
<dl class="detail-list">
<div><dt>URL</dt><dd><a href="{{ source.url }}" target="_blank" rel="noopener">{{ source.url }}</a></dd></div>
<div><dt>Offers attached</dt><dd>{{ source.job_count }}</dd></div>
</dl>
<div class="d-flex flex-wrap justify-content-center gap-3 mt-4">
<a class="btn btn-accent rounded-pill px-4" href="{% url 'job_create' %}">Add offer from this source</a>
<a class="btn btn-outline-dark rounded-pill px-4" href="{% url 'home' %}">Back to dashboard</a>
</div>
</div>
</div>
</section>
{% endblock %}

View File

@ -1,7 +1,14 @@
from django.urls import path from django.urls import path
from .views import home from .views import home, job_create, job_detail, job_list, job_success, ops_dashboard, source_create, source_success
urlpatterns = [ urlpatterns = [
path("", home, name="home"), path("", home, name="home"),
path("sources/new/", source_create, name="source_create"),
path("sources/<int:pk>/ready/", source_success, name="source_success"),
path("ops/", ops_dashboard, name="ops_dashboard"),
path("jobs/", job_list, name="job_list"),
path("jobs/new/", job_create, name="job_create"),
path("jobs/<int:pk>/", job_detail, name="job_detail"),
path("jobs/<int:pk>/added/", job_success, name="job_success"),
] ]

View File

@ -1,25 +1,235 @@
import os from django.contrib import messages
import platform from django.db.models import Count, Q
from django.core.paginator import Paginator
from django import get_version as django_version from django.shortcuts import get_object_or_404, redirect, render
from django.shortcuts import render
from django.utils import timezone from django.utils import timezone
from .forms import JobPostingForm, JobSourceForm
from .models import JobPosting, JobSource
PAGE_META = {
"project_name": "Dijon Job Aggregator",
"project_description": "Pilot dashboard for collecting, reviewing, and searching job offers around Dijon.",
}
def _meta(title, description=None):
return {
**PAGE_META,
"page_title": title,
"meta_description": description or PAGE_META["project_description"],
}
def home(request): def home(request):
"""Render the landing screen with loader and environment details.""" """Landing dashboard with search, source stats, and latest offers."""
host_name = request.get_host().lower() latest_jobs = JobPosting.objects.select_related("source").filter(is_active=True)[:6]
agent_brand = "AppWizzy" if host_name == "appwizzy.com" else "Flatlogic" sources_by_family = JobSource.objects.values("family").annotate(total=Count("id")).order_by("family")
now = timezone.now()
context = { context = {
"project_name": "New Style", **_meta("Dijon Job Aggregator — Pilot Dashboard"),
"agent_brand": agent_brand, "total_jobs": JobPosting.objects.count(),
"django_version": django_version(), "active_jobs": JobPosting.objects.filter(is_active=True).count(),
"python_version": platform.python_version(), "source_count": JobSource.objects.count(),
"current_time": now, "sources_by_family": sources_by_family,
"host_name": host_name, "latest_jobs": latest_jobs,
"project_description": os.getenv("PROJECT_DESCRIPTION", ""), "today": timezone.localdate(),
"project_image_url": os.getenv("PROJECT_IMAGE_URL", ""),
} }
return render(request, "core/index.html", context) return render(request, "core/index.html", context)
def source_create(request):
if request.method == "POST":
form = JobSourceForm(request.POST)
if form.is_valid():
source = form.save()
messages.success(request, "Source registered. You can now attach job offers to it.")
return redirect("source_success", pk=source.pk)
else:
form = JobSourceForm(initial={"status": JobSource.Status.PLANNED})
return render(
request,
"core/source_form.html",
{**_meta("Register a Source", "Add a job portal, agency, or careers page to the Dijon aggregation pipeline."), "form": form},
)
def source_success(request, pk):
source = get_object_or_404(JobSource.objects.annotate(job_count=Count("jobs")), pk=pk)
return render(
request,
"core/source_success.html",
{**_meta("Source Registered", "Connector source confirmation for the Dijon Job Aggregator."), "source": source},
)
def job_create(request):
if not JobSource.objects.exists():
messages.info(request, "Create your first source before adding a job offer.")
return redirect("source_create")
if request.method == "POST":
form = JobPostingForm(request.POST)
if form.is_valid():
job = form.save()
messages.success(request, "Job offer added to the review queue.")
return redirect("job_success", pk=job.pk)
else:
form = JobPostingForm()
return render(
request,
"core/job_form.html",
{**_meta("Add a Job Offer", "Create a normalized job offer linked to a registered source."), "form": form},
)
def job_success(request, pk):
job = get_object_or_404(JobPosting.objects.select_related("source"), pk=pk)
return render(
request,
"core/job_success.html",
{**_meta("Offer Added", "Confirmation page for a newly captured Dijon job offer."), "job": job},
)
def job_list(request):
query = request.GET.get("q", "").strip()
contract = request.GET.get("contract", "").strip()
family = request.GET.get("family", "").strip()
source_status = request.GET.get("source_status", "").strip()
sort = request.GET.get("sort", "newest").strip()
job_sort_map = {
"newest": "-created_at",
"oldest": "created_at",
"company_az": "company",
"company_za": "-company",
}
order_by = job_sort_map.get(sort, "-created_at")
jobs_qs = JobPosting.objects.select_related("source").filter(is_active=True)
if query:
jobs_qs = jobs_qs.filter(
Q(title__icontains=query)
| Q(company__icontains=query)
| Q(location__icontains=query)
| Q(description__icontains=query)
)
if contract:
jobs_qs = jobs_qs.filter(contract_type=contract)
if family:
jobs_qs = jobs_qs.filter(source__family=family)
if source_status:
jobs_qs = jobs_qs.filter(source__status=source_status)
jobs_qs = jobs_qs.order_by(order_by)
paginator = Paginator(jobs_qs, 9)
page_number = request.GET.get("page")
jobs = paginator.get_page(page_number)
filters = request.GET.copy()
filters.pop("page", None)
current_filters_qs = filters.urlencode()
context = {
**_meta("Search Job Offers", "Search normalized job offers collected for Dijon and nearby opportunities."),
"jobs": jobs,
"query": query,
"contract": contract,
"family": family,
"source_status": source_status,
"sort": sort,
"contract_choices": JobPosting.ContractType.choices,
"family_choices": JobSource.Family.choices,
"source_status_choices": JobSource.Status.choices,
"result_count": jobs_qs.count(),
"current_filters_qs": current_filters_qs,
}
return render(request, "core/job_list.html", context)
def job_detail(request, pk):
job = get_object_or_404(JobPosting.objects.select_related("source"), pk=pk)
related_jobs = (
JobPosting.objects.select_related("source")
.filter(is_active=True, company__iexact=job.company)
.exclude(pk=job.pk)[:3]
)
return render(
request,
"core/job_detail.html",
{**_meta(job.title, f"{job.title} at {job.company} — normalized offer from {job.source.name}."), "job": job, "related_jobs": related_jobs},
)
def ops_dashboard(request):
query = request.GET.get("q", "").strip()
family = request.GET.get("family", "").strip()
status = request.GET.get("status", "").strip()
sort = request.GET.get("sort", "family_name").strip()
source_sort_map = {
"family_name": ("family", "name"),
"name_az": ("name",),
"name_za": ("-name",),
"status": ("status", "name"),
"offers_high": ("-job_total", "name"),
"offers_low": ("job_total", "name"),
}
source_order_by = source_sort_map.get(sort, ("family", "name"))
sources_qs = JobSource.objects.annotate(job_total=Count("jobs")).order_by(*source_order_by)
if query:
sources_qs = sources_qs.filter(Q(name__icontains=query) | Q(owner__icontains=query) | Q(url__icontains=query))
if family:
sources_qs = sources_qs.filter(family=family)
if status:
sources_qs = sources_qs.filter(status=status)
paginator = Paginator(sources_qs, 8)
page_number = request.GET.get("page")
sources = paginator.get_page(page_number)
filters = request.GET.copy()
filters.pop("page", None)
current_filters_qs = filters.urlencode()
recent_jobs = (
JobPosting.objects.select_related("source")
.order_by("-created_at")[:8]
)
family_breakdown = (
JobSource.objects.values("family")
.annotate(total=Count("id"), jobs=Count("jobs"))
.order_by("family")
)
status_breakdown = (
JobSource.objects.values("status")
.annotate(total=Count("id"))
.order_by("status")
)
needs_attention = sources_qs.filter(status=JobSource.Status.ERROR).count()
stale_sources = sources_qs.filter(last_checked_at__isnull=True).count()
return render(
request,
"core/ops_dashboard.html",
{
**_meta(
"Ops dashboard · Dijon Job Aggregator",
"Monitor source readiness, intake recency, and connector-family coverage for the Dijon job aggregator pilot.",
),
"sources": sources,
"recent_jobs": recent_jobs,
"family_breakdown": family_breakdown,
"status_breakdown": status_breakdown,
"needs_attention": needs_attention,
"stale_sources": stale_sources,
"active_jobs": JobPosting.objects.filter(is_active=True).count(),
"total_sources": sources_qs.count(),
"query": query,
"family": family,
"status": status,
"sort": sort,
"family_choices": JobSource.Family.choices,
"status_choices": JobSource.Status.choices,
"current_filters_qs": current_filters_qs,
},
)

View File

@ -1,4 +1,226 @@
/* Custom styles for the application */ /* Dijon Job Aggregator custom design system */
body { :root {
font-family: system-ui, -apple-system, sans-serif; --dja-ink: #17211d;
--dja-muted: #65736d;
--dja-paper: #fbf7ef;
--dja-surface: #fffdf8;
--dja-line: rgba(23, 33, 29, 0.12);
--dja-primary: #0f6b55;
--dja-primary-dark: #084737;
--dja-secondary: #f2b84b;
--dja-accent: #ff6b35;
--dja-sky: #67c9d0;
--dja-shadow: 0 24px 70px rgba(23, 33, 29, 0.14);
--dja-radius: 28px;
} }
* { box-sizing: border-box; }
body {
margin: 0;
font-family: "Inter", system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
color: var(--dja-ink);
background:
radial-gradient(circle at 12% 8%, rgba(242, 184, 75, 0.22), transparent 26rem),
radial-gradient(circle at 88% 18%, rgba(103, 201, 208, 0.22), transparent 28rem),
linear-gradient(180deg, #fffaf1 0%, #f7efe2 52%, #f8f4ec 100%);
min-height: 100vh;
}
h1, h2, h3, .navbar-brand {
font-family: "Space Grotesk", "Inter", sans-serif;
letter-spacing: -0.035em;
}
a { color: var(--dja-primary-dark); }
a:hover { color: var(--dja-accent); }
.skip-link {
position: absolute;
left: 1rem;
top: -4rem;
z-index: 2000;
background: var(--dja-ink);
color: #fff;
padding: .75rem 1rem;
border-radius: 999px;
transition: top .2s ease;
}
.skip-link:focus { top: 1rem; }
.app-nav {
background: rgba(255, 250, 241, 0.76);
border-bottom: 1px solid rgba(23, 33, 29, 0.08);
backdrop-filter: blur(18px);
}
.brand-lockup { display: inline-flex; align-items: center; gap: .7rem; font-weight: 700; color: var(--dja-ink); }
.brand-mark {
display: inline-grid;
place-items: center;
width: 42px;
height: 42px;
border-radius: 14px;
background: linear-gradient(135deg, var(--dja-primary), var(--dja-sky));
color: #fff;
font-size: .86rem;
box-shadow: 0 14px 30px rgba(15, 107, 85, .25);
}
.nav-link { color: rgba(23, 33, 29, .72); font-weight: 650; }
.nav-link:hover { color: var(--dja-primary-dark); }
.message-stack { position: fixed; top: 86px; left: 0; right: 0; z-index: 1020; pointer-events: none; }
.message-stack .alert { pointer-events: auto; max-width: 780px; margin-left: auto; }
.hero-section { padding: 4.5rem 0 3rem; }
.form-hero, .list-hero, .detail-hero { padding: 4.5rem 0 2.5rem; }
.success-wrap { padding: 5rem 0; min-height: 66vh; display: grid; align-items: center; }
.section-pad { padding: 4.5rem 0; }
.eyebrow {
display: inline-flex;
align-items: center;
gap: .45rem;
color: var(--dja-primary-dark);
font-weight: 800;
text-transform: uppercase;
letter-spacing: .11em;
font-size: .76rem;
}
.eyebrow::before {
content: "";
width: .65rem;
height: .65rem;
border-radius: 999px;
background: var(--dja-accent);
box-shadow: 0 0 0 7px rgba(255, 107, 53, .13);
}
.display-title, .form-hero h1, .list-hero h1, .detail-hero h1 {
font-size: clamp(2.6rem, 7vw, 5.9rem);
line-height: .92;
font-weight: 700;
margin: 0;
}
.form-hero h1, .list-hero h1, .detail-hero h1 { max-width: 980px; }
.hero-copy { max-width: 720px; color: var(--dja-muted); font-size: 1.18rem; }
.lead { color: var(--dja-muted); }
.shape { position: absolute; border-radius: 36px; opacity: .86; filter: blur(.1px); }
.shape-one { width: 180px; height: 180px; right: 11%; top: 6rem; background: linear-gradient(135deg, var(--dja-secondary), var(--dja-accent)); transform: rotate(18deg); }
.shape-two { width: 120px; height: 120px; right: 3%; bottom: 1rem; background: linear-gradient(135deg, var(--dja-sky), var(--dja-primary)); border-radius: 999px; }
.glass-card, .feature-card, .job-card, .content-panel {
background: rgba(255, 253, 248, 0.82);
border: 1px solid rgba(255, 255, 255, 0.74);
box-shadow: var(--dja-shadow);
backdrop-filter: blur(18px);
}
.command-card { border-radius: 34px; padding: 1.5rem; position: relative; overflow: hidden; }
.command-card::after {
content: "";
position: absolute;
width: 180px;
height: 180px;
right: -75px;
bottom: -80px;
background: rgba(255, 107, 53, .16);
border-radius: 999px;
}
.card-kicker { text-transform: uppercase; letter-spacing: .12em; color: var(--dja-muted); font-size: .74rem; font-weight: 800; }
.status-pill, .remote-pill, .badge-soft, .source-chip {
display: inline-flex;
align-items: center;
width: fit-content;
border-radius: 999px;
font-weight: 800;
font-size: .76rem;
}
.status-pill { background: rgba(15, 107, 85, .1); color: var(--dja-primary-dark); padding: .45rem .75rem; }
.badge-soft { background: rgba(242, 184, 75, .26); color: #6b4507; padding: .38rem .7rem; }
.remote-pill { background: rgba(103, 201, 208, .2); color: #17656b; padding: .38rem .7rem; }
.source-chip { background: rgba(23, 33, 29, .06); color: var(--dja-muted); padding: .45rem .7rem; }
.metric-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: .75rem; }
.metric-tile { background: rgba(255,255,255,.7); border: 1px solid var(--dja-line); border-radius: 22px; padding: 1rem; }
.metric-tile span { display: block; font-family: "Space Grotesk"; font-size: 2.1rem; line-height: 1; color: var(--dja-primary-dark); }
.metric-tile small { color: var(--dja-muted); font-weight: 700; }
.pipeline-list { display: grid; gap: .75rem; color: var(--dja-muted); font-weight: 700; }
.dot { display: inline-block; width: .65rem; height: .65rem; border-radius: 999px; margin-right: .55rem; }
.dot-green { background: var(--dja-primary); }
.dot-orange { background: var(--dja-accent); }
.dot-blue { background: var(--dja-sky); }
.hero-search {
display: flex;
gap: .75rem;
padding: .5rem;
border-radius: 999px;
background: rgba(255,255,255,.8);
border: 1px solid rgba(255,255,255,.9);
box-shadow: 0 18px 45px rgba(23,33,29,.11);
max-width: 760px;
}
.hero-search .form-control { border: 0; border-radius: 999px; background: transparent; padding-left: 1.2rem; }
.form-control:focus, .form-select:focus, .form-check-input:focus { border-color: var(--dja-primary); box-shadow: 0 0 0 .2rem rgba(15, 107, 85, .18); }
.btn-accent { background: var(--dja-accent); border-color: var(--dja-accent); color: #fff; font-weight: 800; }
.btn-accent:hover { background: #e65722; border-color: #e65722; color: #fff; transform: translateY(-1px); }
.btn-dark { background: var(--dja-ink); border-color: var(--dja-ink); }
.btn-glass { background: rgba(255,255,255,.58); border: 1px solid rgba(23,33,29,.15); color: var(--dja-ink); font-weight: 800; }
.btn { transition: transform .18s ease, box-shadow .18s ease; }
.btn:hover { box-shadow: 0 14px 25px rgba(23,33,29,.12); }
.feature-card, .job-card, .content-panel { border-radius: var(--dja-radius); padding: 1.35rem; position: relative; }
.feature-card { min-height: 250px; }
.feature-card p, .job-excerpt { color: var(--dja-muted); }
.feature-icon { display: inline-grid; place-items: center; width: 52px; height: 52px; border-radius: 18px; background: var(--dja-ink); color: #fff; font-family: "Space Grotesk"; margin-bottom: 1.5rem; }
.section-heading h2 { font-size: clamp(2rem, 4vw, 3.3rem); margin: .2rem 0 0; }
.job-card { display: flex; flex-direction: column; min-height: 310px; }
.job-card h3 a { text-decoration: none; color: var(--dja-ink); }
.job-card h3 a:hover { color: var(--dja-primary-dark); }
.company { font-weight: 800; }
.empty-state { text-align: center; border: 1px dashed rgba(23,33,29,.2); border-radius: 34px; padding: 3rem 1.5rem; background: rgba(255,255,255,.45); }
.empty-orb { width: 86px; height: 86px; border-radius: 30px; background: linear-gradient(135deg, var(--dja-secondary), var(--dja-sky)); margin: 0 auto 1rem; transform: rotate(-10deg); }
.app-form, .filter-bar, .success-card { border-radius: 34px; padding: clamp(1.25rem, 4vw, 2.2rem); }
.app-form .form-control, .app-form .form-select, .filter-bar .form-control, .filter-bar .form-select { border-radius: 16px; min-height: 50px; border-color: var(--dja-line); }
.app-form textarea.form-control { min-height: 150px; }
.form-label { font-weight: 800; color: var(--dja-ink); }
.form-text { color: var(--dja-muted); }
.form-switch .form-check-input { width: 3.2rem; height: 1.65rem; }
.form-check-input:checked { background-color: var(--dja-primary); border-color: var(--dja-primary); }
.success-card { max-width: 820px; text-align: center; }
.success-mark { display: inline-grid; place-items: center; width: 78px; height: 78px; border-radius: 26px; background: var(--dja-primary); color: #fff; font-size: 2rem; font-weight: 900; margin-bottom: 1.2rem; }
.detail-list { display: grid; gap: .75rem; text-align: left; margin: 1.5rem 0 0; }
.detail-list div { padding: .8rem 0; border-top: 1px solid var(--dja-line); }
.detail-list dt { color: var(--dja-muted); font-size: .78rem; text-transform: uppercase; letter-spacing: .08em; }
.detail-list dd { margin: 0; font-weight: 800; overflow-wrap: anywhere; }
.detail-list.compact div:first-child { border-top: 0; }
.results-label { color: var(--dja-muted); font-weight: 800; }
.back-link { text-decoration: none; font-weight: 800; color: var(--dja-primary-dark); }
.detail-side { border-radius: 28px; padding: 1.35rem; }
.content-panel h2 { margin-bottom: 1rem; }
.content-panel p { color: var(--dja-muted); line-height: 1.75; }
.app-footer { padding: 2rem 0; border-top: 1px solid var(--dja-line); color: var(--dja-muted); }
@media (max-width: 768px) {
.hero-search { border-radius: 28px; flex-direction: column; }
.hero-search .btn, .hero-search .form-control { width: 100%; }
.metric-grid { grid-template-columns: 1fr; }
.shape { opacity: .35; }
}
/* Ops dashboard */
.ops-hero { padding: 4.5rem 0 2.25rem; }
.ops-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 1rem; }
.ops-tile { min-height: 132px; background: rgba(255,255,255,.66); border: 1px solid var(--dja-line); }
.ops-panel { padding: 1.35rem; }
.ops-table { --bs-table-bg: transparent; margin-bottom: 0; }
.ops-table th { color: var(--dja-muted); font-size: .78rem; text-transform: uppercase; letter-spacing: .08em; }
.status-chip { display: inline-flex; border-radius: 999px; padding: .35rem .65rem; font-weight: 800; font-size: .78rem; background: rgba(103,201,208,.18); color: var(--dja-primary-dark); }
.status-error { background: rgba(255,107,53,.16); color: #9b3417; }
.status-paused, .status-planned { background: rgba(242,184,75,.22); color: #7a5310; }
.status-active { background: rgba(15,107,85,.14); color: var(--dja-primary-dark); }
.breakdown-row { display: flex; justify-content: space-between; gap: 1rem; padding: .85rem 0; border-bottom: 1px solid var(--dja-line); }
.breakdown-row:last-child { border-bottom: 0; }
.queue-card { height: 100%; padding: 1rem; border: 1px solid var(--dja-line); border-radius: 20px; background: rgba(255,253,248,.72); }
@media (max-width: 991px) { .ops-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
@media (max-width: 575px) { .ops-grid { grid-template-columns: 1fr; } }

View File

@ -1,21 +1,226 @@
/* Dijon Job Aggregator custom design system */
:root { :root {
--bg-color-start: #6a11cb; --dja-ink: #17211d;
--bg-color-end: #2575fc; --dja-muted: #65736d;
--text-color: #ffffff; --dja-paper: #fbf7ef;
--card-bg-color: rgba(255, 255, 255, 0.01); --dja-surface: #fffdf8;
--card-border-color: rgba(255, 255, 255, 0.1); --dja-line: rgba(23, 33, 29, 0.12);
--dja-primary: #0f6b55;
--dja-primary-dark: #084737;
--dja-secondary: #f2b84b;
--dja-accent: #ff6b35;
--dja-sky: #67c9d0;
--dja-shadow: 0 24px 70px rgba(23, 33, 29, 0.14);
--dja-radius: 28px;
} }
* { box-sizing: border-box; }
body { body {
margin: 0; margin: 0;
font-family: 'Inter', sans-serif; font-family: "Inter", system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
background: linear-gradient(45deg, var(--bg-color-start), var(--bg-color-end)); color: var(--dja-ink);
color: var(--text-color); background:
display: flex; radial-gradient(circle at 12% 8%, rgba(242, 184, 75, 0.22), transparent 26rem),
justify-content: center; radial-gradient(circle at 88% 18%, rgba(103, 201, 208, 0.22), transparent 28rem),
align-items: center; linear-gradient(180deg, #fffaf1 0%, #f7efe2 52%, #f8f4ec 100%);
min-height: 100vh; min-height: 100vh;
text-align: center;
overflow: hidden;
position: relative;
} }
h1, h2, h3, .navbar-brand {
font-family: "Space Grotesk", "Inter", sans-serif;
letter-spacing: -0.035em;
}
a { color: var(--dja-primary-dark); }
a:hover { color: var(--dja-accent); }
.skip-link {
position: absolute;
left: 1rem;
top: -4rem;
z-index: 2000;
background: var(--dja-ink);
color: #fff;
padding: .75rem 1rem;
border-radius: 999px;
transition: top .2s ease;
}
.skip-link:focus { top: 1rem; }
.app-nav {
background: rgba(255, 250, 241, 0.76);
border-bottom: 1px solid rgba(23, 33, 29, 0.08);
backdrop-filter: blur(18px);
}
.brand-lockup { display: inline-flex; align-items: center; gap: .7rem; font-weight: 700; color: var(--dja-ink); }
.brand-mark {
display: inline-grid;
place-items: center;
width: 42px;
height: 42px;
border-radius: 14px;
background: linear-gradient(135deg, var(--dja-primary), var(--dja-sky));
color: #fff;
font-size: .86rem;
box-shadow: 0 14px 30px rgba(15, 107, 85, .25);
}
.nav-link { color: rgba(23, 33, 29, .72); font-weight: 650; }
.nav-link:hover { color: var(--dja-primary-dark); }
.message-stack { position: fixed; top: 86px; left: 0; right: 0; z-index: 1020; pointer-events: none; }
.message-stack .alert { pointer-events: auto; max-width: 780px; margin-left: auto; }
.hero-section { padding: 4.5rem 0 3rem; }
.form-hero, .list-hero, .detail-hero { padding: 4.5rem 0 2.5rem; }
.success-wrap { padding: 5rem 0; min-height: 66vh; display: grid; align-items: center; }
.section-pad { padding: 4.5rem 0; }
.eyebrow {
display: inline-flex;
align-items: center;
gap: .45rem;
color: var(--dja-primary-dark);
font-weight: 800;
text-transform: uppercase;
letter-spacing: .11em;
font-size: .76rem;
}
.eyebrow::before {
content: "";
width: .65rem;
height: .65rem;
border-radius: 999px;
background: var(--dja-accent);
box-shadow: 0 0 0 7px rgba(255, 107, 53, .13);
}
.display-title, .form-hero h1, .list-hero h1, .detail-hero h1 {
font-size: clamp(2.6rem, 7vw, 5.9rem);
line-height: .92;
font-weight: 700;
margin: 0;
}
.form-hero h1, .list-hero h1, .detail-hero h1 { max-width: 980px; }
.hero-copy { max-width: 720px; color: var(--dja-muted); font-size: 1.18rem; }
.lead { color: var(--dja-muted); }
.shape { position: absolute; border-radius: 36px; opacity: .86; filter: blur(.1px); }
.shape-one { width: 180px; height: 180px; right: 11%; top: 6rem; background: linear-gradient(135deg, var(--dja-secondary), var(--dja-accent)); transform: rotate(18deg); }
.shape-two { width: 120px; height: 120px; right: 3%; bottom: 1rem; background: linear-gradient(135deg, var(--dja-sky), var(--dja-primary)); border-radius: 999px; }
.glass-card, .feature-card, .job-card, .content-panel {
background: rgba(255, 253, 248, 0.82);
border: 1px solid rgba(255, 255, 255, 0.74);
box-shadow: var(--dja-shadow);
backdrop-filter: blur(18px);
}
.command-card { border-radius: 34px; padding: 1.5rem; position: relative; overflow: hidden; }
.command-card::after {
content: "";
position: absolute;
width: 180px;
height: 180px;
right: -75px;
bottom: -80px;
background: rgba(255, 107, 53, .16);
border-radius: 999px;
}
.card-kicker { text-transform: uppercase; letter-spacing: .12em; color: var(--dja-muted); font-size: .74rem; font-weight: 800; }
.status-pill, .remote-pill, .badge-soft, .source-chip {
display: inline-flex;
align-items: center;
width: fit-content;
border-radius: 999px;
font-weight: 800;
font-size: .76rem;
}
.status-pill { background: rgba(15, 107, 85, .1); color: var(--dja-primary-dark); padding: .45rem .75rem; }
.badge-soft { background: rgba(242, 184, 75, .26); color: #6b4507; padding: .38rem .7rem; }
.remote-pill { background: rgba(103, 201, 208, .2); color: #17656b; padding: .38rem .7rem; }
.source-chip { background: rgba(23, 33, 29, .06); color: var(--dja-muted); padding: .45rem .7rem; }
.metric-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: .75rem; }
.metric-tile { background: rgba(255,255,255,.7); border: 1px solid var(--dja-line); border-radius: 22px; padding: 1rem; }
.metric-tile span { display: block; font-family: "Space Grotesk"; font-size: 2.1rem; line-height: 1; color: var(--dja-primary-dark); }
.metric-tile small { color: var(--dja-muted); font-weight: 700; }
.pipeline-list { display: grid; gap: .75rem; color: var(--dja-muted); font-weight: 700; }
.dot { display: inline-block; width: .65rem; height: .65rem; border-radius: 999px; margin-right: .55rem; }
.dot-green { background: var(--dja-primary); }
.dot-orange { background: var(--dja-accent); }
.dot-blue { background: var(--dja-sky); }
.hero-search {
display: flex;
gap: .75rem;
padding: .5rem;
border-radius: 999px;
background: rgba(255,255,255,.8);
border: 1px solid rgba(255,255,255,.9);
box-shadow: 0 18px 45px rgba(23,33,29,.11);
max-width: 760px;
}
.hero-search .form-control { border: 0; border-radius: 999px; background: transparent; padding-left: 1.2rem; }
.form-control:focus, .form-select:focus, .form-check-input:focus { border-color: var(--dja-primary); box-shadow: 0 0 0 .2rem rgba(15, 107, 85, .18); }
.btn-accent { background: var(--dja-accent); border-color: var(--dja-accent); color: #fff; font-weight: 800; }
.btn-accent:hover { background: #e65722; border-color: #e65722; color: #fff; transform: translateY(-1px); }
.btn-dark { background: var(--dja-ink); border-color: var(--dja-ink); }
.btn-glass { background: rgba(255,255,255,.58); border: 1px solid rgba(23,33,29,.15); color: var(--dja-ink); font-weight: 800; }
.btn { transition: transform .18s ease, box-shadow .18s ease; }
.btn:hover { box-shadow: 0 14px 25px rgba(23,33,29,.12); }
.feature-card, .job-card, .content-panel { border-radius: var(--dja-radius); padding: 1.35rem; position: relative; }
.feature-card { min-height: 250px; }
.feature-card p, .job-excerpt { color: var(--dja-muted); }
.feature-icon { display: inline-grid; place-items: center; width: 52px; height: 52px; border-radius: 18px; background: var(--dja-ink); color: #fff; font-family: "Space Grotesk"; margin-bottom: 1.5rem; }
.section-heading h2 { font-size: clamp(2rem, 4vw, 3.3rem); margin: .2rem 0 0; }
.job-card { display: flex; flex-direction: column; min-height: 310px; }
.job-card h3 a { text-decoration: none; color: var(--dja-ink); }
.job-card h3 a:hover { color: var(--dja-primary-dark); }
.company { font-weight: 800; }
.empty-state { text-align: center; border: 1px dashed rgba(23,33,29,.2); border-radius: 34px; padding: 3rem 1.5rem; background: rgba(255,255,255,.45); }
.empty-orb { width: 86px; height: 86px; border-radius: 30px; background: linear-gradient(135deg, var(--dja-secondary), var(--dja-sky)); margin: 0 auto 1rem; transform: rotate(-10deg); }
.app-form, .filter-bar, .success-card { border-radius: 34px; padding: clamp(1.25rem, 4vw, 2.2rem); }
.app-form .form-control, .app-form .form-select, .filter-bar .form-control, .filter-bar .form-select { border-radius: 16px; min-height: 50px; border-color: var(--dja-line); }
.app-form textarea.form-control { min-height: 150px; }
.form-label { font-weight: 800; color: var(--dja-ink); }
.form-text { color: var(--dja-muted); }
.form-switch .form-check-input { width: 3.2rem; height: 1.65rem; }
.form-check-input:checked { background-color: var(--dja-primary); border-color: var(--dja-primary); }
.success-card { max-width: 820px; text-align: center; }
.success-mark { display: inline-grid; place-items: center; width: 78px; height: 78px; border-radius: 26px; background: var(--dja-primary); color: #fff; font-size: 2rem; font-weight: 900; margin-bottom: 1.2rem; }
.detail-list { display: grid; gap: .75rem; text-align: left; margin: 1.5rem 0 0; }
.detail-list div { padding: .8rem 0; border-top: 1px solid var(--dja-line); }
.detail-list dt { color: var(--dja-muted); font-size: .78rem; text-transform: uppercase; letter-spacing: .08em; }
.detail-list dd { margin: 0; font-weight: 800; overflow-wrap: anywhere; }
.detail-list.compact div:first-child { border-top: 0; }
.results-label { color: var(--dja-muted); font-weight: 800; }
.back-link { text-decoration: none; font-weight: 800; color: var(--dja-primary-dark); }
.detail-side { border-radius: 28px; padding: 1.35rem; }
.content-panel h2 { margin-bottom: 1rem; }
.content-panel p { color: var(--dja-muted); line-height: 1.75; }
.app-footer { padding: 2rem 0; border-top: 1px solid var(--dja-line); color: var(--dja-muted); }
@media (max-width: 768px) {
.hero-search { border-radius: 28px; flex-direction: column; }
.hero-search .btn, .hero-search .form-control { width: 100%; }
.metric-grid { grid-template-columns: 1fr; }
.shape { opacity: .35; }
}
/* Ops dashboard */
.ops-hero { padding: 4.5rem 0 2.25rem; }
.ops-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 1rem; }
.ops-tile { min-height: 132px; background: rgba(255,255,255,.66); border: 1px solid var(--dja-line); }
.ops-panel { padding: 1.35rem; }
.ops-table { --bs-table-bg: transparent; margin-bottom: 0; }
.ops-table th { color: var(--dja-muted); font-size: .78rem; text-transform: uppercase; letter-spacing: .08em; }
.status-chip { display: inline-flex; border-radius: 999px; padding: .35rem .65rem; font-weight: 800; font-size: .78rem; background: rgba(103,201,208,.18); color: var(--dja-primary-dark); }
.status-error { background: rgba(255,107,53,.16); color: #9b3417; }
.status-paused, .status-planned { background: rgba(242,184,75,.22); color: #7a5310; }
.status-active { background: rgba(15,107,85,.14); color: var(--dja-primary-dark); }
.breakdown-row { display: flex; justify-content: space-between; gap: 1rem; padding: .85rem 0; border-bottom: 1px solid var(--dja-line); }
.breakdown-row:last-child { border-bottom: 0; }
.queue-card { height: 100%; padding: 1rem; border: 1px solid var(--dja-line); border-radius: 20px; background: rgba(255,253,248,.72); }
@media (max-width: 991px) { .ops-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
@media (max-width: 575px) { .ops-grid { grid-template-columns: 1fr; } }