Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6baa5f4e3d | ||
|
|
af768b6ac1 | ||
|
|
ef6dd13037 | ||
|
|
41cce00390 |
Binary file not shown.
BIN
core/__pycache__/forms.cpython-311.pyc
Normal file
BIN
core/__pycache__/forms.cpython-311.pyc
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -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
69
core/forms.py
Normal 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()
|
||||||
1
core/management/__init__.py
Normal file
1
core/management/__init__.py
Normal file
@ -0,0 +1 @@
|
|||||||
|
|
||||||
BIN
core/management/__pycache__/__init__.cpython-311.pyc
Normal file
BIN
core/management/__pycache__/__init__.cpython-311.pyc
Normal file
Binary file not shown.
1
core/management/commands/__init__.py
Normal file
1
core/management/commands/__init__.py
Normal file
@ -0,0 +1 @@
|
|||||||
|
|
||||||
BIN
core/management/commands/__pycache__/__init__.cpython-311.pyc
Normal file
BIN
core/management/commands/__pycache__/__init__.cpython-311.pyc
Normal file
Binary file not shown.
Binary file not shown.
212
core/management/commands/seed_demo_data.py
Normal file
212
core/management/commands/seed_demo_data.py
Normal 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)."
|
||||||
|
)
|
||||||
|
)
|
||||||
59
core/migrations/0001_initial.py
Normal file
59
core/migrations/0001_initial.py
Normal 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')],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
BIN
core/migrations/__pycache__/0001_initial.cpython-311.pyc
Normal file
BIN
core/migrations/__pycache__/0001_initial.cpython-311.pyc
Normal file
Binary file not shown.
@ -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])
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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">
|
||||||
|
<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 %}
|
{% endblock %}
|
||||||
58
core/templates/core/job_detail.html
Normal file
58
core/templates/core/job_detail.html
Normal 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 %}
|
||||||
51
core/templates/core/job_form.html
Normal file
51
core/templates/core/job_form.html
Normal 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 %}
|
||||||
97
core/templates/core/job_list.html
Normal file
97
core/templates/core/job_list.html
Normal 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 %}
|
||||||
21
core/templates/core/job_success.html
Normal file
21
core/templates/core/job_success.html
Normal 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 %}
|
||||||
157
core/templates/core/ops_dashboard.html
Normal file
157
core/templates/core/ops_dashboard.html
Normal 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 %}
|
||||||
5
core/templates/core/partials/form_errors.html
Normal file
5
core/templates/core/partials/form_errors.html
Normal 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 %}
|
||||||
11
core/templates/core/partials/job_card.html
Normal file
11
core/templates/core/partials/job_card.html
Normal 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>
|
||||||
44
core/templates/core/source_form.html
Normal file
44
core/templates/core/source_form.html
Normal 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 %}
|
||||||
24
core/templates/core/source_success.html
Normal file
24
core/templates/core/source_success.html
Normal 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 %}
|
||||||
@ -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"),
|
||||||
]
|
]
|
||||||
|
|||||||
246
core/views.py
246
core/views.py
@ -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,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|||||||
@ -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; } }
|
||||||
|
|||||||
@ -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; } }
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user