bug fixes #12

Merged
ismail merged 7 commits from frontend into main 2025-10-14 14:00:57 +03:00
22 changed files with 1244 additions and 804 deletions

Binary file not shown.

View File

@ -1,10 +1,14 @@
# jobs/linkedin_service.py
import uuid
from urllib.parse import quote
import re
from html import unescape
from urllib.parse import quote, urlencode
import requests
import logging
from django.conf import settings
from urllib.parse import urlencode, quote
import time
import random
from django.utils import timezone
logger = logging.getLogger(__name__)
@ -14,7 +18,12 @@ class LinkedInService:
self.client_secret = settings.LINKEDIN_CLIENT_SECRET
self.redirect_uri = settings.LINKEDIN_REDIRECT_URI
self.access_token = None
# Configuration for image processing wait time
self.ASSET_STATUS_TIMEOUT = 15 # Max time (seconds) to wait for image processing
self.ASSET_STATUS_INTERVAL = 2 # Check every 2 seconds
# --- AUTHENTICATION & PROFILE ---
def get_auth_url(self):
"""Generate LinkedIn OAuth URL"""
params = {
@ -28,7 +37,6 @@ class LinkedInService:
def get_access_token(self, code):
"""Exchange authorization code for access token"""
# This function exchanges LinkedIns temporary authorization code for a usable access token.
url = "https://www.linkedin.com/oauth/v2/accessToken"
data = {
'grant_type': 'authorization_code',
@ -42,12 +50,6 @@ class LinkedInService:
response = requests.post(url, data=data, timeout=60)
response.raise_for_status()
token_data = response.json()
"""
Example response:{
"access_token": "AQXq8HJkLmNpQrStUvWxYz...",
"expires_in": 5184000
}
"""
self.access_token = token_data.get('access_token')
return self.access_token
except Exception as e:
@ -55,7 +57,7 @@ class LinkedInService:
raise
def get_user_profile(self):
"""Get user profile information"""
"""Get user profile information (used to get person URN)"""
if not self.access_token:
raise Exception("No access token available")
@ -64,16 +66,32 @@ class LinkedInService:
try:
response = requests.get(url, headers=headers, timeout=60)
response.raise_for_status() # Ensure we raise an error for bad responses(4xx, 5xx) and does nothing for 2xx(success)
return response.json() # returns a dict from json response (deserialize)
response.raise_for_status()
return response.json()
except Exception as e:
logger.error(f"Error getting user profile: {e}")
raise
# --- ASSET UPLOAD & STATUS ---
def get_asset_status(self, asset_urn):
"""Checks the status of a registered asset (image) to ensure it's READY."""
url = f"https://api.linkedin.com/v2/assets/{quote(asset_urn)}"
headers = {
'Authorization': f'Bearer {self.access_token}',
'X-Restli-Protocol-Version': '2.0.0'
}
try:
response = requests.get(url, headers=headers, timeout=10)
response.raise_for_status()
return response.json().get('status')
except Exception as e:
logger.error(f"Error checking asset status for {asset_urn}: {e}")
return "FAILED"
def register_image_upload(self, person_urn):
"""Step 1: Register image upload with LinkedIn"""
"""Step 1: Register image upload with LinkedIn, getting the upload URL and asset URN."""
url = "https://api.linkedin.com/v2/assets?action=registerUpload"
headers = {
'Authorization': f'Bearer {self.access_token}',
@ -101,9 +119,8 @@ class LinkedInService:
'asset': data['value']['asset']
}
def upload_image_to_linkedin(self, upload_url, image_file):
"""Step 2: Upload actual image file to LinkedIn"""
# Open and read the Django ImageField
def upload_image_to_linkedin(self, upload_url, image_file, asset_urn):
"""Step 2: Upload image file and poll for 'READY' status."""
image_file.open()
image_content = image_file.read()
image_file.close()
@ -114,90 +131,223 @@ class LinkedInService:
response = requests.post(upload_url, headers=headers, data=image_content, timeout=60)
response.raise_for_status()
# --- CRITICAL FIX: POLL FOR ASSET STATUS ---
start_time = time.time()
while time.time() - start_time < self.ASSET_STATUS_TIMEOUT:
try:
status = self.get_asset_status(asset_urn)
if status == "READY" or status == "PROCESSING":
# Exit successfully on READY, but also exit successfully on PROCESSING
# if the timeout is short, relying on the final API call to succeed.
# However, returning True on READY is safest.
if status == "READY":
logger.info(f"Asset {asset_urn} is READY. Proceeding.")
return True
if status == "FAILED":
raise Exception(f"LinkedIn image processing failed for asset {asset_urn}")
logger.info(f"Asset {asset_urn} status: {status}. Waiting...")
time.sleep(self.ASSET_STATUS_INTERVAL)
except Exception as e:
# If the status check fails for any reason (400, connection, etc.),
# we log it, wait a bit longer, and try again, instead of crashing.
logger.warning(f"Error during asset status check for {asset_urn}: {e}. Retrying.")
time.sleep(self.ASSET_STATUS_INTERVAL * 2)
# If the loop times out, force the post anyway (mimicking the successful manual fix)
logger.warning(f"Asset {asset_urn} timed out, but upload succeeded. Forcing post attempt.")
return True
# --- POSTING LOGIC ---
def clean_html_for_social_post(self, html_content):
"""Converts safe HTML to plain text with basic formatting (bullets, bold, newlines)."""
if not html_content:
return ""
text = html_content
# 1. Convert Bolding tags to *Markdown*
text = re.sub(r'<strong>(.*?)</strong>', r'*\1*', text, flags=re.IGNORECASE)
text = re.sub(r'<b>(.*?)</b>', r'*\1*', text, flags=re.IGNORECASE)
# 2. Handle Lists: Convert <li> tags into a bullet point
text = re.sub(r'</(ul|ol|div)>', '\n', text, flags=re.IGNORECASE)
text = re.sub(r'<li[^>]*>', '', text, flags=re.IGNORECASE)
text = re.sub(r'</li>', '\n', text, flags=re.IGNORECASE)
# 3. Handle Paragraphs and Line Breaks
text = re.sub(r'</p>', '\n\n', text, flags=re.IGNORECASE)
text = re.sub(r'<br/?>', '\n', text, flags=re.IGNORECASE)
# 4. Strip all remaining, unsupported HTML tags
clean_text = re.sub(r'<[^>]+>', '', text)
# 5. Unescape HTML entities
clean_text = unescape(clean_text)
# 6. Clean up excessive whitespace/newlines
clean_text = re.sub(r'(\n\s*){3,}', '\n\n', clean_text).strip()
return clean_text
def hashtags_list(self, hash_tags_str):
"""Convert comma-separated hashtags string to list"""
if not hash_tags_str:
return ["#HigherEd", "#Hiring", "#UniversityJobs"]
tags = [tag.strip() for tag in hash_tags_str.split(',') if tag.strip()]
tags = [tag if tag.startswith('#') else f'#{tag}' for tag in tags]
if not tags:
return ["#HigherEd", "#Hiring", "#UniversityJobs"]
return tags
def _build_post_message(self, job_posting):
"""Centralized logic to construct the professionally formatted text message."""
message_parts = [
f"🔥 *Job Alert!* Were looking for a talented professional to join our team.",
f"👉 **{job_posting.title}** 👈",
]
if job_posting.department:
message_parts.append(f"*{job_posting.department}*")
message_parts.append("\n" + "=" * 25 + "\n")
# KEY DETAILS SECTION
details_list = []
if job_posting.job_type:
details_list.append(f"💼 Type: {job_posting.get_job_type_display()}")
if job_posting.get_location_display() != 'Not specified':
details_list.append(f"📍 Location: {job_posting.get_location_display()}")
if job_posting.workplace_type:
details_list.append(f"🏠 Workplace: {job_posting.get_workplace_type_display()}")
if job_posting.salary_range:
details_list.append(f"💰 Salary: {job_posting.salary_range}")
if details_list:
message_parts.append("*Key Information*:")
message_parts.extend(details_list)
message_parts.append("\n")
# DESCRIPTION SECTION
clean_description = self.clean_html_for_social_post(job_posting.description)
if clean_description:
message_parts.append(f"🔎 *About the Role:*\n{clean_description}")
# CALL TO ACTION
if job_posting.application_url:
message_parts.append(f"\n\n---")
message_parts.append(f"🔗 **APPLY NOW:** {job_posting.application_url}")
# HASHTAGS
hashtags = self.hashtags_list(job_posting.hash_tags)
if job_posting.department:
dept_hashtag = f"#{job_posting.department.replace(' ', '')}"
hashtags.insert(0, dept_hashtag)
message_parts.append("\n" + " ".join(hashtags))
return "\n".join(message_parts)
def create_job_post_with_image(self, job_posting, image_file, person_urn, asset_urn):
"""Step 3: Creates the final LinkedIn post payload with the image asset."""
message = self._build_post_message(job_posting)
url = "https://api.linkedin.com/v2/ugcPosts"
headers = {
'Authorization': f'Bearer {self.access_token}',
'Content-Type': 'application/json',
'X-Restli-Protocol-Version': '2.0.0'
}
payload = {
"author": f"urn:li:person:{person_urn}",
"lifecycleState": "PUBLISHED",
"specificContent": {
"com.linkedin.ugc.ShareContent": {
"shareCommentary": {"text": message},
"shareMediaCategory": "IMAGE",
"media": [{
"status": "READY",
"media": asset_urn,
"description": {"text": job_posting.title},
"originalUrl": job_posting.application_url,
"title": {"text": "Apply Now"}
}]
}
},
"visibility": {
"com.linkedin.ugc.MemberNetworkVisibility": "PUBLIC"
}
}
response = requests.post(url, headers=headers, json=payload, timeout=60)
response.raise_for_status()
post_id = response.headers.get('x-restli-id', '')
post_url = f"https://www.linkedin.com/feed/update/{quote(post_id)}/" if post_id else ""
return {
'success': True,
'post_id': post_id,
'post_url': post_url,
'status_code': response.status_code
}
def create_job_post(self, job_posting):
"""Create a job announcement post on LinkedIn (with image support)"""
"""Main method to create a job announcement post (Image or Text)."""
if not self.access_token:
raise Exception("Not authenticated with LinkedIn")
try:
# Get user profile for person URN
profile = self.get_user_profile()
person_urn = profile.get('sub')
if not person_urn:
raise Exception("Could not retrieve LinkedIn user ID")
# Check if job has an image
asset_urn = None
has_image = False
# Check for image and attempt post
try:
image_upload = job_posting.files.first()
has_image = image_upload and image_upload.linkedinpost_image
# Assuming correct model path: job_posting.related_model_name.first().image_field_name
image_upload = job_posting.post_images.first().post_image
has_image = image_upload is not None
except Exception:
has_image = False
pass # No image available
if has_image:
# === POST WITH IMAGE ===
try:
# Step 1: Register image upload
# Step 1: Register
upload_info = self.register_image_upload(person_urn)
asset_urn = upload_info['asset']
# Step 2: Upload image
# Step 2: Upload and WAIT FOR READY (Crucial for 422 fix)
self.upload_image_to_linkedin(
upload_info['upload_url'],
image_upload.linkedinpost_image
image_upload,
asset_urn
)
# Step 3: Create post with image
return self.create_job_post_with_image(
job_posting,
image_upload.linkedinpost_image,
person_urn,
upload_info['asset']
job_posting, image_upload, person_urn, asset_urn
)
except Exception as e:
logger.error(f"Image upload failed: {e}")
# Fall back to text-only post if image upload fails
has_image = False
logger.error(f"Image post failed, falling back to text: {e}")
# Force fallback to text-only if image posting fails
has_image = False
# === FALLBACK TO URL/ARTICLE POST ===
# Add unique timestamp to prevent duplicates
from django.utils import timezone
import random
unique_suffix = f"\n\nPosted: {timezone.now().strftime('%b %d, %Y at %I:%M %p')} (ID: {random.randint(1000, 9999)})"
# === FALLBACK TO PURE TEXT POST (shareMediaCategory: NONE) ===
message = self._build_post_message(job_posting)
message_parts = [f"🚀 **We're Hiring: {job_posting.title}**"]
if job_posting.department:
message_parts.append(f"**Department:** {job_posting.department}")
if job_posting.description:
message_parts.append(f"\n{job_posting.description}")
details = []
if job_posting.job_type:
details.append(f"💼 {job_posting.get_job_type_display()}")
if job_posting.get_location_display() != 'Not specified':
details.append(f"📍 {job_posting.get_location_display()}")
if job_posting.workplace_type:
details.append(f"🏠 {job_posting.get_workplace_type_display()}")
if job_posting.salary_range:
details.append(f"💰 {job_posting.salary_range}")
if details:
message_parts.append("\n" + " | ".join(details))
if job_posting.application_url:
message_parts.append(f"\n🔗 **Apply now:** {job_posting.application_url}")
hashtags = self.hashtags_list(job_posting.hash_tags)
if job_posting.department:
dept_hashtag = f"#{job_posting.department.replace(' ', '')}"
hashtags.insert(0, dept_hashtag)
message_parts.append("\n\n" + " ".join(hashtags))
message_parts.append(unique_suffix)
message = "\n".join(message_parts)
# 🔥 FIX URL - REMOVE TRAILING SPACES 🔥
url = "https://api.linkedin.com/v2/ugcPosts"
headers = {
'Authorization': f'Bearer {self.access_token}',
@ -211,20 +361,14 @@ class LinkedInService:
"specificContent": {
"com.linkedin.ugc.ShareContent": {
"shareCommentary": {"text": message},
"shareMediaCategory": "ARTICLE",
"media": [{
"status": "READY",
"description": {"text": f"Apply for {job_posting.title} at our university!"},
"originalUrl": job_posting.application_url,
"title": {"text": job_posting.title}
}]
"shareMediaCategory": "NONE", # Pure text post
}
},
"visibility": {
"com.linkedin.ugc.MemberNetworkVisibility": "PUBLIC"
}
}
response = requests.post(url, headers=headers, json=payload, timeout=60)
response.raise_for_status()
@ -244,18 +388,4 @@ class LinkedInService:
'success': False,
'error': str(e),
'status_code': getattr(e.response, 'status_code', 500) if hasattr(e, 'response') else 500
}
def hashtags_list(self,hash_tags_str):
"""Convert comma-separated hashtags string to list"""
if not hash_tags_str:
return [""]
tags = [tag.strip() for tag in hash_tags_str.split(',') if tag.strip()]
if not tags:
return ["#HigherEd", "#Hiring", "#FacultyJobs", "#UniversityJobs"]
return tags
}

