Compare commits
12 Commits
c532ca97b6
...
b9904b3ec8
| Author | SHA1 | Date | |
|---|---|---|---|
| b9904b3ec8 | |||
| 6521cdf2be | |||
| 60fff6f636 | |||
| f992861947 | |||
| 4a728ff752 | |||
| cacc8d42b2 | |||
| 2be0ab083d | |||
| 6c949f2899 | |||
| 2dd5eb874e | |||
| 9ae89587fa | |||
| ffae8b2e64 | |||
| ce15603802 |
1
.gitignore
vendored
1
.gitignore
vendored
@ -110,3 +110,4 @@ local_settings.py
|
|||||||
# If a rule in .gitignore ends with a directory separator (i.e. `/`
|
# If a rule in .gitignore ends with a directory separator (i.e. `/`
|
||||||
# character), then remove the file in the remaining pattern string and all
|
# character), then remove the file in the remaining pattern string and all
|
||||||
# files with the same name in subdirectories.
|
# files with the same name in subdirectories.
|
||||||
|
db.sqlite3
|
||||||
1
ZoomMeetingAPISpec.json
Normal file
1
ZoomMeetingAPISpec.json
Normal file
File diff suppressed because one or more lines are too long
BIN
db.sqlite3
BIN
db.sqlite3
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -1,10 +1,14 @@
|
|||||||
# jobs/linkedin_service.py
|
# jobs/linkedin_service.py
|
||||||
import uuid
|
import uuid
|
||||||
from urllib.parse import quote
|
import re
|
||||||
|
from html import unescape
|
||||||
|
from urllib.parse import quote, urlencode
|
||||||
import requests
|
import requests
|
||||||
import logging
|
import logging
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from urllib.parse import urlencode, quote
|
import time
|
||||||
|
import random
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@ -14,6 +18,11 @@ class LinkedInService:
|
|||||||
self.client_secret = settings.LINKEDIN_CLIENT_SECRET
|
self.client_secret = settings.LINKEDIN_CLIENT_SECRET
|
||||||
self.redirect_uri = settings.LINKEDIN_REDIRECT_URI
|
self.redirect_uri = settings.LINKEDIN_REDIRECT_URI
|
||||||
self.access_token = None
|
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):
|
def get_auth_url(self):
|
||||||
"""Generate LinkedIn OAuth URL"""
|
"""Generate LinkedIn OAuth URL"""
|
||||||
@ -28,7 +37,6 @@ class LinkedInService:
|
|||||||
|
|
||||||
def get_access_token(self, code):
|
def get_access_token(self, code):
|
||||||
"""Exchange authorization code for access token"""
|
"""Exchange authorization code for access token"""
|
||||||
# This function exchanges LinkedIn’s temporary authorization code for a usable access token.
|
|
||||||
url = "https://www.linkedin.com/oauth/v2/accessToken"
|
url = "https://www.linkedin.com/oauth/v2/accessToken"
|
||||||
data = {
|
data = {
|
||||||
'grant_type': 'authorization_code',
|
'grant_type': 'authorization_code',
|
||||||
@ -42,12 +50,6 @@ class LinkedInService:
|
|||||||
response = requests.post(url, data=data, timeout=60)
|
response = requests.post(url, data=data, timeout=60)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
token_data = response.json()
|
token_data = response.json()
|
||||||
"""
|
|
||||||
Example response:{
|
|
||||||
"access_token": "AQXq8HJkLmNpQrStUvWxYz...",
|
|
||||||
"expires_in": 5184000
|
|
||||||
}
|
|
||||||
"""
|
|
||||||
self.access_token = token_data.get('access_token')
|
self.access_token = token_data.get('access_token')
|
||||||
return self.access_token
|
return self.access_token
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@ -55,7 +57,7 @@ class LinkedInService:
|
|||||||
raise
|
raise
|
||||||
|
|
||||||
def get_user_profile(self):
|
def get_user_profile(self):
|
||||||
"""Get user profile information"""
|
"""Get user profile information (used to get person URN)"""
|
||||||
if not self.access_token:
|
if not self.access_token:
|
||||||
raise Exception("No access token available")
|
raise Exception("No access token available")
|
||||||
|
|
||||||
@ -64,16 +66,32 @@ class LinkedInService:
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
response = requests.get(url, headers=headers, timeout=60)
|
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)
|
response.raise_for_status()
|
||||||
return response.json() # returns a dict from json response (deserialize)
|
return response.json()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error getting user profile: {e}")
|
logger.error(f"Error getting user profile: {e}")
|
||||||
raise
|
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):
|
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"
|
url = "https://api.linkedin.com/v2/assets?action=registerUpload"
|
||||||
headers = {
|
headers = {
|
||||||
'Authorization': f'Bearer {self.access_token}',
|
'Authorization': f'Bearer {self.access_token}',
|
||||||
@ -101,9 +119,8 @@ class LinkedInService:
|
|||||||
'asset': data['value']['asset']
|
'asset': data['value']['asset']
|
||||||
}
|
}
|
||||||
|
|
||||||
def upload_image_to_linkedin(self, upload_url, image_file):
|
def upload_image_to_linkedin(self, upload_url, image_file, asset_urn):
|
||||||
"""Step 2: Upload actual image file to LinkedIn"""
|
"""Step 2: Upload image file and poll for 'READY' status."""
|
||||||
# Open and read the Django ImageField
|
|
||||||
image_file.open()
|
image_file.open()
|
||||||
image_content = image_file.read()
|
image_content = image_file.read()
|
||||||
image_file.close()
|
image_file.close()
|
||||||
@ -114,90 +131,223 @@ class LinkedInService:
|
|||||||
|
|
||||||
response = requests.post(upload_url, headers=headers, data=image_content, timeout=60)
|
response = requests.post(upload_url, headers=headers, data=image_content, timeout=60)
|
||||||
response.raise_for_status()
|
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
|
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!* We’re 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):
|
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:
|
if not self.access_token:
|
||||||
raise Exception("Not authenticated with LinkedIn")
|
raise Exception("Not authenticated with LinkedIn")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Get user profile for person URN
|
|
||||||
profile = self.get_user_profile()
|
profile = self.get_user_profile()
|
||||||
person_urn = profile.get('sub')
|
person_urn = profile.get('sub')
|
||||||
|
|
||||||
if not person_urn:
|
if not person_urn:
|
||||||
raise Exception("Could not retrieve LinkedIn user ID")
|
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:
|
try:
|
||||||
image_upload = job_posting.files.first()
|
# Assuming correct model path: job_posting.related_model_name.first().image_field_name
|
||||||
has_image = image_upload and image_upload.linkedinpost_image
|
image_upload = job_posting.post_images.first().post_image
|
||||||
|
has_image = image_upload is not None
|
||||||
except Exception:
|
except Exception:
|
||||||
has_image = False
|
pass # No image available
|
||||||
|
|
||||||
if has_image:
|
if has_image:
|
||||||
# === POST WITH IMAGE ===
|
|
||||||
try:
|
try:
|
||||||
# Step 1: Register image upload
|
# Step 1: Register
|
||||||
upload_info = self.register_image_upload(person_urn)
|
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(
|
self.upload_image_to_linkedin(
|
||||||
upload_info['upload_url'],
|
upload_info['upload_url'],
|
||||||
image_upload.linkedinpost_image
|
image_upload,
|
||||||
|
asset_urn
|
||||||
)
|
)
|
||||||
|
|
||||||
# Step 3: Create post with image
|
# Step 3: Create post with image
|
||||||
return self.create_job_post_with_image(
|
return self.create_job_post_with_image(
|
||||||
job_posting,
|
job_posting, image_upload, person_urn, asset_urn
|
||||||
image_upload.linkedinpost_image,
|
|
||||||
person_urn,
|
|
||||||
upload_info['asset']
|
|
||||||
)
|
)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Image upload failed: {e}")
|
logger.error(f"Image post failed, falling back to text: {e}")
|
||||||
# Fall back to text-only post if image upload fails
|
# Force fallback to text-only if image posting fails
|
||||||
has_image = False
|
has_image = False
|
||||||
|
|
||||||
# === FALLBACK TO URL/ARTICLE POST ===
|
# === FALLBACK TO PURE TEXT POST (shareMediaCategory: NONE) ===
|
||||||
# Add unique timestamp to prevent duplicates
|
message = self._build_post_message(job_posting)
|
||||||
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)})"
|
|
||||||
|
|
||||||
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"
|
url = "https://api.linkedin.com/v2/ugcPosts"
|
||||||
headers = {
|
headers = {
|
||||||
'Authorization': f'Bearer {self.access_token}',
|
'Authorization': f'Bearer {self.access_token}',
|
||||||
@ -211,13 +361,7 @@ class LinkedInService:
|
|||||||
"specificContent": {
|
"specificContent": {
|
||||||
"com.linkedin.ugc.ShareContent": {
|
"com.linkedin.ugc.ShareContent": {
|
||||||
"shareCommentary": {"text": message},
|
"shareCommentary": {"text": message},
|
||||||
"shareMediaCategory": "ARTICLE",
|
"shareMediaCategory": "NONE", # Pure text post
|
||||||
"media": [{
|
|
||||||
"status": "READY",
|
|
||||||
"description": {"text": f"Apply for {job_posting.title} at our university!"},
|
|
||||||
"originalUrl": job_posting.application_url,
|
|
||||||
"title": {"text": job_posting.title}
|
|
||||||
}]
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"visibility": {
|
"visibility": {
|
||||||
@ -245,17 +389,3 @@ class LinkedInService:
|
|||||||
'error': str(e),
|
'error': str(e),
|
||||||
'status_code': getattr(e.response, 'status_code', 500) if hasattr(e, 'response') else 500
|
'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
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -0,0 +1,22 @@
|
|||||||
|
# Generated by Django 5.2.6 on 2025-10-12 21:21
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('recruitment', '0004_alter_candidate_interview_date'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name='interviewschedule',
|
||||||
|
name='breaks',
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='interviewschedule',
|
||||||
|
name='breaks',
|
||||||
|
field=models.JSONField(blank=True, default=list, verbose_name='Break Times'),
|
||||||
|
),
|
||||||
|
]
|
||||||
18
recruitment/migrations/0006_zoommeeting_meeting_status.py
Normal file
18
recruitment/migrations/0006_zoommeeting_meeting_status.py
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 5.2.6 on 2025-10-13 12:00
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('recruitment', '0005_remove_interviewschedule_breaks_and_more'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='zoommeeting',
|
||||||
|
name='meeting_status',
|
||||||
|
field=models.CharField(choices=[('scheduled', 'Scheduled'), ('started', 'Started'), ('ended', 'Ended')], default='scheduled', max_length=20, verbose_name='Meeting Status'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -0,0 +1,22 @@
|
|||||||
|
# Generated by Django 5.2.6 on 2025-10-13 12:20
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('recruitment', '0006_zoommeeting_meeting_status'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name='zoommeeting',
|
||||||
|
name='meeting_status',
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='zoommeeting',
|
||||||
|
name='status',
|
||||||
|
field=models.CharField(blank=True, max_length=20, null=True, verbose_name='Status'),
|
||||||
|
),
|
||||||
|
]
|
||||||
18
recruitment/migrations/0008_zoommeeting_password.py
Normal file
18
recruitment/migrations/0008_zoommeeting_password.py
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 5.2.6 on 2025-10-13 12:22
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('recruitment', '0007_remove_zoommeeting_meeting_status_zoommeeting_status'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='zoommeeting',
|
||||||
|
name='password',
|
||||||
|
field=models.CharField(blank=True, max_length=20, null=True, verbose_name='Password'),
|
||||||
|
),
|
||||||
|
]
|
||||||
14
recruitment/migrations/0009_merge_20251013_1714.py
Normal file
14
recruitment/migrations/0009_merge_20251013_1714.py
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
# Generated by Django 5.2.6 on 2025-10-13 14:14
|
||||||
|
|
||||||
|
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 = [
|
||||||
|
]
|
||||||
14
recruitment/migrations/0009_merge_20251013_1718.py
Normal file
14
recruitment/migrations/0009_merge_20251013_1718.py
Normal 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 = [
|
||||||
|
]
|
||||||
14
recruitment/migrations/0010_merge_20251013_1819.py
Normal file
14
recruitment/migrations/0010_merge_20251013_1819.py
Normal 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 = [
|
||||||
|
]
|
||||||
@ -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]),
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -1,6 +1,6 @@
|
|||||||
from django.db import models
|
from django.db import models
|
||||||
from django.utils import timezone
|
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.contrib.auth.models import User
|
||||||
from django.core.validators import URLValidator
|
from django.core.validators import URLValidator
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
@ -249,8 +249,8 @@ class JobPosting(Base):
|
|||||||
|
|
||||||
|
|
||||||
class JobPostingImage(models.Model):
|
class JobPostingImage(models.Model):
|
||||||
job=models.ForeignKey('JobPosting',on_delete=models.CASCADE,related_name='post_images')
|
job=models.OneToOneField('JobPosting',on_delete=models.CASCADE,related_name='post_images')
|
||||||
post_image = models.ImageField(upload_to='post/')
|
post_image = models.ImageField(upload_to='post/',validators=[validate_image_size])
|
||||||
|
|
||||||
|
|
||||||
class Candidate(Base):
|
class Candidate(Base):
|
||||||
@ -421,7 +421,6 @@ class Candidate(Base):
|
|||||||
return self.STAGE_SEQUENCE.get(old_stage, [])
|
return self.STAGE_SEQUENCE.get(old_stage, [])
|
||||||
|
|
||||||
@property
|
@property
|
||||||
|
|
||||||
def submission(self):
|
def submission(self):
|
||||||
return FormSubmission.objects.filter(template__job=self.job).first()
|
return FormSubmission.objects.filter(template__job=self.job).first()
|
||||||
@property
|
@property
|
||||||
@ -432,6 +431,16 @@ class Candidate(Base):
|
|||||||
def __str__(self):
|
def __str__(self):
|
||||||
return self.full_name
|
return self.full_name
|
||||||
|
|
||||||
|
@property
|
||||||
|
def get_meetings(self):
|
||||||
|
return self.scheduled_interviews.all()
|
||||||
|
@property
|
||||||
|
def get_latest_meeting(self):
|
||||||
|
schedule = self.scheduled_interviews.order_by('-created_at').first()
|
||||||
|
if schedule:
|
||||||
|
return schedule.zoom_meeting
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
class TrainingMaterial(Base):
|
class TrainingMaterial(Base):
|
||||||
title = models.CharField(max_length=255, verbose_name=_("Title"))
|
title = models.CharField(max_length=255, verbose_name=_("Title"))
|
||||||
@ -453,6 +462,10 @@ class TrainingMaterial(Base):
|
|||||||
|
|
||||||
|
|
||||||
class ZoomMeeting(Base):
|
class ZoomMeeting(Base):
|
||||||
|
class MeetingStatus(models.TextChoices):
|
||||||
|
SCHEDULED = "scheduled", _("Scheduled")
|
||||||
|
STARTED = "started", _("Started")
|
||||||
|
ENDED = "ended", _("Ended")
|
||||||
# Basic meeting details
|
# Basic meeting details
|
||||||
topic = models.CharField(max_length=255, verbose_name=_("Topic"))
|
topic = models.CharField(max_length=255, verbose_name=_("Topic"))
|
||||||
meeting_id = models.CharField(
|
meeting_id = models.CharField(
|
||||||
@ -469,6 +482,9 @@ class ZoomMeeting(Base):
|
|||||||
participant_video = models.BooleanField(
|
participant_video = models.BooleanField(
|
||||||
default=True, verbose_name=_("Participant Video")
|
default=True, verbose_name=_("Participant Video")
|
||||||
)
|
)
|
||||||
|
password = models.CharField(
|
||||||
|
max_length=20, blank=True, null=True, verbose_name=_("Password")
|
||||||
|
)
|
||||||
join_before_host = models.BooleanField(
|
join_before_host = models.BooleanField(
|
||||||
default=False, verbose_name=_("Join Before Host")
|
default=False, verbose_name=_("Join Before Host")
|
||||||
)
|
)
|
||||||
@ -480,6 +496,12 @@ class ZoomMeeting(Base):
|
|||||||
zoom_gateway_response = models.JSONField(
|
zoom_gateway_response = models.JSONField(
|
||||||
blank=True, null=True, verbose_name=_("Zoom Gateway Response")
|
blank=True, null=True, verbose_name=_("Zoom Gateway Response")
|
||||||
)
|
)
|
||||||
|
status = models.CharField(
|
||||||
|
max_length=20,
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
verbose_name=_("Status"),
|
||||||
|
)
|
||||||
# Timestamps
|
# Timestamps
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
@ -952,7 +974,9 @@ class InterviewSchedule(Base):
|
|||||||
) # Store days of week as [0,1,2,3,4] for Mon-Fri
|
) # Store days of week as [0,1,2,3,4] for Mon-Fri
|
||||||
start_time = models.TimeField(verbose_name=_("Start Time"))
|
start_time = models.TimeField(verbose_name=_("Start Time"))
|
||||||
end_time = models.TimeField(verbose_name=_("End Time"))
|
end_time = models.TimeField(verbose_name=_("End Time"))
|
||||||
breaks = models.ManyToManyField(BreakTime, blank=True, related_name="schedules")
|
|
||||||
|
breaks = models.JSONField(default=list, blank=True, verbose_name=_('Break Times'))
|
||||||
|
|
||||||
interview_duration = models.PositiveIntegerField(
|
interview_duration = models.PositiveIntegerField(
|
||||||
verbose_name=_("Interview Duration (minutes)")
|
verbose_name=_("Interview Duration (minutes)")
|
||||||
)
|
)
|
||||||
|
|||||||
@ -66,8 +66,10 @@ urlpatterns = [
|
|||||||
path('forms/', views.form_templates_list, name='form_templates_list'),
|
path('forms/', views.form_templates_list, name='form_templates_list'),
|
||||||
path('forms/create-template/', views.create_form_template, name='create_form_template'),
|
path('forms/create-template/', views.create_form_template, name='create_form_template'),
|
||||||
|
|
||||||
path('jobs/<slug:slug>/candidate-tiers/', views.candidate_tier_management_view, name='candidate_tier_management'),
|
path('jobs/<slug:slug>/candidate_screening_view/', views.candidate_screening_view, name='candidate_screening_view'),
|
||||||
path('jobs/<slug:slug>/candidate_exam_view/', views.candidate_exam_view, name='candidate_exam_view'),
|
path('jobs/<slug:slug>/candidate_exam_view/', views.candidate_exam_view, name='candidate_exam_view'),
|
||||||
|
path('jobs/<slug:slug>/candidate_interview_view/', views.candidate_interview_view, name='candidate_interview_view'),
|
||||||
|
|
||||||
path('jobs/<slug:slug>/update_candidate_exam_status/', views.update_candidate_exam_status, name='update_candidate_exam_status'),
|
path('jobs/<slug:slug>/update_candidate_exam_status/', views.update_candidate_exam_status, name='update_candidate_exam_status'),
|
||||||
path('jobs/<slug:slug>/bulk_update_candidate_exam_status/', views.bulk_update_candidate_exam_status, name='bulk_update_candidate_exam_status'),
|
path('jobs/<slug:slug>/bulk_update_candidate_exam_status/', views.bulk_update_candidate_exam_status, name='bulk_update_candidate_exam_status'),
|
||||||
|
|
||||||
@ -75,8 +77,8 @@ urlpatterns = [
|
|||||||
path('htmx/<slug:slug>/candidate_set_exam_date/', views.candidate_set_exam_date, name='candidate_set_exam_date'),
|
path('htmx/<slug:slug>/candidate_set_exam_date/', views.candidate_set_exam_date, name='candidate_set_exam_date'),
|
||||||
path('htmx/bulk_candidate_move_to_exam/', views.bulk_candidate_move_to_exam, name='bulk_candidate_move_to_exam'),
|
path('htmx/bulk_candidate_move_to_exam/', views.bulk_candidate_move_to_exam, name='bulk_candidate_move_to_exam'),
|
||||||
|
|
||||||
# path('forms/form/<int:template_id>/submit/', views.submit_form, name='submit_form'),
|
path('forms/form/<int:template_id>/submit/', views.submit_form, name='submit_form'),
|
||||||
# path('forms/form/<int:template_id>/', views.form_wizard_view, name='form_wizard'),
|
path('forms/form/<int:template_id>/', views.form_wizard_view, name='form_wizard'),
|
||||||
path('forms/<int:template_id>/submissions/<slug:slug>/', views.form_submission_details, name='form_submission_details'),
|
path('forms/<int:template_id>/submissions/<slug:slug>/', views.form_submission_details, name='form_submission_details'),
|
||||||
path('forms/template/<slug:slug>/submissions/', views.form_template_submissions_list, name='form_template_submissions_list'),
|
path('forms/template/<slug:slug>/submissions/', views.form_template_submissions_list, name='form_template_submissions_list'),
|
||||||
path('forms/template/<int:template_id>/all-submissions/', views.form_template_all_submissions, name='form_template_all_submissions'),
|
path('forms/template/<int:template_id>/all-submissions/', views.form_template_all_submissions, name='form_template_all_submissions'),
|
||||||
@ -90,4 +92,6 @@ urlpatterns = [
|
|||||||
# path('api/forms/<int:form_id>/load/', views.load_form, name='load_form'),
|
# path('api/forms/<int:form_id>/load/', views.load_form, name='load_form'),
|
||||||
# path('api/forms/<int:form_id>/update/', views.update_form_builder, name='update_form_builder'),
|
# path('api/forms/<int:form_id>/update/', views.update_form_builder, name='update_form_builder'),
|
||||||
|
|
||||||
|
path('jobs/<slug:slug>/calendar/', views.interview_calendar_view, name='interview_calendar'),
|
||||||
|
path('jobs/<slug:slug>/calendar/interview/<int:interview_id>/', views.interview_detail_view, name='interview_detail'),
|
||||||
]
|
]
|
||||||
|
|||||||
@ -161,7 +161,7 @@ def create_zoom_meeting(topic, start_time, duration):
|
|||||||
meeting_details = {
|
meeting_details = {
|
||||||
"topic": topic,
|
"topic": topic,
|
||||||
"type": 2,
|
"type": 2,
|
||||||
"start_time": start_time,
|
"start_time": start_time.isoformat() + "Z",
|
||||||
"duration": duration,
|
"duration": duration,
|
||||||
"timezone": "UTC",
|
"timezone": "UTC",
|
||||||
"settings": {
|
"settings": {
|
||||||
@ -273,6 +273,7 @@ def get_zoom_meeting_details(meeting_id):
|
|||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
dict: A dictionary containing the meeting details or an error message.
|
dict: A dictionary containing the meeting details or an error message.
|
||||||
|
The 'start_time' in 'meeting_details' will be a Python datetime object.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
access_token = get_access_token()
|
access_token = get_access_token()
|
||||||
@ -288,6 +289,19 @@ def get_zoom_meeting_details(meeting_id):
|
|||||||
|
|
||||||
if response.status_code == 200:
|
if response.status_code == 200:
|
||||||
meeting_data = response.json()
|
meeting_data = response.json()
|
||||||
|
if 'start_time' in meeting_data and meeting_data['start_time']:
|
||||||
|
try:
|
||||||
|
# Convert ISO 8601 string (with 'Z' for UTC) to datetime object
|
||||||
|
meeting_data['start_time'] = str(datetime.fromisoformat(
|
||||||
|
meeting_data['start_time'].replace('Z', '+00:00')
|
||||||
|
))
|
||||||
|
except (ValueError, TypeError) as e:
|
||||||
|
logger.error(
|
||||||
|
f"Failed to parse start_time '{meeting_data['start_time']}' for meeting {meeting_id}: {e}"
|
||||||
|
)
|
||||||
|
meeting_data['start_time'] = None # Ensure it's None on failure
|
||||||
|
else:
|
||||||
|
meeting_data['start_time'] = None # Explicitly set to None if not present
|
||||||
return {
|
return {
|
||||||
"status": "success",
|
"status": "success",
|
||||||
"message": "Meeting details retrieved successfully.",
|
"message": "Meeting details retrieved successfully.",
|
||||||
@ -325,11 +339,13 @@ def update_zoom_meeting(meeting_id, updated_data):
|
|||||||
"Content-Type": "application/json"
|
"Content-Type": "application/json"
|
||||||
}
|
}
|
||||||
response = requests.patch(
|
response = requests.patch(
|
||||||
f"https://api.zoom.us/v2/meetings/{meeting_id}",
|
f"https://api.zoom.us/v2/meetings/{meeting_id}/",
|
||||||
headers=headers,
|
headers=headers,
|
||||||
json=updated_data
|
json=updated_data
|
||||||
)
|
)
|
||||||
|
|
||||||
|
print(response.status_code)
|
||||||
|
|
||||||
if response.status_code == 204:
|
if response.status_code == 204:
|
||||||
return {
|
return {
|
||||||
"status": "success",
|
"status": "success",
|
||||||
@ -465,7 +481,7 @@ def send_interview_email(scheduled_interview):
|
|||||||
fail_silently=False,
|
fail_silently=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
def get_available_time_slots(schedule, breaks=None):
|
def get_available_time_slots(schedule):
|
||||||
"""
|
"""
|
||||||
Generate a list of available time slots based on the schedule criteria.
|
Generate a list of available time slots based on the schedule criteria.
|
||||||
Returns a list of dictionaries with 'date' and 'time' keys.
|
Returns a list of dictionaries with 'date' and 'time' keys.
|
||||||
@ -475,7 +491,6 @@ def get_available_time_slots(schedule, breaks=None):
|
|||||||
end_date = schedule.end_date
|
end_date = schedule.end_date
|
||||||
|
|
||||||
# Convert working days to a set for quick lookup
|
# Convert working days to a set for quick lookup
|
||||||
# working_days should be a list of integers where 0=Monday, 1=Tuesday, etc.
|
|
||||||
working_days_set = set(int(day) for day in schedule.working_days)
|
working_days_set = set(int(day) for day in schedule.working_days)
|
||||||
|
|
||||||
# Parse times
|
# Parse times
|
||||||
@ -485,17 +500,12 @@ def get_available_time_slots(schedule, breaks=None):
|
|||||||
# Calculate slot duration (interview duration + buffer time)
|
# Calculate slot duration (interview duration + buffer time)
|
||||||
slot_duration = timedelta(minutes=schedule.interview_duration + schedule.buffer_time)
|
slot_duration = timedelta(minutes=schedule.interview_duration + schedule.buffer_time)
|
||||||
|
|
||||||
# Debug output - remove in production
|
# Get breaks from the schedule
|
||||||
print(f"Working days: {working_days_set}")
|
breaks = schedule.breaks if hasattr(schedule, 'breaks') and schedule.breaks else []
|
||||||
print(f"Date range: {current_date} to {end_date}")
|
|
||||||
print(f"Time range: {start_time} to {end_time}")
|
|
||||||
print(f"Slot duration: {slot_duration}")
|
|
||||||
print(f"Breaks: {breaks}")
|
|
||||||
|
|
||||||
while current_date <= end_date:
|
while current_date <= end_date:
|
||||||
# Check if current day is a working day
|
# Check if current day is a working day
|
||||||
weekday = current_date.weekday() # Monday is 0, Sunday is 6
|
weekday = current_date.weekday() # Monday is 0, Sunday is 6
|
||||||
print(f"Checking {current_date}, weekday: {weekday}, in working days: {weekday in working_days_set}")
|
|
||||||
|
|
||||||
if weekday in working_days_set:
|
if weekday in working_days_set:
|
||||||
# Generate slots for this day
|
# Generate slots for this day
|
||||||
@ -511,13 +521,18 @@ def get_available_time_slots(schedule, breaks=None):
|
|||||||
|
|
||||||
# Check if slot conflicts with any break time
|
# Check if slot conflicts with any break time
|
||||||
conflict_with_break = False
|
conflict_with_break = False
|
||||||
if breaks:
|
for break_data in breaks:
|
||||||
for break_time in breaks:
|
# Parse break times
|
||||||
|
try:
|
||||||
|
break_start = datetime.strptime(break_data['start_time'], '%H:%M:%S').time()
|
||||||
|
break_end = datetime.strptime(break_data['end_time'], '%H:%M:%S').time()
|
||||||
|
|
||||||
# Check if the slot overlaps with this break time
|
# Check if the slot overlaps with this break time
|
||||||
if not (current_time >= break_time.end_time or slot_end_time <= break_time.start_time):
|
if not (current_time >= break_end or slot_end_time <= break_start):
|
||||||
conflict_with_break = True
|
conflict_with_break = True
|
||||||
print(f"Slot {current_time}-{slot_end_time} conflicts with break {break_time.start_time}-{break_time.end_time}")
|
|
||||||
break
|
break
|
||||||
|
except (ValueError, KeyError) as e:
|
||||||
|
continue
|
||||||
|
|
||||||
if not conflict_with_break:
|
if not conflict_with_break:
|
||||||
# Add this slot to available slots
|
# Add this slot to available slots
|
||||||
@ -525,7 +540,6 @@ def get_available_time_slots(schedule, breaks=None):
|
|||||||
'date': current_date,
|
'date': current_date,
|
||||||
'time': current_time
|
'time': current_time
|
||||||
})
|
})
|
||||||
print(f"Added slot: {current_date} {current_time}")
|
|
||||||
|
|
||||||
# Move to next slot
|
# Move to next slot
|
||||||
current_datetime = datetime.combine(current_date, current_time) + slot_duration
|
current_datetime = datetime.combine(current_date, current_time) + slot_duration
|
||||||
@ -534,11 +548,9 @@ def get_available_time_slots(schedule, breaks=None):
|
|||||||
# Move to next day
|
# Move to next day
|
||||||
current_date += timedelta(days=1)
|
current_date += timedelta(days=1)
|
||||||
|
|
||||||
print(f"Total slots generated: {len(slots)}")
|
|
||||||
return slots
|
return slots
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def json_to_markdown_table(data_list):
|
def json_to_markdown_table(data_list):
|
||||||
if not data_list:
|
if not data_list:
|
||||||
return ""
|
return ""
|
||||||
|
|||||||
@ -1,11 +1,12 @@
|
|||||||
import json
|
import json
|
||||||
import requests
|
import requests
|
||||||
from rich import print
|
from rich import print
|
||||||
|
|
||||||
from django.template.loader import render_to_string
|
from django.template.loader import render_to_string
|
||||||
from django.views.decorators.csrf import csrf_exempt
|
from django.views.decorators.csrf import csrf_exempt
|
||||||
from django.views.decorators.http import require_http_methods
|
from django.views.decorators.http import require_http_methods
|
||||||
from django.http import JsonResponse
|
from django.http import JsonResponse
|
||||||
from datetime import datetime
|
from datetime import datetime,time,timedelta
|
||||||
from django.views import View
|
from django.views import View
|
||||||
from django.db.models import Q
|
from django.db.models import Q
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
@ -31,6 +32,7 @@ from .utils import (
|
|||||||
create_zoom_meeting,
|
create_zoom_meeting,
|
||||||
delete_zoom_meeting,
|
delete_zoom_meeting,
|
||||||
update_zoom_meeting,
|
update_zoom_meeting,
|
||||||
|
get_zoom_meeting_details,
|
||||||
schedule_interviews,
|
schedule_interviews,
|
||||||
get_available_time_slots,
|
get_available_time_slots,
|
||||||
)
|
)
|
||||||
@ -47,6 +49,7 @@ from .models import (
|
|||||||
ZoomMeeting,
|
ZoomMeeting,
|
||||||
Candidate,
|
Candidate,
|
||||||
JobPosting,
|
JobPosting,
|
||||||
|
ScheduledInterview
|
||||||
)
|
)
|
||||||
import logging
|
import logging
|
||||||
from datastar_py.django import (
|
from datastar_py.django import (
|
||||||
@ -81,15 +84,18 @@ class ZoomMeetingCreateView(CreateView):
|
|||||||
if instance.start_time < timezone.now():
|
if instance.start_time < timezone.now():
|
||||||
messages.error(self.request, "Start time must be in the future.")
|
messages.error(self.request, "Start time must be in the future.")
|
||||||
return redirect("/create-meeting/", status=400)
|
return redirect("/create-meeting/", status=400)
|
||||||
start_time = instance.start_time.isoformat() + "Z"
|
start_time = instance.start_time
|
||||||
|
# start_time = instance.start_time.isoformat() + "Z"
|
||||||
duration = instance.duration
|
duration = instance.duration
|
||||||
|
|
||||||
result = create_zoom_meeting(topic, start_time, duration)
|
result = create_zoom_meeting(topic, start_time, duration)
|
||||||
|
print(result)
|
||||||
if result["status"] == "success":
|
if result["status"] == "success":
|
||||||
instance.meeting_id = result["meeting_details"]["meeting_id"]
|
instance.meeting_id = result["meeting_details"]["meeting_id"]
|
||||||
instance.join_url = result["meeting_details"]["join_url"]
|
instance.join_url = result["meeting_details"]["join_url"]
|
||||||
instance.host_email = result["meeting_details"]["host_email"]
|
instance.host_email = result["meeting_details"]["host_email"]
|
||||||
|
instance.password = result["meeting_details"]["password"]
|
||||||
|
instance.status = result["zoom_gateway_response"]["status"]
|
||||||
instance.zoom_gateway_response = result["zoom_gateway_response"]
|
instance.zoom_gateway_response = result["zoom_gateway_response"]
|
||||||
instance.save()
|
instance.save()
|
||||||
messages.success(self.request, result["message"])
|
messages.success(self.request, result["message"])
|
||||||
@ -139,6 +145,21 @@ class ZoomMeetingUpdateView(UpdateView):
|
|||||||
template_name = "meetings/update_meeting.html"
|
template_name = "meetings/update_meeting.html"
|
||||||
success_url = "/"
|
success_url = "/"
|
||||||
|
|
||||||
|
# def get_form_kwargs(self):
|
||||||
|
# kwargs = super().get_form_kwargs()
|
||||||
|
# # Ensure the form is initialized with the instance's current values
|
||||||
|
# if self.object:
|
||||||
|
# kwargs['initial'] = getattr(kwargs, 'initial', {})
|
||||||
|
# initial_start_time = ""
|
||||||
|
# if self.object.start_time:
|
||||||
|
# try:
|
||||||
|
# initial_start_time = self.object.start_time.strftime('%m-%d-%Y,T%H:%M')
|
||||||
|
# except AttributeError:
|
||||||
|
# print(f"Warning: start_time {self.object.start_time} is not a datetime object.")
|
||||||
|
# initial_start_time = ""
|
||||||
|
# kwargs['initial']['start_time'] = initial_start_time
|
||||||
|
# return kwargs
|
||||||
|
|
||||||
def form_valid(self, form):
|
def form_valid(self, form):
|
||||||
instance = form.save(commit=False)
|
instance = form.save(commit=False)
|
||||||
updated_data = {
|
updated_data = {
|
||||||
@ -149,15 +170,39 @@ class ZoomMeetingUpdateView(UpdateView):
|
|||||||
if instance.start_time < timezone.now():
|
if instance.start_time < timezone.now():
|
||||||
messages.error(self.request, "Start time must be in the future.")
|
messages.error(self.request, "Start time must be in the future.")
|
||||||
return redirect(f"/update-meeting/{instance.pk}/", status=400)
|
return redirect(f"/update-meeting/{instance.pk}/", status=400)
|
||||||
|
|
||||||
result = update_zoom_meeting(instance.meeting_id, updated_data)
|
result = update_zoom_meeting(instance.meeting_id, updated_data)
|
||||||
|
|
||||||
if result["status"] == "success":
|
if result["status"] == "success":
|
||||||
instance.save()
|
# Fetch the latest details from Zoom after successful update
|
||||||
messages.success(self.request, result["message"])
|
details_result = get_zoom_meeting_details(instance.meeting_id)
|
||||||
return redirect(reverse("meeting_details", kwargs={"pk": instance.pk}))
|
|
||||||
|
if details_result["status"] == "success":
|
||||||
|
zoom_details = details_result["meeting_details"]
|
||||||
|
# Update instance with fetched details
|
||||||
|
|
||||||
|
instance.topic = zoom_details.get("topic", instance.topic)
|
||||||
|
|
||||||
|
instance.duration = zoom_details.get("duration", instance.duration)
|
||||||
|
instance.join_url = zoom_details.get("join_url", instance.join_url)
|
||||||
|
instance.password = zoom_details.get("password", instance.password)
|
||||||
|
# Corrected status assignment: instance.status, not instance.password
|
||||||
|
instance.status = zoom_details.get("status")
|
||||||
|
|
||||||
|
instance.zoom_gateway_response = details_result.get("meeting_details") # Store full response
|
||||||
|
instance.save()
|
||||||
|
messages.success(self.request, result["message"] + " Local data updated from Zoom.")
|
||||||
|
else:
|
||||||
|
# If fetching details fails, save with form data and log a warning
|
||||||
|
logger.warning(
|
||||||
|
f"Successfully updated Zoom meeting {instance.meeting_id}, but failed to fetch updated details. "
|
||||||
|
f"Error: {details_result.get('message', 'Unknown error')}"
|
||||||
|
)
|
||||||
|
instance.save() # Save with data from the form
|
||||||
|
messages.success(self.request, result["message"] + " (Note: Could not refresh local data from Zoom.)")
|
||||||
|
return redirect(reverse("meeting_details", kwargs={"slug": instance.slug}))
|
||||||
else:
|
else:
|
||||||
messages.error(self.request, result["message"])
|
messages.error(self.request, result["message"])
|
||||||
return redirect(reverse("meeting_details", kwargs={"pk": instance.pk}))
|
return redirect(reverse("meeting_details", kwargs={"slug": instance.slug}))
|
||||||
|
|
||||||
|
|
||||||
def ZoomMeetingDeleteView(request, pk):
|
def ZoomMeetingDeleteView(request, pk):
|
||||||
@ -275,16 +320,23 @@ def job_detail(request, slug):
|
|||||||
"""View details of a specific job"""
|
"""View details of a specific job"""
|
||||||
job = get_object_or_404(JobPosting, slug=slug)
|
job = get_object_or_404(JobPosting, slug=slug)
|
||||||
|
|
||||||
|
print(job)
|
||||||
# Get all candidates for this job, ordered by most recent
|
# Get all candidates for this job, ordered by most recent
|
||||||
applicants = job.candidates.all().order_by("-created_at")
|
applicants = job.candidates.all().order_by("-created_at")
|
||||||
|
print(applicants)
|
||||||
|
|
||||||
# Count candidates by stage for summary statistics
|
# Count candidates by stage for summary statistics
|
||||||
total_applicant = applicants.count()
|
total_applicant = applicants.count()
|
||||||
|
|
||||||
applied_count = applicants.filter(stage="Applied").count()
|
applied_count = applicants.filter(stage="Applied").count()
|
||||||
|
|
||||||
|
exam_count=applicants.filter(stage="Exam").count
|
||||||
|
|
||||||
interview_count = applicants.filter(stage="Interview").count()
|
interview_count = applicants.filter(stage="Interview").count()
|
||||||
|
|
||||||
offer_count = applicants.filter(stage="Offer").count()
|
offer_count = applicants.filter(stage="Offer").count()
|
||||||
|
|
||||||
|
|
||||||
status_form = JobPostingStatusForm(instance=job)
|
status_form = JobPostingStatusForm(instance=job)
|
||||||
image_upload_form=JobPostingImageForm(instance=job)
|
image_upload_form=JobPostingImageForm(instance=job)
|
||||||
|
|
||||||
@ -314,6 +366,7 @@ def job_detail(request, slug):
|
|||||||
"applicants": applicants,
|
"applicants": applicants,
|
||||||
"total_applicants": total_applicant,
|
"total_applicants": total_applicant,
|
||||||
"applied_count": applied_count,
|
"applied_count": applied_count,
|
||||||
|
'exam_count':exam_count,
|
||||||
"interview_count": interview_count,
|
"interview_count": interview_count,
|
||||||
"offer_count": offer_count,
|
"offer_count": offer_count,
|
||||||
'status_form':status_form,
|
'status_form':status_form,
|
||||||
@ -702,6 +755,7 @@ def form_builder(request, template_id=None):
|
|||||||
template = get_object_or_404(
|
template = get_object_or_404(
|
||||||
FormTemplate, id=template_id, created_by=request.user
|
FormTemplate, id=template_id, created_by=request.user
|
||||||
)
|
)
|
||||||
|
context['template']=template
|
||||||
context["template_id"] = template.id
|
context["template_id"] = template.id
|
||||||
context["template_name"] = template.name
|
context["template_name"] = template.name
|
||||||
return render(request, "forms/form_builder.html", context)
|
return render(request, "forms/form_builder.html", context)
|
||||||
@ -878,7 +932,7 @@ def delete_form_template(request, template_id):
|
|||||||
|
|
||||||
def form_wizard_view(request, template_id):
|
def form_wizard_view(request, template_id):
|
||||||
"""Display the form as a step-by-step wizard"""
|
"""Display the form as a step-by-step wizard"""
|
||||||
template = get_object_or_404(FormTemplate, id=template_id, is_active=True)
|
template = get_object_or_404(FormTemplate, pk=template_id, is_active=True)
|
||||||
job_id = template.job.internal_job_id
|
job_id = template.job.internal_job_id
|
||||||
return render(
|
return render(
|
||||||
request,
|
request,
|
||||||
@ -1065,12 +1119,11 @@ def form_submission_details(request, template_id, slug):
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def schedule_interviews_view(request, slug):
|
||||||
def schedule_interviews_view(request, job_id):
|
job = get_object_or_404(JobPosting, slug=slug)
|
||||||
job = get_object_or_404(JobPosting, id=job_id)
|
|
||||||
|
|
||||||
if request.method == "POST":
|
if request.method == "POST":
|
||||||
form = InterviewScheduleForm(job_id, request.POST)
|
form = InterviewScheduleForm(slug, request.POST)
|
||||||
break_formset = BreakTimeFormSet(request.POST)
|
break_formset = BreakTimeFormSet(request.POST)
|
||||||
|
|
||||||
# Check if this is a confirmation request
|
# Check if this is a confirmation request
|
||||||
@ -1079,21 +1132,31 @@ def schedule_interviews_view(request, job_id):
|
|||||||
schedule_data = request.session.get("interview_schedule_data")
|
schedule_data = request.session.get("interview_schedule_data")
|
||||||
if not schedule_data:
|
if not schedule_data:
|
||||||
messages.error(request, "Session expired. Please try again.")
|
messages.error(request, "Session expired. Please try again.")
|
||||||
return redirect("schedule_interviews", job_id=job_id)
|
return redirect("schedule_interviews", slug=slug)
|
||||||
|
|
||||||
# Create the interview schedule
|
# Create the interview schedule
|
||||||
schedule = InterviewSchedule.objects.create(
|
schedule = InterviewSchedule.objects.create(
|
||||||
job=job, created_by=request.user, **schedule_data
|
job=job,
|
||||||
|
created_by=request.user,
|
||||||
|
start_date=datetime.fromisoformat(schedule_data["start_date"]).date(),
|
||||||
|
end_date=datetime.fromisoformat(schedule_data["end_date"]).date(),
|
||||||
|
working_days=schedule_data["working_days"],
|
||||||
|
start_time=time.fromisoformat(schedule_data["start_time"]),
|
||||||
|
end_time=time.fromisoformat(schedule_data["end_time"]),
|
||||||
|
interview_duration=schedule_data["interview_duration"],
|
||||||
|
buffer_time=schedule_data["buffer_time"],
|
||||||
|
breaks=schedule_data["breaks"],
|
||||||
)
|
)
|
||||||
|
|
||||||
# Add candidates to the schedule
|
# Add candidates to the schedule
|
||||||
candidates = Candidate.objects.filter(id__in=schedule_data["candidate_ids"])
|
candidates = Candidate.objects.filter(id__in=schedule_data["candidate_ids"])
|
||||||
schedule.candidates.set(candidates)
|
schedule.candidates.set(candidates)
|
||||||
|
|
||||||
# Add break times to the schedule
|
# Create temporary break time objects for slot calculation
|
||||||
if "breaks" in schedule_data and schedule_data["breaks"]:
|
temp_breaks = []
|
||||||
for break_data in schedule_data["breaks"]:
|
for break_data in schedule_data["breaks"]:
|
||||||
break_time = BreakTime.objects.create(
|
temp_breaks.append(
|
||||||
|
BreakTime(
|
||||||
start_time=datetime.strptime(
|
start_time=datetime.strptime(
|
||||||
break_data["start_time"], "%H:%M:%S"
|
break_data["start_time"], "%H:%M:%S"
|
||||||
).time(),
|
).time(),
|
||||||
@ -1101,21 +1164,71 @@ def schedule_interviews_view(request, job_id):
|
|||||||
break_data["end_time"], "%H:%M:%S"
|
break_data["end_time"], "%H:%M:%S"
|
||||||
).time(),
|
).time(),
|
||||||
)
|
)
|
||||||
schedule.breaks.add(break_time)
|
|
||||||
|
|
||||||
# Schedule the interviews
|
|
||||||
try:
|
|
||||||
scheduled_count = schedule_interviews(schedule)
|
|
||||||
messages.success(
|
|
||||||
request, f"Successfully scheduled {scheduled_count} interviews."
|
|
||||||
)
|
)
|
||||||
# Clear the session data
|
|
||||||
if "interview_schedule_data" in request.session:
|
# Get available slots
|
||||||
del request.session["interview_schedule_data"]
|
available_slots = get_available_time_slots(schedule)
|
||||||
return redirect("job_detail", pk=job_id)
|
|
||||||
except Exception as e:
|
# Create scheduled interviews
|
||||||
messages.error(request, f"Error scheduling interviews: {str(e)}")
|
scheduled_count = 0
|
||||||
return redirect("schedule_interviews", job_id=job_id)
|
for i, candidate in enumerate(candidates):
|
||||||
|
if i < len(available_slots):
|
||||||
|
slot = available_slots[i]
|
||||||
|
interview_datetime = datetime.combine(slot['date'], slot['time'])
|
||||||
|
|
||||||
|
# Create Zoom meeting
|
||||||
|
meeting_topic = f"Interview for {job.title} - {candidate.name}"
|
||||||
|
|
||||||
|
start_time = interview_datetime.isoformat() + "Z"
|
||||||
|
|
||||||
|
zoom_meeting = create_zoom_meeting(
|
||||||
|
topic=meeting_topic,
|
||||||
|
start_time=start_time,
|
||||||
|
duration=schedule.interview_duration
|
||||||
|
)
|
||||||
|
|
||||||
|
result = create_zoom_meeting(meeting_topic, start_time, schedule.interview_duration)
|
||||||
|
|
||||||
|
if result["status"] == "success":
|
||||||
|
zoom_meeting = ZoomMeeting.objects.create(
|
||||||
|
topic=meeting_topic,
|
||||||
|
start_time=interview_datetime,
|
||||||
|
duration=schedule.interview_duration,
|
||||||
|
meeting_id=result["meeting_details"]["meeting_id"],
|
||||||
|
join_url=result["meeting_details"]["join_url"],
|
||||||
|
zoom_gateway_response=result["zoom_gateway_response"],
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create scheduled interview record
|
||||||
|
scheduled_interview = ScheduledInterview.objects.create(
|
||||||
|
candidate=candidate,
|
||||||
|
job=job,
|
||||||
|
zoom_meeting=zoom_meeting,
|
||||||
|
schedule=schedule,
|
||||||
|
interview_date=slot['date'],
|
||||||
|
interview_time=slot['time']
|
||||||
|
)
|
||||||
|
|
||||||
|
# Send email to candidate
|
||||||
|
# try:
|
||||||
|
# send_interview_email(scheduled_interview)
|
||||||
|
# except Exception as e:
|
||||||
|
# messages.warning(
|
||||||
|
# request,
|
||||||
|
# f"Interview scheduled for {candidate.name}, but failed to send email: {str(e)}"
|
||||||
|
# )
|
||||||
|
|
||||||
|
scheduled_count += 1
|
||||||
|
|
||||||
|
messages.success(
|
||||||
|
request, f"Successfully scheduled {scheduled_count} interviews."
|
||||||
|
)
|
||||||
|
|
||||||
|
# Clear the session data
|
||||||
|
if "interview_schedule_data" in request.session:
|
||||||
|
del request.session["interview_schedule_data"]
|
||||||
|
|
||||||
|
return redirect("job_detail", slug=slug)
|
||||||
|
|
||||||
# This is the initial form submission
|
# This is the initial form submission
|
||||||
if form.is_valid() and break_formset.is_valid():
|
if form.is_valid() and break_formset.is_valid():
|
||||||
@ -1139,8 +1252,8 @@ def schedule_interviews_view(request, job_id):
|
|||||||
{
|
{
|
||||||
"start_time": break_form.cleaned_data[
|
"start_time": break_form.cleaned_data[
|
||||||
"start_time"
|
"start_time"
|
||||||
].isoformat(),
|
].strftime("%H:%M:%S"),
|
||||||
"end_time": break_form.cleaned_data["end_time"].isoformat(),
|
"end_time": break_form.cleaned_data["end_time"].strftime("%H:%M:%S"),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -1154,6 +1267,7 @@ def schedule_interviews_view(request, job_id):
|
|||||||
end_time=end_time,
|
end_time=end_time,
|
||||||
interview_duration=interview_duration,
|
interview_duration=interview_duration,
|
||||||
buffer_time=buffer_time,
|
buffer_time=buffer_time,
|
||||||
|
breaks=breaks,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Create temporary break time objects
|
# Create temporary break time objects
|
||||||
@ -1171,7 +1285,7 @@ def schedule_interviews_view(request, job_id):
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Get available slots
|
# Get available slots
|
||||||
available_slots = get_available_time_slots(temp_schedule, temp_breaks)
|
available_slots = get_available_time_slots(temp_schedule)
|
||||||
|
|
||||||
if len(available_slots) < len(candidates):
|
if len(available_slots) < len(candidates):
|
||||||
messages.error(
|
messages.error(
|
||||||
@ -1224,7 +1338,7 @@ def schedule_interviews_view(request, job_id):
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
form = InterviewScheduleForm(job_id=job_id)
|
form = InterviewScheduleForm(slug=slug)
|
||||||
break_formset = BreakTimeFormSet()
|
break_formset = BreakTimeFormSet()
|
||||||
|
|
||||||
return render(
|
return render(
|
||||||
@ -1232,17 +1346,170 @@ def schedule_interviews_view(request, job_id):
|
|||||||
"interviews/schedule_interviews.html",
|
"interviews/schedule_interviews.html",
|
||||||
{"form": form, "break_formset": break_formset, "job": job},
|
{"form": form, "break_formset": break_formset, "job": job},
|
||||||
)
|
)
|
||||||
|
# def schedule_interviews_view(request, slug):
|
||||||
|
# job = get_object_or_404(JobPosting, slug=slug)
|
||||||
|
|
||||||
|
# if request.method == "POST":
|
||||||
|
# form = InterviewScheduleForm(slug, request.POST)
|
||||||
|
# break_formset = BreakTimeFormSet(request.POST)
|
||||||
|
|
||||||
def candidate_tier_management_view(request, slug):
|
# # Check if this is a confirmation request
|
||||||
|
# if "confirm_schedule" in request.POST:
|
||||||
|
# # Get the schedule data from session
|
||||||
|
# schedule_data = request.session.get("interview_schedule_data")
|
||||||
|
# if not schedule_data:
|
||||||
|
# messages.error(request, "Session expired. Please try again.")
|
||||||
|
# return redirect("schedule_interviews", slug=slug)
|
||||||
|
|
||||||
|
# # Create the interview schedule
|
||||||
|
# schedule = InterviewSchedule.objects.create(
|
||||||
|
# job=job,
|
||||||
|
# created_by=request.user,
|
||||||
|
# start_date=datetime.fromisoformat(schedule_data["start_date"]).date(),
|
||||||
|
# end_date=datetime.fromisoformat(schedule_data["end_date"]).date(),
|
||||||
|
# working_days=schedule_data["working_days"],
|
||||||
|
# start_time=time.fromisoformat(schedule_data["start_time"]),
|
||||||
|
# end_time=time.fromisoformat(schedule_data["end_time"]),
|
||||||
|
# interview_duration=schedule_data["interview_duration"],
|
||||||
|
# buffer_time=schedule_data["buffer_time"],
|
||||||
|
# breaks=schedule_data["breaks"], # Direct assignment for JSON field
|
||||||
|
# )
|
||||||
|
|
||||||
|
# # Add candidates to the schedule
|
||||||
|
# candidates = Candidate.objects.filter(id__in=schedule_data["candidate_ids"])
|
||||||
|
# schedule.candidates.set(candidates)
|
||||||
|
|
||||||
|
# # Schedule the interviews
|
||||||
|
# try:
|
||||||
|
# scheduled_count = schedule_interviews(schedule)
|
||||||
|
# messages.success(
|
||||||
|
# request, f"Successfully scheduled {scheduled_count} interviews."
|
||||||
|
# )
|
||||||
|
# # Clear the session data
|
||||||
|
# if "interview_schedule_data" in request.session:
|
||||||
|
# del request.session["interview_schedule_data"]
|
||||||
|
# return redirect("job_detail", slug=slug)
|
||||||
|
# except Exception as e:
|
||||||
|
# messages.error(request, f"Error scheduling interviews: {str(e)}")
|
||||||
|
# return redirect("schedule_interviews", slug=slug)
|
||||||
|
|
||||||
|
# # This is the initial form submission
|
||||||
|
# if form.is_valid() and break_formset.is_valid():
|
||||||
|
# # Get the form data
|
||||||
|
# candidates = form.cleaned_data["candidates"]
|
||||||
|
# start_date = form.cleaned_data["start_date"]
|
||||||
|
# end_date = form.cleaned_data["end_date"]
|
||||||
|
# working_days = form.cleaned_data["working_days"]
|
||||||
|
# start_time = form.cleaned_data["start_time"]
|
||||||
|
# end_time = form.cleaned_data["end_time"]
|
||||||
|
# interview_duration = form.cleaned_data["interview_duration"]
|
||||||
|
# buffer_time = form.cleaned_data["buffer_time"]
|
||||||
|
|
||||||
|
# # Process break times
|
||||||
|
# breaks = []
|
||||||
|
# for break_form in break_formset:
|
||||||
|
# if break_form.cleaned_data and not break_form.cleaned_data.get(
|
||||||
|
# "DELETE"
|
||||||
|
# ):
|
||||||
|
# breaks.append(
|
||||||
|
# {
|
||||||
|
# "start_time": break_form.cleaned_data[
|
||||||
|
# "start_time"
|
||||||
|
# ].strftime("%H:%M:%S"),
|
||||||
|
# "end_time": break_form.cleaned_data["end_time"].strftime("%H:%M:%S"),
|
||||||
|
# }
|
||||||
|
# )
|
||||||
|
|
||||||
|
# # Create a temporary schedule object (not saved to DB)
|
||||||
|
# temp_schedule = InterviewSchedule(
|
||||||
|
# job=job,
|
||||||
|
# start_date=start_date,
|
||||||
|
# end_date=end_date,
|
||||||
|
# working_days=working_days,
|
||||||
|
# start_time=start_time,
|
||||||
|
# end_time=end_time,
|
||||||
|
# interview_duration=interview_duration,
|
||||||
|
# buffer_time=buffer_time,
|
||||||
|
# breaks=breaks, # Direct assignment for JSON field
|
||||||
|
# )
|
||||||
|
|
||||||
|
# # Get available slots
|
||||||
|
# available_slots = get_available_time_slots(temp_schedule)
|
||||||
|
|
||||||
|
# if len(available_slots) < len(candidates):
|
||||||
|
# messages.error(
|
||||||
|
# request,
|
||||||
|
# f"Not enough available slots. Required: {len(candidates)}, Available: {len(available_slots)}",
|
||||||
|
# )
|
||||||
|
# return render(
|
||||||
|
# request,
|
||||||
|
# "interviews/schedule_interviews.html",
|
||||||
|
# {"form": form, "break_formset": break_formset, "job": job},
|
||||||
|
# )
|
||||||
|
|
||||||
|
# # Create a preview schedule
|
||||||
|
# preview_schedule = []
|
||||||
|
# for i, candidate in enumerate(candidates):
|
||||||
|
# slot = available_slots[i]
|
||||||
|
# preview_schedule.append(
|
||||||
|
# {"candidate": candidate, "date": slot["date"], "time": slot["time"]}
|
||||||
|
# )
|
||||||
|
|
||||||
|
# # Save the form data to session for later use
|
||||||
|
# schedule_data = {
|
||||||
|
# "start_date": start_date.isoformat(),
|
||||||
|
# "end_date": end_date.isoformat(),
|
||||||
|
# "working_days": working_days,
|
||||||
|
# "start_time": start_time.isoformat(),
|
||||||
|
# "end_time": end_time.isoformat(),
|
||||||
|
# "interview_duration": interview_duration,
|
||||||
|
# "buffer_time": buffer_time,
|
||||||
|
# "candidate_ids": [c.id for c in candidates],
|
||||||
|
# "breaks": breaks,
|
||||||
|
# }
|
||||||
|
# request.session["interview_schedule_data"] = schedule_data
|
||||||
|
|
||||||
|
# # Render the preview page
|
||||||
|
# return render(
|
||||||
|
# request,
|
||||||
|
# "interviews/preview_schedule.html",
|
||||||
|
# {
|
||||||
|
# "job": job,
|
||||||
|
# "schedule": preview_schedule,
|
||||||
|
# "start_date": start_date,
|
||||||
|
# "end_date": end_date,
|
||||||
|
# "working_days": working_days,
|
||||||
|
# "start_time": start_time,
|
||||||
|
# "end_time": end_time,
|
||||||
|
# "breaks": breaks,
|
||||||
|
# "interview_duration": interview_duration,
|
||||||
|
# "buffer_time": buffer_time,
|
||||||
|
# },
|
||||||
|
# )
|
||||||
|
# else:
|
||||||
|
# form = InterviewScheduleForm(slug=slug)
|
||||||
|
# break_formset = BreakTimeFormSet()
|
||||||
|
|
||||||
|
# return render(
|
||||||
|
# request,
|
||||||
|
# "interviews/schedule_interviews.html",
|
||||||
|
# {"form": form, "break_formset": break_formset, "job": job},
|
||||||
|
# )
|
||||||
|
|
||||||
|
def candidate_screening_view(request, slug):
|
||||||
"""
|
"""
|
||||||
Manage candidate tiers and stage transitions
|
Manage candidate tiers and stage transitions
|
||||||
"""
|
"""
|
||||||
job = get_object_or_404(JobPosting, slug=slug)
|
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)
|
# Get all candidates for this job, ordered by match score (descending)
|
||||||
candidates = job.candidates.filter(stage="Applied").order_by("-match_score")
|
candidates = job.candidates.filter(stage="Applied").order_by("-match_score")
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
# Get tier categorization parameters
|
# Get tier categorization parameters
|
||||||
# tier1_count = int(request.GET.get("tier1_count", 100))
|
# tier1_count = int(request.GET.get("tier1_count", 100))
|
||||||
|
|
||||||
@ -1265,7 +1532,7 @@ def candidate_tier_management_view(request, slug):
|
|||||||
# if "update_tiers" in request.POST:
|
# if "update_tiers" in request.POST:
|
||||||
# tier1_count = int(request.POST.get("tier1_count", 100))
|
# tier1_count = int(request.POST.get("tier1_count", 100))
|
||||||
# messages.success(request, f"Tier categorization updated. Tier 1: {tier1_count} candidates")
|
# messages.success(request, f"Tier categorization updated. Tier 1: {tier1_count} candidates")
|
||||||
# return redirect("candidate_tier_management", slug=slug)
|
# return redirect("candidate_screening_view", slug=slug)
|
||||||
|
|
||||||
# # Update individual candidate stages
|
# # Update individual candidate stages
|
||||||
# elif "update_stage" in request.POST:
|
# elif "update_stage" in request.POST:
|
||||||
@ -1339,12 +1606,41 @@ def candidate_tier_management_view(request, slug):
|
|||||||
# messages.info(request, "All Tier 1 candidates are already marked as Candidates")
|
# messages.info(request, "All Tier 1 candidates are already marked as Candidates")
|
||||||
|
|
||||||
# Group candidates by current stage for display
|
# Group candidates by current stage for display
|
||||||
stage_groups = {
|
# stage_groups = {
|
||||||
"Applied": candidates.filter(stage="Applied"),
|
# "Applied": candidates.filter(stage="Applied"),
|
||||||
"Exam": candidates.filter(stage="Exam"),
|
# "Exam": candidates.filter(stage="Exam"),
|
||||||
"Interview": candidates.filter(stage="Interview"),
|
# "Interview": candidates.filter(stage="Interview"),
|
||||||
"Offer": candidates.filter(stage="Offer"),
|
# "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 = {
|
context = {
|
||||||
"job": job,
|
"job": job,
|
||||||
@ -1352,11 +1648,25 @@ def candidate_tier_management_view(request, slug):
|
|||||||
# "stage_groups": stage_groups,
|
# "stage_groups": stage_groups,
|
||||||
# "tier1_count": tier1_count,
|
# "tier1_count": tier1_count,
|
||||||
# "total_candidates": candidates.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_tier_management.html", context)
|
return render(request, "recruitment/candidate_screening_view.html", context)
|
||||||
|
|
||||||
|
|
||||||
|
def get_candidates_from_request(request):
|
||||||
|
for c in request.POST.items():
|
||||||
|
try:
|
||||||
|
yield Candidate.objects.get(pk=c[0])
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(e)
|
||||||
|
yield None
|
||||||
def candidate_exam_view(request, slug):
|
def candidate_exam_view(request, slug):
|
||||||
"""
|
"""
|
||||||
Manage candidate tiers and stage transitions
|
Manage candidate tiers and stage transitions
|
||||||
@ -1378,15 +1688,16 @@ def update_candidate_exam_status(request, slug):
|
|||||||
return render(request, "includes/candidate_exam_status_form.html", {"candidate": candidate,"form": form})
|
return render(request, "includes/candidate_exam_status_form.html", {"candidate": candidate,"form": form})
|
||||||
def bulk_update_candidate_exam_status(request,slug):
|
def bulk_update_candidate_exam_status(request,slug):
|
||||||
job = get_object_or_404(JobPosting, slug=slug)
|
job = get_object_or_404(JobPosting, slug=slug)
|
||||||
print(request.headers)
|
|
||||||
status = request.headers.get('status')
|
status = request.headers.get('status')
|
||||||
print(status)
|
|
||||||
if status:
|
if status:
|
||||||
for c in request.POST.items():
|
for candidate in get_candidates_from_request(request):
|
||||||
try:
|
try:
|
||||||
candidate = Candidate.objects.get(pk=c[0])
|
if status == "pass":
|
||||||
candidate.exam_status = "Passed" if status == "pass" else "Failed"
|
candidate.exam_status = "Passed"
|
||||||
candidate.stage = "Interview"
|
candidate.stage = "Interview"
|
||||||
|
else:
|
||||||
|
candidate.exam_status = "Failed"
|
||||||
candidate.save()
|
candidate.save()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(e)
|
print(e)
|
||||||
@ -1403,20 +1714,97 @@ def candidate_set_exam_date(request, slug):
|
|||||||
candidate.exam_date = timezone.now()
|
candidate.exam_date = timezone.now()
|
||||||
candidate.save()
|
candidate.save()
|
||||||
messages.success(request, f"Set exam date for {candidate.name} to {candidate.exam_date}")
|
messages.success(request, f"Set exam date for {candidate.name} to {candidate.exam_date}")
|
||||||
return redirect("candidate_tier_management", slug=candidate.job.slug)
|
return redirect("candidate_screening_view", slug=candidate.job.slug)
|
||||||
|
|
||||||
def bulk_candidate_move_to_exam(request):
|
def bulk_candidate_move_to_exam(request):
|
||||||
for c in request.POST.items():
|
for candidate in get_candidates_from_request(request):
|
||||||
try:
|
candidate.stage = "Exam"
|
||||||
candidate = Candidate.objects.get(pk=c[0])
|
candidate.applicant_status = "Candidate"
|
||||||
candidate.stage = "Exam"
|
candidate.exam_date = timezone.now()
|
||||||
candidate.applicant_status = "Candidate"
|
candidate.save()
|
||||||
candidate.exam_date = timezone.now()
|
|
||||||
candidate.save()
|
messages.success(request, f"Candidates Moved to Exam stage")
|
||||||
except Exception as e:
|
return redirect("candidate_screening_view", slug=candidate.job.slug)
|
||||||
print(e)
|
|
||||||
messages.success(request, f"Moved {candidate.name} to Exam stage")
|
|
||||||
return redirect("candidate_tier_management", slug=candidate.job.slug)
|
|
||||||
# def response():
|
# def response():
|
||||||
# yield SSE.patch_elements("","")
|
# yield SSE.patch_elements("","")
|
||||||
# yield SSE.execute_script("console.log('hello world');")
|
# yield SSE.execute_script("console.log('hello world');")
|
||||||
# return DatastarResponse(response())
|
# return DatastarResponse(response())
|
||||||
|
|
||||||
|
def candidate_interview_view(request,slug):
|
||||||
|
job = get_object_or_404(JobPosting,slug=slug)
|
||||||
|
if "Datastar-Request" in request.headers:
|
||||||
|
for candidate in get_candidates_from_request(request):
|
||||||
|
print(candidate)
|
||||||
|
context = {"job":job,"candidates":job.candidates.all()}
|
||||||
|
return render(request,"recruitment/candidate_interview_view.html",context)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def interview_calendar_view(request, slug):
|
||||||
|
job = get_object_or_404(JobPosting, slug=slug)
|
||||||
|
|
||||||
|
# Get all scheduled interviews for this job
|
||||||
|
scheduled_interviews = ScheduledInterview.objects.filter(
|
||||||
|
job=job
|
||||||
|
).select_related('candidate', 'zoom_meeting')
|
||||||
|
print(scheduled_interviews)
|
||||||
|
# Convert interviews to calendar events
|
||||||
|
events = []
|
||||||
|
for interview in scheduled_interviews:
|
||||||
|
# Create start datetime
|
||||||
|
start_datetime = datetime.combine(
|
||||||
|
interview.interview_date,
|
||||||
|
interview.interview_time
|
||||||
|
)
|
||||||
|
|
||||||
|
# Calculate end datetime based on interview duration
|
||||||
|
duration = interview.zoom_meeting.duration if interview.zoom_meeting else 60
|
||||||
|
end_datetime = start_datetime + timedelta(minutes=duration)
|
||||||
|
|
||||||
|
# Determine event color based on status
|
||||||
|
color = '#00636e' # Default color
|
||||||
|
if interview.status == 'confirmed':
|
||||||
|
color = '#00a86b' # Green for confirmed
|
||||||
|
elif interview.status == 'cancelled':
|
||||||
|
color = '#e74c3c' # Red for cancelled
|
||||||
|
elif interview.status == 'completed':
|
||||||
|
color = '#95a5a6' # Gray for completed
|
||||||
|
|
||||||
|
events.append({
|
||||||
|
'title': f"Interview: {interview.candidate.name}",
|
||||||
|
'start': start_datetime.isoformat(),
|
||||||
|
'end': end_datetime.isoformat(),
|
||||||
|
'url': f"{request.path}interview/{interview.id}/",
|
||||||
|
'color': color,
|
||||||
|
'extendedProps': {
|
||||||
|
'candidate': interview.candidate.name,
|
||||||
|
'email': interview.candidate.email,
|
||||||
|
'status': interview.status,
|
||||||
|
'meeting_id': interview.zoom_meeting.meeting_id if interview.zoom_meeting else None,
|
||||||
|
'join_url': interview.zoom_meeting.join_url if interview.zoom_meeting else None,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
context = {
|
||||||
|
'job': job,
|
||||||
|
'events': events,
|
||||||
|
'calendar_color': '#00636e',
|
||||||
|
}
|
||||||
|
|
||||||
|
return render(request, 'recruitment/interview_calendar.html', context)
|
||||||
|
|
||||||
|
def interview_detail_view(request, slug, interview_id):
|
||||||
|
job = get_object_or_404(JobPosting, slug=slug)
|
||||||
|
interview = get_object_or_404(
|
||||||
|
ScheduledInterview,
|
||||||
|
id=interview_id,
|
||||||
|
job=job
|
||||||
|
)
|
||||||
|
|
||||||
|
context = {
|
||||||
|
'job': job,
|
||||||
|
'interview': interview,
|
||||||
|
}
|
||||||
|
|
||||||
|
return render(request, 'recruitment/interview_detail.html', context)
|
||||||
|
|||||||
@ -278,6 +278,8 @@ def candidate_update_stage(request, slug):
|
|||||||
'new_stage_display': candidate.get_stage_display(),
|
'new_stage_display': candidate.get_stage_display(),
|
||||||
'candidate': candidate
|
'candidate': candidate
|
||||||
}
|
}
|
||||||
|
messages.success(request,"Candidate Stage Updated")
|
||||||
|
return redirect("candidate_detail",slug=candidate.slug)
|
||||||
def response():
|
def response():
|
||||||
stage_form = forms.CandidateStageForm(candidate=candidate)
|
stage_form = forms.CandidateStageForm(candidate=candidate)
|
||||||
context['stage_form'] = stage_form
|
context['stage_form'] = stage_form
|
||||||
|
|||||||
@ -307,7 +307,7 @@
|
|||||||
</span>
|
</span>
|
||||||
</a>
|
</a>
|
||||||
</li> {% endcomment %}
|
</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' %}">
|
<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">
|
<span class="d-flex align-items-center gap-2">
|
||||||
{% include "icons/jobs.html" %}
|
{% include "icons/jobs.html" %}
|
||||||
@ -316,7 +316,7 @@
|
|||||||
</a>
|
</a>
|
||||||
</li>
|
</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' %}">
|
<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">
|
<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">
|
<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>
|
</span>
|
||||||
|
|
||||||
</a>
|
</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' %}">
|
<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">
|
<span class="d-flex align-items-center gap-2">
|
||||||
{% include "icons/users.html" %}
|
{% include "icons/users.html" %}
|
||||||
{% trans "Candidates" %}
|
{% trans "Applicants" %}
|
||||||
</span>
|
</span>
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</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' %}">
|
<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">
|
<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">
|
<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>
|
||||||
|
|
||||||
|
|
||||||
<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' %}">
|
<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">
|
<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">
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6">
|
||||||
|
|||||||
@ -779,10 +779,15 @@
|
|||||||
<a href="{% url 'dashboard' %}" style="color: #6c757d !important; text-decoration: none !important;">Home</a>
|
<a href="{% url 'dashboard' %}" style="color: #6c757d !important; text-decoration: none !important;">Home</a>
|
||||||
/
|
/
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
<span class="me-2">
|
<span class="me-2">
|
||||||
<a href="{% url 'job_list' %}" style="color: #6c757d !important; text-decoration: none !important;">Jobs</a>
|
<a href="{% url 'job_list' %}" style="color: #6c757d !important; text-decoration: none !important;">Jobs</a>
|
||||||
/
|
/
|
||||||
</span>
|
</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>
|
<span style="color: #6c757d; font-weight: 600;">Form Builder</span>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|||||||
3
templates/icons/link.html
Normal file
3
templates/icons/link.html
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6 heroicon">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M13.5 6H5.25A2.25 2.25 0 0 0 3 8.25v10.5A2.25 2.25 0 0 0 5.25 21h10.5A2.25 2.25 0 0 0 18 18.75V10.5m-10.5 6L21 3m0 0h-5.25M21 3v5.25" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 340 B |
@ -80,7 +80,7 @@
|
|||||||
<button type="submit" name="confirm_schedule" class="btn btn-success">
|
<button type="submit" name="confirm_schedule" class="btn btn-success">
|
||||||
<i class="fas fa-check"></i> Confirm Schedule
|
<i class="fas fa-check"></i> Confirm Schedule
|
||||||
</button>
|
</button>
|
||||||
<a href="{% url 'schedule_interviews' job_id=job.id %}" class="btn btn-secondary">
|
<a href="{% url 'schedule_interviews' slug=job.slug %}" class="btn btn-secondary">
|
||||||
<i class="fas fa-arrow-left"></i> Back to Edit
|
<i class="fas fa-arrow-left"></i> Back to Edit
|
||||||
</a>
|
</a>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@ -99,7 +99,7 @@
|
|||||||
|
|
||||||
<div class="mt-4">
|
<div class="mt-4">
|
||||||
<button type="submit" class="btn btn-primary">Preview Schedule</button>
|
<button type="submit" class="btn btn-primary">Preview Schedule</button>
|
||||||
<a href="{% url 'job_detail' pk=job.id %}" class="btn btn-secondary">Cancel</a>
|
<a href="{% url 'job_detail' slug=job.slug %}" class="btn btn-secondary">Cancel</a>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -160,126 +160,6 @@
|
|||||||
border: 1px solid;
|
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>
|
</style>
|
||||||
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
@ -389,11 +269,14 @@
|
|||||||
<div class="col-md-6">
|
<div class="col-md-6">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="btn btn-outline-secondary"
|
class="btn btn-main-action"
|
||||||
id="copyJobLinkButton"
|
id="copyJobLinkButton"
|
||||||
data-url="{{ job.application_url }}">
|
data-url="{{ job.application_url }}">
|
||||||
<i class="fas fa-link me-1"></i>
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6">
|
||||||
{% trans "Copy and Share Public Link" %}
|
<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>
|
</button>
|
||||||
|
|
||||||
<span id="copyFeedback" class="text-success ms-2 small" style="display:none;">
|
<span id="copyFeedback" class="text-success ms-2 small" style="display:none;">
|
||||||
@ -487,69 +370,13 @@
|
|||||||
|
|
||||||
{# RIGHT COLUMN: TABBED CARDS #}
|
{# RIGHT COLUMN: TABBED CARDS #}
|
||||||
<div class="col-lg-4 ">
|
<div class="col-lg-4 ">
|
||||||
<div class="card shadow-sm no-hover mb-4">
|
<div class="card shadow-sm no-hover mb-4">
|
||||||
<div class="card-body p-4">
|
<div class="card-body p-4">
|
||||||
<h6 class="text-muted mb-4">{% trans "Applicant Tracking" %}</h6>
|
<h6 class="text-muted mb-4">{% trans "Applicant Tracking" %}</h6>
|
||||||
<div class="progress-stages">
|
{% include 'jobs/partials/applicant_tracking.html' %}
|
||||||
<div class="d-flex justify-content-between align-items-center">
|
|
||||||
|
|
||||||
{% comment %} STAGE 1: Applied {% endcomment %}
|
|
||||||
<a href="{% url 'job_candidates_list' job.slug %}?stage=Applied"
|
|
||||||
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>
|
</div>
|
||||||
<div class="stage-label">{% trans "Applied" %}</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 'job_candidates_list' job.slug %}?stage=Exam"
|
|
||||||
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 'job_candidates_list' job.slug %}?stage=Interview"
|
|
||||||
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 class="card shadow-sm no-hover" style="height:350px;">
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="card shadow-sm no-hover" style="height:400px;">
|
|
||||||
|
|
||||||
{# RIGHT TABS NAVIGATION #}
|
{# RIGHT TABS NAVIGATION #}
|
||||||
<ul class="nav nav-tabs right-column-tabs" id="rightJobTabs" role="tablist">
|
<ul class="nav nav-tabs right-column-tabs" id="rightJobTabs" role="tablist">
|
||||||
@ -575,7 +402,7 @@
|
|||||||
{# TAB 1: APPLICANTS CONTENT #}
|
{# TAB 1: APPLICANTS CONTENT #}
|
||||||
<div class="tab-pane fade show active" id="applicants-pane" role="tabpanel" aria-labelledby="applicants-tab">
|
<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>
|
<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="row mb-4 applicant-stats">
|
||||||
<div class="col-4">
|
<div class="col-4">
|
||||||
<div class="stat-item">
|
<div class="stat-item">
|
||||||
@ -602,15 +429,15 @@
|
|||||||
</a>
|
</a>
|
||||||
</div>
|
</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">
|
<a href="{% url 'candidate_create_for_job' job.slug %}" class="btn btn-main-action">
|
||||||
<i class="fas fa-user-plus"></i> {% trans "Create Applicant" %}
|
<i class="fas fa-user-plus"></i> {% trans "Create Applicant" %}
|
||||||
</a>
|
</a>
|
||||||
<a href="{% url 'candidate_tier_management' job.slug %}" class="btn btn-main-action">
|
<a href="{% url 'candidate_screening_view' job.slug %}" class="btn btn-main-action">
|
||||||
<i class="fas fa-layer-group"></i> {% trans "Manage Applicants" %}
|
<i class="fas fa-layer-group"></i> {% trans "Manage Applicants" %}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -700,9 +527,11 @@
|
|||||||
</a> {% endcomment %}
|
</a> {% endcomment %}
|
||||||
|
|
||||||
{% comment %} <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 Applicant" %}
|
<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> {% endcomment %}
|
</a> {% endcomment %}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@ -11,11 +11,16 @@
|
|||||||
--kaauh-teal-dark: #004a53;
|
--kaauh-teal-dark: #004a53;
|
||||||
--kaauh-border: #eaeff3;
|
--kaauh-border: #eaeff3;
|
||||||
--kaauh-primary-text: #343a40;
|
--kaauh-primary-text: #343a40;
|
||||||
|
--kaauh-success: #28a745;
|
||||||
|
--kaauh-danger: #dc3545;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Primary Color Overrides */
|
/* Primary Color Overrides */
|
||||||
.text-primary-theme { color: var(--kaauh-teal) !important; }
|
.text-primary-theme { color: var(--kaauh-teal) !important; }
|
||||||
.bg-primary-theme { background-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 */
|
/* Enhanced Card Styling */
|
||||||
.card {
|
.card {
|
||||||
@ -66,6 +71,7 @@
|
|||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.7px;
|
letter-spacing: 0.7px;
|
||||||
|
color: white;
|
||||||
}
|
}
|
||||||
.bg-DRAFT { background-color: #6c757d !important; }
|
.bg-DRAFT { background-color: #6c757d !important; }
|
||||||
.bg-ACTIVE { background-color: var(--kaauh-teal) !important; }
|
.bg-ACTIVE { background-color: var(--kaauh-teal) !important; }
|
||||||
@ -75,92 +81,112 @@
|
|||||||
|
|
||||||
/* --- TABLE ALIGNMENT AND SIZING FIXES --- */
|
/* --- TABLE ALIGNMENT AND SIZING FIXES --- */
|
||||||
.table {
|
.table {
|
||||||
table-layout: fixed; /* Ensures column widths are respected */
|
table-layout: fixed;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
}
|
}
|
||||||
.table thead th {
|
.table thead th {
|
||||||
color: var(--kaauh-primary-text);
|
color: var(--kaauh-primary-text);
|
||||||
font-weight: 500; /* Lighter weight for smaller font */
|
font-weight: 600;
|
||||||
font-size: 0.85rem; /* Smaller font size for header text */
|
font-size: 0.85rem;
|
||||||
vertical-align: middle;
|
vertical-align: middle;
|
||||||
border-bottom: 2px solid var(--kaauh-border);
|
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 {
|
.table-hover tbody tr:hover {
|
||||||
background-color: #f3f7f9;
|
background-color: #f3f7f9;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Optimized Main Table Column Widths (Total must be 100%) */
|
/* 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) */
|
/* Candidate Management Header Row (The one with P/F) */
|
||||||
.table th:nth-child(3) { width: 8%; } /* Actions (Tight, icon buttons) */
|
.nested-metrics-row th {
|
||||||
.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 */
|
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
text-align: center;
|
|
||||||
color: #6c757d;
|
color: #6c757d;
|
||||||
font-size: 0.75rem; /* Even smaller font for nested headers */
|
font-size: 0.75rem;
|
||||||
width: calc(100% / 7);
|
padding: 0.3rem 0;
|
||||||
}
|
border-bottom: 2px solid var(--kaauh-teal);
|
||||||
/* Explicit widths are technically defined by the 1/7 rule, but keeping them for clarity/safety */
|
text-align: center;
|
||||||
.nested-header-table thead th:nth-child(1),
|
border-left: 1px solid var(--kaauh-border);
|
||||||
.nested-header-table thead th:nth-child(2),
|
|
||||||
.nested-header-table thead th:nth-child(5) {
|
|
||||||
width: calc(100% / 7);
|
|
||||||
}
|
|
||||||
.nested-header-table thead th:nth-child(3),
|
|
||||||
.nested-header-table thead th:nth-child(4) {
|
|
||||||
width: calc(100% / 7 * 2);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Inner Nested Table (P/F) */
|
.nested-metrics-row th {
|
||||||
.nested-stage-metrics {
|
width: calc(50% / 7);
|
||||||
width: 100%;
|
|
||||||
border-collapse: collapse;
|
|
||||||
table-layout: fixed;
|
|
||||||
}
|
}
|
||||||
.nested-stage-metrics thead th {
|
.nested-metrics-row th[colspan="2"] {
|
||||||
padding: 0.1rem 0; /* Very minimal padding */
|
width: calc(50% / 7 * 2);
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Inner P/F Headers */
|
||||||
|
.nested-stage-metrics {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-around;
|
||||||
|
padding-top: 5px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--kaauh-teal-dark);
|
color: var(--kaauh-teal-dark);
|
||||||
font-size: 0.7rem; /* Smallest font size */
|
font-size: 0.7rem;
|
||||||
width: 50%;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Main TH for Candidate Management Header */
|
/* Main TH for Candidate Management Header Title */
|
||||||
.candidate-management-header {
|
.candidate-management-header-title {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
padding: 0;
|
padding: 0.5rem 0.25rem;
|
||||||
border-left: 2px solid var(--kaauh-teal);
|
border-left: 2px solid var(--kaauh-teal);
|
||||||
border-right: 1px solid var(--kaauh-border) !important;
|
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 {
|
.candidate-data-cell {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
vertical-align: middle;
|
vertical-align: middle;
|
||||||
font-weight: 600;
|
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);
|
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 {
|
.candidate-data-cell a {
|
||||||
display: block;
|
display: block;
|
||||||
text-decoration: none;
|
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>
|
</style>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
@ -215,63 +241,60 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% comment %} --- START OF TABLE VIEW (Data relied upon context variable 'jobs') --- {% endcomment %}
|
{# --- START OF JOB LIST CONTAINER --- #}
|
||||||
<div id="job-list">
|
<div id="job-list">
|
||||||
{% comment %} Placeholder for View Switcher {% endcomment %}
|
{# View Switcher (Contains the Card/Table buttons and JS/CSS logic) #}
|
||||||
{% include "includes/_list_view_switcher.html" with list_id="job-list" %}
|
{% include "includes/_list_view_switcher.html" with list_id="job-list" %}
|
||||||
|
|
||||||
|
{# 1. TABLE VIEW (Default Active) #}
|
||||||
<div class="table-view active">
|
<div class="table-view active">
|
||||||
<div class="card shadow-sm">
|
<div class="card shadow-sm">
|
||||||
<div class="table-responsive ">
|
<div class="table-responsive ">
|
||||||
<table class="table table-hover align-middle mb-0 table-sm">
|
<table class="table table-hover align-middle mb-0 table-sm">
|
||||||
|
|
||||||
|
{# --- Corrected Multi-Row Header Structure --- #}
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th scope="col">{% trans "Job ID" %}</th>
|
<th scope="col" rowspan="2" style="width: 22%;">{% trans "Job Title / ID" %}</th>
|
||||||
{% comment %} <th scope="col">{% trans "Job Title" %}</th>
|
<th scope="col" rowspan="2" style="width: 12%;">{% trans "Source" %}</th>
|
||||||
<th scope="col">{% trans "Status" %}</th> {% endcomment %}
|
<th scope="col" rowspan="2" style="width: 8%;">{% trans "Actions" %}</th>
|
||||||
<th scope="col">{% trans "Source" %}</th>
|
<th scope="col" rowspan="2" class="text-center" style="width: 8%;">{% trans "Manage Forms" %}</th>
|
||||||
<th scope="col">{% trans "Actions" %}</th>
|
|
||||||
<th scop="col" class="text-center">{% 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" %}
|
{% 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>
|
</th>
|
||||||
</tr>
|
</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>
|
</thead>
|
||||||
|
|
||||||
<tbody>
|
<tbody>
|
||||||
{% comment %} This loop relies on the 'jobs' variable passed from the Django view {% endcomment %}
|
|
||||||
{% for job in jobs %}
|
{% for job in jobs %}
|
||||||
<tr>
|
<tr>
|
||||||
<td class="fw-medium text-primary-theme">{{ job }}</td>
|
<td class="fw-medium text-primary-theme">
|
||||||
{% comment %} <td class="fw-medium text-primary-theme">{{ job.title }}</td>
|
{{ job.title }}
|
||||||
<td><span class="badge bg-{{ job.status }} status-badge">{{ job.status }}</span></td> {% endcomment %}
|
<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>{{ job.get_source }}</td>
|
||||||
<td>
|
<td>
|
||||||
<div class="btn-group btn-group-sm" role="group">
|
<div class="btn-group btn-group-sm" role="group">
|
||||||
@ -283,21 +306,23 @@
|
|||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td class="text-end">
|
<td class="text-center">
|
||||||
<div class="btn-group btn-group-sm" role="group">
|
<div class="btn-group btn-group-sm" role="group">
|
||||||
<a href="{% url 'form_wizard' job.form_template.id %}" class="btn btn-outline-primary" title="{% trans 'Preview' %}">
|
{% if job.form_template %}
|
||||||
<i class="fas fa-eye"></i>
|
<a href="{% url 'form_wizard' job.form_template.pk %}" class="btn btn-outline-secondary" title="{% trans 'Preview' %}">
|
||||||
</a>
|
<i class="fas fa-eye"></i>
|
||||||
<a href="{% url 'form_builder' job.form_template.id %}" class="btn btn-outline-secondary" title="{% trans 'Edit' %}">
|
</a>
|
||||||
<i class="fas fa-edit"></i>
|
<a href="{% url 'form_builder' job.form_template.id %}" class="btn btn-outline-secondary" title="{% trans 'Edit' %}">
|
||||||
</a>
|
<i class="fas fa-edit"></i>
|
||||||
<a href="{% url 'form_template_submissions_list' job.form_template.slug %}" class="btn btn-outline-secondary" title="{% trans 'Submissions' %}">
|
</a>
|
||||||
<i class="fas fa-file-alt"></i>
|
<a href="{% url 'form_template_submissions_list' job.form_template.slug %}" class="btn btn-outline-secondary" title="{% trans 'Submissions' %}">
|
||||||
</a>
|
<i class="fas fa-file-alt"></i>
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</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-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-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>
|
<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>
|
||||||
@ -312,7 +337,53 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
{# --- END OF JOB LIST CONTAINER --- #}
|
||||||
|
|
||||||
{% comment %} Fallback/Empty State {% endcomment %}
|
{% comment %} Fallback/Empty State {% endcomment %}
|
||||||
{% if not jobs and not job_list_data and not page_obj %}
|
{% if not jobs and not job_list_data and not page_obj %}
|
||||||
|
|||||||
183
templates/jobs/partials/applicant_tracking.html
Normal file
183
templates/jobs/partials/applicant_tracking.html
Normal 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>
|
||||||
@ -221,7 +221,7 @@
|
|||||||
|
|
||||||
<div class="mt-auto pt-2 border-top">
|
<div class="mt-auto pt-2 border-top">
|
||||||
<div class="d-flex gap-2">
|
<div class="d-flex gap-2">
|
||||||
<a href="{% url 'meeting_details' meeting.pk %}" class="btn btn-sm btn-outline-primary">
|
<a href="{% url 'meeting_details' meeting.slug %}" class="btn btn-sm btn-outline-primary">
|
||||||
<i class="fas fa-eye"></i> {% trans "View" %}
|
<i class="fas fa-eye"></i> {% trans "View" %}
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
@ -231,12 +231,12 @@
|
|||||||
</a>
|
</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<a href="{% url 'update_meeting' meeting.pk %}" class="btn btn-sm btn-outline-secondary">
|
<a href="{% url 'update_meeting' meeting.slug %}" class="btn btn-sm btn-outline-secondary">
|
||||||
<i class="fas fa-edit"></i>
|
<i class="fas fa-edit"></i>
|
||||||
</a>
|
</a>
|
||||||
<button type="button" class="btn btn-outline-danger btn-sm" title="{% trans 'Delete' %}"
|
<button type="button" class="btn btn-outline-danger btn-sm" title="{% trans 'Delete' %}"
|
||||||
data-bs-toggle="modal" data-bs-target="#deleteModal"
|
data-bs-toggle="modal" data-bs-target="#deleteModal"
|
||||||
data-delete-url="{% url 'delete_meeting' meeting.pk %}"
|
data-delete-url="{% url 'delete_meeting' meeting.slug %}"
|
||||||
data-item-name="{{ meeting.topic }}">
|
data-item-name="{{ meeting.topic }}">
|
||||||
<i class="fas fa-trash-alt"></i>
|
<i class="fas fa-trash-alt"></i>
|
||||||
</button>
|
</button>
|
||||||
@ -281,15 +281,15 @@
|
|||||||
<i class="fas fa-sign-in-alt"></i>
|
<i class="fas fa-sign-in-alt"></i>
|
||||||
</a>
|
</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<a href="{% url 'meeting_details' meeting.pk %}" class="btn btn-outline-primary" title="{% trans 'View' %}">
|
<a href="{% url 'meeting_details' meeting.slug %}" class="btn btn-outline-primary" title="{% trans 'View' %}">
|
||||||
<i class="fas fa-eye"></i>
|
<i class="fas fa-eye"></i>
|
||||||
</a>
|
</a>
|
||||||
<a href="{% url 'update_meeting' meeting.pk %}" class="btn btn-outline-secondary" title="{% trans 'Update' %}">
|
<a href="{% url 'update_meeting' meeting.slug %}" class="btn btn-outline-secondary" title="{% trans 'Update' %}">
|
||||||
<i class="fas fa-edit"></i>
|
<i class="fas fa-edit"></i>
|
||||||
</a>
|
</a>
|
||||||
<button type="button" class="btn btn-outline-danger" title="{% trans 'Delete' %}"
|
<button type="button" class="btn btn-outline-danger" title="{% trans 'Delete' %}"
|
||||||
data-bs-toggle="modal" data-bs-target="#deleteModal"
|
data-bs-toggle="modal" data-bs-target="#deleteModal"
|
||||||
data-delete-url="{% url 'delete_meeting' meeting.pk %}"
|
data-delete-url="{% url 'delete_meeting' meeting.slug %}"
|
||||||
data-item-name="{{ meeting.topic }}">
|
data-item-name="{{ meeting.topic }}">
|
||||||
<i class="fas fa-trash-alt"></i>
|
<i class="fas fa-trash-alt"></i>
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@ -3,61 +3,256 @@
|
|||||||
|
|
||||||
{% block title %}{% trans "Meeting Details" %} - {{ block.super }}{% endblock %}
|
{% block title %}{% trans "Meeting Details" %} - {{ block.super }}{% endblock %}
|
||||||
|
|
||||||
|
{% block customCSS %}
|
||||||
|
<style>
|
||||||
|
/* UI Variables for the KAAT-S Theme (Consistent with list_meetings.html) */
|
||||||
|
:root {
|
||||||
|
--kaauh-teal: #00636e;
|
||||||
|
--kaauh-teal-dark: #004a53;
|
||||||
|
--kaauh-border: #eaeff3;
|
||||||
|
--kaauh-primary-text: #343a40;
|
||||||
|
--kaauh-gray-light: #f8f9fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Main Container and Card Styling */
|
||||||
|
.container {
|
||||||
|
padding: 2rem 1rem;
|
||||||
|
}
|
||||||
|
.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;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
.card:not(.no-hover):hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 6px 16px rgba(0,0,0,0.1);
|
||||||
|
transition: transform 0.2s, box-shadow 0.2s;
|
||||||
|
}
|
||||||
|
.card.no-hover:hover {
|
||||||
|
transform: none;
|
||||||
|
box-shadow: 0 4px 12px rgba(0,0,0,0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* Header Card Styling */
|
||||||
|
.card-header {
|
||||||
|
background-color: var(--kaauh-gray-light);
|
||||||
|
border-bottom: 1px solid var(--kaauh-border);
|
||||||
|
padding: 1.5rem;
|
||||||
|
border-radius: 0.75rem 0.75rem 0 0; /* Match top border radius of card */
|
||||||
|
}
|
||||||
|
.card-header h1 {
|
||||||
|
color: var(--kaauh-teal-dark);
|
||||||
|
font-weight: 700;
|
||||||
|
margin: 0 0 0.5rem 0; /* Space below title */
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
.card-header .heroicon {
|
||||||
|
width: 1.75rem;
|
||||||
|
height: 1.75rem;
|
||||||
|
color: var(--kaauh-teal);
|
||||||
|
}
|
||||||
|
.card-header .btn-secondary {
|
||||||
|
background-color: var(--kaauh-teal);
|
||||||
|
border-color: var(--kaauh-teal);
|
||||||
|
color: white;
|
||||||
|
font-weight: 600;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
.card-header .btn-secondary:hover {
|
||||||
|
background-color: var(--kaauh-teal-dark);
|
||||||
|
border-color: var(--kaauh-teal-dark);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Status Badge Styling (from list_meetings.html) */
|
||||||
|
.status-badge {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
padding: 0.4em 0.8em;
|
||||||
|
border-radius: 0.4rem;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.7px;
|
||||||
|
}
|
||||||
|
.bg-waiting { background-color: #ffc107 !important; color: var(--kaauh-primary-text) !important;}
|
||||||
|
.bg-started { background-color: var(--kaauh-teal) !important; color: white !important;}
|
||||||
|
.bg-ended { background-color: #dc3545 !important; color: white !important;}
|
||||||
|
|
||||||
|
|
||||||
|
/* Detail Row Styling */
|
||||||
|
.detail-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0.75rem 1.5rem;
|
||||||
|
border-bottom: 1px solid var(--kaauh-border);
|
||||||
|
}
|
||||||
|
.detail-row:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
.detail-label {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--kaauh-teal-dark);
|
||||||
|
min-width: 40%; /* Ensure labels align */
|
||||||
|
}
|
||||||
|
.detail-value {
|
||||||
|
text-align: right;
|
||||||
|
color: var(--kaauh-primary-text);
|
||||||
|
word-wrap: break-word; /* Long URLs or text wrap */
|
||||||
|
max-width: 60%; /* Ensure values align */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Card Title Styling within content cards */
|
||||||
|
.card h2 {
|
||||||
|
color: var(--kaauh-teal-dark);
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 1.5rem 1.5rem 0.75rem;
|
||||||
|
margin: 0;
|
||||||
|
border-bottom: 1px solid var(--kaauh-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Join URL Styling */
|
||||||
|
.join-url-display {
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
border: 1px solid var(--kaauh-border);
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
word-break: break-all; /* Force long URLs to break */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Actions Styling */
|
||||||
|
.actions {
|
||||||
|
padding: 1.5rem;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 1rem; /* Space between buttons */
|
||||||
|
justify-content: flex-start; /* Align buttons to the left */
|
||||||
|
}
|
||||||
|
.btn-primary {
|
||||||
|
background-color: var(--kaauh-teal);
|
||||||
|
border-color: var(--kaauh-teal);
|
||||||
|
color: white;
|
||||||
|
font-weight: 600;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
.btn-primary:hover {
|
||||||
|
background-color: var(--kaauh-teal-dark);
|
||||||
|
border-color: var(--kaauh-teal-dark);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
.btn-danger {
|
||||||
|
background-color: #dc3545;
|
||||||
|
border-color: #dc3545;
|
||||||
|
color: white;
|
||||||
|
font-weight: 600;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
.btn-danger:hover {
|
||||||
|
background-color: #c82333;
|
||||||
|
border-color: #bd2130;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
.btn-secondary {
|
||||||
|
background-color: #6c757d;
|
||||||
|
border-color: #6c757d;
|
||||||
|
color: white;
|
||||||
|
font-weight: 600;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
.btn-secondary:hover {
|
||||||
|
background-color: #5a6268;
|
||||||
|
border-color: #545b62;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* API Response Styling */
|
||||||
|
#gateway-response {
|
||||||
|
margin-top: 2rem;
|
||||||
|
}
|
||||||
|
#gateway-response .card {
|
||||||
|
border-left: 4px solid var(--kaauh-teal); /* Indicate it's different content */
|
||||||
|
}
|
||||||
|
#gateway-response pre {
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
border: 1px solid var(--kaauh-border);
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
padding: 1rem;
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
overflow-x: auto; /* For long JSON lines */
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--kaauh-primary-text);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="card">
|
<div class="container py-4">
|
||||||
<div class="card-header">
|
<div class="card no-hover">
|
||||||
<h1>
|
<div class="card-header">
|
||||||
<svg class="heroicon" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
<h1>
|
||||||
<path d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"></path>
|
<svg class="heroicon" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
<path d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0 8.268-2.943-9.542-7z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"></path>
|
<path d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"></path>
|
||||||
</svg>
|
<path d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0 8.268-2.943-9.542-7z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"></path>
|
||||||
{{ meeting.topic }}
|
</svg>
|
||||||
</h1>
|
{{ meeting.topic }}
|
||||||
<span class="status-badge status-{{ meeting.status }}">
|
</h1>
|
||||||
{{ meeting.status|title }}
|
<span class="status-badge bg-{{ meeting.status }}">
|
||||||
</span>
|
{{ meeting.status|title }}
|
||||||
<a href="{% url 'list_meetings' %}" class="btn btn-secondary">{% trans "Back to Meetings" %}</a>
|
</span>
|
||||||
|
<a href="{% url 'list_meetings' %}" class="btn btn-secondary mt-3">{% trans "Back to Meetings" %}</a>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card">
|
<div class="card no-hover">
|
||||||
<h2>{% trans "Meeting Information" %}</h2>
|
<h2>{% trans "Meeting Information" %}</h2>
|
||||||
<div class="detail-row">
|
<div class="detail-row">
|
||||||
<div class="detail-label">{% trans "Meeting ID" %}:</div>
|
<div class="detail-label">{% trans "Meeting ID" %}:</div>
|
||||||
<div class="detail-value">{{ meeting.meeting_id }}</div>
|
<div class="detail-value">{{ meeting.meeting_id }}</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="detail-row">
|
||||||
|
<div class="detail-label">{% trans "Topic" %}:</div>
|
||||||
|
<div class="detail-value">{{ meeting.topic }}</div>
|
||||||
|
</div>
|
||||||
<div class="detail-row">
|
<div class="detail-row">
|
||||||
<div class="detail-label">{% trans "Start Time" %}:</div>
|
<div class="detail-label">{% trans "Start Time" %}:</div>
|
||||||
<div class="detail-value">{{ meeting.start_time|date:"M d, Y H:i" }}</div>
|
<div class="detail-value">{{ meeting.start_time|date:"M d, Y H:i" }}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="detail-row">
|
<div class="detail-row">
|
||||||
<div class="detail-label">{% trans "Duration" %}:</div>
|
<div class="detail-label">{% trans "Duration" %}:</div>
|
||||||
<div class="detail-value">{{ meeting.duration }} minutes</div>
|
<div class="detail-value">{{ meeting.duration }} {% trans "minutes" %}</div>
|
||||||
|
</div>
|
||||||
|
<div class="detail-row">
|
||||||
|
<div class="detail-label">{% trans "Timezone" %}:</div>
|
||||||
|
<div class="detail-value">{{ meeting.timezone|default:"UTC" }}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="detail-row">
|
<div class="detail-row">
|
||||||
<div class="detail-label">{% trans "Host Email" %}:</div>
|
<div class="detail-label">{% trans "Host Email" %}:</div>
|
||||||
<div class="detail-value">{{ meeting.host_email }}</div>
|
<div class="detail-value">{{ meeting.host_email|default:"N/A" }}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% if meeting.join_url %}
|
{% if meeting.join_url %}
|
||||||
<div class="card">
|
<div class="card no-hover">
|
||||||
<h2>{% trans "Join Information" %}</h2>
|
<h2>{% trans "Join Information" %}</h2>
|
||||||
<a href="{{ meeting.join_url }}" class="btn btn-primary" target="_blank">{% trans "Join Meeting" %}</a>
|
<a href="{{ meeting.join_url }}" class="btn btn-primary" target="_blank">{% trans "Join Meeting" %}</a>
|
||||||
|
{% comment %} <div class="join-url-display">
|
||||||
|
<strong>{% trans "Join URL:" %}</strong> {{ meeting.join_url }}
|
||||||
|
</div> {% endcomment %}
|
||||||
{% if meeting.password %}
|
{% if meeting.password %}
|
||||||
<div class="detail-row">
|
<div class="detail-row">
|
||||||
<div class="detail-label">{% trans "Password" %}:</div>
|
<div class="detail-label">{% trans "Password" %}: {{ meeting.password }}</div>
|
||||||
<div class="detail-value">{{ meeting.password }}</div>
|
<div class="detail-value"></div>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<div class="card">
|
<div class="card no-hover">
|
||||||
<h2>{% trans "Settings" %}</h2>
|
<h2>{% trans "Settings" %}</h2>
|
||||||
<div class="detail-row">
|
|
||||||
<div class="detail-label">{% trans "Host Video" %}:</div>
|
|
||||||
<div class="detail-value">{{ meeting.host_video|yesno:"Yes,No" }}</div>
|
|
||||||
</div>
|
|
||||||
<div class="detail-row">
|
<div class="detail-row">
|
||||||
<div class="detail-label">{% trans "Participant Video" %}:</div>
|
<div class="detail-label">{% trans "Participant Video" %}:</div>
|
||||||
<div class="detail-value">{{ meeting.participant_video|yesno:"Yes,No" }}</div>
|
<div class="detail-value">{{ meeting.participant_video|yesno:"Yes,No" }}</div>
|
||||||
@ -76,27 +271,36 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="actions">
|
<div class="card no-hover">
|
||||||
{% if meeting.zoom_gateway_response %}
|
<div class="actions">
|
||||||
<a href="#" class="btn btn-secondary" onclick="toggleGateway()">{% trans "View API Response" %}</a>
|
{% if meeting.zoom_gateway_response %}
|
||||||
{% endif %}
|
<a href="#" class="btn btn-secondary" onclick="toggleGateway()">{% trans "View API Response" %}</a>
|
||||||
<a href="{% url 'update_meeting' meeting.pk %}" class="btn btn-primary">{% trans "Update Meeting" %}</a>
|
{% endif %}
|
||||||
<form method="post" action="{% url 'delete_meeting' meeting.pk %}" style="display: inline;">
|
<a href="{% url 'update_meeting' meeting.slug %}" class="btn btn-primary">{% trans "Update Meeting" %}</a>
|
||||||
{% csrf_token %}
|
<form method="post" action="{% url 'delete_meeting' meeting.pk %}" style="display: inline;">
|
||||||
<button type="submit" class="btn btn-danger" onclick="return confirm('{% trans "Are you sure?" %}')">{% trans "Delete Meeting" %}</button>
|
{% csrf_token %}
|
||||||
</form>
|
<button type="submit" class="btn btn-danger" onclick="return confirm('{% trans "Are you sure?" %}')">{% trans "Delete Meeting" %}</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% if meeting.zoom_gateway_response %}
|
{% if meeting.zoom_gateway_response %}
|
||||||
<div id="gateway-response" style="display: none; margin-top: 2rem;">
|
{% comment %} <div id="gateway-response">
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h3>{% trans "Zoom API Response" %}</h3>
|
<h3>{% trans "Zoom API Response" %}</h3>
|
||||||
<pre>{{ meeting.zoom_gateway_response|safe }}</pre>
|
<pre>{{ meeting.zoom_gateway_response|safe }}</pre>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div> {% endcomment %}
|
||||||
<script>
|
<script>
|
||||||
function toggleGateway() {
|
function toggleGateway() {
|
||||||
document.getElementById('gateway-response').style.display = document.getElementById('gateway-response').style.display === 'none' ? 'block' : 'none';
|
const element = document.getElementById('gateway-response');
|
||||||
|
if (element.style.display === 'none' || !element.style.display) {
|
||||||
|
element.style.display = 'block';
|
||||||
|
} else {
|
||||||
|
element.style.display = 'none';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|||||||
@ -190,24 +190,20 @@
|
|||||||
<div class="card">
|
<div class="card">
|
||||||
<h2 class="card-title">{% trans "Meeting Information" %}</h2>
|
<h2 class="card-title">{% trans "Meeting Information" %}</h2>
|
||||||
|
|
||||||
<form method="post" action="{% url 'update_meeting' meeting.pk %}">
|
<form method="post" action="{% url 'update_meeting' meeting.slug %}">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
|
|
||||||
<div class="form-row">
|
<div class="form-row">
|
||||||
<label for="topic" class="form-label">{% trans "Topic:" %}</label>
|
<label class="form-label" for="{{ form.topic.id_for_label }}">{% trans "Topic" %}</label>
|
||||||
<input type="text" id="topic" name="topic" class="form-input" value="{{ meeting.topic }}" required>
|
{{ form.topic }}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-row">
|
<div class="form-row">
|
||||||
<label for="start_time" class="form-label">{% trans "Start Time (ISO 8601):" %}</label>
|
<label class="form-label" for="{{ form.start_time.id_for_label }}">{% trans "Start Time" %}</label>
|
||||||
{# Note: Django template filter for datetime-local needs to be precise Y-m-d\TH:i #}
|
{{ form.start_time }}
|
||||||
<input type="datetime-local" id="start_time" name="start_time" class="form-input"
|
|
||||||
value="{{ meeting.start_time|slice:'0:16' }}" required>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-row">
|
<div class="form-row">
|
||||||
<label for="duration" class="form-label">{% trans "Duration (minutes):" %}</label>
|
<label class="form-label" for="{{ form.duration.id_for_label }}">{% trans "Duration (minutes)" %}</label>
|
||||||
<input type="number" id="duration" name="duration" class="form-input" value="{{ meeting.duration }}" required>
|
{{ form.duration }}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
|
|||||||
@ -5,143 +5,143 @@
|
|||||||
|
|
||||||
{% block customCSS %}
|
{% block customCSS %}
|
||||||
<style>
|
<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 {
|
.tier-controls {
|
||||||
background-color: #f8f9fa;
|
background-color: var(--kaauh-border); /* Light background for control sections */
|
||||||
padding: 1rem;
|
border-radius: 0.75rem;
|
||||||
border-radius: 0.375rem;
|
padding: 1.25rem;
|
||||||
margin-bottom: 1.5rem;
|
margin-bottom: 2rem;
|
||||||
|
border: 1px solid var(--kaauh-border);
|
||||||
}
|
}
|
||||||
.tier-controls .form-row {
|
.tier-controls .form-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: end;
|
align-items: end;
|
||||||
gap: 0.75rem;
|
gap: 1rem;
|
||||||
}
|
}
|
||||||
.tier-controls .form-group {
|
.tier-controls .form-group {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
margin-bottom: 0;
|
margin-bottom: 0;
|
||||||
}
|
}
|
||||||
.bulk-update-controls {
|
|
||||||
background-color: #f8f9fa;
|
/* 2. Button Styling (Themed for Main Actions) */
|
||||||
padding: 1rem;
|
.btn-main-action {
|
||||||
border-radius: 0.375rem;
|
background-color: var(--kaauh-teal);
|
||||||
margin-bottom: 1.5rem;
|
border-color: var(--kaauh-teal);
|
||||||
|
color: white;
|
||||||
|
font-weight: 600;
|
||||||
|
transition: all 0.2s ease;
|
||||||
}
|
}
|
||||||
.stage-groups {
|
.btn-main-action:hover {
|
||||||
display: grid;
|
background-color: var(--kaauh-teal-dark);
|
||||||
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
border-color: var(--kaauh-teal-dark);
|
||||||
gap: 1rem;
|
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;
|
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 */
|
/* 4. Candidate Table Styling (KAAT-S Look) */
|
||||||
.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 */
|
|
||||||
.candidate-table {
|
.candidate-table {
|
||||||
|
table-layout: fixed;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
border-collapse: separate;
|
border-collapse: separate;
|
||||||
border-spacing: 0;
|
border-spacing: 0;
|
||||||
background-color: white;
|
background-color: white;
|
||||||
border-radius: 0.375rem;
|
border-radius: 0.5rem;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
|
|
||||||
}
|
}
|
||||||
.candidate-table thead {
|
.candidate-table thead {
|
||||||
background-color: #f8f9fa;
|
background-color: var(--kaauh-border);
|
||||||
}
|
}
|
||||||
.candidate-table th {
|
.candidate-table th {
|
||||||
padding: 0.75rem;
|
padding: 0.75rem;
|
||||||
text-align: left;
|
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
font-size: 0.875rem;
|
color: var(--kaauh-teal-dark);
|
||||||
color: #495057;
|
border-bottom: 2px solid var(--kaauh-teal);
|
||||||
border-bottom: 1px solid #dee2e6;
|
font-size: 0.9rem;
|
||||||
|
vertical-align: middle;
|
||||||
}
|
}
|
||||||
.candidate-table td {
|
.candidate-table td {
|
||||||
padding: 0.75rem;
|
padding: 0.75rem;
|
||||||
border-bottom: 1px solid #f1f3f4;
|
border-bottom: 1px solid var(--kaauh-border);
|
||||||
vertical-align: middle;
|
vertical-align: middle;
|
||||||
|
font-size: 0.9rem;
|
||||||
}
|
}
|
||||||
.candidate-table tbody tr:hover {
|
.candidate-table tbody tr:hover {
|
||||||
background-color: #f8f9fa;
|
background-color: #f1f3f4;
|
||||||
}
|
|
||||||
.candidate-table tbody tr:last-child td {
|
|
||||||
border-bottom: none;
|
|
||||||
}
|
}
|
||||||
.candidate-name {
|
.candidate-name {
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
font-size: 0.95rem;
|
color: var(--kaauh-primary-text);
|
||||||
}
|
}
|
||||||
.candidate-details {
|
.candidate-details {
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
@ -151,211 +151,262 @@
|
|||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
margin-bottom: 1rem;
|
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;
|
padding: 0.125rem 0.5rem;
|
||||||
border-radius: 0.5rem;
|
border-radius: 0.5rem;
|
||||||
font-size: 0.7rem;
|
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
margin-left: 0.375rem;
|
margin-left: 0.5rem;
|
||||||
|
display: inline-block;
|
||||||
}
|
}
|
||||||
.stage-Applied {
|
.tier-1-badge { background-color: var(--kaauh-success); color: white; }
|
||||||
background-color: #e9ecef;
|
.tier-2-badge { background-color: var(--kaauh-warning); color: #856404; }
|
||||||
color: #495057;
|
.tier-3-badge { background-color: #d1ecf1; color: #0c5460; }
|
||||||
}
|
|
||||||
.stage-Exam {
|
/* Fix table column widths for better layout */
|
||||||
background-color: #cce5ff;
|
.candidate-table th:nth-child(1) { width: 40px; } /* Checkbox */
|
||||||
color: #004085;
|
.candidate-table th:nth-child(4) { width: 10%; } /* AI Score */
|
||||||
}
|
.candidate-table th:nth-child(5) { width: 12%; } /* Exam Status */
|
||||||
.stage-Interview {
|
.candidate-table th:nth-child(6) { width: 15%; } /* Exam Date */
|
||||||
background-color: #d1ecf1;
|
.candidate-table th:nth-child(7) { width: 220px; } /* Actions */
|
||||||
color: #0c5460;
|
|
||||||
}
|
.cd_exam{
|
||||||
.stage-Offer {
|
color: #00636e;
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% 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 class="d-flex justify-content-between align-items-center mb-4">
|
||||||
<div>
|
<div>
|
||||||
<h1 class="h3 mb-1">
|
<h1 class="h3 mb-1" style="color: var(--kaauh-teal-dark); font-weight: 700;">
|
||||||
<i class="fas fa-layer-group me-2"></i>
|
<i class="fas fa-edit me-2"></i>
|
||||||
{% trans "Exam" %} - {{ job.title }}
|
{% trans "Exam Management" %} - {{ job.title }}
|
||||||
</h1>
|
</h1>
|
||||||
<p class="text-muted mb-0">
|
<h2 class="h5 text-muted mb-0">
|
||||||
Total Candidates: {{ total_candidates }}
|
{% trans "Candidates in Exam Stage:" %} <span class="fw-bold">{{ total_candidates }}</span>
|
||||||
</p>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
<a href="{% url 'job_detail' job.slug %}" class="btn btn-outline-secondary">
|
<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" %}
|
<i class="fas fa-arrow-left me-1"></i> {% trans "Back to Job" %}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Tier Controls -->
|
{# APPLICANT TRACKING TIMELINE INCLUSION #}
|
||||||
<div class="tier-controls">
|
<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">
|
<form method="post" class="mb-0">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
<div class="form-row">
|
<div class="row g-3 align-items-end">
|
||||||
<div class="form-group">
|
<div class="col-md-3 col-sm-6">
|
||||||
<label for="tier1_count">{% trans "Number of candidates in Tier 1 (Top N)" %}</label>
|
<label for="tier1_count" class="form-label small text-muted mb-1">
|
||||||
<input type="number" name="tier1_count" id="tier1_count" class="form-control"
|
{% trans "Number of Tier 1 Candidates (Top N)" %}
|
||||||
value="{{ tier1_count }}" min="1" max="{{ total_candidates }}">
|
</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>
|
||||||
<div class="form-group">
|
<div class="col-md-3 col-sm-6">
|
||||||
<button type="submit" name="update_tiers" class="btn btn-primary">
|
<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" %}
|
<i class="fas fa-sync-alt me-1"></i> {% trans "Update Tiers" %}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="col-md-6 d-none d-md-block"></div>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form> {% endcomment %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Tier Display -->
|
<h2 class="h4 mb-3" style="color: var(--kaauh-primary-text);">
|
||||||
<h2 class="h4 mb-3 mt-5">{% trans "Candidate Tiers" %}</h2>
|
{% 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)}">
|
<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 %}
|
{% url "bulk_update_candidate_exam_status" job.slug as bulk_update_candidate_exam_status_url %}
|
||||||
{% if candidates %}
|
|
||||||
<button class="btn btn-primary"
|
<div class="mb-3 d-flex gap-2">
|
||||||
data-attr="{disabled: !$selections.filter(Boolean).length}"
|
{% if candidates %}
|
||||||
data-on-click="@post('{{bulk_update_candidate_exam_status_url}}',{
|
<button class="btn btn-bulk-pass btn-sm"
|
||||||
contentType: 'form',
|
data-attr="{disabled: !$selections.filter(Boolean).length}"
|
||||||
selector: '#myform',
|
data-on-click="@post('{{bulk_update_candidate_exam_status_url}}',{
|
||||||
headers: {'X-CSRFToken': '{{ csrf_token }}','status': 'pass'}
|
contentType: 'form',
|
||||||
})"
|
selector: '#candidate-form',
|
||||||
>Mark as Pass and move to Interview</button>
|
headers: {'X-CSRFToken': '{{ csrf_token }}','status': 'Passed'}
|
||||||
<button class="btn btn-danger"
|
})"
|
||||||
data-attr="{disabled: !$selections.filter(Boolean).length}"
|
>
|
||||||
data-on-click="@post('{{bulk_update_candidate_exam_status_url}}',{
|
<i class="fas fa-check-circle me-1"></i>
|
||||||
contentType: 'form',
|
{% trans "Bulk Mark Passed (-> Interview)" %}
|
||||||
selector: '#myform',
|
</button>
|
||||||
headers: {'X-CSRFToken': '{{ csrf_token }}','status': 'fail'}
|
<button class="btn btn-bulk-fail btn-sm"
|
||||||
})"
|
data-attr="{disabled: !$selections.filter(Boolean).length}"
|
||||||
>Mark as Failed</button>
|
data-on-click="@post('{{bulk_update_candidate_exam_status_url}}',{
|
||||||
{% endif %}
|
contentType: 'form',
|
||||||
<form id="myform" action="{{move_to_exam_url}}" method="post">
|
selector: '#candidate-form',
|
||||||
<table class="candidate-table">
|
headers: {'X-CSRFToken': '{{ csrf_token }}','status': 'Failed'}
|
||||||
<thead>
|
})"
|
||||||
<tr>
|
>
|
||||||
<th>
|
<i class="fas fa-times-circle me-1"></i>
|
||||||
{% if candidates %}
|
{% trans "Bulk Mark Failed" %}
|
||||||
<div class="form-check">
|
</button>
|
||||||
<input
|
{% endif %}
|
||||||
data-bind-_all
|
</div>
|
||||||
data-on-change="$selections = Array({{ candidates|length }}).fill($_all)"
|
|
||||||
data-effect="$selections; $_all = $selections.every(Boolean)"
|
<form id="candidate-form" method="post">
|
||||||
data-attr-disabled="$_fetching"
|
{% csrf_token %}
|
||||||
type="checkbox" class="form-check-input" id="candidate-{{ candidate.id }}">
|
<table class="table candidate-table align-middle">
|
||||||
</div>
|
<thead>
|
||||||
{% endif %}
|
<tr>
|
||||||
</th>
|
<th>
|
||||||
<th>{% trans "Name" %}</th>
|
{% if candidates %}
|
||||||
<th>{% trans "Contact" %}</th>
|
<div class="form-check">
|
||||||
<th>{% trans "AI Score" %}</th>
|
<input
|
||||||
<th>{% trans "Exam Status" %}</th>
|
data-bind-_all
|
||||||
<th>{% trans "Exam Date" %}</th>
|
data-on-change="$selections = Array({{ candidates|length }}).fill($_all)"
|
||||||
<th>{% trans "Actions" %}</th>
|
data-effect="$selections; $_all = $selections.every(Boolean)"
|
||||||
</tr>
|
data-attr-disabled="$_fetching"
|
||||||
</thead>
|
type="checkbox" class="form-check-input" id="checkAll">
|
||||||
<tbody>
|
</div>
|
||||||
{% for candidate in candidates %}
|
{% 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>
|
<tr>
|
||||||
<td>
|
<td>
|
||||||
<div class="form-check">
|
<div class="form-check">
|
||||||
<input
|
<input
|
||||||
data-bind-selections
|
data-bind-selections
|
||||||
data-attr-disabled="$_fetching"
|
data-attr-disabled="$_fetching"
|
||||||
name="{{ candidate.id }}"
|
name="candidate_ids"
|
||||||
|
value="{{ candidate.id }}"
|
||||||
type="checkbox" class="form-check-input" id="candidate-{{ candidate.id }}">
|
type="checkbox" class="form-check-input" id="candidate-{{ candidate.id }}">
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<div class="candidate-name">{{ candidate.name }}</div>
|
<div class="candidate-name">
|
||||||
</td>
|
{{ candidate.name }}
|
||||||
<td>
|
{# Tier logic updated to be cleaner #}
|
||||||
<div class="candidate-details">
|
{% if forloop.counter <= tier1_count %}
|
||||||
Email: {{ candidate.email }}<br>
|
<span class="stage-badge tier-1-badge">Tier 1</span>
|
||||||
Phone: {{ candidate.phone }}<br>
|
{% 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>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<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>
|
<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" %}
|
{% 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" %}
|
{% 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 %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
<td>{{candidate.exam_date|date:"M d, Y h:i A"}}</td>
|
|
||||||
<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-toggle="modal"
|
||||||
data-bs-target="#candidateviewModal"
|
data-bs-target="#candidateviewModal"
|
||||||
hx-get="{% url 'candidate_criteria_view_htmx' candidate.pk %}"
|
hx-get="{% url 'candidate_criteria_view_htmx' candidate.pk %}"
|
||||||
hx-target="#candidateviewModalBody"
|
hx-target="#candidateviewModalBody"
|
||||||
>
|
title="View Profile">
|
||||||
{% include "icons/view.html" %}
|
<i class="fas fa-eye"></i> View
|
||||||
{% trans "View" %}</button>
|
</button>
|
||||||
<button class="btn btn-primary"
|
<button type="button" class="btn btn-main-action btn-sm"
|
||||||
data-bs-toggle="modal"
|
data-bs-toggle="modal"
|
||||||
data-bs-target="#candidateviewModal"
|
data-bs-target="#candidateviewModal"
|
||||||
hx-get="{% url 'update_candidate_exam_status' candidate.slug %}"
|
hx-get="{% url 'update_candidate_exam_status' candidate.slug %}"
|
||||||
hx-target="#candidateviewModalBody"
|
hx-target="#candidateviewModalBody"
|
||||||
>
|
title="Set Exam Status/Date">
|
||||||
{% include "icons/view.html" %}
|
<i class="fas fa-calendar-alt"></i> Set Exam
|
||||||
{% trans "Set Exam Date" %}</button>
|
</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 %}
|
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</form>
|
{% if not candidates %}
|
||||||
</div>
|
<div class="alert alert-info text-center mt-3 mb-0" role="alert">
|
||||||
<!-- Tab Content -->
|
<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 fade modal-lg" id="candidateviewModal" tabindex="-1" aria-labelledby="candidateviewModalLabel" aria-hidden="true">
|
||||||
<div class="modal-dialog">
|
<div class="modal-dialog">
|
||||||
<div class="modal-content">
|
<div class="modal-content kaauh-card">
|
||||||
<div class="modal-header">
|
<div class="modal-header" style="border-bottom: 1px solid var(--kaauh-border);">
|
||||||
<h5 class="modal-title" id="candidateviewModalLabel">Form Settings</h5>
|
<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>
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||||
</div>
|
</div>
|
||||||
<div id="candidateviewModalBody" class="modal-body">
|
<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>
|
||||||
<div class="modal-footer">
|
<div class="modal-footer" style="border-top: 1px solid var(--kaauh-border);">
|
||||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">
|
||||||
|
{% trans "Close" %}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
318
templates/recruitment/candidate_interview_view.html
Normal file
318
templates/recruitment/candidate_interview_view.html
Normal file
@ -0,0 +1,318 @@
|
|||||||
|
{% extends 'base.html' %}
|
||||||
|
{% load static i18n %}
|
||||||
|
|
||||||
|
{% block title %}Candidate Tier Management - {{ job.title }} - ATS{% endblock %}
|
||||||
|
|
||||||
|
{% block customCSS %}
|
||||||
|
<style>
|
||||||
|
/* Minimal Tier Management Styles */
|
||||||
|
.tier-controls {
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
.tier-controls .form-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: end;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
.stage-groups {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
||||||
|
gap: 1rem;
|
||||||
|
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 */
|
||||||
|
.candidate-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: separate;
|
||||||
|
border-spacing: 0;
|
||||||
|
background-color: white;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
|
||||||
|
}
|
||||||
|
.candidate-table thead {
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
}
|
||||||
|
.candidate-table th {
|
||||||
|
padding: 0.75rem;
|
||||||
|
text-align: left;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: #495057;
|
||||||
|
border-bottom: 1px solid #dee2e6;
|
||||||
|
}
|
||||||
|
.candidate-table td {
|
||||||
|
padding: 0.75rem;
|
||||||
|
border-bottom: 1px solid #f1f3f4;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
.candidate-table tbody tr:hover {
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
}
|
||||||
|
.candidate-table tbody tr:last-child td {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
.candidate-name {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
.candidate-details {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: #6c757d;
|
||||||
|
}
|
||||||
|
.candidate-table-responsive {
|
||||||
|
overflow-x: auto;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
.stage-badge {
|
||||||
|
padding: 0.125rem 0.5rem;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-left: 0.375rem;
|
||||||
|
}
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="container 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 "Interview" %} - {{ job.title }}
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
</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 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 "candidate_interview_view" 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 "Interview Date" %}</th>
|
||||||
|
<th>{% trans "Interview Link" %}</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 }}"
|
||||||
|
|
||||||
|
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>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td>{{candidate.get_latest_meeting.start_time|date:"m-d-Y h:i A"}}</td>
|
||||||
|
<td><a href="{{candidate.get_latest_meeting.join_url}}">{% include "icons/link.html" %}</a></td>
|
||||||
|
<td>
|
||||||
|
<button class="btn btn-primary 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>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<!-- Tab Content -->
|
||||||
|
|
||||||
|
<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>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
<div id="candidateviewModalBody" class="modal-body">
|
||||||
|
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
377
templates/recruitment/candidate_screening_view.html
Normal file
377
templates/recruitment/candidate_screening_view.html
Normal file
@ -0,0 +1,377 @@
|
|||||||
|
{% extends 'base.html' %}
|
||||||
|
{% load static i18n %}
|
||||||
|
|
||||||
|
{% block title %}Candidate Management - {{ job.title }} - University ATS{% endblock %}
|
||||||
|
|
||||||
|
{% block customCSS %}
|
||||||
|
<style>
|
||||||
|
/* 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;
|
||||||
|
--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 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 (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;
|
||||||
|
}
|
||||||
|
.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 the Bulk Move button */
|
||||||
|
.btn-bulk-action {
|
||||||
|
background-color: var(--kaauh-teal-dark);
|
||||||
|
border-color: var(--kaauh-teal-dark);
|
||||||
|
color: white;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.btn-bulk-action:hover {
|
||||||
|
background-color: #00363e;
|
||||||
|
border-color: #00363e;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 3. Candidate Table Styling (Aligned with KAAT-S) */
|
||||||
|
.candidate-table {
|
||||||
|
table-layout: fixed;
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: separate;
|
||||||
|
border-spacing: 0;
|
||||||
|
background-color: white;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.candidate-table thead {
|
||||||
|
background-color: var(--kaauh-border);
|
||||||
|
}
|
||||||
|
.candidate-table th {
|
||||||
|
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 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);
|
||||||
|
}
|
||||||
|
.candidate-details {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: #6c757d;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 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.75rem;
|
||||||
|
padding: 0.3em 0.7em;
|
||||||
|
border-radius: 0.35rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
.bg-applicant { background-color: #6c757d !important; color: white; }
|
||||||
|
.bg-candidate { background-color: var(--kaauh-success) !important; color: white; }
|
||||||
|
|
||||||
|
/* 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-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-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" %}
|
||||||
|
</h1>
|
||||||
|
<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="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-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 form-control-sm"
|
||||||
|
value="{{ min_ai_score}}" min="0" max="100" step="1"
|
||||||
|
placeholder="e.g., 75">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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 form-control-sm"
|
||||||
|
value="{{ tier1_count }}" min="1" max="{{ total_candidates }}">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<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-_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 "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 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" 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>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@ -1,305 +0,0 @@
|
|||||||
{% extends 'base.html' %}
|
|
||||||
{% load static i18n %}
|
|
||||||
|
|
||||||
{% block title %}Candidate Management - {{ job.title }} - University ATS{% endblock %}
|
|
||||||
|
|
||||||
{% block customCSS %}
|
|
||||||
<style>
|
|
||||||
/* KAAT-S UI Variables */
|
|
||||||
:root {
|
|
||||||
--kaauh-teal: #00636e;
|
|
||||||
--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 */
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 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;
|
|
||||||
}
|
|
||||||
.tier-controls {
|
|
||||||
background-color: var(--kaauh-border); /* Light background for control sections */
|
|
||||||
border-radius: 0.5rem;
|
|
||||||
padding: 1.25rem;
|
|
||||||
margin-bottom: 1.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 2. Button Styling (from reference) */
|
|
||||||
.btn-main-action {
|
|
||||||
background-color: var(--kaauh-teal);
|
|
||||||
border-color: var(--kaauh-teal);
|
|
||||||
color: white;
|
|
||||||
font-weight: 600;
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
}
|
|
||||||
.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);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 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);
|
|
||||||
color: white;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 4. Candidate Table Styling (Aligned with KAAT-S) */
|
|
||||||
.candidate-table {
|
|
||||||
border-collapse: separate;
|
|
||||||
border-spacing: 0;
|
|
||||||
background-color: white;
|
|
||||||
border-radius: 0.5rem;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
.candidate-table thead {
|
|
||||||
background-color: var(--kaauh-border);
|
|
||||||
}
|
|
||||||
.candidate-table th {
|
|
||||||
padding: 0.75rem;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--kaauh-teal-dark);
|
|
||||||
border-bottom: 2px solid var(--kaauh-teal);
|
|
||||||
}
|
|
||||||
.candidate-table td {
|
|
||||||
padding: 0.75rem;
|
|
||||||
border-bottom: 1px solid var(--kaauh-border);
|
|
||||||
vertical-align: middle;
|
|
||||||
}
|
|
||||||
.candidate-table tbody tr:hover {
|
|
||||||
background-color: #f1f3f4;
|
|
||||||
}
|
|
||||||
.candidate-name {
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--kaauh-primary-text);
|
|
||||||
}
|
|
||||||
.candidate-details {
|
|
||||||
font-size: 0.8rem;
|
|
||||||
color: #6c757d;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 5. Badges (Status/Score) */
|
|
||||||
.status-badge {
|
|
||||||
font-size: 0.8rem;
|
|
||||||
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; }
|
|
||||||
|
|
||||||
/* 6. Stage Badges (More distinct from KAAT-S reference) */
|
|
||||||
.stage-badge {
|
|
||||||
font-size: 0.75rem;
|
|
||||||
padding: 0.25rem 0.6rem;
|
|
||||||
border-radius: 0.3rem;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
.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;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
{% block content %}
|
|
||||||
<div class="container py-4">
|
|
||||||
<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 }} <small class="text-muted fs-6">{{job.internal_job_id}}<small>
|
|
||||||
</h1>
|
|
||||||
<p class="text-muted mb-0">
|
|
||||||
Total Applicants: <span class="fw-bold">{{ total_candidates }}</span>
|
|
||||||
</p>
|
|
||||||
</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">
|
|
||||||
{% 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" %}
|
|
||||||
</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"
|
|
||||||
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)" %}
|
|
||||||
</label>
|
|
||||||
<input type="number" name="tier1_count" id="tier1_count" class="form-control"
|
|
||||||
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" %}
|
|
||||||
</button>
|
|
||||||
</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>
|
|
||||||
<div class="form-check">
|
|
||||||
<input
|
|
||||||
data-bind-selections
|
|
||||||
data-attr-disabled="$_fetching"
|
|
||||||
name="{{ 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>
|
|
||||||
</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 -->
|
|
||||||
|
|
||||||
<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>
|
|
||||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
|
||||||
</div>
|
|
||||||
<div id="candidateviewModalBody" class="modal-body">
|
|
||||||
|
|
||||||
</div>
|
|
||||||
<div class="modal-footer">
|
|
||||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endblock %}
|
|
||||||
251
templates/recruitment/interview_calendar.html
Normal file
251
templates/recruitment/interview_calendar.html
Normal file
@ -0,0 +1,251 @@
|
|||||||
|
<!-- templates/recruitment/interview_calendar.html -->
|
||||||
|
{% extends "base.html" %}
|
||||||
|
{% load static %}
|
||||||
|
|
||||||
|
{% block customCSS %}
|
||||||
|
<link href="https://cdn.jsdelivr.net/npm/fullcalendar@5.10.1/main.min.css" rel="stylesheet">
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--calendar-color: #00636e;
|
||||||
|
--calendar-light: rgba(0, 99, 110, 0.1);
|
||||||
|
--calendar-hover: rgba(0, 99, 110, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-header {
|
||||||
|
background-color: var(--calendar-color);
|
||||||
|
color: white;
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-container {
|
||||||
|
background-color: white;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
box-shadow: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.075);
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fc-toolbar-title {
|
||||||
|
color: var(--calendar-color) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fc-button-primary {
|
||||||
|
background-color: var(--calendar-color) !important;
|
||||||
|
border-color: var(--calendar-color) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fc-button-primary:hover {
|
||||||
|
background-color: #004d56 !important;
|
||||||
|
border-color: #004d56 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fc-button-primary:not(:disabled):active, .fc-button-primary:not(:disabled).fc-button-active {
|
||||||
|
background-color: #003a40 !important;
|
||||||
|
border-color: #003a40 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fc-daygrid-day.fc-day-today {
|
||||||
|
background-color: var(--calendar-light) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fc-event-title {
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
border-radius: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-scheduled {
|
||||||
|
background-color: #e3f2fd;
|
||||||
|
color: #0d47a1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-confirmed {
|
||||||
|
background-color: #e8f5e9;
|
||||||
|
color: #1b5e20;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-cancelled {
|
||||||
|
background-color: #ffebee;
|
||||||
|
color: #b71c1c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-completed {
|
||||||
|
background-color: #f5f5f5;
|
||||||
|
color: #424242;
|
||||||
|
}
|
||||||
|
|
||||||
|
.interview-details {
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.interview-details .card {
|
||||||
|
border-left: 4px solid var(--calendar-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-legend {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-top: 1rem;
|
||||||
|
padding: 0.75rem;
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legend-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legend-color {
|
||||||
|
width: 1rem;
|
||||||
|
height: 1rem;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="container mt-4">
|
||||||
|
<div class="calendar-header">
|
||||||
|
<div class="d-flex justify-content-between align-items-center">
|
||||||
|
<h1 class="h3 mb-0">Interview Calendar</h1>
|
||||||
|
<div>
|
||||||
|
<span class="h5">{{ job.title }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="calendar-container">
|
||||||
|
<div id="calendar"></div>
|
||||||
|
|
||||||
|
<div class="calendar-legend">
|
||||||
|
<div class="legend-item">
|
||||||
|
<div class="legend-color" style="background-color: #00636e;"></div>
|
||||||
|
<span>Scheduled</span>
|
||||||
|
</div>
|
||||||
|
<div class="legend-item">
|
||||||
|
<div class="legend-color" style="background-color: #00a86b;"></div>
|
||||||
|
<span>Confirmed</span>
|
||||||
|
</div>
|
||||||
|
<div class="legend-item">
|
||||||
|
<div class="legend-color" style="background-color: #e74c3c;"></div>
|
||||||
|
<span>Cancelled</span>
|
||||||
|
</div>
|
||||||
|
<div class="legend-item">
|
||||||
|
<div class="legend-color" style="background-color: #95a5a6;"></div>
|
||||||
|
<span>Completed</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="interview-details" id="interview-details" style="display: none;">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header d-flex justify-content-between align-items-center">
|
||||||
|
<h5 class="mb-0">Interview Details</h5>
|
||||||
|
<button type="button" class="btn-close" id="close-details"></button>
|
||||||
|
</div>
|
||||||
|
<div class="card-body" id="interview-info">
|
||||||
|
<!-- Interview details will be loaded here -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Include FullCalendar JS -->
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/fullcalendar@5.10.1/main.min.js"></script>
|
||||||
|
<script>
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
var calendarEl = document.getElementById('calendar');
|
||||||
|
var events = {{ events|safe }};
|
||||||
|
|
||||||
|
var calendar = new FullCalendar.Calendar(calendarEl, {
|
||||||
|
initialView: 'dayGridMonth',
|
||||||
|
headerToolbar: {
|
||||||
|
left: 'prev,next today',
|
||||||
|
center: 'title',
|
||||||
|
right: 'dayGridMonth,timeGridWeek,timeGridDay'
|
||||||
|
},
|
||||||
|
events: events,
|
||||||
|
eventClick: function(info) {
|
||||||
|
// Prevent default browser behavior
|
||||||
|
info.jsEvent.preventDefault();
|
||||||
|
|
||||||
|
// Show interview details
|
||||||
|
showInterviewDetails(info.event);
|
||||||
|
},
|
||||||
|
eventMouseEnter: function(info) {
|
||||||
|
// Change cursor to pointer on hover
|
||||||
|
document.body.style.cursor = 'pointer';
|
||||||
|
},
|
||||||
|
eventMouseLeave: function() {
|
||||||
|
// Reset cursor
|
||||||
|
document.body.style.cursor = 'default';
|
||||||
|
},
|
||||||
|
height: 'auto',
|
||||||
|
aspectRatio: 2,
|
||||||
|
eventDisplay: 'block',
|
||||||
|
displayEventTime: true,
|
||||||
|
displayEventEnd: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
calendar.render();
|
||||||
|
|
||||||
|
// Function to show interview details
|
||||||
|
function showInterviewDetails(event) {
|
||||||
|
const detailsContainer = document.getElementById('interview-details');
|
||||||
|
const infoContainer = document.getElementById('interview-info');
|
||||||
|
|
||||||
|
const statusClass = `status-${event.extendedProps.status}`;
|
||||||
|
const statusText = event.extendedProps.status.charAt(0).toUpperCase() + event.extendedProps.status.slice(1);
|
||||||
|
|
||||||
|
let meetingInfo = '';
|
||||||
|
if (event.extendedProps.meeting_id) {
|
||||||
|
meetingInfo = `
|
||||||
|
<div class="mb-3">
|
||||||
|
<h6>Meeting Information</h6>
|
||||||
|
<p><strong>Meeting ID:</strong> ${event.extendedProps.meeting_id}</p>
|
||||||
|
<p><strong>Join URL:</strong> <a href="${event.extendedProps.join_url}" target="_blank">${event.extendedProps.join_url}</a></p>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
infoContainer.innerHTML = `
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<h6>Candidate Information</h6>
|
||||||
|
<p><strong>Name:</strong> ${event.extendedProps.candidate}</p>
|
||||||
|
<p><strong>Email:</strong> ${event.extendedProps.email}</p>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<h6>Interview Details</h6>
|
||||||
|
<p><strong>Date:</strong> ${event.start.toLocaleDateString()}</p>
|
||||||
|
<p><strong>Time:</strong> ${event.start.toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'})} - ${event.end.toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'})}</p>
|
||||||
|
<p><strong>Status:</strong> <span class="status-badge ${statusClass}">${statusText}</span></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
${meetingInfo}
|
||||||
|
<div class="mt-3">
|
||||||
|
<a href="${event.url}" class="btn btn-primary btn-sm">View Full Details</a>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
detailsContainer.style.display = 'block';
|
||||||
|
|
||||||
|
// Scroll to details
|
||||||
|
detailsContainer.scrollIntoView({ behavior: 'smooth' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close details button
|
||||||
|
document.getElementById('close-details').addEventListener('click', function() {
|
||||||
|
document.getElementById('interview-details').style.display = 'none';
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
149
templates/recruitment/interview_detail.html
Normal file
149
templates/recruitment/interview_detail.html
Normal file
@ -0,0 +1,149 @@
|
|||||||
|
<!-- templates/recruitment/interview_detail.html -->
|
||||||
|
{% extends "base.html" %}
|
||||||
|
{% load static %}
|
||||||
|
|
||||||
|
{% block extra_css %}
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--calendar-color: #00636e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-header {
|
||||||
|
background-color: var(--calendar-color);
|
||||||
|
color: white;
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-card {
|
||||||
|
border-left: 4px solid var(--calendar-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
padding: 0.25rem 0.75rem;
|
||||||
|
border-radius: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-scheduled {
|
||||||
|
background-color: #e3f2fd;
|
||||||
|
color: #0d47a1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-confirmed {
|
||||||
|
background-color: #e8f5e9;
|
||||||
|
color: #1b5e20;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-cancelled {
|
||||||
|
background-color: #ffebee;
|
||||||
|
color: #b71c1c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-completed {
|
||||||
|
background-color: #f5f5f5;
|
||||||
|
color: #424242;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="container mt-4">
|
||||||
|
<div class="detail-header">
|
||||||
|
<div class="d-flex justify-content-between align-items-center">
|
||||||
|
<h1 class="h3 mb-0">Interview Details</h1>
|
||||||
|
<div>
|
||||||
|
<span class="h5">{{ job.title }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card detail-card mb-4">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<h5>Candidate Information</h5>
|
||||||
|
<table class="table table-borderless">
|
||||||
|
<tr>
|
||||||
|
<td><strong>Name:</strong></td>
|
||||||
|
<td>{{ interview.candidate.name }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><strong>Email:</strong></td>
|
||||||
|
<td>{{ interview.candidate.email }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><strong>Phone:</strong></td>
|
||||||
|
<td>{{ interview.candidate.phone|default:"Not provided" }}</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<h5>Interview Details</h5>
|
||||||
|
<table class="table table-borderless">
|
||||||
|
<tr>
|
||||||
|
<td><strong>Date:</strong></td>
|
||||||
|
<td>{{ interview.interview_date|date:"l, F j, Y" }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><strong>Time:</strong></td>
|
||||||
|
<td>{{ interview.interview_time|time:"g:i A" }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><strong>Status:</strong></td>
|
||||||
|
<td>
|
||||||
|
<span class="status-badge status-{{ interview.status }}">
|
||||||
|
{{ interview.status|title }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if interview.zoom_meeting %}
|
||||||
|
<div class="mt-4">
|
||||||
|
<h5>Meeting Information</h5>
|
||||||
|
<table class="table table-borderless">
|
||||||
|
<tr>
|
||||||
|
<td><strong>Meeting ID:</strong></td>
|
||||||
|
<td>{{ interview.zoom_meeting.meeting_id }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><strong>Topic:</strong></td>
|
||||||
|
<td>{{ interview.zoom_meeting.topic }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><strong>Duration:</strong></td>
|
||||||
|
<td>{{ interview.zoom_meeting.duration }} minutes</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><strong>Join URL:</strong></td>
|
||||||
|
<td><a href="{{ interview.zoom_meeting.join_url }}" target="_blank">{{ interview.zoom_meeting.join_url }}</a></td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="mt-4">
|
||||||
|
<div class="d-flex gap-2">
|
||||||
|
<a href="{% url 'interview_calendar' slug=job.slug %}" class="btn btn-secondary">
|
||||||
|
<i class="fas fa-calendar"></i> Back to Calendar
|
||||||
|
</a>
|
||||||
|
{% if interview.status == 'scheduled' %}
|
||||||
|
<button class="btn btn-success">
|
||||||
|
<i class="fas fa-check"></i> Confirm Interview
|
||||||
|
</button>
|
||||||
|
{% endif %}
|
||||||
|
{% if interview.status != 'cancelled' and interview.status != 'completed' %}
|
||||||
|
<button class="btn btn-danger">
|
||||||
|
<i class="fas fa-times"></i> Cancel Interview
|
||||||
|
</button>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@ -24,7 +24,7 @@
|
|||||||
|
|
||||||
/* Main Action Button Style */
|
/* Main Action Button Style */
|
||||||
.btn-main-action{
|
.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);
|
border-color: var(--kaauh-teal);
|
||||||
color: white;
|
color: white;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user