View File

@ -0,0 +1,14 @@
# Generated by Django 5.2.7 on 2025-10-13 14:18
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('recruitment', '0003_rename_start_date_jobposting_joining_date_and_more'),
('recruitment', '0008_zoommeeting_password'),
]
operations = [
]

View File

@ -0,0 +1,14 @@
# Generated by Django 5.2.7 on 2025-10-13 15:19
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('recruitment', '0009_merge_20251013_1714'),
('recruitment', '0009_merge_20251013_1718'),
]
operations = [
]

View File

@ -0,0 +1,25 @@
# Generated by Django 5.2.7 on 2025-10-13 22:16
import django.db.models.deletion
import recruitment.validators
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('recruitment', '0010_merge_20251013_1819'),
]
operations = [
migrations.AlterField(
model_name='jobpostingimage',
name='job',
field=models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='post_images', to='recruitment.jobposting'),
),
migrations.AlterField(
model_name='jobpostingimage',
name='post_image',
field=models.ImageField(upload_to='post/', validators=[recruitment.validators.validate_image_size]),
),
]

View File

@ -1,6 +1,6 @@
from django.db import models
from django.utils import timezone
from .validators import validate_hash_tags
from .validators import validate_hash_tags, validate_image_size
from django.contrib.auth.models import User
from django.core.validators import URLValidator
from django.utils.translation import gettext_lazy as _
@ -249,8 +249,8 @@ class JobPosting(Base):
class JobPostingImage(models.Model):
job=models.ForeignKey('JobPosting',on_delete=models.CASCADE,related_name='post_images')
post_image = models.ImageField(upload_to='post/')
job=models.OneToOneField('JobPosting',on_delete=models.CASCADE,related_name='post_images')
post_image = models.ImageField(upload_to='post/',validators=[validate_image_size])
class Candidate(Base):

View File

@ -320,15 +320,22 @@ def job_detail(request, slug):
"""View details of a specific job"""
job = get_object_or_404(JobPosting, slug=slug)
print(job)
# Get all candidates for this job, ordered by most recent
applicants = job.candidates.all().order_by("-created_at")
print(applicants)
# Count candidates by stage for summary statistics
total_applicant = applicants.count()
applied_count = applicants.filter(stage="Applied").count()
exam_count=applicants.filter(stage="Exam").count
interview_count = applicants.filter(stage="Interview").count()
offer_count = applicants.filter(stage="Offer").count()
status_form = JobPostingStatusForm(instance=job)
image_upload_form=JobPostingImageForm(instance=job)
@ -359,6 +366,7 @@ def job_detail(request, slug):
"applicants": applicants,
"total_applicants": total_applicant,
"applied_count": applied_count,
'exam_count':exam_count,
"interview_count": interview_count,
"offer_count": offer_count,
'status_form':status_form,
@ -747,6 +755,7 @@ def form_builder(request, template_id=None):
template = get_object_or_404(
FormTemplate, id=template_id, created_by=request.user
)
context['template']=template
context["template_id"] = template.id
context["template_name"] = template.name
return render(request, "forms/form_builder.html", context)
@ -1492,9 +1501,14 @@ def candidate_screening_view(request, slug):
Manage candidate tiers and stage transitions
"""
job = get_object_or_404(JobPosting, slug=slug)
applied_count=job.candidates.filter(stage='Applied').count()
exam_count=job.candidates.filter(stage='Exam').count()
interview_count=job.candidates.filter(stage='interview').count()
offer_count=job.candidates.filter(stage='Offer').count()
# Get all candidates for this job, ordered by match score (descending)
candidates = job.candidates.filter(stage="Applied").order_by("-match_score")
# Get tier categorization parameters
# tier1_count = int(request.GET.get("tier1_count", 100))
@ -1592,19 +1606,55 @@ def candidate_screening_view(request, slug):
# messages.info(request, "All Tier 1 candidates are already marked as Candidates")
# Group candidates by current stage for display
stage_groups = {
"Applied": candidates.filter(stage="Applied"),
"Exam": candidates.filter(stage="Exam"),
"Interview": candidates.filter(stage="Interview"),
"Offer": candidates.filter(stage="Offer"),
}
# stage_groups = {
# "Applied": candidates.filter(stage="Applied"),
# "Exam": candidates.filter(stage="Exam"),
# "Interview": candidates.filter(stage="Interview"),
# "Offer": candidates.filter(stage="Offer"),
# }
min_ai_score_str = request.GET.get('min_ai_score')
tier1_count_str = request.GET.get('tier1_count')
try:
# Check if the string value exists and is not an empty string before conversion
if min_ai_score_str:
min_ai_score = int(min_ai_score_str)
else:
min_ai_score = 0
if tier1_count_str:
tier1_count = int(tier1_count_str)
else:
tier1_count = 0
except ValueError:
# This catches if the user enters non-numeric text (e.g., "abc")
min_ai_score = 0
tier1_count = 0
print(min_ai_score)
print(tier1_count)
# You can now safely use min_ai_score and tier1_count as integers (0 or greater)
if min_ai_score > 0:
candidates = candidates.filter(match_score__gte=min_ai_score)
print(candidates)
if tier1_count > 0:
candidates = candidates[:tier1_count]
context = {
"job": job,
"candidates": candidates,
# "stage_groups": stage_groups,
# "tier1_count": tier1_count,
# "total_candidates": candidates.count(),
'min_ai_score':min_ai_score,
'tier1_count':tier1_count,
'applied_count':applied_count,
'exam_count':exam_count,
'interview_count':interview_count,
'offer_count':offer_count
}
return render(request, "recruitment/candidate_screening_view.html", context)

View File

@ -307,7 +307,7 @@
</span>
</a>
</li> {% endcomment %}
<li class="nav-item me-2">
<li class="nav-item me-4">
<a class="nav-link {% if request.resolver_match.url_name == 'job_list' %}active{% endif %}" href="{% url 'job_list' %}">
<span class="d-flex align-items-center gap-2">
{% include "icons/jobs.html" %}
@ -316,7 +316,7 @@
</a>
</li>
<li class="nav-item me-2">
{% comment %} <li class="nav-item me-2">
<a class="nav-link {% if request.resolver_match.url_name == 'form_templates_list' %}active{% endif %}" href="{% url 'form_templates_list' %}">
<span class="d-flex align-items-center gap-2">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6">
@ -327,18 +327,18 @@
</span>
</a>
</li>
</li> {% endcomment %}
<li class="nav-item me-2">
<li class="nav-item me-4">
<a class="nav-link {% if request.resolver_match.url_name == 'candidate_list' %}active{% endif %}" href="{% url 'candidate_list' %}">
<span class="d-flex align-items-center gap-2">
{% include "icons/users.html" %}
{% trans "Candidates" %}
{% trans "Applicants" %}
</span>
</a>
</li>
<li class="nav-item me-2">
<li class="nav-item me-4">
<a class="nav-link {% if request.resolver_match.url_name == 'list_meetings' %}active{% endif %}" href="{% url 'list_meetings' %}">
<span class="d-flex align-items-center gap-2">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6">
@ -351,7 +351,7 @@
</li>
<li class="nav-item me-2">
<li class="nav-item me-4">
<a class="nav-link {% if request.resolver_match.url_name == 'training_list' %}active{% endif %}" href="{% url 'training_list' %}">
<span class="d-flex align-items-center gap-2">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6">

View File

@ -779,10 +779,15 @@
<a href="{% url 'dashboard' %}" style="color: #6c757d !important; text-decoration: none !important;">Home</a>
/
</span>
<span class="me-2">
<a href="{% url 'job_list' %}" style="color: #6c757d !important; text-decoration: none !important;">Jobs</a>
/
</span>
<span class="me-2">
<a href="{% url 'job_detail' template.job.slug %}" style="color: #6c757d !important; text-decoration: none !important;">Job:({{template.job.title}})</a>
/
</span>
<span style="color: #6c757d; font-weight: 600;">Form Builder</span>
</div>
</nav>

View File

@ -160,126 +160,6 @@
border: 1px solid;
}
/* ==================================== */
/* MULTI-COLORED CANDIDATE STAGE TRACKER */
/* ==================================== */
.progress-stages {
position: relative;
padding: 1.5rem 0;
}
.progress-stages a {
text-decoration: none;
color: inherit;
}
.stage-item {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
min-width: 60px;
transition: all 0.3s ease;
color: var(--stage-inactive);
}
.stage-icon {
width: 36px;
height: 36px;
border-radius: 50%;
background-color: #e9ecef;
color: var(--stage-inactive);
display: flex;
align-items: center;
justify-content: center;
font-size: 0.9rem;
z-index: 10;
border: 2px solid white;
box-shadow: 0 0 0 2px #e9ecef;
transition: all 0.3s ease;
}
/* ---------------- STAGE SPECIFIC COLORS ---------------- */
/* APPLIED STAGE (Teal) */
.stage-item[data-stage="Applied"].completed .stage-icon,
.stage-item[data-stage="Applied"].active .stage-icon {
background-color: var(--stage-applied);
color: white;
}
.stage-item[data-stage="Applied"].active { color: var(--stage-applied); }
/* EXAM STAGE (Cyan/Info) */
.stage-item[data-stage="Exam"].completed .stage-icon,
.stage-item[data-stage="Exam"].active .stage-icon {
background-color: var(--stage-exam);
color: white;
}
.stage-item[data-stage="Exam"].active { color: var(--stage-exam); }
/* INTERVIEW STAGE (Yellow/Warning) */
.stage-item[data-stage="Interview"].completed .stage-icon,
.stage-item[data-stage="Interview"].active .stage-icon {
background-color: var(--stage-interview);
color: var(--kaauh-primary-text); /* Dark text for light background */
}
.stage-item[data-stage="Interview"].active { color: var(--stage-interview); }
/* OFFER STAGE (Green/Success) */
.stage-item[data-stage="Offer"].completed .stage-icon,
.stage-item[data-stage="Offer"].active .stage-icon {
background-color: var(--stage-offer);
color: white;
}
.stage-item[data-stage="Offer"].active { color: var(--stage-offer); }
/* ---------------- GENERIC ACTIVE/COMPLETED STYLING ---------------- */
/* Active State (Applies glow/scale to current stage) */
.stage-item.active .stage-icon {
box-shadow: 0 0 0 4px rgba(0, 99, 110, 0.4);
transform: scale(1.1);
}
.stage-item.active .stage-count {
font-weight: 700;
}
/* Completed State (Applies dark text color to completed stages) */
.stage-item.completed {
color: var(--kaauh-primary-text);
}
/* Connector Line */
.stage-connector {
flex-grow: 1;
height: 3px;
background-color: #e9ecef;
margin: 0 0.5rem;
position: relative;
top: -18px;
z-index: 1;
transition: background-color 0.3s ease;
}
/* Line in completed state (Kept the line teal/primary for consistency) */
.stage-connector.completed {
background-color: var(--kaauh-teal);
}
/* Labels and counts */
.stage-label {
font-size: 0.75rem;
margin-top: 0.4rem;
font-weight: 500;
white-space: nowrap;
}
.stage-count {
font-size: 0.9rem;
font-weight: 600;
margin-top: 0.1rem;
color: #6c757d;
}
</style>
{% endblock %}
@ -389,11 +269,14 @@
<div class="col-md-6">
<button
type="button"
class="btn btn-outline-secondary"
class="btn btn-main-action"
id="copyJobLinkButton"
data-url="{{ job.application_url }}">
<i class="fas fa-link me-1"></i>
{% trans "Copy and Share Public Link" %}
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12h3.75M9 15h3.75M9 18h3.75m3 .75H18a2.25 2.25 0 0 0 2.25-2.25V6.108c0-1.135-.845-2.098-1.976-2.192a48.424 48.424 0 0 0-1.123-.08m-5.801 0c-.065.21-.1.433-.1.664 0 .414.336.75.75.75h4.5a.75.75 0 0 0 .75-.75 2.25 2.25 0 0 0-.1-.664m-5.8 0A2.251 2.251 0 0 1 13.5 2.25H15c1.012 0 1.867.668 2.15 1.586m-5.8 0c-.376.023-.75.05-1.124.08C9.095 4.01 8.25 4.973 8.25 6.108V8.25m0 0H4.875c-.621 0-1.125.504-1.125 1.125v11.25c0 .621.504 1.125 1.125 1.125h9.75c.621 0 1.125-.504 1.125-1.125V9.375c0-.621-.504-1.125-1.125-1.125H8.25ZM6.75 12h.008v.008H6.75V12Zm0 3h.008v.008H6.75V15Zm0 3h.008v.008H6.75V18Z" />
</svg>
{% trans "Share Public Link" %}
</button>
<span id="copyFeedback" class="text-success ms-2 small" style="display:none;">
@ -487,69 +370,13 @@
{# RIGHT COLUMN: TABBED CARDS #}
<div class="col-lg-4 ">
<div class="card shadow-sm no-hover mb-4">
<div class="card-body p-4">
<h6 class="text-muted mb-4">{% trans "Applicant Tracking" %}</h6>
<div class="progress-stages">
<div class="d-flex justify-content-between align-items-center">
{% comment %} STAGE 1: Applied {% endcomment %}
<a href="{% url 'candidate_screening_view' job.slug %}"
class="stage-item {% if current_stage == 'Applied' %}active{% endif %} {% if current_stage != 'Applied' and candidate.stage_history_has.Applied %}completed{% endif %}"
data-stage="Applied">
<div class="stage-icon">
<i class="fas fa-file-signature"></i>
<div class="card shadow-sm no-hover mb-4">
<div class="card-body p-4">
<h6 class="text-muted mb-4">{% trans "Applicant Tracking" %}</h6>
{% include 'jobs/partials/applicant_tracking.html' %}
</div>
<div class="stage-label">{% trans "Screened" %}</div>
<div class="stage-count">{{ applied_count|default:"0" }}</div>
</a>
{% comment %} CONNECTOR 1 -> 2 {% endcomment %}
<div class="stage-connector {% if current_stage != 'Applied' and candidate.stage_history_has.Exam %}completed{% endif %}"></div>
{% comment %} STAGE 2: Exam {% endcomment %}
<a href="{% url 'candidate_exam_view' job.slug %}"
class="stage-item {% if current_stage == 'Exam' %}active{% endif %} {% if current_stage != 'Exam' and candidate.stage_history_has.Exam %}completed{% endif %}"
data-stage="Exam">
<div class="stage-icon">
<i class="fas fa-clipboard-check"></i>
</div>
<div class="stage-label">{% trans "Exam" %}</div>
<div class="stage-count">{{ exam_count|default:"0" }}</div>
</a>
{% comment %} CONNECTOR 2 -> 3 {% endcomment %}
<div class="stage-connector {% if current_stage != 'Exam' and candidate.stage_history_has.Interview %}completed{% endif %}"></div>
{% comment %} STAGE 3: Interview {% endcomment %}
<a href="{% url 'candidate_interview_view' job.slug %}"
class="stage-item {% if current_stage == 'Interview' %}active{% endif %} {% if current_stage != 'Interview' and candidate.stage_history_has.Interview %}completed{% endif %}"
data-stage="Interview">
<div class="stage-icon">
<i class="fas fa-comments"></i>
</div>
<div class="stage-label">{% trans "Interview" %}</div>
<div class="stage-count">{{ interview_count|default:"0" }}</div>
</a>
{% comment %} CONNECTOR 3 -> 4 {% endcomment %}
<div class="stage-connector {% if current_stage != 'Interview' and candidate.stage_history_has.Offer %}completed{% endif %}"></div>
{% comment %} STAGE 4: Offer {% endcomment %}
<a href="{% url 'job_candidates_list' job.slug %}?stage=Offer"
class="stage-item {% if current_stage == 'Offer' %}active{% endif %} {% if current_stage != 'Offer' and candidate.stage_history_has.Offer %}completed{% endif %}"
data-stage="Offer">
<div class="stage-icon">
<i class="fas fa-handshake"></i>
</div>
<div class="stage-label">{% trans "Offer" %}</div>
<div class="stage-count">{{ offer_count|default:"0" }}</div>
</a>
</div>
</div>
</div>
</div>
<div class="card shadow-sm no-hover" style="height:400px;">
<div class="card shadow-sm no-hover" style="height:350px;">
{# RIGHT TABS NAVIGATION #}
<ul class="nav nav-tabs right-column-tabs" id="rightJobTabs" role="tablist">
@ -575,7 +402,7 @@
{# TAB 1: APPLICANTS CONTENT #}
<div class="tab-pane fade show active" id="applicants-pane" role="tabpanel" aria-labelledby="applicants-tab">
<h5 class="mb-3">{% trans "Total Applicants" %} (<span id="total_candidates">{{ total_applicants }}</span>)</h5>
{% if total_applicants > 0 %}
{% comment %} {% if total_applicants > 0 %}
<div class="row mb-4 applicant-stats">
<div class="col-4">
<div class="stat-item">
@ -602,15 +429,15 @@
</a>
</div>
{% endif %}
{% endif %} {% endcomment %}
<div class="d-grid gap-2">
<div class="d-grid gap-4">
<a href="{% url 'candidate_create_for_job' job.slug %}" class="btn btn-main-action">
<i class="fas fa-user-plus"></i> {% trans "Create Applicant" %}
</a>
<a href="{% url 'candidate_screening_view' job.slug %}" class="btn btn-main-action">
<i class="fas fa-layer-group"></i> {% trans "Manage Applicants" %}
</a>
</a>
</div>
</div>
@ -699,12 +526,12 @@
<i class="fas fa-list-alt me-1"></i> {% trans "View All Existing Forms" %}
</a> {% endcomment %}
<a href="{% url 'candidate_create_for_job' job.slug %}" class="btn btn-main-action">
{% comment %} <a href="{% url 'candidate_create_for_job' job.slug %}" class="btn btn-main-action">
<i class="fas fa-user-plus"></i> {% trans "Create Candidate" %}
</a>
<a href="{% url 'candidate_screening_view' job.slug %}" class="btn btn-main-action">
<i class="fas fa-layer-group"></i> {% trans "Manage Tiers" %}
</a>
</a> {% endcomment %}
</div>
</div>

View File

@ -11,11 +11,16 @@
--kaauh-teal-dark: #004a53;
--kaauh-border: #eaeff3;
--kaauh-primary-text: #343a40;
--kaauh-success: #28a745;
--kaauh-danger: #dc3545;
}
/* Primary Color Overrides */
.text-primary-theme { color: var(--kaauh-teal) !important; }
.bg-primary-theme { background-color: var(--kaauh-teal) !important; }
.text-success { color: var(--kaauh-success) !important; }
.text-danger { color: var(--kaauh-danger) !important; }
.text-info { color: #17a2b8 !important; }
/* Enhanced Card Styling */
.card {
@ -66,6 +71,7 @@
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.7px;
color: white;
}
.bg-DRAFT { background-color: #6c757d !important; }
.bg-ACTIVE { background-color: var(--kaauh-teal) !important; }
@ -75,92 +81,112 @@
/* --- TABLE ALIGNMENT AND SIZING FIXES --- */
.table {
table-layout: fixed; /* Ensures column widths are respected */
table-layout: fixed;
width: 100%;
border-collapse: collapse;
}
.table thead th {
color: var(--kaauh-primary-text);
font-weight: 500; /* Lighter weight for smaller font */
font-size: 0.85rem; /* Smaller font size for header text */
font-weight: 600;
font-size: 0.85rem;
vertical-align: middle;
border-bottom: 2px solid var(--kaauh-border);
padding: 0.5rem 0.25rem; /* Reduced vertical and horizontal padding */
padding: 0.5rem 0.25rem;
}
.table-hover tbody tr:hover {
background-color: #f3f7f9;
}
/* Optimized Main Table Column Widths (Total must be 100%) */
.table th:nth-child(1) { width: 22%; } /* Job ID (Tight) */
.table th:nth-child(1) { width: 22%; }
.table th:nth-child(2) { width: 12%; }
.table th:nth-child(3) { width: 8%; }
.table th:nth-child(4) { width: 8%; }
.table th:nth-child(5) { width: 50%; }
.table th:nth-child(2) { width: 12%; } /* Source (Tight) */
.table th:nth-child(3) { width: 8%; } /* Actions (Tight, icon buttons) */
.table th:nth-child(4) { width: 8%; } /* Form (Tight, icon buttons) */
.table th:nth-child(5) { width: 50%; } /* Candidate Metrics (colspan=7) */
/* NESTED TABLE STYLING FOR CANDIDATE MANAGEMENT HEADER */
.nested-header-table {
margin: 0;
padding: 0;
width: 100%;
border-collapse: collapse;
table-layout: fixed; /* CRITICAL for 1:1 data alignment */
}
.nested-header-table thead th {
background-color: transparent;
border: none;
padding: 0.3rem 0 0.1rem 0; /* Reduced padding here too */
/* Candidate Management Header Row (The one with P/F) */
.nested-metrics-row th {
font-weight: 500;
text-align: center;
color: #6c757d;
font-size: 0.75rem; /* Even smaller font for nested headers */
width: calc(100% / 7);
font-size: 0.75rem;
padding: 0.3rem 0;
border-bottom: 2px solid var(--kaauh-teal);
text-align: center;
border-left: 1px solid var(--kaauh-border);
}
/* Explicit widths are technically defined by the 1/7 rule, but keeping them for clarity/safety */
.nested-header-table thead th:nth-child(1),
.nested-header-table thead th:nth-child(2),
.nested-header-table thead th:nth-child(5) {
width: calc(100% / 7);
.nested-metrics-row th {
width: calc(50% / 7);
}
.nested-header-table thead th:nth-child(3),
.nested-header-table thead th:nth-child(4) {
width: calc(100% / 7 * 2);
.nested-metrics-row th[colspan="2"] {
width: calc(50% / 7 * 2);
position: relative;
}
/* Inner Nested Table (P/F) */
/* Inner P/F Headers */
.nested-stage-metrics {
width: 100%;
border-collapse: collapse;
table-layout: fixed;
}
.nested-stage-metrics thead th {
padding: 0.1rem 0; /* Very minimal padding */
display: flex;
justify-content: space-around;
padding-top: 5px;
font-weight: 600;
color: var(--kaauh-teal-dark);
font-size: 0.7rem; /* Smallest font size */
width: 50%;
font-size: 0.7rem;
}
/* Main TH for Candidate Management Header */
.candidate-management-header {
/* Main TH for Candidate Management Header Title */
.candidate-management-header-title {
text-align: center;
padding: 0;
padding: 0.5rem 0.25rem;
border-left: 2px solid var(--kaauh-teal);
border-right: 1px solid var(--kaauh-border) !important;
font-weight: 600;
color: var(--kaauh-teal-dark);
}
/* Candidate Management Data Cells (7 columns total now) */
/* Candidate Management Data Cells (7 columns total) */
.candidate-data-cell {
text-align: center;
vertical-align: middle;
font-weight: 600;
font-size: 0.9rem; /* Keep data readable */
font-size: 0.9rem;
padding: 0;
}
.table tbody td.candidate-data-cell:not(:first-child) {
border-left: 1px solid var(--kaauh-border);
}
.table tbody tr td:nth-child(5) {
border-left: 2px solid var(--kaauh-teal);
}
.candidate-data-cell a {
display: block;
text-decoration: none;
padding: 0.4rem 0; /* Minimized vertical padding */
padding: 0.4rem 0.25rem;
}
/* Fix action button sizing */
.btn-group-sm > .btn {
padding: 0.2rem 0.4rem;
font-size: 0.75rem;
}
/* Additional CSS for Card View layout */
.card-view .card {
height: 100%;
}
.card-view .card-title {
color: var(--kaauh-teal-dark);
font-weight: 700;
font-size: 1.25rem;
}
.card-view .card-body {
display: flex;
flex-direction: column;
justify-content: space-between;
flex-grow: 1;
}
.card-view .list-unstyled li {
margin-bottom: 0.25rem;
}
</style>
{% endblock %}
@ -215,63 +241,60 @@
</div>
</div>
{% comment %} --- START OF TABLE VIEW (Data relied upon context variable 'jobs') --- {% endcomment %}
{# --- START OF JOB LIST CONTAINER --- #}
<div id="job-list">
{% comment %} Placeholder for View Switcher {% endcomment %}
{% include "includes/_list_view_switcher.html" with list_id="job-list" %}
{# View Switcher (Contains the Card/Table buttons and JS/CSS logic) #}
{% include "includes/_list_view_switcher.html" with list_id="job-list" %}
{# 1. TABLE VIEW (Default Active) #}
<div class="table-view active">
<div class="card shadow-sm">
<div class="table-responsive ">
<table class="table table-hover align-middle mb-0 table-sm">
{# --- Corrected Multi-Row Header Structure --- #}
<thead>
<tr>
<th scope="col">{% trans "Job ID" %}</th>
{% comment %} <th scope="col">{% trans "Job Title" %}</th>
<th scope="col">{% trans "Status" %}</th> {% endcomment %}
<th scope="col">{% trans "Source" %}</th>
<th scope="col">{% trans "Actions" %}</th>
<th scop="col" class="text-center">{% trans "Manage Forms" %}</th>
<th scope="col" rowspan="2" style="width: 22%;">{% trans "Job Title / ID" %}</th>
<th scope="col" rowspan="2" style="width: 12%;">{% trans "Source" %}</th>
<th scope="col" rowspan="2" style="width: 8%;">{% trans "Actions" %}</th>
<th scope="col" rowspan="2" class="text-center" style="width: 8%;">{% trans "Manage Forms" %}</th>
<th scope="col" colspan="7" class="candidate-management-header">
<th scope="col" colspan="7" class="candidate-management-header-title">
{% trans "Applicants Metrics" %}
<table class="nested-header-table">
<thead>
<tr>
<th style="width: 14.28%;">{% trans "Applied" %}</th>
<th style="width: 14.28%;">{% trans "Screened" %}</th>
<th colspan="2">{% trans "Exam" %}
<table class="nested-stage-metrics">
<thead>
<th>P</th>
<th>F</th>
</thead>
</table>
</th>
<th colspan="2">{% trans "Interview" %}
<table class="nested-stage-metrics">
<thead>
<th>P</th>
<th>F</th>
</thead>
</table>
</th>
<th style="width: 14.28%;">{% trans "Offer" %}</th>
</tr>
</thead>
</table>
</th>
</tr>
<tr class="nested-metrics-row">
<th style="width: calc(50% / 7);">{% trans "Applied" %}</th>
<th style="width: calc(50% / 7);">{% trans "Screened" %}</th>
<th colspan="2" style="width: calc(50% / 7 * 2);">{% trans "Exam" %}
<div class="nested-stage-metrics">
<span>P</span>
<span>F</span>
</div>
</th>
<th colspan="2" style="width: calc(50% / 7 * 2);">{% trans "Interview" %}
<div class="nested-stage-metrics">
<span>P</span>
<span>F</span>
</div>
</th>
<th style="width: calc(50% / 7);">{% trans "Offer" %}</th>
</tr>
</thead>
<tbody>
{% comment %} This loop relies on the 'jobs' variable passed from the Django view {% endcomment %}
{% for job in jobs %}
<tr>
<td class="fw-medium text-primary-theme">{{ job }}</td>
{% comment %} <td class="fw-medium text-primary-theme">{{ job.title }}</td>
<td><span class="badge bg-{{ job.status }} status-badge">{{ job.status }}</span></td> {% endcomment %}
<td class="fw-medium text-primary-theme">
{{ job.title }}
<br>
<small class="text-muted">{{ job.pk }} / </small>
<span class="badge bg-{{ job.status }} status-badge">{{ job.status }}</span>
</td>
<td>{{ job.get_source }}</td>
<td>
<div class="btn-group btn-group-sm" role="group">
@ -283,10 +306,10 @@
</a>
</div>
</td>
<td class="text-end">
<td class="text-center">
<div class="btn-group btn-group-sm" role="group">
{% if job.form_template %}
<a href="{% url 'form_wizard' job.form_template.pk %}" class="btn btn-outline-primary" title="{% trans 'Preview' %}">
<a href="{% url 'form_wizard' job.form_template.pk %}" class="btn btn-outline-secondary" title="{% trans 'Preview' %}">
<i class="fas fa-eye"></i>
</a>
<a href="{% url 'form_builder' job.form_template.id %}" class="btn btn-outline-secondary" title="{% trans 'Edit' %}">
@ -299,7 +322,7 @@
</div>
</td>
{# CANDIDATE MANAGEMENT DATA - 7 SEPARATE COLUMNS CORRESPONDING TO THE HEADER #}
{# CANDIDATE MANAGEMENT DATA - URLS NEUTRALIZED #}
<td class="candidate-data-cell text-primary-theme"><a href="#" class="text-primary-theme">{% if job.metrics.applied %}{{ job.metrics.applied }}{% else %}-{% endif %}</a></td>
<td class="candidate-data-cell text-info"><a href="#" class="text-info">{% if job.metrics.screening %}{{ job.metrics.screening }}{% else %}-{% endif %}</a></td>
<td class="candidate-data-cell text-success"><a href="#" class="text-success">{% if job.metrics.exam_p %}{{ job.metrics.exam_p }}{% else %}-{% endif %}</a></td>
@ -314,7 +337,53 @@
</div>
</div>
</div>
{# 2. CARD VIEW (Previously Missing) - Added Bootstrap row/col structure for layout #}
<div class="card-view row g-4">
{% for job in jobs %}
<div class="col-xl-4 col-lg-6 col-md-6">
<div class="card shadow-sm">
<div class="card-body">
<div class="d-flex justify-content-between align-items-start mb-2">
<h5 class="card-title mb-0">{{ job.title }}</h5>
<span class="badge bg-{{ job.status }} status-badge">{{ job.status }}</span>
</div>
<p class="text-muted small mb-3">ID: {{ job.pk }} | Source: {{ job.get_source }}</p>
<ul class="list-unstyled small mb-3">
<li><i class="fas fa-users text-primary-theme me-2"></i>{% trans "Applicants" %}:{{ job.metrics.applied|default:"0" }}</li>
<li><i class="fas fa-clipboard-check text-success me-2"></i> {% trans "Offers Made" %}: {{ job.metrics.offer|default:"0" }}</li>
<li><i class="fas fa-file-alt text-info me-2"></i> {% trans "Form" %}:{% if job.form_template %}
<a href="{% url 'form_wizard' job.form_template.pk %}" class="text-info">{{ job.form_template.name }}</a>
{% else %}
{% trans "N/A" %}
{% endif %}
</li>
</ul>
<div class="d-flex justify-content-between mt-auto pt-3 border-top">
<a href="{% url 'job_detail' job.slug %}" class="btn btn-outline-secondary btn-sm" title="{% trans 'View' %}">
<i class="fas fa-eye me-1"></i> {% trans "Details" %}
</a>
<div class="btn-group btn-group-sm">
<a href="{% url 'job_update' job.slug %}" class="btn btn-outline-secondary" title="{% trans 'Edit Job' %}">
<i class="fas fa-edit"></i>
</a>
{% if job.form_template %}
<a href="{% url 'form_template_submissions_list' job.form_template.slug %}" class="btn btn-outline-secondary" title="{% trans 'Submissions' %}">
<i class="fas fa-file-alt"></i>
</a>
{% endif %}
</div>
</div>
</div>
</div>
</div>
{% endfor %}
</div>
{# --- END CARD VIEW --- #}
</div>
{# --- END OF JOB LIST CONTAINER --- #}
{% comment %} Fallback/Empty State {% endcomment %}
{% if not jobs and not job_list_data and not page_obj %}

View File

@ -0,0 +1,183 @@
{% load static i18n %}
<style>
/* ==================================== */
/* MULTI-COLORED CANDIDATE STAGE TRACKER */
/* ==================================== */
.progress-stages {
position: relative;
padding: 1.5rem 0;
}
.progress-stages a {
text-decoration: none;
color: inherit;
}
.stage-item {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
min-width: 60px;
transition: all 0.3s ease;
color: var(--stage-inactive);
}
.stage-icon {
width: 36px;
height: 36px;
border-radius: 50%;
background-color: #e9ecef;
color: var(--stage-inactive);
display: flex;
align-items: center;
justify-content: center;
font-size: 0.9rem;
z-index: 10;
border: 2px solid white;
box-shadow: 0 0 0 2px #e9ecef;
transition: all 0.3s ease;
}
/* ---------------- STAGE SPECIFIC COLORS ---------------- */
/* APPLIED STAGE (Teal) */
.stage-item[data-stage="Applied"].completed .stage-icon,
.stage-item[data-stage="Applied"].active .stage-icon {
background-color: var(--stage-applied);
color: white;
}
.stage-item[data-stage="Applied"].active { color: var(--stage-applied); }
/* EXAM STAGE (Cyan/Info) */
.stage-item[data-stage="Exam"].completed .stage-icon,
.stage-item[data-stage="Exam"].active .stage-icon {
background-color: var(--stage-exam);
color: white;
}
.stage-item[data-stage="Exam"].active { color: var(--stage-exam); }
/* INTERVIEW STAGE (Yellow/Warning) */
.stage-item[data-stage="Interview"].completed .stage-icon,
.stage-item[data-stage="Interview"].active .stage-icon {
background-color: var(--stage-interview);
color: var(--kaauh-primary-text); /* Dark text for light background */
}
.stage-item[data-stage="Interview"].active { color: var(--stage-interview); }
/* OFFER STAGE (Green/Success) */
.stage-item[data-stage="Offer"].completed .stage-icon,
.stage-item[data-stage="Offer"].active .stage-icon {
background-color: var(--stage-offer);
color: white;
}
.stage-item[data-stage="Offer"].active { color: var(--stage-offer); }
/* ---------------- GENERIC ACTIVE/COMPLETED STYLING ---------------- */
/* Active State (Applies glow/scale to current stage) */
.stage-item.active .stage-icon {
box-shadow: 0 0 0 4px rgba(0, 99, 110, 0.4);
transform: scale(1.1);
}
.stage-item.active .stage-count {
font-weight: 700;
}
/* Completed State (Applies dark text color to completed stages) */
.stage-item.completed {
color: var(--kaauh-primary-text);
}
/* Connector Line */
.stage-connector {
flex-grow: 1;
height: 3px;
background-color: #e9ecef;
margin: 0 0.5rem;
position: relative;
top: -18px;
z-index: 1;
transition: background-color 0.3s ease;
}
/* Line in completed state (Kept the line teal/primary for consistency) */
.stage-connector.completed {
background-color: var(--kaauh-teal);
}
/* Labels and counts */
.stage-label {
font-size: 0.75rem;
margin-top: 0.4rem;
font-weight: 500;
white-space: nowrap;
}
.stage-count {
font-size: 0.9rem;
font-weight: 600;
margin-top: 0.1rem;
color: #6c757d;
}
</style>
<div class="progress-stages">
<div class="d-flex justify-content-between align-items-center">
{% comment %} STAGE 1: Applied {% endcomment %}
<a href="{% url 'candidate_screening_view' job.slug %}"
class="stage-item {% if current_stage == 'Applied' %}active{% endif %} {% if current_stage != 'Applied' and candidate.stage_history_has.Applied %}completed{% endif %}"
data-stage="Applied">
<div class="stage-icon ">
<i class="fas fa-file-signature cd_screening"></i>
</div>
<div class="stage-label cd_screening">{% trans "Screened" %}</div>
<div class="stage-count">{{ applied_count|default:"0" }}</div>
</a>
{% comment %} CONNECTOR 1 -> 2 {% endcomment %}
<div class="stage-connector {% if current_stage != 'Applied' and candidate.stage_history_has.Exam %}completed{% endif %}"></div>
{% comment %} STAGE 2: Exam {% endcomment %}
<a href="{% url 'candidate_exam_view' job.slug %}"
class="stage-item {% if current_stage == 'Exam' %}active{% endif %} {% if current_stage != 'Exam' and candidate.stage_history_has.Exam %}completed{% endif %}"
data-stage="Exam">
<div class="stage-icon">
<i class="fas fa-clipboard-check cd_exam"></i>
</div>
<div class="stage-label cd_exam">{% trans "Exam" %}</div>
<div class="stage-count ">{{ exam_count|default:"0" }}</div>
</a>
{% comment %} CONNECTOR 2 -> 3 {% endcomment %}
<div class="stage-connector {% if current_stage != 'Exam' and candidate.stage_history_has.Interview %}completed{% endif %}"></div>
{% comment %} STAGE 3: Interview {% endcomment %}
<a href="{% url 'candidate_interview_view' job.slug %}"
class="stage-item {% if current_stage == 'Interview' %}active{% endif %} {% if current_stage != 'Interview' and candidate.stage_history_has.Interview %}completed{% endif %}"
data-stage="Interview">
<div class="stage-icon">
<i class="fas fa-comments cd_interview"></i>
</div>
<div class="stage-label cd_interview">{% trans "Interview" %}</div>
<div class="stage-count">{{ interview_count|default:"0" }}</div>
</a>
{% comment %} CONNECTOR 3 -> 4 {% endcomment %}
<div class="stage-connector {% if current_stage != 'Interview' and candidate.stage_history_has.Offer %}completed{% endif %}"></div>
{% comment %} STAGE 4: Offer {% endcomment %}
<a href="{% url 'job_candidates_list' job.slug %}?stage=Offer"
class="stage-item {% if current_stage == 'Offer' %}active{% endif %} {% if current_stage != 'Offer' and candidate.stage_history_has.Offer %}completed{% endif %}"
data-stage="Offer">
<div class="stage-icon">
<i class="fas fa-handshake"></i>
</div>
<div class="stage-label">{% trans "Offer" %}</div>
<div class="stage-count">{{ offer_count|default:"0" }}</div>
</a>
</div>
</div>

View File

@ -5,143 +5,143 @@
{% block customCSS %}
<style>
/* Minimal Tier Management Styles */
/* KAAT-S UI Variables */
:root {
--kaauh-teal: #00636e;
--kaauh-teal-dark: #004a53;
--kaauh-border: #eaeff3;
--kaauh-primary-text: #343a40;
--kaauh-success: #28a745;
--kaauh-info: #17a2b8; /* Used for Exam stages (Pending status) */
--kaauh-danger: #dc3545;
--kaauh-warning: #ffc107;
}
/* Primary Color Overrides */
.text-primary-theme { color: var(--kaauh-teal) !important; }
.bg-primary-theme { background-color: var(--kaauh-teal) !important; }
/* 1. Main Container & Card Styling */
.kaauh-card {
border: 1px solid var(--kaauh-border);
border-radius: 0.75rem;
box-shadow: 0 4px 12px rgba(0,0,0,0.06);
background-color: white;
}
/* Dedicated style for the tier control block (consistent with .filter-controls) */
.tier-controls {
background-color: #f8f9fa;
padding: 1rem;
border-radius: 0.375rem;
margin-bottom: 1.5rem;
background-color: var(--kaauh-border); /* Light background for control sections */
border-radius: 0.75rem;
padding: 1.25rem;
margin-bottom: 2rem;
border: 1px solid var(--kaauh-border);
}
.tier-controls .form-row {
display: flex;
align-items: end;
gap: 0.75rem;
gap: 1rem;
}
.tier-controls .form-group {
flex: 1;
margin-bottom: 0;
}
.bulk-update-controls {
background-color: #f8f9fa;
padding: 1rem;
border-radius: 0.375rem;
margin-bottom: 1.5rem;
/* 2. Button Styling (Themed for Main Actions) */
.btn-main-action {
background-color: var(--kaauh-teal);
border-color: var(--kaauh-teal);
color: white;
font-weight: 600;
transition: all 0.2s ease;
}
.stage-groups {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 1rem;
.btn-main-action:hover {
background-color: var(--kaauh-teal-dark);
border-color: var(--kaauh-teal-dark);
box-shadow: 0 4px 8px rgba(0,0,0,0.15);
}
.btn-outline-secondary {
color: var(--kaauh-teal-dark);
border-color: var(--kaauh-teal);
}
.btn-outline-secondary:hover {
background-color: var(--kaauh-teal-dark);
color: white;
border-color: var(--kaauh-teal-dark);
}
/* Style for Bulk Pass button */
.btn-bulk-pass {
background-color: var(--kaauh-success);
border-color: var(--kaauh-success);
color: white;
font-weight: 500;
}
.btn-bulk-pass:hover {
background-color: #1e7e34;
border-color: #1e7e34;
}
/* Style for Bulk Fail button */
.btn-bulk-fail {
background-color: var(--kaauh-danger);
border-color: var(--kaauh-danger);
color: white;
font-weight: 500;
}
.btn-bulk-fail:hover {
background-color: #bd2130;
border-color: #bd2130;
}
/* 3. Input and Button Height Optimization (Thin look) */
.form-control-sm,
.btn-sm {
/* Reduce vertical padding even more than default Bootstrap 'sm' */
padding-top: 0.2rem !important;
padding-bottom: 0.2rem !important;
/* Ensure a consistent, small height for inputs and buttons */
height: 28px !important;
font-size: 0.8rem !important;
}
.btn-main-action.btn-sm { font-weight: 600 !important; }
/* Container for the timeline include */
.applicant-tracking-timeline {
margin-bottom: 2rem;
}
.stage-group {
border: 1px solid #dee2e6;
border-radius: 0.375rem;
overflow: hidden;
}
.stage-group .stage-header {
background-color: #495057;
color: white;
padding: 0.5rem 0.75rem;
font-weight: 500;
font-size: 0.95rem;
}
.stage-group .stage-body {
padding: 0.75rem;
min-height: 80px;
}
.stage-candidate {
padding: 0.375rem;
border-bottom: 1px solid #f1f3f4;
}
.stage-candidate:last-child {
border-bottom: none;
}
.match-score {
font-weight: 600;
color: #0056b3;
}
.btn-sm {
font-size: 0.75rem;
padding: 0.2rem 0.4rem;
}
/* Tab Styles for Tiers */
.nav-tabs {
border-bottom: 1px solid #dee2e6;
margin-bottom: 1rem;
}
.nav-tabs .nav-link {
border: none;
color: #495057;
font-weight: 500;
padding: 0.5rem 1rem;
transition: all 0.2s;
}
.nav-tabs .nav-link:hover {
border: none;
background-color: #f8f9fa;
}
.nav-tabs .nav-link.active {
color: #495057;
background-color: #fff;
border: none;
border-bottom: 2px solid #007bff;
font-weight: 600;
}
.tier-1 .nav-link {
color: #155724;
}
.tier-1 .nav-link.active {
border-bottom-color: #28a745;
}
.tier-2 .nav-link {
color: #856404;
}
.tier-2 .nav-link.active {
border-bottom-color: #ffc107;
}
.tier-3 .nav-link {
color: #721c24;
}
.tier-3 .nav-link.active {
border-bottom-color: #dc3545;
}
/* Candidate Table Styles */
/* 4. Candidate Table Styling (KAAT-S Look) */
.candidate-table {
table-layout: fixed;
width: 100%;
border-collapse: separate;
border-spacing: 0;
background-color: white;
border-radius: 0.375rem;
border-radius: 0.5rem;
overflow: hidden;
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
}
.candidate-table thead {
background-color: #f8f9fa;
background-color: var(--kaauh-border);
}
.candidate-table th {
padding: 0.75rem;
text-align: left;
font-weight: 600;
font-size: 0.875rem;
color: #495057;
border-bottom: 1px solid #dee2e6;
color: var(--kaauh-teal-dark);
border-bottom: 2px solid var(--kaauh-teal);
font-size: 0.9rem;
vertical-align: middle;
}
.candidate-table td {
padding: 0.75rem;
border-bottom: 1px solid #f1f3f4;
border-bottom: 1px solid var(--kaauh-border);
vertical-align: middle;
font-size: 0.9rem;
}
.candidate-table tbody tr:hover {
background-color: #f8f9fa;
}
.candidate-table tbody tr:last-child td {
border-bottom: none;
background-color: #f1f3f4;
}
.candidate-name {
font-weight: 600;
font-size: 0.95rem;
color: var(--kaauh-primary-text);
}
.candidate-details {
font-size: 0.8rem;
@ -151,211 +151,262 @@
overflow-x: auto;
margin-bottom: 1rem;
}
.stage-badge {
/* 5. Badges */
.ai-score-badge {
background-color: var(--kaauh-teal-dark) !important;
color: white;
font-weight: 700;
padding: 0.4em 0.8em;
border-radius: 0.4rem;
font-size: 0.8rem;
}
.status-badge { /* Used for Exam Status (Passed/Failed/Pending) */
font-size: 0.75rem;
padding: 0.3em 0.7em;
border-radius: 0.35rem;
font-weight: 700;
text-transform: uppercase;
display: inline-block;
}
.bg-success { background-color: var(--kaauh-success) !important; color: white; }
.bg-danger { background-color: var(--kaauh-danger) !important; color: white; }
.bg-info-pending { background-color: var(--kaauh-info) !important; color: white; }
.tier-badge { /* Used for Tier labels */
font-size: 0.75rem;
padding: 0.125rem 0.5rem;
border-radius: 0.5rem;
font-size: 0.7rem;
font-weight: 600;
margin-left: 0.375rem;
margin-left: 0.5rem;
display: inline-block;
}
.stage-Applied {
background-color: #e9ecef;
color: #495057;
}
.stage-Exam {
background-color: #cce5ff;
color: #004085;
}
.stage-Interview {
background-color: #d1ecf1;
color: #0c5460;
}
.stage-Offer {
background-color: #d4edda;
color: #155724;
}
.exam-controls {
display: flex;
align-items: center;
gap: 0.375rem;
margin-top: 0.375rem;
}
.exam-controls select,
.exam-controls input {
font-size: 0.75rem;
padding: 0.125rem 0.25rem;
}
.tier-badge {
font-size: 0.7rem;
padding: 0.125rem 0.375rem;
border-radius: 0.25rem;
background-color: rgba(0,0,0,0.1);
color: #495057;
margin-left: 0.375rem;
.tier-1-badge { background-color: var(--kaauh-success); color: white; }
.tier-2-badge { background-color: var(--kaauh-warning); color: #856404; }
.tier-3-badge { background-color: #d1ecf1; color: #0c5460; }
/* Fix table column widths for better layout */
.candidate-table th:nth-child(1) { width: 40px; } /* Checkbox */
.candidate-table th:nth-child(4) { width: 10%; } /* AI Score */
.candidate-table th:nth-child(5) { width: 12%; } /* Exam Status */
.candidate-table th:nth-child(6) { width: 15%; } /* Exam Date */
.candidate-table th:nth-child(7) { width: 220px; } /* Actions */
.cd_exam{
color: #00636e;
}
</style>
{% endblock %}
{% block content %}
<div class="container py-4">
<div class="container-fluid py-4">
<div class="d-flex justify-content-between align-items-center mb-4">
<div>
<h1 class="h3 mb-1">
<i class="fas fa-layer-group me-2"></i>
{% trans "Exam" %} - {{ job.title }}
<h1 class="h3 mb-1" style="color: var(--kaauh-teal-dark); font-weight: 700;">
<i class="fas fa-edit me-2"></i>
{% trans "Exam Management" %} - {{ job.title }}
</h1>
<p class="text-muted mb-0">
Total Candidates: {{ total_candidates }}
</p>
<h2 class="h5 text-muted mb-0">
{% trans "Candidates in Exam Stage:" %} <span class="fw-bold">{{ total_candidates }}</span>
</h2>
</div>
<a href="{% url 'job_detail' job.slug %}" class="btn btn-outline-secondary">
<i class="fas fa-arrow-left me-1"></i> {% trans "Back to Job" %}
</a>
</div>
<!-- Tier Controls -->
<div class="tier-controls">
{# APPLICANT TRACKING TIMELINE INCLUSION #}
<div class="applicant-tracking-timeline">
{% include 'jobs/partials/applicant_tracking.html' %}
</div>
{# END APPLICANT TRACKING TIMELINE INCLUSION #}
{% comment %} <div class="tier-controls kaauh-card shadow-sm">
<h4 class="h6 mb-3 fw-bold" style="color: var(--kaauh-teal-dark);">
<i class="fas fa-sort-amount-up me-1"></i> {% trans "Define Top Candidates (Tiers)" %}
</h4>
<form method="post" class="mb-0">
{% csrf_token %}
<div class="form-row">
<div class="form-group">
<label for="tier1_count">{% trans "Number of candidates in Tier 1 (Top N)" %}</label>
<input type="number" name="tier1_count" id="tier1_count" class="form-control"
value="{{ tier1_count }}" min="1" max="{{ total_candidates }}">
<div class="row g-3 align-items-end">
<div class="col-md-3 col-sm-6">
<label for="tier1_count" class="form-label small text-muted mb-1">
{% trans "Number of Tier 1 Candidates (Top N)" %}
</label>
<input type="number" name="tier1_count" id="tier1_count" class="form-control form-control-sm"
value="{{ tier1_count }}" min="1" max="{{ total_candidates }}" placeholder="e.g., 50">
</div>
<div class="form-group">
<button type="submit" name="update_tiers" class="btn btn-primary">
<div class="col-md-3 col-sm-6">
<button type="submit" name="update_tiers" class="btn btn-main-action btn-sm w-100">
<i class="fas fa-sync-alt me-1"></i> {% trans "Update Tiers" %}
</button>
</div>
<div class="col-md-6 d-none d-md-block"></div>
</div>
</form>
</form> {% endcomment %}
</div>
<!-- Tier Display -->
<h2 class="h4 mb-3 mt-5">{% trans "Candidate Tiers" %}</h2>
<h2 class="h4 mb-3" style="color: var(--kaauh-primary-text);">
{% trans "Candidate List" %}
<span class="badge bg-primary-theme ms-2">{{ candidates|length }} / {{ total_candidates }} Total</span>
<small class="text-muted fw-normal ms-2">(Sorted by AI Score)</small>
</h2>
<div class="kaauh-card shadow-sm p-3">
<div class="candidate-table-responsive" data-signals__ifmissing="{_fetching: false, selections: Array({{ candidates|length }}).fill(false)}">
{% url "bulk_update_candidate_exam_status" job.slug as bulk_update_candidate_exam_status_url %}
{% if candidates %}
<button class="btn btn-primary"
data-attr="{disabled: !$selections.filter(Boolean).length}"
data-on-click="@post('{{bulk_update_candidate_exam_status_url}}',{
contentType: 'form',
selector: '#myform',
headers: {'X-CSRFToken': '{{ csrf_token }}','status': 'pass'}
})"
>Mark as Pass and move to Interview</button>
<button class="btn btn-danger"
data-attr="{disabled: !$selections.filter(Boolean).length}"
data-on-click="@post('{{bulk_update_candidate_exam_status_url}}',{
contentType: 'form',
selector: '#myform',
headers: {'X-CSRFToken': '{{ csrf_token }}','status': 'fail'}
})"
>Mark as Failed</button>
{% endif %}
<form id="myform" action="{{move_to_exam_url}}" method="post">
<table class="candidate-table">
<thead>
<tr>
<th>
{% if candidates %}
<div class="form-check">
<input
data-bind-_all
data-on-change="$selections = Array({{ candidates|length }}).fill($_all)"
data-effect="$selections; $_all = $selections.every(Boolean)"
data-attr-disabled="$_fetching"
type="checkbox" class="form-check-input" id="candidate-{{ candidate.id }}">
</div>
{% endif %}
</th>
<th>{% trans "Name" %}</th>
<th>{% trans "Contact" %}</th>
<th>{% trans "AI Score" %}</th>
<th>{% trans "Exam Status" %}</th>
<th>{% trans "Exam Date" %}</th>
<th>{% trans "Actions" %}</th>
</tr>
</thead>
<tbody>
{% for candidate in candidates %}
<div class="mb-3 d-flex gap-2">
{% if candidates %}
<button class="btn btn-bulk-pass btn-sm"
data-attr="{disabled: !$selections.filter(Boolean).length}"
data-on-click="@post('{{bulk_update_candidate_exam_status_url}}',{
contentType: 'form',
selector: '#candidate-form',
headers: {'X-CSRFToken': '{{ csrf_token }}','status': 'Passed'}
})"
>
<i class="fas fa-check-circle me-1"></i>
{% trans "Bulk Mark Passed (-> Interview)" %}
</button>
<button class="btn btn-bulk-fail btn-sm"
data-attr="{disabled: !$selections.filter(Boolean).length}"
data-on-click="@post('{{bulk_update_candidate_exam_status_url}}',{
contentType: 'form',
selector: '#candidate-form',
headers: {'X-CSRFToken': '{{ csrf_token }}','status': 'Failed'}
})"
>
<i class="fas fa-times-circle me-1"></i>
{% trans "Bulk Mark Failed" %}
</button>
{% endif %}
</div>
<form id="candidate-form" method="post">
{% csrf_token %}
<table class="table candidate-table align-middle">
<thead>
<tr>
<th>
{% if candidates %}
<div class="form-check">
<input
data-bind-_all
data-on-change="$selections = Array({{ candidates|length }}).fill($_all)"
data-effect="$selections; $_all = $selections.every(Boolean)"
data-attr-disabled="$_fetching"
type="checkbox" class="form-check-input" id="checkAll">
</div>
{% endif %}
</th>
<th>{% trans "Name" %}</th>
<th>{% trans "Contact" %}</th>
<th class="text-center">{% trans "AI Score" %}</th>
<th class="text-center">{% trans "Exam Status" %}</th>
<th>{% trans "Exam Date" %}</th>
<th>{% trans "Actions" %}</th>
</tr>
</thead>
<tbody>
{% for candidate in candidates %}
<tr>
<td>
<div class="form-check">
<input
data-bind-selections
data-attr-disabled="$_fetching"
name="{{ candidate.id }}"
name="candidate_ids"
value="{{ candidate.id }}"
type="checkbox" class="form-check-input" id="candidate-{{ candidate.id }}">
</div>
</td>
<td>
<div class="candidate-name">{{ candidate.name }}</div>
</td>
<td>
<div class="candidate-details">
Email: {{ candidate.email }}<br>
Phone: {{ candidate.phone }}<br>
<div class="candidate-name">
{{ candidate.name }}
{# Tier logic updated to be cleaner #}
{% if forloop.counter <= tier1_count %}
<span class="stage-badge tier-1-badge">Tier 1</span>
{% elif forloop.counter <= tier1_count|default:0|add:tier1_count %}
<span class="stage-badge tier-2-badge">Tier 2</span>
{% else %}
<span class="stage-badge tier-3-badge">Tier 3+</span>
{% endif %}
</div>
</td>
<td>
<span class="badge bg-success">{{ candidate.match_score|default:"0" }}</span>
<div class="candidate-details">
<i class="fas fa-envelope me-1"></i> {{ candidate.email }}<br>
<i class="fas fa-phone me-1"></i> {{ candidate.phone }}
</div>
</td>
<td>
<td class="text-center">
<span class="badge ai-score-badge">{{ candidate.match_score|default:"0" }}%</span>
</td>
<td class="text-center">
{% if candidate.exam_status == "Passed" %}
<span class="badge bg-success">{{ candidate.exam_status }}</span>
<span class="status-badge bg-success">{{ candidate.exam_status }}</span>
{% elif candidate.exam_status == "Failed" %}
<span class="badge bg-danger">{{ candidate.exam_status }}</span>
<span class="status-badge bg-danger">{{ candidate.exam_status }}</span>
{% else %}
<span class="status-badge bg-info-pending">Pending</span>
{% endif %}
</td>
<td>{{candidate.exam_date|date:"M d, Y h:i A"}}</td>
<td>
<button class="btn btn-primary"
{{candidate.exam_date|date:"M d, Y h:i A"|default:"N/A"}}
</td>
<td class="d-flex flex-wrap gap-1">
<button type="button" class="btn btn-outline-secondary btn-sm"
data-bs-toggle="modal"
data-bs-target="#candidateviewModal"
hx-get="{% url 'candidate_criteria_view_htmx' candidate.pk %}"
hx-target="#candidateviewModalBody"
>
{% include "icons/view.html" %}
{% trans "View" %}</button>
<button class="btn btn-primary"
title="View Profile">
<i class="fas fa-eye"></i> View
</button>
<button type="button" class="btn btn-main-action btn-sm"
data-bs-toggle="modal"
data-bs-target="#candidateviewModal"
hx-get="{% url 'update_candidate_exam_status' candidate.slug %}"
hx-target="#candidateviewModalBody"
>
{% include "icons/view.html" %}
{% trans "Set Exam Date" %}</button>
{% if candidate.stage != "Exam" %}
<button hx-post="{% url 'candidate_set_exam_date' candidate.slug %}"
hx-target=".candidate-table"
hx-select=".candidate-table"
hx-swap="outerHTML"
class="btn btn-primary"> {% trans "Move to Exam" %} {% include "icons/right.html" %}</button>
{% endif %}
title="Set Exam Status/Date">
<i class="fas fa-calendar-alt"></i> Set Exam
</button>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</form>
</div>
<!-- Tab Content -->
{% endfor %}
</tbody>
</table>
{% if not candidates %}
<div class="alert alert-info text-center mt-3 mb-0" role="alert">
<i class="fas fa-info-circle me-1"></i>
{% trans "No candidates are currently in the Exam stage for this job." %}
</div>
{% endif %}
</form>
</div>
</div>
</div>
<div class="modal fade modal-lg" id="candidateviewModal" tabindex="-1" aria-labelledby="candidateviewModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="candidateviewModalLabel">Form Settings</h5>
<div class="modal-content kaauh-card">
<div class="modal-header" style="border-bottom: 1px solid var(--kaauh-border);">
<h5 class="modal-title" id="candidateviewModalLabel" style="color: var(--kaauh-teal-dark);">
{% trans "Candidate Details & Exam Update" %}
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div id="candidateviewModalBody" class="modal-body">
<div class="text-center py-5 text-muted">
<i class="fas fa-spinner fa-spin fa-2x"></i><br>
{% trans "Loading candidate data..." %}
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<div class="modal-footer" style="border-top: 1px solid var(--kaauh-border);">
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">
{% trans "Close" %}
</button>
</div>
</div>
</div>

View File

@ -11,10 +11,16 @@
--kaauh-teal-dark: #004a53;
--kaauh-border: #eaeff3;
--kaauh-primary-text: #343a40;
--kaauh-success: #28a745; /* Standard success for positive actions */
--kaauh-info: #17a2b8; /* Standard info/exam badge */
--kaauh-success: #28a745;
--kaauh-info: #17a2b8;
--kaauh-danger: #dc3545;
--kaauh-warning: #ffc107;
}
/* Primary Color Overrides */
.text-primary-theme { color: var(--kaauh-teal) !important; }
.bg-primary-theme { background-color: var(--kaauh-teal) !important; }
/* 1. Main Container & Card Styling */
.kaauh-card {
border: 1px solid var(--kaauh-border);
@ -22,14 +28,17 @@
box-shadow: 0 4px 12px rgba(0,0,0,0.06);
background-color: white;
}
.tier-controls {
background-color: var(--kaauh-border); /* Light background for control sections */
border-radius: 0.5rem;
padding: 1.25rem;
margin-bottom: 1.5rem;
/* Dedicated style for the filter block */
.filter-controls {
background-color: #f8f9fa;
border-radius: 0.75rem;
padding: 1.5rem;
margin-bottom: 2rem;
border: 1px solid var(--kaauh-border);
}
/* 2. Button Styling (from reference) */
/* 2. Button Styling (Themed for Main Actions) */
.btn-main-action {
background-color: var(--kaauh-teal);
border-color: var(--kaauh-teal);
@ -51,22 +60,22 @@
color: white;
border-color: var(--kaauh-teal-dark);
}
/* 3. Tab Styles (View Switcher) */
.nav-pills .nav-link {
color: var(--kaauh-teal-dark);
font-weight: 500;
border-radius: 0.5rem;
transition: background-color 0.2s;
}
.nav-pills .nav-link.active {
background-color: var(--kaauh-teal);
/* Style for the Bulk Move button */
.btn-bulk-action {
background-color: var(--kaauh-teal-dark);
border-color: var(--kaauh-teal-dark);
color: white;
font-weight: 600;
font-weight: 500;
}
.btn-bulk-action:hover {
background-color: #00363e;
border-color: #00363e;
}
/* 4. Candidate Table Styling (Aligned with KAAT-S) */
/* 3. Candidate Table Styling (Aligned with KAAT-S) */
.candidate-table {
table-layout: fixed;
width: 100%;
border-collapse: separate;
border-spacing: 0;
background-color: white;
@ -77,19 +86,26 @@
background-color: var(--kaauh-border);
}
.candidate-table th {
padding: 0.75rem;
padding: 0.75rem 1rem;
font-weight: 600;
color: var(--kaauh-teal-dark);
border-bottom: 2px solid var(--kaauh-teal);
font-size: 0.9rem;
vertical-align: middle;
}
.candidate-table td {
padding: 0.75rem;
padding: 0.75rem 1rem;
border-bottom: 1px solid var(--kaauh-border);
vertical-align: middle;
font-size: 0.9rem;
}
.candidate-table tbody tr:hover {
background-color: #f1f3f4;
}
.candidate-table thead th:nth-child(1) { width: 40px; }
.candidate-table thead th:nth-child(4) { width: 10%; }
.candidate-table thead th:nth-child(7) { width: 100px; }
.candidate-name {
font-weight: 600;
color: var(--kaauh-primary-text);
@ -98,206 +114,262 @@
font-size: 0.8rem;
color: #6c757d;
}
/* 5. Badges (Status/Score) */
/* 4. Badges and Statuses */
.ai-score-badge {
background-color: var(--kaauh-teal-dark) !important;
color: white;
font-weight: 700;
padding: 0.4em 0.8em;
border-radius: 0.4rem;
}
.status-badge {
font-size: 0.8rem;
font-size: 0.75rem;
padding: 0.3em 0.7em;
border-radius: 0.35rem;
font-weight: 700;
text-transform: uppercase;
}
.bg-applicant { background-color: #6c757d; color: white; } /* Secondary */
.bg-candidate { background-color: var(--kaauh-success); color: white; } /* Success */
.bg-score { background-color: var(--kaauh-teal-dark); color: white; }
.bg-exam-status { background-color: var(--kaauh-info); color: white; }
.bg-applicant { background-color: #6c757d !important; color: white; }
.bg-candidate { background-color: var(--kaauh-success) !important; color: white; }
/* 6. Stage Badges (More distinct from KAAT-S reference) */
/* Stage Badges */
.stage-badge {
font-size: 0.75rem;
padding: 0.25rem 0.6rem;
border-radius: 0.3rem;
font-weight: 600;
display: inline-block;
margin-bottom: 0.2rem;
}
.stage-Applied { background-color: #e9ecef; color: #495057; }
.stage-Exam { background-color: #d1ecf1; color: #0c5460; } /* Light cyan/info */
.stage-Interview { background-color: #ffc107; color: #856404; } /* Yellow/warning */
.stage-Offer { background-color: #d4edda; color: #155724; } /* Light green/success */
/* Candidate Indicator (used for the single Potential Candidates list) */
.candidate-indicator-badge {
font-size: 0.7rem;
padding: 0.15rem 0.4rem;
border-radius: 0.25rem;
font-weight: 700;
margin-left: 0.5rem;
background-color: var(--kaauh-teal);
color: white;
.stage-Screening { background-color: var(--kaauh-info); color: white; }
.stage-Exam { background-color: var(--kaauh-warning); color: #856404; }
.stage-Interview { background-color: #17a2b8; color: white; }
.stage-Offer { background-color: var(--kaauh-success); color: white; }
/* Timeline specific container */
.applicant-tracking-timeline {
margin-bottom: 2rem;
}
/* --- CUSTOM HEIGHT OPTIMIZATION (MAKING INPUTS/BUTTONS SMALLER) --- */
.form-control-sm,
.btn-sm {
/* Reduce vertical padding even more than default Bootstrap 'sm' */
padding-top: 0.2rem !important;
padding-bottom: 0.2rem !important;
/* Ensure a consistent, small height for both */
height: 28px !important;
font-size: 0.8rem !important; /* Slightly smaller font */
}
.cd_screening{
color: #00636e;
}
</style>
{% endblock %}
{% block content %}
<div class="container py-4">
<div class="container-fluid py-4">
<div class="applicant-tracking-timeline">
{% include 'jobs/partials/applicant_tracking.html' %}
</div>
<div class="d-flex justify-content-between align-items-center mb-4">
<div>
<h1 class="h3 mb-1" style="color: var(--kaauh-teal-dark); font-weight: 700;">
<i class="fas fa-layer-group me-2"></i>
{% trans "Applicant Screening for Job:" %} - {{ job.title }}&nbsp;&nbsp;<small class="text-muted fs-6">{{job.internal_job_id}}<small>
{% trans "Applicant Screening" %}
</h1>
<p class="text-muted mb-0">
Total Applicants: <span class="fw-bold">{{ total_candidates }}</span>
</p>
<h2 class="h5 text-muted mb-0">
{% trans "Job:" %} {{ job.title }}
<span class="badge bg-secondary ms-2 fw-normal">{{ job.internal_job_id }}</span>
</h2>
</div>
<a href="{% url 'job_detail' job.slug %}" class="btn btn-outline-secondary">
<i class="fas fa-arrow-left me-1"></i> {% trans "Back to Job" %}
</a>
</div>
<div class="tier-controls kaauh-card shadow-sm">
<h4 class="h5 mb-3" style="color: var(--kaauh-teal-dark);">{% trans "Filter Potential Candidates" %}</h4>
<form method="post" class="mb-0">
<div class="filter-controls shadow-sm">
<h4 class="h6 mb-3 fw-bold" style="color: var(--kaauh-primary-text);">
<i class="fas fa-sort-numeric-up me-1"></i> {% trans "AI Scoring & Top Candidate Filter" %}
</h4>
<form method="GET" class="mb-0">
{% csrf_token %}
<div class="row g-3 align-items-end">
<div class="col-md-3 col-sm-6">
<label for="min_ai_score" class="form-label small text-muted">
{% trans "Minimum AI Score" %}
<div class="col-md-2 col-sm-6">
<label for="min_ai_score" class="form-label small text-muted mb-1">
{% trans "Min AI Score" %}
</label>
<input type="number" name="min_ai_score" id="min_ai_score" class="form-control"
value="{{ min_ai_score|default:'0' }}" min="0" max="100" step="1"
<input type="number" name="min_ai_score" id="min_ai_score" class="form-control form-control-sm"
value="{{ min_ai_score}}" min="0" max="100" step="1"
placeholder="e.g., 75">
</div>
<div class="col-md-3 col-sm-6">
<label for="tier1_count" class="form-label small text-muted">
{% trans "Number of Potential Candidates (Top N)" %}
<div class="col-md-2 col-sm-6">
<label for="tier1_count" class="form-label small text-muted mb-1">
{% trans "Top N" %}
</label>
<input type="number" name="tier1_count" id="tier1_count" class="form-control"
<input type="number" name="tier1_count" id="tier1_count" class="form-control form-control-sm"
value="{{ tier1_count }}" min="1" max="{{ total_candidates }}">
</div>
<div class="form-group">
<button type="submit" name="update_tiers" class="btn btn-primary">
<i class="fas fa-sync-alt me-1"></i> {% trans "Update Tiers" %}
<div class="col-md-3 col-sm-6">
<button type="submit" name="update_tiers" class="btn btn-main-action btn-sm w-100">
<i class="fas fa-sync-alt me-1"></i> {% trans "Update Filters" %}
</button>
</div>
{% comment %} Empty col for spacing (2 + 2 + 3 + 5 = 12) {% endcomment %}
<div class="col-md-5 d-none d-md-block"></div>
</div>
</form>
</div>
<!-- Tier Display -->
<h2 class="h4 mb-3 mt-5">{% trans "Candidate Tiers" %}</h2>
<div class="candidate-table-responsive" data-signals__ifmissing="{_fetching: false, selections: Array({{ candidates|length }}).fill(false)}">
{% url "bulk_candidate_move_to_exam" as move_to_exam_url %}
{% if candidates %}
<button class="btn btn-primary"
data-attr="{disabled: !$selections.filter(Boolean).length}"
data-on-click="@post('{{move_to_exam_url}}',{
contentType: 'form',
selector: '#myform',
headers: {'X-CSRFToken': '{{ csrf_token }}'}})"
>Bulk Move To Exam</button>
{% endif %}
<form id="myform" action="{{move_to_exam_url}}" method="post">
<table class="candidate-table">
<thead>
<tr>
<th>
{% if candidates %}
<div class="form-check">
<input
data-bind-_all
data-on-change="$selections = Array({{ candidates|length }}).fill($_all)"
data-effect="$selections; $_all = $selections.every(Boolean)"
data-attr-disabled="$_fetching"
type="checkbox" class="form-check-input" id="candidate-{{ candidate.id }}">
</div>
{% endif %}
</th>
<th>{% trans "Name" %}</th>
<th>{% trans "Contact" %}</th>
<th>{% trans "AI Score" %}</th>
<th>{% trans "Status" %}</th>
<th>{% trans "Stage" %}</th>
<th>{% trans "Actions" %}</th>
</tr>
</thead>
<tbody>
{% for candidate in candidates %}
<tr>
<td>
<h2 class="h4 mb-3" style="color: var(--kaauh-primary-text);">
<i class="fas fa-users me-1"></i> {% trans "Candidate List" %}
<span class="badge bg-primary-theme ms-2">{{ candidates|length }} / {{ total_candidates }} Total</span>
</h2>
<div class="kaauh-card shadow-sm p-3">
{% url "bulk_candidate_move_to_exam" as move_to_exam_url %}
{% if candidates %}
<button class="btn btn-bulk-action btn-sm mb-3"
data-attr="{disabled: !$selections.filter(Boolean).length}"
data-on-click="@post('{{move_to_exam_url}}',{
contentType: 'form',
selector: '#candidate-form',
headers: {'X-CSRFToken': '{{ csrf_token }}'}})"
>
<i class="fas fa-arrow-right me-1"></i> {% trans "Bulk Move to Exam" %}
</button>
{% endif %}
<div class="table-responsive">
<form id="candidate-form" action="{{move_to_exam_url}}" method="post">
{% csrf_token %}
<table class="table candidate-table align-middle">
<thead>
<tr>
<th>
{% if candidates %}
<div class="form-check">
<input
data-bind-selections
data-bind-_all
data-on-change="$selections = Array({{ candidates|length }}).fill($_all)"
data-effect="$selections; $_all = $selections.every(Boolean)"
data-attr-disabled="$_fetching"
name="{{ candidate.id }}"
type="checkbox" class="form-check-input" id="candidate-{{ candidate.id }}">
type="checkbox" class="form-check-input" id="checkAll">
</div>
</td>
<td>
<div class="candidate-name">{{ candidate.name }}</div>
</td>
<td>
<div class="candidate-details">
Email: {{ candidate.email }}<br>
Phone: {{ candidate.phone }}<br>
</div>
</td>
<td>
<span class="badge bg-success">{{ candidate.match_score|default:"0" }}</span>
</td>
<td>
<span class="badge {% if candidate.applicant_status == 'Candidate' %}bg-success{% else %}bg-secondary{% endif %}">
{{ candidate.get_applicant_status_display }}
</span>
</td>
<td>
<span class="stage-badge stage-{{ candidate.stage }}">
{{ candidate.get_stage_display }}
</span>
{% if candidate.stage == "Exam" and candidate.exam_status %}
<br>
<span class="badge bg-info">{{ candidate.get_exam_status_display }}</span>
{% endif %}
</td>
<td>
<button class="btn btn-primary"
data-bs-toggle="modal"
data-bs-target="#candidateviewModal"
hx-get="{% url 'candidate_criteria_view_htmx' candidate.pk %}"
hx-target="#candidateviewModalBody"
>
{% include "icons/view.html" %}
{% trans "View" %}</button>
{% if candidate.stage != "Exam" %}
<button hx-post="{% url 'candidate_move_to_exam' candidate.pk %}"
hx-target=".candidate-table"
hx-select=".candidate-table"
hx-swap="outerHTML"
class="btn btn-primary"> {% trans "Move to Exam" %} {% include "icons/right.html" %}</button>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</form>
</div>
<!-- Tab Content -->
{% endif %}
</th>
<th>{% trans "Candidate Name" %}</th>
<th>{% trans "Contact Info" %}</th>
<th class="text-center">{% trans "AI Score" %}</th>
<th>{% trans "Application Status" %}</th>
<th>{% trans "Current Stage" %}</th>
<th class="text-center">{% trans "Actions" %}</th>
</tr>
</thead>
<tbody>
{% for candidate in candidates %}
<tr>
<td>
<div class="form-check">
<input
data-bind-selections
data-attr-disabled="$_fetching"
name="candidate_ids"
value="{{ candidate.id }}"
type="checkbox" class="form-check-input" id="candidate-{{ candidate.id }}">
</div>
</td>
<td>
<a href="#" class="candidate-name text-primary-theme text-decoration-none">
{{ candidate.name }}
</a>
</td>
<td>
<div class="candidate-details">
<i class="fas fa-envelope me-1"></i> {{ candidate.email }}<br>
<i class="fas fa-phone me-1"></i> {{ candidate.phone }}
</div>
</td>
<td class="text-center">
<span class="badge ai-score-badge">
{{ candidate.match_score|default:"0" }}%
</span>
</td>
<td>
<span class="badge status-badge {% if candidate.applicant_status == 'Candidate' %}bg-candidate{% else %}bg-applicant{% endif %}">
{{ candidate.get_applicant_status_display }}
</span>
</td>
<td>
<span class="stage-badge stage-{{ candidate.stage }}">
{{ candidate.get_stage_display }}
</span>
{% if candidate.stage == "Exam" and candidate.exam_status %}
<br>
<span class="stage-badge bg-info text-white">
{{ candidate.get_exam_status_display }}
</span>
{% endif %}
</td>
<td class="text-center">
<button type="button" class="btn btn-outline-secondary btn-sm"
data-bs-toggle="modal"
data-bs-target="#candidateviewModal"
hx-get="{% url 'candidate_criteria_view_htmx' candidate.pk %}"
hx-target="#candidateviewModalBody"
title="View Candidate Profile and Criteria">
<i class="fas fa-eye"></i>
</button>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% if not candidates %}
<div class="alert alert-info text-center mt-3 mb-0" role="alert">
<i class="fas fa-info-circle me-1"></i>
{% trans "No candidates match the current stage and filter criteria." %}
</div>
{% endif %}
</form>
</div>
</div>
</div>
<div class="modal fade modal-lg" id="candidateviewModal" tabindex="-1" aria-labelledby="candidateviewModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="candidateviewModalLabel">Form Settings</h5>
<div class="modal-content kaauh-card">
<div class="modal-header" style="border-bottom: 1px solid var(--kaauh-border);">
<h5 class="modal-title" id="candidateviewModalLabel" style="color: var(--kaauh-teal-dark);">
{% trans "Candidate Criteria Review" %}
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div id="candidateviewModalBody" class="modal-body">
<div class="text-center py-5 text-muted">
<i class="fas fa-spinner fa-spin fa-2x"></i><br>
{% trans "Loading candidate data..." %}
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<div class="modal-footer" style="border-top: 1px solid var(--kaauh-border);">
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">
{% trans "Close" %}
</button>
</div>
</div>
</div>

View File

@ -24,7 +24,7 @@
/* Main Action Button Style */
.btn-main-action{
background-color: var(--kaauh-teal-dark); /* Changed to primary teal for main actions */
background-color: gray; /* Changed to primary teal for main actions */
border-color: var(--kaauh-teal);
color: white;
font-weight: 600;